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 +159 -0
- arrows/__main__.py +6 -0
- arrows/auth.py +39 -0
- arrows/cli.py +102 -0
- arrows/components/__init__.py +69 -0
- arrows/components/aws.py +164 -0
- arrows/components/gmail.py +33 -0
- arrows/components/google.py +115 -0
- arrows/components/google_sheets.py +47 -0
- arrows/components/redshift.py +137 -0
- arrows/components/s3.py +61 -0
- arrows/components/sqlite.py +60 -0
- arrows/core/__init__.py +62 -0
- arrows/core/component.py +147 -0
- arrows/core/config.py +70 -0
- arrows/core/engine.py +58 -0
- arrows/core/errors.py +73 -0
- arrows/core/registry.py +90 -0
- arrows/core/secrets.py +467 -0
- arrows/core/session.py +424 -0
- arrows/gmail.py +104 -0
- arrows/google_sheets.py +364 -0
- arrows/mysql.py +0 -0
- arrows/py.typed +0 -0
- arrows/redis.py +0 -0
- arrows/redshift.py +201 -0
- arrows/s3.py +211 -0
- arrows/spark.py +0 -0
- arrows/sqlite.py +80 -0
- arrows/template_renderer.py +22 -0
- arrows/utils.py +43 -0
- arrows-0.2.0.dist-info/METADATA +629 -0
- arrows-0.2.0.dist-info/RECORD +36 -0
- arrows-0.2.0.dist-info/WHEEL +4 -0
- arrows-0.2.0.dist-info/entry_points.txt +5 -0
- arrows-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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)
|
arrows/components/aws.py
ADDED
|
@@ -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.'
|