freescout-api 0.1.0__py3-none-any.whl

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.
freescout/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """FreeScout API Python Client Library."""
2
+
3
+ from freescout._version import __version__
4
+ from freescout.client import FreeScoutClient
5
+ from freescout.exceptions import (
6
+ FreeScoutError,
7
+ AuthenticationError,
8
+ NotFoundError,
9
+ ValidationError,
10
+ RateLimitError,
11
+ ServerError,
12
+ )
13
+ from freescout.enums import (
14
+ ConversationType,
15
+ ConversationStatus,
16
+ ConversationState,
17
+ ThreadType,
18
+ ThreadState,
19
+ SortOrder,
20
+ SortField,
21
+ CustomerSortField,
22
+ EmailType,
23
+ PhoneType,
24
+ SocialProfileType,
25
+ PhotoType,
26
+ FolderType,
27
+ SourceType,
28
+ SourceVia,
29
+ )
30
+
31
+ __all__ = [
32
+ "__version__",
33
+ "FreeScoutClient",
34
+ "FreeScoutError",
35
+ "AuthenticationError",
36
+ "NotFoundError",
37
+ "ValidationError",
38
+ "RateLimitError",
39
+ "ServerError",
40
+ "ConversationType",
41
+ "ConversationStatus",
42
+ "ConversationState",
43
+ "ThreadType",
44
+ "ThreadState",
45
+ "SortOrder",
46
+ "SortField",
47
+ "CustomerSortField",
48
+ "EmailType",
49
+ "PhoneType",
50
+ "SocialProfileType",
51
+ "PhotoType",
52
+ "FolderType",
53
+ "SourceType",
54
+ "SourceVia",
55
+ ]
@@ -0,0 +1,344 @@
1
+ """Internal HTTP transport helpers for FreeScout API client."""
2
+
3
+ import time
4
+ from collections.abc import Iterator
5
+ from typing import Any
6
+
7
+ import requests
8
+ from requests.adapters import HTTPAdapter
9
+ from urllib3.util.retry import Retry
10
+
11
+ from freescout._version import __version__
12
+ from freescout.exceptions import (
13
+ AuthenticationError,
14
+ ConflictError,
15
+ ForbiddenError,
16
+ FreeScoutError,
17
+ NotFoundError,
18
+ RateLimitError,
19
+ ServerError,
20
+ ValidationError,
21
+ )
22
+
23
+
24
+ DEFAULT_TIMEOUT = 30
25
+ DEFAULT_MAX_RETRIES = 3
26
+ DEFAULT_BACKOFF_FACTOR = 0.5
27
+
28
+
29
+ def create_session(
30
+ max_retries: int = DEFAULT_MAX_RETRIES,
31
+ backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
32
+ ) -> requests.Session:
33
+ """Create a requests Session with retry configuration.
34
+
35
+ Args:
36
+ max_retries: Maximum number of retries for failed requests.
37
+ backoff_factor: Backoff factor for retries.
38
+
39
+ Returns:
40
+ Configured requests Session.
41
+ """
42
+ session = requests.Session()
43
+
44
+ if max_retries > 0:
45
+ retry_strategy = Retry(
46
+ total=max_retries,
47
+ backoff_factor=backoff_factor,
48
+ status_forcelist=[502, 503, 504],
49
+ allowed_methods=["GET", "POST", "PUT", "DELETE"],
50
+ raise_on_status=False,
51
+ )
52
+
53
+ adapter = HTTPAdapter(max_retries=retry_strategy)
54
+ session.mount("http://", adapter)
55
+ session.mount("https://", adapter)
56
+
57
+ return session
58
+
59
+
60
+ def handle_response(response: requests.Response) -> dict[str, Any] | None:
61
+ """Handle API response and raise appropriate exceptions.
62
+
63
+ Args:
64
+ response: The requests Response object.
65
+
66
+ Returns:
67
+ Parsed JSON response data, or None for 204 responses.
68
+
69
+ Raises:
70
+ AuthenticationError: For 401 responses.
71
+ ForbiddenError: For 403 responses.
72
+ NotFoundError: For 404 responses.
73
+ ValidationError: For 400 responses.
74
+ ConflictError: For 409 responses.
75
+ RateLimitError: For 429 responses.
76
+ ServerError: For 5xx responses.
77
+ FreeScoutError: For other error responses.
78
+ """
79
+ status_code = response.status_code
80
+
81
+ if status_code == 204:
82
+ return None
83
+
84
+ if status_code in (200, 201):
85
+ if response.headers.get("Resource-ID"):
86
+ return {"id": int(response.headers["Resource-ID"])}
87
+ try:
88
+ return response.json()
89
+ except ValueError:
90
+ return None
91
+
92
+ try:
93
+ error_data = response.json()
94
+ except ValueError:
95
+ error_data = {"message": response.text or "Unknown error"}
96
+
97
+ message = error_data.get("message", "API error")
98
+ errors = error_data.get("_embedded", {}).get("errors", [])
99
+
100
+ # Common kwargs for all exceptions
101
+ common_kwargs = {
102
+ "message": message,
103
+ "status_code": status_code,
104
+ "response_data": error_data,
105
+ "request_url": response.request.url,
106
+ "request_method": response.request.method,
107
+ "response_text": response.text,
108
+ "response_headers": dict(response.headers),
109
+ }
110
+
111
+ if status_code == 400:
112
+ raise ValidationError(
113
+ **common_kwargs,
114
+ errors=errors,
115
+ )
116
+ elif status_code == 401:
117
+ raise AuthenticationError(**common_kwargs)
118
+ elif status_code == 403:
119
+ raise ForbiddenError(**common_kwargs)
120
+ elif status_code == 404:
121
+ raise NotFoundError(**common_kwargs)
122
+ elif status_code == 409:
123
+ raise ConflictError(**common_kwargs)
124
+ elif status_code == 429:
125
+ raise RateLimitError(**common_kwargs)
126
+ elif status_code >= 500:
127
+ raise ServerError(**common_kwargs)
128
+ else:
129
+ raise FreeScoutError(**common_kwargs)
130
+
131
+
132
+ class Paginator:
133
+ """Iterator for paginated API responses.
134
+
135
+ Handles automatic pagination through list endpoints.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ transport: "Transport",
141
+ endpoint: str,
142
+ params: dict[str, Any] | None = None,
143
+ page_size: int = 50,
144
+ max_pages: int | None = None,
145
+ ) -> None:
146
+ """Initialize Paginator.
147
+
148
+ Args:
149
+ transport: Transport instance for making requests.
150
+ endpoint: API endpoint path.
151
+ params: Query parameters for the request.
152
+ page_size: Number of items per page.
153
+ max_pages: Maximum number of pages to fetch (None for all).
154
+ """
155
+ self.transport = transport
156
+ self.endpoint = endpoint
157
+ self.params = params or {}
158
+ self.page_size = page_size
159
+ self.max_pages = max_pages
160
+ self.current_page = 0
161
+ self.total_pages: int | None = None
162
+
163
+ def __iter__(self) -> Iterator[dict[str, Any]]:
164
+ """Iterate through all pages."""
165
+ return self
166
+
167
+ def __next__(self) -> dict[str, Any]:
168
+ """Get the next page of results."""
169
+ if self.total_pages is not None and self.current_page >= self.total_pages:
170
+ raise StopIteration
171
+
172
+ if self.max_pages is not None and self.current_page >= self.max_pages:
173
+ raise StopIteration
174
+
175
+ self.current_page += 1
176
+ params = {
177
+ **self.params,
178
+ "page": self.current_page,
179
+ "pageSize": self.page_size,
180
+ }
181
+
182
+ result = self.transport.get(self.endpoint, params=params)
183
+ if result is None:
184
+ raise StopIteration
185
+
186
+ page_info = result.get("page", {})
187
+ self.total_pages = page_info.get("totalPages", 1)
188
+
189
+ return result
190
+
191
+
192
+ class Transport:
193
+ """HTTP transport layer for making API requests."""
194
+
195
+ def __init__(
196
+ self,
197
+ base_url: str,
198
+ api_key: str,
199
+ timeout: int = DEFAULT_TIMEOUT,
200
+ max_retries: int = DEFAULT_MAX_RETRIES,
201
+ session: requests.Session | None = None,
202
+ ) -> None:
203
+ """Initialize Transport.
204
+
205
+ Args:
206
+ base_url: Base URL for the FreeScout API.
207
+ api_key: API key for authentication.
208
+ timeout: Request timeout in seconds.
209
+ max_retries: Maximum number of retries for failed requests.
210
+ session: Optional pre-configured requests Session.
211
+ """
212
+ self.base_url = base_url.rstrip("/")
213
+ self.api_key = api_key
214
+ self.timeout = timeout
215
+ self.session = session or create_session(max_retries=max_retries)
216
+
217
+ def _get_headers(self) -> dict[str, str]:
218
+ """Get headers for API requests."""
219
+ return {
220
+ "X-FreeScout-API-Key": self.api_key,
221
+ "Content-Type": "application/json",
222
+ "Accept": "application/json",
223
+ "User-Agent": f"freescout-api-python/{__version__}",
224
+ }
225
+
226
+ def _build_url(self, endpoint: str) -> str:
227
+ """Build full URL from endpoint."""
228
+ endpoint = endpoint.lstrip("/")
229
+ return f"{self.base_url}/api/{endpoint}"
230
+
231
+ def get(
232
+ self,
233
+ endpoint: str,
234
+ params: dict[str, Any] | None = None,
235
+ ) -> dict[str, Any] | None:
236
+ """Make a GET request.
237
+
238
+ Args:
239
+ endpoint: API endpoint path.
240
+ params: Query parameters.
241
+
242
+ Returns:
243
+ Response data.
244
+ """
245
+ url = self._build_url(endpoint)
246
+ response = self.session.get(
247
+ url,
248
+ headers=self._get_headers(),
249
+ params=params,
250
+ timeout=self.timeout,
251
+ )
252
+ return handle_response(response)
253
+
254
+ def post(
255
+ self,
256
+ endpoint: str,
257
+ data: dict[str, Any] | None = None,
258
+ ) -> dict[str, Any] | None:
259
+ """Make a POST request.
260
+
261
+ Args:
262
+ endpoint: API endpoint path.
263
+ data: Request body data.
264
+
265
+ Returns:
266
+ Response data.
267
+ """
268
+ url = self._build_url(endpoint)
269
+ response = self.session.post(
270
+ url,
271
+ headers=self._get_headers(),
272
+ json=data,
273
+ timeout=self.timeout,
274
+ )
275
+ return handle_response(response)
276
+
277
+ def put(
278
+ self,
279
+ endpoint: str,
280
+ data: dict[str, Any] | None = None,
281
+ ) -> dict[str, Any] | None:
282
+ """Make a PUT request.
283
+
284
+ Args:
285
+ endpoint: API endpoint path.
286
+ data: Request body data.
287
+
288
+ Returns:
289
+ Response data.
290
+ """
291
+ url = self._build_url(endpoint)
292
+ response = self.session.put(
293
+ url,
294
+ headers=self._get_headers(),
295
+ json=data,
296
+ timeout=self.timeout,
297
+ )
298
+ return handle_response(response)
299
+
300
+ def delete(
301
+ self,
302
+ endpoint: str,
303
+ ) -> dict[str, Any] | None:
304
+ """Make a DELETE request.
305
+
306
+ Args:
307
+ endpoint: API endpoint path.
308
+
309
+ Returns:
310
+ Response data.
311
+ """
312
+ url = self._build_url(endpoint)
313
+ response = self.session.delete(
314
+ url,
315
+ headers=self._get_headers(),
316
+ timeout=self.timeout,
317
+ )
318
+ return handle_response(response)
319
+
320
+ def paginate(
321
+ self,
322
+ endpoint: str,
323
+ params: dict[str, Any] | None = None,
324
+ page_size: int = 50,
325
+ max_pages: int | None = None,
326
+ ) -> Paginator:
327
+ """Create a paginator for the given endpoint.
328
+
329
+ Args:
330
+ endpoint: API endpoint path.
331
+ params: Query parameters.
332
+ page_size: Number of items per page.
333
+ max_pages: Maximum number of pages to fetch.
334
+
335
+ Returns:
336
+ Paginator instance.
337
+ """
338
+ return Paginator(
339
+ transport=self,
340
+ endpoint=endpoint,
341
+ params=params,
342
+ page_size=page_size,
343
+ max_pages=max_pages,
344
+ )
freescout/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Version information for freescout-api."""
2
+
3
+ __version__ = "0.1.0"
freescout/client.py ADDED
@@ -0,0 +1,168 @@
1
+ """FreeScout API Client."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import requests
7
+
8
+ from freescout._transport import Transport
9
+ from freescout.exceptions import FreeScoutError
10
+ from freescout.resources.conversations import ConversationsResource
11
+ from freescout.resources.customers import CustomersResource
12
+ from freescout.resources.mailboxes import MailboxesResource
13
+ from freescout.resources.tags import TagsResource
14
+ from freescout.resources.threads import ThreadsResource
15
+ from freescout.resources.users import UsersResource
16
+ from freescout.resources.webhooks import WebhooksResource
17
+
18
+
19
+ class FreeScoutClient:
20
+ """Client for interacting with the FreeScout API.
21
+
22
+ This client provides access to all FreeScout API resources through
23
+ resource-specific attributes.
24
+
25
+ Example:
26
+ ```python
27
+ from freescout import FreeScoutClient
28
+
29
+ # Using environment variables
30
+ client = FreeScoutClient()
31
+
32
+ # Or with explicit credentials
33
+ client = FreeScoutClient(
34
+ base_url="https://support.example.com",
35
+ api_key="your-api-key"
36
+ )
37
+
38
+ # List conversations
39
+ conversations = client.conversations.list(mailbox_id=1)
40
+ for conv in conversations.conversations:
41
+ print(conv.subject)
42
+
43
+ # Get a specific customer
44
+ customer = client.customers.get(123)
45
+ print(customer.first_name)
46
+ ```
47
+
48
+ Attributes:
49
+ conversations: Resource for managing conversations.
50
+ customers: Resource for managing customers.
51
+ mailboxes: Resource for managing mailboxes.
52
+ tags: Resource for managing tags.
53
+ threads: Resource for managing conversation threads.
54
+ users: Resource for managing users.
55
+ webhooks: Resource for managing webhooks.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ base_url: str | None = None,
61
+ api_key: str | None = None,
62
+ timeout: int = 30,
63
+ max_retries: int = 3,
64
+ session: requests.Session | None = None,
65
+ ) -> None:
66
+ """Initialize the FreeScout API client.
67
+
68
+ Args:
69
+ base_url: Base URL for the FreeScout instance. If not provided,
70
+ uses the FREESCOUT_URL environment variable.
71
+ api_key: API key for authentication. If not provided, uses the
72
+ FREESCOUT_API_KEY environment variable.
73
+ timeout: Request timeout in seconds. Defaults to 30.
74
+ max_retries: Maximum number of retries for failed requests.
75
+ Defaults to 3.
76
+ session: Optional pre-configured requests Session.
77
+
78
+ Raises:
79
+ FreeScoutError: If base_url or api_key is not provided and
80
+ corresponding environment variable is not set.
81
+ """
82
+ self._base_url = base_url or os.environ.get("FREESCOUT_URL")
83
+ self._api_key = api_key or os.environ.get("FREESCOUT_API_KEY")
84
+
85
+ if not self._base_url:
86
+ raise FreeScoutError(
87
+ "base_url is required. Provide it directly or set FREESCOUT_URL "
88
+ "environment variable."
89
+ )
90
+
91
+ if not self._api_key:
92
+ raise FreeScoutError(
93
+ "api_key is required. Provide it directly or set FREESCOUT_API_KEY "
94
+ "environment variable."
95
+ )
96
+
97
+ self._transport = Transport(
98
+ base_url=self._base_url,
99
+ api_key=self._api_key,
100
+ timeout=timeout,
101
+ max_retries=max_retries,
102
+ session=session,
103
+ )
104
+
105
+ self._conversations: ConversationsResource | None = None
106
+ self._customers: CustomersResource | None = None
107
+ self._mailboxes: MailboxesResource | None = None
108
+ self._tags: TagsResource | None = None
109
+ self._threads: ThreadsResource | None = None
110
+ self._users: UsersResource | None = None
111
+ self._webhooks: WebhooksResource | None = None
112
+
113
+ @property
114
+ def conversations(self) -> ConversationsResource:
115
+ """Access the conversations resource."""
116
+ if self._conversations is None:
117
+ self._conversations = ConversationsResource(self._transport)
118
+ return self._conversations
119
+
120
+ @property
121
+ def customers(self) -> CustomersResource:
122
+ """Access the customers resource."""
123
+ if self._customers is None:
124
+ self._customers = CustomersResource(self._transport)
125
+ return self._customers
126
+
127
+ @property
128
+ def mailboxes(self) -> MailboxesResource:
129
+ """Access the mailboxes resource."""
130
+ if self._mailboxes is None:
131
+ self._mailboxes = MailboxesResource(self._transport)
132
+ return self._mailboxes
133
+
134
+ @property
135
+ def tags(self) -> TagsResource:
136
+ """Access the tags resource."""
137
+ if self._tags is None:
138
+ self._tags = TagsResource(self._transport)
139
+ return self._tags
140
+
141
+ @property
142
+ def threads(self) -> ThreadsResource:
143
+ """Access the threads resource."""
144
+ if self._threads is None:
145
+ self._threads = ThreadsResource(self._transport)
146
+ return self._threads
147
+
148
+ @property
149
+ def users(self) -> UsersResource:
150
+ """Access the users resource."""
151
+ if self._users is None:
152
+ self._users = UsersResource(self._transport)
153
+ return self._users
154
+
155
+ @property
156
+ def webhooks(self) -> WebhooksResource:
157
+ """Access the webhooks resource."""
158
+ if self._webhooks is None:
159
+ self._webhooks = WebhooksResource(self._transport)
160
+ return self._webhooks
161
+
162
+ @property
163
+ def base_url(self) -> str:
164
+ """Get the base URL for the FreeScout instance."""
165
+ return self._base_url
166
+
167
+ def __repr__(self) -> str:
168
+ return f"FreeScoutClient(base_url='{self._base_url}')"