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.
@@ -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,6 @@
1
+ """Authentication module."""
2
+
3
+ from gsuite_core.auth.oauth import GoogleAuth
4
+ from gsuite_core.auth.scopes import Scopes
5
+
6
+ __all__ = ["GoogleAuth", "Scopes"]
@@ -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