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.
- selander_bridge/__init__.py +60 -0
- selander_bridge/auth.py +199 -0
- selander_bridge/base.py +52 -0
- selander_bridge/contacts.py +123 -0
- selander_bridge/drive.py +98 -0
- selander_bridge/exceptions.py +13 -0
- selander_bridge-0.1.0.dist-info/METADATA +131 -0
- selander_bridge-0.1.0.dist-info/RECORD +10 -0
- selander_bridge-0.1.0.dist-info/WHEEL +5 -0
- selander_bridge-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
selander_bridge/auth.py
ADDED
|
@@ -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
|
selander_bridge/base.py
ADDED
|
@@ -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()
|
selander_bridge/drive.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
selander_bridge
|