Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .oagen-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@
"src/workos/common/models/data_integrations_vend_credentials_request_connection_owner.py",
"src/workos/common/models/directory_group.py",
"src/workos/common/models/directory_state.py",
"src/workos/common/models/directory_sync_rate_limit_error.py",
"src/workos/common/models/directory_type.py",
"src/workos/common/models/directory_user.py",
"src/workos/common/models/directory_user_email.py",
Expand Down Expand Up @@ -637,6 +638,7 @@
"src/workos/directory_sync/models/directory.py",
"src/workos/directory_sync/models/directory_metadata.py",
"src/workos/directory_sync/models/directory_metadata_user.py",
"src/workos/directory_sync/models/directory_sync_response.py",
"src/workos/directory_sync/models/directory_user_with_groups.py",
"src/workos/directory_sync/models/directory_user_with_groups_email.py",
"src/workos/events/__init__.py",
Expand Down Expand Up @@ -1167,6 +1169,8 @@
"tests/fixtures/directory_group.json",
"tests/fixtures/directory_metadata.json",
"tests/fixtures/directory_metadata_user.json",
"tests/fixtures/directory_sync_rate_limit_error.json",
"tests/fixtures/directory_sync_response.json",
"tests/fixtures/directory_user.json",
"tests/fixtures/directory_user_email.json",
"tests/fixtures/directory_user_with_groups.json",
Expand Down Expand Up @@ -2633,6 +2637,10 @@
"GET /audit_logs/exports/{auditLogExportId}": {
"sdkMethod": "get_export",
"service": "audit_logs"
},
"POST /directories/{id}/sync": {
"sdkMethod": "sync_directory",
"service": "directory_sync"
}
}
}
3 changes: 3 additions & 0 deletions src/workos/common/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,9 @@
)
from .directory_group import DirectoryGroup as DirectoryGroup
from .directory_state import DirectoryState as DirectoryState
from .directory_sync_rate_limit_error import (
DirectorySyncRateLimitError as DirectorySyncRateLimitError,
)
from .directory_type import DirectoryType as DirectoryType
from .directory_user import DirectoryUser as DirectoryUser
from .directory_user_email import DirectoryUserEmail as DirectoryUserEmail
Expand Down
40 changes: 40 additions & 0 deletions src/workos/common/models/directory_sync_rate_limit_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# This file is auto-generated by oagen. Do not edit.

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Literal

from workos._types import _raise_deserialize_error


@dataclass(slots=True)
class DirectorySyncRateLimitError:
"""Directory Sync Rate Limit Error model."""

code: Literal["directory_sync_rate_limited"]
"""The error code identifying the type of error."""
message: str
"""A human-readable description of the error."""
retry_after_seconds: int
"""The number of seconds to wait before requesting another manual sync of this directory."""

@classmethod
def from_dict(cls, data: dict[str, Any]) -> DirectorySyncRateLimitError:
"""Deserialize from a dictionary."""
try:
return cls(
code=data.get("code", "directory_sync_rate_limited"),
message=data["message"],
retry_after_seconds=data["retry_after_seconds"],
)
except (KeyError, ValueError) as e:
_raise_deserialize_error("DirectorySyncRateLimitError", e)

def to_dict(self) -> dict[str, Any]:
"""Serialize to a dictionary."""
result: dict[str, Any] = {}
result["code"] = self.code
result["message"] = self.message
result["retry_after_seconds"] = self.retry_after_seconds
return result
68 changes: 67 additions & 1 deletion src/workos/directory_sync/_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from .._pagination import AsyncPage, SyncPage
from .._types import RequestOptions, enum_value
from .models import Directory, DirectoryUserWithGroups
from .models import Directory, DirectorySyncResponse, DirectoryUserWithGroups


class DirectorySync:
Expand Down Expand Up @@ -135,6 +135,39 @@ def delete_directory(
request_options=request_options,
)

def sync_directory(
self,
id: str,
*,
request_options: RequestOptions | None = None,
) -> DirectorySyncResponse:
"""Sync a Directory

Request an asynchronous sync from the directory provider. Currently supports Google Workspace directories in linked or validating state. Manual requests share a five-minute per-directory cooldown across the API, Dashboard, Admin Portal, and MCP. Acceptance means the request was queued, not that the sync has started or completed. A running sync prevents another request from being queued.

Args:
id: Unique identifier for the Directory.
request_options: Per-request options. Supports extra_headers, timeout, max_retries, and base_url override.

Returns:
DirectorySyncResponse

Raises:
AuthorizationError: If the request is forbidden (403).
NotFoundError: If the resource is not found (404).
ConflictError: If a conflict occurs (409).
UnprocessableEntityError: If the request data is unprocessable (422).
RateLimitExceededError: If rate limited (429).
AuthenticationError: If the API key is invalid (401).
ServerError: If the server returns a 5xx error.
"""
return self._client.request(
method="post",
path=("directories", str(id), "sync"),
model=DirectorySyncResponse,
request_options=request_options,
)

