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,67 @@
1
+ """Megaplan Python SDK - Professional SDK for Megaplan API v3."""
2
+
3
+ from megaplan_sdk.client import MegaplanClient
4
+ from megaplan_sdk.exceptions import (
5
+ AuthenticationError,
6
+ AuthorizationError,
7
+ MegaplanError,
8
+ NotFoundError,
9
+ RateLimitError,
10
+ ServerError,
11
+ ValidationError,
12
+ )
13
+ from megaplan_sdk.helpers import (
14
+ make_contractor_entity,
15
+ make_deal_entity,
16
+ make_employee_entity,
17
+ make_entity,
18
+ make_project_entity,
19
+ make_task_entity,
20
+ )
21
+ from megaplan_sdk.logging_config import setup_logging
22
+ from megaplan_sdk.models.comment import Comment
23
+ from megaplan_sdk.models.common import DateTime
24
+ from megaplan_sdk.models.contractor import Contractor, ContractorCompany, ContractorHuman
25
+ from megaplan_sdk.models.deal import Deal, DealFullDetails
26
+ from megaplan_sdk.models.department import Department
27
+ from megaplan_sdk.models.employee import Employee
28
+ from megaplan_sdk.models.project import Project, ProjectFullDetails
29
+ from megaplan_sdk.models.task import Task, TaskFullDetails
30
+
31
+ __all__ = [
32
+ # Client
33
+ "MegaplanClient",
34
+ # Exceptions
35
+ "MegaplanError",
36
+ "AuthenticationError",
37
+ "AuthorizationError",
38
+ "NotFoundError",
39
+ "ValidationError",
40
+ "RateLimitError",
41
+ "ServerError",
42
+ # Models
43
+ "Task",
44
+ "TaskFullDetails",
45
+ "Project",
46
+ "ProjectFullDetails",
47
+ "Deal",
48
+ "DealFullDetails",
49
+ "Comment",
50
+ "Contractor",
51
+ "ContractorCompany",
52
+ "ContractorHuman",
53
+ "Employee",
54
+ "Department",
55
+ "DateTime",
56
+ # Utils
57
+ "setup_logging",
58
+ # Helpers
59
+ "make_entity",
60
+ "make_employee_entity",
61
+ "make_project_entity",
62
+ "make_task_entity",
63
+ "make_deal_entity",
64
+ "make_contractor_entity",
65
+ ]
66
+
67
+ __version__ = "0.1.0"
megaplan_sdk/auth.py ADDED
@@ -0,0 +1,185 @@
1
+ """OAuth2 authentication for Megaplan API."""
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+
7
+ import httpx
8
+
9
+ from megaplan_sdk.exceptions import AuthenticationError
10
+ from megaplan_sdk.http_client import HTTPClient
11
+ from megaplan_sdk.logging_config import logger
12
+ from megaplan_sdk.types import AuthTokenResponse
13
+
14
+
15
+ class AuthManager:
16
+ """Manages OAuth2 authentication and token lifecycle.
17
+
18
+ Handles token acquisition, refresh, and expiration tracking.
19
+ """
20
+
21
+ def __init__(self, http_client: HTTPClient) -> None:
22
+ """Initialize auth manager.
23
+
24
+ Args:
25
+ http_client: HTTP client for making requests.
26
+ """
27
+ self._http = http_client
28
+ self._access_token: str | None = None
29
+ self._refresh_token: str | None = None
30
+ self._expires_at: float | None = None
31
+ self._refresh_lock = asyncio.Lock()
32
+
33
+ async def authenticate(self, username: str, password: str) -> str:
34
+ """Authenticate with username and password.
35
+
36
+ Args:
37
+ username: User email or username.
38
+ password: User password.
39
+
40
+ Returns:
41
+ Access token.
42
+
43
+ Raises:
44
+ AuthenticationError: If authentication fails.
45
+ """
46
+ logger.info(f"Authenticating user: {username}")
47
+
48
+ try:
49
+ response = await self._http.post_form(
50
+ f"{self._http.base_url}/api/v3/auth/access_token",
51
+ data={
52
+ "username": username,
53
+ "password": password,
54
+ "grant_type": "password",
55
+ },
56
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
57
+ )
58
+
59
+ response.raise_for_status()
60
+ token_data: AuthTokenResponse = response.json()
61
+
62
+ self._access_token = token_data["access_token"]
63
+ self._refresh_token = token_data.get("refresh_token")
64
+ expires_in = token_data.get("expires_in", 172800)
65
+ self._expires_at = time.time() + expires_in
66
+
67
+ self._http.set_access_token(self._access_token)
68
+
69
+ logger.info(f"Authentication successful for user: {username}")
70
+
71
+ return self._access_token
72
+
73
+ except (httpx.HTTPError, httpx.TimeoutException, httpx.RequestError) as e:
74
+ logger.error(f"Authentication network error for {username}: {str(e)}")
75
+ raise AuthenticationError(f"Authentication failed: {str(e)}") from e
76
+ except (json.JSONDecodeError, KeyError) as e:
77
+ logger.error(f"Authentication response parsing error for {username}: {str(e)}")
78
+ raise AuthenticationError(f"Invalid authentication response: {str(e)}") from e
79
+
80
+ async def refresh(self, refresh_token: str | None = None) -> str:
81
+ """Refresh access token.
82
+
83
+ Args:
84
+ refresh_token: Optional refresh token. Uses stored token if not provided.
85
+
86
+ Returns:
87
+ New access token.
88
+
89
+ Raises:
90
+ AuthenticationError: If refresh fails.
91
+ """
92
+ token = refresh_token or self._refresh_token
93
+ if not token:
94
+ logger.error("No refresh token available for token refresh")
95
+ raise AuthenticationError("No refresh token available")
96
+
97
+ logger.info("Refreshing access token")
98
+
99
+ try:
100
+ response = await self._http.post_form(
101
+ f"{self._http.base_url}/api/v3/auth/access_token",
102
+ data={
103
+ "refresh_token": token,
104
+ "grant_type": "refresh_token",
105
+ },
106
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
107
+ )
108
+
109
+ response.raise_for_status()
110
+ token_data: AuthTokenResponse = response.json()
111
+
112
+ self._access_token = token_data["access_token"]
113
+ self._refresh_token = token_data.get("refresh_token")
114
+ expires_in = token_data.get("expires_in", 172800)
115
+ self._expires_at = time.time() + expires_in
116
+
117
+ self._http.set_access_token(self._access_token)
118
+
119
+ logger.info("Token refresh successful")
120
+
121
+ return self._access_token
122
+
123
+ except (httpx.HTTPError, httpx.TimeoutException, httpx.RequestError) as e:
124
+ logger.error(f"Token refresh network error: {str(e)}")
125
+ raise AuthenticationError(f"Token refresh failed: {str(e)}") from e
126
+ except (json.JSONDecodeError, KeyError) as e:
127
+ logger.error(f"Token refresh response parsing error: {str(e)}")
128
+ raise AuthenticationError(f"Invalid token refresh response: {str(e)}") from e
129
+
130
+ def get_access_token(self) -> str | None:
131
+ """Get current access token.
132
+
133
+ Returns:
134
+ Access token or None if not authenticated.
135
+ """
136
+ return self._access_token
137
+
138
+ def is_token_expired(self, buffer_seconds: int = 60) -> bool:
139
+ """Check if token is expired or will expire soon.
140
+
141
+ Args:
142
+ buffer_seconds: Seconds before expiration to consider token expired.
143
+
144
+ Returns:
145
+ True if token is expired or will expire within buffer.
146
+ """
147
+ if not self._expires_at:
148
+ return True
149
+ return time.time() >= (self._expires_at - buffer_seconds)
150
+
151
+ async def ensure_authenticated(self, username: str, password: str) -> str:
152
+ """Ensure we have a valid access token.
153
+
154
+ Authenticates if needed or refreshes token if expired.
155
+ Uses lock to prevent race conditions when multiple requests
156
+ try to refresh token simultaneously.
157
+
158
+ Args:
159
+ username: Username for authentication.
160
+ password: Password for authentication.
161
+
162
+ Returns:
163
+ Valid access token.
164
+ """
165
+ if not self._access_token or self.is_token_expired():
166
+ async with self._refresh_lock:
167
+ # Double-check after acquiring lock
168
+ # Another coroutine might have already refreshed the token
169
+ if not self._access_token or self.is_token_expired():
170
+ if self._refresh_token and not self.is_token_expired(buffer_seconds=3600):
171
+ try:
172
+ return await self.refresh()
173
+ except AuthenticationError:
174
+ pass
175
+
176
+ return await self.authenticate(username, password)
177
+
178
+ return self._access_token
179
+
180
+ def clear_tokens(self) -> None:
181
+ """Clear stored tokens."""
182
+ self._access_token = None
183
+ self._refresh_token = None
184
+ self._expires_at = None
185
+ self._http.set_access_token(None)
megaplan_sdk/cache.py ADDED
@@ -0,0 +1,192 @@
1
+ """Entity caching system for Megaplan SDK."""
2
+
3
+ from dataclasses import dataclass
4
+ from time import time
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class CacheEntry:
10
+ """Cache entry with data and expiration time.
11
+
12
+ Attributes:
13
+ data: Cached entity data (dict or model).
14
+ expires_at: Expiration timestamp (seconds since epoch).
15
+ """
16
+
17
+ data: Any
18
+ expires_at: float
19
+
20
+
21
+ class EntityCache:
22
+ """LRU cache for entities with TTL.
23
+
24
+ Thread-safe cache with automatic expiration and size limits.
25
+ Uses (contentType, id) as key for entity storage.
26
+
27
+ Attributes:
28
+ max_size: Maximum number of entities to cache.
29
+ ttl: Time-to-live in seconds for cached entities.
30
+
31
+ Examples:
32
+ >>> cache = EntityCache(max_size=1000, ttl=300)
33
+ >>> cache.set("Employee", 123, {"id": 123, "name": "John"})
34
+ >>> data = cache.get("Employee", 123)
35
+ >>> print(data)
36
+ {'id': 123, 'name': 'John'}
37
+ """
38
+
39
+ def __init__(self, max_size: int = 1000, ttl: int = 300) -> None:
40
+ """Initialize entity cache.
41
+
42
+ Args:
43
+ max_size: Maximum cache size (default: 1000 entities).
44
+ ttl: Time-to-live in seconds (default: 300 = 5 minutes).
45
+ """
46
+ self._cache: dict[tuple[str, int], CacheEntry] = {}
47
+ self._max_size = max_size
48
+ self._ttl = ttl
49
+ self._access_order: list[tuple[str, int]] = [] # For LRU
50
+
51
+ def get(self, content_type: str, entity_id: int) -> Any | None:
52
+ """Get entity from cache if not expired.
53
+
54
+ Args:
55
+ content_type: Entity content type (e.g., "Employee").
56
+ entity_id: Entity identifier.
57
+
58
+ Returns:
59
+ Cached entity data or None if not found/expired.
60
+
61
+ Examples:
62
+ >>> cache = EntityCache()
63
+ >>> cache.set("Employee", 123, {"name": "John"})
64
+ >>> data = cache.get("Employee", 123)
65
+ >>> print(data)
66
+ {'name': 'John'}
67
+ """
68
+ key = (content_type, entity_id)
69
+ entry = self._cache.get(key)
70
+
71
+ if entry is None:
72
+ return None
73
+
74
+ # Check expiration
75
+ if time() > entry.expires_at:
76
+ # Expired - remove from cache
77
+ self._cache.pop(key, None)
78
+ if key in self._access_order:
79
+ self._access_order.remove(key)
80
+ return None
81
+
82
+ # Update access order (LRU)
83
+ if key in self._access_order:
84
+ self._access_order.remove(key)
85
+ self._access_order.append(key)
86
+
87
+ return entry.data
88
+
89
+ def set(self, content_type: str, entity_id: int, data: Any) -> None:
90
+ """Add entity to cache with expiration.
91
+
92
+ Args:
93
+ content_type: Entity content type (e.g., "Employee").
94
+ entity_id: Entity identifier.
95
+ data: Entity data to cache.
96
+
97
+ Examples:
98
+ >>> cache = EntityCache()
99
+ >>> cache.set("Employee", 123, {"id": 123, "name": "John"})
100
+ """
101
+ key = (content_type, entity_id)
102
+
103
+ # Evict oldest if at capacity
104
+ if len(self._cache) >= self._max_size and key not in self._cache:
105
+ if self._access_order:
106
+ oldest_key = self._access_order.pop(0)
107
+ self._cache.pop(oldest_key, None)
108
+
109
+ # Store entry
110
+ self._cache[key] = CacheEntry(
111
+ data=data,
112
+ expires_at=time() + self._ttl,
113
+ )
114
+
115
+ # Update access order
116
+ if key in self._access_order:
117
+ self._access_order.remove(key)
118
+ self._access_order.append(key)
119
+
120
+ def clear(self) -> None:
121
+ """Clear all cached entities.
122
+
123
+ Examples:
124
+ >>> cache = EntityCache()
125
+ >>> cache.set("Employee", 1, {"id": 1})
126
+ >>> cache.clear()
127
+ >>> assert cache.get("Employee", 1) is None
128
+ """
129
+ self._cache.clear()
130
+ self._access_order.clear()
131
+
132
+ def clear_type(self, content_type: str) -> None:
133
+ """Clear cache for specific entity type.
134
+
135
+ Args:
136
+ content_type: Entity type to clear (e.g., "Employee").
137
+
138
+ Examples:
139
+ >>> cache = EntityCache()
140
+ >>> cache.set("Employee", 1, {"id": 1})
141
+ >>> cache.set("Task", 2, {"id": 2})
142
+ >>> cache.clear_type("Employee")
143
+ >>> assert cache.get("Employee", 1) is None
144
+ >>> assert cache.get("Task", 2) is not None
145
+ """
146
+ keys_to_remove = [key for key in self._cache.keys() if key[0] == content_type]
147
+
148
+ for key in keys_to_remove:
149
+ self._cache.pop(key, None)
150
+ if key in self._access_order:
151
+ self._access_order.remove(key)
152
+
153
+ def size(self) -> int:
154
+ """Get current cache size.
155
+
156
+ Returns:
157
+ Number of cached entities.
158
+
159
+ Examples:
160
+ >>> cache = EntityCache()
161
+ >>> cache.set("Employee", 1, {"id": 1})
162
+ >>> cache.set("Employee", 2, {"id": 2})
163
+ >>> assert cache.size() == 2
164
+ """
165
+ return len(self._cache)
166
+
167
+ def stats(self) -> dict[str, Any]:
168
+ """Get cache statistics.
169
+
170
+ Returns:
171
+ Dict with cache stats (size, types, etc.).
172
+
173
+ Examples:
174
+ >>> cache = EntityCache()
175
+ >>> cache.set("Employee", 1, {"id": 1})
176
+ >>> cache.set("Task", 2, {"id": 2})
177
+ >>> stats = cache.stats()
178
+ >>> print(stats["size"])
179
+ 2
180
+ >>> print(stats["types"])
181
+ {'Employee': 1, 'Task': 1}
182
+ """
183
+ type_counts: dict[str, int] = {}
184
+ for content_type, _ in self._cache.keys():
185
+ type_counts[content_type] = type_counts.get(content_type, 0) + 1
186
+
187
+ return {
188
+ "size": len(self._cache),
189
+ "max_size": self._max_size,
190
+ "ttl": self._ttl,
191
+ "types": type_counts,
192
+ }
megaplan_sdk/client.py ADDED
@@ -0,0 +1,201 @@
1
+ """Main client for Megaplan SDK."""
2
+
3
+ from types import TracebackType
4
+
5
+ from megaplan_sdk.auth import AuthManager
6
+ from megaplan_sdk.cache import EntityCache
7
+ from megaplan_sdk.http_client import HTTPClient
8
+ from megaplan_sdk.logging_config import logger, setup_logging
9
+ from megaplan_sdk.resources.auth import AuthResource
10
+ from megaplan_sdk.resources.comments import CommentsResource
11
+ from megaplan_sdk.resources.contractors import ContractorsResource
12
+ from megaplan_sdk.resources.deals import DealsResource
13
+ from megaplan_sdk.resources.departments import DepartmentsResource
14
+ from megaplan_sdk.resources.employees import EmployeesResource
15
+ from megaplan_sdk.resources.projects import ProjectsResource
16
+ from megaplan_sdk.resources.tasks import TasksResource
17
+
18
+
19
+ class MegaplanClient:
20
+ """Main client for Megaplan API.
21
+
22
+ Coordinates all resources and handles authentication.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ base_url: str,
28
+ username: str | None = None,
29
+ password: str | None = None,
30
+ access_token: str | None = None,
31
+ timeout: float = 30.0,
32
+ max_retries: int = 3,
33
+ allow_http: bool = False,
34
+ log_level: str = "WARNING",
35
+ enable_cache: bool = True,
36
+ cache_ttl: int = 300,
37
+ cache_max_size: int = 1000,
38
+ default_comments_limit: int | None = None,
39
+ default_history_limit: int | None = None,
40
+ ) -> None:
41
+ """Initialize Megaplan client.
42
+
43
+ Args:
44
+ base_url: Base URL for Megaplan API (e.g., https://example.megaplan.ru).
45
+ username: Username for authentication (optional if access_token provided).
46
+ password: Password for authentication (optional if access_token provided).
47
+ Note: Password is NOT stored in memory for security reasons.
48
+ access_token: Pre-obtained access token (optional).
49
+ timeout: Request timeout in seconds.
50
+ max_retries: Maximum number of retry attempts for 5xx errors.
51
+ allow_http: Allow HTTP connections (insecure, only for dev/test).
52
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
53
+ enable_cache: Enable entity caching (default: True).
54
+ cache_ttl: Cache time-to-live in seconds (default: 300 = 5 minutes).
55
+ cache_max_size: Maximum number of cached entities (default: 1000).
56
+ default_comments_limit: Default limit for comments in get_full_details().
57
+ None = use Megaplan API default (no explicit limit).
58
+ This value is used only if comments_limit is not specified in method call.
59
+ default_history_limit: Default limit for history in get_full_details().
60
+ None = use Megaplan API default (no explicit limit).
61
+ This value is used only if history_limit is not specified in method call.
62
+
63
+ Security Note:
64
+ For production use, it's recommended to use refresh tokens or pre-obtained
65
+ access_token instead of username/password authentication.
66
+ """
67
+ # Setup logging
68
+ setup_logging(log_level)
69
+ logger.info(f"Initializing MegaplanClient for {base_url}")
70
+
71
+ self.base_url = base_url
72
+ self.username = username
73
+ # Security: Do NOT store password in plain text
74
+ self._http = HTTPClient(
75
+ base_url,
76
+ access_token=access_token,
77
+ timeout=timeout,
78
+ max_retries=max_retries,
79
+ allow_http=allow_http,
80
+ )
81
+ self._auth_manager = AuthManager(self._http)
82
+
83
+ # Initialize entity cache
84
+ self._cache = EntityCache(max_size=cache_max_size, ttl=cache_ttl) if enable_cache else None
85
+ if self._cache:
86
+ logger.debug(f"Entity cache enabled (max_size={cache_max_size}, ttl={cache_ttl}s)")
87
+
88
+ if access_token:
89
+ self._auth_manager._access_token = access_token
90
+ self._auth_manager._expires_at = None
91
+ logger.debug("MegaplanClient initialized with access_token")
92
+
93
+ self.auth = AuthResource(self._http, cache=self._cache)
94
+ self.tasks = TasksResource(
95
+ self._http,
96
+ cache=self._cache,
97
+ default_comments_limit=default_comments_limit,
98
+ default_history_limit=default_history_limit,
99
+ )
100
+ self.projects = ProjectsResource(
101
+ self._http,
102
+ cache=self._cache,
103
+ default_comments_limit=default_comments_limit,
104
+ default_history_limit=default_history_limit,
105
+ )
106
+ self.deals = DealsResource(
107
+ self._http,
108
+ cache=self._cache,
109
+ default_comments_limit=default_comments_limit,
110
+ default_history_limit=default_history_limit,
111
+ )
112
+ self.comments = CommentsResource(self._http, cache=self._cache)
113
+ self.contractors = ContractorsResource(self._http, cache=self._cache)
114
+ self.employees = EmployeesResource(self._http, cache=self._cache)
115
+ self.departments = DepartmentsResource(self._http, cache=self._cache)
116
+
117
+ # Security: Store password only for initial authentication if provided
118
+ self._initial_password = password if (username and password) else None
119
+ if self._initial_password:
120
+ logger.debug("MegaplanClient initialized with username/password")
121
+
122
+ async def __aenter__(self) -> "MegaplanClient":
123
+ """Async context manager entry."""
124
+ logger.debug("Entering MegaplanClient context")
125
+ await self._http._ensure_client()
126
+
127
+ # Perform initial authentication if credentials provided
128
+ if self._initial_password and self.username:
129
+ await self._auth_manager.authenticate(self.username, self._initial_password)
130
+ # Clear password from memory after first use
131
+ self._initial_password = None
132
+
133
+ logger.debug("MegaplanClient context ready")
134
+ return self
135
+
136
+ async def __aexit__(
137
+ self,
138
+ exc_type: type[BaseException] | None,
139
+ exc_val: BaseException | None,
140
+ exc_tb: TracebackType | None,
141
+ ) -> None:
142
+ """Async context manager exit."""
143
+ await self.close()
144
+
145
+ async def authenticate(self, username: str, password: str) -> str:
146
+ """Manually authenticate with username and password.
147
+
148
+ Args:
149
+ username: User email or username.
150
+ password: User password.
151
+
152
+ Returns:
153
+ Access token.
154
+
155
+ Note:
156
+ For security, password is not stored. Use refresh tokens for
157
+ subsequent authentications.
158
+ """
159
+ return await self._auth_manager.authenticate(username, password)
160
+
161
+ async def close(self) -> None:
162
+ """Close client and cleanup resources."""
163
+ logger.debug("Closing MegaplanClient")
164
+ await self._http.close()
165
+ logger.debug("MegaplanClient closed")
166
+
167
+ def set_access_token(self, access_token: str) -> None:
168
+ """Set access token manually.
169
+
170
+ Args:
171
+ access_token: OAuth2 access token.
172
+ """
173
+ self._http.set_access_token(access_token)
174
+ self._auth_manager._access_token = access_token
175
+
176
+ def clear_cache(self) -> None:
177
+ """Clear all cached entities.
178
+
179
+ Examples:
180
+ >>> async with MegaplanClient(...) as client:
181
+ ... await client.tasks.list() # Caches employees
182
+ ... client.clear_cache() # Clear all cache
183
+ """
184
+ if self._cache:
185
+ self._cache.clear()
186
+ logger.debug("Entity cache cleared")
187
+
188
+ def clear_cache_type(self, content_type: str) -> None:
189
+ """Clear cache for specific entity type.
190
+
191
+ Args:
192
+ content_type: Entity type to clear (e.g., "Employee", "Contractor").
193
+
194
+ Examples:
195
+ >>> async with MegaplanClient(...) as client:
196
+ ... await client.employees.list() # Caches employees
197
+ ... client.clear_cache_type("Employee") # Clear only employees
198
+ """
199
+ if self._cache:
200
+ self._cache.clear_type(content_type)
201
+ logger.debug(f"Entity cache cleared for type: {content_type}")
@@ -0,0 +1,16 @@
1
+ """Constants for Megaplan SDK."""
2
+
3
+
4
+ class ContentType:
5
+ """Content type constants for Megaplan API entities."""
6
+
7
+ TASK = "Task"
8
+ PROJECT = "Project"
9
+ DEAL = "Deal"
10
+ EMPLOYEE = "Employee"
11
+ CONTRACTOR = "Contractor"
12
+ CONTRACTOR_COMPANY = "ContractorCompany"
13
+ CONTRACTOR_HUMAN = "ContractorHuman"
14
+ CONTRACTOR_CATEGORY = "ContractorCategory"
15
+ DEPARTMENT = "Department"
16
+ COMMENT = "Comment"