python-zendesk-sdk 0.11.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/.gitignore +2 -1
  2. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/PKG-INFO +53 -3
  3. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/README.md +52 -2
  4. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/groups.py +20 -0
  5. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/pyproject.toml +1 -1
  6. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/__init__.py +3 -1
  7. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/groups.py +66 -1
  8. python_zendesk_sdk-0.13.0/src/zendesk_sdk/config.py +175 -0
  9. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/http_client.py +51 -2
  10. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/__init__.py +2 -0
  11. python_zendesk_sdk-0.13.0/src/zendesk_sdk/models/group_membership.py +31 -0
  12. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/pagination.py +27 -1
  13. python_zendesk_sdk-0.13.0/tests/test_config.py +216 -0
  14. python_zendesk_sdk-0.13.0/tests/test_http_client.py +368 -0
  15. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/tests/test_package_import.py +1 -1
  16. python_zendesk_sdk-0.11.0/src/zendesk_sdk/config.py +0 -126
  17. python_zendesk_sdk-0.11.0/tests/test_config.py +0 -92
  18. python_zendesk_sdk-0.11.0/tests/test_http_client.py +0 -180
  19. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/.flake8 +0 -0
  20. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/.github/workflows/publish.yml +0 -0
  21. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/.python-version +0 -0
  22. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/LICENSE +0 -0
  23. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/context7.json +0 -0
  24. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/basic_usage.py +0 -0
  25. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/caching.py +0 -0
  26. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/enriched_tickets.py +0 -0
  27. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/error_handling.py +0 -0
  28. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/help_center.py +0 -0
  29. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/organizations.py +0 -0
  30. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/pagination_example.py +0 -0
  31. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/search.py +0 -0
  32. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/examples/users.py +0 -0
  33. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/client.py +0 -0
  34. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/__init__.py +0 -0
  35. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/attachments.py +0 -0
  36. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/base.py +0 -0
  37. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/__init__.py +0 -0
  38. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/articles.py +0 -0
  39. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/categories.py +0 -0
  40. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/sections.py +0 -0
  41. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/organizations.py +0 -0
  42. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/search.py +0 -0
  43. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/ticket_fields.py +0 -0
  44. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/tickets.py +0 -0
  45. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/users.py +0 -0
  46. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/exceptions.py +0 -0
  47. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/base.py +0 -0
  48. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/comment.py +0 -0
  49. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/enriched_ticket.py +0 -0
  50. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/group.py +0 -0
  51. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/help_center.py +0 -0
  52. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/organization.py +0 -0
  53. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/search.py +0 -0
  54. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/ticket.py +0 -0
  55. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/user.py +0 -0
  56. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/tests/__init__.py +0 -0
  57. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/tests/test_client.py +0 -0
  58. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/tests/test_clients.py +0 -0
  59. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/tests/test_exceptions.py +0 -0
  60. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/tests/test_help_center_client.py +0 -0
  61. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/tests/test_models.py +0 -0
  62. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/tests/test_pagination.py +0 -0
  63. {python_zendesk_sdk-0.11.0 → python_zendesk_sdk-0.13.0}/tests/test_search_query_config.py +0 -0
@@ -190,4 +190,5 @@ debug_*.py
190
190
 
191
191
  # Local directories
192
192
  .claude/
193
- llm/
193
+ llm/
194
+ CLAUDE.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-zendesk-sdk
3
- Version: 0.11.0
3
+ Version: 0.13.0
4
4
  Summary: Modern Python SDK for Zendesk API
5
5
  Project-URL: Homepage, https://github.com/bormog/python-zendesk-sdk
6
6
  Project-URL: Repository, https://github.com/bormog/python-zendesk-sdk
