diff --git a/README.md b/README.md index 4b39c9632..deb35eb39 100644 --- a/README.md +++ b/README.md @@ -1644,6 +1644,48 @@ latest_tenant_token = descope_client.mgmt.outbound_application.fetch_tenant_toke "tenant-id", {"forceRefresh": True} # Optional ) + +# List the IDs of the outbound apps a user currently holds a valid token for. +# Use this for connection-status views instead of calling fetch_token once per app. +connected = descope_client.mgmt.outbound_application.list_apps_with_user_token( + "user-id", + tenant_id="tenant-id", # Optional +) +# connected => {"appIds": ["app-1", "app-2"]} + +# Store a static API key for a user / tenant on an apikey-type outbound app +descope_client.mgmt.outbound_application.upload_user_api_key( + "my-app-id", + "user-id", + "the-users-api-key", + tenant_id="tenant-id", # Optional +) +descope_client.mgmt.outbound_application.upload_tenant_api_key( + "my-app-id", + "tenant-id", + "the-tenants-api-key", +) + +# Upload (migrate) an existing OAuth token for a user / tenant on an oauth-type outbound app, +# without requiring the user to re-run the OAuth flow. +descope_client.mgmt.outbound_application.upload_user_token( + "my-app-id", + "user-id", + refresh_token="the-refresh-token", + scopes=["read", "write"], +) +descope_client.mgmt.outbound_application.upload_tenant_token( + "my-app-id", + "tenant-id", + access_token="the-access-token", +) + +# Batch upload OAuth tokens (all-or-nothing): inspect "failures" to see rejected items +batch_resp = descope_client.mgmt.outbound_application.batch_upload_user_tokens([ + {"appId": "my-app-id", "userId": "user-1", "accessToken": "token-1"}, + {"appId": "my-app-id", "userId": "user-2", "accessToken": "token-2"}, +]) +# batch_resp => {"failures": [{"appId": ..., "userId": ..., "errorCode": ..., "reason": ...}, ...]} ``` Fetch outbound application tokens using an inbound application token that includes the "outbound.token.fetch" scope (no management key required) diff --git a/descope/management/_outbound_application_base.py b/descope/management/_outbound_application_base.py index bff972cf9..4dc5ed798 100644 --- a/descope/management/_outbound_application_base.py +++ b/descope/management/_outbound_application_base.py @@ -67,3 +67,37 @@ def _compose_create_update_body( if prompt is not None: body["prompt"] = [p.value for p in prompt] return body + + @staticmethod + def _compose_oauth_upload_body( + base: dict, + refresh_token: Optional[str] = None, + access_token: Optional[str] = None, + access_token_expiry: Optional[int] = None, + access_token_type: Optional[str] = None, + scopes: Optional[List[str]] = None, + external_identifier: Optional[str] = None, + id_token: Optional[str] = None, + granted_by: Optional[str] = None, + verify_refresh: Optional[bool] = None, + ) -> dict: + body = dict(base) + if refresh_token is not None: + body["refreshToken"] = refresh_token + if access_token is not None: + body["accessToken"] = access_token + if access_token_expiry is not None: + body["accessTokenExpiry"] = access_token_expiry + if access_token_type is not None: + body["accessTokenType"] = access_token_type + if scopes is not None: + body["scopes"] = scopes + if external_identifier is not None: + body["externalIdentifier"] = external_identifier + if id_token is not None: + body["idToken"] = id_token + if granted_by is not None: + body["grantedBy"] = granted_by + if verify_refresh is not None: + body["verifyRefresh"] = verify_refresh + return body diff --git a/descope/management/common.py b/descope/management/common.py index 266c5d317..574d25c81 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -120,6 +120,13 @@ class MgmtV1: outbound_application_fetch_tenant_token_path = "/v1/mgmt/outbound/app/tenant/token/latest" outbound_application_delete_user_tokens_path = "/v1/mgmt/outbound/user/tokens" outbound_application_delete_token_path = "/v1/mgmt/outbound/token" + outbound_application_list_apps_with_user_token_path = "/v1/mgmt/outbound/apps-with-user-token" + outbound_application_upload_user_api_key_path = "/v1/mgmt/outbound/app/user/apikey/upload" + outbound_application_upload_tenant_api_key_path = "/v1/mgmt/outbound/app/tenant/apikey/upload" + outbound_application_upload_user_token_path = "/v1/mgmt/outbound/app/user/oauthtoken/upload" + outbound_application_upload_tenant_token_path = "/v1/mgmt/outbound/app/tenant/oauthtoken/upload" + outbound_application_batch_upload_user_tokens_path = "/v1/mgmt/outbound/app/user/oauthtoken/batch/upload" + outbound_application_batch_upload_tenant_tokens_path = "/v1/mgmt/outbound/app/tenant/oauthtoken/batch/upload" # user user_create_path = "/v1/mgmt/user/create" diff --git a/descope/management/outbound_application.py b/descope/management/outbound_application.py index 41729d562..3c87b5342 100644 --- a/descope/management/outbound_application.py +++ b/descope/management/outbound_application.py @@ -489,6 +489,236 @@ def delete_token( uri = MgmtV1.outbound_application_delete_token_path self._http.delete(uri, params={"id": token_id}) + def list_apps_with_user_token( + self, + user_id: str, + tenant_id: Optional[str] = None, + ) -> dict: + """ + List the IDs of the outbound applications the given user currently holds a valid token for. + Use this for connection-status views instead of calling fetch_token once per application. + + Args: + user_id (str): The ID of the user. + tenant_id (str): Optional tenant ID to scope the lookup to. + + Return value (dict): + Return dict in the format + {"appIds": [, ...]} + + Raise: + AuthException: raised if the operation fails + """ + params = {"userId": user_id} + if tenant_id: + params["tenantId"] = tenant_id + response = self._http.get( + MgmtV1.outbound_application_list_apps_with_user_token_path, + params=params, + ) + return response.json() + + def upload_user_api_key( + self, + app_id: str, + user_id: str, + api_key: str, + tenant_id: Optional[str] = None, + ) -> dict: + """ + Upload (set) a static API key for a user on an apikey-type outbound application. + + Args: + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + api_key (str): The API key to store for the user. + tenant_id (str): Optional tenant ID, used to associate different keys for the + same user in different tenants. + + Raise: + AuthException: raised if the operation fails + """ + body: dict = {"appId": app_id, "userId": user_id, "apiKey": api_key} + if tenant_id is not None: + body["tenantId"] = tenant_id + response = self._http.post( + MgmtV1.outbound_application_upload_user_api_key_path, + body=body, + ) + return response.json() + + def upload_tenant_api_key( + self, + app_id: str, + tenant_id: str, + api_key: str, + ) -> dict: + """ + Upload (set) a static API key for a tenant on an apikey-type outbound application. + + Args: + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + api_key (str): The API key to store for the tenant. + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.post( + MgmtV1.outbound_application_upload_tenant_api_key_path, + body={"appId": app_id, "tenantId": tenant_id, "apiKey": api_key}, + ) + return response.json() + + def upload_user_token( + self, + app_id: str, + user_id: str, + tenant_id: Optional[str] = None, + refresh_token: Optional[str] = None, + access_token: Optional[str] = None, + access_token_expiry: Optional[int] = None, + access_token_type: Optional[str] = None, + scopes: Optional[List[str]] = None, + external_identifier: Optional[str] = None, + id_token: Optional[str] = None, + verify_refresh: Optional[bool] = None, + granted_by: Optional[str] = None, + ) -> dict: + """ + Upload (migrate) an existing OAuth token for a user on an oauth-type outbound application, + without requiring the user to re-run the OAuth flow. At least one of refresh_token or + access_token must be provided. + + Args: + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + tenant_id (str): Optional tenant ID. + refresh_token (str): Optional refresh token. + access_token (str): Optional access token. + access_token_expiry (int): Optional access token expiry, in epoch seconds. + access_token_type (str): Optional token type (defaults to "Bearer"). + scopes (List[str]): Optional scopes the token was granted for. + external_identifier (str): Optional external identifier. + id_token (str): Optional id token. + verify_refresh (bool): If True, verify the refresh token against the provider before + persisting; nothing is written if verification fails. + granted_by (str): Optional attribution for who granted the token. + + Raise: + AuthException: raised if the operation fails + """ + base: dict = {"appId": app_id, "userId": user_id} + if tenant_id is not None: + base["tenantId"] = tenant_id + body = OutboundApplicationBase._compose_oauth_upload_body( + base, + refresh_token, + access_token, + access_token_expiry, + access_token_type, + scopes, + external_identifier, + id_token, + granted_by, + verify_refresh, + ) + response = self._http.post( + MgmtV1.outbound_application_upload_user_token_path, + body=body, + ) + return response.json() + + def upload_tenant_token( + self, + app_id: str, + tenant_id: str, + refresh_token: Optional[str] = None, + access_token: Optional[str] = None, + access_token_expiry: Optional[int] = None, + access_token_type: Optional[str] = None, + scopes: Optional[List[str]] = None, + external_identifier: Optional[str] = None, + id_token: Optional[str] = None, + verify_refresh: Optional[bool] = None, + granted_by: Optional[str] = None, + ) -> dict: + """ + Upload (migrate) an existing OAuth token for a tenant on an oauth-type outbound application. + At least one of refresh_token or access_token must be provided. + + Args: + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + (remaining args): see upload_user_token. + + Raise: + AuthException: raised if the operation fails + """ + base: dict = {"appId": app_id, "tenantId": tenant_id} + body = OutboundApplicationBase._compose_oauth_upload_body( + base, + refresh_token, + access_token, + access_token_expiry, + access_token_type, + scopes, + external_identifier, + id_token, + granted_by, + verify_refresh, + ) + response = self._http.post( + MgmtV1.outbound_application_upload_tenant_token_path, + body=body, + ) + return response.json() + + def batch_upload_user_tokens(self, tokens: List[dict]) -> dict: + """ + Batch upload (migrate) existing OAuth tokens for users. All-or-nothing: if any item fails + per-item validation, the returned failures are populated and no tokens are committed. + + Args: + tokens (List[dict]): A list of token dicts, each with at least appId and userId plus one of + refreshToken / accessToken. See upload_user_token for the full set of fields + (verifyRefresh is not supported for batch uploads). + + Return value (dict): + Return dict in the format + {"failures": [{"appId": , "userId": , "errorCode": , "reason": }, ...]} + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.post( + MgmtV1.outbound_application_batch_upload_user_tokens_path, + body={"tokens": tokens}, + ) + return response.json() + + def batch_upload_tenant_tokens(self, tokens: List[dict]) -> dict: + """ + Batch upload (migrate) existing OAuth tokens for tenants. All-or-nothing: if any item fails + per-item validation, the returned failures are populated and no tokens are committed. + + Args: + tokens (List[dict]): A list of token dicts, each with at least appId and tenantId plus one of + refreshToken / accessToken. + + Return value (dict): + Return dict in the format + {"failures": [{"appId": , "tenantId": , "errorCode": , "reason": }, ...]} + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.post( + MgmtV1.outbound_application_batch_upload_tenant_tokens_path, + body={"tokens": tokens}, + ) + return response.json() + class OutboundApplicationByToken(HTTPBase): def __init__(self, http_client: HTTPClient): diff --git a/descope/management/outbound_application_async.py b/descope/management/outbound_application_async.py index e2ab35411..ff9d73838 100644 --- a/descope/management/outbound_application_async.py +++ b/descope/management/outbound_application_async.py @@ -489,6 +489,236 @@ async def delete_token( uri = MgmtV1.outbound_application_delete_token_path await self._http.delete(uri, params={"id": token_id}) + async def list_apps_with_user_token( + self, + user_id: str, + tenant_id: Optional[str] = None, + ) -> dict: + """ + List the IDs of the outbound applications the given user currently holds a valid token for. + Use this for connection-status views instead of calling fetch_token once per application. + + Args: + user_id (str): The ID of the user. + tenant_id (str): Optional tenant ID to scope the lookup to. + + Return value (dict): + Return dict in the format + {"appIds": [, ...]} + + Raise: + AuthException: raised if the operation fails + """ + params = {"userId": user_id} + if tenant_id: + params["tenantId"] = tenant_id + response = await self._http.get( + MgmtV1.outbound_application_list_apps_with_user_token_path, + params=params, + ) + return response.json() + + async def upload_user_api_key( + self, + app_id: str, + user_id: str, + api_key: str, + tenant_id: Optional[str] = None, + ) -> dict: + """ + Upload (set) a static API key for a user on an apikey-type outbound application. + + Args: + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + api_key (str): The API key to store for the user. + tenant_id (str): Optional tenant ID, used to associate different keys for the + same user in different tenants. + + Raise: + AuthException: raised if the operation fails + """ + body: dict = {"appId": app_id, "userId": user_id, "apiKey": api_key} + if tenant_id is not None: + body["tenantId"] = tenant_id + response = await self._http.post( + MgmtV1.outbound_application_upload_user_api_key_path, + body=body, + ) + return response.json() + + async def upload_tenant_api_key( + self, + app_id: str, + tenant_id: str, + api_key: str, + ) -> dict: + """ + Upload (set) a static API key for a tenant on an apikey-type outbound application. + + Args: + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + api_key (str): The API key to store for the tenant. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.outbound_application_upload_tenant_api_key_path, + body={"appId": app_id, "tenantId": tenant_id, "apiKey": api_key}, + ) + return response.json() + + async def upload_user_token( + self, + app_id: str, + user_id: str, + tenant_id: Optional[str] = None, + refresh_token: Optional[str] = None, + access_token: Optional[str] = None, + access_token_expiry: Optional[int] = None, + access_token_type: Optional[str] = None, + scopes: Optional[List[str]] = None, + external_identifier: Optional[str] = None, + id_token: Optional[str] = None, + verify_refresh: Optional[bool] = None, + granted_by: Optional[str] = None, + ) -> dict: + """ + Upload (migrate) an existing OAuth token for a user on an oauth-type outbound application, + without requiring the user to re-run the OAuth flow. At least one of refresh_token or + access_token must be provided. + + Args: + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + tenant_id (str): Optional tenant ID. + refresh_token (str): Optional refresh token. + access_token (str): Optional access token. + access_token_expiry (int): Optional access token expiry, in epoch seconds. + access_token_type (str): Optional token type (defaults to "Bearer"). + scopes (List[str]): Optional scopes the token was granted for. + external_identifier (str): Optional external identifier. + id_token (str): Optional id token. + verify_refresh (bool): If True, verify the refresh token against the provider before + persisting; nothing is written if verification fails. + granted_by (str): Optional attribution for who granted the token. + + Raise: + AuthException: raised if the operation fails + """ + base: dict = {"appId": app_id, "userId": user_id} + if tenant_id is not None: + base["tenantId"] = tenant_id + body = OutboundApplicationBase._compose_oauth_upload_body( + base, + refresh_token, + access_token, + access_token_expiry, + access_token_type, + scopes, + external_identifier, + id_token, + granted_by, + verify_refresh, + ) + response = await self._http.post( + MgmtV1.outbound_application_upload_user_token_path, + body=body, + ) + return response.json() + + async def upload_tenant_token( + self, + app_id: str, + tenant_id: str, + refresh_token: Optional[str] = None, + access_token: Optional[str] = None, + access_token_expiry: Optional[int] = None, + access_token_type: Optional[str] = None, + scopes: Optional[List[str]] = None, + external_identifier: Optional[str] = None, + id_token: Optional[str] = None, + verify_refresh: Optional[bool] = None, + granted_by: Optional[str] = None, + ) -> dict: + """ + Upload (migrate) an existing OAuth token for a tenant on an oauth-type outbound application. + At least one of refresh_token or access_token must be provided. + + Args: + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + (remaining args): see upload_user_token. + + Raise: + AuthException: raised if the operation fails + """ + base: dict = {"appId": app_id, "tenantId": tenant_id} + body = OutboundApplicationBase._compose_oauth_upload_body( + base, + refresh_token, + access_token, + access_token_expiry, + access_token_type, + scopes, + external_identifier, + id_token, + granted_by, + verify_refresh, + ) + response = await self._http.post( + MgmtV1.outbound_application_upload_tenant_token_path, + body=body, + ) + return response.json() + + async def batch_upload_user_tokens(self, tokens: List[dict]) -> dict: + """ + Batch upload (migrate) existing OAuth tokens for users. All-or-nothing: if any item fails + per-item validation, the returned failures are populated and no tokens are committed. + + Args: + tokens (List[dict]): A list of token dicts, each with at least appId and userId plus one of + refreshToken / accessToken. See upload_user_token for the full set of fields + (verifyRefresh is not supported for batch uploads). + + Return value (dict): + Return dict in the format + {"failures": [{"appId": , "userId": , "errorCode": , "reason": }, ...]} + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.outbound_application_batch_upload_user_tokens_path, + body={"tokens": tokens}, + ) + return response.json() + + async def batch_upload_tenant_tokens(self, tokens: List[dict]) -> dict: + """ + Batch upload (migrate) existing OAuth tokens for tenants. All-or-nothing: if any item fails + per-item validation, the returned failures are populated and no tokens are committed. + + Args: + tokens (List[dict]): A list of token dicts, each with at least appId and tenantId plus one of + refreshToken / accessToken. + + Return value (dict): + Return dict in the format + {"failures": [{"appId": , "tenantId": , "errorCode": , "reason": }, ...]} + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.outbound_application_batch_upload_tenant_tokens_path, + body={"tokens": tokens}, + ) + return response.json() + class OutboundApplicationByTokenAsync(AsyncHTTPBase): def __init__(self, http_client: HTTPClientAsync): diff --git a/pyproject.toml b/pyproject.toml index f7bdaf401..5519307ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,11 @@ tests = [ "pytest>=9.0.3; python_version >= '3.10'", "pytest>=8.4,<9; python_version < '3.10'", "pytest-cov>=5", - "pytest-asyncio==1.4.0; python_version < '3.10'", + # pytest-asyncio 1.4.0 dropped Python 3.9 support; on 3.9 we stay on the last + # release that still supports it (1.2.0). Bumping this to 1.4.0 (renovate, #1584) + # made the locked resolution unsatisfiable for the 3.9 split and broke CI's + # install step. Keep ==1.2.0 on the 3.9 entry. + "pytest-asyncio==1.2.0; python_version < '3.10'", "pytest-asyncio==1.4.0; python_version >= '3.10'", "coverage[toml]>=7.3.1,<8", ] diff --git a/tests/management/test_outbound_application.py b/tests/management/test_outbound_application.py index 5b58d775b..64590ded3 100644 --- a/tests/management/test_outbound_application.py +++ b/tests/management/test_outbound_application.py @@ -601,6 +601,188 @@ async def test_delete_token_failure(self, client_factory): with pytest.raises(AuthException): await client.invoke(client.mgmt.outbound_application.delete_token("token123")) + async def test_list_apps_with_user_token_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_get(make_response({"appIds": ["app1", "app2"]})) as mock_get: + response = await client.invoke( + client.mgmt.outbound_application.list_apps_with_user_token("user456", tenant_id="tenant789") + ) + assert response == {"appIds": ["app1", "app2"]} + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_list_apps_with_user_token_path}", + headers=MGMT_HEADERS, + params={"userId": "user456", "tenantId": "tenant789"}, + follow_redirects=True, + ) + + async def test_list_apps_with_user_token_no_tenant(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_get(make_response({"appIds": ["app1"]})) as mock_get: + await client.invoke(client.mgmt.outbound_application.list_apps_with_user_token("user456")) + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_list_apps_with_user_token_path}", + headers=MGMT_HEADERS, + params={"userId": "user456"}, + follow_redirects=True, + ) + + async def test_list_apps_with_user_token_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_get(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.outbound_application.list_apps_with_user_token("user456")) + + async def test_upload_user_api_key_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke( + client.mgmt.outbound_application.upload_user_api_key( + "app123", "user456", "secret-key", tenant_id="tenant789" + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_upload_user_api_key_path}", + headers=MGMT_HEADERS, + params=None, + json={ + "appId": "app123", + "userId": "user456", + "apiKey": "secret-key", + "tenantId": "tenant789", + }, + follow_redirects=False, + ) + + async def test_upload_user_api_key_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.upload_user_api_key("app123", "user456", "secret-key") + ) + + async def test_upload_tenant_api_key_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke( + client.mgmt.outbound_application.upload_tenant_api_key("app123", "tenant789", "secret-key") + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_upload_tenant_api_key_path}", + headers=MGMT_HEADERS, + params=None, + json={"appId": "app123", "tenantId": "tenant789", "apiKey": "secret-key"}, + follow_redirects=False, + ) + + async def test_upload_user_token_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke( + client.mgmt.outbound_application.upload_user_token( + "app123", + "user456", + refresh_token="refresh", + scopes=["read"], + verify_refresh=True, + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_upload_user_token_path}", + headers=MGMT_HEADERS, + params=None, + json={ + "appId": "app123", + "userId": "user456", + "refreshToken": "refresh", + "scopes": ["read"], + "verifyRefresh": True, + }, + follow_redirects=False, + ) + + async def test_upload_tenant_token_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke( + client.mgmt.outbound_application.upload_tenant_token( + "app123", + "tenant789", + access_token="access", + access_token_expiry=3600, + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_upload_tenant_token_path}", + headers=MGMT_HEADERS, + params=None, + json={ + "appId": "app123", + "tenantId": "tenant789", + "accessToken": "access", + "accessTokenExpiry": 3600, + }, + follow_redirects=False, + ) + + async def test_batch_upload_user_tokens_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + tokens = [ + {"appId": "app123", "userId": "user1", "accessToken": "a1"}, + {"appId": "app123", "userId": "user2", "accessToken": "a2"}, + ] + failures = {"failures": [{"appId": "app123", "userId": "user2", "errorCode": "E152110", "reason": "bad token"}]} + with client.mock_mgmt_post(make_response(failures)) as mock_post: + response = await client.invoke(client.mgmt.outbound_application.batch_upload_user_tokens(tokens)) + assert response == failures + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_batch_upload_user_tokens_path}", + headers=MGMT_HEADERS, + params=None, + json={"tokens": tokens}, + follow_redirects=False, + ) + + async def test_batch_upload_tenant_tokens_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + tokens = [{"appId": "app123", "tenantId": "tenant1", "accessToken": "a1"}] + with client.mock_mgmt_post(make_response({"failures": []})) as mock_post: + response = await client.invoke(client.mgmt.outbound_application.batch_upload_tenant_tokens(tokens)) + assert response == {"failures": []} + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_batch_upload_tenant_tokens_path}", + headers=MGMT_HEADERS, + params=None, + json={"tokens": tokens}, + follow_redirects=False, + ) + def test_url_param_to_dict(self): param = URLParam("test_name", "test_value") param_dict = param.to_dict() diff --git a/uv.lock b/uv.lock index cec8d5490..89bc7fc2b 100644 --- a/uv.lock +++ b/uv.lock @@ -17,9 +17,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "exceptiongroup" }, + { name = "idna" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -35,9 +35,9 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } wheels = [ @@ -241,7 +241,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ @@ -257,7 +257,7 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ @@ -390,7 +390,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "tomli" }, ] [[package]] @@ -497,7 +497,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -508,8 +508,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cffi", marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } wheels = [ @@ -573,8 +573,8 @@ resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", ] dependencies = [ - { name = "cffi", marker = "python_full_version > '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.11'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ @@ -673,7 +673,7 @@ provides-extras = ["flask"] [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = "==3.8.0" }, - { name = "ruff", specifier = "==0.15.15" }, + { name = "ruff", specifier = "==0.15.16" }, ] tests = [ { name = "coverage", extras = ["toml"], specifier = ">=7.3.1,<8" }, @@ -742,7 +742,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -872,7 +872,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.10'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1128,9 +1128,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "mypy-extensions", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "mypy-extensions" }, + { name = "tomli" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806, upload-time = "2024-08-24T22:50:11.357Z" } wheels = [ @@ -1166,12 +1166,12 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ - { name = "ast-serialize", marker = "python_full_version >= '3.10'" }, - { name = "librt", marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions", marker = "python_full_version >= '3.10'" }, - { name = "pathspec", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ @@ -1371,13 +1371,13 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -1393,13 +1393,13 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -1415,9 +1415,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "backports-asyncio-runner" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ @@ -1433,9 +1433,9 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.15'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -1548,27 +1548,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, - { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, - { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, - { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, - { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +version = "0.15.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, + { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] [[package]]