scan-google-sheet 0.1.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.
- scan_google_sheet-0.1.0/PKG-INFO +270 -0
- scan_google_sheet-0.1.0/README.md +243 -0
- scan_google_sheet-0.1.0/pyproject.toml +96 -0
- scan_google_sheet-0.1.0/src/scan_google_sheet/__init__.py +93 -0
- scan_google_sheet-0.1.0/src/scan_google_sheet/exceptions.py +257 -0
- scan_google_sheet-0.1.0/src/scan_google_sheet/fetch.py +60 -0
- scan_google_sheet-0.1.0/src/scan_google_sheet/parse.py +50 -0
- scan_google_sheet-0.1.0/src/scan_google_sheet/py.typed +0 -0
- scan_google_sheet-0.1.0/src/scan_google_sheet/scan.py +178 -0
- scan_google_sheet-0.1.0/src/scan_google_sheet/url.py +79 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: scan-google-sheet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read public Google Sheets into Polars LazyFrames — no auth required
|
|
5
|
+
Keywords: polars,google-sheets,csv,dataframe,lazy
|
|
6
|
+
Author: Attica-oss
|
|
7
|
+
Author-email: Attica-oss <g.mounac@outlook.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Dist: duckdb>=1.5.1
|
|
17
|
+
Requires-Dist: httpx>=0.28.1
|
|
18
|
+
Requires-Dist: polars>=1.39.3
|
|
19
|
+
Requires-Dist: pyarrow>=23.0.1
|
|
20
|
+
Requires-Dist: sqlglot>=30.2.1
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Project-URL: Documentation, https://github.com/Attica-oss/scan_google_sheet/blob/main/README.md
|
|
23
|
+
Project-URL: Homepage, https://github.com/Attica-oss/scan_google_sheet
|
|
24
|
+
Project-URL: Issues, https://github.com/Attica-oss/scan_google_sheet/issues
|
|
25
|
+
Project-URL: Repository, https://github.com/Attica-oss/scan_google_sheet
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# read-sheet
|
|
29
|
+
|
|
30
|
+
Read public Google Sheets into Polars DataFrames and LazyFrames — no auth, no service accounts, no API keys.
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/scan-google-sheet/)
|
|
33
|
+
[](https://pypi.org/project/scan-google-sheet/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
[](https://github.com/Attica-oss/scan_google_sheet/actions/workflows/ci.yml)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- Python ≥ 3.13
|
|
42
|
+
- The spreadsheet must be set to **Anyone with the link can view**
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install scan-google-sheet
|
|
50
|
+
# or
|
|
51
|
+
uv add scan-google-sheet
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from scan_google_sheet import read_google_sheet, scan_google_sheet
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Eager — returns a `DataFrame` immediately:**
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
df = read_google_sheet("Sheet1", sheet_id="1BxiMVs0XRA5nFMdKvBdBZjgm...")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Lazy — returns a `LazyFrame`, participates in Polars query optimisation:**
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
lf = scan_google_sheet("Sheet1", sheet_id="1BxiMVs0XRA5nFMdKvBdBZjgm...")
|
|
72
|
+
|
|
73
|
+
df = (
|
|
74
|
+
lf
|
|
75
|
+
.filter(pl.col("year") == 2025)
|
|
76
|
+
.select("vessel", "amount")
|
|
77
|
+
.collect()
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
You can also pass a full Google Sheets URL instead of a bare sheet ID:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
df = read_google_sheet(
|
|
85
|
+
"Sheet1",
|
|
86
|
+
url="https://docs.google.com/spreadsheets/d/1BxiMVs0.../edit#gid=0",
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
### `read_google_sheet`
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
def read_google_sheet(
|
|
98
|
+
sheet_name: str,
|
|
99
|
+
sheet_id: str | None = None,
|
|
100
|
+
url: str | None = None,
|
|
101
|
+
*,
|
|
102
|
+
timeout: int = 10,
|
|
103
|
+
parse_dates: bool = True,
|
|
104
|
+
) -> pl.DataFrame
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Fetches the sheet and returns a collected `DataFrame`. Use this when you want the data immediately and do not need lazy evaluation.
|
|
108
|
+
|
|
109
|
+
### `scan_google_sheet`
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
def scan_google_sheet(
|
|
113
|
+
sheet_name: str,
|
|
114
|
+
sheet_id: str | None = None,
|
|
115
|
+
url: str | None = None,
|
|
116
|
+
*,
|
|
117
|
+
timeout: int = 10,
|
|
118
|
+
parse_dates: bool = True,
|
|
119
|
+
batch_size: int = 1_000,
|
|
120
|
+
) -> pl.LazyFrame
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Returns a `LazyFrame` registered via the Polars IO plugin API. Projection pushdown, predicate pushdown, `head()`, and streaming are all supported.
|
|
124
|
+
|
|
125
|
+
> **Note:** Google Sheets does not support partial HTTP reads. The full sheet is always downloaded in one request. Pushdowns reduce processing cost, not network cost.
|
|
126
|
+
|
|
127
|
+
**Parameters shared by both functions:**
|
|
128
|
+
|
|
129
|
+
| Parameter | Type | Default | Description |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| `sheet_name` | `str` | — | Tab name as shown in Google Sheets |
|
|
132
|
+
| `sheet_id` | `str \| None` | `None` | Spreadsheet ID from the URL |
|
|
133
|
+
| `url` | `str \| None` | `None` | Full Google Sheets URL (ID extracted automatically) |
|
|
134
|
+
| `timeout` | `int` | `10` | HTTP timeout in seconds |
|
|
135
|
+
| `parse_dates` | `bool` | `True` | Attempt automatic date/datetime parsing |
|
|
136
|
+
|
|
137
|
+
Provide either `sheet_id` or `url`, not both.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## URL utilities
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from scan_google_sheet import extract_sheet_id, build_gviz_url, from_url
|
|
145
|
+
|
|
146
|
+
# Extract the sheet ID from any Google Sheets URL
|
|
147
|
+
sheet_id = extract_sheet_id("https://docs.google.com/spreadsheets/d/ABC123/edit")
|
|
148
|
+
# "ABC123"
|
|
149
|
+
|
|
150
|
+
# Build a gviz CSV export URL from a sheet ID and tab name
|
|
151
|
+
url = build_gviz_url("ABC123", "Sheet1")
|
|
152
|
+
# "https://docs.google.com/spreadsheets/d/ABC123/gviz/tq?tqx=out:csv&sheet=Sheet1"
|
|
153
|
+
|
|
154
|
+
# Build a gviz URL directly from a full Google Sheets URL
|
|
155
|
+
url = from_url("https://docs.google.com/spreadsheets/d/ABC123/edit", "Sheet1")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Error handling
|
|
161
|
+
|
|
162
|
+
All exceptions inherit from `ReadSheetError`, so you can catch everything with one handler or branch on specific types:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from scan_google_sheet import (
|
|
166
|
+
read_google_sheet,
|
|
167
|
+
ReadSheetError,
|
|
168
|
+
SheetFetchError,
|
|
169
|
+
SheetURLError,
|
|
170
|
+
SheetParseError,
|
|
171
|
+
NetworkError,
|
|
172
|
+
ConfigurationError,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
df = read_google_sheet("Sheet1", sheet_id="...")
|
|
177
|
+
except ReadSheetError as e:
|
|
178
|
+
match e:
|
|
179
|
+
case SheetFetchError() if e.is_auth_error:
|
|
180
|
+
print("Make the sheet public (Share → Anyone with the link)")
|
|
181
|
+
case SheetFetchError() if e.is_not_found:
|
|
182
|
+
print(f"Sheet not found — check the ID: {e.url}")
|
|
183
|
+
case NetworkError():
|
|
184
|
+
print(f"No connection: {e.cause}")
|
|
185
|
+
case SheetURLError(raw=r):
|
|
186
|
+
print(f"Could not parse URL: {r!r}")
|
|
187
|
+
case SheetParseError():
|
|
188
|
+
print(f"CSV parse failed: {e.cause}")
|
|
189
|
+
case ConfigurationError():
|
|
190
|
+
print(str(e))
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Exception hierarchy
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
ReadSheetError
|
|
197
|
+
├── SheetURLError malformed URL or unextractable sheet ID (.raw)
|
|
198
|
+
├── SheetFetchError non-200 HTTP response (.url, .status_code)
|
|
199
|
+
│ (.is_auth_error, .is_not_found)
|
|
200
|
+
├── SheetParseError CSV or Polars parsing failure (.column)
|
|
201
|
+
├── NetworkError transport failure, no response received (.url)
|
|
202
|
+
└── ConfigurationError invalid argument combination
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Making your sheet public
|
|
208
|
+
|
|
209
|
+
In Google Sheets: **Share → Change to Anyone with the link → Viewer → Done.**
|
|
210
|
+
|
|
211
|
+
The export URL used by this library (`gviz/tq?tqx=out:csv`) requires the sheet to be publicly readable. No data is ever written.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## How it works
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
Google Sheets URL / ID
|
|
219
|
+
│
|
|
220
|
+
▼
|
|
221
|
+
build_gviz_url() constructs the CSV export URL
|
|
222
|
+
│
|
|
223
|
+
▼
|
|
224
|
+
fetch_raw() httpx GET with follow_redirects=True
|
|
225
|
+
│
|
|
226
|
+
▼
|
|
227
|
+
pl.scan_csv() parsed into a Polars LazyFrame
|
|
228
|
+
│
|
|
229
|
+
▼
|
|
230
|
+
register_io_source() registered as a Polars IO plugin
|
|
231
|
+
│
|
|
232
|
+
▼
|
|
233
|
+
LazyFrame / DataFrame ready for your pipeline
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Development
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
git clone https://github.com/Attica-oss/scan_google_sheet
|
|
242
|
+
cd scan_google_sheet
|
|
243
|
+
uv sync --group dev
|
|
244
|
+
uv run pytest
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Lint and format:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
uv run ruff check src/ tests/
|
|
251
|
+
uv run ruff format src/ tests/
|
|
252
|
+
uv run ty check src/
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Changelog
|
|
258
|
+
|
|
259
|
+
### 0.1.0 (2025)
|
|
260
|
+
- Initial release
|
|
261
|
+
- `read_google_sheet` and `scan_google_sheet`
|
|
262
|
+
- Polars IO plugin for lazy evaluation
|
|
263
|
+
- Structured exception hierarchy
|
|
264
|
+
- Full test suite with `pytest-httpx`
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
[MIT](LICENSE) © 2025 Attica-oss
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# read-sheet
|
|
2
|
+
|
|
3
|
+
Read public Google Sheets into Polars DataFrames and LazyFrames — no auth, no service accounts, no API keys.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/scan-google-sheet/)
|
|
6
|
+
[](https://pypi.org/project/scan-google-sheet/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://github.com/Attica-oss/scan_google_sheet/actions/workflows/ci.yml)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- Python ≥ 3.13
|
|
15
|
+
- The spreadsheet must be set to **Anyone with the link can view**
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install scan-google-sheet
|
|
23
|
+
# or
|
|
24
|
+
uv add scan-google-sheet
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from scan_google_sheet import read_google_sheet, scan_google_sheet
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Eager — returns a `DataFrame` immediately:**
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
df = read_google_sheet("Sheet1", sheet_id="1BxiMVs0XRA5nFMdKvBdBZjgm...")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Lazy — returns a `LazyFrame`, participates in Polars query optimisation:**
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
lf = scan_google_sheet("Sheet1", sheet_id="1BxiMVs0XRA5nFMdKvBdBZjgm...")
|
|
45
|
+
|
|
46
|
+
df = (
|
|
47
|
+
lf
|
|
48
|
+
.filter(pl.col("year") == 2025)
|
|
49
|
+
.select("vessel", "amount")
|
|
50
|
+
.collect()
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
You can also pass a full Google Sheets URL instead of a bare sheet ID:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
df = read_google_sheet(
|
|
58
|
+
"Sheet1",
|
|
59
|
+
url="https://docs.google.com/spreadsheets/d/1BxiMVs0.../edit#gid=0",
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## API
|
|
66
|
+
|
|
67
|
+
### `read_google_sheet`
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
def read_google_sheet(
|
|
71
|
+
sheet_name: str,
|
|
72
|
+
sheet_id: str | None = None,
|
|
73
|
+
url: str | None = None,
|
|
74
|
+
*,
|
|
75
|
+
timeout: int = 10,
|
|
76
|
+
parse_dates: bool = True,
|
|
77
|
+
) -> pl.DataFrame
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Fetches the sheet and returns a collected `DataFrame`. Use this when you want the data immediately and do not need lazy evaluation.
|
|
81
|
+
|
|
82
|
+
### `scan_google_sheet`
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
def scan_google_sheet(
|
|
86
|
+
sheet_name: str,
|
|
87
|
+
sheet_id: str | None = None,
|
|
88
|
+
url: str | None = None,
|
|
89
|
+
*,
|
|
90
|
+
timeout: int = 10,
|
|
91
|
+
parse_dates: bool = True,
|
|
92
|
+
batch_size: int = 1_000,
|
|
93
|
+
) -> pl.LazyFrame
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Returns a `LazyFrame` registered via the Polars IO plugin API. Projection pushdown, predicate pushdown, `head()`, and streaming are all supported.
|
|
97
|
+
|
|
98
|
+
> **Note:** Google Sheets does not support partial HTTP reads. The full sheet is always downloaded in one request. Pushdowns reduce processing cost, not network cost.
|
|
99
|
+
|
|
100
|
+
**Parameters shared by both functions:**
|
|
101
|
+
|
|
102
|
+
| Parameter | Type | Default | Description |
|
|
103
|
+
|---|---|---|---|
|
|
104
|
+
| `sheet_name` | `str` | — | Tab name as shown in Google Sheets |
|
|
105
|
+
| `sheet_id` | `str \| None` | `None` | Spreadsheet ID from the URL |
|
|
106
|
+
| `url` | `str \| None` | `None` | Full Google Sheets URL (ID extracted automatically) |
|
|
107
|
+
| `timeout` | `int` | `10` | HTTP timeout in seconds |
|
|
108
|
+
| `parse_dates` | `bool` | `True` | Attempt automatic date/datetime parsing |
|
|
109
|
+
|
|
110
|
+
Provide either `sheet_id` or `url`, not both.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## URL utilities
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from scan_google_sheet import extract_sheet_id, build_gviz_url, from_url
|
|
118
|
+
|
|
119
|
+
# Extract the sheet ID from any Google Sheets URL
|
|
120
|
+
sheet_id = extract_sheet_id("https://docs.google.com/spreadsheets/d/ABC123/edit")
|
|
121
|
+
# "ABC123"
|
|
122
|
+
|
|
123
|
+
# Build a gviz CSV export URL from a sheet ID and tab name
|
|
124
|
+
url = build_gviz_url("ABC123", "Sheet1")
|
|
125
|
+
# "https://docs.google.com/spreadsheets/d/ABC123/gviz/tq?tqx=out:csv&sheet=Sheet1"
|
|
126
|
+
|
|
127
|
+
# Build a gviz URL directly from a full Google Sheets URL
|
|
128
|
+
url = from_url("https://docs.google.com/spreadsheets/d/ABC123/edit", "Sheet1")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Error handling
|
|
134
|
+
|
|
135
|
+
All exceptions inherit from `ReadSheetError`, so you can catch everything with one handler or branch on specific types:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from scan_google_sheet import (
|
|
139
|
+
read_google_sheet,
|
|
140
|
+
ReadSheetError,
|
|
141
|
+
SheetFetchError,
|
|
142
|
+
SheetURLError,
|
|
143
|
+
SheetParseError,
|
|
144
|
+
NetworkError,
|
|
145
|
+
ConfigurationError,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
df = read_google_sheet("Sheet1", sheet_id="...")
|
|
150
|
+
except ReadSheetError as e:
|
|
151
|
+
match e:
|
|
152
|
+
case SheetFetchError() if e.is_auth_error:
|
|
153
|
+
print("Make the sheet public (Share → Anyone with the link)")
|
|
154
|
+
case SheetFetchError() if e.is_not_found:
|
|
155
|
+
print(f"Sheet not found — check the ID: {e.url}")
|
|
156
|
+
case NetworkError():
|
|
157
|
+
print(f"No connection: {e.cause}")
|
|
158
|
+
case SheetURLError(raw=r):
|
|
159
|
+
print(f"Could not parse URL: {r!r}")
|
|
160
|
+
case SheetParseError():
|
|
161
|
+
print(f"CSV parse failed: {e.cause}")
|
|
162
|
+
case ConfigurationError():
|
|
163
|
+
print(str(e))
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Exception hierarchy
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
ReadSheetError
|
|
170
|
+
├── SheetURLError malformed URL or unextractable sheet ID (.raw)
|
|
171
|
+
├── SheetFetchError non-200 HTTP response (.url, .status_code)
|
|
172
|
+
│ (.is_auth_error, .is_not_found)
|
|
173
|
+
├── SheetParseError CSV or Polars parsing failure (.column)
|
|
174
|
+
├── NetworkError transport failure, no response received (.url)
|
|
175
|
+
└── ConfigurationError invalid argument combination
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Making your sheet public
|
|
181
|
+
|
|
182
|
+
In Google Sheets: **Share → Change to Anyone with the link → Viewer → Done.**
|
|
183
|
+
|
|
184
|
+
The export URL used by this library (`gviz/tq?tqx=out:csv`) requires the sheet to be publicly readable. No data is ever written.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## How it works
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
Google Sheets URL / ID
|
|
192
|
+
│
|
|
193
|
+
▼
|
|
194
|
+
build_gviz_url() constructs the CSV export URL
|
|
195
|
+
│
|
|
196
|
+
▼
|
|
197
|
+
fetch_raw() httpx GET with follow_redirects=True
|
|
198
|
+
│
|
|
199
|
+
▼
|
|
200
|
+
pl.scan_csv() parsed into a Polars LazyFrame
|
|
201
|
+
│
|
|
202
|
+
▼
|
|
203
|
+
register_io_source() registered as a Polars IO plugin
|
|
204
|
+
│
|
|
205
|
+
▼
|
|
206
|
+
LazyFrame / DataFrame ready for your pipeline
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Development
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
git clone https://github.com/Attica-oss/scan_google_sheet
|
|
215
|
+
cd scan_google_sheet
|
|
216
|
+
uv sync --group dev
|
|
217
|
+
uv run pytest
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Lint and format:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
uv run ruff check src/ tests/
|
|
224
|
+
uv run ruff format src/ tests/
|
|
225
|
+
uv run ty check src/
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Changelog
|
|
231
|
+
|
|
232
|
+
### 0.1.0 (2025)
|
|
233
|
+
- Initial release
|
|
234
|
+
- `read_google_sheet` and `scan_google_sheet`
|
|
235
|
+
- Polars IO plugin for lazy evaluation
|
|
236
|
+
- Structured exception hierarchy
|
|
237
|
+
- Full test suite with `pytest-httpx`
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
[MIT](LICENSE) © 2025 Attica-oss
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scan-google-sheet"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Read public Google Sheets into Polars LazyFrames — no auth required"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Attica-oss", email = "g.mounac@outlook.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"duckdb>=1.5.1",
|
|
13
|
+
"httpx>=0.28.1",
|
|
14
|
+
"polars>=1.39.3",
|
|
15
|
+
"pyarrow>=23.0.1",
|
|
16
|
+
"sqlglot>=30.2.1",
|
|
17
|
+
]
|
|
18
|
+
keywords = ["polars", "google-sheets", "csv", "dataframe", "lazy"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Repository = "https://github.com/Attica-oss/scan_google_sheet"
|
|
32
|
+
Issues = "https://github.com/Attica-oss/scan_google_sheet/issues"
|
|
33
|
+
Documentation = "https://github.com/Attica-oss/scan_google_sheet/blob/main/README.md"
|
|
34
|
+
Homepage = "https://github.com/Attica-oss/scan_google_sheet"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.9.17,<0.10.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/scan_google_sheet"]
|
|
42
|
+
|
|
43
|
+
# [build-system]
|
|
44
|
+
# requires = ["hatchling"]
|
|
45
|
+
# build-backend = "hatchling.build"
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
dev = [
|
|
49
|
+
"marimo>=0.22.0",
|
|
50
|
+
"pytest>=9.0.2",
|
|
51
|
+
"pytest-httpx>=0.36.0",
|
|
52
|
+
"ruff>=0.15.9",
|
|
53
|
+
"ty>=0.0.28",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── Ruff ──────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 88
|
|
61
|
+
target-version = "py311"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = [
|
|
65
|
+
"E", # pycodestyle errors
|
|
66
|
+
"F", # pyflakes
|
|
67
|
+
"I", # isort
|
|
68
|
+
"UP", # pyupgrade
|
|
69
|
+
"B", # flake8-bugbear
|
|
70
|
+
"SIM", # flake8-simplify
|
|
71
|
+
"TID", # flake8-tidy-imports
|
|
72
|
+
"ANN", # flake8-annotations
|
|
73
|
+
]
|
|
74
|
+
ignore = [
|
|
75
|
+
"ANN101", # missing type annotation for self
|
|
76
|
+
"ANN102", # missing type annotation for cls
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint.isort]
|
|
80
|
+
known-first-party = ["scan_google_sheet"]
|
|
81
|
+
|
|
82
|
+
[tool.ruff.format]
|
|
83
|
+
quote-style = "double"
|
|
84
|
+
indent-style = "space"
|
|
85
|
+
|
|
86
|
+
# ── ty ────────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
[tool.ty.rules]
|
|
89
|
+
division-by-zero = "error"
|
|
90
|
+
invalid-return-type = "error"
|
|
91
|
+
|
|
92
|
+
# ── Pytest ────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
[tool.pytest.ini_options]
|
|
95
|
+
testpaths = ["tests"]
|
|
96
|
+
addopts = "-v --tb=short"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""read_sheet — Read public Google Sheets into Polars DataFrames."""
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
NetworkError,
|
|
8
|
+
ReadSheetError,
|
|
9
|
+
SheetFetchError,
|
|
10
|
+
SheetParseError,
|
|
11
|
+
SheetURLError,
|
|
12
|
+
)
|
|
13
|
+
from .fetch import fetch_raw
|
|
14
|
+
from .parse import parse_csv
|
|
15
|
+
from .scan import read_google_sheet, scan_google_sheet
|
|
16
|
+
from .url import build_export_url, build_gviz_url, extract_gid, extract_sheet_id, from_url
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
# main API
|
|
20
|
+
"read_google_sheet",
|
|
21
|
+
"scan_google_sheet",
|
|
22
|
+
# exceptions
|
|
23
|
+
"ReadSheetError",
|
|
24
|
+
"SheetURLError",
|
|
25
|
+
"SheetFetchError",
|
|
26
|
+
"SheetParseError",
|
|
27
|
+
"NetworkError",
|
|
28
|
+
"ConfigurationError",
|
|
29
|
+
# url utilities
|
|
30
|
+
"extract_sheet_id",
|
|
31
|
+
"extract_gid",
|
|
32
|
+
"build_export_url",
|
|
33
|
+
"build_gviz_url",
|
|
34
|
+
"from_url",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_sheet(
|
|
39
|
+
url_or_id: str,
|
|
40
|
+
*,
|
|
41
|
+
gid: str | None = None,
|
|
42
|
+
timeout: int = 30,
|
|
43
|
+
infer_schema_length: int | None = 100,
|
|
44
|
+
null_values: list[str] | None = None,
|
|
45
|
+
try_parse_dates: bool = False,
|
|
46
|
+
schema_overrides: dict[str, pl.DataType] | None = None,
|
|
47
|
+
) -> pl.DataFrame:
|
|
48
|
+
"""Read a public Google Sheet into a Polars DataFrame.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
url_or_id:
|
|
53
|
+
Either a full Google Sheets URL or a bare spreadsheet ID.
|
|
54
|
+
If a URL is provided, the ``gid`` is extracted automatically
|
|
55
|
+
unless overridden by the ``gid`` parameter.
|
|
56
|
+
gid:
|
|
57
|
+
Tab/sheet index (gid). Overrides any gid found in ``url_or_id``.
|
|
58
|
+
Defaults to the first sheet if omitted.
|
|
59
|
+
timeout:
|
|
60
|
+
HTTP request timeout in seconds.
|
|
61
|
+
infer_schema_length:
|
|
62
|
+
Rows used to infer column types. ``None`` = all rows.
|
|
63
|
+
null_values:
|
|
64
|
+
Extra strings to treat as null (on top of the built-in defaults).
|
|
65
|
+
try_parse_dates:
|
|
66
|
+
Attempt automatic date parsing.
|
|
67
|
+
schema_overrides:
|
|
68
|
+
Override inferred dtypes per column.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
pl.DataFrame
|
|
73
|
+
|
|
74
|
+
Examples
|
|
75
|
+
--------
|
|
76
|
+
>>> df = read_sheet("https://docs.google.com/spreadsheets/d/SHEET_ID/edit#gid=0")
|
|
77
|
+
>>> df = read_sheet("SHEET_ID", gid="123456789")
|
|
78
|
+
"""
|
|
79
|
+
sheet_id = extract_sheet_id(url_or_id)
|
|
80
|
+
|
|
81
|
+
# gid: explicit param wins, then try extracting from URL, then None (first sheet)
|
|
82
|
+
resolved_gid = gid or extract_gid(url_or_id)
|
|
83
|
+
|
|
84
|
+
export_url = build_export_url(sheet_id, resolved_gid)
|
|
85
|
+
csv_text = fetch_raw(export_url, timeout=timeout)
|
|
86
|
+
|
|
87
|
+
return parse_csv(
|
|
88
|
+
csv_text,
|
|
89
|
+
infer_schema_length=infer_schema_length,
|
|
90
|
+
null_values=null_values,
|
|
91
|
+
try_parse_dates=try_parse_dates,
|
|
92
|
+
schema_overrides=schema_overrides,
|
|
93
|
+
)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Custom exceptions for read-sheet.
|
|
2
|
+
|
|
3
|
+
Three failure domains, one base:
|
|
4
|
+
|
|
5
|
+
ReadSheetError ← base, always carries optional ``cause``
|
|
6
|
+
├── SheetURLError ← malformed URL or unextractable sheet ID
|
|
7
|
+
├── SheetFetchError ← HTTP / network failure, carries ``url`` + ``status_code``
|
|
8
|
+
└── SheetParseError ← CSV or Polars schema/parse failure, carries ``column``
|
|
9
|
+
|
|
10
|
+
All subclasses preserve the original exception via ``cause`` / ``__cause__``
|
|
11
|
+
so tracebacks remain intact and ``match``/``case`` branches can inspect them.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from polars.exceptions import (
|
|
17
|
+
ColumnNotFoundError,
|
|
18
|
+
ComputeError,
|
|
19
|
+
DuplicateError,
|
|
20
|
+
InvalidOperationError,
|
|
21
|
+
NoDataError,
|
|
22
|
+
PolarsError,
|
|
23
|
+
SchemaError,
|
|
24
|
+
ShapeError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ConfigurationError",
|
|
29
|
+
"NetworkError",
|
|
30
|
+
"ReadSheetError",
|
|
31
|
+
"SheetFetchError",
|
|
32
|
+
"SheetParseError",
|
|
33
|
+
"SheetURLError",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Base ──────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ReadSheetError(Exception):
|
|
41
|
+
"""Base exception for all read-sheet errors.
|
|
42
|
+
|
|
43
|
+
Every subclass carries an optional ``cause`` that preserves the original
|
|
44
|
+
exception, making it safe to use inside ``match``/``case`` without losing
|
|
45
|
+
the underlying traceback.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> raise ReadSheetError("something went wrong")
|
|
49
|
+
ReadSheetError('something went wrong')
|
|
50
|
+
|
|
51
|
+
>>> try:
|
|
52
|
+
... int("bad")
|
|
53
|
+
... except ValueError as e:
|
|
54
|
+
... raise ReadSheetError("unexpected value", cause=e) from e
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.cause = cause
|
|
60
|
+
if cause is not None:
|
|
61
|
+
self.__cause__ = cause
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
if self.cause:
|
|
65
|
+
return f"{type(self).__name__}({self.args[0]!r}, cause={self.cause!r})"
|
|
66
|
+
return f"{type(self).__name__}({self.args[0]!r})"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── Subclasses ────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SheetURLError(ReadSheetError):
|
|
73
|
+
"""Raised when a Google Sheets URL or ID cannot be parsed.
|
|
74
|
+
|
|
75
|
+
Carries the ``raw`` value that failed so callers can log or surface it
|
|
76
|
+
without reparsing the message string.
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
>>> err = SheetURLError("No sheet ID found", raw="not-a-url")
|
|
80
|
+
>>> err.raw
|
|
81
|
+
'not-a-url'
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
message: str,
|
|
87
|
+
*,
|
|
88
|
+
raw: str | None = None,
|
|
89
|
+
cause: BaseException | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
super().__init__(message, cause=cause)
|
|
92
|
+
self.raw = raw
|
|
93
|
+
|
|
94
|
+
def __repr__(self) -> str:
|
|
95
|
+
parts = [repr(self.args[0])]
|
|
96
|
+
if self.raw is not None:
|
|
97
|
+
parts.append(f"raw={self.raw!r}")
|
|
98
|
+
if self.cause is not None:
|
|
99
|
+
parts.append(f"cause={self.cause!r}")
|
|
100
|
+
return f"SheetURLError({', '.join(parts)})"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SheetFetchError(ReadSheetError):
|
|
104
|
+
"""Raised when the HTTP request to Google Sheets fails.
|
|
105
|
+
|
|
106
|
+
Carries the ``url`` that was requested and the HTTP ``status_code`` when
|
|
107
|
+
one is available (``None`` for pure network errors).
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
>>> err = SheetFetchError("Sheet is not public", url="https://...", status_code=401)
|
|
111
|
+
>>> err.status_code
|
|
112
|
+
401
|
|
113
|
+
>>> err.is_auth_error
|
|
114
|
+
True
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
message: str,
|
|
120
|
+
*,
|
|
121
|
+
url: str | None = None,
|
|
122
|
+
status_code: int | None = None,
|
|
123
|
+
cause: BaseException | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
super().__init__(message, cause=cause)
|
|
126
|
+
self.url = url
|
|
127
|
+
self.status_code = status_code
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_auth_error(self) -> bool:
|
|
131
|
+
"""True when the sheet is not publicly accessible (HTTP 401/403)."""
|
|
132
|
+
return self.status_code in (401, 403)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def is_not_found(self) -> bool:
|
|
136
|
+
"""True when the sheet ID does not exist (HTTP 404)."""
|
|
137
|
+
return self.status_code == 404
|
|
138
|
+
|
|
139
|
+
def __repr__(self) -> str:
|
|
140
|
+
parts = [repr(self.args[0])]
|
|
141
|
+
if self.url is not None:
|
|
142
|
+
parts.append(f"url={self.url!r}")
|
|
143
|
+
if self.status_code is not None:
|
|
144
|
+
parts.append(f"status_code={self.status_code!r}")
|
|
145
|
+
if self.cause is not None:
|
|
146
|
+
parts.append(f"cause={self.cause!r}")
|
|
147
|
+
return f"SheetFetchError({', '.join(parts)})"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class SheetParseError(ReadSheetError):
|
|
151
|
+
"""Raised when CSV or Polars parsing fails after a successful fetch.
|
|
152
|
+
|
|
153
|
+
Carries the ``column`` name when the failure can be attributed to a
|
|
154
|
+
specific field, and wraps Polars exceptions via ``from_polars()``.
|
|
155
|
+
|
|
156
|
+
Examples:
|
|
157
|
+
>>> err = SheetParseError("Type mismatch", column="amount")
|
|
158
|
+
>>> err.column
|
|
159
|
+
'amount'
|
|
160
|
+
|
|
161
|
+
>>> from polars.exceptions import SchemaError
|
|
162
|
+
>>> err = SheetParseError.from_polars(SchemaError("bad schema"), step="parse_csv")
|
|
163
|
+
>>> type(err)
|
|
164
|
+
<class 'SheetParseError'>
|
|
165
|
+
>>> err.cause
|
|
166
|
+
SchemaError('bad schema')
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
message: str,
|
|
172
|
+
*,
|
|
173
|
+
column: str | None = None,
|
|
174
|
+
cause: BaseException | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
super().__init__(message, cause=cause)
|
|
177
|
+
self.column = column
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def from_polars(cls, error: PolarsError, step: str) -> SheetParseError:
|
|
181
|
+
"""Wrap a raw Polars exception with step context.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
error: The original Polars exception.
|
|
185
|
+
step: Human-readable label for the pipeline step that failed.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
A ``SheetParseError`` with ``cause`` set to the original error.
|
|
189
|
+
"""
|
|
190
|
+
return cls(f"{step}: {error}", cause=error)
|
|
191
|
+
|
|
192
|
+
def __repr__(self) -> str:
|
|
193
|
+
parts = [repr(self.args[0])]
|
|
194
|
+
if self.column is not None:
|
|
195
|
+
parts.append(f"column={self.column!r}")
|
|
196
|
+
if self.cause is not None:
|
|
197
|
+
parts.append(f"cause={self.cause!r}")
|
|
198
|
+
return f"SheetParseError({', '.join(parts)})"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class NetworkError(ReadSheetError):
|
|
202
|
+
"""Raised when a connection or transport-level failure occurs.
|
|
203
|
+
|
|
204
|
+
Distinct from ``SheetFetchError`` (which implies a completed HTTP round-trip
|
|
205
|
+
with a status code). ``NetworkError`` fires when no response is received at
|
|
206
|
+
all — DNS failure, refused connection, proxy error, etc.
|
|
207
|
+
|
|
208
|
+
Examples:
|
|
209
|
+
>>> err = NetworkError("DNS resolution failed", url="https://docs.google.com/...")
|
|
210
|
+
>>> err.url
|
|
211
|
+
'https://docs.google.com/...'
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(
|
|
215
|
+
self,
|
|
216
|
+
message: str,
|
|
217
|
+
*,
|
|
218
|
+
url: str | None = None,
|
|
219
|
+
cause: BaseException | None = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
super().__init__(message, cause=cause)
|
|
222
|
+
self.url = url
|
|
223
|
+
|
|
224
|
+
def __repr__(self) -> str:
|
|
225
|
+
parts = [repr(self.args[0])]
|
|
226
|
+
if self.url is not None:
|
|
227
|
+
parts.append(f"url={self.url!r}")
|
|
228
|
+
if self.cause is not None:
|
|
229
|
+
parts.append(f"cause={self.cause!r}")
|
|
230
|
+
return f"NetworkError({', '.join(parts)})"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class ConfigurationError(ReadSheetError):
|
|
234
|
+
"""Raised when ``scan_google_sheet`` is called with invalid arguments.
|
|
235
|
+
|
|
236
|
+
Typically fired when both ``sheet_id`` and ``url`` are provided, or
|
|
237
|
+
neither is provided.
|
|
238
|
+
|
|
239
|
+
Examples:
|
|
240
|
+
>>> raise ConfigurationError("Provide either sheet_id or url, not both")
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ── Polars → SheetParseError mapping ─────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
# Used internally by SheetParseError.from_polars() and parse.py.
|
|
247
|
+
_POLARS_PARSE_ERRORS: frozenset[type[PolarsError]] = frozenset(
|
|
248
|
+
{
|
|
249
|
+
SchemaError,
|
|
250
|
+
ColumnNotFoundError,
|
|
251
|
+
DuplicateError,
|
|
252
|
+
ComputeError,
|
|
253
|
+
InvalidOperationError,
|
|
254
|
+
ShapeError,
|
|
255
|
+
NoDataError,
|
|
256
|
+
}
|
|
257
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Fetch CSV data from a public Google Sheets export URL using httpx."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from . import exceptions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fetch_raw(url: str, *, timeout: int = 30, sheet_name: str = "") -> str:
|
|
9
|
+
"""Fetch raw CSV text from a Google Sheets gviz export URL.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
url:
|
|
14
|
+
The full gviz CSV export URL (use ``build_gviz_url`` or ``from_url``).
|
|
15
|
+
timeout:
|
|
16
|
+
Request timeout in seconds.
|
|
17
|
+
sheet_name:
|
|
18
|
+
Used in error messages to identify which sheet failed.
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
str
|
|
23
|
+
Raw CSV text.
|
|
24
|
+
|
|
25
|
+
Raises
|
|
26
|
+
------
|
|
27
|
+
SheetFetchError
|
|
28
|
+
On non-200 HTTP status or timeout.
|
|
29
|
+
NetworkError
|
|
30
|
+
On connection/transport failure (no response received).
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
response = httpx.get(
|
|
34
|
+
url,
|
|
35
|
+
timeout=timeout,
|
|
36
|
+
follow_redirects=True,
|
|
37
|
+
headers={"User-Agent": "read-sheet/0.1.0"},
|
|
38
|
+
)
|
|
39
|
+
except httpx.TimeoutException as e:
|
|
40
|
+
raise exceptions.SheetFetchError(
|
|
41
|
+
f"Request timed out after {timeout} seconds", url=url, cause=e
|
|
42
|
+
) from e
|
|
43
|
+
except httpx.RequestError as e:
|
|
44
|
+
raise exceptions.NetworkError(f"Network error: {e}", url=url, cause=e) from e
|
|
45
|
+
|
|
46
|
+
if response.status_code != 200:
|
|
47
|
+
label = f"'{sheet_name}'" if sheet_name else url
|
|
48
|
+
raise exceptions.SheetFetchError(
|
|
49
|
+
f"Failed to fetch {label}. "
|
|
50
|
+
f"Status: {response.status_code}, Reason: {response.reason_phrase}",
|
|
51
|
+
url=url,
|
|
52
|
+
status_code=response.status_code,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return response.text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# # Backwards-compatible alias used by read_sheet()
|
|
59
|
+
# def fetch_csv(export_url: str, timeout: int = 30) -> str:
|
|
60
|
+
# return fetch_raw(export_url, timeout=timeout)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Parse raw CSV text into a Polars DataFrame."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
from .exceptions import _POLARS_PARSE_ERRORS, SheetParseError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_csv(
|
|
11
|
+
csv_text: str,
|
|
12
|
+
*,
|
|
13
|
+
infer_schema_length: int | None = 100,
|
|
14
|
+
null_values: list[str] | None = None,
|
|
15
|
+
try_parse_dates: bool = False,
|
|
16
|
+
schema_overrides: dict[str, pl.DataType] | None = None,
|
|
17
|
+
) -> pl.DataFrame:
|
|
18
|
+
"""Parse CSV text into a Polars DataFrame.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
csv_text:
|
|
23
|
+
Raw CSV string fetched from Google Sheets export.
|
|
24
|
+
infer_schema_length:
|
|
25
|
+
Number of rows used to infer column types. Pass `None` to read all rows.
|
|
26
|
+
null_values:
|
|
27
|
+
Additional strings to treat as null (e.g. ``["N/A", "-"]``).
|
|
28
|
+
try_parse_dates:
|
|
29
|
+
Attempt to parse date-like strings automatically.
|
|
30
|
+
schema_overrides:
|
|
31
|
+
Map of column name → Polars dtype to override inferred types.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
pl.DataFrame
|
|
36
|
+
"""
|
|
37
|
+
_null_values = ["", "NULL", "null", "None", "NA"] + (null_values or [])
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
return pl.read_csv(
|
|
41
|
+
io.StringIO(csv_text),
|
|
42
|
+
infer_schema_length=infer_schema_length,
|
|
43
|
+
null_values=_null_values,
|
|
44
|
+
try_parse_dates=try_parse_dates,
|
|
45
|
+
schema_overrides=schema_overrides,
|
|
46
|
+
)
|
|
47
|
+
except tuple(_POLARS_PARSE_ERRORS) as e:
|
|
48
|
+
raise SheetParseError.from_polars(e, step="parse_csv") from e
|
|
49
|
+
except Exception as e:
|
|
50
|
+
raise SheetParseError(f"Unexpected error parsing CSV: {e}", cause=e) from e
|
|
File without changes
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Lazy Google Sheets reader via Polars IO plugin API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from io import StringIO
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
from polars.io.plugins import register_io_source
|
|
10
|
+
|
|
11
|
+
from . import exceptions
|
|
12
|
+
from .fetch import fetch_raw
|
|
13
|
+
from .url import build_gviz_url, from_url
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def scan_google_sheet(
|
|
17
|
+
sheet_name: str,
|
|
18
|
+
sheet_id: str | None = None,
|
|
19
|
+
url: str | None = None,
|
|
20
|
+
*,
|
|
21
|
+
timeout: int = 10,
|
|
22
|
+
parse_dates: bool = True,
|
|
23
|
+
batch_size: int = 1_000,
|
|
24
|
+
) -> pl.LazyFrame:
|
|
25
|
+
"""Scan a public Google Sheet as a Polars LazyFrame via the IO plugin API.
|
|
26
|
+
|
|
27
|
+
Registered as a lazy source so it participates in Polars query
|
|
28
|
+
optimisation — projection pushdown, predicate pushdown, early stopping,
|
|
29
|
+
and streaming in batches are all supported.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
sheet_name:
|
|
34
|
+
The tab name to read (as shown on the sheet tab in Google Sheets).
|
|
35
|
+
sheet_id:
|
|
36
|
+
The spreadsheet ID from the URL. Mutually exclusive with ``url``.
|
|
37
|
+
url:
|
|
38
|
+
A full Google Sheets URL. The sheet ID is extracted automatically.
|
|
39
|
+
Mutually exclusive with ``sheet_id``.
|
|
40
|
+
timeout:
|
|
41
|
+
HTTP request timeout in seconds.
|
|
42
|
+
parse_dates:
|
|
43
|
+
Attempt automatic date/datetime parsing.
|
|
44
|
+
batch_size:
|
|
45
|
+
Number of rows per batch yielded to the Polars engine.
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
pl.LazyFrame
|
|
50
|
+
|
|
51
|
+
Raises
|
|
52
|
+
------
|
|
53
|
+
ConfigurationError
|
|
54
|
+
If neither or both of ``sheet_id`` / ``url`` are provided.
|
|
55
|
+
SheetFetchError
|
|
56
|
+
If the HTTP request fails or returns a non-200 status.
|
|
57
|
+
NetworkError
|
|
58
|
+
On connection or transport failure.
|
|
59
|
+
|
|
60
|
+
Notes
|
|
61
|
+
-----
|
|
62
|
+
Google Sheets CSV export does not support partial reads — the full sheet
|
|
63
|
+
is always downloaded in a single HTTP request. Projection and predicate
|
|
64
|
+
pushdown are applied in Python after the download, so they reduce
|
|
65
|
+
processing cost but not network cost.
|
|
66
|
+
|
|
67
|
+
Examples
|
|
68
|
+
--------
|
|
69
|
+
>>> lf = scan_google_sheet("Sheet1", sheet_id="1BxiMVs0XRA5nFMdKvBdBZjgm...")
|
|
70
|
+
>>> lf.filter(pl.col("year") == 2025).select("vessel", "amount").collect()
|
|
71
|
+
|
|
72
|
+
>>> lf = scan_google_sheet(
|
|
73
|
+
... "Sheet1",
|
|
74
|
+
... url="https://docs.google.com/spreadsheets/d/1BxiMVs0.../edit",
|
|
75
|
+
... )
|
|
76
|
+
"""
|
|
77
|
+
match (sheet_id, url):
|
|
78
|
+
case (None, None):
|
|
79
|
+
raise exceptions.ConfigurationError("Provide either sheet_id or url, not neither.")
|
|
80
|
+
case (_, None):
|
|
81
|
+
assert sheet_id is not None
|
|
82
|
+
resolved_url = build_gviz_url(sheet_id, sheet_name)
|
|
83
|
+
case (None, _):
|
|
84
|
+
assert url is not None
|
|
85
|
+
resolved_url = from_url(url, sheet_name)
|
|
86
|
+
case _:
|
|
87
|
+
raise exceptions.ConfigurationError("Provide either sheet_id or url, not both.")
|
|
88
|
+
|
|
89
|
+
# One upfront fetch to resolve the schema — unavoidable for CSV over HTTP.
|
|
90
|
+
raw_csv = fetch_raw(resolved_url, timeout=timeout, sheet_name=sheet_name)
|
|
91
|
+
|
|
92
|
+
schema = pl.read_csv(
|
|
93
|
+
StringIO(raw_csv),
|
|
94
|
+
try_parse_dates=parse_dates,
|
|
95
|
+
infer_schema_length=10_000,
|
|
96
|
+
n_rows=0, # schema only, no data rows needed
|
|
97
|
+
).schema
|
|
98
|
+
|
|
99
|
+
def source_generator(
|
|
100
|
+
with_columns: list[str] | None,
|
|
101
|
+
predicate: pl.Expr | None,
|
|
102
|
+
n_rows: int | None,
|
|
103
|
+
batch_size: int | None,
|
|
104
|
+
) -> Iterator[pl.DataFrame]:
|
|
105
|
+
"""Produce batches of rows, honouring pushdown hints from the engine."""
|
|
106
|
+
_batch = batch_size or 1_000
|
|
107
|
+
|
|
108
|
+
lf = pl.scan_csv(
|
|
109
|
+
StringIO(raw_csv),
|
|
110
|
+
try_parse_dates=parse_dates,
|
|
111
|
+
infer_schema_length=10_000,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Apply pushdowns — filtering in Python, not at source (HTTP limitation)
|
|
115
|
+
if with_columns is not None:
|
|
116
|
+
lf = lf.select(with_columns)
|
|
117
|
+
if predicate is not None:
|
|
118
|
+
lf = lf.filter(predicate)
|
|
119
|
+
if n_rows is not None:
|
|
120
|
+
lf = lf.head(n_rows)
|
|
121
|
+
|
|
122
|
+
df = lf.collect()
|
|
123
|
+
assert isinstance(df, pl.DataFrame)
|
|
124
|
+
|
|
125
|
+
for offset in range(0, df.height, _batch):
|
|
126
|
+
yield df.slice(offset, _batch)
|
|
127
|
+
|
|
128
|
+
return register_io_source(io_source=source_generator, schema=schema)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_google_sheet(
|
|
132
|
+
sheet_name: str,
|
|
133
|
+
sheet_id: str | None = None,
|
|
134
|
+
url: str | None = None,
|
|
135
|
+
*,
|
|
136
|
+
timeout: int = 10,
|
|
137
|
+
parse_dates: bool = True,
|
|
138
|
+
) -> pl.DataFrame:
|
|
139
|
+
"""Read a public Google Sheet into a Polars DataFrame.
|
|
140
|
+
|
|
141
|
+
Convenience wrapper around ``scan_google_sheet`` that collects immediately.
|
|
142
|
+
Prefer ``scan_google_sheet`` when chaining filters or projections before
|
|
143
|
+
collecting, to avoid loading unused columns into memory.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
sheet_name:
|
|
148
|
+
The tab name to read.
|
|
149
|
+
sheet_id:
|
|
150
|
+
The spreadsheet ID. Mutually exclusive with ``url``.
|
|
151
|
+
url:
|
|
152
|
+
A full Google Sheets URL. Mutually exclusive with ``sheet_id``.
|
|
153
|
+
timeout:
|
|
154
|
+
HTTP request timeout in seconds.
|
|
155
|
+
parse_dates:
|
|
156
|
+
Attempt automatic date/datetime parsing.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
pl.DataFrame
|
|
161
|
+
|
|
162
|
+
Examples
|
|
163
|
+
--------
|
|
164
|
+
>>> df = read_google_sheet("Sheet1", sheet_id="1BxiMVs0XRA5nFMdKvBdBZjgm...")
|
|
165
|
+
>>> df = read_google_sheet(
|
|
166
|
+
... "Sheet1",
|
|
167
|
+
... url="https://docs.google.com/spreadsheets/d/1BxiMVs0.../edit",
|
|
168
|
+
... )
|
|
169
|
+
"""
|
|
170
|
+
df = scan_google_sheet(
|
|
171
|
+
sheet_name,
|
|
172
|
+
sheet_id=sheet_id,
|
|
173
|
+
url=url,
|
|
174
|
+
timeout=timeout,
|
|
175
|
+
parse_dates=parse_dates,
|
|
176
|
+
).collect()
|
|
177
|
+
assert isinstance(df, pl.DataFrame)
|
|
178
|
+
return df
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Utilities for parsing Google Sheets URLs."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import parse_qs, urlparse
|
|
5
|
+
|
|
6
|
+
from .exceptions import SheetURLError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_sheet_id(url: str) -> str:
|
|
10
|
+
"""Extract the spreadsheet ID from a Google Sheets URL or return as-is if already an ID."""
|
|
11
|
+
pattern = r"/spreadsheets/d/([a-zA-Z0-9_-]+)"
|
|
12
|
+
match = re.search(pattern, url)
|
|
13
|
+
if match:
|
|
14
|
+
return match.group(1)
|
|
15
|
+
# Assume it's already a bare sheet ID
|
|
16
|
+
if re.fullmatch(r"[a-zA-Z0-9_-]+", url):
|
|
17
|
+
return url
|
|
18
|
+
raise SheetURLError(f"Could not extract sheet ID from: {url!r}", raw=url)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_gid(url: str) -> str | None:
|
|
22
|
+
"""Extract the gid (tab/sheet index) from a Google Sheets URL, if present."""
|
|
23
|
+
parsed = urlparse(url)
|
|
24
|
+
# gid can appear in the fragment: #gid=123456
|
|
25
|
+
fragment_params = parse_qs(parsed.fragment)
|
|
26
|
+
if "gid" in fragment_params:
|
|
27
|
+
return fragment_params["gid"][0]
|
|
28
|
+
# or in the query string
|
|
29
|
+
query_params = parse_qs(parsed.query)
|
|
30
|
+
if "gid" in query_params:
|
|
31
|
+
return query_params["gid"][0]
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_export_url(sheet_id: str, gid: str | None = None) -> str:
|
|
36
|
+
"""Build the CSV export URL for a public Google Sheet (by gid/tab index)."""
|
|
37
|
+
base = f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
|
|
38
|
+
if gid is not None:
|
|
39
|
+
base += f"&gid={gid}"
|
|
40
|
+
return base
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_gviz_url(sheet_id: str, sheet_name: str) -> str:
|
|
44
|
+
"""Build the gviz CSV export URL for a public Google Sheet (by sheet name).
|
|
45
|
+
|
|
46
|
+
Preferred over the ``/export`` URL when the sheet name is known, as it
|
|
47
|
+
allows selecting a specific tab by name rather than numeric gid.
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
>>> build_gviz_url("1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms", "Sheet1")
|
|
51
|
+
'https://docs.google.com/spreadsheets/d/1BxiMVs0.../gviz/tq?tqx=out:csv&sheet=Sheet1'
|
|
52
|
+
"""
|
|
53
|
+
return (
|
|
54
|
+
f"https://docs.google.com/spreadsheets/d/{sheet_id}/gviz/tq?tqx=out:csv&sheet={sheet_name}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def from_url(url: str, sheet_name: str) -> str:
|
|
59
|
+
"""Extract the sheet ID from a full Google Sheets URL and build a gviz export URL.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
url:
|
|
64
|
+
Any Google Sheets URL containing a spreadsheet ID.
|
|
65
|
+
sheet_name:
|
|
66
|
+
The tab name to export.
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
str
|
|
71
|
+
A ready-to-fetch gviz CSV export URL.
|
|
72
|
+
|
|
73
|
+
Raises
|
|
74
|
+
------
|
|
75
|
+
SheetURLError
|
|
76
|
+
If no sheet ID can be extracted from ``url``.
|
|
77
|
+
"""
|
|
78
|
+
sheet_id = extract_sheet_id(url)
|
|
79
|
+
return build_gviz_url(sheet_id, sheet_name)
|