arrows 0.2.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.
arrows/__init__.py ADDED
@@ -0,0 +1,159 @@
1
+ """arrows — an Arrow-native ETL toolkit with pluggable components.
2
+
3
+ Nothing heavy is imported at module load. Components (and the third-party
4
+ libraries they need) are pulled in the first time they are used::
5
+
6
+ import arrows
7
+
8
+ arrows.load('s3', 'redshift') # load exactly what this job needs
9
+ arrow = arrows.redshift.fetch_arrow('select 1')
10
+
11
+ ``arrows.list_components()`` shows what is available, including components
12
+ contributed by other packages through the ``arrows.components`` entry point.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ from importlib.metadata import PackageNotFoundError
19
+ from importlib.metadata import version as _metadata_version
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ # Registering the built-in specs costs nothing: only metadata is imported here.
23
+ from . import components as _components # noqa: F401
24
+ from .core.component import Component, ComponentSpec, HealthStatus
25
+ from .core.errors import (
26
+ ArrowsError,
27
+ ComponentNotFoundError,
28
+ ComponentNotLoadedError,
29
+ ComponentSetupError,
30
+ MissingDependencyError,
31
+ MissingSecretError,
32
+ )
33
+ from .core.registry import ENTRY_POINT_GROUP, register, specs
34
+ from .core.secrets import PromptProvider, Secret, SecretStore
35
+ from .core.session import (
36
+ Session,
37
+ close,
38
+ configure,
39
+ default_session,
40
+ get,
41
+ health,
42
+ is_loaded,
43
+ load,
44
+ login,
45
+ unload,
46
+ use_session,
47
+ )
48
+
49
+ #: pyproject.toml is the single source of truth; read it back from the installed
50
+ #: metadata rather than keeping a second copy here that can drift from the tag.
51
+ try:
52
+ __version__ = _metadata_version('arrows')
53
+ except PackageNotFoundError: # running from a source tree that was never installed
54
+ __version__ = '0.0.0.dev0'
55
+
56
+ #: Submodules exposed as attributes but imported on first access.
57
+ _LAZY_MODULES = {
58
+ 'auth',
59
+ 'gmail',
60
+ 'google_sheets',
61
+ 'redshift',
62
+ 's3',
63
+ 'sqlite',
64
+ 'template_renderer',
65
+ 'utils',
66
+ }
67
+
68
+ if TYPE_CHECKING: # give editors and type checkers the real modules
69
+ from . import auth, gmail, google_sheets, redshift, s3, sqlite, template_renderer, utils
70
+
71
+ __all__ = [
72
+ 'ENTRY_POINT_GROUP',
73
+ 'ArrowsError',
74
+ 'Component',
75
+ 'ComponentNotFoundError',
76
+ 'ComponentNotLoadedError',
77
+ 'ComponentSetupError',
78
+ 'ComponentSpec',
79
+ 'HealthStatus',
80
+ 'MissingDependencyError',
81
+ 'MissingSecretError',
82
+ 'PromptProvider',
83
+ 'Secret',
84
+ 'SecretStore',
85
+ 'Session',
86
+ 'auth',
87
+ 'close',
88
+ 'configure',
89
+ 'default_session',
90
+ 'get',
91
+ 'gmail',
92
+ 'google_sheets',
93
+ 'health',
94
+ 'is_loaded',
95
+ 'list_components',
96
+ 'load',
97
+ 'load_credentials',
98
+ 'login',
99
+ 'redshift',
100
+ 'register',
101
+ 's3',
102
+ 'sqlite',
103
+ 'template_renderer',
104
+ 'unload',
105
+ 'use_session',
106
+ 'utils',
107
+ ]
108
+
109
+
110
+ def __getattr__(name: str) -> Any:
111
+ """PEP 562 lazy submodule import.
112
+
113
+ ``arrows.redshift`` works without ``import arrows`` dragging in psycopg2,
114
+ boto3, awswrangler and the Google client for every process that only wanted
115
+ to send an email.
116
+ """
117
+ if name in _LAZY_MODULES:
118
+ module = importlib.import_module(f'.{name}', __name__)
119
+ globals()[name] = module
120
+ return module
121
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
122
+
123
+
124
+ def __dir__() -> list[str]:
125
+ return sorted(set(__all__) | set(globals()))
126
+
127
+
128
+ def list_components() -> list[dict[str, Any]]:
129
+ """Describe every registered component: name, dependencies, load state."""
130
+ session = default_session()
131
+ return [
132
+ {
133
+ 'name': spec.name,
134
+ 'summary': spec.summary,
135
+ 'depends_on': list(spec.depends_on),
136
+ 'install_hint': spec.install_hint,
137
+ 'loaded': session.is_loaded(spec.name),
138
+ }
139
+ for spec in sorted(specs().values(), key=lambda s: s.name)
140
+ ]
141
+
142
+
143
+ def load_credentials(*names: str) -> Session:
144
+ """Deprecated alias for :func:`load`.
145
+
146
+ The old behaviour — loading AWS, Redshift and Google unconditionally — is
147
+ kept only when called with no arguments, and warns.
148
+ """
149
+ import warnings
150
+
151
+ if not names:
152
+ warnings.warn(
153
+ 'load_credentials() without arguments loads every component and fails if any '
154
+ "credential is missing. Prefer arrows.load('s3', 'redshift').",
155
+ DeprecationWarning,
156
+ stacklevel=2,
157
+ )
158
+ names = ('s3', 'redshift', 'google')
159
+ return load(*names)
arrows/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == '__main__':
6
+ sys.exit(main())
arrows/auth.py ADDED
@@ -0,0 +1,39 @@
1
+ """Backwards-compatible credential loading.
2
+
3
+ Superseded by :mod:`arrows.core.secrets` and :func:`arrows.load`. These
4
+ functions remain so existing scripts keep running; each one loads the matching
5
+ component and warns once.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from typing import Any
12
+
13
+ from .core.session import default_session
14
+
15
+ __all__ = ['load_aws_credentials', 'load_google_credentials', 'load_redshift_credentials']
16
+
17
+
18
+ def _deprecated(old: str, new: str) -> None:
19
+ warnings.warn(f'arrows.auth.{old}() is deprecated; use {new} instead.', DeprecationWarning, stacklevel=3)
20
+
21
+
22
+ def load_aws_credentials() -> Any:
23
+ _deprecated('load_aws_credentials', "arrows.load('s3')")
24
+ return default_session().get('s3')
25
+
26
+
27
+ def load_redshift_credentials() -> Any:
28
+ _deprecated('load_redshift_credentials', "arrows.load('redshift')")
29
+ return default_session().get('redshift')
30
+
31
+
32
+ def load_google_credentials() -> Any:
33
+ _deprecated('load_google_credentials', "arrows.load('google')")
34
+ return default_session().get('google')
35
+
36
+
37
+ def _get_google_credentials() -> Any:
38
+ """Legacy accessor for the raw google-auth credentials object."""
39
+ return default_session().get('google').credentials
arrows/cli.py ADDED
@@ -0,0 +1,102 @@
1
+ """``arrows`` command line: see what exists, and whether it works.
2
+
3
+ arrows components list every registered component
4
+ arrows doctor s3 redshift load them and run their health checks
5
+ arrows secrets redshift show which required secrets resolve (redacted)
6
+ arrows login redshift --save type the missing ones, store them in the keychain
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+
14
+ from . import list_components
15
+ from .core import registry
16
+ from .core.session import default_session
17
+
18
+
19
+ def _cmd_components(args: argparse.Namespace) -> int:
20
+ rows = list_components()
21
+ width = max((len(row['name']) for row in rows), default=4)
22
+ for row in rows:
23
+ marker = '*' if row['loaded'] else ' '
24
+ depends = f' (needs {", ".join(row["depends_on"])})' if row['depends_on'] else ''
25
+ hint = f' [{row["install_hint"]}]' if row['install_hint'] else ''
26
+ print(f'{marker} {row["name"]:<{width}} {row["summary"]}{depends}{hint}')
27
+ print('\n* = loaded in this session')
28
+ return 0
29
+
30
+
31
+ def _cmd_doctor(args: argparse.Namespace) -> int:
32
+ session = default_session()
33
+ names = args.components or registry.names()
34
+ exit_code = 0
35
+ for name in names:
36
+ try:
37
+ session.load(name)
38
+ status = session.get(name).health_check()
39
+ print(f'{"ok " if status.ok else "FAIL"} {name:<14} {status.detail}')
40
+ exit_code |= 0 if status.ok else 1
41
+ except Exception as exc:
42
+ print(f'FAIL {name:<14} {type(exc).__name__}: {exc}')
43
+ exit_code = 1
44
+ return exit_code
45
+
46
+
47
+ def _cmd_secrets(args: argparse.Namespace) -> int:
48
+ session = default_session()
49
+ print(f'providers: {", ".join(p.name for p in session.secrets.providers)}\n')
50
+ exit_code = 0
51
+ for name in args.components or registry.names():
52
+ spec = registry.get_spec(name)
53
+ component = spec.build()
54
+ print(f'{spec.name}:')
55
+ for key in component.requires:
56
+ found = session.secrets.get(key)
57
+ print(f' {"ok " if found else "MISS"} {key:<32} {found.source if found else "-"}')
58
+ exit_code |= 0 if found else 1
59
+ for key in component.optional:
60
+ found = session.secrets.get(key)
61
+ print(f' {"ok " if found else "-- "} {key:<32} {(found.source if found else "optional")}')
62
+ if not component.requires and not component.optional:
63
+ print(' (no secrets required)')
64
+ return exit_code
65
+
66
+
67
+ def _cmd_login(args: argparse.Namespace) -> int:
68
+ session = default_session()
69
+ try:
70
+ session.login(*args.components, save=args.save, include_optional=args.all)
71
+ except Exception as exc:
72
+ print(f'{type(exc).__name__}: {exc}', file=sys.stderr)
73
+ return 1
74
+ return _cmd_doctor(args)
75
+
76
+
77
+ def main(argv: list[str] | None = None) -> int:
78
+ parser = argparse.ArgumentParser(prog='arrows', description=__doc__.splitlines()[0])
79
+ subparsers = parser.add_subparsers(dest='command', required=True)
80
+
81
+ subparsers.add_parser('components', help='list registered components').set_defaults(func=_cmd_components)
82
+
83
+ doctor = subparsers.add_parser('doctor', help='load components and run health checks')
84
+ doctor.add_argument('components', nargs='*', help='component names (default: all)')
85
+ doctor.set_defaults(func=_cmd_doctor)
86
+
87
+ secrets = subparsers.add_parser('secrets', help='report which secrets resolve, without revealing them')
88
+ secrets.add_argument('components', nargs='*', help='component names (default: all)')
89
+ secrets.set_defaults(func=_cmd_secrets)
90
+
91
+ login = subparsers.add_parser('login', help='prompt for missing secrets, then load and check')
92
+ login.add_argument('components', nargs='+', help='component names')
93
+ login.add_argument('--save', action='store_true', help='remember the answers in the OS keychain')
94
+ login.add_argument('--all', action='store_true', help='also ask for optional keys')
95
+ login.set_defaults(func=_cmd_login)
96
+
97
+ args = parser.parse_args(argv)
98
+ return args.func(args)
99
+
100
+
101
+ if __name__ == '__main__':
102
+ sys.exit(main())
@@ -0,0 +1,69 @@
1
+ """Built-in components.
2
+
3
+ Only the :class:`ComponentSpec` metadata is imported here; the modules that
4
+ define the components — and their heavy third-party dependencies — are imported
5
+ the first time a component is actually loaded.
6
+
7
+ To add a component:
8
+
9
+ 1. Copy ``sqlite.py``, implement ``setup``/``close``/``health_check``.
10
+ 2. Declare ``requires`` (secret keys) and ``depends_on`` (other components).
11
+ 3. Export ``COMPONENT`` and a ``SPEC``.
12
+ 4. Register the ``SPEC`` below — or, from a separate distribution, advertise it
13
+ through the ``arrows.components`` entry-point group; nothing in arrows needs
14
+ to change.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from ..core.component import ComponentSpec
20
+ from ..core.registry import register
21
+
22
+ __all__ = ['BUILTIN_SPECS']
23
+
24
+ BUILTIN_SPECS: tuple[ComponentSpec, ...] = (
25
+ ComponentSpec(
26
+ name='aws',
27
+ module='arrows.components.aws',
28
+ summary='boto3 session + DuckDB S3 credential chain (shared by s3 and redshift)',
29
+ ),
30
+ ComponentSpec(
31
+ name='s3',
32
+ module='arrows.components.s3',
33
+ summary='S3 datasets in Parquet, queryable with DuckDB/Polars',
34
+ depends_on=('aws',),
35
+ ),
36
+ ComponentSpec(
37
+ name='redshift',
38
+ module='arrows.components.redshift',
39
+ summary='Amazon Redshift: query, UNLOAD to S3, COPY from S3',
40
+ depends_on=('aws',),
41
+ ),
42
+ ComponentSpec(
43
+ name='google',
44
+ module='arrows.components.google',
45
+ summary='Google OAuth credentials shared by gmail and google_sheets',
46
+ aliases=('google_auth',),
47
+ ),
48
+ ComponentSpec(
49
+ name='google_sheets',
50
+ module='arrows.components.google_sheets',
51
+ summary='Read and write Google Sheets as Arrow/Polars/DuckDB',
52
+ depends_on=('google',),
53
+ aliases=('sheets', 'googlesheets'),
54
+ ),
55
+ ComponentSpec(
56
+ name='gmail',
57
+ module='arrows.components.gmail',
58
+ summary='Send email through the Gmail API',
59
+ depends_on=('google',),
60
+ ),
61
+ ComponentSpec(
62
+ name='sqlite',
63
+ module='arrows.components.sqlite',
64
+ summary='Local SQLite files, read and written through DuckDB (reference component)',
65
+ ),
66
+ )
67
+
68
+ for _spec in BUILTIN_SPECS:
69
+ register(_spec, replace=True)
@@ -0,0 +1,164 @@
1
+ """AWS credentials, shared by the s3 and redshift components.
2
+
3
+ Best practice applied here: arrows does **not** invent a credential format. It
4
+ defers to boto3's own resolution chain (env vars, ``~/.aws/credentials``, SSO,
5
+ ``AWS_PROFILE``, EC2/ECS/EKS instance roles), which is the only path that
6
+ supports short-lived, automatically rotated credentials. Explicit keys from the
7
+ :class:`SecretStore` are honoured when present, for laptops that still use them.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ from pathlib import Path
14
+ from typing import Any, ClassVar
15
+
16
+ from ..core.component import Component, HealthStatus
17
+ from ..core.engine import get_duckdb, quote_literal
18
+ from ..core.secrets import SecretStore
19
+
20
+ EXPLICIT_KEYS = ('AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN')
21
+
22
+
23
+ class LegacyAwsFileProvider:
24
+ """Reads the historical ``~/.credentials/aws_credentials.txt`` layout.
25
+
26
+ Kept so existing setups keep working; prefer a real ``~/.aws/credentials``
27
+ profile or SSO, which boto3 refreshes on its own.
28
+ """
29
+
30
+ def __init__(self, path: str | Path):
31
+ self.path = Path(path).expanduser()
32
+ self.name = f'legacy-aws-file({self.path})'
33
+ self._cache: dict[str, str] | None = None
34
+
35
+ def _load(self) -> dict[str, str]:
36
+ if self._cache is not None:
37
+ return self._cache
38
+ values: dict[str, str] = {}
39
+ if self.path.is_file():
40
+ from ..core.secrets import _warn_if_world_readable
41
+
42
+ _warn_if_world_readable(self.path)
43
+ for line in self.path.read_text(encoding='utf-8').splitlines():
44
+ line = line.strip()
45
+ if not line or line.startswith('[') or '=' not in line:
46
+ continue
47
+ key, _, value = line.partition('=')
48
+ key = key.strip().upper()
49
+ if key in EXPLICIT_KEYS:
50
+ values[key] = value.strip()
51
+ self._cache = values
52
+ return values
53
+
54
+ def get(self, key: str) -> str | None:
55
+ return self._load().get(key)
56
+
57
+
58
+ class AwsComponent(Component):
59
+ """Owns the boto3 session and registers DuckDB's S3 secret."""
60
+
61
+ name = 'aws'
62
+ optional = (*EXPLICIT_KEYS, 'AWS_REGION', 'AWS_PROFILE')
63
+ login_args: ClassVar[dict[str, str]] = {
64
+ 'access_key_id': 'AWS_ACCESS_KEY_ID',
65
+ 'secret_access_key': 'AWS_SECRET_ACCESS_KEY',
66
+ 'session_token': 'AWS_SESSION_TOKEN',
67
+ 'region': 'AWS_REGION',
68
+ 'profile': 'AWS_PROFILE',
69
+ }
70
+
71
+ def __init__(self) -> None:
72
+ super().__init__()
73
+ self.boto3_session: Any = None
74
+ self.region: str | None = None
75
+ self._explicit_credentials = False
76
+
77
+ def setup(self, secrets: SecretStore) -> None:
78
+ boto3 = self.import_module('boto3')
79
+
80
+ # Backwards compatibility only, and only when the file is really there:
81
+ # a production store built without file providers must stay that way.
82
+ credentials_dir = (
83
+ Path(self.session.config.credentials_dir).expanduser()
84
+ if self.session
85
+ else Path('~/.credentials').expanduser()
86
+ )
87
+ legacy_file = credentials_dir / 'aws_credentials.txt'
88
+ if legacy_file.is_file():
89
+ secrets.add_provider(LegacyAwsFileProvider(legacy_file))
90
+
91
+ self.region = self.secret('AWS_REGION') or self.secret('AWS_DEFAULT_REGION')
92
+ access_key = self.secret('AWS_ACCESS_KEY_ID')
93
+ kwargs: dict[str, Any] = {'region_name': self.region} if self.region else {}
94
+ self._explicit_credentials = bool(access_key)
95
+ if access_key:
96
+ kwargs.update(
97
+ aws_access_key_id=access_key,
98
+ aws_secret_access_key=self.secret('AWS_SECRET_ACCESS_KEY'),
99
+ aws_session_token=self.secret('AWS_SESSION_TOKEN'),
100
+ )
101
+ elif profile := self.secret('AWS_PROFILE'):
102
+ kwargs['profile_name'] = profile
103
+
104
+ self.boto3_session = boto3.Session(**kwargs)
105
+ self._register_duckdb_secret()
106
+
107
+ def _register_duckdb_secret(self) -> None:
108
+ """Give DuckDB the same identity boto3 resolved.
109
+
110
+ Two paths, and the difference matters:
111
+
112
+ * When boto3 resolved credentials from its own chain (profile, SSO,
113
+ instance role), DuckDB is pointed at that same chain — no key material
114
+ is written into SQL, and DuckDB refreshes on its own.
115
+ * When the credentials came from the arrows SecretStore, DuckDB cannot
116
+ see that store, so the keys have to be handed over as a literal
117
+ secret. They are then visible in ``duckdb_secrets()``, which is the
118
+ cost of that setup and a reason to prefer a real AWS profile.
119
+
120
+ A failure here is not fatal: boto3 and pyarrow still work.
121
+ """
122
+ import warnings
123
+
124
+ credentials = self.boto3_session.get_credentials()
125
+ if credentials is None:
126
+ warnings.warn('No AWS credentials resolved; DuckDB S3 access is unconfigured.', stacklevel=2)
127
+ return
128
+
129
+ region = f', REGION {quote_literal(self.region)}' if self.region else ''
130
+ if self._explicit_credentials:
131
+ frozen = credentials.get_frozen_credentials()
132
+ token = f', SESSION_TOKEN {quote_literal(frozen.token)}' if frozen.token else ''
133
+ body = (
134
+ f'TYPE s3, KEY_ID {quote_literal(frozen.access_key)}, '
135
+ f'SECRET {quote_literal(frozen.secret_key)}{token}{region}'
136
+ )
137
+ else:
138
+ body = f'TYPE s3, PROVIDER credential_chain{region}'
139
+
140
+ try:
141
+ get_duckdb().execute(f'CREATE OR REPLACE SECRET arrows_s3 ({body});')
142
+ except Exception as exc:
143
+ warnings.warn(f'Could not register the DuckDB S3 secret: {exc}', stacklevel=2)
144
+
145
+ def client(self, service: str, **kwargs) -> Any:
146
+ return self.boto3_session.client(service, **kwargs)
147
+
148
+ def health_check(self) -> HealthStatus:
149
+ if self.boto3_session is None:
150
+ return HealthStatus(self.name, False, 'no boto3 session')
151
+ try:
152
+ identity = self.boto3_session.client('sts').get_caller_identity()
153
+ return HealthStatus(self.name, True, f'arn={identity.get("Arn", "?")}')
154
+ except Exception as exc:
155
+ return HealthStatus(self.name, False, f'{type(exc).__name__}: {exc}')
156
+
157
+ def close(self) -> None:
158
+ # Teardown is best effort: the connection may already be gone.
159
+ with contextlib.suppress(Exception):
160
+ get_duckdb().execute('DROP SECRET IF EXISTS arrows_s3;')
161
+ self.boto3_session = None
162
+
163
+
164
+ COMPONENT = AwsComponent
@@ -0,0 +1,33 @@
1
+ """Gmail sending, on top of the shared google component."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..core.component import Component, HealthStatus
8
+ from ..core.secrets import SecretStore
9
+
10
+
11
+ class GmailComponent(Component):
12
+ name = 'gmail'
13
+ depends_on = ('google',)
14
+
15
+ def setup(self, secrets: SecretStore) -> None:
16
+ self.import_module('googleapiclient.discovery')
17
+
18
+ @property
19
+ def service(self) -> Any:
20
+ return self.session.get('google').service('gmail', 'v1')
21
+
22
+ def address(self) -> str:
23
+ """The authenticated mailbox address."""
24
+ return self.service.users().getProfile(userId='me').execute().get('emailAddress', '')
25
+
26
+ def health_check(self) -> HealthStatus:
27
+ try:
28
+ return HealthStatus(self.name, True, f'mailbox={self.address()}')
29
+ except Exception as exc:
30
+ return HealthStatus(self.name, False, f'{type(exc).__name__}: {exc}')
31
+
32
+
33
+ COMPONENT = GmailComponent
@@ -0,0 +1,115 @@
1
+ """Google OAuth credentials, shared by the gmail and google_sheets components.
2
+
3
+ Best practice applied here: the token is refreshed **lazily and in memory**.
4
+ The previous implementation refreshed at import time (a network call as a side
5
+ effect of ``import arrows``) and then copied the whole token document into
6
+ ``os.environ``, where every subprocess inherited it. Here the credential object
7
+ stays in the component, and the short-lived access token is re-issued only when
8
+ it actually expires.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Any, ClassVar
17
+
18
+ from ..core.component import Component, HealthStatus
19
+ from ..core.engine import get_duckdb, quote_literal
20
+ from ..core.secrets import SecretStore
21
+
22
+ DEFAULT_SCOPES = (
23
+ 'https://www.googleapis.com/auth/spreadsheets',
24
+ 'https://www.googleapis.com/auth/drive',
25
+ 'https://www.googleapis.com/auth/gmail.send',
26
+ )
27
+
28
+
29
+ class GoogleComponent(Component):
30
+ """Holds refreshable Google OAuth credentials and builds API services."""
31
+
32
+ name = 'google'
33
+ requires = ('GOOGLE_TOKEN_JSON',)
34
+ optional = ('GOOGLE_SCOPES',)
35
+ login_args: ClassVar[dict[str, str]] = {'token_json': 'GOOGLE_TOKEN_JSON', 'scopes': 'GOOGLE_SCOPES'}
36
+
37
+ def __init__(self) -> None:
38
+ super().__init__()
39
+ self._credentials: Any = None
40
+ self._duckdb_token: str | None = None
41
+ self._services: dict[tuple[str, str], Any] = {}
42
+
43
+ def setup(self, secrets: SecretStore) -> None:
44
+ google_credentials = self.import_module('google.oauth2.credentials')
45
+ token_document = json.loads(self.require_secret('GOOGLE_TOKEN_JSON'))
46
+ scopes = self.secret('GOOGLE_SCOPES')
47
+ self._credentials = google_credentials.Credentials.from_authorized_user_info(
48
+ token_document,
49
+ scopes=scopes.split(',') if scopes else list(DEFAULT_SCOPES),
50
+ )
51
+
52
+ # -- credentials -------------------------------------------------------
53
+ @property
54
+ def credentials(self) -> Any:
55
+ """Valid credentials, refreshed on demand."""
56
+ creds = self._credentials
57
+ if creds is None:
58
+ raise RuntimeError('google component is not set up')
59
+ if not creds.valid and creds.refresh_token:
60
+ from google.auth.transport.requests import Request
61
+
62
+ creds.refresh(Request())
63
+ self._services.clear()
64
+ return creds
65
+
66
+ def service(self, api: str, version: str) -> Any:
67
+ """Cached ``googleapiclient`` service, rebuilt after a token refresh."""
68
+ credentials = self.credentials
69
+ key = (api, version)
70
+ if key not in self._services:
71
+ discovery = self.import_module('googleapiclient.discovery')
72
+ self._services[key] = discovery.build(api, version, credentials=credentials, cache_discovery=False)
73
+ return self._services[key]
74
+
75
+ # -- duckdb ------------------------------------------------------------
76
+ def register_duckdb_secret(self) -> None:
77
+ """(Re)register the DuckDB ``gsheet`` secret with the current access token.
78
+
79
+ DuckDB's ``CREATE SECRET`` takes literals only, so the token is inlined;
80
+ it is quoted defensively and it is an access token that expires in an
81
+ hour, never the refresh token.
82
+ """
83
+ token = self.credentials.token
84
+ if token == self._duckdb_token:
85
+ return
86
+ connection = get_duckdb()
87
+ connection.execute('INSTALL gsheets FROM community; LOAD gsheets;')
88
+ connection.execute(
89
+ f'CREATE OR REPLACE SECRET arrows_gsheet '
90
+ f'(TYPE gsheet, PROVIDER access_token, TOKEN {quote_literal(token)});'
91
+ )
92
+ self._duckdb_token = token
93
+
94
+ def health_check(self) -> HealthStatus:
95
+ try:
96
+ profile = self.service('oauth2', 'v2').userinfo().get().execute()
97
+ return HealthStatus(self.name, True, f'user={profile.get("email", "?")}')
98
+ except Exception as exc:
99
+ return HealthStatus(self.name, False, f'{type(exc).__name__}: {exc}')
100
+
101
+ def close(self) -> None:
102
+ # Teardown is best effort: the connection may already be gone.
103
+ with contextlib.suppress(Exception):
104
+ get_duckdb().execute('DROP SECRET IF EXISTS arrows_gsheet;')
105
+ self._services.clear()
106
+ self._credentials = None
107
+ self._duckdb_token = None
108
+
109
+
110
+ COMPONENT = GoogleComponent
111
+
112
+
113
+ def token_file_hint(credentials_dir: str | Path = '~/.credentials') -> str:
114
+ path = Path(credentials_dir).expanduser() / 'google_token.json'
115
+ return f'Place the OAuth token document at {path} (chmod 600) or set GOOGLE_TOKEN_JSON.'