Skip to content

Commit 7e3d5f8

Browse files
authored
feat(filesystem): load workbook worksheets as tables (#271)
- Treat XLSX and ODS workbooks as multi-table sources, loading every non-empty worksheet in one ingest. - Preserve scalar worksheet selection and support JSON lists by worksheet name or ID. - Apply active dlt naming, merge matching worksheet names across globs, and report normalized-name collisions. - Reject plural loads for file, CSV, and blob destinations that can only represent one table. - Support spreadsheet reader hints across Python 3.10+ and document the workbook contract.
1 parent 747b6aa commit 7e3d5f8

26 files changed

Lines changed: 767 additions & 78 deletions

File tree

.github/workflows/tests.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,8 @@ jobs:
104104
timezoneWindows: "European Standard Time"
105105

106106
- name: Run Docker-free unit tests
107-
run: pytest -m "not integration" -k "not odbc" -p no:cacheprovider
107+
# Calamine and mq-bridge load incompatible native runtimes on macOS ARM.
108+
# Exercise both, but keep workbook tests in a separate interpreter process.
109+
run: |
110+
pytest -m "not integration" -k "not odbc" -p no:cacheprovider --ignore=tests/dlt_filesystem/format/test_spreadsheet.py
111+
pytest -m "not integration" -k "not odbc" -p no:cacheprovider tests/dlt_filesystem/format/test_spreadsheet.py -n1

docs/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## in progress
44

5+
- Filesystem: Treat XLSX and ODS workbooks as multi-table sources by default,
6+
while preserving one-table loads through explicit worksheet selectors.
7+
Thanks, @hampsterx.
58
- Filesystem: Fail ingestion when a concrete source path matches no file while
69
keeping unmatched glob selections valid. Thanks, @hampsterx.
710
- Filesystem: Added rsync source connector. Thanks, @oferchen.

docs/supported-sources/filesystem.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ The named-hint grammar:
160160
- An empty value is preserved (`#sheet_name=` gives `sheet_name` = `""`);
161161
a reader decides whether that means "unset".
162162
- Duplicate keys take the last value (`#sheet_name=a&sheet_name=b` gives `b`).
163+
- JSON arrays select several values when the reader accepts a list, for example
164+
`#sheet_name=["events","inventory"]`.
163165
- If any segment of the fragment is neither a `key=value` pair nor a single
164166
known format, the whole `#...` is treated as a literal part of the path, so a
165167
real `#` in a filename keeps working. Percent-encode a literal `#` as `%23`
@@ -174,6 +176,45 @@ reader requires a `#tagname=<row-tag>` hint to define the repeated element
174176
that represents one record / row.
175177
:::
176178

179+
(workbook-tables)=
180+
181+
## Workbook tables
182+
183+
XLSX and ODS workbooks are multi-table sources. Without a worksheet selector,
184+
omniload reads every worksheet and uses each worksheet name as its destination
185+
table name. Empty worksheets are skipped.
186+
187+
`--dest-table` keeps its usual `<dataset>.<table>` syntax. For a plural workbook
188+
load, the dataset component is used and the table component is a placeholder:
189+
190+
```sh
191+
omniload ingest \
192+
--source-uri 'file://data/workbook.xlsx' \
193+
--dest-uri 'duckdb:///local.duckdb' \
194+
--dest-table 'landing.workbook'
195+
```
196+
197+
A worksheet named `Quarterly Sales` becomes `quarterly_sales` under the default
198+
schema naming convention. With `--schema-naming direct`, its name remains
199+
`Quarterly Sales`. Distinct worksheet names that resolve to the same table under
200+
the active convention fail before loading, with both worksheet and workbook
201+
names in the error.
202+
203+
When a glob matches several workbooks, worksheets with the same original name
204+
merge into one destination table. Different original names that collide after
205+
normalization fail instead of merging silently.
206+
207+
Select one worksheet with `#sheet_name=<name>` or `#sheet_id=<number>`. A single
208+
selection keeps the existing one-table behavior and uses the table component of
209+
`--dest-table`. Select several worksheets with a JSON array such as
210+
`#sheet_name=["events","inventory"]` or `#sheet_id=[1,2]`.
211+
212+
:::{note}
213+
The `csv://` and `file://` destinations each produce one output file, and the
214+
cloud blob destinations currently address one table path. They reject a plural
215+
workbook load. Select one worksheet or use a dataset-capable destination.
216+
:::
217+
177218
(file-format-routing)=
178219

179220
## File format routing

docs/supported-sources/ods.md

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,39 @@
44

55
`omniload` reads [OpenDocument spreadsheet (ODS)] files,
66
used by [OpenOffice], [LibreOffice], and other spreadsheet applications.
7+
By default, every nonempty worksheet is loaded into its own destination table.
8+
9+
ODS is currently supported for read operations only.
10+
11+
## Example: load a workbook into DuckDB
12+
13+
```sh
14+
omniload ingest \
15+
--source-uri 'file://path/to/workbook.ods' \
16+
--dest-uri 'duckdb:///local.duckdb' \
17+
--dest-table 'public.workbook'
18+
```
19+
20+
The first part of `--dest-table` selects the destination dataset. The second
21+
part is a required placeholder for a plural workbook load. Worksheet names
22+
replace it as the destination table names.
23+
24+
To load one worksheet into the table named by `--dest-table`, select it by name
25+
or one-based number:
26+
27+
```sh
28+
omniload ingest \
29+
--source-uri 'file://path/to/workbook.ods#sheet_name=events' \
30+
--dest-uri 'duckdb:///local.duckdb' \
31+
--dest-table 'public.events'
32+
```
33+
34+
Select several worksheets by name or one-based position with a JSON array:
35+
36+
```text
37+
file://path/to/workbook.ods#sheet_name=["events","inventory"]
38+
file://path/to/workbook.ods#sheet_id=[1,2]
39+
```
740

841
## Where it works
942

@@ -22,24 +55,17 @@ Gzipped files are decompressed automatically.
2255
The whole file is read into memory and decoded at once (ODS is not a streaming
2356
format); a corrupt or truncated file raises rather than loading partial data.
2457
Map keys are expected to be strings.
58+
During plural loads, worksheets without data rows are skipped because dlt has
59+
no row from which to create a destination table. This includes header-only sheets.
2560

2661
## Options
2762

2863
Options can be defined by using reader hints. The loader is using
2964
[polars.read_ods], please consult its documentation about all available
3065
parameters and their descriptions.
3166

32-
Please note due to introspection and automatic type casting capabilities,
33-
the full set of parameters is only available with Python 3.14 and higher.
34-
35-
## Example: Load ODS file into DuckDB
36-
37-
```sh
38-
omniload ingest \
39-
--source-uri 'file://path/to/workbook.ods#sheet_name=events' \
40-
--dest-uri 'duckdb:///local.duckdb' \
41-
--dest-table 'public.events'
42-
```
67+
See {ref}`Workbook tables <workbook-tables>` for naming, glob, collision, and
68+
destination compatibility rules shared by XLSX and ODS.
4369

4470

4571
[LibreOffice]: https://en.wikipedia.org/wiki/LibreOffice

docs/supported-sources/xlsx.md

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,41 @@
22

33
# XLSX
44

5-
`omniload` reads [Excel Workbook] XLSX spreadsheet files.
5+
`omniload` reads [Excel Workbook] XLSX spreadsheet files. By default, every
6+
nonempty worksheet is loaded into its own destination table.
67

78
XLSX is currently supported for read operations only.
89

10+
## Example: load a workbook into DuckDB
11+
12+
```sh
13+
omniload ingest \
14+
--source-uri 'file://path/to/workbook.xlsx' \
15+
--dest-uri 'duckdb:///local.duckdb' \
16+
--dest-table 'public.workbook'
17+
```
18+
19+
The first part of `--dest-table` selects the destination dataset. The second
20+
part is a required placeholder for a plural workbook load. Worksheet names
21+
replace it as the destination table names.
22+
23+
To load one worksheet into the table named by `--dest-table`, select it by name
24+
or one-based number:
25+
26+
```sh
27+
omniload ingest \
28+
--source-uri 'file://path/to/workbook.xlsx#sheet_name=events' \
29+
--dest-uri 'duckdb:///local.duckdb' \
30+
--dest-table 'public.events'
31+
```
32+
33+
Select several worksheets by name or one-based position with a JSON array:
34+
35+
```text
36+
file://path/to/workbook.xlsx#sheet_name=["events","inventory"]
37+
file://path/to/workbook.xlsx#sheet_id=[1,2]
38+
```
39+
940
## Where it works
1041

1142
Excel XLSX files can be accessed on every source that goes through the shared file readers:
@@ -22,24 +53,17 @@ Gzipped files are decompressed automatically.
2253
The whole file is read into memory and decoded at once (XLSX is not a streaming
2354
format); a corrupt or truncated file raises rather than loading partial data.
2455
Map keys are expected to be strings.
56+
During plural loads, worksheets without data rows are skipped because dlt has
57+
no row from which to create a destination table. This includes header-only sheets.
2558

2659
## Options
2760

2861
Options can be defined by using reader hints. The loader is using
2962
[polars.read_excel], please consult its documentation about all available
3063
parameters and their descriptions.
3164

32-
Please note due to introspection and automatic type casting capabilities,
33-
the full set of parameters is only available with Python 3.14 and higher.
34-
35-
## Example: Load XLSX file into DuckDB
36-
37-
```sh
38-
omniload ingest \
39-
--source-uri 'file://path/to/workbook.xlsx#sheet_name=events' \
40-
--dest-uri 'duckdb:///local.duckdb' \
41-
--dest-table 'public.events'
42-
```
65+
See {ref}`Workbook tables <workbook-tables>` for naming, glob, collision, and
66+
destination compatibility rules shared by XLSX and ODS.
4367

4468

4569
[Excel workbook (XLSX)]: https://en.wikipedia.org/wiki/Microsoft_Excel#Current_file_extensions

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ optional-dependencies.test = [
202202
"testcontainers[azurite,mysql,postgres]>=4.9.1,<4.16",
203203
"types-requests<3",
204204
"verlib2",
205+
"xlsxwriter<4",
205206
]
206207
urls.Changelog = "https://omniload.readthedocs.io/changelog.html"
207208
urls.Documentation = "https://omniload.readthedocs.io/"

src/dlt_filesystem/source/base.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ def supports_filesystem_incremental(self) -> bool:
2929
"""Return whether the source supports file-level mtime selection."""
3030
return True
3131

32+
def produces_multiple_tables(self, uri: str, table: str) -> bool:
33+
"""Return whether a workbook selection dispatches worksheet tables."""
34+
from dlt_filesystem.source.error import UnsupportedEndpointError
35+
from dlt_filesystem.source.format.readers import (
36+
spreadsheet_selection_is_plural,
37+
)
38+
from dlt_filesystem.source.router import (
39+
blob_hints,
40+
determine_endpoint,
41+
parse_uri,
42+
)
43+
44+
parsed_uri = urlparse(uri)
45+
_, path = parse_uri(parsed_uri, table)
46+
try:
47+
endpoint = determine_endpoint(table, path)
48+
except (UnsupportedEndpointError, ValueError):
49+
return False
50+
return endpoint in {
51+
"read_excel",
52+
"read_ods",
53+
} and spreadsheet_selection_is_plural(blob_hints(parsed_uri, table))
54+
3255
@staticmethod
3356
def endpoint_namespace(endpoint: Union[str, None], default: str) -> str:
3457
"""

src/dlt_filesystem/source/core.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Union
1+
from typing import Any, Union
22

33
import dlt
44
from dlt.extract import DltResource, DltSource
@@ -42,11 +42,18 @@ def resource_for_reader(ref: FilesystemReference) -> Union[DltSource, DltResourc
4242

4343
# Apply parameter bindings for certain readers.
4444
# TODO: Can this be generalized? Why not always loop in column_names into reader hints?
45+
reader_kwargs: dict[str, Any] = dict(ref.hints)
46+
if ref.reader_name in {"read_excel", "read_ods"}:
47+
# The filesystem lister yields pages of files. Keep one collision registry
48+
# bound to the reader so distinct worksheet names cannot normalize to the
49+
# same table even when the workbooks occur in different pages.
50+
reader_kwargs["worksheet_names"] = {}
51+
4552
if ref.reader_name == "read_csv_headless":
4653
column_names = list(ref.column_types.keys()) if ref.column_types else None
47-
reader = reader.bind(column_names=column_names, **ref.hints)
54+
reader = reader.bind(column_names=column_names, **reader_kwargs)
4855
else:
49-
reader = reader.bind(**ref.hints)
56+
reader = reader.bind(**reader_kwargs)
5057

5158
# Connect and propagate elements.
5259
return filesystem_resource | reader

src/dlt_filesystem/source/error.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,25 @@ class UnsupportedEndpointError(Exception):
2525
pass
2626

2727

28+
class WorksheetNameCollisionError(ValueError):
29+
"""Two distinct worksheet names resolve to the same destination table."""
30+
31+
def __init__(
32+
self,
33+
*,
34+
table_name: str,
35+
first_sheet: str,
36+
first_file: str,
37+
second_sheet: str,
38+
second_file: str,
39+
) -> None:
40+
super().__init__(
41+
f"Worksheet names {first_sheet!r} in {first_file!r} and "
42+
f"{second_sheet!r} in {second_file!r} both resolve to destination "
43+
f"table {table_name!r} under the active schema naming convention"
44+
)
45+
46+
2847
class MissingDecoderError(UnsupportedEndpointError):
2948
"""A routable format resolved to a reader whose decoder package is not installed.
3049

0 commit comments

Comments
 (0)