diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1568d69..0751614 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -59,5 +59,5 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 133a3be..e3b28d7 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -102,7 +102,7 @@ jobs: - name: Create issue on failure if: failure() - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | github.rest.issues.create({ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d8d20e..6c35026 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,7 +155,7 @@ jobs: EOF - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: name: v${{ steps.version.outputs.version }} body_path: release_notes.md diff --git a/src/devrev/client.py b/src/devrev/client.py index 6278dd1..98b7072 100644 --- a/src/devrev/client.py +++ b/src/devrev/client.py @@ -38,6 +38,7 @@ AsyncRecommendationsService, RecommendationsService, ) +from devrev.services.rev_orgs import AsyncRevOrgsService, RevOrgsService from devrev.services.rev_users import AsyncRevUsersService, RevUsersService from devrev.services.search import AsyncSearchService, SearchService from devrev.services.slas import AsyncSlasService, SlasService @@ -160,6 +161,7 @@ def __init__( self._groups = GroupsService(self._http) self._links = LinksService(self._http) self._parts = PartsService(self._http) + self._rev_orgs = RevOrgsService(self._http) self._rev_users = RevUsersService(self._http) self._slas = SlasService(self._http) self._tags = TagsService(self._http) @@ -242,6 +244,11 @@ def parts(self) -> PartsService: """Access the Parts service.""" return self._parts + @property + def rev_orgs(self) -> RevOrgsService: + """Access the Rev Orgs service for managing revenue organizations.""" + return self._rev_orgs + @property def rev_users(self) -> RevUsersService: """Access the Rev Users service.""" @@ -482,6 +489,7 @@ def __init__( self._groups = AsyncGroupsService(self._http) self._links = AsyncLinksService(self._http) self._parts = AsyncPartsService(self._http) + self._rev_orgs = AsyncRevOrgsService(self._http) self._rev_users = AsyncRevUsersService(self._http) self._slas = AsyncSlasService(self._http) self._tags = AsyncTagsService(self._http) @@ -564,6 +572,11 @@ def parts(self) -> AsyncPartsService: """Access the Parts service.""" return self._parts + @property + def rev_orgs(self) -> AsyncRevOrgsService: + """Access the Rev Orgs service for managing revenue organizations.""" + return self._rev_orgs + @property def rev_users(self) -> AsyncRevUsersService: """Access the Rev Users service.""" diff --git a/src/devrev/models/__init__.py b/src/devrev/models/__init__.py index dd28739..a3c89fd 100644 --- a/src/devrev/models/__init__.py +++ b/src/devrev/models/__init__.py @@ -260,6 +260,20 @@ MessageRole, TokenUsage, ) +from devrev.models.rev_orgs import ( + RevOrg, + RevOrgsCreateRequest, + RevOrgsCreateResponse, + RevOrgsDeleteRequest, + RevOrgsDeleteResponse, + RevOrgsGetRequest, + RevOrgsGetResponse, + RevOrgsListRequest, + RevOrgsListResponse, + RevOrgSummary, + RevOrgsUpdateRequest, + RevOrgsUpdateResponse, +) from devrev.models.rev_users import ( RevUser, RevUsersAssociationsAddRequest, @@ -694,6 +708,19 @@ "ChatCompletionResponse", "GetReplyRequest", "GetReplyResponse", + # Rev Orgs + "RevOrg", + "RevOrgSummary", + "RevOrgsCreateRequest", + "RevOrgsCreateResponse", + "RevOrgsGetRequest", + "RevOrgsGetResponse", + "RevOrgsListRequest", + "RevOrgsListResponse", + "RevOrgsUpdateRequest", + "RevOrgsUpdateResponse", + "RevOrgsDeleteRequest", + "RevOrgsDeleteResponse", # Rev Users "RevUser", "RevUserSummary", @@ -889,6 +916,12 @@ AccountsListResponse as _AccountsListResponse, ) from devrev.models.base import TagWithValue as _TagWithValue # noqa: F811 +from devrev.models.rev_orgs import ( # noqa: F811 + RevOrg as _RevOrg, +) +from devrev.models.rev_orgs import ( + RevOrgsListResponse as _RevOrgsListResponse, +) from devrev.models.works import ( # noqa: F811 Work as _Work, ) @@ -904,6 +937,8 @@ _Account.model_rebuild() _AccountsListResponse.model_rebuild() _AccountsExportResponse.model_rebuild() +_RevOrg.model_rebuild() +_RevOrgsListResponse.model_rebuild() _Work.model_rebuild() _WorksListResponse.model_rebuild() _WorksExportResponse.model_rebuild() diff --git a/src/devrev/models/rev_orgs.py b/src/devrev/models/rev_orgs.py new file mode 100644 index 0000000..5fe419c --- /dev/null +++ b/src/devrev/models/rev_orgs.py @@ -0,0 +1,159 @@ +"""Rev Org models for DevRev SDK. + +This module contains Pydantic models for Rev Org (Revenue Organization)-related +API operations. Rev Orgs represent customer organizations in DevRev. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import Field + +from devrev.models.accounts import AccountSummary +from devrev.models.base import ( + CustomSchemaSpec, + DateFilter, + DevRevBaseModel, + DevRevResponseModel, + PaginatedResponse, + TagWithValue, + UserSummary, +) + + +class RevOrg(DevRevResponseModel): + """DevRev Rev Org (Revenue Organization) model. + + Represents a customer organization in DevRev, typically associated + with an Account. + """ + + id: str = Field(..., description="Rev org ID") + display_id: str | None = Field(default=None, description="Human-readable display ID") + display_name: str | None = Field(default=None, description="Rev org display name") + description: str | None = Field(default=None, description="Rev org description") + account: AccountSummary | None = Field(default=None, description="Associated account") + created_date: datetime | None = Field(default=None, description="Creation timestamp") + modified_date: datetime | None = Field(default=None, description="Last modification timestamp") + created_by: UserSummary | None = Field( + default=None, description="User who created this rev org" + ) + modified_by: UserSummary | None = Field( + default=None, description="User who last modified this rev org" + ) + domain: str | None = Field(default=None, description="Domain") + external_ref: str | None = Field(default=None, description="External reference identifier") + external_refs: list[str] | None = Field(default=None, description="External references") + custom_fields: dict[str, Any] | None = Field(default=None, description="Custom fields") + sub_type: str | None = Field(default=None, description="Rev org subtype") + tags: list[TagWithValue] | None = Field(default=None, description="Tags") + tier: str | None = Field(default=None, description="Rev org tier") + artifacts: list[str] | None = Field(default=None, description="Associated artifact IDs") + + +class RevOrgSummary(DevRevResponseModel): + """Summary of a Rev Org for list/reference operations.""" + + id: str = Field(..., description="Rev org ID") + display_id: str | None = Field(default=None, description="Human-readable display ID") + display_name: str | None = Field(default=None, description="Rev org display name") + + +# Request Models + + +class RevOrgsGetRequest(DevRevBaseModel): + """Request to get a rev org.""" + + id: str | None = Field(default=None, description="Rev org ID") + account: str | None = Field( + default=None, description="Account ID to get the default rev org for" + ) + + +class RevOrgsListRequest(DevRevBaseModel): + """Request to list rev orgs.""" + + account: list[str] | None = Field(default=None, description="Filter by account IDs") + created_by: list[str] | None = Field(default=None, description="Filter by creator user IDs") + created_date: DateFilter | None = Field(default=None, description="Filter by creation date") + cursor: str | None = Field(default=None, description="Pagination cursor") + display_name: list[str] | None = Field(default=None, description="Filter by display names") + domains: list[str] | None = Field(default=None, description="Filter by domains") + external_refs: list[str] | None = Field(default=None, description="Filter by external refs") + limit: int | None = Field(default=None, ge=1, le=100, description="Max results to return") + modified_date: DateFilter | None = Field( + default=None, description="Filter by modification date" + ) + owned_by: list[str] | None = Field(default=None, description="Filter by owner user IDs") + tags: list[str] | None = Field(default=None, description="Filter by tag IDs") + + +class RevOrgsCreateRequest(DevRevBaseModel): + """Request to create a rev org.""" + + display_name: str = Field(..., description="Rev org display name", min_length=1, max_length=256) + account: str = Field(..., description="Parent account ID") + description: str | None = Field(default=None, description="Rev org description") + external_ref: str | None = Field(default=None, description="External reference identifier") + tier: str | None = Field(default=None, description="Rev org tier") + custom_fields: dict[str, Any] | None = Field(default=None, description="Custom fields") + custom_schema_spec: CustomSchemaSpec | None = Field( + default=None, description="Custom schema spec" + ) + + +class RevOrgsUpdateRequest(DevRevBaseModel): + """Request to update a rev org.""" + + id: str = Field(..., description="Rev org ID") + display_name: str | None = Field(default=None, description="New display name") + description: str | None = Field(default=None, description="New description") + tier: str | None = Field(default=None, description="New tier") + custom_fields: dict[str, Any] | None = Field( + default=None, description="Custom fields to update" + ) + artifacts: dict[str, Any] | None = Field( + default=None, description="Artifact set/remove operations" + ) + + +class RevOrgsDeleteRequest(DevRevBaseModel): + """Request to delete a rev org.""" + + id: str = Field(..., description="Rev org ID to delete") + + +# Response Models + + +class RevOrgsGetResponse(DevRevResponseModel): + """Response from getting a rev org.""" + + rev_org: RevOrg = Field(..., description="Retrieved rev org") + + +class RevOrgsListResponse(PaginatedResponse): + """Response from listing rev orgs.""" + + rev_orgs: list[RevOrg] = Field(..., description="List of rev orgs") + + +class RevOrgsCreateResponse(DevRevResponseModel): + """Response from creating a rev org.""" + + rev_org: RevOrg = Field(..., description="Created rev org") + + +class RevOrgsUpdateResponse(DevRevResponseModel): + """Response from updating a rev org.""" + + rev_org: RevOrg = Field(..., description="Updated rev org") + + +class RevOrgsDeleteResponse(DevRevResponseModel): + """Response from deleting a rev org.""" + + pass # Empty response body diff --git a/src/devrev/services/__init__.py b/src/devrev/services/__init__.py index c3881c1..6944ebc 100644 --- a/src/devrev/services/__init__.py +++ b/src/devrev/services/__init__.py @@ -34,6 +34,7 @@ AsyncRecommendationsService, RecommendationsService, ) +from devrev.services.rev_orgs import AsyncRevOrgsService, RevOrgsService from devrev.services.rev_users import AsyncRevUsersService, RevUsersService from devrev.services.search import AsyncSearchService, SearchService from devrev.services.slas import AsyncSlasService, SlasService @@ -99,6 +100,9 @@ # Recommendations "RecommendationsService", "AsyncRecommendationsService", + # Rev Orgs + "RevOrgsService", + "AsyncRevOrgsService", # Rev Users "RevUsersService", "AsyncRevUsersService", diff --git a/src/devrev/services/rev_orgs.py b/src/devrev/services/rev_orgs.py new file mode 100644 index 0000000..72f81cc --- /dev/null +++ b/src/devrev/services/rev_orgs.py @@ -0,0 +1,271 @@ +"""Rev Orgs service for DevRev SDK. + +This module provides the RevOrgsService for managing DevRev Rev Orgs +(Revenue Organizations). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from devrev.models.rev_orgs import ( + RevOrg, + RevOrgsCreateRequest, + RevOrgsCreateResponse, + RevOrgsDeleteRequest, + RevOrgsDeleteResponse, + RevOrgsGetRequest, + RevOrgsGetResponse, + RevOrgsListRequest, + RevOrgsListResponse, + RevOrgsUpdateRequest, + RevOrgsUpdateResponse, +) +from devrev.services.base import AsyncBaseService, BaseService + +if TYPE_CHECKING: + from devrev.utils.http import AsyncHTTPClient, HTTPClient + + +class RevOrgsService(BaseService): + """Synchronous service for managing DevRev Rev Orgs. + + Provides methods for creating, reading, updating, and deleting + Rev Orgs (Revenue Organizations). + + Example: + ```python + from devrev import DevRevClient + + client = DevRevClient() + rev_orgs = client.rev_orgs.list() + ``` + """ + + def __init__(self, http_client: HTTPClient) -> None: + """Initialize the RevOrgsService.""" + super().__init__(http_client) + + def create( + self, + display_name: str, + account: str, + *, + description: str | None = None, + external_ref: str | None = None, + tier: str | None = None, + custom_fields: dict[str, Any] | None = None, + ) -> RevOrg: + """Create a new rev org. + + Args: + display_name: Rev org display name + account: Parent account ID + description: Rev org description + external_ref: External reference identifier + tier: Rev org tier + custom_fields: Custom fields + + Returns: + The created RevOrg + """ + request = RevOrgsCreateRequest( + display_name=display_name, + account=account, + description=description, + external_ref=external_ref, + tier=tier, + custom_fields=custom_fields, + ) + response = self._post("/rev-orgs.create", request, RevOrgsCreateResponse) + return response.rev_org + + def get(self, id: str) -> RevOrg: + """Get a rev org by ID. + + Args: + id: Rev org ID + + Returns: + The RevOrg + """ + request = RevOrgsGetRequest(id=id) + response = self._post("/rev-orgs.get", request, RevOrgsGetResponse) + return response.rev_org + + def list( + self, + *, + cursor: str | None = None, + limit: int | None = None, + account: list[str] | None = None, + display_name: list[str] | None = None, + domains: list[str] | None = None, + external_refs: list[str] | None = None, + owned_by: list[str] | None = None, + tags: list[str] | None = None, + ) -> RevOrgsListResponse: + """List rev orgs. + + Args: + cursor: Pagination cursor + limit: Maximum number of results + account: Filter by account IDs + display_name: Filter by display names + domains: Filter by domains + external_refs: Filter by external refs + owned_by: Filter by owner user IDs + tags: Filter by tag IDs + + Returns: + Paginated list of rev orgs + """ + request = RevOrgsListRequest( + cursor=cursor, + limit=limit, + account=account, + display_name=display_name, + domains=domains, + external_refs=external_refs, + owned_by=owned_by, + tags=tags, + ) + return self._post("/rev-orgs.list", request, RevOrgsListResponse) + + def update( + self, + id: str, + *, + display_name: str | None = None, + description: str | None = None, + tier: str | None = None, + custom_fields: dict[str, Any] | None = None, + ) -> RevOrg: + """Update a rev org. + + Args: + id: Rev org ID + display_name: New display name + description: New description + tier: New tier + custom_fields: Custom fields to update + + Returns: + The updated RevOrg + """ + request = RevOrgsUpdateRequest( + id=id, + display_name=display_name, + description=description, + tier=tier, + custom_fields=custom_fields, + ) + response = self._post("/rev-orgs.update", request, RevOrgsUpdateResponse) + return response.rev_org + + def delete(self, id: str) -> None: + """Delete a rev org. + + Args: + id: Rev org ID to delete + """ + request = RevOrgsDeleteRequest(id=id) + self._post("/rev-orgs.delete", request, RevOrgsDeleteResponse) + + +class AsyncRevOrgsService(AsyncBaseService): + """Asynchronous service for managing DevRev Rev Orgs. + + Provides async methods for creating, reading, updating, and deleting + Rev Orgs (Revenue Organizations). + + Example: + ```python + from devrev import AsyncDevRevClient + + async with AsyncDevRevClient() as client: + rev_orgs = await client.rev_orgs.list() + ``` + """ + + def __init__(self, http_client: AsyncHTTPClient) -> None: + """Initialize the AsyncRevOrgsService.""" + super().__init__(http_client) + + async def create( + self, + display_name: str, + account: str, + *, + description: str | None = None, + external_ref: str | None = None, + tier: str | None = None, + custom_fields: dict[str, Any] | None = None, + ) -> RevOrg: + """Create a new rev org.""" + request = RevOrgsCreateRequest( + display_name=display_name, + account=account, + description=description, + external_ref=external_ref, + tier=tier, + custom_fields=custom_fields, + ) + response = await self._post("/rev-orgs.create", request, RevOrgsCreateResponse) + return response.rev_org + + async def get(self, id: str) -> RevOrg: + """Get a rev org by ID.""" + request = RevOrgsGetRequest(id=id) + response = await self._post("/rev-orgs.get", request, RevOrgsGetResponse) + return response.rev_org + + async def list( + self, + *, + cursor: str | None = None, + limit: int | None = None, + account: list[str] | None = None, + display_name: list[str] | None = None, + domains: list[str] | None = None, + external_refs: list[str] | None = None, + owned_by: list[str] | None = None, + tags: list[str] | None = None, + ) -> RevOrgsListResponse: + """List rev orgs.""" + request = RevOrgsListRequest( + cursor=cursor, + limit=limit, + account=account, + display_name=display_name, + domains=domains, + external_refs=external_refs, + owned_by=owned_by, + tags=tags, + ) + return await self._post("/rev-orgs.list", request, RevOrgsListResponse) + + async def update( + self, + id: str, + *, + display_name: str | None = None, + description: str | None = None, + tier: str | None = None, + custom_fields: dict[str, Any] | None = None, + ) -> RevOrg: + """Update a rev org.""" + request = RevOrgsUpdateRequest( + id=id, + display_name=display_name, + description=description, + tier=tier, + custom_fields=custom_fields, + ) + response = await self._post("/rev-orgs.update", request, RevOrgsUpdateResponse) + return response.rev_org + + async def delete(self, id: str) -> None: + """Delete a rev org.""" + request = RevOrgsDeleteRequest(id=id) + await self._post("/rev-orgs.delete", request, RevOrgsDeleteResponse) diff --git a/src/devrev_mcp/server.py b/src/devrev_mcp/server.py index 69a4f69..04ccead 100644 --- a/src/devrev_mcp/server.py +++ b/src/devrev_mcp/server.py @@ -243,6 +243,7 @@ def _patched_sse_app(*args: Any, **kwargs: Any) -> Any: from devrev_mcp.tools import incidents as _incidents_tools # noqa: E402, F401 from devrev_mcp.tools import links as _links_tools # noqa: E402, F401 from devrev_mcp.tools import parts as _parts_tools # noqa: E402, F401 +from devrev_mcp.tools import rev_orgs as _rev_orgs_tools # noqa: E402, F401 from devrev_mcp.tools import server_info as _server_info_tools # noqa: E402, F401 from devrev_mcp.tools import slas as _slas_tools # noqa: E402, F401 from devrev_mcp.tools import tags as _tags_tools # noqa: E402, F401 diff --git a/src/devrev_mcp/tools/accounts.py b/src/devrev_mcp/tools/accounts.py index 5a2ad0a..e686891 100644 --- a/src/devrev_mcp/tools/accounts.py +++ b/src/devrev_mcp/tools/accounts.py @@ -9,6 +9,7 @@ from devrev.exceptions import DevRevError from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size, paginated_response @@ -61,6 +62,7 @@ async def devrev_accounts_get( Args: id: The account ID. """ + validate_don_id(id, "account", "devrev_accounts_get") app = ctx.request_context.lifespan_context try: account = await app.get_client().accounts.get(id) @@ -124,6 +126,7 @@ async def devrev_accounts_update( description: New description. tier: New account tier. """ + validate_don_id(id, "account", "devrev_accounts_update") app = ctx.request_context.lifespan_context try: account = await app.get_client().accounts.update( @@ -146,6 +149,7 @@ async def devrev_accounts_delete( Args: id: The account ID to delete. """ + validate_don_id(id, "account", "devrev_accounts_delete") app = ctx.request_context.lifespan_context try: await app.get_client().accounts.delete(id) @@ -167,6 +171,8 @@ async def devrev_accounts_merge( primary_account: ID of the primary (surviving) account. secondary_account: ID of the secondary (merged) account. """ + validate_don_id(primary_account, "account", "devrev_accounts_merge") + validate_don_id(secondary_account, "account", "devrev_accounts_merge") app = ctx.request_context.lifespan_context try: account = await app.get_client().accounts.merge( diff --git a/src/devrev_mcp/tools/articles.py b/src/devrev_mcp/tools/articles.py index ff23141..f0a4878 100644 --- a/src/devrev_mcp/tools/articles.py +++ b/src/devrev_mcp/tools/articles.py @@ -22,6 +22,7 @@ from devrev.models.base import SetTagWithValue from devrev.utils.content_converter import OutputFormat from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size, paginated_response @@ -89,6 +90,7 @@ async def devrev_articles_get( Raises: RuntimeError: If the DevRev API call fails. """ + validate_don_id(id, "article", "devrev_articles_get") app = ctx.request_context.lifespan_context try: if include_content: @@ -251,6 +253,7 @@ async def devrev_articles_update( Raises: RuntimeError: If the DevRev API call fails. """ + validate_don_id(id, "article", "devrev_articles_update") app = ctx.request_context.lifespan_context try: article_status = None @@ -323,6 +326,7 @@ async def devrev_articles_delete(ctx: Context[Any, Any, Any], id: str) -> dict[s Raises: RuntimeError: If the DevRev API call fails. """ + validate_don_id(id, "article", "devrev_articles_delete") app = ctx.request_context.lifespan_context try: request = ArticlesDeleteRequest(id=id) diff --git a/src/devrev_mcp/tools/conversations.py b/src/devrev_mcp/tools/conversations.py index 6662e3a..a31f180 100644 --- a/src/devrev_mcp/tools/conversations.py +++ b/src/devrev_mcp/tools/conversations.py @@ -16,6 +16,7 @@ ConversationsUpdateRequest, ) from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size, paginated_response @@ -60,6 +61,7 @@ async def devrev_conversations_get( Args: id: Conversation ID (e.g., "don:core:dvrv-us-1:devo/1:conversation/123"). """ + validate_don_id(id, "conversation", "devrev_conversations_get") app = ctx.request_context.lifespan_context try: request = ConversationsGetRequest(id=id) @@ -108,6 +110,7 @@ async def devrev_conversations_update( title: New conversation title. description: New conversation description. """ + validate_don_id(id, "conversation", "devrev_conversations_update") app = ctx.request_context.lifespan_context try: request = ConversationsUpdateRequest(id=id, title=title, description=description) @@ -126,6 +129,7 @@ async def devrev_conversations_delete( Args: id: Conversation ID to delete. """ + validate_don_id(id, "conversation", "devrev_conversations_delete") app = ctx.request_context.lifespan_context try: request = ConversationsDeleteRequest(id=id) diff --git a/src/devrev_mcp/tools/engagements.py b/src/devrev_mcp/tools/engagements.py index 15a677e..86a1e94 100644 --- a/src/devrev_mcp/tools/engagements.py +++ b/src/devrev_mcp/tools/engagements.py @@ -14,6 +14,7 @@ from devrev.exceptions import DevRevError from devrev.models.engagements import EngagementType from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size, paginated_response @@ -82,6 +83,7 @@ async def devrev_engagements_get(ctx: Context[Any, Any, Any], id: str) -> dict[s Returns: Engagement details """ + validate_don_id(id, "engagement", "devrev_engagements_get") app = ctx.request_context.lifespan_context try: engagement = await app.get_client().engagements.get(id) @@ -172,6 +174,7 @@ async def devrev_engagements_update( Returns: Updated engagement """ + validate_don_id(id, "engagement", "devrev_engagements_update") app = ctx.request_context.lifespan_context try: eng_type = None @@ -214,6 +217,7 @@ async def devrev_engagements_delete(ctx: Context[Any, Any, Any], id: str) -> dic Returns: Deletion confirmation """ + validate_don_id(id, "engagement", "devrev_engagements_delete") app = ctx.request_context.lifespan_context try: await app.get_client().engagements.delete(id) diff --git a/src/devrev_mcp/tools/groups.py b/src/devrev_mcp/tools/groups.py index 43530c3..ceff419 100644 --- a/src/devrev_mcp/tools/groups.py +++ b/src/devrev_mcp/tools/groups.py @@ -22,6 +22,7 @@ GroupType, ) from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size @@ -75,6 +76,7 @@ async def devrev_groups_get(ctx: Context[Any, Any, Any], id: str) -> dict[str, A Raises: RuntimeError: If the DevRev API call fails. """ + validate_don_id(id, "group", "devrev_groups_get") app = ctx.request_context.lifespan_context try: request = GroupsGetRequest(id=id) @@ -146,6 +148,7 @@ async def devrev_groups_update( Raises: RuntimeError: If the DevRev API call fails. """ + validate_don_id(id, "group", "devrev_groups_update") app = ctx.request_context.lifespan_context try: request = GroupsUpdateRequest(id=id, name=name, description=description) diff --git a/src/devrev_mcp/tools/incidents.py b/src/devrev_mcp/tools/incidents.py index c1c980e..5ce4e74 100644 --- a/src/devrev_mcp/tools/incidents.py +++ b/src/devrev_mcp/tools/incidents.py @@ -10,6 +10,7 @@ from devrev.exceptions import DevRevError from devrev.models.incidents import IncidentSeverity, IncidentStage from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size, paginated_response @@ -77,6 +78,7 @@ async def devrev_incidents_get( Args: id: The incident ID. """ + validate_don_id(id, "incident", "devrev_incidents_get") app = ctx.request_context.lifespan_context try: incident = await app.get_client().incidents.get(id) @@ -148,6 +150,7 @@ async def devrev_incidents_update( stage: New stage: ACKNOWLEDGED, IDENTIFIED, MITIGATED, RESOLVED. severity: New severity level: SEV0, SEV1, SEV2, SEV3. """ + validate_don_id(id, "incident", "devrev_incidents_update") app = ctx.request_context.lifespan_context try: stage_enum = None @@ -189,6 +192,7 @@ async def devrev_incidents_delete( Args: id: The incident ID to delete. """ + validate_don_id(id, "incident", "devrev_incidents_delete") app = ctx.request_context.lifespan_context try: await app.get_client().incidents.delete(id) diff --git a/src/devrev_mcp/tools/links.py b/src/devrev_mcp/tools/links.py index 7dfee4a..a158f42 100644 --- a/src/devrev_mcp/tools/links.py +++ b/src/devrev_mcp/tools/links.py @@ -16,6 +16,7 @@ LinkType, ) from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size @@ -63,6 +64,7 @@ async def devrev_links_get( Args: id: Link ID (e.g., "don:core:dvrv-us-1:devo/1:link/123"). """ + validate_don_id(id, "link", "devrev_links_get") app = ctx.request_context.lifespan_context try: request = LinksGetRequest(id=id) @@ -119,6 +121,7 @@ async def devrev_links_delete( Args: id: Link ID to delete. """ + validate_don_id(id, "link", "devrev_links_delete") app = ctx.request_context.lifespan_context try: request = LinksDeleteRequest(id=id) diff --git a/src/devrev_mcp/tools/parts.py b/src/devrev_mcp/tools/parts.py index 606ea9b..cc11c52 100644 --- a/src/devrev_mcp/tools/parts.py +++ b/src/devrev_mcp/tools/parts.py @@ -20,6 +20,7 @@ PartType, ) from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size, paginated_response @@ -70,6 +71,7 @@ async def devrev_parts_get(ctx: Context[Any, Any, Any], id: str) -> dict[str, An Raises: RuntimeError: If the DevRev API call fails. """ + validate_don_id(id, "part", "devrev_parts_get") app = ctx.request_context.lifespan_context try: part = await app.get_client().parts.get(PartsGetRequest(id=id)) @@ -156,6 +158,7 @@ async def devrev_parts_update( Raises: RuntimeError: If the DevRev API call fails. """ + validate_don_id(id, "part", "devrev_parts_update") app = ctx.request_context.lifespan_context try: request = PartsUpdateRequest( @@ -181,6 +184,7 @@ async def devrev_parts_delete(ctx: Context[Any, Any, Any], id: str) -> dict[str, Raises: RuntimeError: If the DevRev API call fails. """ + validate_don_id(id, "part", "devrev_parts_delete") app = ctx.request_context.lifespan_context try: await app.get_client().parts.delete(PartsDeleteRequest(id=id)) diff --git a/src/devrev_mcp/tools/question_answers.py b/src/devrev_mcp/tools/question_answers.py index ae9f6b1..7e8c6cb 100644 --- a/src/devrev_mcp/tools/question_answers.py +++ b/src/devrev_mcp/tools/question_answers.py @@ -16,6 +16,7 @@ QuestionAnswersUpdateRequest, ) from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size, paginated_response @@ -62,6 +63,7 @@ async def devrev_question_answers_get( Args: id: The question answer ID. """ + validate_don_id(id, "qa", "devrev_question_answers_get") app = ctx.request_context.lifespan_context try: request = QuestionAnswersGetRequest(id=id) @@ -124,6 +126,7 @@ async def devrev_question_answers_update( question: New question text. answer: New answer text. """ + validate_don_id(id, "qa", "devrev_question_answers_update") app = ctx.request_context.lifespan_context try: request = QuestionAnswersUpdateRequest(id=id, question=question, answer=answer) @@ -142,6 +145,7 @@ async def devrev_question_answers_delete( Args: id: The question answer ID to delete. """ + validate_don_id(id, "qa", "devrev_question_answers_delete") app = ctx.request_context.lifespan_context try: request = QuestionAnswersDeleteRequest(id=id) diff --git a/src/devrev_mcp/tools/rev_orgs.py b/src/devrev_mcp/tools/rev_orgs.py new file mode 100644 index 0000000..38d1b01 --- /dev/null +++ b/src/devrev_mcp/tools/rev_orgs.py @@ -0,0 +1,158 @@ +"""MCP tools for DevRev rev org operations.""" + +from __future__ import annotations + +import logging +from typing import Any + +from mcp.server.fastmcp import Context + +from devrev.exceptions import DevRevError +from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id +from devrev_mcp.utils.errors import format_devrev_error +from devrev_mcp.utils.formatting import serialize_model, serialize_models +from devrev_mcp.utils.pagination import clamp_page_size, paginated_response + +logger = logging.getLogger(__name__) + + +@mcp.tool() +async def devrev_rev_orgs_list( + ctx: Context[Any, Any, Any], + account: list[str] | None = None, + display_name: list[str] | None = None, + domains: list[str] | None = None, + owned_by: list[str] | None = None, + cursor: str | None = None, + limit: int | None = None, +) -> dict[str, Any]: + """List DevRev rev orgs. + + Args: + account: Filter by account ID(s). + display_name: Filter by rev org display name(s). + domains: Filter by domain(s). + owned_by: Filter by owner user ID(s). + cursor: Pagination cursor from a previous response. + limit: Maximum number of items to return (default: 25, max: 100). + """ + app = ctx.request_context.lifespan_context + try: + response = await app.get_client().rev_orgs.list( + account=account, + display_name=display_name, + domains=domains, + owned_by=owned_by, + cursor=cursor, + limit=clamp_page_size( + limit, default=app.config.default_page_size, maximum=app.config.max_page_size + ), + ) + items = serialize_models(response.rev_orgs) + return paginated_response(items, next_cursor=response.next_cursor, total_label="rev_orgs") + except DevRevError as e: + raise RuntimeError(format_devrev_error(e)) from e + + +@mcp.tool() +async def devrev_rev_orgs_get( + ctx: Context[Any, Any, Any], + id: str, +) -> dict[str, Any]: + """Get a DevRev rev org by ID. + + Args: + id: The rev org ID. + """ + validate_don_id(id, "revo", "devrev_rev_orgs_get") + app = ctx.request_context.lifespan_context + try: + rev_org = await app.get_client().rev_orgs.get(id) + return serialize_model(rev_org) + except DevRevError as e: + raise RuntimeError(format_devrev_error(e)) from e + + +# Destructive tools (only registered when enabled) +if _config.enable_destructive_tools: + + @mcp.tool() + async def devrev_rev_orgs_create( + ctx: Context[Any, Any, Any], + display_name: str, + account: str, + description: str | None = None, + external_ref: str | None = None, + tier: str | None = None, + ) -> dict[str, Any]: + """Create a new rev org. + + Args: + display_name: Display name for the rev org. + account: Parent account ID. + description: Rev org description. + external_ref: External reference identifier. + tier: Rev org tier. + """ + app = ctx.request_context.lifespan_context + try: + rev_org = await app.get_client().rev_orgs.create( + display_name=display_name, + account=account, + description=description, + external_ref=external_ref, + tier=tier, + ) + return serialize_model(rev_org) + except DevRevError as e: + raise RuntimeError(format_devrev_error(e)) from e + + @mcp.tool() + async def devrev_rev_orgs_update( + ctx: Context[Any, Any, Any], + id: str, + display_name: str | None = None, + description: str | None = None, + tier: str | None = None, + ) -> dict[str, Any]: + """Update an existing rev org. + + Only provided fields will be updated; others remain unchanged. + + Args: + id: The rev org ID to update. + display_name: New display name. + description: New description. + tier: New tier. + """ + validate_don_id(id, "revo", "devrev_rev_orgs_update") + app = ctx.request_context.lifespan_context + try: + rev_org = await app.get_client().rev_orgs.update( + id, + display_name=display_name, + description=description, + tier=tier, + ) + return serialize_model(rev_org) + except DevRevError as e: + raise RuntimeError(format_devrev_error(e)) from e + + @mcp.tool() + async def devrev_rev_orgs_delete( + ctx: Context[Any, Any, Any], + id: str, + ) -> dict[str, Any]: + """Delete a rev org. + + Args: + id: The rev org ID to delete. + """ + validate_don_id(id, "revo", "devrev_rev_orgs_delete") + app = ctx.request_context.lifespan_context + try: + await app.get_client().rev_orgs.delete(id) + return {"deleted": True, "id": id} + except DevRevError as e: + raise RuntimeError(format_devrev_error(e)) from e diff --git a/src/devrev_mcp/tools/slas.py b/src/devrev_mcp/tools/slas.py index 8a72fbe..7f60970 100644 --- a/src/devrev_mcp/tools/slas.py +++ b/src/devrev_mcp/tools/slas.py @@ -18,6 +18,7 @@ SlaTrackerStatus, ) from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size @@ -62,6 +63,7 @@ async def devrev_slas_get( Args: id: SLA ID (e.g., "don:core:dvrv-us-1:devo/1:sla/123"). """ + validate_don_id(id, "sla", "devrev_slas_get") app = ctx.request_context.lifespan_context try: request = SlasGetRequest(id=id) @@ -110,6 +112,7 @@ async def devrev_slas_update( name: New SLA name. description: New SLA description. """ + validate_don_id(id, "sla", "devrev_slas_update") app = ctx.request_context.lifespan_context try: request = SlasUpdateRequest(id=id, name=name, description=description) @@ -130,6 +133,7 @@ async def devrev_slas_transition( id: SLA ID to transition. status: New status (draft, published, archived, active, paused, breached, completed). """ + validate_don_id(id, "sla", "devrev_slas_transition") app = ctx.request_context.lifespan_context try: # Try SlaStatus first, then SlaTrackerStatus, then use raw string diff --git a/src/devrev_mcp/tools/tags.py b/src/devrev_mcp/tools/tags.py index 995d288..53343b4 100644 --- a/src/devrev_mcp/tools/tags.py +++ b/src/devrev_mcp/tools/tags.py @@ -16,6 +16,7 @@ TagsUpdateRequest, ) from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size @@ -67,6 +68,7 @@ async def devrev_tags_get(ctx: Context[Any, Any, Any], id: str) -> dict[str, Any Raises: RuntimeError: If the DevRev API request fails. """ + validate_don_id(id, "tag", "devrev_tags_get") app = ctx.request_context.lifespan_context try: request = TagsGetRequest(id=id) @@ -123,6 +125,7 @@ async def devrev_tags_update( Raises: RuntimeError: If the DevRev API request fails. """ + validate_don_id(id, "tag", "devrev_tags_update") app = ctx.request_context.lifespan_context try: request = TagsUpdateRequest(id=id, name=name, description=description) @@ -144,6 +147,7 @@ async def devrev_tags_delete(ctx: Context[Any, Any, Any], id: str) -> dict[str, Raises: RuntimeError: If the DevRev API request fails. """ + validate_don_id(id, "tag", "devrev_tags_delete") app = ctx.request_context.lifespan_context try: request = TagsDeleteRequest(id=id) diff --git a/src/devrev_mcp/tools/timeline.py b/src/devrev_mcp/tools/timeline.py index afcbea2..30c05e0 100644 --- a/src/devrev_mcp/tools/timeline.py +++ b/src/devrev_mcp/tools/timeline.py @@ -17,6 +17,7 @@ TimelineEntryType, ) from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size @@ -64,6 +65,7 @@ async def devrev_timeline_get( Args: id: Timeline entry ID (e.g., "don:core:dvrv-us-1:devo/1:timeline_entry/123"). """ + validate_don_id(id, "timeline_entry", "devrev_timeline_get") app = ctx.request_context.lifespan_context try: request = TimelineEntriesGetRequest(id=id) @@ -120,6 +122,7 @@ async def devrev_timeline_update( id: Timeline entry ID to update. body: New entry content/body text. """ + validate_don_id(id, "timeline_entry", "devrev_timeline_update") app = ctx.request_context.lifespan_context try: request = TimelineEntriesUpdateRequest(id=id, body=body) @@ -138,6 +141,7 @@ async def devrev_timeline_delete( Args: id: Timeline entry ID to delete. """ + validate_don_id(id, "timeline_entry", "devrev_timeline_delete") app = ctx.request_context.lifespan_context try: request = TimelineEntriesDeleteRequest(id=id) diff --git a/src/devrev_mcp/tools/users.py b/src/devrev_mcp/tools/users.py index 111553a..44eedf3 100644 --- a/src/devrev_mcp/tools/users.py +++ b/src/devrev_mcp/tools/users.py @@ -10,6 +10,7 @@ from devrev.exceptions import DevRevError from devrev.models.dev_users import DevUserState from devrev_mcp.server import mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size, paginated_response @@ -60,6 +61,7 @@ async def devrev_dev_users_get( Args: id: The dev user ID. """ + validate_don_id(id, "devu", "devrev_dev_users_get") app = ctx.request_context.lifespan_context try: user = await app.get_client().dev_users.get(id) @@ -110,6 +112,7 @@ async def devrev_rev_users_get( Args: id: The rev user ID. """ + validate_don_id(id, "revu", "devrev_rev_users_get") app = ctx.request_context.lifespan_context try: user = await app.get_client().rev_users.get(id) diff --git a/src/devrev_mcp/tools/works.py b/src/devrev_mcp/tools/works.py index 038f5c9..58876d6 100644 --- a/src/devrev_mcp/tools/works.py +++ b/src/devrev_mcp/tools/works.py @@ -10,6 +10,7 @@ from devrev.exceptions import DevRevError from devrev.models.works import IssuePriority, TicketSeverity, WorkType from devrev_mcp.server import _config, mcp +from devrev_mcp.utils.don_id import validate_don_id from devrev_mcp.utils.errors import format_devrev_error from devrev_mcp.utils.formatting import serialize_model, serialize_models from devrev_mcp.utils.pagination import clamp_page_size, paginated_response @@ -72,6 +73,7 @@ async def devrev_works_get( Args: id: The work item ID (e.g., don:core:dvrv-us-1:devo/1:issue/123). """ + validate_don_id(id, ["work", "ticket", "issue"], "devrev_works_get") app = ctx.request_context.lifespan_context try: work = await app.get_client().works.get(id) @@ -171,6 +173,7 @@ async def devrev_works_update( priority: New issue priority: P0, P1, P2, P3. severity: New ticket severity: BLOCKER, HIGH, MEDIUM, LOW. """ + validate_don_id(id, ["work", "ticket", "issue"], "devrev_works_update") app = ctx.request_context.lifespan_context try: # Convert enum strings, catching invalid values @@ -216,6 +219,7 @@ async def devrev_works_delete( Args: id: The work item ID to delete. """ + validate_don_id(id, ["work", "ticket", "issue"], "devrev_works_delete") app = ctx.request_context.lifespan_context try: await app.get_client().works.delete(id) diff --git a/src/devrev_mcp/utils/don_id.py b/src/devrev_mcp/utils/don_id.py new file mode 100644 index 0000000..abd7000 --- /dev/null +++ b/src/devrev_mcp/utils/don_id.py @@ -0,0 +1,177 @@ +"""DON ID validation utilities for DevRev MCP tools. + +DevRev uses DON (DevRev Object Notation) IDs to uniquely identify objects. +The format is: ``don::::/`` + +Examples: + - ``don:core:dvrv-us-1:devo/1:account/123`` + - ``don:identity:dvrv-us-1:devo/11Ca9baGrM:revo/CHjPMe8K`` + - ``don:core:dvrv-us-1:devo/1:ticket/456`` + +This module provides helpers to parse DON IDs and validate that the correct +type of ID is passed to each MCP tool, producing actionable error messages +instead of opaque "Bad Request" responses from the DevRev API. +""" + +from __future__ import annotations + +DON_TYPE_MAP: dict[str, str] = { + "account": "account", + "revo": "rev_org", + "ticket": "ticket", + "issue": "issue", + "part": "part", + "devu": "dev_user", + "revu": "rev_user", + "work": "work", + "conversation": "conversation", + "tag": "tag", + "sla": "sla", + "group": "group", + "link": "link", + "article": "article", + "incident": "incident", + "engagement": "engagement", + "qa": "question_answer", + "question_answer": "question_answer", + "timeline_entry": "timeline_entry", +} + +TOOL_SUGGESTIONS: dict[str, str] = { + "account": "devrev_accounts_get", + "revo": "devrev_rev_orgs_get", + "ticket": "devrev_works_get", + "issue": "devrev_works_get", + "part": "devrev_parts_get", + "devu": "devrev_dev_users_get", + "revu": "devrev_rev_users_get", + "work": "devrev_works_get", + "conversation": "devrev_conversations_get", + "tag": "devrev_tags_get", + "sla": "devrev_slas_get", + "group": "devrev_groups_get", + "link": "devrev_links_get", + "article": "devrev_articles_get", + "incident": "devrev_incidents_get", + "engagement": "devrev_engagements_get", + "qa": "devrev_question_answers_get", + "question_answer": "devrev_question_answers_get", + "timeline_entry": "devrev_timeline_get", +} + + +def parse_don_type(don_id: str) -> str | None: + """Parse a DON ID string and extract the object type segment. + + DON IDs follow the format ``don::::/``. + The type is the portion before the ``/`` in the final colon-delimited + segment. + + Args: + don_id: The DON ID string to parse. + + Returns: + The type segment string (e.g. ``"account"``, ``"revo"``) when the + input is a valid DON ID, or ``None`` if the string cannot be parsed + (empty string, display ID such as ``ACC-12345``, or any value that + does not start with ``don:``). + + Examples: + >>> parse_don_type("don:core:dvrv-us-1:devo/1:account/123") + 'account' + >>> parse_don_type("don:identity:dvrv-us-1:devo/11Ca9:revo/CHj") + 'revo' + >>> parse_don_type("ACC-12345") + >>> parse_don_type("") + """ + if not don_id or not don_id.startswith("don:"): + return None + + # Split on ":" — a valid DON ID has at least 5 segments. + parts = don_id.split(":") + if len(parts) < 5: + return None + + # The last segment looks like "/". + last_segment = parts[-1] + slash_index = last_segment.find("/") + if slash_index <= 0: + return None + + return last_segment[:slash_index] + + +def validate_don_id( + don_id: str, + expected_types: str | list[str], + tool_name: str, +) -> None: + """Validate that a DON ID matches the expected object type(s). + + This is a soft check: if the provided value does not look like a DON ID + (i.e. it does not start with ``don:``), the function returns silently so + that display IDs and other non-DON identifiers are accepted without error. + + Args: + don_id: The ID value supplied by the caller. + expected_types: The DON type segment(s) that are valid for the + operation being performed. Either a single string such as + ``"account"`` or a list such as ``["ticket", "issue", "work"]``. + tool_name: The name of the MCP tool performing the validation, used + in the error message to aid the caller. + + Raises: + RuntimeError: When ``don_id`` is a DON ID whose type segment does not + match any of the ``expected_types``. ``RuntimeError`` is used + (rather than ``ValueError``) so that the exception is caught by + the ``except … RuntimeError`` blocks already present in MCP tool + handlers, ensuring callers see a friendly error message. + + Examples: + >>> validate_don_id("don:core:dvrv-us-1:devo/1:account/1", "account", "devrev_accounts_get") + >>> validate_don_id("ACC-123", "account", "devrev_accounts_get") # silent — not a DON ID + >>> validate_don_id( + ... "don:identity:dvrv-us-1:devo/1:revo/CHj", + ... "account", + ... "devrev_accounts_get", + ... ) + Traceback (most recent call last): + ... + RuntimeError: The provided ID 'don:identity:dvrv-us-1:devo/1:revo/CHj' appears to be + a rev_org ID, but devrev_accounts_get expects an account ID. + Try using devrev_rev_orgs_get instead. + """ + if not don_id.startswith("don:"): + return + + actual_type = parse_don_type(don_id) + + if isinstance(expected_types, str): + expected_types = [expected_types] + + if actual_type in expected_types: + return + + # Build a human-readable description of what was received. + if actual_type is None: + actual_description = "an unrecognised DON ID" + else: + friendly_name = DON_TYPE_MAP.get(actual_type, actual_type) + actual_description = f"a {friendly_name} ID" + + # Build a human-readable description of what was expected. + expected_friendly = [DON_TYPE_MAP.get(t, t) for t in expected_types] + if len(expected_friendly) == 1: + expected_description = f"an {expected_friendly[0]} ID" + else: + expected_description = "one of: " + ", ".join(f"{n} ID" for n in expected_friendly) + + # Suggest the correct tool for the supplied type, if known. + suggestion = "" + if actual_type and actual_type in TOOL_SUGGESTIONS: + suggestion = f" Try using {TOOL_SUGGESTIONS[actual_type]} instead." + + raise RuntimeError( + f"The provided ID '{don_id}' appears to be {actual_description}, " + f"but {tool_name} expects {expected_description}.{suggestion}" + ) diff --git a/tests/unit/test_don_id_validation.py b/tests/unit/test_don_id_validation.py new file mode 100644 index 0000000..81a371b --- /dev/null +++ b/tests/unit/test_don_id_validation.py @@ -0,0 +1,165 @@ +"""Unit tests for the DON ID validation utilities.""" + +import pytest + +from devrev_mcp.utils.don_id import parse_don_type, validate_don_id + + +class TestParseDonType: + """Tests for parse_don_type().""" + + @pytest.mark.parametrize( + "don_id, expected", + [ + ("don:core:dvrv-us-1:devo/1:account/123", "account"), + ("don:core:dvrv-us-1:devo/1:revo/abc", "revo"), + ("don:core:dvrv-us-1:devo/1:ticket/456", "ticket"), + ("don:identity:dvrv-us-1:devo/1:devu/789", "devu"), + ], + ) + def test_parse_don_type_returns_correct_type_for_valid_ids( + self, don_id: str, expected: str + ) -> None: + """Verify that valid DON IDs return the correct type segment.""" + assert parse_don_type(don_id) == expected + + def test_parse_don_type_empty_string_returns_none(self) -> None: + """Empty string is not a DON ID — should return None.""" + assert parse_don_type("") is None + + def test_parse_don_type_display_id_returns_none(self) -> None: + """Display IDs like ACC-12345 are not DON IDs — should return None.""" + assert parse_don_type("ACC-12345") is None + + def test_parse_don_type_random_string_returns_none(self) -> None: + """Arbitrary strings that don't start with 'don:' return None.""" + assert parse_don_type("some-random-id") is None + + def test_parse_don_type_too_few_segments_returns_none(self) -> None: + """A DON-prefixed string with fewer than 5 colon-segments returns None.""" + # Only 4 segments: don, core, dvrv-us-1, account/123 + assert parse_don_type("don:core:dvrv-us-1:account/123") is None + + def test_parse_don_type_last_segment_without_slash_returns_none(self) -> None: + """Last segment must contain '/'; without it the type cannot be parsed.""" + assert parse_don_type("don:core:dvrv-us-1:devo/1:account") is None + + def test_parse_don_type_identity_domain_revo(self) -> None: + """DON IDs using the identity domain should parse correctly.""" + don_id = "don:identity:dvrv-us-1:devo/11Ca9baGrM:revo/CHjPMe8K" + assert parse_don_type(don_id) == "revo" + + +class TestValidateDonId: + """Tests for validate_don_id().""" + + # ------------------------------------------------------------------ # + # Happy-path tests # + # ------------------------------------------------------------------ # + + def test_validate_don_id_correct_type_does_not_raise(self) -> None: + """Matching type should pass without raising.""" + validate_don_id( + "don:core:dvrv-us-1:devo/1:account/123", + "account", + "devrev_accounts_get", + ) + + def test_validate_don_id_non_don_id_passes_silently(self) -> None: + """Non-DON IDs (e.g. display IDs) pass through without error.""" + validate_don_id("ACC-12345", "account", "devrev_accounts_get") + + def test_validate_don_id_empty_string_passes_silently(self) -> None: + """Empty string does not start with 'don:' and passes silently.""" + validate_don_id("", "account", "devrev_accounts_get") + + def test_validate_don_id_list_one_matching_type_passes(self) -> None: + """A ticket ID is valid when expected_types includes 'ticket'.""" + validate_don_id( + "don:core:dvrv-us-1:devo/1:ticket/456", + ["work", "ticket", "issue"], + "devrev_works_get", + ) + + # ------------------------------------------------------------------ # + # Error-raising tests # + # ------------------------------------------------------------------ # + + def test_validate_don_id_wrong_type_raises_value_error(self) -> None: + """A DON ID whose type doesn't match expected_types raises RuntimeError.""" + with pytest.raises(RuntimeError): + validate_don_id( + "don:identity:dvrv-us-1:devo/1:revo/CHj", + "account", + "devrev_accounts_get", + ) + + def test_validate_don_id_error_message_contains_tool_name(self) -> None: + """The error message names the tool that rejected the ID.""" + with pytest.raises(RuntimeError, match="devrev_accounts_get"): + validate_don_id( + "don:identity:dvrv-us-1:devo/1:revo/CHj", + "account", + "devrev_accounts_get", + ) + + def test_validate_don_id_error_message_contains_actual_type_name(self) -> None: + """The error message includes the friendly name of the actual type.""" + with pytest.raises(RuntimeError, match="rev_org"): + validate_don_id( + "don:identity:dvrv-us-1:devo/1:revo/CHj", + "account", + "devrev_accounts_get", + ) + + def test_validate_don_id_error_message_contains_expected_type_name(self) -> None: + """The error message includes the friendly name of the expected type.""" + with pytest.raises(RuntimeError, match="account"): + validate_don_id( + "don:identity:dvrv-us-1:devo/1:revo/CHj", + "account", + "devrev_accounts_get", + ) + + def test_validate_don_id_error_message_suggests_correct_tool(self) -> None: + """The error message suggests the tool that handles the supplied type.""" + with pytest.raises(RuntimeError, match="devrev_rev_orgs_get"): + validate_don_id( + "don:identity:dvrv-us-1:devo/1:revo/CHj", + "account", + "devrev_accounts_get", + ) + + def test_validate_don_id_list_no_matching_type_raises(self) -> None: + """An account ID is rejected when none of the expected types match.""" + with pytest.raises(RuntimeError): + validate_don_id( + "don:core:dvrv-us-1:devo/1:account/123", + ["work", "ticket", "issue"], + "devrev_works_get", + ) + + def test_validate_don_id_unknown_parseable_type_raises_with_raw_type_name( + self, + ) -> None: + """A DON ID whose type parses but isn't in DON_TYPE_MAP raises RuntimeError + with the raw type name in the message.""" + with pytest.raises(RuntimeError, match="unknowntype"): + validate_don_id( + "don:core:dvrv-us-1:devo/1:unknowntype/999", + "account", + "devrev_accounts_get", + ) + + def test_validate_don_id_unparseable_don_id_raises_unrecognised_message( + self, + ) -> None: + """A 'don:'-prefixed string whose last segment lacks '/' cannot be parsed; + the error message describes it as 'unrecognised DON ID'.""" + with pytest.raises(RuntimeError, match="unrecognised DON ID"): + validate_don_id( + # 5 colon-segments but the last one has no '/', so parse_don_type → None + "don:core:dvrv-us-1:devo/1:noslash", + "account", + "devrev_accounts_get", + ) diff --git a/tests/unit/test_rev_orgs.py b/tests/unit/test_rev_orgs.py new file mode 100644 index 0000000..bfa53b0 --- /dev/null +++ b/tests/unit/test_rev_orgs.py @@ -0,0 +1,351 @@ +"""Unit tests for Rev Orgs models and service.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import httpx +import pytest +from pydantic import ValidationError + +from devrev.models.rev_orgs import ( + RevOrg, + RevOrgsCreateRequest, + RevOrgsDeleteRequest, + RevOrgsGetRequest, + RevOrgsGetResponse, + RevOrgsListRequest, + RevOrgsListResponse, + RevOrgSummary, + RevOrgsUpdateRequest, +) +from devrev.services.rev_orgs import RevOrgsService + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def create_mock_response(data: dict[str, Any], status_code: int = 200) -> MagicMock: + """Create a mock HTTP response.""" + response = MagicMock(spec=httpx.Response) + response.status_code = status_code + response.is_success = 200 <= status_code < 300 + response.content = b"{}" # truthy so _post reads response.json() + response.json.return_value = data + return response + + +@pytest.fixture +def mock_http_client() -> MagicMock: + """Sync mock HTTP client.""" + return MagicMock() + + +@pytest.fixture +def sample_rev_org_data() -> dict[str, Any]: + """Minimal Rev Org data matching the API response shape.""" + return { + "id": "don:core:dvrv-us-1:devo/1:revo/123", + "display_id": "REV-123", + "display_name": "Acme Corp", + } + + +@pytest.fixture +def full_rev_org_data(sample_rev_org_data: dict[str, Any]) -> dict[str, Any]: + """Rev Org data with all optional fields populated.""" + return { + **sample_rev_org_data, + "description": "Primary org for Acme", + "account": { + "id": "don:core:dvrv-us-1:devo/1:account/42", + "display_name": "Acme Account", + }, + "created_date": "2024-01-15T10:00:00Z", + "modified_date": "2024-06-01T08:30:00Z", + "created_by": {"id": "don:identity:dvrv-us-1:devo/1:devu/7"}, + "modified_by": {"id": "don:identity:dvrv-us-1:devo/1:devu/7"}, + "domain": "acme.example.com", + "external_ref": "ext-acme-001", + "external_refs": ["ext-acme-001", "crm-9999"], + "custom_fields": {"key": "value"}, + "sub_type": "enterprise", + "tier": "gold", + "artifacts": ["don:core:artifact:1"], + } + + +# --------------------------------------------------------------------------- +# Model tests +# --------------------------------------------------------------------------- + + +class TestRevOrgModels: + """Tests for Rev Org Pydantic models.""" + + def test_rev_org_model_creation(self, sample_rev_org_data: dict[str, Any]) -> None: + """RevOrg can be created with only the required id field.""" + rev_org = RevOrg.model_validate({"id": "don:core:dvrv-us-1:devo/1:revo/1"}) + + assert rev_org.id == "don:core:dvrv-us-1:devo/1:revo/1" + assert rev_org.display_name is None + assert rev_org.account is None + + def test_rev_org_model_all_fields(self, full_rev_org_data: dict[str, Any]) -> None: + """RevOrg parses all optional fields correctly.""" + rev_org = RevOrg.model_validate(full_rev_org_data) + + assert rev_org.id == full_rev_org_data["id"] + assert rev_org.display_name == "Acme Corp" + assert rev_org.description == "Primary org for Acme" + assert rev_org.account is not None + assert rev_org.account.id == "don:core:dvrv-us-1:devo/1:account/42" + assert rev_org.domain == "acme.example.com" + assert rev_org.external_ref == "ext-acme-001" + assert rev_org.external_refs == ["ext-acme-001", "crm-9999"] + assert rev_org.custom_fields == {"key": "value"} + assert rev_org.tier == "gold" + assert rev_org.artifacts == ["don:core:artifact:1"] + assert rev_org.created_date is not None + assert rev_org.modified_date is not None + + def test_rev_org_summary_model(self) -> None: + """RevOrgSummary parses id and optional display fields.""" + summary = RevOrgSummary.model_validate( + { + "id": "don:core:dvrv-us-1:devo/1:revo/99", + "display_id": "REV-99", + "display_name": "Summary Org", + } + ) + + assert summary.id == "don:core:dvrv-us-1:devo/1:revo/99" + assert summary.display_id == "REV-99" + assert summary.display_name == "Summary Org" + + def test_rev_orgs_get_request(self) -> None: + """RevOrgsGetRequest serializes id correctly.""" + req = RevOrgsGetRequest(id="don:core:dvrv-us-1:devo/1:revo/5") + data = req.model_dump(exclude_none=True) + + assert data["id"] == "don:core:dvrv-us-1:devo/1:revo/5" + + def test_rev_orgs_list_request_defaults(self) -> None: + """RevOrgsListRequest has all-None defaults.""" + req = RevOrgsListRequest() + data = req.model_dump(exclude_none=True) + + assert data == {} + + @pytest.mark.parametrize( + "account_ids, limit", + [ + (["don:core:account:1"], 10), + (["don:core:account:1", "don:core:account:2"], 50), + ], + ) + def test_rev_orgs_list_request_with_filters(self, account_ids: list[str], limit: int) -> None: + """RevOrgsListRequest serializes account and limit filters.""" + req = RevOrgsListRequest(account=account_ids, limit=limit) + data = req.model_dump(exclude_none=True) + + assert data["account"] == account_ids + assert data["limit"] == limit + + def test_rev_orgs_create_request(self) -> None: + """RevOrgsCreateRequest accepts required display_name and account.""" + req = RevOrgsCreateRequest( + display_name="New Org", + account="don:core:dvrv-us-1:devo/1:account/1", + ) + + assert req.display_name == "New Org" + assert req.account == "don:core:dvrv-us-1:devo/1:account/1" + assert req.description is None + + def test_rev_orgs_create_request_validation(self) -> None: + """RevOrgsCreateRequest enforces display_name min_length=1.""" + with pytest.raises(ValidationError): + RevOrgsCreateRequest( + display_name="", + account="don:core:dvrv-us-1:devo/1:account/1", + ) + + def test_rev_orgs_update_request(self) -> None: + """RevOrgsUpdateRequest requires id.""" + req = RevOrgsUpdateRequest(id="don:core:dvrv-us-1:devo/1:revo/7", display_name="Renamed") + assert req.id == "don:core:dvrv-us-1:devo/1:revo/7" + assert req.display_name == "Renamed" + + with pytest.raises(ValidationError): + RevOrgsUpdateRequest() # type: ignore[call-arg] + + def test_rev_orgs_delete_request(self) -> None: + """RevOrgsDeleteRequest requires id.""" + req = RevOrgsDeleteRequest(id="don:core:dvrv-us-1:devo/1:revo/8") + assert req.id == "don:core:dvrv-us-1:devo/1:revo/8" + + with pytest.raises(ValidationError): + RevOrgsDeleteRequest() # type: ignore[call-arg] + + def test_rev_orgs_get_response(self, sample_rev_org_data: dict[str, Any]) -> None: + """RevOrgsGetResponse deserializes a get response.""" + response = RevOrgsGetResponse.model_validate({"rev_org": sample_rev_org_data}) + + assert isinstance(response.rev_org, RevOrg) + assert response.rev_org.id == sample_rev_org_data["id"] + assert response.rev_org.display_name == "Acme Corp" + + def test_rev_orgs_list_response(self, sample_rev_org_data: dict[str, Any]) -> None: + """RevOrgsListResponse deserializes list and pagination cursor.""" + response = RevOrgsListResponse.model_validate( + { + "rev_orgs": [ + sample_rev_org_data, + {**sample_rev_org_data, "id": "don:core:revo/456"}, + ], + "next_cursor": "cursor-abc", + } + ) + + assert len(response.rev_orgs) == 2 + assert all(isinstance(r, RevOrg) for r in response.rev_orgs) + assert response.next_cursor == "cursor-abc" + + +# --------------------------------------------------------------------------- +# Service tests +# --------------------------------------------------------------------------- + + +class TestRevOrgsService: + """Tests for RevOrgsService (mocked HTTP).""" + + def test_rev_orgs_service_get( + self, + mock_http_client: MagicMock, + sample_rev_org_data: dict[str, Any], + ) -> None: + """get() calls /rev-orgs.get and returns a RevOrg.""" + mock_http_client.post.return_value = create_mock_response({"rev_org": sample_rev_org_data}) + + service = RevOrgsService(mock_http_client) + result = service.get("don:core:dvrv-us-1:devo/1:revo/123") + + assert isinstance(result, RevOrg) + assert result.id == sample_rev_org_data["id"] + mock_http_client.post.assert_called_once() + call_endpoint = mock_http_client.post.call_args[0][0] + assert call_endpoint == "/rev-orgs.get" + + def test_rev_orgs_service_list( + self, + mock_http_client: MagicMock, + sample_rev_org_data: dict[str, Any], + ) -> None: + """list() calls /rev-orgs.list and returns RevOrgsListResponse.""" + mock_http_client.post.return_value = create_mock_response( + {"rev_orgs": [sample_rev_org_data]} + ) + + service = RevOrgsService(mock_http_client) + result = service.list() + + assert isinstance(result, RevOrgsListResponse) + assert len(result.rev_orgs) == 1 + assert isinstance(result.rev_orgs[0], RevOrg) + mock_http_client.post.assert_called_once() + call_endpoint = mock_http_client.post.call_args[0][0] + assert call_endpoint == "/rev-orgs.list" + + def test_rev_orgs_service_list_with_filters( + self, + mock_http_client: MagicMock, + sample_rev_org_data: dict[str, Any], + ) -> None: + """list() passes account and limit filters to the endpoint.""" + mock_http_client.post.return_value = create_mock_response( + {"rev_orgs": [sample_rev_org_data]} + ) + + service = RevOrgsService(mock_http_client) + result = service.list( + account=["don:core:dvrv-us-1:devo/1:account/1"], + limit=25, + ) + + assert isinstance(result, RevOrgsListResponse) + mock_http_client.post.assert_called_once() + call_kwargs = mock_http_client.post.call_args[1] + assert call_kwargs["data"]["account"] == ["don:core:dvrv-us-1:devo/1:account/1"] + assert call_kwargs["data"]["limit"] == 25 + + def test_rev_orgs_service_create( + self, + mock_http_client: MagicMock, + sample_rev_org_data: dict[str, Any], + ) -> None: + """create() calls /rev-orgs.create with the correct payload.""" + mock_http_client.post.return_value = create_mock_response({"rev_org": sample_rev_org_data}) + + service = RevOrgsService(mock_http_client) + result = service.create( + display_name="Acme Corp", + account="don:core:dvrv-us-1:devo/1:account/42", + description="Primary org", + tier="gold", + ) + + assert isinstance(result, RevOrg) + assert result.id == sample_rev_org_data["id"] + mock_http_client.post.assert_called_once() + call_endpoint = mock_http_client.post.call_args[0][0] + assert call_endpoint == "/rev-orgs.create" + payload = mock_http_client.post.call_args[1]["data"] + assert payload["display_name"] == "Acme Corp" + assert payload["account"] == "don:core:dvrv-us-1:devo/1:account/42" + assert payload["description"] == "Primary org" + assert payload["tier"] == "gold" + + def test_rev_orgs_service_update( + self, + mock_http_client: MagicMock, + sample_rev_org_data: dict[str, Any], + ) -> None: + """update() calls /rev-orgs.update with id and changed fields.""" + updated = {**sample_rev_org_data, "display_name": "Acme Corp Renamed"} + mock_http_client.post.return_value = create_mock_response({"rev_org": updated}) + + service = RevOrgsService(mock_http_client) + result = service.update( + "don:core:dvrv-us-1:devo/1:revo/123", + display_name="Acme Corp Renamed", + ) + + assert isinstance(result, RevOrg) + assert result.display_name == "Acme Corp Renamed" + mock_http_client.post.assert_called_once() + call_endpoint = mock_http_client.post.call_args[0][0] + assert call_endpoint == "/rev-orgs.update" + payload = mock_http_client.post.call_args[1]["data"] + assert payload["id"] == "don:core:dvrv-us-1:devo/1:revo/123" + assert payload["display_name"] == "Acme Corp Renamed" + + def test_rev_orgs_service_delete( + self, + mock_http_client: MagicMock, + ) -> None: + """delete() calls /rev-orgs.delete and returns None.""" + mock_http_client.post.return_value = create_mock_response({}) + + service = RevOrgsService(mock_http_client) + result = service.delete("don:core:dvrv-us-1:devo/1:revo/123") + + assert result is None + mock_http_client.post.assert_called_once() + call_endpoint = mock_http_client.post.call_args[0][0] + assert call_endpoint == "/rev-orgs.delete" + payload = mock_http_client.post.call_args[1]["data"] + assert payload["id"] == "don:core:dvrv-us-1:devo/1:revo/123" diff --git a/uv.lock b/uv.lock index f6847e5..28d6783 100644 --- a/uv.lock +++ b/uv.lock @@ -399,61 +399,61 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]]