@@ -68,6 +68,7 @@ Modern Python SDK for Zendesk API, designed for automation and AI agents.
68
68
  - [Search](#search)
69
69
  - [Help Center](#help-center)
70
70
  - [Error Handling](#error-handling)
71
+ - [Proactive Rate Limiting](#proactive-rate-limiting)
71
72
  - [Caching](#caching)
72
73
  - [Examples](#examples)
73
74
 
@@ -102,6 +103,8 @@ Zendesk has a powerful REST API, but using it directly is painful:
102
103
  - **Caching**: TTL-based caching for users, organizations, and Help Center
103
104
  - **Help Center**: Full CRUD for Categories, Sections, and Articles
104
105
  - **Async HTTP**: Built on httpx with retry logic, rate limiting, exponential backoff
106
+ - **Proactive Rate Limiting**: Monitors `X-Rate-Limit-Remaining` and throttles before hitting limits
107
+ - **Authentication**: Token auth (Basic Auth) and OAuth (Bearer token)
105
108
  - **Configuration**: Environment variables or direct instantiation
106
109
 
107
110
  ## Installation
@@ -141,20 +144,33 @@ asyncio.run(main())
141
144
 
142
145
  ## Configuration
143
146
 
144
- ### Direct instantiation
147
+ ### Token Authentication
145
148
  ```python
146
149
  config = ZendeskConfig(
147
150
  subdomain="mycompany",
148
151
  email="user@example.com",
149
- token="api_token_here"
152
+ token="api_token_here",
153
+ )
154
+ ```
155
+
156
+ ### OAuth Authentication
157
+ ```python
158
+ config = ZendeskConfig(
159
+ subdomain="mycompany",
160
+ oauth_token="your_oauth_token",
150
161
  )
151
162
  ```
152
163
 
153
164
  ### Environment variables
154
165
  ```bash
166
+ # Token auth
155
167
  export ZENDESK_SUBDOMAIN=mycompany
156
168
  export ZENDESK_EMAIL=user@example.com
157
169
  export ZENDESK_TOKEN=api_token_here
170
+
171
+ # Or OAuth
172
+ export ZENDESK_SUBDOMAIN=mycompany
173
+ export ZENDESK_OAUTH_TOKEN=your_oauth_token
158
174
  ```
159
175
 
160
176
  ```python
@@ -266,6 +282,17 @@ count = await client.groups.count() # Get total number of groups
266
282
  paginator = client.groups.list() # List all groups (paginator)
267
283
  paginator = client.groups.list_assignable() # List assignable groups (paginator)
268
284
 
285
+ # Memberships — find which agents belong to a group
286
+ paginator = client.groups.list_memberships() # All memberships (paginator)
287
+ paginator = client.groups.list_group_members(group_id) # Members of specific group (paginator)
288
+ membership = await client.groups.get_membership(membership_id) # Get specific membership
289
+
290
+ # Example: get all agents in a group
291
+ members = await client.groups.list_group_members(group_id).collect()
292
+ for m in members:
293
+ user = await client.users.get(m.user_id)
294
+ print(f" {user.name} (default={m.default})")
295
+
269
296
  # Create
270
297
  group = await client.groups.create(
271
298
  name="Support Team",
@@ -645,6 +672,29 @@ config = ZendeskConfig(
645
672
  )
646
673
  ```
647
674
 
675
+ ### Proactive Rate Limiting
676
+
677
+ By default, the SDK reacts to rate limiting after hitting a 429 response. With proactive rate limiting, the SDK reads the `X-Rate-Limit-Remaining` header from every response and starts throttling **before** hitting the limit:
678
+
679
+ ```python
680
+ config = ZendeskConfig(
681
+ subdomain="mycompany",
682
+ email="user@example.com",
683
+ token="api_token",
684
+ proactive_ratelimit=50, # Start throttling when < 50 requests remaining
685
+ proactive_ratelimit_request_interval=10, # Wait 10s between requests when throttling
686
+ )
687
+ ```
688
+
689
+ When `X-Rate-Limit-Remaining` drops below the threshold, the SDK pauses between requests to let the quota recover. Once remaining goes back above the threshold, requests resume at full speed.
690
+
691
+ | Parameter | Default | Description |
692
+ |-----------|---------|-------------|
693
+ | `proactive_ratelimit` | None (disabled) | Threshold to start throttling |
694
+ | `proactive_ratelimit_request_interval` | 10 | Seconds to wait between requests when throttling |
695
+
696
+ Zendesk typically allows 400 requests per minute. A threshold of 40-60 provides a good safety margin.
697
+
648
698
  ## Caching
649
699
 
650
700
  The SDK includes built-in caching for frequently accessed resources. Caching is enabled by default and can be configured or disabled.
@@ -30,6 +30,7 @@ Modern Python SDK for Zendesk API, designed for automation and AI agents.
30
30
  - [Search](#search)
31
31
  - [Help Center](#help-center)
32
32
  - [Error Handling](#error-handling)
33
+ - [Proactive Rate Limiting](#proactive-rate-limiting)
33
34
  - [Caching](#caching)
34
35
  - [Examples](#examples)
35
36
 
@@ -64,6 +65,8 @@ Zendesk has a powerful REST API, but using it directly is painful:
64
65
  - **Caching**: TTL-based caching for users, organizations, and Help Center
65
66
  - **Help Center**: Full CRUD for Categories, Sections, and Articles
66
67
  - **Async HTTP**: Built on httpx with retry logic, rate limiting, exponential backoff
68
+ - **Proactive Rate Limiting**: Monitors `X-Rate-Limit-Remaining` and throttles before hitting limits
69
+ - **Authentication**: Token auth (Basic Auth) and OAuth (Bearer token)
67
70
  - **Configuration**: Environment variables or direct instantiation
68
71
 
69
72
  ## Installation
@@ -103,20 +106,33 @@ asyncio.run(main())
103
106
 
104
107
  ## Configuration
105
108
 
106
- ### Direct instantiation
109
+ ### Token Authentication
107
110
  ```python
108
111
  config = ZendeskConfig(
109
112
  subdomain="mycompany",
110
113
  email="user@example.com",
111
- token="api_token_here"
114
+ token="api_token_here",
115
+ )
116
+ ```
117
+
118
+ ### OAuth Authentication
119
+ ```python
120
+ config = ZendeskConfig(
121
+ subdomain="mycompany",
122
+ oauth_token="your_oauth_token",
112
123
  )
113
124
  ```
114
125
 
115
126
  ### Environment variables
116
127
  ```bash
128
+ # Token auth
117
129
  export ZENDESK_SUBDOMAIN=mycompany
118
130
  export ZENDESK_EMAIL=user@example.com
119
131
  export ZENDESK_TOKEN=api_token_here
132
+
133
+ # Or OAuth
134
+ export ZENDESK_SUBDOMAIN=mycompany
135
+ export ZENDESK_OAUTH_TOKEN=your_oauth_token
120
136
  ```
121
137
 
122
138
  ```python
@@ -228,6 +244,17 @@ count = await client.groups.count() # Get total number of groups
228
244
  paginator = client.groups.list() # List all groups (paginator)
229
245
  paginator = client.groups.list_assignable() # List assignable groups (paginator)
230
246
 
247
+ # Memberships — find which agents belong to a group
248
+ paginator = client.groups.list_memberships() # All memberships (paginator)
249
+ paginator = client.groups.list_group_members(group_id) # Members of specific group (paginator)
250
+ membership = await client.groups.get_membership(membership_id) # Get specific membership
251
+
252
+ # Example: get all agents in a group
253
+ members = await client.groups.list_group_members(group_id).collect()
254
+ for m in members:
255
+ user = await client.users.get(m.user_id)
256
+ print(f" {user.name} (default={m.default})")
257
+
231
258
  # Create
232
259
  group = await client.groups.create(
233
260
  name="Support Team",
@@ -607,6 +634,29 @@ config = ZendeskConfig(
607
634
  )
608
635
  ```
609
636
 
637
+ ### Proactive Rate Limiting
638
+
639
+ By default, the SDK reacts to rate limiting after hitting a 429 response. With proactive rate limiting, the SDK reads the `X-Rate-Limit-Remaining` header from every response and starts throttling **before** hitting the limit:
640
+
641
+ ```python
642
+ config = ZendeskConfig(
643
+ subdomain="mycompany",
644
+ email="user@example.com",
645
+ token="api_token",
646
+ proactive_ratelimit=50, # Start throttling when < 50 requests remaining
647
+ proactive_ratelimit_request_interval=10, # Wait 10s between requests when throttling
648
+ )
649
+ ```
650
+
651
+ When `X-Rate-Limit-Remaining` drops below the threshold, the SDK pauses between requests to let the quota recover. Once remaining goes back above the threshold, requests resume at full speed.
652
+
653
+ | Parameter | Default | Description |
654
+ |-----------|---------|-------------|
655
+ | `proactive_ratelimit` | None (disabled) | Threshold to start throttling |
656
+ | `proactive_ratelimit_request_interval` | 10 | Seconds to wait between requests when throttling |
657
+
658
+ Zendesk typically allows 400 requests per minute. A threshold of 40-60 provides a good safety margin.
659
+
610
660
  ## Caching
611
661
 
612
662
  The SDK includes built-in caching for frequently accessed resources. Caching is enabled by default and can be configured or disabled.
@@ -6,6 +6,7 @@ This example demonstrates:
6
6
  - Updating groups
7
7
  - Deleting groups
8
8
  - Listing assignable groups
9
+ - Group memberships (list members of a group)
9
10
  """
10
11
 
11
12
  import asyncio
@@ -97,6 +98,25 @@ async def main() -> None:
97
98
  await client.groups.delete(detailed_group.id)
98
99
  print(f"Deleted group: {detailed_group.id}")
99
100
 
101
+ # ==================== Membership Operations ====================
102
+
103
+ print("\n=== Membership Operations ===")
104
+
105
+ # List all memberships across all groups
106
+ print("All memberships:")
107
+ async for membership in client.groups.list_memberships(limit=10):
108
+ print(f" User {membership.user_id} -> Group {membership.group_id} (default={membership.default})")
109
+
110
+ # List members of a specific group
111
+ print("\nMembers of first group:")
112
+ first_group = groups[0] if groups else None
113
+ if first_group:
114
+ members = await client.groups.list_group_members(first_group.id).collect()
115
+ for m in members:
116
+ user = await client.users.get(m.user_id)
117
+ default_str = " [DEFAULT]" if m.default else ""
118
+ print(f" {user.name}{default_str}")
119
+
100
120
  # ==================== Caching Example ====================
101
121
 
102
122
  print("\n=== Caching Example ===")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-zendesk-sdk"
3
- version = "0.11.0"
3
+ version = "0.13.0"
4
4
  description = "Modern Python SDK for Zendesk API"
5
5
  authors = [
6
6
  {name = "bormog"}
@@ -5,7 +5,7 @@ This package provides a clean, async-first interface to the Zendesk API
5
5
  with full type safety and comprehensive error handling.
6
6
  """
7
7
 
8
- __version__ = "0.11.0"
8
+ __version__ = "0.13.0"
9
9
 
10
10
  from .client import ZendeskClient
11
11
  from .clients import (
@@ -38,6 +38,7 @@ from .models import (
38
38
  Category,
39
39
  EnrichedTicket,
40
40
  Group,
41
+ GroupMembership,
41
42
  PasswordRequirements,
42
43
  SearchQueryConfig,
43
44
  SearchType,
@@ -76,6 +77,7 @@ __all__ = [
76
77
  "ArticlesClient",
77
78
  # Models
78
79
  "Group",
80
+ "GroupMembership",
79
81
  "EnrichedTicket",
80
82
  "TicketField",
81
83
  "Category",
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
4
4
 
5
- from ..models import Group
5
+ from ..models import Group, GroupMembership
6
6
  from ..pagination import ZendeskPaginator
7
7
  from .base import BaseClient
8
8
 
@@ -300,3 +300,68 @@ class GroupsClient(BaseClient):
300
300
  """
301
301
  await self._delete(f"groups/{group_id}.json")
302
302
  return True
303
+
304
+ # ==================== Membership Operations ====================
305
+
306
+ def list_memberships(self, per_page: int = 100, limit: Optional[int] = None) -> "Paginator[GroupMembership]":
307
+ """Get paginated list of all group memberships.
308
+
309
+ Returns all group membership records across the entire account.
310
+
311
+ Args:
312
+ per_page: Number of memberships per API request (max 100).
313
+ limit: Maximum total memberships to return. None for no limit.
314
+
315
+ Returns:
316
+ Paginator[GroupMembership] for iterating through all memberships
317
+
318
+ Example:
319
+ async for membership in client.groups.list_memberships():
320
+ print(f"User {membership.user_id} in Group {membership.group_id}")
321
+ """
322
+ return ZendeskPaginator.create_group_memberships_paginator(self._http, per_page=per_page, limit=limit)
323
+
324
+ def list_group_members(
325
+ self, group_id: int, per_page: int = 100, limit: Optional[int] = None
326
+ ) -> "Paginator[GroupMembership]":
327
+ """Get paginated list of memberships for a specific group.
328
+
329
+ Returns membership records for agents in the given group.
330
+ Use this to find out which agents belong to a group.
331
+
332
+ Args:
333
+ group_id: The group's ID
334
+ per_page: Number of memberships per API request (max 100).
335
+ limit: Maximum total memberships to return. None for no limit.
336
+
337
+ Returns:
338
+ Paginator[GroupMembership] for iterating through group's memberships
339
+
340
+ Example:
341
+ # List all agents in a group
342
+ async for membership in client.groups.list_group_members(12345):
343
+ print(f"Agent {membership.user_id}, default={membership.default}")
344
+
345
+ # Collect all members
346
+ members = await client.groups.list_group_members(12345).collect()
347
+ user_ids = [m.user_id for m in members]
348
+ """
349
+ return ZendeskPaginator.create_group_memberships_by_group_paginator(
350
+ self._http, group_id, per_page=per_page, limit=limit
351
+ )
352
+
353
+ async def get_membership(self, membership_id: int) -> GroupMembership:
354
+ """Get a specific group membership by ID.
355
+
356
+ Args:
357
+ membership_id: The membership's ID
358
+
359
+ Returns:
360
+ GroupMembership object
361
+
362
+ Example:
363
+ membership = await client.groups.get_membership(99999)
364
+ print(f"User {membership.user_id} in Group {membership.group_id}")
365
+ """
366
+ response = await self._get(f"group_memberships/{membership_id}.json")
367
+ return GroupMembership(**response["group_membership"])
@@ -0,0 +1,175 @@
1
+ """Configuration management for Zendesk SDK."""
2
+
3
+ import os
4
+ from typing import Any, Optional
5
+
6
+ from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
7
+
8
+
9
+ class CacheConfig(BaseModel):
10
+ """Cache configuration for Zendesk SDK.
11
+
12
+ Controls TTL (time-to-live) and max size for different resource caches.
13
+ Set enabled=False to disable caching entirely.
14
+ """
15
+
16
+ enabled: bool = Field(default=True, description="Enable/disable caching")
17
+
18
+ # Users cache
19
+ user_ttl: int = Field(default=300, description="User cache TTL in seconds (default: 5 min)", ge=0)
20
+ user_maxsize: int = Field(default=1000, description="Max cached users", ge=1)
21
+
22
+ # Organizations cache
23
+ org_ttl: int = Field(default=600, description="Organization cache TTL in seconds (default: 10 min)", ge=0)
24
+ org_maxsize: int = Field(default=500, description="Max cached organizations", ge=1)
25
+
26
+ # Groups cache
27
+ group_ttl: int = Field(default=600, description="Group cache TTL in seconds (default: 10 min)", ge=0)
28
+ group_maxsize: int = Field(default=500, description="Max cached groups", ge=1)
29
+
30
+ # Help Center cache
31
+ article_ttl: int = Field(default=900, description="Article cache TTL in seconds (default: 15 min)", ge=0)
32
+ article_maxsize: int = Field(default=500, description="Max cached articles", ge=1)
33
+
34
+ category_ttl: int = Field(default=1800, description="Category cache TTL in seconds (default: 30 min)", ge=0)
35
+ category_maxsize: int = Field(default=200, description="Max cached categories", ge=1)
36
+
37
+ section_ttl: int = Field(default=1800, description="Section cache TTL in seconds (default: 30 min)", ge=0)
38
+ section_maxsize: int = Field(default=200, description="Max cached sections", ge=1)
39
+
40
+
41
+ class ZendeskConfig(BaseModel):
42
+ """Configuration for Zendesk API client.
43
+
44
+ Supports two authentication methods (mutually exclusive):
45
+ - Token auth: email + token (Basic Auth)
46
+ - OAuth: oauth_token (Bearer token)
47
+
48
+ Environment variables: ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_TOKEN, ZENDESK_OAUTH_TOKEN.
49
+ """
50
+
51
+ subdomain: str = Field(
52
+ ...,
53
+ description="Zendesk subdomain (e.g., 'mycompany' for mycompany.zendesk.com)",
54
+ min_length=1,
55
+ )
56
+ email: Optional[str] = Field(
57
+ default=None,
58
+ description="User email for token authentication",
59
+ )
60
+ token: Optional[str] = Field(
61
+ default=None,
62
+ description="API token for token authentication",
63
+ )
64
+ oauth_token: Optional[str] = Field(
65
+ default=None,
66
+ description="OAuth token for Bearer authentication",
67
+ )
68
+ timeout: float = Field(
69
+ default=30.0,
70
+ description="HTTP request timeout in seconds",
71
+ gt=0,
72
+ )
73
+ max_retries: int = Field(
74
+ default=3,
75
+ description="Maximum number of retry attempts",
76
+ ge=0,
77
+ )
78
+ proactive_ratelimit: Optional[int] = Field(
79
+ default=None,
80
+ description="Start sleeping when X-Rate-Limit-Remaining drops below this threshold",
81
+ ge=1,
82
+ )
83
+ proactive_ratelimit_request_interval: int = Field(
84
+ default=10,
85
+ description="Seconds to wait between requests when proactive rate limit threshold is reached",
86
+ ge=1,
87
+ )
88
+ cache: CacheConfig = Field(
89
+ default_factory=CacheConfig,
90
+ description="Cache configuration",
91
+ )
92
+
93
+ def __init__(self, **data: Any) -> None:
94
+ # Load from environment variables if not provided
95
+ if "subdomain" not in data:
96
+ data["subdomain"] = os.getenv("ZENDESK_SUBDOMAIN", data.get("subdomain"))
97
+
98
+ # Only load env vars for one auth method — explicit args take precedence
99
+ has_explicit_token_auth = "email" in data or "token" in data
100
+ has_explicit_oauth = "oauth_token" in data
101
+
102
+ if not has_explicit_token_auth and not has_explicit_oauth:
103
+ # Nothing explicit — try env vars, token auth first
104
+ env_email = os.getenv("ZENDESK_EMAIL")
105
+ env_token = os.getenv("ZENDESK_TOKEN")
106
+ env_oauth = os.getenv("ZENDESK_OAUTH_TOKEN")
107
+ if env_email or env_token:
108
+ data.setdefault("email", env_email)
109
+ data.setdefault("token", env_token)
110
+ elif env_oauth:
111
+ data.setdefault("oauth_token", env_oauth)
112
+ elif has_explicit_token_auth and not has_explicit_oauth:
113
+ # Token auth explicit — fill missing from env
114
+ data.setdefault("email", os.getenv("ZENDESK_EMAIL"))
115
+ data.setdefault("token", os.getenv("ZENDESK_TOKEN"))
116
+ elif has_explicit_oauth and not has_explicit_token_auth:
117
+ # OAuth explicit — only fill oauth from env
118
+ data.setdefault("oauth_token", os.getenv("ZENDESK_OAUTH_TOKEN"))
119
+
120
+ super().__init__(**data)
121
+
122
+ @model_validator(mode="after")
123
+ def validate_auth(self) -> "ZendeskConfig":
124
+ """Validate that exactly one auth method is provided."""
125
+ has_token_auth = self.email is not None or self.token is not None
126
+ has_oauth = self.oauth_token is not None
127
+
128
+ if has_token_auth and has_oauth:
129
+ raise ValueError("Cannot use both token auth (email/token) and oauth_token simultaneously")
130
+
131
+ if not has_token_auth and not has_oauth:
132
+ raise ValueError("Either email/token or oauth_token must be provided")
133
+
134
+ if has_token_auth:
135
+ if not self.email or not self.token:
136
+ raise ValueError("Both email and token are required for token authentication")
137
+ if "@" not in self.email:
138
+ raise ValueError("Invalid email format")
139
+
140
+ return self
141
+
142
+ @field_validator("subdomain")
143
+ @classmethod
144
+ def validate_subdomain(cls, v: str) -> str:
145
+ """Validate subdomain format."""
146
+ if not v.replace("-", "").replace("_", "").isalnum():
147
+ raise ValueError("Subdomain can only contain letters, numbers, hyphens and underscores")
148
+ return v.lower()
149
+
150
+ @computed_field # type: ignore[prop-decorator]
151
+ @property
152
+ def endpoint(self) -> str:
153
+ """Generate the base API endpoint URL."""
154
+ return f"https://{self.subdomain}.zendesk.com/api/v2"
155
+
156
+ @computed_field # type: ignore[prop-decorator]
157
+ @property
158
+ def auth_tuple(self) -> Optional[tuple[str, str]]:
159
+ """Generate authentication tuple for HTTP requests. None for OAuth mode."""
160
+ if self.email and self.token:
161
+ return f"{self.email}/token", self.token
162
+ return None
163
+
164
+ def __repr__(self) -> str:
165
+ """String representation without exposing credentials."""
166
+ auth_info = f"email='{self.email}'" if self.email else "oauth=True"
167
+ parts = [
168
+ f"subdomain='{self.subdomain}'",
169
+ auth_info,
170
+ f"timeout={self.timeout}",
171
+ f"max_retries={self.max_retries}",
172
+ ]
173
+ if self.proactive_ratelimit is not None:
174
+ parts.append(f"proactive_ratelimit={self.proactive_ratelimit}")
175
+ return f"ZendeskConfig({', '.join(parts)})"
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ from time import monotonic
5
6
  from typing import Any, Dict, Optional
6
7
  from urllib.parse import urljoin
7
8
 
@@ -31,6 +32,10 @@ class HTTPClient:
31
32
  self._client: Optional[httpx.AsyncClient] = None
32
33
  self._closed = False
33
34
 
35
+ # Proactive rate limit tracking
36
+ self._last_call_time: Optional[float] = None
37
+ self._last_limit_remaining: Optional[int] = None
38
+
34
39
  @property
35
40
  def client(self) -> httpx.AsyncClient:
36
41
  """Get or create httpx async client."""
@@ -40,14 +45,18 @@ class HTTPClient:
40
45
 
41
46
  def _create_client(self) -> httpx.AsyncClient:
42
47
  """Create configured httpx async client."""
43
- auth = httpx.BasicAuth(username=self.config.auth_tuple[0], password=self.config.auth_tuple[1])
44
-
45
48
  headers = {
46
49
  "User-Agent": "python-zendesk-sdk/0.1.0",
47
50
  "Content-Type": "application/json",
48
51
  "Accept": "application/json",
49
52
  }
50
53
 
54
+ auth: Optional[httpx.BasicAuth] = None
55
+ if self.config.auth_tuple:
56
+ auth = httpx.BasicAuth(username=self.config.auth_tuple[0], password=self.config.auth_tuple[1])
57
+ elif self.config.oauth_token:
58
+ headers["Authorization"] = f"Bearer {self.config.oauth_token}"
59
+
51
60
  return httpx.AsyncClient(
52
61
  auth=auth,
53
62
  headers=headers,
@@ -55,6 +64,40 @@ class HTTPClient:
55
64
  limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
56
65
  )
57
66
 
67
+ async def _apply_proactive_ratelimit(self) -> None:
68
+ """Apply proactive rate limiting by sleeping if remaining requests are below threshold."""
69
+ if self.config.proactive_ratelimit is None:
70
+ return
71
+ if self._last_limit_remaining is None or self._last_call_time is None:
72
+ return
73
+
74
+ if self._last_limit_remaining >= self.config.proactive_ratelimit: # type: ignore[operator]
75
+ return
76
+
77
+ time_since_last = monotonic() - self._last_call_time
78
+ interval = self.config.proactive_ratelimit_request_interval
79
+ if time_since_last >= interval:
80
+ return
81
+
82
+ remaining_sleep = interval - time_since_last
83
+ logger.warning(
84
+ f"Proactive rate limit: {self._last_limit_remaining} remaining "
85
+ f"(threshold: {self.config.proactive_ratelimit}), sleeping {remaining_sleep:.1f}s"
86
+ )
87
+ await asyncio.sleep(remaining_sleep)
88
+
89
+ def _update_rate_limit_state(self, response: httpx.Response) -> None:
90
+ """Update rate limit tracking state from response headers."""
91
+ if self.config.proactive_ratelimit is None:
92
+ return
93
+ self._last_call_time = monotonic()
94
+ remaining_header = response.headers.get("X-Rate-Limit-Remaining")
95
+ if remaining_header is not None:
96
+ try:
97
+ self._last_limit_remaining = int(remaining_header)
98
+ except (ValueError, TypeError):
99
+ pass
100
+
58
101
  async def _make_request_with_retry(
59
102
  self,
60
103
  method: str,
@@ -72,6 +115,9 @@ class HTTPClient:
72
115
 
73
116
  for attempt in range(max_retries + 1):
74
117
  try:
118
+ # Apply proactive rate limiting before request
119
+ await self._apply_proactive_ratelimit()
120
+
75
121
  # Make the actual request
76
122
  response = await self.client.request(
77
123
  method=method,
@@ -80,6 +126,9 @@ class HTTPClient:
80
126
  json=json,
81
127
  )
82
128
 
129
+ # Update rate limit tracking state
130
+ self._update_rate_limit_state(response)
131
+
83
132
  # Handle different response types
84
133
  retry_info = await self._handle_response(response, attempt, max_retries)
85
134
  if retry_info:
@@ -4,6 +4,7 @@ from .base import ZendeskModel
4
4
  from .comment import Comment, CommentAttachment, CommentMetadata, CommentVia
5
5
  from .enriched_ticket import EnrichedTicket
6
6
  from .group import Group
7
+ from .group_membership import GroupMembership
7
8
  from .help_center import Article, Category, Section
8
9
  from .organization import Organization, OrganizationField, OrganizationSubscription
9
10
  from .search import (
@@ -43,6 +44,7 @@ __all__ = [
43
44
  "PasswordRequirements",
44
45
  # Group models
45
46
  "Group",
47
+ "GroupMembership",
46
48
  # Organization models
47
49
  "Organization",
48
50
  "OrganizationField",
@@ -0,0 +1,31 @@
1
+ """Group Membership model for Zendesk API."""
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from pydantic import Field
7
+
8
+ from .base import ZendeskModel
9
+
10
+
11
+ class GroupMembership(ZendeskModel):
12
+ """Zendesk Group Membership model.
13
+
14
+ Represents the association between a user (agent) and a group.
15
+ Agents can be members of multiple groups, and one group is marked
16
+ as their default group for ticket assignment.
17
+ """
18
+
19
+ # Read-only fields
20
+ id: Optional[int] = Field(None, description="Automatically assigned when creating memberships")
21
+ url: Optional[str] = Field(None, description="The API url of the membership")
22
+ user_id: int = Field(..., description="The ID of the agent")
23
+ group_id: int = Field(..., description="The ID of the group")
24
+ default: Optional[bool] = Field(None, description="If true, tickets assigned directly to the agent use this group")
25
+ created_at: Optional[datetime] = Field(None, description="The time the membership was created")
26
+ updated_at: Optional[datetime] = Field(None, description="The time of the last update")
27
+
28
+ def __str__(self) -> str:
29
+ """Human-readable string representation."""
30
+ default_str = " (default)" if self.default else ""
31
+ return f"User {self.user_id} -> Group {self.group_id}{default_str} (id={self.id})"