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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/scan-google-sheet)](https://pypi.org/project/scan-google-sheet/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/scan-google-sheet)](https://pypi.org/project/scan-google-sheet/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
35
+ [![CI](https://github.com/Attica-oss/scan_google_sheet/actions/workflows/ci.yml/badge.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/scan-google-sheet)](https://pypi.org/project/scan-google-sheet/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/scan-google-sheet)](https://pypi.org/project/scan-google-sheet/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![CI](https://github.com/Attica-oss/scan_google_sheet/actions/workflows/ci.yml/badge.svg)](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)