def list_groups(
self,
*,
Expand Down Expand Up @@ -434,6 +467,39 @@ async def delete_directory(
request_options=request_options,
)

async def sync_directory(
self,
id: str,
*,
request_options: RequestOptions | None = None,
) -> DirectorySyncResponse:
"""Sync a Directory

Request an asynchronous sync from the directory provider. Currently supports Google Workspace directories in linked or validating state. Manual requests share a five-minute per-directory cooldown across the API, Dashboard, Admin Portal, and MCP. Acceptance means the request was queued, not that the sync has started or completed. A running sync prevents another request from being queued.

Args:
id: Unique identifier for the Directory.
request_options: Per-request options. Supports extra_headers, timeout, max_retries, and base_url override.

Returns:
DirectorySyncResponse

Raises:
AuthorizationError: If the request is forbidden (403).
NotFoundError: If the resource is not found (404).
ConflictError: If a conflict occurs (409).
UnprocessableEntityError: If the request data is unprocessable (422).
RateLimitExceededError: If rate limited (429).
AuthenticationError: If the API key is invalid (401).
ServerError: If the server returns a 5xx error.
"""
return await self._client.request(
method="post",
path=("directories", str(id), "sync"),
model=DirectorySyncResponse,
request_options=request_options,
)

async def list_groups(
self,
*,
Expand Down
1 change: 1 addition & 0 deletions src/workos/directory_sync/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .directory import Directory as Directory
from .directory_metadata import DirectoryMetadata as DirectoryMetadata
from .directory_metadata_user import DirectoryMetadataUser as DirectoryMetadataUser
from .directory_sync_response import DirectorySyncResponse as DirectorySyncResponse
from .directory_user_with_groups import (
DirectoryUserWithGroups as DirectoryUserWithGroups,
)
Expand Down
32 changes: 32 additions & 0 deletions src/workos/directory_sync/models/directory_sync_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# This file is auto-generated by oagen. Do not edit.

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Literal

from workos._types import _raise_deserialize_error


@dataclass(slots=True)
class DirectorySyncResponse:
"""Directory Sync Response model."""

status: Literal["queued"]
"""The sync request was queued for asynchronous processing. This does not indicate that the sync has started or completed."""

@classmethod
def from_dict(cls, data: dict[str, Any]) -> DirectorySyncResponse:
"""Deserialize from a dictionary."""
try:
return cls(
status=data.get("status", "queued"),
)
except (KeyError, ValueError) as e:
_raise_deserialize_error("DirectorySyncResponse", e)

def to_dict(self) -> dict[str, Any]:
"""Serialize to a dictionary."""
result: dict[str, Any] = {}
result["status"] = self.status
return result
5 changes: 5 additions & 0 deletions tests/fixtures/directory_sync_rate_limit_error.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"code": "directory_sync_rate_limited",
"message": "Request could not be processed.",
"retry_after_seconds": 120
}
3 changes: 3 additions & 0 deletions tests/fixtures/directory_sync_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"status": "queued"
}
21 changes: 21 additions & 0 deletions tests/test_common_models_round_trip.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
ConnectionSAMLCertificateRenewedData,
ConnectionSAMLCertificateRenewedDataCertificate,
ConnectionSAMLCertificateRenewedDataConnection,
DirectorySyncRateLimitError,
DirectoryUser,
DirectoryUserEmail,
DsyncActivated,
Expand Down Expand Up @@ -479,6 +480,26 @@ def test_connect_application_m2m_preserves_nullable_fields(self):
serialized = instance.to_dict()
assert serialized["description"] is None

def test_directory_sync_rate_limit_error_round_trip(self):
data = load_fixture("directory_sync_rate_limit_error.json")
instance = DirectorySyncRateLimitError.from_dict(data)
serialized = instance.to_dict()
assert serialized == data
restored = DirectorySyncRateLimitError.from_dict(serialized)
assert restored.to_dict() == serialized

def test_directory_sync_rate_limit_error_minimal_payload(self):
data = {
"code": "directory_sync_rate_limited",
"message": "Request could not be processed.",
"retry_after_seconds": 120,
}
instance = DirectorySyncRateLimitError.from_dict(data)
serialized = instance.to_dict()
assert serialized["code"] == data["code"]
assert serialized["message"] == data["message"]
assert serialized["retry_after_seconds"] == data["retry_after_seconds"]

