megaplan-sdk 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.
@@ -0,0 +1,180 @@
1
+ """Custom exceptions for Megaplan SDK."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class MegaplanError(Exception):
7
+ """Base exception for all Megaplan SDK errors.
8
+
9
+ Attributes:
10
+ message: Human-readable error message.
11
+ status_code: HTTP status code if available.
12
+ errors: List of error details from API response.
13
+ response: Full API response if available.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ status_code: int | None = None,
20
+ errors: list[dict[str, Any]] | None = None,
21
+ response: dict[str, Any] | None = None,
22
+ ) -> None:
23
+ """Initialize MegaplanError.
24
+
25
+ Args:
26
+ message: Error message.
27
+ status_code: HTTP status code.
28
+ errors: List of error details.
29
+ response: Full API response.
30
+ """
31
+ super().__init__(message)
32
+ self.message = message
33
+ self.status_code = status_code
34
+ self.errors = errors or []
35
+ self.response = response
36
+
37
+ def __str__(self) -> str:
38
+ """Return string representation of error."""
39
+ if self.status_code:
40
+ return f"{self.message} (HTTP {self.status_code})"
41
+ return self.message
42
+
43
+
44
+ class AuthenticationError(MegaplanError):
45
+ """Raised when authentication fails (401).
46
+
47
+ Typically occurs when credentials are invalid or token has expired.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ message: str = "Authentication failed",
53
+ errors: list[dict[str, Any]] | None = None,
54
+ response: dict[str, Any] | None = None,
55
+ ) -> None:
56
+ """Initialize AuthenticationError."""
57
+ super().__init__(message, status_code=401, errors=errors, response=response)
58
+
59
+
60
+ class AuthorizationError(MegaplanError):
61
+ """Raised when authorization fails (403).
62
+
63
+ Occurs when user doesn't have permission to access the resource.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ message: str = "Authorization failed",
69
+ errors: list[dict[str, Any]] | None = None,
70
+ response: dict[str, Any] | None = None,
71
+ ) -> None:
72
+ """Initialize AuthorizationError."""
73
+ super().__init__(message, status_code=403, errors=errors, response=response)
74
+
75
+
76
+ class NotFoundError(MegaplanError):
77
+ """Raised when resource is not found (404)."""
78
+
79
+ def __init__(
80
+ self,
81
+ message: str = "Resource not found",
82
+ errors: list[dict[str, Any]] | None = None,
83
+ response: dict[str, Any] | None = None,
84
+ ) -> None:
85
+ """Initialize NotFoundError."""
86
+ super().__init__(message, status_code=404, errors=errors, response=response)
87
+
88
+
89
+ class ValidationError(MegaplanError):
90
+ """Raised when request validation fails (422).
91
+
92
+ Contains detailed validation errors from API.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ message: str = "Validation failed",
98
+ errors: list[dict[str, Any]] | None = None,
99
+ response: dict[str, Any] | None = None,
100
+ ) -> None:
101
+ """Initialize ValidationError."""
102
+ super().__init__(message, status_code=422, errors=errors, response=response)
103
+
104
+
105
+ class RateLimitError(MegaplanError):
106
+ """Raised when rate limit is exceeded (429)."""
107
+
108
+ def __init__(
109
+ self,
110
+ message: str = "Rate limit exceeded",
111
+ errors: list[dict[str, Any]] | None = None,
112
+ response: dict[str, Any] | None = None,
113
+ ) -> None:
114
+ """Initialize RateLimitError."""
115
+ super().__init__(message, status_code=429, errors=errors, response=response)
116
+
117
+
118
+ class ServerError(MegaplanError):
119
+ """Raised when server returns 5xx error."""
120
+
121
+ def __init__(
122
+ self,
123
+ message: str = "Server error",
124
+ status_code: int = 500,
125
+ errors: list[dict[str, Any]] | None = None,
126
+ response: dict[str, Any] | None = None,
127
+ ) -> None:
128
+ """Initialize ServerError."""
129
+ super().__init__(message, status_code=status_code, errors=errors, response=response)
130
+
131
+
132
+ def raise_for_status(
133
+ status_code: int,
134
+ response: dict[str, Any],
135
+ default_message: str = "Request failed",
136
+ ) -> None:
137
+ """Raise appropriate exception based on HTTP status code.
138
+
139
+ Args:
140
+ status_code: HTTP status code.
141
+ response: API response dictionary.
142
+ default_message: Default error message.
143
+
144
+ Raises:
145
+ AuthenticationError: For 401 status.
146
+ AuthorizationError: For 403 status.
147
+ NotFoundError: For 404 status.
148
+ ValidationError: For 422 status.
149
+ RateLimitError: For 429 status.
150
+ ServerError: For 5xx status.
151
+ MegaplanError: For other error status codes.
152
+ """
153
+ meta = response.get("meta", {})
154
+ errors = meta.get("errors", [])
155
+ error_message = default_message
156
+
157
+ if errors and isinstance(errors, list) and len(errors) > 0:
158
+ first_error = errors[0]
159
+ if isinstance(first_error, dict) and "message" in first_error:
160
+ error_message = first_error["message"]
161
+
162
+ if status_code == 401:
163
+ raise AuthenticationError(error_message, errors=errors, response=response)
164
+ elif status_code == 403:
165
+ raise AuthorizationError(error_message, errors=errors, response=response)
166
+ elif status_code == 404:
167
+ raise NotFoundError(error_message, errors=errors, response=response)
168
+ elif status_code == 422:
169
+ raise ValidationError(error_message, errors=errors, response=response)
170
+ elif status_code == 429:
171
+ raise RateLimitError(error_message, errors=errors, response=response)
172
+ elif 500 <= status_code < 600:
173
+ raise ServerError(error_message, status_code=status_code, errors=errors, response=response)
174
+ else:
175
+ raise MegaplanError(
176
+ error_message,
177
+ status_code=status_code,
178
+ errors=errors,
179
+ response=response,
180
+ )
@@ -0,0 +1,108 @@
1
+ """Helper functions for working with Megaplan SDK.
2
+
3
+ Provides convenience functions for creating BaseEntity objects and simplifying
4
+ common operations.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from megaplan_sdk.constants import ContentType
10
+
11
+
12
+ def make_entity(content_type: str, entity_id: int) -> dict[str, Any]:
13
+ """Create a BaseEntity reference dictionary.
14
+
15
+ Args:
16
+ content_type: Entity content type (e.g., "Employee", "Project", "Task").
17
+ entity_id: Entity identifier.
18
+
19
+ Returns:
20
+ Dictionary representing a BaseEntity reference.
21
+
22
+ Examples:
23
+ >>> make_entity("Employee", 123)
24
+ {"contentType": "Employee", "id": 123}
25
+ >>> make_entity("Project", 456)
26
+ {"contentType": "Project", "id": 456}
27
+ """
28
+ return {"contentType": content_type, "id": entity_id}
29
+
30
+
31
+ def make_employee_entity(employee_id: int) -> dict[str, Any]:
32
+ """Create an Employee BaseEntity reference.
33
+
34
+ Args:
35
+ employee_id: Employee identifier.
36
+
37
+ Returns:
38
+ Dictionary representing an Employee reference.
39
+
40
+ Examples:
41
+ >>> make_employee_entity(123)
42
+ {"contentType": "Employee", "id": 123}
43
+ """
44
+ return make_entity(ContentType.EMPLOYEE, employee_id)
45
+
46
+
47
+ def make_project_entity(project_id: int) -> dict[str, Any]:
48
+ """Create a Project BaseEntity reference.
49
+
50
+ Args:
51
+ project_id: Project identifier.
52
+
53
+ Returns:
54
+ Dictionary representing a Project reference.
55
+
56
+ Examples:
57
+ >>> make_project_entity(456)
58
+ {"contentType": "Project", "id": 456}
59
+ """
60
+ return make_entity(ContentType.PROJECT, project_id)
61
+
62
+
63
+ def make_task_entity(task_id: int) -> dict[str, Any]:
64
+ """Create a Task BaseEntity reference.
65
+
66
+ Args:
67
+ task_id: Task identifier.
68
+
69
+ Returns:
70
+ Dictionary representing a Task reference.
71
+
72
+ Examples:
73
+ >>> make_task_entity(789)
74
+ {"contentType": "Task", "id": 789}
75
+ """
76
+ return make_entity(ContentType.TASK, task_id)
77
+
78
+
79
+ def make_deal_entity(deal_id: int) -> dict[str, Any]:
80
+ """Create a Deal BaseEntity reference.
81
+
82
+ Args:
83
+ deal_id: Deal identifier.
84
+
85
+ Returns:
86
+ Dictionary representing a Deal reference.
87
+
88
+ Examples:
89
+ >>> make_deal_entity(101)
90
+ {"contentType": "Deal", "id": 101}
91
+ """
92
+ return make_entity(ContentType.DEAL, deal_id)
93
+
94
+
95
+ def make_contractor_entity(contractor_id: int) -> dict[str, Any]:
96
+ """Create a Contractor BaseEntity reference.
97
+
98
+ Args:
99
+ contractor_id: Contractor identifier.
100
+
101
+ Returns:
102
+ Dictionary representing a Contractor reference.
103
+
104
+ Examples:
105
+ >>> make_contractor_entity(202)
106
+ {"contentType": "Contractor", "id": 202}
107
+ """
108
+ return make_entity(ContentType.CONTRACTOR, contractor_id)
@@ -0,0 +1,390 @@
1
+ """HTTP client for Megaplan API."""
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from megaplan_sdk.exceptions import raise_for_status
10
+ from megaplan_sdk.logging_config import logger, sanitize_dict
11
+
12
+
13
+ class HTTPClient:
14
+ """HTTP client with authentication, retry logic, and response validation.
15
+
16
+ Handles:
17
+ - Automatic access token injection
18
+ - JSON parameters in query string
19
+ - Retry logic with exponential backoff
20
+ - Response validation
21
+ - Error handling
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ base_url: str,
27
+ access_token: str | None = None,
28
+ timeout: float = 30.0,
29
+ max_retries: int = 3,
30
+ allow_http: bool = False,
31
+ ) -> None:
32
+ """Initialize HTTP client.
33
+
34
+ Args:
35
+ base_url: Base URL for Megaplan API (e.g., https://example.megaplan.ru).
36
+ access_token: Optional access token for authentication.
37
+ timeout: Request timeout in seconds.
38
+ max_retries: Maximum number of retry attempts for 5xx errors.
39
+ allow_http: Allow HTTP connections (insecure, only for dev/test).
40
+
41
+ Raises:
42
+ ValueError: If base_url is not HTTPS and allow_http is False.
43
+ """
44
+ # Security: Validate HTTPS URL
45
+ if not base_url.startswith("https://") and not allow_http:
46
+ raise ValueError(
47
+ f"Only HTTPS URLs are allowed for security. Got: {base_url}. "
48
+ f"Use allow_http=True only for development/testing."
49
+ )
50
+
51
+ self.base_url = base_url.rstrip("/")
52
+ self.access_token = access_token
53
+ self.timeout = timeout
54
+ self.max_retries = max_retries
55
+ self._client: httpx.AsyncClient | None = None
56
+
57
+ async def __aenter__(self) -> "HTTPClient":
58
+ """Async context manager entry."""
59
+ await self._ensure_client()
60
+ return self
61
+
62
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
63
+ """Async context manager exit."""
64
+ await self.close()
65
+
66
+ async def _ensure_client(self) -> None:
67
+ """Ensure HTTP client is initialized."""
68
+ if self._client is None:
69
+ # Configure connection pooling for better performance
70
+ limits = httpx.Limits(
71
+ max_connections=100, # Maximum total connections
72
+ max_keepalive_connections=20, # Keep 20 connections alive
73
+ keepalive_expiry=30.0, # Keep connections alive for 30 seconds
74
+ )
75
+
76
+ self._client = httpx.AsyncClient(
77
+ base_url=self.base_url,
78
+ timeout=self.timeout,
79
+ headers={"Content-Type": "application/json"},
80
+ limits=limits,
81
+ follow_redirects=True, # Follow redirects automatically
82
+ )
83
+
84
+ async def close(self) -> None:
85
+ """Close HTTP client."""
86
+ if self._client is not None:
87
+ await self._client.aclose()
88
+ self._client = None
89
+
90
+ def set_access_token(self, access_token: str | None) -> None:
91
+ """Set access token for authentication.
92
+
93
+ Args:
94
+ access_token: OAuth2 access token (or None to clear).
95
+ """
96
+ self.access_token = access_token
97
+
98
+ def _build_url(self, path: str, params: dict[str, Any] | None = None) -> str:
99
+ """Build URL with JSON parameters in query string.
100
+
101
+ Megaplan API expects JSON parameters in query string format:
102
+ /api/v3/task?{"limit":5}
103
+
104
+ Args:
105
+ path: API path (e.g., /api/v3/task).
106
+ params: Query parameters as dictionary.
107
+
108
+ Returns:
109
+ Full URL with query string.
110
+ """
111
+ url = f"{self.base_url}{path}"
112
+
113
+ if params:
114
+ params_json = json.dumps(params, ensure_ascii=False)
115
+ url = f"{url}?{params_json}"
116
+
117
+ return url
118
+
119
+ def _build_headers(self, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
120
+ """Build request headers with authentication.
121
+
122
+ Args:
123
+ extra_headers: Additional headers to include.
124
+
125
+ Returns:
126
+ Headers dictionary.
127
+ """
128
+ headers: dict[str, str] = {"Content-Type": "application/json"}
129
+
130
+ if self.access_token:
131
+ headers["Authorization"] = f"Bearer {self.access_token}"
132
+
133
+ if extra_headers:
134
+ headers.update(extra_headers)
135
+
136
+ return headers
137
+
138
+ async def _request(
139
+ self,
140
+ method: str,
141
+ path: str,
142
+ params: dict[str, Any] | None = None,
143
+ json_data: dict[str, Any] | None = None,
144
+ files: dict[str, Any] | None = None,
145
+ headers: dict[str, str] | None = None,
146
+ ) -> dict[str, Any]:
147
+ """Make HTTP request with retry logic.
148
+
149
+ Args:
150
+ method: HTTP method (GET, POST, DELETE, etc.).
151
+ path: API path.
152
+ params: Query parameters.
153
+ json_data: JSON body data.
154
+ files: Files for multipart/form-data.
155
+ headers: Additional headers.
156
+
157
+ Returns:
158
+ Response JSON as dictionary.
159
+
160
+ Raises:
161
+ MegaplanError: For various error conditions.
162
+ """
163
+ await self._ensure_client()
164
+ assert self._client is not None # For mypy: ensured by _ensure_client()
165
+
166
+ url = self._build_url(path, params)
167
+ request_headers = self._build_headers(headers)
168
+
169
+ if files:
170
+ request_headers.pop("Content-Type", None)
171
+
172
+ for attempt in range(self.max_retries + 1):
173
+ try:
174
+ # LOG REQUEST
175
+ logger.debug(
176
+ f"Making {method} request to {path}",
177
+ extra={
178
+ "method": method,
179
+ "path": path,
180
+ "params": sanitize_dict(params) if params else None,
181
+ "attempt": attempt + 1,
182
+ },
183
+ )
184
+
185
+ response = await self._client.request(
186
+ method=method,
187
+ url=url,
188
+ headers=request_headers,
189
+ json=json_data,
190
+ files=files,
191
+ )
192
+
193
+ response.raise_for_status()
194
+ response_data: dict[str, Any] = response.json()
195
+
196
+ meta = response_data.get("meta", {})
197
+ status = meta.get("status", response.status_code)
198
+
199
+ if status != 200:
200
+ raise_for_status(status, response_data)
201
+
202
+ # LOG SUCCESS
203
+ logger.debug(
204
+ f"{method} {path} succeeded",
205
+ extra={"status_code": response.status_code, "attempt": attempt + 1},
206
+ )
207
+
208
+ return response_data
209
+
210
+ except httpx.HTTPStatusError as e:
211
+ status_code = e.response.status_code
212
+
213
+ # LOG HTTP ERROR
214
+ logger.warning(
215
+ f"HTTP error {status_code} on {method} {path}",
216
+ extra={"status_code": status_code, "attempt": attempt + 1},
217
+ )
218
+
219
+ # Handle 429 Rate Limit
220
+ if status_code == 429:
221
+ retry_after = e.response.headers.get("Retry-After", "60")
222
+ try:
223
+ wait_time = int(retry_after)
224
+ except ValueError:
225
+ # Retry-After might be a date string, default to 60s
226
+ wait_time = 60
227
+
228
+ logger.warning(
229
+ f"Rate limit exceeded (429). Retry after {wait_time}s",
230
+ extra={"wait_time": wait_time, "attempt": attempt + 1},
231
+ )
232
+
233
+ if attempt < self.max_retries:
234
+ await asyncio.sleep(wait_time)
235
+ continue
236
+
237
+ # Handle 5xx Server Errors
238
+ if 500 <= status_code < 600 and attempt < self.max_retries:
239
+ # Check Retry-After header
240
+ retry_after_header = e.response.headers.get("Retry-After")
241
+ if retry_after_header:
242
+ try:
243
+ wait_time = int(retry_after_header)
244
+ except ValueError:
245
+ wait_time = 2**attempt
246
+ else:
247
+ wait_time = 2**attempt
248
+
249
+ logger.info(
250
+ f"Retrying after {wait_time}s (attempt {attempt + 1}/{self.max_retries})",
251
+ extra={"wait_time": wait_time, "attempt": attempt + 1},
252
+ )
253
+ await asyncio.sleep(wait_time)
254
+ continue
255
+
256
+ # Parse error response
257
+ response_data = {}
258
+ if e.response:
259
+ try:
260
+ response_data = e.response.json()
261
+ except json.JSONDecodeError:
262
+ logger.warning("Failed to parse error response as JSON")
263
+ response_data = {"meta": {"errors": [{"message": e.response.text[:200]}]}}
264
+
265
+ raise_for_status(status_code, response_data)
266
+
267
+ except httpx.RequestError as e:
268
+ # LOG REQUEST ERROR
269
+ logger.warning(
270
+ f"Request error on {method} {path}: {str(e)}",
271
+ extra={"error_type": type(e).__name__, "attempt": attempt + 1},
272
+ )
273
+
274
+ if attempt < self.max_retries:
275
+ wait_time = 2**attempt
276
+ logger.info(
277
+ f"Retrying after {wait_time}s (attempt {attempt + 1}/{self.max_retries})",
278
+ extra={"wait_time": wait_time, "attempt": attempt + 1},
279
+ )
280
+ await asyncio.sleep(wait_time)
281
+ continue
282
+ raise
283
+
284
+ # Note: This point is never reached because:
285
+ # - Success case returns via 'return response_data'
286
+ # - Error cases either raise immediately or continue retrying
287
+ # - After max retries, exceptions are re-raised above
288
+ raise RuntimeError("Unexpected error in request")
289
+
290
+ async def get(
291
+ self,
292
+ path: str,
293
+ params: dict[str, Any] | None = None,
294
+ headers: dict[str, str] | None = None,
295
+ ) -> dict[str, Any]:
296
+ """Make GET request.
297
+
298
+ Args:
299
+ path: API path.
300
+ params: Query parameters.
301
+ headers: Additional headers.
302
+
303
+ Returns:
304
+ Response JSON as dictionary.
305
+ """
306
+ return await self._request("GET", path, params=params, headers=headers)
307
+
308
+ async def post(
309
+ self,
310
+ path: str,
311
+ json_data: dict[str, Any] | None = None,
312
+ files: dict[str, Any] | None = None,
313
+ params: dict[str, Any] | None = None,
314
+ headers: dict[str, str] | None = None,
315
+ ) -> dict[str, Any]:
316
+ """Make POST request.
317
+
318
+ Args:
319
+ path: API path.
320
+ json_data: JSON body data.
321
+ files: Files for multipart/form-data.
322
+ params: Query parameters.
323
+ headers: Additional headers.
324
+
325
+ Returns:
326
+ Response JSON as dictionary.
327
+ """
328
+ return await self._request(
329
+ "POST", path, json_data=json_data, files=files, params=params, headers=headers
330
+ )
331
+
332
+ async def put(
333
+ self,
334
+ path: str,
335
+ json_data: dict[str, Any] | None = None,
336
+ params: dict[str, Any] | None = None,
337
+ headers: dict[str, str] | None = None,
338
+ ) -> dict[str, Any]:
339
+ """Make PUT request.
340
+
341
+ Args:
342
+ path: API path.
343
+ json_data: JSON body data.
344
+ params: Query parameters.
345
+ headers: Additional headers.
346
+
347
+ Returns:
348
+ Response JSON as dictionary.
349
+ """
350
+ return await self._request("PUT", path, json_data=json_data, params=params, headers=headers)
351
+
352
+ async def delete(
353
+ self,
354
+ path: str,
355
+ params: dict[str, Any] | None = None,
356
+ headers: dict[str, str] | None = None,
357
+ ) -> dict[str, Any]:
358
+ """Make DELETE request.
359
+
360
+ Args:
361
+ path: API path.
362
+ params: Query parameters.
363
+ headers: Additional headers.
364
+
365
+ Returns:
366
+ Response JSON as dictionary.
367
+ """
368
+ return await self._request("DELETE", path, params=params, headers=headers)
369
+
370
+ async def post_form(
371
+ self,
372
+ url: str,
373
+ data: dict[str, Any],
374
+ headers: dict[str, str] | None = None,
375
+ ) -> httpx.Response:
376
+ """Make POST request with form data (for auth).
377
+
378
+ This is a public method for authentication that doesn't require a token.
379
+
380
+ Args:
381
+ url: Full URL (not just path).
382
+ data: Form data dictionary.
383
+ headers: Optional headers.
384
+
385
+ Returns:
386
+ Raw httpx Response object.
387
+ """
388
+ await self._ensure_client()
389
+ assert self._client is not None # For mypy: ensured by _ensure_client()
390
+ return await self._client.post(url, data=data, headers=headers)