From 00cea265d3e9d9cf10312696fb7f8579e582eab2 Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Tue, 1 Sep 2026 08:25:21 -0700 Subject: [PATCH] Feature parity with XLSX.jl's FileIO extension - load("file.xlsx") without a sheet argument loads the first sheet. - New transpose=true keyword for load (fixes #12), for sheets organized in rows rather than columns. - save accepts any Tables.jl source in addition to iterable tables, can write multiple sheets via save(file, "name" => table, ...) (fixes #38), overwrites existing files by default in line with other FileIO formats (fixes #24; pass overwrite=false for the old error), and passes keyword arguments through to XLSX.writetable. - Tests proving the lazy ExcelFile return works as a Tables.jl source (Tables.columns) and with DataFrame, so it is a drop-in for sinks. - README: document the new features, fix the colnames type (Vector{Symbol}, fixes #44), add an open-once-read-many example via ExcelReaders.openxl (fixes #34), and point to XLSX.readtable for its exclusive options. Co-Authored-By: Claude Fable 5 --- NEWS.md | 9 +++++++ Project.toml | 3 ++- README.md | 37 ++++++++++++++++++++++++--- src/ExcelFiles.jl | 37 +++++++++++++++++++++------ test/test_excelfiles.jl | 56 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 12 deletions(-) diff --git a/NEWS.md b/NEWS.md index ad734ae..e3944fb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,12 @@ +# ExcelFiles.jl v1.1.0 +* `load("file.xlsx")` without a sheet argument loads the first sheet +* New `transpose=true` keyword for `load`, for sheets organized in rows + rather than columns +* `save` accepts any Tables.jl source in addition to iterable tables, can + write multiple sheets via `save(file, "name" => table, ...)`, overwrites + existing files by default (pass `overwrite=false` for an error instead), + and passes keyword arguments through to `XLSX.writetable` + # ExcelFiles.jl v1.0.1 * Loading goes through the rewritten ExcelReaders 1.0: legacy xls files are read natively via LibXLS.jl and modern xlsx files via XLSX.jl, without any diff --git a/Project.toml b/Project.toml index bc3c71f..e894ce9 100644 --- a/Project.toml +++ b/Project.toml @@ -31,8 +31,9 @@ julia = "1.10" [extras] DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" TestItemRunner = "f8b46487-2199-4994-9208-9a1283c18c0a" [targets] -test = ["Test", "DataFrames", "TestItemRunner"] +test = ["Test", "DataFrames", "Tables", "TestItemRunner"] diff --git a/README.md b/README.md index 749d70e..5308555 100644 --- a/README.md +++ b/README.md @@ -61,18 +61,47 @@ function load(f::FileIO.File{FileIO.format"Excel"}, range; keywords...) ```` #### Arguments: -* ``range``: either the name of the sheet in the Excel file to read, or a full Excel range specification (i.e. "Sheetname!A1:B2"). -* The ``keywords`` arguments are the same as in [ExcelReaders.jl](https://github.com/queryverse/ExcelReaders.jl) (which is used under the hood to read Excel files). When ``range`` is a sheet name, the keyword arguments for the ``readxlsheet`` function from ExcelReaders.jl apply, if ``range`` is a range specification, the keyword arguments for the ``readxl`` function apply. +* ``range``: either the name of the sheet in the Excel file to read, or a full Excel range specification (i.e. "Sheetname!A1:B2"). If omitted, the first sheet is loaded. +* ``header=true``: whether the first row holds the column names. With ``header=false``, columns are named ``x1``, ``x2``, ... unless ``colnames`` is given. +* ``colnames``: a ``Vector{Symbol}`` of column names to use (e.g. ``colnames=[:a, :b]``), instead of names from the header row. +* ``transpose=false``: with ``transpose=true`` the sheet or range is transposed before the table is constructed, for data organized in rows rather than columns. +* The remaining ``keywords`` arguments are the same as in [ExcelReaders.jl](https://github.com/queryverse/ExcelReaders.jl) (which is used under the hood to read Excel files). When ``range`` is a sheet name, the keyword arguments for the ``readxlsheet`` function from ExcelReaders.jl apply, if ``range`` is a range specification, the keyword arguments for the ``readxl`` function apply. + +To read many sheets or ranges from the same file efficiently, open it once +with ``ExcelReaders.openxl`` and use the ExcelReaders functions directly: + +````julia +using ExcelReaders + +f = openxl("data.xlsx") +data1 = readxlsheet(f, "Sheet1") +data2 = readxlsheet(f, "Sheet2") +```` + +For advanced xlsx-only reading options (native Excel table names, row +callbacks, string-based missing values), [XLSX.jl](https://github.com/JuliaData/XLSX.jl)'s +``XLSX.readtable`` can always be used directly. ### Save an Excel file -The following code saves any iterable table as an excel file: +The following code saves any iterable table or Tables.jl source as an excel file: ````julia using ExcelFiles save("output.xlsx", it) ```` -This will work as long as it is any of the types supported as sources in IterableTables.jl. +This will work as long as ``it`` is any of the types supported as sources in IterableTables.jl, or any Tables.jl table. + +An existing file is overwritten by default; pass ``overwrite=false`` to get +an error instead. A ``sheetname`` keyword sets the name of the sheet, and any +further keyword arguments are passed on to ``XLSX.writetable``. + +Multiple sheets can be written to the same file by passing ``name => table`` +pairs: + +````julia +save("output.xlsx", "Data" => df1, "Summary" => df2) +```` ### Using the pipe syntax diff --git a/src/ExcelFiles.jl b/src/ExcelFiles.jl index fa19dd6..3469484 100644 --- a/src/ExcelFiles.jl +++ b/src/ExcelFiles.jl @@ -55,13 +55,31 @@ Base.Multimedia.showable(::MIME"application/vnd.dataresource+json", source::Exce const ExcelFileFormat = Union{FileIO.File{FileIO.format"Excel"},FileIO.File{FileIO.format"ExcelLegacy"}} +# Without a sheet or range argument the first sheet is loaded (resolved +# lazily at iteration time). +function fileio_load(f::ExcelFileFormat; keywords...) + return ExcelFile(f.filename, "", keywords) +end + function fileio_load(f::ExcelFileFormat, range; keywords...) return ExcelFile(f.filename, range, keywords) end -function fileio_save(f::FileIO.File{FileIO.format"Excel"}, data; sheetname::AbstractString="") - cols, colnames = TableTraitsUtils.create_columns_from_iterabletable(data, na_representation=:missing) - return XLSX.writetable(f.filename, cols, colnames; sheetname=sheetname) +# Any iterable table or Tables.jl source can be saved; keyword arguments +# (sheetname, anchor_cell, ...) are passed through to XLSX.writetable. +# Unlike XLSX.writetable, an existing file is overwritten by default, in line +# with the conventions of other FileIO formats. +function fileio_save(f::FileIO.File{FileIO.format"Excel"}, data; overwrite::Bool=true, kwargs...) + if TableTraits.isiterabletable(data) === true + cols, colnames = TableTraitsUtils.create_columns_from_iterabletable(data, na_representation=:missing) + return XLSX.writetable(f.filename, cols, colnames; overwrite=overwrite, kwargs...) + else + return XLSX.writetable(f.filename, data; overwrite=overwrite, kwargs...) + end +end + +function fileio_save(f::FileIO.File{FileIO.format"Excel"}, tables::Pair...; overwrite::Bool=true, kwargs...) + return XLSX.writetable(f.filename, tables...; overwrite=overwrite, kwargs...) end function fileio_save(f::FileIO.File{FileIO.format"ExcelLegacy"}, data; kwargs...) @@ -79,9 +97,13 @@ function gennames(n::Integer) return res end -function _readxl(file::ExcelReaders.ExcelFile, sheetname::AbstractString, startrow::Integer, startcol::Integer, endrow::Integer, endcol::Integer; header::Bool=true, colnames::Vector{Symbol}=Symbol[]) +function _readxl(file::ExcelReaders.ExcelFile, sheetname::AbstractString, startrow::Integer, startcol::Integer, endrow::Integer, endcol::Integer; header::Bool=true, colnames::Vector{Symbol}=Symbol[], transpose::Bool=false) data = ExcelReaders.readxl_internal(file, sheetname, startrow, startcol, endrow, endcol) + if transpose + data = permutedims(data) + end + nrow, ncol = size(data) if length(colnames) == 0 @@ -142,15 +164,16 @@ function IteratorInterfaceExtensions.getiterator(file::ExcelFile) _readxl(excelfile, sheetname, startrow, startcol, endrow, endcol; file.keywords...) else excelfile = openxl(file.filename) - sheet = ExcelReaders.sheet_handle(excelfile, file.range) + sheetname = isempty(file.range) ? ExcelReaders.sheetnames(excelfile)[1] : file.range + sheet = ExcelReaders.sheet_handle(excelfile, sheetname) - keywords = filter(i -> !(i[1] in (:header, :colnames)), file.keywords) + keywords = filter(i -> !(i[1] in (:header, :colnames, :transpose)), file.keywords) startrow, startcol, endrow, endcol = ExcelReaders.convert_args_to_row_col(sheet; keywords...) keywords2 = copy(file.keywords) keywords2 = filter(i -> !(i[1] in (:skipstartrows, :skipstartcols, :nrows, :ncols)), file.keywords) - _readxl(excelfile, file.range, startrow, startcol, endrow, endcol; keywords2...) + _readxl(excelfile, sheetname, startrow, startcol, endrow, endcol; keywords2...) end return create_tableiterator(column_data, col_names) diff --git a/test/test_excelfiles.jl b/test/test_excelfiles.jl index 5a45b15..0ddf13a 100644 --- a/test/test_excelfiles.jl +++ b/test/test_excelfiles.jl @@ -166,3 +166,59 @@ end @test size(df2) == (4, 13) @test df2[!, 1] == [1., 1.5, 2., 2.5] end + +@testitem "FileIO parity features" begin + using ExcelReaders + using TableTraitsUtils + using Dates + using DataValues + using DataFrames + import Tables + + filename = normpath(dirname(pathof(ExcelReaders)), "..", "test", "TestData.xlsx") + filename_xls = normpath(dirname(pathof(ExcelReaders)), "..", "test", "TestData.xls") + + # zero-arg load defaults to the first sheet + df = DataFrame(load(filename)) + @test size(df) == (4, 13) + @test df[!, 1] == [1., 1.5, 2., 2.5] + df_xls = DataFrame(load(filename_xls)) + @test df_xls[!, 1] == [1., 1.5, 2., 2.5] + + # transpose (issue #12) + data, names = create_columns_from_iterabletable(load(filename, "Sheet1!C3:D5", header=false, transpose=true)) + @test length(data) == 3 + @test data[1] == ["Some Float64s", "Some Strings"] + @test data[2] == [1.0, "A"] + @test data[3] == [1.5, "BB"] + + # the lazy return is a Tables.jl source for any sink + ef = load(filename, "Sheet1") + cols = Tables.columns(ef) + @test length(Tables.columnnames(cols)) == 13 + @test collect(skipmissing(Tables.getcolumn(cols, 1))) == [1., 1.5, 2., 2.5] + + dir = mktempdir() + + # save overwrites existing files by default (issue #24) + path = joinpath(dir, "overwrite.xlsx") + input = (a = [1.0, 2.0], b = ["x", "y"]) |> DataFrame + save(path, input) + save(path, input) + @test DataFrame(load(path, "Sheet1")) == input + @test_throws Exception save(path, input, overwrite = false) + + # save accepts plain Tables.jl sources that are not iterable tables + path2 = joinpath(dir, "tables.xlsx") + save(path2, Tables.table([1 2; 3 4])) + back = DataFrame(load(path2, "Sheet1")) + @test back[!, 1] == [1.0, 3.0] + @test back[!, 2] == [2.0, 4.0] + + # multi-sheet save (issue #38) + path3 = joinpath(dir, "multi.xlsx") + input2 = (c = [10.0, 20.0], d = ["u", "v"]) |> DataFrame + save(path3, "First" => input, "Second" => input2) + @test DataFrame(load(path3, "First")) == input + @test DataFrame(load(path3, "Second")) == input2 +end