def test_event_context_actor_round_trip(self):
data = load_fixture("event_context_actor.json")
instance = EventContextActor.from_dict(data)
Expand Down
27 changes: 26 additions & 1 deletion tests/test_directory_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
)
from workos._pagination import AsyncPage, SyncPage
from workos.common.models import DirectoryGroup, PaginationOrder
from workos.directory_sync.models import Directory, DirectoryUserWithGroups
from workos.directory_sync.models import (
Directory,
DirectorySyncResponse,
DirectoryUserWithGroups,
)


class TestDirectorySync:
Expand Down Expand Up @@ -74,6 +78,17 @@ def test_delete_directory(self, workos, httpx_mock):
assert request.method == "DELETE"
assert request.url.path.endswith("/directories/test_id")

def test_sync_directory(self, workos, httpx_mock):
httpx_mock.add_response(
json=load_fixture("directory_sync_response.json"),
)
result = workos.directory_sync.sync_directory("test_id")
assert isinstance(result, DirectorySyncResponse)
assert result.status == "queued"
request = httpx_mock.get_request()
assert request.method == "POST"
assert request.url.path.endswith("/directories/test_id/sync")

def test_list_groups(self, workos, httpx_mock):
httpx_mock.add_response(
json=load_fixture("list_directory_group.json"),
Expand Down Expand Up @@ -303,6 +318,16 @@ async def test_delete_directory(self, async_workos, httpx_mock):
assert request.method == "DELETE"
assert request.url.path.endswith("/directories/test_id")

@pytest.mark.asyncio
async def test_sync_directory(self, async_workos, httpx_mock):
httpx_mock.add_response(json=load_fixture("directory_sync_response.json"))
result = await async_workos.directory_sync.sync_directory("test_id")
assert isinstance(result, DirectorySyncResponse)
assert result.status == "queued"
request = httpx_mock.get_request()
assert request.method == "POST"
assert request.url.path.endswith("/directories/test_id/sync")

@pytest.mark.asyncio
async def test_list_groups(self, async_workos, httpx_mock):
httpx_mock.add_response(json=load_fixture("list_directory_group.json"))
Expand Down
82 changes: 82 additions & 0 deletions tests/test_directory_sync_manual_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# @oagen-ignore-file

import pytest

from workos import (
ConflictError,
RateLimitExceededError,
ServerError,
UnprocessableEntityError,
)
from workos.directory_sync.models import DirectorySyncResponse


def test_manual_sync_accepts_202(workos, httpx_mock):
httpx_mock.add_response(status_code=202, json={"status": "queued"})

result = workos.directory_sync.sync_directory("directory_123")

assert isinstance(result, DirectorySyncResponse)
assert result.status == "queued"
sent = httpx_mock.get_request()
assert sent.method == "POST"
assert sent.url.path == "/directories/directory_123/sync"
assert sent.content == b""


@pytest.mark.asyncio
async def test_async_manual_sync_accepts_202(async_workos, httpx_mock):
httpx_mock.add_response(status_code=202, json={"status": "queued"})

result = await async_workos.directory_sync.sync_directory("directory_123")

assert isinstance(result, DirectorySyncResponse)
assert result.status == "queued"
assert httpx_mock.get_request().url.path == "/directories/directory_123/sync"


def test_manual_sync_preserves_rate_limit_details(workos, httpx_mock):
body = {
"code": "directory_sync_rate_limited",
"message": "Wait before requesting another sync.",
"retry_after_seconds": 120,
}
httpx_mock.add_response(status_code=429, json=body, headers={"Retry-After": "120"})

with pytest.raises(RateLimitExceededError) as raised:
workos.directory_sync.sync_directory(
"directory_123", request_options={"max_retries": 0}
)

assert raised.value.status_code == 429
assert raised.value.code == "directory_sync_rate_limited"
assert raised.value.response_json is not None
assert raised.value.response_json["retry_after_seconds"] == 120
assert raised.value.response is not None
assert raised.value.response.headers["Retry-After"] == "120"
assert len(httpx_mock.get_requests()) == 1


@pytest.mark.parametrize(
("status", "code", "error"),
[
(409, "directory_sync_in_progress", ConflictError),
(422, "directory_sync_unsupported", UnprocessableEntityError),
(503, "directory_sync_disabled", ServerError),
],
)
def test_manual_sync_does_not_report_errors_as_queued(
workos, httpx_mock, status, code, error
):
httpx_mock.add_response(
status_code=status, json={"code": code, "message": "Not queued."}
)

with pytest.raises(error) as raised:
workos.directory_sync.sync_directory(
"directory_123", request_options={"max_retries": 0}
)

assert raised.value.status_code == status
assert raised.value.code == code
assert len(httpx_mock.get_requests()) == 1
Loading
Loading