python-zendesk-sdk 0.12.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.
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/.gitignore +2 -1
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/PKG-INFO +42 -3
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/README.md +41 -2
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/pyproject.toml +1 -1
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/__init__.py +1 -1
- python_zendesk_sdk-0.13.0/src/zendesk_sdk/config.py +175 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/http_client.py +51 -2
- python_zendesk_sdk-0.13.0/tests/test_config.py +216 -0
- python_zendesk_sdk-0.13.0/tests/test_http_client.py +368 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_package_import.py +1 -1
- python_zendesk_sdk-0.12.0/src/zendesk_sdk/config.py +0 -126
- python_zendesk_sdk-0.12.0/tests/test_config.py +0 -92
- python_zendesk_sdk-0.12.0/tests/test_http_client.py +0 -180
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/.flake8 +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/.github/workflows/publish.yml +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/.python-version +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/LICENSE +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/context7.json +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/basic_usage.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/caching.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/enriched_tickets.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/error_handling.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/groups.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/help_center.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/organizations.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/pagination_example.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/search.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/examples/users.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/client.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/__init__.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/attachments.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/base.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/groups.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/__init__.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/articles.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/categories.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/help_center/sections.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/organizations.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/search.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/ticket_fields.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/tickets.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/clients/users.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/exceptions.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/__init__.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/base.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/comment.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/enriched_ticket.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/group.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/group_membership.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/help_center.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/organization.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/search.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/ticket.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/models/user.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/src/zendesk_sdk/pagination.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/__init__.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_client.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_clients.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_exceptions.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_help_center_client.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_models.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_pagination.py +0 -0
- {python_zendesk_sdk-0.12.0 → python_zendesk_sdk-0.13.0}/tests/test_search_query_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-zendesk-sdk
|
|
3
|
-
Version: 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
|
-
###
|
|
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
|
|
@@ -656,6 +672,29 @@ config = ZendeskConfig(
|
|
|
656
672
|
)
|
|
657
673
|
```
|
|
658
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
|
+
|
|
659
698
|
## Caching
|
|
660
699
|
|
|
661
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
|
-
###
|
|
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
|
|
@@ -618,6 +634,29 @@ config = ZendeskConfig(
|
|
|
618
634
|
)
|
|
619
635
|
```
|
|
620
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
|
+
|
|
621
660
|
## Caching
|
|
622
661
|
|
|
623
662
|
The SDK includes built-in caching for frequently accessed resources. Caching is enabled by default and can be configured or disabled.
|
|
@@ -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:
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Tests for ZendeskConfig."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from zendesk_sdk.config import ZendeskConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestZendeskConfig:
|
|
13
|
+
"""Test cases for ZendeskConfig class."""
|
|
14
|
+
|
|
15
|
+
def test_basic_config_with_token(self):
|
|
16
|
+
"""Test creating config with email/token authentication."""
|
|
17
|
+
config = ZendeskConfig(
|
|
18
|
+
subdomain="test",
|
|
19
|
+
email="user@example.com",
|
|
20
|
+
token="api_token_123",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
assert config.subdomain == "test"
|
|
24
|
+
assert config.email == "user@example.com"
|
|
25
|
+
assert config.token == "api_token_123"
|
|
26
|
+
assert config.oauth_token is None
|
|
27
|
+
assert config.endpoint == "https://test.zendesk.com/api/v2"
|
|
28
|
+
assert config.auth_tuple == ("user@example.com/token", "api_token_123")
|
|
29
|
+
|
|
30
|
+
def test_basic_config_with_oauth_token(self):
|
|
31
|
+
"""Test creating config with OAuth token authentication."""
|
|
32
|
+
config = ZendeskConfig(
|
|
33
|
+
subdomain="test",
|
|
34
|
+
oauth_token="oauth_token_123",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
assert config.subdomain == "test"
|
|
38
|
+
assert config.oauth_token == "oauth_token_123"
|
|
39
|
+
assert config.email is None
|
|
40
|
+
assert config.token is None
|
|
41
|
+
assert config.auth_tuple is None
|
|
42
|
+
assert config.endpoint == "https://test.zendesk.com/api/v2"
|
|
43
|
+
|
|
44
|
+
def test_invalid_timeout(self):
|
|
45
|
+
"""Test invalid timeout values."""
|
|
46
|
+
with pytest.raises(ValidationError):
|
|
47
|
+
ZendeskConfig(
|
|
48
|
+
subdomain="test",
|
|
49
|
+
email="user@example.com",
|
|
50
|
+
token="api_token_123",
|
|
51
|
+
timeout=0.0, # Must be > 0
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def test_invalid_max_retries(self):
|
|
55
|
+
"""Test invalid max_retries values."""
|
|
56
|
+
with pytest.raises(ValidationError):
|
|
57
|
+
ZendeskConfig(
|
|
58
|
+
subdomain="test",
|
|
59
|
+
email="user@example.com",
|
|
60
|
+
token="api_token_123",
|
|
61
|
+
max_retries=-1, # Must be >= 0
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def test_missing_all_auth(self):
|
|
65
|
+
"""Test that at least one auth method is required."""
|
|
66
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
67
|
+
with pytest.raises(ValidationError, match="Either email/token or oauth_token must be provided"):
|
|
68
|
+
ZendeskConfig(subdomain="test")
|
|
69
|
+
|
|
70
|
+
def test_both_auth_methods(self):
|
|
71
|
+
"""Test that token and oauth_token are mutually exclusive."""
|
|
72
|
+
with pytest.raises(ValidationError, match="Cannot use both"):
|
|
73
|
+
ZendeskConfig(
|
|
74
|
+
subdomain="test",
|
|
75
|
+
email="user@example.com",
|
|
76
|
+
token="api_token_123",
|
|
77
|
+
oauth_token="oauth_token_123",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def test_email_without_token(self):
|
|
81
|
+
"""Test that email requires token."""
|
|
82
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
83
|
+
with pytest.raises(ValidationError, match="Both email and token are required"):
|
|
84
|
+
ZendeskConfig(
|
|
85
|
+
subdomain="test",
|
|
86
|
+
email="user@example.com",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def test_token_without_email(self):
|
|
90
|
+
"""Test that token requires email."""
|
|
91
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
92
|
+
with pytest.raises(ValidationError, match="Both email and token are required"):
|
|
93
|
+
ZendeskConfig(
|
|
94
|
+
subdomain="test",
|
|
95
|
+
token="api_token_123",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def test_invalid_email(self):
|
|
99
|
+
"""Test invalid email format."""
|
|
100
|
+
with pytest.raises(ValidationError, match="Invalid email format"):
|
|
101
|
+
ZendeskConfig(
|
|
102
|
+
subdomain="test",
|
|
103
|
+
email="invalid-email",
|
|
104
|
+
token="api_token_123",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def test_env_variables_token_auth(self):
|
|
108
|
+
"""Test loading token auth config from environment variables."""
|
|
109
|
+
with patch.dict(
|
|
110
|
+
os.environ,
|
|
111
|
+
{
|
|
112
|
+
"ZENDESK_SUBDOMAIN": "env-test",
|
|
113
|
+
"ZENDESK_EMAIL": "env@example.com",
|
|
114
|
+
"ZENDESK_TOKEN": "env_token_123",
|
|
115
|
+
},
|
|
116
|
+
):
|
|
117
|
+
config = ZendeskConfig()
|
|
118
|
+
assert config.subdomain == "env-test"
|
|
119
|
+
assert config.email == "env@example.com"
|
|
120
|
+
assert config.token == "env_token_123"
|
|
121
|
+
|
|
122
|
+
def test_env_variables_oauth(self):
|
|
123
|
+
"""Test loading OAuth config from environment variables."""
|
|
124
|
+
with patch.dict(
|
|
125
|
+
os.environ,
|
|
126
|
+
{
|
|
127
|
+
"ZENDESK_SUBDOMAIN": "env-test",
|
|
128
|
+
"ZENDESK_OAUTH_TOKEN": "env_oauth_123",
|
|
129
|
+
},
|
|
130
|
+
clear=True,
|
|
131
|
+
):
|
|
132
|
+
config = ZendeskConfig()
|
|
133
|
+
assert config.subdomain == "env-test"
|
|
134
|
+
assert config.oauth_token == "env_oauth_123"
|
|
135
|
+
assert config.auth_tuple is None
|
|
136
|
+
|
|
137
|
+
def test_repr_hides_token(self):
|
|
138
|
+
"""Test that repr doesn't expose token."""
|
|
139
|
+
config = ZendeskConfig(
|
|
140
|
+
subdomain="test",
|
|
141
|
+
email="user@example.com",
|
|
142
|
+
token="secret_token",
|
|
143
|
+
)
|
|
144
|
+
repr_str = repr(config)
|
|
145
|
+
assert "secret_token" not in repr_str
|
|
146
|
+
assert "test" in repr_str
|
|
147
|
+
assert "user@example.com" in repr_str
|
|
148
|
+
|
|
149
|
+
def test_repr_hides_oauth_token(self):
|
|
150
|
+
"""Test that repr doesn't expose oauth_token."""
|
|
151
|
+
config = ZendeskConfig(
|
|
152
|
+
subdomain="test",
|
|
153
|
+
oauth_token="secret_oauth_token",
|
|
154
|
+
)
|
|
155
|
+
repr_str = repr(config)
|
|
156
|
+
assert "secret_oauth_token" not in repr_str
|
|
157
|
+
assert "oauth=True" in repr_str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TestRateLimitConfig:
|
|
161
|
+
"""Test cases for proactive rate limiting configuration."""
|
|
162
|
+
|
|
163
|
+
def test_default_ratelimit_config(self):
|
|
164
|
+
"""Test default values for rate limiting fields."""
|
|
165
|
+
config = ZendeskConfig(subdomain="test", email="user@example.com", token="abc123")
|
|
166
|
+
assert config.proactive_ratelimit is None
|
|
167
|
+
assert config.proactive_ratelimit_request_interval == 10
|
|
168
|
+
|
|
169
|
+
def test_proactive_ratelimit(self):
|
|
170
|
+
"""Test setting proactive_ratelimit threshold."""
|
|
171
|
+
config = ZendeskConfig(subdomain="test", email="user@example.com", token="abc123", proactive_ratelimit=50)
|
|
172
|
+
assert config.proactive_ratelimit == 50
|
|
173
|
+
|
|
174
|
+
def test_proactive_ratelimit_with_custom_interval(self):
|
|
175
|
+
"""Test setting proactive_ratelimit with custom interval."""
|
|
176
|
+
config = ZendeskConfig(
|
|
177
|
+
subdomain="test",
|
|
178
|
+
email="user@example.com",
|
|
179
|
+
token="abc123",
|
|
180
|
+
proactive_ratelimit=100,
|
|
181
|
+
proactive_ratelimit_request_interval=15,
|
|
182
|
+
)
|
|
183
|
+
assert config.proactive_ratelimit == 100
|
|
184
|
+
assert config.proactive_ratelimit_request_interval == 15
|
|
185
|
+
|
|
186
|
+
def test_proactive_ratelimit_invalid_zero(self):
|
|
187
|
+
"""Test that proactive_ratelimit=0 raises ValidationError."""
|
|
188
|
+
with pytest.raises(ValidationError):
|
|
189
|
+
ZendeskConfig(subdomain="test", email="user@example.com", token="abc123", proactive_ratelimit=0)
|
|
190
|
+
|
|
191
|
+
def test_proactive_ratelimit_invalid_negative(self):
|
|
192
|
+
"""Test that proactive_ratelimit=-1 raises ValidationError."""
|
|
193
|
+
with pytest.raises(ValidationError):
|
|
194
|
+
ZendeskConfig(subdomain="test", email="user@example.com", token="abc123", proactive_ratelimit=-1)
|
|
195
|
+
|
|
196
|
+
def test_proactive_ratelimit_interval_invalid_zero(self):
|
|
197
|
+
"""Test that proactive_ratelimit_request_interval=0 raises ValidationError."""
|
|
198
|
+
with pytest.raises(ValidationError):
|
|
199
|
+
ZendeskConfig(
|
|
200
|
+
subdomain="test",
|
|
201
|
+
email="user@example.com",
|
|
202
|
+
token="abc123",
|
|
203
|
+
proactive_ratelimit_request_interval=0,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def test_repr_includes_proactive_ratelimit(self):
|
|
207
|
+
"""Test that repr shows proactive_ratelimit when set."""
|
|
208
|
+
config = ZendeskConfig(subdomain="test", email="user@example.com", token="abc123", proactive_ratelimit=50)
|
|
209
|
+
repr_str = repr(config)
|
|
210
|
+
assert "proactive_ratelimit=50" in repr_str
|
|
211
|
+
|
|
212
|
+
def test_repr_excludes_proactive_ratelimit_when_none(self):
|
|
213
|
+
"""Test that repr omits proactive_ratelimit when None."""
|
|
214
|
+
config = ZendeskConfig(subdomain="test", email="user@example.com", token="abc123")
|
|
215
|
+
repr_str = repr(config)
|
|
216
|
+
assert "proactive_ratelimit" not in repr_str
|