selander-bridge 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,60 @@
1
+ """
2
+ selander_bridge
3
+ ===============
4
+
5
+ A reusable bridge between Python apps and Google Workspace APIs
6
+ (Drive, Contacts, and easy to extend to more).
7
+
8
+ Quick start (personal / per-developer Google accounts, no hosting needed):
9
+
10
+ from selander_bridge import GoogleAuthManager, ContactsClient, DriveClient
11
+
12
+ auth = GoogleAuthManager(
13
+ client_secrets_file="client_secret.json",
14
+ scopes=[*ContactsClient.scopes, *DriveClient.scopes],
15
+ )
16
+
17
+ contacts = ContactsClient(auth, account="me@gmail.com")
18
+ for person in contacts.list_contacts():
19
+ print(person.get("names"))
20
+
21
+ drive = DriveClient(auth, account="me@gmail.com")
22
+ for f in drive.list_files(query="mimeType='application/vnd.google-apps.folder'"):
23
+ print(f["name"])
24
+
25
+ Quick start (Google Workspace org you administer, zero browser interaction):
26
+
27
+ from selander_bridge import ServiceAccountAuthManager, DriveClient
28
+
29
+ auth = ServiceAccountAuthManager(
30
+ service_account_file="service_account.json",
31
+ scopes=DriveClient.scopes,
32
+ )
33
+ drive = DriveClient(auth, account="someuser@yourcompany.com")
34
+ """
35
+
36
+ from .auth import GoogleAuthManager, ServiceAccountAuthManager, TokenStore
37
+ from .base import BaseService
38
+ from .contacts import ContactsClient, SCOPE_CONTACTS, SCOPE_CONTACTS_READONLY
39
+ from .drive import DriveClient
40
+ from .exceptions import (
41
+ AuthenticationError,
42
+ MissingClientSecretsError,
43
+ SelanderBridgeError,
44
+ )
45
+
46
+ __version__ = "0.1.0"
47
+
48
+ __all__ = [
49
+ "GoogleAuthManager",
50
+ "ServiceAccountAuthManager",
51
+ "TokenStore",
52
+ "BaseService",
53
+ "ContactsClient",
54
+ "SCOPE_CONTACTS",
55
+ "SCOPE_CONTACTS_READONLY",
56
+ "DriveClient",
57
+ "SelanderBridgeError",
58
+ "AuthenticationError",
59
+ "MissingClientSecretsError",
60
+ ]
@@ -0,0 +1,199 @@
1
+ """
2
+ Authentication for selander_bridge.
3
+
4
+ The design goal here is specifically to avoid the "I need a hosted server
5
+ just to catch an OAuth redirect" problem. Instead of the web-application
6
+ OAuth flow (which needs a permanently running, publicly reachable callback
7
+ URL), this uses Google's installed-app / loopback flow:
8
+
9
+ 1. A browser window opens and the user logs into Google + consents.
10
+ 2. Google redirects to http://127.0.0.1:<random free port>/...
11
+ 3. A local HTTP server -- spun up only for this exchange, on a random
12
+ free port, and torn down immediately after -- receives the
13
+ authorization code and finishes the token exchange.
14
+ 4. The resulting refresh token is cached to disk (via TokenStore) so
15
+ every later run, in any app that imports selander_bridge and uses the
16
+ same `account_key`, picks the cached token up and never prompts again.
17
+
18
+ No server needs to be deployed or kept running anywhere for this to work.
19
+
20
+ If you're building a true multi-tenant web product where strangers need to
21
+ grant consent through your own domain, you'll still need *some* hosted
22
+ redirect endpoint for that flow specifically -- that's a Google requirement,
23
+ not a limitation of this library. Everything below still helps in that
24
+ case (token storage/refresh, the service wrappers), you'd just plug in a
25
+ different Flow object. See `GoogleAuthManager.from_web_flow` for a starting
26
+ point.
27
+
28
+ For the common Google Workspace admin case -- you control the whole
29
+ Workspace org and want to act on behalf of users in it -- a service account
30
+ with domain-wide delegation needs *zero* browser interaction and zero
31
+ hosting. See `ServiceAccountAuthManager` below.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import logging
37
+ from pathlib import Path
38
+ from typing import Optional, Sequence, Union
39
+
40
+ from google.auth.exceptions import RefreshError
41
+ from google.auth.transport.requests import Request
42
+ from google.oauth2.credentials import Credentials
43
+ from google.oauth2 import service_account
44
+ from google_auth_oauthlib.flow import InstalledAppFlow
45
+
46
+ from .exceptions import AuthenticationError, MissingClientSecretsError
47
+
48
+ logger = logging.getLogger("selander_bridge.auth")
49
+
50
+ DEFAULT_TOKEN_DIR = Path.home() / ".selander_bridge" / "tokens"
51
+
52
+
53
+ class TokenStore:
54
+ """
55
+ Pluggable storage for cached OAuth credentials.
56
+
57
+ Default implementation is one JSON file per `account_key` under
58
+ ~/.selander_bridge/tokens/. Swap this out (same 3 methods) for a
59
+ database- or keyring-backed store in a multi-user server app.
60
+ """
61
+
62
+ def __init__(self, base_dir: Optional[Union[str, Path]] = None):
63
+ self.base_dir = Path(base_dir) if base_dir else DEFAULT_TOKEN_DIR
64
+ self.base_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ def _path(self, account_key: str) -> Path:
67
+ safe = "".join(c if c.isalnum() or c in "-_.@" else "_" for c in account_key)
68
+ return self.base_dir / f"{safe}.json"
69
+
70
+ def load(self, account_key: str) -> Optional[Credentials]:
71
+ path = self._path(account_key)
72
+ if not path.exists():
73
+ return None
74
+ try:
75
+ data = json.loads(path.read_text())
76
+ return Credentials.from_authorized_user_info(data)
77
+ except (ValueError, KeyError) as exc:
78
+ logger.warning("Could not parse cached token for %s: %s", account_key, exc)
79
+ return None
80
+
81
+ def save(self, account_key: str, creds: Credentials) -> None:
82
+ self._path(account_key).write_text(creds.to_json())
83
+
84
+ def delete(self, account_key: str) -> None:
85
+ path = self._path(account_key)
86
+ if path.exists():
87
+ path.unlink()
88
+
89
+
90
+ class GoogleAuthManager:
91
+ """
92
+ Acquires and caches Google OAuth2 user credentials.
93
+
94
+ Usage:
95
+ auth = GoogleAuthManager(
96
+ client_secrets_file="client_secret.json",
97
+ scopes=ContactsClient.scopes + DriveClient.scopes,
98
+ )
99
+ creds = auth.get_credentials("someone@gmail.com")
100
+
101
+ One `GoogleAuthManager` (and its scope list) is normally shared across
102
+ every service wrapper in your app, so you only get one consent screen
103
+ covering everything you need, per account.
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ client_secrets_file: Union[str, Path],
109
+ scopes: Sequence[str],
110
+ token_store: Optional[TokenStore] = None,
111
+ ):
112
+ if not Path(client_secrets_file).exists():
113
+ raise MissingClientSecretsError(
114
+ f"client_secrets_file not found: {client_secrets_file}. "
115
+ "Download this from Google Cloud Console > APIs & Services > "
116
+ "Credentials > OAuth client ID (type: Desktop app)."
117
+ )
118
+ self.client_secrets_file = str(client_secrets_file)
119
+ self.scopes = list(scopes)
120
+ self.token_store = token_store or TokenStore()
121
+
122
+ def get_credentials(self, account_key: str = "default") -> Credentials:
123
+ """
124
+ Return valid credentials for `account_key`, refreshing or running
125
+ the interactive consent flow only if necessary.
126
+ """
127
+ creds = self.token_store.load(account_key)
128
+
129
+ if creds and set(self.scopes) - set(creds.scopes or []):
130
+ # Previously cached token doesn't cover everything requested now.
131
+ logger.info("Cached scopes for %s are insufficient; re-authenticating", account_key)
132
+ creds = None
133
+
134
+ if creds and creds.valid:
135
+ return creds
136
+
137
+ if creds and creds.expired and creds.refresh_token:
138
+ try:
139
+ creds.refresh(Request())
140
+ self.token_store.save(account_key, creds)
141
+ return creds
142
+ except RefreshError as exc:
143
+ logger.warning("Refresh failed for %s (%s); re-authenticating", account_key, exc)
144
+ creds = None
145
+
146
+ creds = self._run_interactive_flow()
147
+ self.token_store.save(account_key, creds)
148
+ return creds
149
+
150
+ def _run_interactive_flow(self) -> Credentials:
151
+ try:
152
+ flow = InstalledAppFlow.from_client_secrets_file(
153
+ self.client_secrets_file, scopes=self.scopes
154
+ )
155
+ # port=0 -> OS picks a free local port; server shuts down right
156
+ # after the redirect is caught. Nothing stays running.
157
+ return flow.run_local_server(port=0)
158
+ except Exception as exc: # pragma: no cover - surfaced to caller
159
+ raise AuthenticationError(f"Interactive Google sign-in failed: {exc}") from exc
160
+
161
+ def forget(self, account_key: str) -> None:
162
+ """Drop the cached token for an account, forcing re-auth next time."""
163
+ self.token_store.delete(account_key)
164
+
165
+
166
+ class ServiceAccountAuthManager:
167
+ """
168
+ Zero-interaction, zero-hosting auth for Google Workspace admins.
169
+
170
+ Use this instead of GoogleAuthManager when every account you need to
171
+ touch belongs to a Workspace domain you administer. A super admin
172
+ enables domain-wide delegation for a service account once, and from
173
+ then on this manager can impersonate any user in the org with no
174
+ browser, no consent screen, and no token caching needed (a fresh
175
+ short-lived token is minted on every call).
176
+
177
+ Setup (one-time, in Google Cloud Console + Workspace Admin console):
178
+ 1. Create a service account, generate a JSON key.
179
+ 2. Enable domain-wide delegation on it, note its OAuth Client ID.
180
+ 3. In admin.google.com > Security > API controls > Domain-wide
181
+ delegation, authorize that client ID for the scopes you need.
182
+ """
183
+
184
+ def __init__(self, service_account_file: Union[str, Path], scopes: Sequence[str]):
185
+ if not Path(service_account_file).exists():
186
+ raise MissingClientSecretsError(
187
+ f"service_account_file not found: {service_account_file}"
188
+ )
189
+ self.service_account_file = str(service_account_file)
190
+ self.scopes = list(scopes)
191
+
192
+ def get_credentials(self, subject: str) -> Credentials:
193
+ """`subject` is the email of the Workspace user to impersonate."""
194
+ try:
195
+ return service_account.Credentials.from_service_account_file(
196
+ self.service_account_file, scopes=self.scopes, subject=subject
197
+ )
198
+ except Exception as exc:
199
+ raise AuthenticationError(f"Service-account auth failed for {subject}: {exc}") from exc
@@ -0,0 +1,52 @@
1
+ """Shared plumbing for every wrapped Google API."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Sequence
5
+
6
+ from googleapiclient.discovery import build, Resource
7
+ from googleapiclient.errors import HttpError
8
+
9
+ from .exceptions import SelanderBridgeError
10
+
11
+
12
+ class BaseService:
13
+ """
14
+ Base class every service wrapper (ContactsClient, DriveClient, ...)
15
+ inherits from. Handles lazily building + caching the underlying
16
+ googleapiclient Resource object the first time it's actually used.
17
+
18
+ Works with either GoogleAuthManager (pass an account_key/email for
19
+ `account`) or ServiceAccountAuthManager (pass the subject email to
20
+ impersonate for `account`) -- both expose a one-argument
21
+ `get_credentials(account)` method.
22
+ """
23
+
24
+ api_name: str
25
+ api_version: str
26
+ scopes: Sequence[str] = ()
27
+
28
+ def __init__(self, auth_manager: Any, account: str = "default"):
29
+ self._auth_manager = auth_manager
30
+ self._account = account
31
+ self._service: Resource | None = None
32
+
33
+ @property
34
+ def service(self) -> Resource:
35
+ if self._service is None:
36
+ creds = self._auth_manager.get_credentials(self._account)
37
+ self._service = build(self.api_name, self.api_version, credentials=creds)
38
+ return self._service
39
+
40
+
41
+ def wrap_http_errors(func):
42
+ """Decorator that turns raw googleapiclient HttpError into a SelanderBridgeError."""
43
+
44
+ def _wrapped(*args, **kwargs):
45
+ try:
46
+ return func(*args, **kwargs)
47
+ except HttpError as exc:
48
+ raise SelanderBridgeError(f"Google API call failed: {exc}") from exc
49
+
50
+ _wrapped.__name__ = func.__name__
51
+ _wrapped.__doc__ = func.__doc__
52
+ return _wrapped
@@ -0,0 +1,123 @@
1
+ """Wrapper around the Google People API (Contacts)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Iterator, Optional
5
+
6
+ from .base import BaseService, wrap_http_errors
7
+
8
+ # Read-only is the safer default. Write operations need the full "contacts"
9
+ # scope, so callers can opt in only when they need CRUD.
10
+ SCOPE_CONTACTS_READONLY = "https://www.googleapis.com/auth/contacts.readonly"
11
+ SCOPE_CONTACTS = "https://www.googleapis.com/auth/contacts"
12
+
13
+ DEFAULT_PERSON_FIELDS = "names,emailAddresses,phoneNumbers,organizations"
14
+
15
+
16
+ class ContactsClient(BaseService):
17
+ """
18
+ Example:
19
+ contacts = ContactsClient(auth, account="me@gmail.com")
20
+ for person in contacts.list_contacts():
21
+ print(person.get("names"))
22
+
23
+ By default this client is read-only. To use create/update/delete methods,
24
+ include `SCOPE_CONTACTS` in the auth manager's scope list.
25
+ """
26
+
27
+ api_name = "people"
28
+ api_version = "v1"
29
+ scopes = (SCOPE_CONTACTS_READONLY,)
30
+
31
+ @wrap_http_errors
32
+ def list_contacts(
33
+ self,
34
+ page_size: int = 100,
35
+ person_fields: str = DEFAULT_PERSON_FIELDS,
36
+ ) -> Iterator[dict]:
37
+ """Yield every contact connection for the authenticated user, handling pagination."""
38
+ request = self.service.people().connections().list(
39
+ resourceName="people/me",
40
+ pageSize=page_size,
41
+ personFields=person_fields,
42
+ )
43
+ while request is not None:
44
+ response = request.execute()
45
+ yield from response.get("connections", [])
46
+ request = self.service.people().connections().list_next(request, response)
47
+
48
+ @wrap_http_errors
49
+ def get_contact(
50
+ self, resource_name: str, person_fields: str = DEFAULT_PERSON_FIELDS
51
+ ) -> dict:
52
+ """resource_name looks like 'people/c1234567890123456789'."""
53
+ return (
54
+ self.service.people()
55
+ .get(resourceName=resource_name, personFields=person_fields)
56
+ .execute()
57
+ )
58
+
59
+ @wrap_http_errors
60
+ def search_contacts(self, query: str, page_size: int = 10) -> Iterator[dict]:
61
+ """
62
+ Search the authenticated user's "other contacts" + contacts by name,
63
+ nickname, email, or phone number substring.
64
+ """
65
+ response = (
66
+ self.service.people()
67
+ .searchContacts(query=query, pageSize=page_size, readMask=DEFAULT_PERSON_FIELDS)
68
+ .execute()
69
+ )
70
+ for result in response.get("results", []):
71
+ yield result.get("person", {})
72
+
73
+ @wrap_http_errors
74
+ def create_contact(
75
+ self,
76
+ *,
77
+ body: Optional[dict[str, Any]] = None,
78
+ given_name: Optional[str] = None,
79
+ family_name: str = "",
80
+ email: Optional[str] = None,
81
+ ) -> dict:
82
+ """
83
+ Create a new contact.
84
+
85
+ You can pass a raw People API `body`, or use the simple name/email
86
+ helpers for common cases.
87
+ """
88
+ if body is None:
89
+ if not given_name:
90
+ raise ValueError("Either body or given_name must be provided")
91
+ body = {"names": [{"givenName": given_name, "familyName": family_name}]}
92
+ if email:
93
+ body["emailAddresses"] = [{"value": email}]
94
+ return self.service.people().createContact(body=body).execute()
95
+
96
+ @wrap_http_errors
97
+ def update_contact(
98
+ self,
99
+ resource_name: str,
100
+ *,
101
+ body: dict[str, Any],
102
+ update_person_fields: str,
103
+ ) -> dict:
104
+ """
105
+ Update an existing contact.
106
+
107
+ `update_person_fields` should be a comma-separated field mask such as
108
+ "names,emailAddresses".
109
+ """
110
+ return (
111
+ self.service.people()
112
+ .updateContact(
113
+ resourceName=resource_name,
114
+ updatePersonFields=update_person_fields,
115
+ body=body,
116
+ )
117
+ .execute()
118
+ )
119
+
120
+ @wrap_http_errors
121
+ def delete_contact(self, resource_name: str) -> None:
122
+ """Delete a contact by People API resource name."""
123
+ self.service.people().deleteContact(resourceName=resource_name).execute()
@@ -0,0 +1,98 @@
1
+ """Wrapper around the Google Drive API."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Iterator, Optional, Union
6
+
7
+ from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
8
+
9
+ from .base import BaseService, wrap_http_errors
10
+
11
+ SCOPE_DRIVE_READONLY = "https://www.googleapis.com/auth/drive.readonly"
12
+ SCOPE_DRIVE_FILE = "https://www.googleapis.com/auth/drive.file" # files the app created/opened
13
+ SCOPE_DRIVE = "https://www.googleapis.com/auth/drive" # full access
14
+
15
+ DEFAULT_FIELDS = "nextPageToken, files(id, name, mimeType, modifiedTime, size, webViewLink)"
16
+
17
+
18
+ class DriveClient(BaseService):
19
+ """
20
+ Example:
21
+ drive = DriveClient(auth, account="me@gmail.com")
22
+ for f in drive.list_files(query="mimeType='application/pdf'"):
23
+ print(f["name"])
24
+ """
25
+
26
+ api_name = "drive"
27
+ api_version = "v3"
28
+ scopes = (SCOPE_DRIVE,)
29
+
30
+ @wrap_http_errors
31
+ def list_files(
32
+ self,
33
+ query: Optional[str] = None,
34
+ page_size: int = 100,
35
+ fields: str = DEFAULT_FIELDS,
36
+ ) -> Iterator[dict]:
37
+ """Yield files matching an optional Drive query string, handling pagination."""
38
+ page_token = None
39
+ while True:
40
+ response = (
41
+ self.service.files()
42
+ .list(q=query, pageSize=page_size, fields=fields, pageToken=page_token)
43
+ .execute()
44
+ )
45
+ yield from response.get("files", [])
46
+ page_token = response.get("nextPageToken")
47
+ if not page_token:
48
+ break
49
+
50
+ @wrap_http_errors
51
+ def upload_file(
52
+ self,
53
+ local_path: Union[str, Path],
54
+ parent_id: Optional[str] = None,
55
+ name: Optional[str] = None,
56
+ ) -> dict:
57
+ local_path = Path(local_path)
58
+ metadata: dict = {"name": name or local_path.name}
59
+ if parent_id:
60
+ metadata["parents"] = [parent_id]
61
+ media = MediaFileUpload(str(local_path), resumable=True)
62
+ return (
63
+ self.service.files()
64
+ .create(body=metadata, media_body=media, fields="id, name, webViewLink")
65
+ .execute()
66
+ )
67
+
68
+ @wrap_http_errors
69
+ def download_file(self, file_id: str, destination: Union[str, Path]) -> Path:
70
+ destination = Path(destination)
71
+ request = self.service.files().get_media(fileId=file_id)
72
+ with destination.open("wb") as fh:
73
+ downloader = MediaIoBaseDownload(fh, request)
74
+ done = False
75
+ while not done:
76
+ _, done = downloader.next_chunk()
77
+ return destination
78
+
79
+ @wrap_http_errors
80
+ def create_folder(self, name: str, parent_id: Optional[str] = None) -> dict:
81
+ metadata: dict = {"name": name, "mimeType": "application/vnd.google-apps.folder"}
82
+ if parent_id:
83
+ metadata["parents"] = [parent_id]
84
+ return self.service.files().create(body=metadata, fields="id, name").execute()
85
+
86
+ @wrap_http_errors
87
+ def delete_file(self, file_id: str) -> None:
88
+ self.service.files().delete(fileId=file_id).execute()
89
+
90
+ @wrap_http_errors
91
+ def share_file(self, file_id: str, email: str, role: str = "reader") -> dict:
92
+ """role: 'reader', 'commenter', or 'writer'."""
93
+ permission = {"type": "user", "role": role, "emailAddress": email}
94
+ return (
95
+ self.service.permissions()
96
+ .create(fileId=file_id, body=permission, fields="id")
97
+ .execute()
98
+ )
@@ -0,0 +1,13 @@
1
+ """Exceptions raised by selander_bridge."""
2
+
3
+
4
+ class SelanderBridgeError(Exception):
5
+ """Base class for every error raised by this library."""
6
+
7
+
8
+ class AuthenticationError(SelanderBridgeError):
9
+ """Raised when acquiring or refreshing Google credentials fails."""
10
+
11
+
12
+ class MissingClientSecretsError(SelanderBridgeError):
13
+ """Raised when no client_secrets file/dict was supplied and none can be found."""
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: selander-bridge
3
+ Version: 0.1.0
4
+ Summary: Reusable bridge between Python apps and Google Workspace APIs (Drive, Contacts, and more)
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: google-api-python-client>=2.100.0
9
+ Requires-Dist: google-auth>=2.23.0
10
+ Requires-Dist: google-auth-oauthlib>=1.1.0
11
+ Requires-Dist: google-auth-httplib2>=0.1.1
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
14
+
15
+ # selander_bridge
16
+
17
+ A small, reusable Python library that bridges your apps to Google accounts
18
+ and Google Workspace services (Drive, Contacts, more to come) - built once,
19
+ imported everywhere, with **no server you have to host** for auth.
20
+
21
+ ## Why this exists
22
+
23
+ The standard "web app" OAuth flow needs a permanently running, publicly
24
+ reachable URL to catch Google's redirect. That's the "too much hosting"
25
+ problem. `selander_bridge` defaults to Google's **installed-app / loopback
26
+ flow** instead: a browser opens, you log in, Google redirects to a local
27
+ port that only exists for a few seconds, and the resulting refresh token is
28
+ cached to disk. Every app that imports this library and reuses the same
29
+ `account` key skips the login screen after the first time.
30
+
31
+ If every account you need belongs to a Workspace domain you administer,
32
+ you can skip browser login entirely with `ServiceAccountAuthManager`
33
+ (domain-wide delegation) — zero hosting, zero interaction.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install -e .
39
+ ```
40
+
41
+ (Once you're happy with it, `pip install build twine` and publish it to
42
+ your own PyPI-compatible index or a private Git URL so other projects can
43
+ `pip install` it directly.)
44
+
45
+ ## One-time Google Cloud setup
46
+
47
+ Before running your code, you must configure a Google Cloud project to get your `client_secret.json` and enable the APIs you plan to use. **If you skip enabling the APIs, your code will crash.**
48
+
49
+ Please follow our detailed step-by-step guide:
50
+ 👉 **[Google Cloud Setup Guide](docs/cloud_setup.md)**
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ from selander_bridge import (
56
+ ContactsClient,
57
+ DriveClient,
58
+ GoogleAuthManager,
59
+ SCOPE_CONTACTS,
60
+ )
61
+
62
+ auth = GoogleAuthManager(
63
+ client_secrets_file="client_secret.json",
64
+ scopes=[*ContactsClient.scopes, *DriveClient.scopes, SCOPE_CONTACTS],
65
+ )
66
+
67
+ # First call opens a browser once; after that, the cached token is reused.
68
+ contacts = ContactsClient(auth, account="me@gmail.com")
69
+ for person in contacts.list_contacts():
70
+ print(person.get("names"))
71
+
72
+ created = contacts.create_contact(
73
+ given_name="Ada",
74
+ family_name="Lovelace",
75
+ email="ada@example.com",
76
+ )
77
+
78
+ contacts.update_contact(
79
+ created["resourceName"],
80
+ body={"names": [{"givenName": "Ada", "familyName": "Byron"}]},
81
+ update_person_fields="names",
82
+ )
83
+
84
+ contacts.delete_contact(created["resourceName"])
85
+
86
+ drive = DriveClient(auth, account="me@gmail.com")
87
+ drive.upload_file("report.pdf", name="Q2 Report.pdf")
88
+ ```
89
+
90
+ Use a different `account` string per Google account you need to talk to —
91
+ each gets its own cached token under `~/.selander_bridge/tokens/`.
92
+
93
+ ## Extending to more Workspace APIs
94
+
95
+ Every wrapper follows the same shape. To add Calendar, Sheets, Gmail, etc.,
96
+ copy `drive.py` as a template:
97
+
98
+ ```python
99
+ from .base import BaseService, wrap_http_errors
100
+
101
+ class CalendarClient(BaseService):
102
+ api_name = "calendar"
103
+ api_version = "v3"
104
+ scopes = ("https://www.googleapis.com/auth/calendar.readonly",)
105
+
106
+ @wrap_http_errors
107
+ def list_events(self, calendar_id="primary", max_results=50):
108
+ resp = self.service.events().list(
109
+ calendarId=calendar_id, maxResults=max_results
110
+ ).execute()
111
+ return resp.get("items", [])
112
+ ```
113
+
114
+ Then export it from `selander_bridge/__init__.py`.
115
+
116
+ ## Project layout
117
+
118
+ ```
119
+ selander_bridge/
120
+ ├── pyproject.toml
121
+ ├── README.md
122
+ ├── src/selander_bridge/
123
+ │ ├── __init__.py # public exports
124
+ │ ├── auth.py # GoogleAuthManager, ServiceAccountAuthManager, TokenStore
125
+ │ ├── base.py # BaseService (shared plumbing), wrap_http_errors
126
+ │ ├── contacts.py # ContactsClient (People API)
127
+ │ ├── drive.py # DriveClient
128
+ │ └── exceptions.py
129
+ └── tests/
130
+ └── test_imports.py
131
+ ```
@@ -0,0 +1,10 @@
1
+ selander_bridge/__init__.py,sha256=tHhIZHlBREiooEMT25Z3TK8-zgnSInxfZfxBfLXMXl0,1750
2
+ selander_bridge/auth.py,sha256=Vu-gfAI5_R7bmqtXrJ0HU8qs_ubayFYeW0_Oll989gg,8319
3
+ selander_bridge/base.py,sha256=c1kHQB2LUxoxEzyhBKvDYTQ4RrO8u7tcnO-4n9pkYBI,1705
4
+ selander_bridge/contacts.py,sha256=aCNaYB9w6VstrGYk3BsaHBvv-gynSaW1Ya-sRPMX3SU,4142
5
+ selander_bridge/drive.py,sha256=CHLMh2JkmObk6fa11vaae87T50dQih-DSa2QI284Zzs,3467
6
+ selander_bridge/exceptions.py,sha256=LzJ6tbRtc4W1V4T_INIsafnXRUgHeXUqb12JxtVNmQM,409
7
+ selander_bridge-0.1.0.dist-info/METADATA,sha256=TOoKzuzQ7Y8KY8naHUtLAXSzg_BPwfbqJpDwz3nPAKs,4232
8
+ selander_bridge-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ selander_bridge-0.1.0.dist-info/top_level.txt,sha256=PItKrMh-i4H6JzfBUkHJQn9hzf1BrXfzzSD7SsBRPMI,16
10
+ selander_bridge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ selander_bridge