google-drive-model 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.
gdm/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """Public package surface for google-drive-model."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from gdm.auth import login_interactive
6
+ from gdm.client import GDM, GDMClient
7
+ from gdm.collection.collection import DriveCollection
8
+ from gdm.collection.content import ContentHandle
9
+ from gdm.errors import (
10
+ AmbiguousResourceError,
11
+ ConflictError,
12
+ ForeignProjectError,
13
+ GDMError,
14
+ InvalidDrivePropertyError,
15
+ InvalidForeignKeyError,
16
+ InvalidModelConfigError,
17
+ MissingOptionalDependencyError,
18
+ NotFoundError,
19
+ PartialCommitError,
20
+ PermissionDeniedError,
21
+ QueryEvaluationError,
22
+ ReadOnlySessionError,
23
+ ResultError,
24
+ ScanLimitExceeded,
25
+ UnknownSourceFieldError,
26
+ UnsupportedOperationError,
27
+ )
28
+ from gdm.fields import Field, GDMFieldMetadata
29
+ from gdm.indexes import GdmIndexError, IndexStore, StaleIndexError, StalePolicy
30
+ from gdm.model import DriveObjectMixin, GDMModel
31
+ from gdm.project import ResourceKind
32
+ from gdm.query import Select, select
33
+ from gdm.session import Result, Session
34
+
35
+ __all__ = [
36
+ "GDM",
37
+ "AmbiguousResourceError",
38
+ "ConflictError",
39
+ "ContentHandle",
40
+ "DriveCollection",
41
+ "DriveObjectMixin",
42
+ "Field",
43
+ "ForeignProjectError",
44
+ "GDMClient",
45
+ "GDMError",
46
+ "GDMFieldMetadata",
47
+ "GDMModel",
48
+ "GdmIndexError",
49
+ "IndexStore",
50
+ "InvalidDrivePropertyError",
51
+ "InvalidForeignKeyError",
52
+ "InvalidModelConfigError",
53
+ "MissingOptionalDependencyError",
54
+ "NotFoundError",
55
+ "PartialCommitError",
56
+ "PermissionDeniedError",
57
+ "QueryEvaluationError",
58
+ "ReadOnlySessionError",
59
+ "ResourceKind",
60
+ "Result",
61
+ "ResultError",
62
+ "ScanLimitExceeded",
63
+ "Select",
64
+ "Session",
65
+ "StaleIndexError",
66
+ "StalePolicy",
67
+ "UnknownSourceFieldError",
68
+ "UnsupportedOperationError",
69
+ "__version__",
70
+ "login_interactive",
71
+ "select",
72
+ ]
73
+
74
+ try:
75
+ __version__ = version("google-drive-model")
76
+ except PackageNotFoundError: # pragma: no cover - source tree without installation
77
+ __version__ = "0+unknown"
gdm/auth.py ADDED
@@ -0,0 +1,514 @@
1
+ """Credential and Google API service construction for GDM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Any, Literal, overload
10
+
11
+ from google.auth import default as google_auth_default
12
+ from google.auth.credentials import Credentials
13
+ from google.oauth2 import service_account
14
+ from googleapiclient.discovery import Resource, build
15
+
16
+ from gdm.google_api import (
17
+ DriveExplorerAdapter,
18
+ ExecutingDocsService,
19
+ ExecutingDriveService,
20
+ ExecutingSheetsService,
21
+ ExecutingSlidesService,
22
+ wrap_docs_service,
23
+ wrap_drive_service,
24
+ wrap_sheets_service,
25
+ wrap_slides_service,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Least-privilege scopes for GDM-created or user-opened resources.
31
+ SCOPE_DRIVE_FILE = "https://www.googleapis.com/auth/drive.file"
32
+ SCOPE_SPREADSHEETS = "https://www.googleapis.com/auth/spreadsheets"
33
+
34
+ # Broader Drive scope required to discover/adopt arbitrary existing content by path
35
+ # or name. Restricted-scope verification may be required for distributed OAuth apps.
36
+ SCOPE_DRIVE = "https://www.googleapis.com/auth/drive"
37
+ SCOPE_DRIVE_READONLY = "https://www.googleapis.com/auth/drive.readonly"
38
+
39
+ DEFAULT_SCOPES: tuple[str, ...] = (SCOPE_DRIVE_FILE, SCOPE_SPREADSHEETS)
40
+ DISCOVERY_SCOPES: tuple[str, ...] = (SCOPE_DRIVE, SCOPE_SPREADSHEETS)
41
+
42
+ DRIVE_API_VERSION = "v3"
43
+ SHEETS_API_VERSION = "v4"
44
+ DOCS_API_VERSION = "v1"
45
+ SLIDES_API_VERSION = "v1"
46
+ TOKEN_FILENAME = "token.json"
47
+
48
+ SCOPE_ALIASES: dict[str, str] = {
49
+ "drive": SCOPE_DRIVE,
50
+ "drive.readonly": SCOPE_DRIVE_READONLY,
51
+ "drive.file": SCOPE_DRIVE_FILE,
52
+ "spreadsheets": SCOPE_SPREADSHEETS,
53
+ }
54
+
55
+
56
+ class OAuthUnavailableError(ImportError):
57
+ """Raised when installed-app OAuth is requested without the optional extra."""
58
+
59
+
60
+ def _user_config_dir(appname: str = "gdm") -> Path:
61
+ """Return a platform-appropriate user config directory."""
62
+ try:
63
+ import platformdirs
64
+
65
+ return Path(platformdirs.user_config_dir(appname, appauthor=False))
66
+ except ImportError:
67
+ home = Path.home()
68
+ xdg = home / ".config" / appname
69
+ if xdg.parent.exists() or not (home / "Library").exists():
70
+ return xdg
71
+ return home / "Library" / "Application Support" / appname
72
+
73
+
74
+ def default_token_path(*, appname: str = "gdm") -> Path:
75
+ """Default on-disk location for OAuth refresh tokens (never inside a project)."""
76
+ return _user_config_dir(appname) / TOKEN_FILENAME
77
+
78
+
79
+ def _write_private_token(path: Path, payload: str) -> None:
80
+ """Write an OAuth token with owner-only permissions."""
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
83
+ try:
84
+ fchmod = getattr(os, "fchmod", None)
85
+ if callable(fchmod):
86
+ fchmod(descriptor, 0o600)
87
+ else: # pragma: no cover - Windows fallback
88
+ os.chmod(path, 0o600)
89
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
90
+ descriptor = -1
91
+ handle.write(payload)
92
+ finally:
93
+ if descriptor >= 0:
94
+ os.close(descriptor)
95
+
96
+
97
+ def resolve_client_secrets_path(
98
+ explicit: str | Path | None = None,
99
+ ) -> Path | None:
100
+ """Resolve OAuth client secrets from explicit path or ``GDM_CLIENT_SECRETS``."""
101
+ if explicit is not None:
102
+ return Path(explicit)
103
+ env = os.environ.get("GDM_CLIENT_SECRETS")
104
+ if env:
105
+ return Path(env)
106
+ return None
107
+
108
+
109
+ def _load_oauth_modules() -> tuple[Any, Any, Any]:
110
+ try:
111
+ from google.auth.transport.requests import Request
112
+ from google.oauth2.credentials import Credentials as UserCredentials
113
+ from google_auth_oauthlib.flow import InstalledAppFlow
114
+ except ImportError as exc: # pragma: no cover - exercised via OAuthUnavailableError
115
+ raise OAuthUnavailableError(
116
+ "Installed-app OAuth requires the optional dependency: "
117
+ "pip install google-drive-model[oauth]"
118
+ ) from exc
119
+ return Request, UserCredentials, InstalledAppFlow
120
+
121
+
122
+ def _normalize_scopes(scopes: Sequence[str] | None) -> list[str]:
123
+ if scopes is None:
124
+ return list(DEFAULT_SCOPES)
125
+ resolved: list[str] = []
126
+ for scope in scopes:
127
+ token = scope.strip()
128
+ if not token:
129
+ continue
130
+ resolved.append(SCOPE_ALIASES.get(token, token))
131
+ return resolved
132
+
133
+
134
+ def _load_token_credentials(
135
+ token_path: Path,
136
+ scopes: list[str],
137
+ ) -> Credentials | None:
138
+ if not token_path.is_file():
139
+ return None
140
+ # Authorized-user files already record the scopes that were granted during
141
+ # consent. Replacing them here can make refresh fail with ``invalid_scope``
142
+ # when a broader token (for example ``drive``) is reused by a narrower
143
+ # client path (for example ``drive.file``).
144
+ del scopes
145
+ _, UserCredentials, _ = _load_oauth_modules()
146
+ creds = UserCredentials.from_authorized_user_file(str(token_path))
147
+ if creds and creds.valid:
148
+ return creds
149
+ if creds and creds.expired and creds.refresh_token:
150
+ Request, _, _ = _load_oauth_modules()
151
+ creds.refresh(Request())
152
+ _write_private_token(token_path, creds.to_json())
153
+ return creds
154
+ return None
155
+
156
+
157
+ def _run_installed_app_flow(
158
+ client_secrets: str | Path | dict[str, Any],
159
+ scopes: list[str],
160
+ token_path: Path,
161
+ ) -> Credentials:
162
+ _, _, InstalledAppFlow = _load_oauth_modules()
163
+ if isinstance(client_secrets, dict):
164
+ flow = InstalledAppFlow.from_client_config(client_secrets, scopes)
165
+ else:
166
+ flow = InstalledAppFlow.from_client_secrets_file(str(client_secrets), scopes)
167
+ creds = flow.run_local_server(port=0)
168
+ _write_private_token(token_path, creds.to_json())
169
+ logger.info("Stored OAuth token at %s", token_path)
170
+ return creds
171
+
172
+
173
+ def build_credentials(
174
+ *,
175
+ credentials: Credentials | None = None,
176
+ use_adc: bool = False,
177
+ service_account_file: str | Path | None = None,
178
+ service_account_info: dict[str, Any] | None = None,
179
+ oauth_client_secrets: str | Path | dict[str, Any] | None = None,
180
+ token_path: str | Path | None = None,
181
+ scopes: Sequence[str] | None = None,
182
+ allow_oauth_interactive: bool = False,
183
+ ) -> Credentials:
184
+ """Build Google credentials from explicit inputs, ADC, or optional OAuth.
185
+
186
+ Precedence: injected ``credentials`` > service account > on-disk OAuth token
187
+ > interactive OAuth (when enabled) > Application Default Credentials.
188
+ """
189
+ normalized = _normalize_scopes(scopes)
190
+ if credentials is not None:
191
+ return credentials
192
+
193
+ if service_account_file is not None:
194
+ return service_account.Credentials.from_service_account_file(
195
+ str(service_account_file),
196
+ scopes=normalized,
197
+ )
198
+ if service_account_info is not None:
199
+ return service_account.Credentials.from_service_account_info(
200
+ service_account_info,
201
+ scopes=normalized,
202
+ )
203
+
204
+ resolved_token = (
205
+ Path(token_path) if token_path is not None else default_token_path()
206
+ )
207
+ token_creds = _load_token_credentials(resolved_token, normalized)
208
+ if token_creds is not None:
209
+ has_scopes = getattr(token_creds, "has_scopes", None)
210
+ scopes_satisfied = (
211
+ not normalized or not callable(has_scopes) or bool(has_scopes(normalized))
212
+ )
213
+ if scopes_satisfied:
214
+ return token_creds
215
+ if not allow_oauth_interactive:
216
+ msg = (
217
+ f"Stored OAuth token {resolved_token} does not grant all required "
218
+ "scopes. Run `gdm auth login` to authorize the current scope set."
219
+ )
220
+ raise PermissionDeniedError(msg)
221
+
222
+ resolved_secrets = resolve_client_secrets_path(
223
+ oauth_client_secrets if isinstance(oauth_client_secrets, (str, Path)) else None
224
+ )
225
+ secrets = oauth_client_secrets
226
+ if secrets is None and resolved_secrets is not None:
227
+ secrets = resolved_secrets
228
+
229
+ if secrets is not None and allow_oauth_interactive:
230
+ return _run_installed_app_flow(secrets, normalized, resolved_token)
231
+
232
+ if use_adc or (
233
+ service_account_file is None
234
+ and service_account_info is None
235
+ and secrets is None
236
+ ):
237
+ adc_creds, _ = google_auth_default(scopes=normalized)
238
+ return adc_creds
239
+
240
+ if secrets is not None:
241
+ raise OAuthUnavailableError(
242
+ "OAuth client secrets were provided but no token exists. "
243
+ "Pass allow_oauth_interactive=True to run the installed-app flow, "
244
+ "or install google-drive-model[oauth]."
245
+ )
246
+
247
+ msg = "No credential source configured."
248
+ raise ValueError(msg)
249
+
250
+
251
+ def login_interactive(
252
+ *,
253
+ scopes: Sequence[str] | None = None,
254
+ client_secrets: str | Path | dict[str, Any] | None = None,
255
+ token_path: str | Path | None = None,
256
+ ) -> Credentials:
257
+ """Run installed-app OAuth and persist the refresh token locally."""
258
+ resolved_secrets = client_secrets or resolve_client_secrets_path()
259
+ if resolved_secrets is None:
260
+ msg = (
261
+ "OAuth client secrets are required. Pass client_secrets= or set "
262
+ "GDM_CLIENT_SECRETS to a client_secret JSON path."
263
+ )
264
+ raise ValueError(msg)
265
+ requested_scopes = list(scopes) if scopes is not None else list(DISCOVERY_SCOPES)
266
+ return build_credentials(
267
+ oauth_client_secrets=resolved_secrets,
268
+ token_path=token_path,
269
+ scopes=requested_scopes,
270
+ allow_oauth_interactive=True,
271
+ )
272
+
273
+
274
+ @overload
275
+ def build_drive_service(
276
+ credentials: Credentials,
277
+ *,
278
+ cache_discovery: bool = False,
279
+ wrap: Literal[True] = True,
280
+ supports_all_drives: bool = True,
281
+ ) -> ExecutingDriveService: ...
282
+
283
+
284
+ @overload
285
+ def build_drive_service(
286
+ credentials: Credentials,
287
+ *,
288
+ cache_discovery: bool = False,
289
+ wrap: Literal[False],
290
+ supports_all_drives: bool = True,
291
+ ) -> Resource: ...
292
+
293
+
294
+ def build_drive_service(
295
+ credentials: Credentials,
296
+ *,
297
+ cache_discovery: bool = False,
298
+ wrap: bool = True,
299
+ supports_all_drives: bool = True,
300
+ ) -> ExecutingDriveService | Resource:
301
+ """Construct a Drive v3 discovery client."""
302
+ raw = build(
303
+ "drive",
304
+ DRIVE_API_VERSION,
305
+ credentials=credentials,
306
+ cache_discovery=cache_discovery,
307
+ )
308
+ if wrap:
309
+ return wrap_drive_service(raw, supports_all_drives=supports_all_drives)
310
+ return raw
311
+
312
+
313
+ @overload
314
+ def build_sheets_service(
315
+ credentials: Credentials,
316
+ *,
317
+ cache_discovery: bool = False,
318
+ wrap: Literal[True] = True,
319
+ ) -> ExecutingSheetsService: ...
320
+
321
+
322
+ @overload
323
+ def build_sheets_service(
324
+ credentials: Credentials,
325
+ *,
326
+ cache_discovery: bool = False,
327
+ wrap: Literal[False],
328
+ ) -> Resource: ...
329
+
330
+
331
+ def build_sheets_service(
332
+ credentials: Credentials,
333
+ *,
334
+ cache_discovery: bool = False,
335
+ wrap: bool = True,
336
+ ) -> ExecutingSheetsService | Resource:
337
+ """Construct a Sheets v4 discovery client."""
338
+ raw = build(
339
+ "sheets",
340
+ SHEETS_API_VERSION,
341
+ credentials=credentials,
342
+ cache_discovery=cache_discovery,
343
+ )
344
+ if wrap:
345
+ return wrap_sheets_service(raw)
346
+ return raw
347
+
348
+
349
+ @overload
350
+ def build_docs_service(
351
+ credentials: Credentials,
352
+ *,
353
+ cache_discovery: bool = False,
354
+ wrap: Literal[True] = True,
355
+ ) -> ExecutingDocsService: ...
356
+
357
+
358
+ @overload
359
+ def build_docs_service(
360
+ credentials: Credentials,
361
+ *,
362
+ cache_discovery: bool = False,
363
+ wrap: Literal[False],
364
+ ) -> Resource: ...
365
+
366
+
367
+ def build_docs_service(
368
+ credentials: Credentials,
369
+ *,
370
+ cache_discovery: bool = False,
371
+ wrap: bool = True,
372
+ ) -> ExecutingDocsService | Resource:
373
+ """Construct a Google Docs v1 discovery client."""
374
+ raw = build(
375
+ "docs",
376
+ DOCS_API_VERSION,
377
+ credentials=credentials,
378
+ cache_discovery=cache_discovery,
379
+ )
380
+ if wrap:
381
+ return wrap_docs_service(raw)
382
+ return raw
383
+
384
+
385
+ @overload
386
+ def build_slides_service(
387
+ credentials: Credentials,
388
+ *,
389
+ cache_discovery: bool = False,
390
+ wrap: Literal[True] = True,
391
+ ) -> ExecutingSlidesService: ...
392
+
393
+
394
+ @overload
395
+ def build_slides_service(
396
+ credentials: Credentials,
397
+ *,
398
+ cache_discovery: bool = False,
399
+ wrap: Literal[False],
400
+ ) -> Resource: ...
401
+
402
+
403
+ def build_slides_service(
404
+ credentials: Credentials,
405
+ *,
406
+ cache_discovery: bool = False,
407
+ wrap: bool = True,
408
+ ) -> ExecutingSlidesService | Resource:
409
+ """Construct a Google Slides v1 discovery client."""
410
+ raw = build(
411
+ "slides",
412
+ SLIDES_API_VERSION,
413
+ credentials=credentials,
414
+ cache_discovery=cache_discovery,
415
+ )
416
+ if wrap:
417
+ return wrap_slides_service(raw)
418
+ return raw
419
+
420
+
421
+ def build_explorer_service(
422
+ *,
423
+ credentials: Credentials | None = None,
424
+ use_adc: bool = True,
425
+ token_path: str | Path | None = None,
426
+ scopes: Sequence[str] | None = None,
427
+ ) -> DriveExplorerAdapter:
428
+ """Build a read-only Drive explorer adapter using token, ADC, or OAuth."""
429
+ creds = build_credentials(
430
+ credentials=credentials,
431
+ use_adc=use_adc,
432
+ token_path=token_path,
433
+ scopes=scopes or [SCOPE_DRIVE_READONLY, SCOPE_SPREADSHEETS],
434
+ )
435
+ drive = build_drive_service(creds, wrap=True)
436
+ return DriveExplorerAdapter(drive)
437
+
438
+
439
+ def save_token(credentials: Credentials, token_path: str | Path | None = None) -> Path:
440
+ """Persist authorized-user credentials to disk without logging secrets."""
441
+ to_json = getattr(credentials, "to_json", None)
442
+ if not callable(to_json):
443
+ msg = "Only authorized-user OAuth credentials can be saved as tokens."
444
+ raise TypeError(msg)
445
+ path = Path(token_path) if token_path is not None else default_token_path()
446
+ _write_private_token(path, to_json())
447
+ logger.info("Saved OAuth token at %s", path)
448
+ return path
449
+
450
+
451
+ def scopes_for_discovery() -> list[str]:
452
+ """Return scopes needed for arbitrary Drive discovery/adoption."""
453
+ return list(DISCOVERY_SCOPES)
454
+
455
+
456
+ def scopes_for_created_resources() -> list[str]:
457
+ """Return least-privilege scopes for GDM-managed resources."""
458
+ return list(DEFAULT_SCOPES)
459
+
460
+
461
+ def explain_scope_requirements() -> str:
462
+ """Human-readable scope guidance for operators."""
463
+ return (
464
+ "Use drive.file + spreadsheets for resources GDM creates or the user "
465
+ "opens through the app. Discovering or adopting arbitrary existing Drive "
466
+ "content by path requires the broader drive scope (or drive.readonly for "
467
+ "read-only adoption), which may trigger Google's restricted-scope review "
468
+ "for distributed OAuth clients."
469
+ )
470
+
471
+
472
+ # Backward-compatible re-exports for modules that imported transport types from auth.
473
+ from gdm.errors import ( # noqa: E402
474
+ AmbiguousResourceError,
475
+ ForeignProjectError,
476
+ MappingMode,
477
+ NotFoundError,
478
+ PermissionDeniedError,
479
+ UnsupportedOperationError,
480
+ )
481
+ from gdm.retry import RetryPolicy, execute_with_retry # noqa: E402
482
+
483
+ __all__ = [
484
+ "DEFAULT_SCOPES",
485
+ "DISCOVERY_SCOPES",
486
+ "DOCS_API_VERSION",
487
+ "SCOPE_DRIVE",
488
+ "SCOPE_DRIVE_FILE",
489
+ "SCOPE_DRIVE_READONLY",
490
+ "SCOPE_SPREADSHEETS",
491
+ "SLIDES_API_VERSION",
492
+ "AmbiguousResourceError",
493
+ "ForeignProjectError",
494
+ "MappingMode",
495
+ "NotFoundError",
496
+ "OAuthUnavailableError",
497
+ "PermissionDeniedError",
498
+ "RetryPolicy",
499
+ "UnsupportedOperationError",
500
+ "build_credentials",
501
+ "build_docs_service",
502
+ "build_drive_service",
503
+ "build_explorer_service",
504
+ "build_sheets_service",
505
+ "build_slides_service",
506
+ "default_token_path",
507
+ "execute_with_retry",
508
+ "explain_scope_requirements",
509
+ "login_interactive",
510
+ "resolve_client_secrets_path",
511
+ "save_token",
512
+ "scopes_for_created_resources",
513
+ "scopes_for_discovery",
514
+ ]
@@ -0,0 +1,15 @@
1
+ """Storage backends shipped with GDM."""
2
+
3
+ from gdm.backends.base import Backend
4
+ from gdm.backends.drive import DriveBackend
5
+ from gdm.backends.memory import MemoryBackend
6
+ from gdm.backends.registry import RegistryBackend
7
+ from gdm.backends.sheets import SheetsBackend
8
+
9
+ __all__ = [
10
+ "Backend",
11
+ "DriveBackend",
12
+ "MemoryBackend",
13
+ "RegistryBackend",
14
+ "SheetsBackend",
15
+ ]