arrows 0.2.0__tar.gz
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-0.2.0/LICENSE +21 -0
- arrows-0.2.0/PKG-INFO +629 -0
- arrows-0.2.0/README.md +592 -0
- arrows-0.2.0/examples/README.md +87 -0
- arrows-0.2.0/examples/bootstrap_aws.py +154 -0
- arrows-0.2.0/pyproject.toml +112 -0
- arrows-0.2.0/pyproject.toml.orig +106 -0
- arrows-0.2.0/src/arrows/__init__.py +159 -0
- arrows-0.2.0/src/arrows/__main__.py +6 -0
- arrows-0.2.0/src/arrows/auth.py +39 -0
- arrows-0.2.0/src/arrows/cli.py +102 -0
- arrows-0.2.0/src/arrows/components/__init__.py +69 -0
- arrows-0.2.0/src/arrows/components/aws.py +164 -0
- arrows-0.2.0/src/arrows/components/gmail.py +33 -0
- arrows-0.2.0/src/arrows/components/google.py +115 -0
- arrows-0.2.0/src/arrows/components/google_sheets.py +47 -0
- arrows-0.2.0/src/arrows/components/redshift.py +137 -0
- arrows-0.2.0/src/arrows/components/s3.py +61 -0
- arrows-0.2.0/src/arrows/components/sqlite.py +60 -0
- arrows-0.2.0/src/arrows/core/__init__.py +62 -0
- arrows-0.2.0/src/arrows/core/component.py +147 -0
- arrows-0.2.0/src/arrows/core/config.py +70 -0
- arrows-0.2.0/src/arrows/core/engine.py +58 -0
- arrows-0.2.0/src/arrows/core/errors.py +73 -0
- arrows-0.2.0/src/arrows/core/registry.py +90 -0
- arrows-0.2.0/src/arrows/core/secrets.py +467 -0
- arrows-0.2.0/src/arrows/core/session.py +424 -0
- arrows-0.2.0/src/arrows/gmail.py +104 -0
- arrows-0.2.0/src/arrows/google_sheets.py +364 -0
- arrows-0.2.0/src/arrows/mysql.py +0 -0
- arrows-0.2.0/src/arrows/py.typed +0 -0
- arrows-0.2.0/src/arrows/redis.py +0 -0
- arrows-0.2.0/src/arrows/redshift.py +201 -0
- arrows-0.2.0/src/arrows/s3.py +211 -0
- arrows-0.2.0/src/arrows/spark.py +0 -0
- arrows-0.2.0/src/arrows/sqlite.py +80 -0
- arrows-0.2.0/src/arrows/template_renderer.py +22 -0
- arrows-0.2.0/src/arrows/utils.py +43 -0
- arrows-0.2.0/tests/conftest.py +16 -0
- arrows-0.2.0/tests/test_login.py +190 -0
- arrows-0.2.0/tests/test_packaging.py +60 -0
- arrows-0.2.0/tests/test_public_api.py +36 -0
- arrows-0.2.0/tests/test_registry_and_session.py +221 -0
- arrows-0.2.0/tests/test_secrets.py +112 -0
- arrows-0.2.0/tests/test_sqlite_component.py +32 -0
arrows-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 George Jiang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
arrows-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arrows
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Universal data wrangling tool built around Apache Arrow, DuckDB, Polars, S3 for efficiently transferring and transforming data between different data sources with pluggable, independently loadable components
|
|
5
|
+
Keywords: arrow,duckdb,polars,etl,s3,redshift,data-engineering
|
|
6
|
+
Author: George Jiang
|
|
7
|
+
Author-email: George Jiang <huifengjiang@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: duckdb>=1.5.5
|
|
20
|
+
Requires-Dist: jinja2>=3.1.6
|
|
21
|
+
Requires-Dist: pyarrow>=25.0.1
|
|
22
|
+
Requires-Dist: adbc-driver-postgresql==1.11.0
|
|
23
|
+
Requires-Dist: awswrangler>=3.17.1
|
|
24
|
+
Requires-Dist: boto3>=1.35
|
|
25
|
+
Requires-Dist: psycopg2-binary>=2.9.13
|
|
26
|
+
Requires-Dist: google-api-python-client>=2.200.0
|
|
27
|
+
Requires-Dist: google-auth>=2.58.0
|
|
28
|
+
Requires-Dist: pandas>=2.2
|
|
29
|
+
Requires-Dist: polars>=1.44.2
|
|
30
|
+
Requires-Dist: keyring>=25.0
|
|
31
|
+
Requires-Python: >=3.11
|
|
32
|
+
Project-URL: Homepage, https://github.com/George-Jiang/arrows
|
|
33
|
+
Project-URL: Repository, https://github.com/George-Jiang/arrows
|
|
34
|
+
Project-URL: Issues, https://github.com/George-Jiang/arrows/issues
|
|
35
|
+
Project-URL: Changelog, https://github.com/George-Jiang/arrows/blob/main/CHANGELOG.md
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# arrows
|
|
39
|
+
|
|
40
|
+
arrows is an **universal data wrangling tool** built around Apache Arrow, DuckDB, Polars, S3 for efficiently transferring and transforming data between different data sources.
|
|
41
|
+
|
|
42
|
+
Every integration is a **component**: an independently installable, independently
|
|
43
|
+
loadable unit that owns its own dependencies and its own credentials. A job that
|
|
44
|
+
only sends email never imports boto3, and never needs a Redshift password to exist.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- 🧩 **Pluggable components**: load only what a job needs; add new ones without touching the core
|
|
49
|
+
- 🔄 **Data format conversion**: Apache Arrow, Pandas, Polars, DuckDB
|
|
50
|
+
- 📊 **Google Sheets**: read and write, with SQL query support
|
|
51
|
+
- 🗄️ **Amazon Redshift**: query, UNLOAD to S3, COPY from S3
|
|
52
|
+
- ☁️ **AWS S3**: Parquet datasets, queryable in place
|
|
53
|
+
- 📧 **Gmail**: send templated email
|
|
54
|
+
- 🔐 **Layered secret resolution**: env → .env → local files → OS keychain → AWS Secrets Manager
|
|
55
|
+
- 📝 **SQL template rendering** with Jinja2
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install arrows # every component
|
|
61
|
+
uv add arrows # with uv
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
That is the whole installation. There are no extras to choose between: every
|
|
65
|
+
component's libraries are ordinary dependencies.
|
|
66
|
+
|
|
67
|
+
Installing everything does **not** mean loading everything. `import arrows` pulls
|
|
68
|
+
in none of it — boto3, psycopg2, the Google client and Polars are imported the
|
|
69
|
+
first time a component that needs them is loaded, so start-up stays in the tens
|
|
70
|
+
of milliseconds whatever a job actually uses.
|
|
71
|
+
|
|
72
|
+
## Components
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
arrows components # what exists, what it needs, what is loaded
|
|
76
|
+
arrows secrets redshift # which secrets resolve, and from where (values redacted)
|
|
77
|
+
arrows doctor s3 redshift # load them and run a real health check
|
|
78
|
+
arrows login redshift # type the missing secrets, then verify
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Component | Depends on | Third-party libraries | Purpose |
|
|
82
|
+
|---|---|---|---|
|
|
83
|
+
| `aws` | – | boto3 | boto3 session + DuckDB S3 credentials |
|
|
84
|
+
| `s3` | `aws` | pyarrow | Parquet datasets in S3 |
|
|
85
|
+
| `redshift` | `aws` | psycopg2, awswrangler, ADBC | Query, UNLOAD, COPY |
|
|
86
|
+
| `google` | – | google-auth | Shared Google OAuth credentials |
|
|
87
|
+
| `google_sheets` | `google` | google-api-python-client | Read/write Sheets |
|
|
88
|
+
| `gmail` | `google` | google-api-python-client | Send email |
|
|
89
|
+
| `sqlite` | – | duckdb | Local SQLite files (reference component) |
|
|
90
|
+
|
|
91
|
+
### Loading
|
|
92
|
+
|
|
93
|
+
Nothing is loaded on import. Ask for what the job needs:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import arrows
|
|
97
|
+
|
|
98
|
+
arrows.load('s3', 'redshift') # authenticates both, fails fast if a secret is missing
|
|
99
|
+
arrows.load('gmail') # pulls in 'google' automatically
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Loading is how a job states its requirements up front, so a missing credential
|
|
103
|
+
surfaces at start-up instead of an hour into a run. In production, turn off
|
|
104
|
+
implicit loading so that is enforced:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
arrows.configure(autoload=False) # or ARROWS_AUTOLOAD=0
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
With `autoload` left on (the default), using a data API loads its component on
|
|
111
|
+
first call, which is what you want in a notebook.
|
|
112
|
+
|
|
113
|
+
Other entry points:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
arrows.list_components() # names, dependencies, load state
|
|
117
|
+
arrows.health() # per-component health checks
|
|
118
|
+
arrows.unload('redshift') # close connections, drop registered secrets
|
|
119
|
+
arrows.close() # release everything
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`ARROWS_COMPONENTS=s3,redshift` supplies the default set, so `arrows.load()` with
|
|
123
|
+
no arguments loads it — useful for containers and scheduled jobs. Creating the
|
|
124
|
+
session itself never touches a credential, so `arrows.configure(secrets=...)` can
|
|
125
|
+
install a different secret source first.
|
|
126
|
+
|
|
127
|
+
### Isolated sessions
|
|
128
|
+
|
|
129
|
+
A `Session` owns its components and its secret store, so one process can talk to
|
|
130
|
+
two environments at once:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from arrows.core import Session, SecretStore
|
|
134
|
+
from arrows.core.secrets import AwsSecretsManagerProvider
|
|
135
|
+
|
|
136
|
+
prod = Session(secrets=SecretStore([AwsSecretsManagerProvider('prod/arrows')]))
|
|
137
|
+
with prod.activate(): # module-level APIs now use this session
|
|
138
|
+
prod.load('redshift')
|
|
139
|
+
arrow = arrows.redshift.fetch_arrow('select 1')
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Adding a component
|
|
143
|
+
|
|
144
|
+
Copy [`src/arrows/components/sqlite.py`](src/arrows/components/sqlite.py) — the
|
|
145
|
+
reference implementation — and declare what you need:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from arrows.core.component import Component, HealthStatus
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ClickhouseComponent(Component):
|
|
152
|
+
name = 'clickhouse'
|
|
153
|
+
requires = ('CLICKHOUSE_URL', 'CLICKHOUSE_PASSWORD') # resolved before setup runs
|
|
154
|
+
optional = ('CLICKHOUSE_DATABASE',)
|
|
155
|
+
depends_on = () # other components
|
|
156
|
+
|
|
157
|
+
def setup(self, secrets):
|
|
158
|
+
# A hint is only needed for a component distributed separately from
|
|
159
|
+
# arrows; a built-in leaves it out, since its libraries always ship.
|
|
160
|
+
driver = self.import_module('clickhouse_connect', hint='pip install arrows-clickhouse')
|
|
161
|
+
self.client = driver.get_client(
|
|
162
|
+
host=self.require_secret('CLICKHOUSE_URL'),
|
|
163
|
+
password=self.require_secret('CLICKHOUSE_PASSWORD'),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def health_check(self):
|
|
167
|
+
return HealthStatus(self.name, bool(self.client.ping()))
|
|
168
|
+
|
|
169
|
+
def close(self):
|
|
170
|
+
self.client.close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
COMPONENT = ClickhouseComponent
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Register it in [`src/arrows/components/__init__.py`](src/arrows/components/__init__.py)
|
|
177
|
+
for a built-in, or ship it from **your own package** with no change to arrows at all:
|
|
178
|
+
|
|
179
|
+
```toml
|
|
180
|
+
[project.entry-points."arrows.components"]
|
|
181
|
+
clickhouse = "arrows_clickhouse:SPEC"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
SPEC = ComponentSpec(
|
|
186
|
+
name='clickhouse',
|
|
187
|
+
module='arrows_clickhouse.component', # imported only when the component is loaded
|
|
188
|
+
summary='ClickHouse over the native protocol',
|
|
189
|
+
install_hint='pip install arrows-clickhouse',
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The component's data API is an ordinary module; it reaches its component with
|
|
194
|
+
`arrows.core.session.get('clickhouse')`.
|
|
195
|
+
|
|
196
|
+
## Secrets
|
|
197
|
+
|
|
198
|
+
Components never read files or environment variables directly. They ask a
|
|
199
|
+
`SecretStore` for a **key**; where that key comes from is a deployment decision.
|
|
200
|
+
The default chain, first hit wins:
|
|
201
|
+
|
|
202
|
+
| Order | Provider | Intended for |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| 1 | `EnvProvider` | Containers, CI, 12-factor deployments |
|
|
205
|
+
| 2 | `DotEnvProvider('.env')` | Local development |
|
|
206
|
+
| 3 | `SecretFileProvider(~/.credentials)` | One file per secret; also Docker/K8s `/run/secrets` |
|
|
207
|
+
| 4 | `JsonFileProvider(...)` | The legacy `~/.credentials/*.json` layout |
|
|
208
|
+
| 5 | `KeyringProvider` | macOS Keychain / libsecret / Windows Credential Manager |
|
|
209
|
+
| 6 | `PromptProvider` | Notebooks and one-off scripts, opt-in — see below |
|
|
210
|
+
|
|
211
|
+
`AwsSecretsManagerProvider` is available for production, and is the recommended
|
|
212
|
+
source once more than one machine is involved:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import arrows
|
|
216
|
+
from arrows.core import SecretStore
|
|
217
|
+
from arrows.core.secrets import AwsSecretsManagerProvider, EnvProvider
|
|
218
|
+
|
|
219
|
+
arrows.configure(secrets=SecretStore([EnvProvider(), AwsSecretsManagerProvider('prod/arrows')]))
|
|
220
|
+
arrows.load('redshift')
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Guarantees worth knowing:
|
|
224
|
+
|
|
225
|
+
- **Values are not copied into `os.environ`.** The environment is inherited by
|
|
226
|
+
every subprocess and appears in crash dumps. Use `store.export([...])` only for
|
|
227
|
+
a library that insists on reading the environment itself.
|
|
228
|
+
- **Values cannot leak into a log by accident.** `Secret.__str__` and
|
|
229
|
+
`__repr__` render `***`; the plaintext requires an explicit `.reveal()`.
|
|
230
|
+
- **Credential files are permission-checked.** A file readable by group or others
|
|
231
|
+
triggers a warning telling you to `chmod 600`.
|
|
232
|
+
- **No secret is ever written to disk by arrows.**
|
|
233
|
+
|
|
234
|
+
### Notebooks: supplying secrets by hand
|
|
235
|
+
|
|
236
|
+
Two ways, mixable. **In code**, with named arguments per component:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
import arrows
|
|
240
|
+
|
|
241
|
+
arrows.redshift.login(host='my-cluster...amazonaws.com', database='dev', user='analyst', password='...')
|
|
242
|
+
|
|
243
|
+
arrows.s3.login(profile='analytics', bucket='my-staging-bucket')
|
|
244
|
+
arrows.google_sheets.login(token_json=open('~/token.json').read())
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**By prompt**, for anything not supplied — masked, and only for what is actually
|
|
248
|
+
missing:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
arrows.login('redshift')
|
|
252
|
+
# redshift: enter 2 secret(s), blank to skip
|
|
253
|
+
# REDSHIFT_HOST: ········
|
|
254
|
+
# REDSHIFT_PASSWORD: ········
|
|
255
|
+
|
|
256
|
+
arrows.redshift.login(user='analyst') # asks only for host, database, password
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The precedence rule differs between the two on purpose:
|
|
260
|
+
|
|
261
|
+
| Source | Position in the chain | Why |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| Passed in code | **first** — outranks everything | You wrote it down; it is an instruction, not a fallback |
|
|
264
|
+
| Typed at a prompt | **last** — fills gaps only | A value typed in a notebook must not shadow real configuration |
|
|
265
|
+
|
|
266
|
+
Calling `login()` again with different credentials rebuilds the affected
|
|
267
|
+
components (and anything depending on them), so rotating a password mid-session
|
|
268
|
+
takes effect immediately.
|
|
269
|
+
|
|
270
|
+
Keyword names come from each component's `login_args`; a raw `UPPER_CASE` secret
|
|
271
|
+
key always works too, which is the escape hatch for keys a component has not
|
|
272
|
+
named:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
arrows.login('redshift', REDSHIFT_IAM_DURATION_SECONDS='900')
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
To stop retyping on every kernel restart, save to the OS keychain — needs
|
|
279
|
+
`arrows[keyring]`:
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
arrows.redshift.login(password='...', save=True) # found by KeyringProvider next time
|
|
283
|
+
arrows.login('redshift', save=True) # same, for prompted answers
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Same thing from a terminal: `arrows login redshift --save`.
|
|
287
|
+
|
|
288
|
+
Prompting is inert outside a terminal or an IPython kernel, and
|
|
289
|
+
`ARROWS_NON_INTERACTIVE=1` disables it everywhere — a scheduled job raises a
|
|
290
|
+
missing-secret error naming the key, instead of hanging forever on a prompt
|
|
291
|
+
nobody will answer. `prompt=False` forces that behaviour, `prompt=True` requires
|
|
292
|
+
a human. So the same call works in both places:
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
arrows.redshift.login(host=..., user=..., password=...) # script: no prompt, nothing missing
|
|
296
|
+
arrows.redshift.login() # notebook: asks for all of it
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
A key ending in `_JSON` or `_FILE` accepts a path at the prompt, which is the
|
|
300
|
+
sane way to supply a Google token:
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
arrows.login('google')
|
|
304
|
+
# GOOGLE_TOKEN_JSON: ~/Downloads/token.json ← read from the file
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Keys by component
|
|
308
|
+
|
|
309
|
+
| Component | Required | Optional |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| `aws` | – (falls back to the standard AWS chain) | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_PROFILE`, `AWS_REGION` |
|
|
312
|
+
| `s3` | – | `ARROWS_DEFAULT_BUCKET` |
|
|
313
|
+
| `redshift` | `REDSHIFT_HOST`, `REDSHIFT_DATABASE`, `REDSHIFT_USER` | `REDSHIFT_PASSWORD` **or** `REDSHIFT_CLUSTER_IDENTIFIER`, `REDSHIFT_PORT` |
|
|
314
|
+
| `google` | `GOOGLE_TOKEN_JSON` | `GOOGLE_SCOPES` |
|
|
315
|
+
| `sqlite` | – | `SQLITE_DATABASE` |
|
|
316
|
+
|
|
317
|
+
Two credentials deserve a note:
|
|
318
|
+
|
|
319
|
+
- **AWS**: prefer *no* credentials in arrows at all. With nothing configured,
|
|
320
|
+
boto3 resolves its own chain — `AWS_PROFILE`, SSO, EC2/ECS/EKS instance roles —
|
|
321
|
+
all of which rotate automatically. Explicit keys are supported for laptops but
|
|
322
|
+
are the weakest option, and they are the only case where key material has to be
|
|
323
|
+
handed to DuckDB as a literal secret.
|
|
324
|
+
- **Redshift**: set `REDSHIFT_CLUSTER_IDENTIFIER` and omit `REDSHIFT_PASSWORD`.
|
|
325
|
+
arrows then mints a short-lived password through `redshift:GetClusterCredentials`
|
|
326
|
+
using the AWS identity already loaded, so no database password exists on disk.
|
|
327
|
+
|
|
328
|
+
### Migrating from `load_credentials()`
|
|
329
|
+
|
|
330
|
+
`arrows.load_credentials()` and the `arrows.auth.load_*` functions still work and
|
|
331
|
+
now delegate to the component loader, with a `DeprecationWarning`:
|
|
332
|
+
|
|
333
|
+
| Before | Now |
|
|
334
|
+
|---|---|
|
|
335
|
+
| `arrows.load_credentials()` | `arrows.load('s3', 'redshift', 'google')` |
|
|
336
|
+
| `auth.load_aws_credentials()` | `arrows.load('s3')` |
|
|
337
|
+
| `auth.load_redshift_credentials()` | `arrows.load('redshift')` |
|
|
338
|
+
| `auth.load_google_credentials()` | `arrows.load('google')` |
|
|
339
|
+
|
|
340
|
+
The legacy `~/.credentials/` files are still read, so no file needs to move.
|
|
341
|
+
|
|
342
|
+
## Usage
|
|
343
|
+
|
|
344
|
+
### Amazon Redshift
|
|
345
|
+
|
|
346
|
+
#### Querying Data
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
import arrows
|
|
350
|
+
from arrows import redshift
|
|
351
|
+
|
|
352
|
+
arrows.load('redshift')
|
|
353
|
+
|
|
354
|
+
# Query using S3 Unload engine (default, suitable for large data)
|
|
355
|
+
arrow = redshift.fetch_arrow(sql='SELECT * FROM my_table', engine='s3')
|
|
356
|
+
|
|
357
|
+
# Query using ADBC engine (fast for smaller datasets)
|
|
358
|
+
arrow = redshift.fetch_arrow(sql='SELECT * FROM my_table WHERE date > %(date)s', engine='adbc', date='2024-01-01')
|
|
359
|
+
|
|
360
|
+
# Get Pandas DataFrame (defaults to ADBC engine)
|
|
361
|
+
df = redshift.fetch_dataframe(
|
|
362
|
+
sql='SELECT * FROM my_table',
|
|
363
|
+
engine='adbc',
|
|
364
|
+
dtype_backend='numpy', # or 'pyarrow'
|
|
365
|
+
)
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
#### Importing Data to Redshift
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
from arrows import redshift
|
|
372
|
+
|
|
373
|
+
# Import Arrow data to Redshift
|
|
374
|
+
redshift.arrow_to_redshift(
|
|
375
|
+
arrow=arrow,
|
|
376
|
+
table_name='schema.table_name',
|
|
377
|
+
mode='append', # or 'overwrite'
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Copy from S3 to Redshift
|
|
381
|
+
redshift.copy(table_name='schema.table_name', s3_path='s3://bucket/path/', mode='append')
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
#### Exporting Data to S3
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
from arrows import redshift
|
|
388
|
+
|
|
389
|
+
# Export Redshift query results to S3
|
|
390
|
+
dataset = redshift.unload(sql='SELECT * FROM my_table', s3_path='s3://bucket/path/')
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
#### Executing SQL
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
from arrows import redshift
|
|
397
|
+
|
|
398
|
+
# Execute SQL statement
|
|
399
|
+
redshift.execute_sql(sql='CREATE TABLE IF NOT EXISTS my_table (id INT, name VARCHAR)')
|
|
400
|
+
|
|
401
|
+
# Execute SQL file (supports Jinja2 templates)
|
|
402
|
+
redshift.execute_sql_file(sql_script_path='scripts/create_table.sql', table_name='my_table')
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### AWS S3
|
|
406
|
+
|
|
407
|
+
#### Storing and Reading Data
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
from arrows import s3
|
|
411
|
+
|
|
412
|
+
# Store Arrow data to S3
|
|
413
|
+
dataset = s3.arrow_to_s3(
|
|
414
|
+
arrow=arrow,
|
|
415
|
+
s3_path='s3://bucket/path/',
|
|
416
|
+
bucket='my-bucket', # Optional
|
|
417
|
+
engine='duckdb', # or 'pyarrow'
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Store Polars DataFrame to S3
|
|
421
|
+
dataset = s3.polars_to_s3(df=df, s3_path='s3://bucket/path/')
|
|
422
|
+
|
|
423
|
+
# Read data from S3
|
|
424
|
+
dataset = s3.get_dataset('s3://bucket/path/')
|
|
425
|
+
arrow = dataset.to_arrow()
|
|
426
|
+
|
|
427
|
+
# Convert to Pandas
|
|
428
|
+
df = dataset.to_duckdb().df()
|
|
429
|
+
|
|
430
|
+
# Convert to Polars
|
|
431
|
+
df = dataset.to_polars(lazy=False)
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
#### S3Dataset Operations
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
from arrows import s3
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# Get dataset
|
|
441
|
+
dataset = s3.get_dataset('s3://bucket/path/')
|
|
442
|
+
|
|
443
|
+
dataset = s3.S3Dataset(s3_path='s3://bucket/path/')
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# Create dataset
|
|
447
|
+
dataset = s3.create_dataset(s3_path='s3://bucket/path/')
|
|
448
|
+
|
|
449
|
+
# Write from Arrow
|
|
450
|
+
dataset.from_arrow(arrow)
|
|
451
|
+
|
|
452
|
+
# Write from Polars
|
|
453
|
+
dataset.from_polars(df)
|
|
454
|
+
|
|
455
|
+
# Import from Redshift
|
|
456
|
+
dataset.from_redshift(sql='SELECT * FROM my_table')
|
|
457
|
+
|
|
458
|
+
# Export to Redshift
|
|
459
|
+
dataset.to_redshift('schema.table_name', mode='append')
|
|
460
|
+
|
|
461
|
+
# Query S3 data with SQL
|
|
462
|
+
result = dataset.query('SELECT * FROM self WHERE id > 100')
|
|
463
|
+
|
|
464
|
+
# Delete dataset
|
|
465
|
+
dataset.delete()
|
|
466
|
+
|
|
467
|
+
# Clear dataset contents
|
|
468
|
+
dataset.clear_contents()
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Google Sheets
|
|
472
|
+
|
|
473
|
+
#### Reading from Google Sheets
|
|
474
|
+
|
|
475
|
+
```python
|
|
476
|
+
from arrows import google_sheets
|
|
477
|
+
|
|
478
|
+
# Read data from Google Sheet as Arrow format
|
|
479
|
+
arrow = google_sheets.fetch_arrow(
|
|
480
|
+
spreadsheet_id='your_spreadsheet_id',
|
|
481
|
+
sheet_name='Sheet1',
|
|
482
|
+
sheet_range='A1:D100', # Optional
|
|
483
|
+
all_varchar=False, # Optional: Treat all columns as VARCHAR
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
spreadsheet = google_sheets.get_spreadsheet(spreadsheet_id)
|
|
487
|
+
sheet = spreadsheet.get_sheet(sheet_name)
|
|
488
|
+
# or
|
|
489
|
+
sheet = google_sheets.get_sheet(spreadsheet_id, sheet_name)
|
|
490
|
+
|
|
491
|
+
arrow = sheet.to_arrow(self, sheet_range=None, all_varchar=False, sql=None)
|
|
492
|
+
# or
|
|
493
|
+
df = sheet.to_polars(self, sheet_range=None, all_varchar=False, sql=None)
|
|
494
|
+
# or
|
|
495
|
+
df = sheet.to_pandas(self, sheet_range=None, all_varchar=False, sql=None)
|
|
496
|
+
# or
|
|
497
|
+
duckdb_relation = sheet.to_duckdb(self, sheet_range=None, all_varchar=False, sql=None)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# Use SQL query
|
|
501
|
+
arrow = google_sheets.fetch_arrow(
|
|
502
|
+
spreadsheet_id='your_spreadsheet_id',
|
|
503
|
+
sheet_name='Sheet1',
|
|
504
|
+
sql="""
|
|
505
|
+
SELECT
|
|
506
|
+
*
|
|
507
|
+
FROM
|
|
508
|
+
self
|
|
509
|
+
WHERE column1 > 100
|
|
510
|
+
""",
|
|
511
|
+
)
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
#### Writing to Google Sheets
|
|
515
|
+
|
|
516
|
+
```python
|
|
517
|
+
from arrows import google_sheets
|
|
518
|
+
|
|
519
|
+
# Write Arrow data to Google Sheet
|
|
520
|
+
sheet = google_sheets.arrow_to_googlesheet(arrow=arrow, spreadsheet_id='your_spreadsheet_id', sheet_name='Sheet1')
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
#### Managing Spreadsheets and Sheets
|
|
524
|
+
|
|
525
|
+
```python
|
|
526
|
+
from arrows import google_sheets
|
|
527
|
+
|
|
528
|
+
# Create a new Spreadsheet
|
|
529
|
+
spreadsheet = google_sheets.create_spreadsheet(
|
|
530
|
+
spreadsheet_name='My Spreadsheet',
|
|
531
|
+
parent_id='parent_id', # Optional
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Get a Spreadsheet
|
|
535
|
+
spreadsheet = google_sheets.get_spreadsheet('spreadsheet_id')
|
|
536
|
+
|
|
537
|
+
# Create a new Sheet
|
|
538
|
+
sheet = spreadsheet.create_sheet('New Sheet')
|
|
539
|
+
|
|
540
|
+
# Get a Sheet
|
|
541
|
+
sheet = spreadsheet.get_sheet('Sheet1')
|
|
542
|
+
|
|
543
|
+
# Share Spreadsheet
|
|
544
|
+
spreadsheet.share(email='user@example.com', role='writer')
|
|
545
|
+
|
|
546
|
+
# Delete a Sheet
|
|
547
|
+
spreadsheet.delete_sheet('Sheet1')
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Gmail
|
|
551
|
+
|
|
552
|
+
#### Sending Emails
|
|
553
|
+
|
|
554
|
+
```python
|
|
555
|
+
from arrows import gmail
|
|
556
|
+
|
|
557
|
+
# Simple email
|
|
558
|
+
gmail.send_email(to=['user@example.com'], subject='Report', content='<h1>Hello</h1>', cc=['manager@example.com'])
|
|
559
|
+
|
|
560
|
+
# Advanced usage with Email class
|
|
561
|
+
email = gmail.Email(subject='Monthly Report', to=['user@example.com'], sender='Data Team')
|
|
562
|
+
|
|
563
|
+
# Set content from template
|
|
564
|
+
email.from_template('path/to/template.html', variable='value')
|
|
565
|
+
|
|
566
|
+
# Send email
|
|
567
|
+
email.send()
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### SQL Template Rendering
|
|
571
|
+
|
|
572
|
+
```python
|
|
573
|
+
from arrows.template_renderer import render_template
|
|
574
|
+
|
|
575
|
+
# Render SQL template
|
|
576
|
+
sql = render_template('path/to/template.sql', table_name='my_table', date='2024-01-01')
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Core API
|
|
580
|
+
|
|
581
|
+
### Google Sheets
|
|
582
|
+
|
|
583
|
+
- `fetch_arrow()` - Read data from Google Sheet as Arrow format
|
|
584
|
+
- `arrow_to_googlesheet()` - Write Arrow data to Google Sheet
|
|
585
|
+
- `get_sheet()` - Get Sheet object
|
|
586
|
+
- `get_spreadsheet()` - Get Spreadsheet object
|
|
587
|
+
- `create_spreadsheet()` - Create new Spreadsheet
|
|
588
|
+
|
|
589
|
+
### Redshift
|
|
590
|
+
|
|
591
|
+
- `fetch_arrow()` - Query data from Redshift as Arrow format
|
|
592
|
+
- `fetch_dataframe()` - Query data from Redshift as DataFrame
|
|
593
|
+
- `arrow_to_redshift()` - Import Arrow data to Redshift
|
|
594
|
+
- `unload()` - Export Redshift query results to S3
|
|
595
|
+
- `copy()` - Copy data from S3 to Redshift
|
|
596
|
+
- `execute_sql()` - Execute SQL on Redshift
|
|
597
|
+
- `execute_sql_file()` - Execute SQL file
|
|
598
|
+
|
|
599
|
+
### S3
|
|
600
|
+
|
|
601
|
+
- `arrow_to_s3()` - Store Arrow data to S3
|
|
602
|
+
- `polars_to_s3()` - Store Polars DataFrame to S3
|
|
603
|
+
- `get_dataset()` - Get S3 dataset
|
|
604
|
+
- `create_dataset()` - Create new S3 dataset
|
|
605
|
+
|
|
606
|
+
### Gmail
|
|
607
|
+
|
|
608
|
+
- `send_email()` - Send an email
|
|
609
|
+
- `Email` - Class for constructing and sending emails
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
## Development
|
|
613
|
+
|
|
614
|
+
```bash
|
|
615
|
+
uv sync # install with every component
|
|
616
|
+
uv run pytest # unit tests, no credentials needed
|
|
617
|
+
uv run ruff check src tests # lint
|
|
618
|
+
uv run arrows doctor # check real connectivity for what is configured
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
## Notes
|
|
622
|
+
|
|
623
|
+
1. **Load what you need**: `arrows.load(...)` fails fast on a missing credential; `arrows doctor` checks a real round-trip
|
|
624
|
+
2. **Google Sheets permissions**: the OAuth token needs the `spreadsheets` and `drive` scopes; Gmail needs `gmail.send`
|
|
625
|
+
3. **S3 permissions**: Redshift UNLOAD/COPY needs the cluster's IAM role to reach the same bucket your session does
|
|
626
|
+
4. **Data formats**: everything is Apache Arrow in the middle; check type compatibility at the edges
|
|
627
|
+
5. **Breaking change in 0.2**: `redshift.fetch_arrow(..., engine='s3')` now renders its SQL with Jinja2 (`{{ name }}`)
|
|
628
|
+
like every other entry point, instead of `str.format` (`{name}`)
|
|
629
|
+
|