gsuite-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.
- gsuite_calendar/__init__.py +13 -0
- gsuite_calendar/calendar_entity.py +31 -0
- gsuite_calendar/client.py +268 -0
- gsuite_calendar/event.py +57 -0
- gsuite_calendar/parser.py +119 -0
- gsuite_calendar/py.typed +0 -0
- gsuite_core/__init__.py +62 -0
- gsuite_core/api_utils.py +167 -0
- gsuite_core/auth/__init__.py +6 -0
- gsuite_core/auth/oauth.py +249 -0
- gsuite_core/auth/scopes.py +84 -0
- gsuite_core/config.py +73 -0
- gsuite_core/exceptions.py +125 -0
- gsuite_core/py.typed +0 -0
- gsuite_core/storage/__init__.py +13 -0
- gsuite_core/storage/base.py +65 -0
- gsuite_core/storage/secretmanager.py +141 -0
- gsuite_core/storage/sqlite.py +79 -0
- gsuite_drive/__init__.py +12 -0
- gsuite_drive/client.py +401 -0
- gsuite_drive/file.py +103 -0
- gsuite_drive/parser.py +66 -0
- gsuite_drive/py.typed +0 -0
- gsuite_gmail/__init__.py +17 -0
- gsuite_gmail/client.py +412 -0
- gsuite_gmail/label.py +56 -0
- gsuite_gmail/message.py +211 -0
- gsuite_gmail/parser.py +155 -0
- gsuite_gmail/py.typed +0 -0
- gsuite_gmail/query.py +227 -0
- gsuite_gmail/thread.py +54 -0
- gsuite_sdk-0.1.0.dist-info/METADATA +384 -0
- gsuite_sdk-0.1.0.dist-info/RECORD +42 -0
- gsuite_sdk-0.1.0.dist-info/WHEEL +5 -0
- gsuite_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- gsuite_sdk-0.1.0.dist-info/top_level.txt +5 -0
- gsuite_sheets/__init__.py +13 -0
- gsuite_sheets/client.py +375 -0
- gsuite_sheets/parser.py +76 -0
- gsuite_sheets/py.typed +0 -0
- gsuite_sheets/spreadsheet.py +97 -0
- gsuite_sheets/worksheet.py +185 -0
gsuite_core/api_utils.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""API utilities for consistent error handling and retries."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
from googleapiclient.errors import HttpError
|
|
10
|
+
|
|
11
|
+
from gsuite_core.exceptions import (
|
|
12
|
+
APIError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
QuotaExceededError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def map_http_error(error: HttpError, service: str, resource_type: str = "resource") -> APIError:
|
|
25
|
+
"""
|
|
26
|
+
Map Google API HttpError to domain exception.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
error: The HttpError from Google API
|
|
30
|
+
service: Service name (gmail, calendar, drive, sheets)
|
|
31
|
+
resource_type: Type of resource for NotFoundError
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Appropriate GSuiteError subclass
|
|
35
|
+
"""
|
|
36
|
+
status = error.resp.status
|
|
37
|
+
message = str(error)
|
|
38
|
+
|
|
39
|
+
if status == 404:
|
|
40
|
+
# Extract resource ID from error if possible
|
|
41
|
+
resource_id = "unknown"
|
|
42
|
+
return NotFoundError(service, resource_type, resource_id)
|
|
43
|
+
elif status == 403:
|
|
44
|
+
if "quota" in message.lower():
|
|
45
|
+
return QuotaExceededError(service)
|
|
46
|
+
return PermissionDeniedError(service, "operation")
|
|
47
|
+
elif status == 429:
|
|
48
|
+
# Try to extract retry-after header
|
|
49
|
+
retry_after = error.resp.get("retry-after")
|
|
50
|
+
return RateLimitError(service, int(retry_after) if retry_after else None)
|
|
51
|
+
else:
|
|
52
|
+
return APIError(message, service, status, cause=error)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def api_call(
|
|
56
|
+
service: str,
|
|
57
|
+
resource_type: str = "resource",
|
|
58
|
+
retry_on_rate_limit: bool | None = None,
|
|
59
|
+
max_retries: int | None = None,
|
|
60
|
+
retry_delay: float | None = None,
|
|
61
|
+
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
|
62
|
+
"""
|
|
63
|
+
Decorator for API calls with consistent error handling and retry logic.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
service: Service name for error messages
|
|
67
|
+
resource_type: Resource type for NotFoundError
|
|
68
|
+
retry_on_rate_limit: Whether to retry on 429 errors
|
|
69
|
+
max_retries: Maximum retry attempts
|
|
70
|
+
retry_delay: Base delay between retries (exponential backoff)
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
@api_call("gmail", "message")
|
|
74
|
+
def get_message(self, message_id: str) -> Message:
|
|
75
|
+
...
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
|
79
|
+
@wraps(func)
|
|
80
|
+
def wrapper(*args, **kwargs) -> T:
|
|
81
|
+
# Import here to avoid circular imports
|
|
82
|
+
from gsuite_core.config import get_settings
|
|
83
|
+
|
|
84
|
+
settings = get_settings()
|
|
85
|
+
|
|
86
|
+
# Use provided values or fall back to settings
|
|
87
|
+
_retry_on_rate_limit = (
|
|
88
|
+
retry_on_rate_limit
|
|
89
|
+
if retry_on_rate_limit is not None
|
|
90
|
+
else settings.retry_on_rate_limit
|
|
91
|
+
)
|
|
92
|
+
_max_retries = max_retries if max_retries is not None else settings.max_retries
|
|
93
|
+
_retry_delay = retry_delay if retry_delay is not None else settings.retry_delay
|
|
94
|
+
|
|
95
|
+
last_error = None
|
|
96
|
+
|
|
97
|
+
for attempt in range(_max_retries + 1):
|
|
98
|
+
try:
|
|
99
|
+
return func(*args, **kwargs)
|
|
100
|
+
except HttpError as e:
|
|
101
|
+
last_error = e
|
|
102
|
+
status = e.resp.status
|
|
103
|
+
|
|
104
|
+
# Retry on rate limit
|
|
105
|
+
if status == 429 and _retry_on_rate_limit and attempt < _max_retries:
|
|
106
|
+
wait_time = _retry_delay * (2**attempt)
|
|
107
|
+
logger.warning(
|
|
108
|
+
f"{service}: Rate limited, retrying in {wait_time:.1f}s "
|
|
109
|
+
f"(attempt {attempt + 1}/{_max_retries})"
|
|
110
|
+
)
|
|
111
|
+
time.sleep(wait_time)
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Retry on server errors (5xx)
|
|
115
|
+
if 500 <= status < 600 and attempt < _max_retries:
|
|
116
|
+
wait_time = _retry_delay * (2**attempt)
|
|
117
|
+
logger.warning(
|
|
118
|
+
f"{service}: Server error {status}, retrying in {wait_time:.1f}s "
|
|
119
|
+
f"(attempt {attempt + 1}/{_max_retries})"
|
|
120
|
+
)
|
|
121
|
+
time.sleep(wait_time)
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Don't retry other errors
|
|
125
|
+
raise map_http_error(e, service, resource_type)
|
|
126
|
+
|
|
127
|
+
# Exhausted retries
|
|
128
|
+
if last_error:
|
|
129
|
+
raise map_http_error(last_error, service, resource_type)
|
|
130
|
+
|
|
131
|
+
# Should never reach here
|
|
132
|
+
raise RuntimeError("Unexpected state in api_call")
|
|
133
|
+
|
|
134
|
+
return wrapper
|
|
135
|
+
|
|
136
|
+
return decorator
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def api_call_optional(
|
|
140
|
+
service: str,
|
|
141
|
+
resource_type: str = "resource",
|
|
142
|
+
) -> Callable[[Callable[..., T | None]], Callable[..., T | None]]:
|
|
143
|
+
"""
|
|
144
|
+
Decorator for API calls that return None on 404 instead of raising.
|
|
145
|
+
|
|
146
|
+
Use for get-by-id operations where not-found is expected.
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
@api_call_optional("drive", "file")
|
|
150
|
+
def get(self, file_id: str) -> File | None:
|
|
151
|
+
...
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def decorator(func: Callable[..., T | None]) -> Callable[..., T | None]:
|
|
155
|
+
@wraps(func)
|
|
156
|
+
def wrapper(*args, **kwargs) -> T | None:
|
|
157
|
+
try:
|
|
158
|
+
return func(*args, **kwargs)
|
|
159
|
+
except HttpError as e:
|
|
160
|
+
if e.resp.status == 404:
|
|
161
|
+
logger.debug(f"{service}: {resource_type} not found")
|
|
162
|
+
return None
|
|
163
|
+
raise map_http_error(e, service, resource_type)
|
|
164
|
+
|
|
165
|
+
return wrapper
|
|
166
|
+
|
|
167
|
+
return decorator
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Google OAuth2 authentication."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from google.auth.exceptions import RefreshError
|
|
6
|
+
from google.auth.transport.requests import Request
|
|
7
|
+
from google.oauth2 import service_account
|
|
8
|
+
from google.oauth2.credentials import Credentials
|
|
9
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
10
|
+
|
|
11
|
+
from gsuite_core.auth.scopes import Scopes
|
|
12
|
+
from gsuite_core.config import get_settings
|
|
13
|
+
from gsuite_core.exceptions import (
|
|
14
|
+
CredentialsNotFoundError,
|
|
15
|
+
TokenRefreshError,
|
|
16
|
+
)
|
|
17
|
+
from gsuite_core.storage import SQLiteTokenStore, TokenStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GoogleAuth:
|
|
21
|
+
"""
|
|
22
|
+
Google OAuth2 authentication handler.
|
|
23
|
+
|
|
24
|
+
Manages credential acquisition, refresh, and storage.
|
|
25
|
+
Supports both OAuth (interactive) and Service Account (server-to-server).
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
# OAuth (interactive)
|
|
29
|
+
auth = GoogleAuth()
|
|
30
|
+
auth.authenticate() # Opens browser
|
|
31
|
+
|
|
32
|
+
# Service Account
|
|
33
|
+
auth = GoogleAuth.from_service_account("service-account.json")
|
|
34
|
+
|
|
35
|
+
# Use credentials
|
|
36
|
+
service = build("gmail", "v1", credentials=auth.credentials)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
token_store: TokenStore | None = None,
|
|
42
|
+
credentials_file: str | None = None,
|
|
43
|
+
scopes: list[str] | None = None,
|
|
44
|
+
user_id: str = "default",
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Initialize OAuth authenticator.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
token_store: Storage backend for tokens (default: SQLite)
|
|
51
|
+
credentials_file: Path to OAuth credentials JSON
|
|
52
|
+
scopes: OAuth scopes to request (default: Gmail + Calendar)
|
|
53
|
+
user_id: User identifier for multi-user setups
|
|
54
|
+
"""
|
|
55
|
+
settings = get_settings()
|
|
56
|
+
|
|
57
|
+
self.token_store = token_store or SQLiteTokenStore(settings.token_db_path)
|
|
58
|
+
self.credentials_file = Path(credentials_file or settings.credentials_file)
|
|
59
|
+
self.scopes = scopes or Scopes.default()
|
|
60
|
+
self.user_id = user_id
|
|
61
|
+
self._credentials: Credentials | None = None
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_service_account(
|
|
65
|
+
cls,
|
|
66
|
+
service_account_file: str,
|
|
67
|
+
scopes: list[str] | None = None,
|
|
68
|
+
subject: str | None = None,
|
|
69
|
+
) -> "GoogleAuth":
|
|
70
|
+
"""
|
|
71
|
+
Create authenticator from service account.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
service_account_file: Path to service account JSON
|
|
75
|
+
scopes: OAuth scopes
|
|
76
|
+
subject: Email to impersonate (for domain-wide delegation)
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
GoogleAuth instance with service account credentials
|
|
80
|
+
"""
|
|
81
|
+
instance = cls.__new__(cls)
|
|
82
|
+
instance.token_store = None
|
|
83
|
+
instance.credentials_file = Path(service_account_file)
|
|
84
|
+
instance.scopes = scopes or Scopes.default()
|
|
85
|
+
instance.user_id = "service_account"
|
|
86
|
+
|
|
87
|
+
creds = service_account.Credentials.from_service_account_file(
|
|
88
|
+
service_account_file,
|
|
89
|
+
scopes=instance.scopes,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if subject:
|
|
93
|
+
creds = creds.with_subject(subject)
|
|
94
|
+
|
|
95
|
+
instance._credentials = creds
|
|
96
|
+
return instance
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def credentials(self) -> Credentials | None:
|
|
100
|
+
"""Get current credentials, loading from store if needed."""
|
|
101
|
+
if self._credentials is None:
|
|
102
|
+
self._load_credentials()
|
|
103
|
+
return self._credentials
|
|
104
|
+
|
|
105
|
+
def _load_credentials(self) -> None:
|
|
106
|
+
"""Load credentials from token store."""
|
|
107
|
+
if self.token_store is None:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
token_data = self.token_store.get_token(self.user_id)
|
|
111
|
+
|
|
112
|
+
if token_data:
|
|
113
|
+
self._credentials = Credentials.from_authorized_user_info(
|
|
114
|
+
token_data,
|
|
115
|
+
self.scopes,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _save_credentials(self) -> None:
|
|
119
|
+
"""Save current credentials to token store."""
|
|
120
|
+
if self._credentials and self.token_store:
|
|
121
|
+
token_data = {
|
|
122
|
+
"token": self._credentials.token,
|
|
123
|
+
"refresh_token": self._credentials.refresh_token,
|
|
124
|
+
"token_uri": self._credentials.token_uri,
|
|
125
|
+
"client_id": self._credentials.client_id,
|
|
126
|
+
"client_secret": self._credentials.client_secret,
|
|
127
|
+
"scopes": list(self._credentials.scopes or self.scopes),
|
|
128
|
+
}
|
|
129
|
+
self.token_store.save_token(token_data, self.user_id)
|
|
130
|
+
|
|
131
|
+
def is_authenticated(self) -> bool:
|
|
132
|
+
"""Check if we have valid credentials."""
|
|
133
|
+
creds = self.credentials
|
|
134
|
+
return creds is not None and creds.valid
|
|
135
|
+
|
|
136
|
+
def needs_refresh(self) -> bool:
|
|
137
|
+
"""Check if credentials need refresh."""
|
|
138
|
+
creds = self.credentials
|
|
139
|
+
if creds is None:
|
|
140
|
+
return False
|
|
141
|
+
return creds.expired and creds.refresh_token is not None
|
|
142
|
+
|
|
143
|
+
def refresh(self) -> bool:
|
|
144
|
+
"""
|
|
145
|
+
Refresh expired credentials.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
True if refresh succeeded
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
TokenRefreshError: If refresh fails
|
|
152
|
+
"""
|
|
153
|
+
if not self.needs_refresh():
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
self._credentials.refresh(Request())
|
|
158
|
+
self._save_credentials()
|
|
159
|
+
return True
|
|
160
|
+
except RefreshError as e:
|
|
161
|
+
raise TokenRefreshError(cause=e) from e
|
|
162
|
+
except Exception as e:
|
|
163
|
+
raise TokenRefreshError(cause=e) from e
|
|
164
|
+
|
|
165
|
+
def authenticate(self, force: bool = False) -> Credentials:
|
|
166
|
+
"""
|
|
167
|
+
Get valid credentials, running OAuth flow if needed.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
force: Force new authentication even if credentials exist
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Valid Google credentials
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
FileNotFoundError: If credentials file doesn't exist
|
|
177
|
+
"""
|
|
178
|
+
if not force:
|
|
179
|
+
if self.is_authenticated():
|
|
180
|
+
return self._credentials
|
|
181
|
+
|
|
182
|
+
if self.needs_refresh() and self.refresh():
|
|
183
|
+
return self._credentials
|
|
184
|
+
|
|
185
|
+
if not self.credentials_file.exists():
|
|
186
|
+
raise CredentialsNotFoundError(str(self.credentials_file))
|
|
187
|
+
|
|
188
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
189
|
+
str(self.credentials_file),
|
|
190
|
+
self.scopes,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
self._credentials = flow.run_local_server(
|
|
194
|
+
port=0,
|
|
195
|
+
prompt="consent",
|
|
196
|
+
access_type="offline",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
self._save_credentials()
|
|
200
|
+
return self._credentials
|
|
201
|
+
|
|
202
|
+
def revoke(self) -> bool:
|
|
203
|
+
"""
|
|
204
|
+
Revoke and delete stored credentials.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
True if credentials were deleted
|
|
208
|
+
"""
|
|
209
|
+
self._credentials = None
|
|
210
|
+
if self.token_store:
|
|
211
|
+
return self.token_store.delete_token(self.user_id)
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
def export_token(self) -> dict | None:
|
|
215
|
+
"""
|
|
216
|
+
Export token data for external storage.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Token data dict or None if not authenticated
|
|
220
|
+
"""
|
|
221
|
+
if self._credentials:
|
|
222
|
+
return {
|
|
223
|
+
"token": self._credentials.token,
|
|
224
|
+
"refresh_token": self._credentials.refresh_token,
|
|
225
|
+
"token_uri": self._credentials.token_uri,
|
|
226
|
+
"client_id": self._credentials.client_id,
|
|
227
|
+
"client_secret": self._credentials.client_secret,
|
|
228
|
+
"scopes": list(self._credentials.scopes or self.scopes),
|
|
229
|
+
}
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def get_user_email(self) -> str | None:
|
|
233
|
+
"""
|
|
234
|
+
Get authenticated user's email.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Email address or None
|
|
238
|
+
"""
|
|
239
|
+
if not self.is_authenticated():
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
from googleapiclient.discovery import build
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
service = build("oauth2", "v2", credentials=self._credentials)
|
|
246
|
+
user_info = service.userinfo().get().execute()
|
|
247
|
+
return user_info.get("email")
|
|
248
|
+
except Exception:
|
|
249
|
+
return None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Google API OAuth scopes."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Scopes:
|
|
5
|
+
"""
|
|
6
|
+
Google API OAuth scopes.
|
|
7
|
+
|
|
8
|
+
Use these constants to request specific permissions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Gmail
|
|
12
|
+
GMAIL_READONLY = "https://www.googleapis.com/auth/gmail.readonly"
|
|
13
|
+
GMAIL_SEND = "https://www.googleapis.com/auth/gmail.send"
|
|
14
|
+
GMAIL_MODIFY = "https://www.googleapis.com/auth/gmail.modify"
|
|
15
|
+
GMAIL_LABELS = "https://www.googleapis.com/auth/gmail.labels"
|
|
16
|
+
GMAIL_FULL = "https://mail.google.com/"
|
|
17
|
+
|
|
18
|
+
# Calendar
|
|
19
|
+
CALENDAR_FULL = "https://www.googleapis.com/auth/calendar"
|
|
20
|
+
CALENDAR_EVENTS = "https://www.googleapis.com/auth/calendar.events"
|
|
21
|
+
CALENDAR_READONLY = "https://www.googleapis.com/auth/calendar.readonly"
|
|
22
|
+
|
|
23
|
+
# Drive
|
|
24
|
+
DRIVE_FULL = "https://www.googleapis.com/auth/drive"
|
|
25
|
+
DRIVE_FILE = "https://www.googleapis.com/auth/drive.file"
|
|
26
|
+
DRIVE_READONLY = "https://www.googleapis.com/auth/drive.readonly"
|
|
27
|
+
DRIVE_METADATA = "https://www.googleapis.com/auth/drive.metadata.readonly"
|
|
28
|
+
|
|
29
|
+
# Sheets
|
|
30
|
+
SHEETS_FULL = "https://www.googleapis.com/auth/spreadsheets"
|
|
31
|
+
SHEETS_READONLY = "https://www.googleapis.com/auth/spreadsheets.readonly"
|
|
32
|
+
|
|
33
|
+
# Tasks
|
|
34
|
+
TASKS_FULL = "https://www.googleapis.com/auth/tasks"
|
|
35
|
+
TASKS_READONLY = "https://www.googleapis.com/auth/tasks.readonly"
|
|
36
|
+
|
|
37
|
+
# Contacts/People
|
|
38
|
+
CONTACTS_READONLY = "https://www.googleapis.com/auth/contacts.readonly"
|
|
39
|
+
|
|
40
|
+
# User info
|
|
41
|
+
USERINFO_EMAIL = "https://www.googleapis.com/auth/userinfo.email"
|
|
42
|
+
USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo.profile"
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def gmail(cls) -> list[str]:
|
|
46
|
+
"""Standard Gmail scopes for read, send, modify."""
|
|
47
|
+
return [
|
|
48
|
+
cls.GMAIL_READONLY,
|
|
49
|
+
cls.GMAIL_SEND,
|
|
50
|
+
cls.GMAIL_MODIFY,
|
|
51
|
+
cls.GMAIL_LABELS,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def calendar(cls) -> list[str]:
|
|
56
|
+
"""Standard Calendar scopes."""
|
|
57
|
+
return [
|
|
58
|
+
cls.CALENDAR_FULL,
|
|
59
|
+
cls.CALENDAR_EVENTS,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def drive(cls) -> list[str]:
|
|
64
|
+
"""Standard Drive scopes."""
|
|
65
|
+
return [
|
|
66
|
+
cls.DRIVE_FULL,
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def sheets(cls) -> list[str]:
|
|
71
|
+
"""Standard Sheets scopes."""
|
|
72
|
+
return [
|
|
73
|
+
cls.SHEETS_FULL,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def all(cls) -> list[str]:
|
|
78
|
+
"""All standard scopes for full access."""
|
|
79
|
+
return cls.gmail() + cls.calendar() + cls.drive() + cls.sheets()
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def default(cls) -> list[str]:
|
|
83
|
+
"""Default scopes (Gmail + Calendar)."""
|
|
84
|
+
return cls.gmail() + cls.calendar()
|
gsuite_core/config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Application configuration."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
from pydantic_settings import BaseSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
"""
|
|
12
|
+
Application settings loaded from environment variables.
|
|
13
|
+
|
|
14
|
+
Prefix: GSUITE_
|
|
15
|
+
Example: GSUITE_API_KEY=secret
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# API settings
|
|
19
|
+
api_key: str | None = Field(default=None, description="API key for REST endpoints")
|
|
20
|
+
host: str = Field(default="0.0.0.0", description="Server host")
|
|
21
|
+
port: int = Field(default=8080, description="Server port")
|
|
22
|
+
version: str = Field(default="dev", description="API version")
|
|
23
|
+
|
|
24
|
+
# Google OAuth
|
|
25
|
+
credentials_file: str = Field(
|
|
26
|
+
default="credentials.json", description="Path to Google OAuth credentials file"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Token storage backend
|
|
30
|
+
token_storage: Literal["sqlite", "secretmanager"] = Field(
|
|
31
|
+
default="sqlite", description="Token storage backend"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# SQLite storage
|
|
35
|
+
token_db_path: str = Field(default="tokens.db", description="Path to SQLite token database")
|
|
36
|
+
|
|
37
|
+
# Secret Manager (for Cloud Run)
|
|
38
|
+
gcp_project_id: str | None = Field(default=None, description="GCP project ID")
|
|
39
|
+
token_secret_name: str = Field(
|
|
40
|
+
default="gsuite-token", description="Secret name in Secret Manager"
|
|
41
|
+
)
|
|
42
|
+
token_secret_auto_create: bool = Field(
|
|
43
|
+
default=False, description="Auto-create secret if it doesn't exist"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# API behavior
|
|
47
|
+
default_timezone: str = Field(default="UTC", description="Default timezone for calendar events")
|
|
48
|
+
request_timeout: int = Field(default=30, description="HTTP request timeout in seconds")
|
|
49
|
+
max_retries: int = Field(default=3, description="Maximum retry attempts for failed requests")
|
|
50
|
+
retry_delay: float = Field(
|
|
51
|
+
default=1.0, description="Base delay between retries in seconds (exponential backoff)"
|
|
52
|
+
)
|
|
53
|
+
retry_on_rate_limit: bool = Field(
|
|
54
|
+
default=True, description="Whether to automatically retry on rate limit errors"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
model_config = {
|
|
58
|
+
"env_prefix": "GSUITE_",
|
|
59
|
+
"env_file": ".env",
|
|
60
|
+
"env_file_encoding": "utf-8",
|
|
61
|
+
"extra": "ignore",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def validate_for_secretmanager(self) -> None:
|
|
65
|
+
"""Validate settings when using Secret Manager."""
|
|
66
|
+
if self.token_storage == "secretmanager" and not self.gcp_project_id:
|
|
67
|
+
raise ValueError("GSUITE_GCP_PROJECT_ID is required when using secretmanager storage")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@lru_cache
|
|
71
|
+
def get_settings() -> Settings:
|
|
72
|
+
"""Get cached settings instance."""
|
|
73
|
+
return Settings()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Google Suite exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GSuiteError(Exception):
|
|
5
|
+
"""Base exception for all Google Suite errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, cause: Exception | None = None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
self.cause = cause
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthenticationError(GSuiteError):
|
|
14
|
+
"""Authentication-related errors."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CredentialsNotFoundError(AuthenticationError):
|
|
20
|
+
"""OAuth credentials file not found."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path: str):
|
|
23
|
+
super().__init__(
|
|
24
|
+
f"Credentials file not found: {path}\n"
|
|
25
|
+
"Download from Google Cloud Console -> APIs & Services -> Credentials"
|
|
26
|
+
)
|
|
27
|
+
self.path = path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TokenExpiredError(AuthenticationError):
|
|
31
|
+
"""Token is expired and cannot be refreshed."""
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
super().__init__("Token expired and no refresh token available")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TokenRefreshError(AuthenticationError):
|
|
38
|
+
"""Failed to refresh token."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, cause: Exception | None = None):
|
|
41
|
+
super().__init__("Failed to refresh token", cause)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotAuthenticatedError(AuthenticationError):
|
|
45
|
+
"""Operation requires authentication but not authenticated."""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
super().__init__("Not authenticated. Run authenticate() first")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class APIError(GSuiteError):
|
|
52
|
+
"""Google API call errors."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
message: str,
|
|
57
|
+
service: str,
|
|
58
|
+
status_code: int | None = None,
|
|
59
|
+
cause: Exception | None = None,
|
|
60
|
+
):
|
|
61
|
+
super().__init__(message, cause)
|
|
62
|
+
self.service = service
|
|
63
|
+
self.status_code = status_code
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RateLimitError(APIError):
|
|
67
|
+
"""Rate limit exceeded."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, service: str, retry_after: int | None = None):
|
|
70
|
+
super().__init__(
|
|
71
|
+
f"Rate limit exceeded for {service}",
|
|
72
|
+
service=service,
|
|
73
|
+
status_code=429,
|
|
74
|
+
)
|
|
75
|
+
self.retry_after = retry_after
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class QuotaExceededError(APIError):
|
|
79
|
+
"""API quota exceeded."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, service: str):
|
|
82
|
+
super().__init__(
|
|
83
|
+
f"API quota exceeded for {service}",
|
|
84
|
+
service=service,
|
|
85
|
+
status_code=403,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class NotFoundError(APIError):
|
|
90
|
+
"""Resource not found."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, service: str, resource_type: str, resource_id: str):
|
|
93
|
+
super().__init__(
|
|
94
|
+
f"{resource_type} not found: {resource_id}",
|
|
95
|
+
service=service,
|
|
96
|
+
status_code=404,
|
|
97
|
+
)
|
|
98
|
+
self.resource_type = resource_type
|
|
99
|
+
self.resource_id = resource_id
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PermissionDeniedError(APIError):
|
|
103
|
+
"""Permission denied for operation."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, service: str, operation: str):
|
|
106
|
+
super().__init__(
|
|
107
|
+
f"Permission denied for {operation}",
|
|
108
|
+
service=service,
|
|
109
|
+
status_code=403,
|
|
110
|
+
)
|
|
111
|
+
self.operation = operation
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ValidationError(GSuiteError):
|
|
115
|
+
"""Input validation error."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, field: str, message: str):
|
|
118
|
+
super().__init__(f"Validation error on {field}: {message}")
|
|
119
|
+
self.field = field
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ConfigurationError(GSuiteError):
|
|
123
|
+
"""Configuration error."""
|
|
124
|
+
|
|
125
|
+
pass
|