From cd88fbbd105ae33a50b8443943feff9fc79d49bd Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Fri, 28 Aug 2026 18:03:10 -0700 Subject: [PATCH 1/3] Migrate to test item framework Migrate tests and CI to the test item framework, remove PkgButler, bump min Julia to 1.12, and modernize repo infrastructure. Co-Authored-By: Claude Fable 5 --- .github/dependabot.yml | 10 + .../jlpkgbutler-butler-dev-workflow.yml | 22 -- .../jlpkgbutler-ci-master-workflow.yml | 43 ---- .../workflows/jlpkgbutler-ci-pr-workflow.yml | 39 ---- .../jlpkgbutler-codeformat-pr-workflow.yml | 23 --- .../jlpkgbutler-compathelper-workflow.yml | 20 -- .../jlpkgbutler-docdeploy-workflow.yml | 23 --- .../workflows/jlpkgbutler-tagbot-workflow.yml | 17 -- .github/workflows/juliaci.yml | 17 ++ .gitignore | 5 + .jlpkgbutler.toml | 1 - LICENSE.md | 2 +- Project.toml | 23 ++- README.md | 35 ++-- docs/Project.toml | 2 +- docs/make.jl | 3 +- src/ExcelReaders.jl | 194 +++++++++++------- test/TestData.xlsx | Bin 0 -> 17767 bytes test/test_excelreaders.jl | 91 +++++++- 19 files changed, 270 insertions(+), 300 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/jlpkgbutler-butler-dev-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-ci-master-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-ci-pr-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-codeformat-pr-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-compathelper-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-docdeploy-workflow.yml delete mode 100644 .github/workflows/jlpkgbutler-tagbot-workflow.yml create mode 100644 .github/workflows/juliaci.yml delete mode 100644 .jlpkgbutler.toml create mode 100644 test/TestData.xlsx diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..4e00cd8bf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "julia" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/jlpkgbutler-butler-dev-workflow.yml b/.github/workflows/jlpkgbutler-butler-dev-workflow.yml deleted file mode 100644 index 4c6813360..000000000 --- a/.github/workflows/jlpkgbutler-butler-dev-workflow.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Run the Julia Package Butler - -on: - push: - branches: - - main - - master - schedule: - - cron: '*/5 * * * *' - workflow_dispatch: - -jobs: - butler: - name: "Run Package Butler" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: davidanthoff/julia-pkgbutler@releases/v1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - ssh-private-key: ${{ secrets.JLPKGBUTLER_TOKEN }} - channel: dev diff --git a/.github/workflows/jlpkgbutler-ci-master-workflow.yml b/.github/workflows/jlpkgbutler-ci-master-workflow.yml deleted file mode 100644 index 1f8ab264a..000000000 --- a/.github/workflows/jlpkgbutler-ci-master-workflow.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Run CI on main - -on: - push: - branches: - - main - - master - workflow_dispatch: - -jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - matrix: - julia-version: ['1.6', '1.7', '1.8', '1.9', '1.10', '1.11'] - julia-arch: [x64, x86] - os: [ubuntu-latest, windows-latest, macos-13] - exclude: - - os: macos-13 - julia-arch: x86 - - os: macos-13 - julia-version: "1.4" - - steps: - - uses: actions/checkout@v4 - - uses: julia-actions/setup-julia@v2 - with: - version: ${{ matrix.julia-version }} - arch: ${{ matrix.julia-arch }} - - uses: julia-actions/cache@v2 - - uses: julia-actions/julia-buildpkg@v1 - env: - PYTHON: "" - - uses: julia-actions/julia-runtest@v1 - env: - PYTHON: "" - - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v4 - with: - files: ./lcov.info - flags: unittests - token: ${{ secrets.CODECOV_TOKEN }} - \ No newline at end of file diff --git a/.github/workflows/jlpkgbutler-ci-pr-workflow.yml b/.github/workflows/jlpkgbutler-ci-pr-workflow.yml deleted file mode 100644 index 76683999a..000000000 --- a/.github/workflows/jlpkgbutler-ci-pr-workflow.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Run CI on PR - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - matrix: - julia-version: ['1.6', '1.7', '1.8', '1.9', '1.10', '1.11'] - julia-arch: [x64, x86] - os: [ubuntu-latest, windows-latest, macos-13] - exclude: - - os: macos-13 - julia-arch: x86 - - os: macos-13 - julia-version: "1.4" - - steps: - - uses: actions/checkout@v4 - - uses: julia-actions/setup-julia@v2 - with: - version: ${{ matrix.julia-version }} - arch: ${{ matrix.julia-arch }} - - uses: julia-actions/cache@v2 - - uses: julia-actions/julia-buildpkg@v1 - env: - PYTHON: "" - - uses: julia-actions/julia-runtest@v1 - env: - PYTHON: "" - - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v4 - with: - files: ./lcov.info - flags: unittests - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/jlpkgbutler-codeformat-pr-workflow.yml b/.github/workflows/jlpkgbutler-codeformat-pr-workflow.yml deleted file mode 100644 index d99a8e060..000000000 --- a/.github/workflows/jlpkgbutler-codeformat-pr-workflow.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Code Formatting - -on: - push: - branches: - - main - - master - workflow_dispatch: - -jobs: - format: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: julia-actions/julia-codeformat@releases/v1 - - name: Create Pull Request - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: Format files using DocumentFormat - title: '[AUTO] Format files using DocumentFormat' - body: '[DocumentFormat.jl](https://github.com/julia-vscode/DocumentFormat.jl) would suggest these formatting changes' - labels: no changelog diff --git a/.github/workflows/jlpkgbutler-compathelper-workflow.yml b/.github/workflows/jlpkgbutler-compathelper-workflow.yml deleted file mode 100644 index b31583129..000000000 --- a/.github/workflows/jlpkgbutler-compathelper-workflow.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Run CompatHelper - -on: - schedule: - - cron: '00 * * * *' - issues: - types: [opened, reopened] - workflow_dispatch: - -jobs: - CompatHelper: - name: "Run CompatHelper.jl" - runs-on: ubuntu-latest - steps: - - name: Pkg.add("CompatHelper") - run: julia -e 'using Pkg; Pkg.add("CompatHelper")' - - name: CompatHelper.main() - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: julia -e 'using CompatHelper; CompatHelper.main()' diff --git a/.github/workflows/jlpkgbutler-docdeploy-workflow.yml b/.github/workflows/jlpkgbutler-docdeploy-workflow.yml deleted file mode 100644 index 6656d97ff..000000000 --- a/.github/workflows/jlpkgbutler-docdeploy-workflow.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Deploy documentation - -on: - push: - branches: - - main - - master - tags: - - v* - workflow_dispatch: - -jobs: - docdeploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: julia-actions/julia-buildpkg@v1 - env: - PYTHON: "" - - uses: julia-actions/julia-docdeploy@latest - env: - DOCUMENTER_KEY: ${{ secrets.JLPKGBUTLER_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/jlpkgbutler-tagbot-workflow.yml b/.github/workflows/jlpkgbutler-tagbot-workflow.yml deleted file mode 100644 index d3ca956ea..000000000 --- a/.github/workflows/jlpkgbutler-tagbot-workflow.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: TagBot -on: - issue_comment: - types: - - created - workflow_dispatch: - -jobs: - TagBot: - if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot' - runs-on: ubuntu-latest - steps: - - uses: JuliaRegistries/TagBot@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - ssh: ${{ secrets.JLPKGBUTLER_TOKEN }} - branches: true diff --git a/.github/workflows/juliaci.yml b/.github/workflows/juliaci.yml new file mode 100644 index 000000000..6aa24b5de --- /dev/null +++ b/.github/workflows/juliaci.yml @@ -0,0 +1,17 @@ +name: Julia CI + +on: + push: {branches: [main,master]} + pull_request: {types: [opened,synchronize,reopened,ready_for_review,converted_to_draft]} + issue_comment: {types: [created]} + workflow_dispatch: {inputs: {feature: {type: choice, description: What to run, options: [DocDeploy,LintAndTest,TagBot]}}} + +jobs: + julia-ci: + uses: julia-testitems/testitem-workflow/.github/workflows/juliaci.yml@v2 + with: + include-all-compatible-minor-versions: true + include-rc-versions: true + permissions: write-all + secrets: + codecov_token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 28fa6a049..080f53dac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ *.jl.cov *.jl.mem .vscode +*.jl.*.cov +docs/build/ +docs/Manifest.toml +test-output*.* +Manifest.toml diff --git a/.jlpkgbutler.toml b/.jlpkgbutler.toml deleted file mode 100644 index b72304ff0..000000000 --- a/.jlpkgbutler.toml +++ /dev/null @@ -1 +0,0 @@ -template = "bach" diff --git a/LICENSE.md b/LICENSE.md index e21939a8e..9fecfc8ba 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The ExcelReaders.jl package is licensed under the MIT "Expat" License: -> Copyright (c) 2016-2019: David Anthoff. +> Copyright (c) 2016-2026: David Anthoff. > > Permission is hereby granted, free of charge, to any person obtaining > a copy of this software and associated documentation files (the diff --git a/Project.toml b/Project.toml index 7f6b7327d..c0bb8f1e6 100644 --- a/Project.toml +++ b/Project.toml @@ -1,22 +1,23 @@ name = "ExcelReaders" uuid = "c04bee98-12a5-510c-87df-2a230cb6e075" -version = "0.12.1-DEV" +version = "0.13.0-DEV" [deps] -Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" -Conda = "8f4d0f93-b110-5947-807f-2305c1781a2d" DataValues = "e7dc6d0d-1eca-5fa6-8ad6-5aecde8b7ea5" -PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +LibXLS = "221edcab-ec84-5148-84a2-7385855c517f" +XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" + +[compat] +DataValues = "0.4.4, 0.5" +Dates = "1" +LibXLS = "0.1, 0.2" +XLSX = "0.12" +julia = "1.12" [extras] -TestItemRunner = "f8b46487-2199-4994-9208-9a1283c18c0a" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" - -[compat] -julia = "1.6" -Conda = "1.9" -DataValues = "0.4.4" -PyCall = "1.96" +TestItemRunner = "f8b46487-2199-4994-9208-9a1283c18c0a" [targets] test = ["Test", "TestItemRunner"] diff --git a/README.md b/README.md index 61f8a89b5..3d2c04007 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,21 @@ # ExcelReaders -[![Build Status](https://travis-ci.org/queryverse/ExcelReaders.jl.svg?branch=master)](https://travis-ci.org/queryverse/ExcelReaders.jl) -[![Build status](https://ci.appveyor.com/api/projects/status/v7b60gfrg65qkqt5/branch/master?svg=true)](https://ci.appveyor.com/project/queryverse/excelreaders-jl/branch/master) -[![Coverage Status](https://coveralls.io/repos/queryverse/ExcelReaders.jl/badge.svg)](https://coveralls.io/r/queryverse/ExcelReaders.jl) -[![codecov](https://codecov.io/gh/queryverse/ExcelReaders.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/queryverse/ExcelReaders.jl) +[![Build Status](https://github.com/queryverse/ExcelReaders.jl/actions/workflows/juliaci.yml/badge.svg?branch=main)](https://github.com/queryverse/ExcelReaders.jl/actions/workflows/juliaci.yml) ExcelReaders is a package that provides functionality to read Excel files. -**WARNING**: Version v0.12 removed support for modern Excel files. This package is now _only_ supporting legacy xls files. The reason for this is that the underlying Python package made that move a couple of years ago as well. - -The [XLSX.jl](https://github.com/felipenoris/XLSX.jl) provides excellent support for modern Excel files. +Both legacy xls files (Excel 97-2003) and modern xlsx files are supported. +The file format is detected from the content of the file, not its extension. +Under the hood legacy files are read via +[LibXLS.jl](https://github.com/queryverse/LibXLS.jl) (a wrapper of the +[libxls](https://github.com/libxls/libxls) C library) and modern files via +[XLSX.jl](https://github.com/JuliaData/XLSX.jl), so the package has no Python +or Java dependency. ## Installation Use ``Pkg.add("ExcelReaders")`` in Julia to install ExcelReaders and its dependencies. -The package uses the Python xlrd library. If either Python or the xlrd package are not installed on your Mac or Windows system, the package will use the [Conda.jl](https://github.com/Luthaf/Conda.jl) package to install all necessary dependencies automatically. If you are on another system you can either install Python and xlrd yourself or instruct PyCall to use Conda.jl to manage its own python install (`ENV["PYTHON"]=""; Pkg.build("PyCall")` and restart Julia). - -## Alternatives - -The [XLSX.jl](https://github.com/felipenoris/XLSX.jl) provides excellent support for modern Excel files. - -The [Taro](https://github.com/aviks/Taro.jl) package also provides Excel file reading functionality. The main difference between the two packages (in terms of Excel functionality) is that ExcelReaders uses the Python package [xlrd](https://github.com/python-excel/xlrd) for its processing, whereas Taro uses the Java packages Apache [Tika](http://tika.apache.org/) and Apache [POI](http://poi.apache.org/). - ## Basic usage The most basic usage is this: @@ -33,9 +26,11 @@ using ExcelReaders data = readxl("Filename.xls", "Sheet1!A1:C4") ```` -This will return an array with all the data in the cell range A1 to C4 on Sheet1 in the Excel file Filename.xls. +This will return an array with all the data in the cell range A1 to C4 on +Sheet1 in the Excel file Filename.xls. -If you expect to read multiple ranges from the same Excel file you can get much better performance by opening the Excel file only once: +If you expect to read multiple ranges from the same Excel file you can get much +better performance by opening the Excel file only once: ````julia using ExcelReaders @@ -64,3 +59,9 @@ This will read all content on Sheet1 in the file Filename.xls. Eventual blank ro - ``ncols`` accepts either ``:all`` (default) or a postiive integer. With ``:all``, all columns (except skipped ones) are read. An integer specifies the exact number of columns to be read. ``readxlsheet`` also accepts an ExcelFile (as obtained from ``openxl``) as its first argument. + +## Alternatives + +[XLSX.jl](https://github.com/JuliaData/XLSX.jl) provides excellent, more +fully-featured support for modern Excel files (including write support), and +is used by this package as its xlsx backend. diff --git a/docs/Project.toml b/docs/Project.toml index f2a273e56..1814eb330 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -2,4 +2,4 @@ Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" [compat] -Documenter = "~0.24" +Documenter = "1" diff --git a/docs/make.jl b/docs/make.jl index 29bef7baa..a75e3ba4f 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -2,7 +2,8 @@ using Documenter, ExcelReaders makedocs(modules = [ExcelReaders], sitename = "ExcelReaders.jl", - analytics = "UA-132838790-1", + format = Documenter.HTML(analytics = "UA-132838790-1"), + warnonly = [:missing_docs], pages = [ "Introduction" => "index.md" ]) diff --git a/src/ExcelReaders.jl b/src/ExcelReaders.jl index 4d9f7a54d..22845c77a 100644 --- a/src/ExcelReaders.jl +++ b/src/ExcelReaders.jl @@ -1,17 +1,13 @@ module ExcelReaders -using PyCall, DataValues, Dates +using DataValues, Dates -export openxl, readxl, readxlsheet, ExcelErrorCell, ExcelFile, readxlnames, readxlrange +import LibXLS, XLSX -const xlrd = PyNULL() +export openxl, readxl, readxlsheet, ExcelErrorCell, ExcelFile, readxlnames, readxlrange include("package_documentation.jl") -function __init__() - copy!(xlrd, pyimport_conda("xlrd", "xlrd")) -end - """ ExcelFile @@ -20,8 +16,8 @@ A handle to an open Excel file. You can create an instance of an ``ExcelFile`` by calling ``openxl``. """ mutable struct ExcelFile - workbook::PyObject - filename::AbstractString + workbook::Union{LibXLS.Workbook,XLSX.XLSXFile} + filename::String end """ @@ -30,20 +26,36 @@ end An Excel cell that has an Excel error. You cannot create ``ExcelErrorCell`` objects, they are returned if a cell in an -Excel file has an Excel error. +Excel file has an Excel error. ``errorcode`` is the BIFF error code. """ mutable struct ExcelErrorCell errorcode::Int end +const ERROR_CODE_TEXTS = Dict{Int,String}( + 0 => "#NULL!", + 7 => "#DIV/0!", + 15 => "#VALUE!", + 23 => "#REF!", + 29 => "#NAME?", + 36 => "#NUM!", + 42 => "#N/A", + 43 => "#GETTING_DATA", +) + +const ERROR_TEXT_CODES = Dict{String,Int}(v => k for (k, v) in ERROR_CODE_TEXTS) + function Base.show(io::IO, o::ExcelFile) print(io, "ExcelFile <$(o.filename)>") end function Base.show(io::IO, o::ExcelErrorCell) - print(io, xlrd.error_text_from_code[o.errorcode]) + print(io, get(ERROR_CODE_TEXTS, o.errorcode, "#ERROR($(o.errorcode))")) end +const OLE2_FILE_HEADER = [0xd0, 0xcf, 0x11, 0xe0] # legacy xls (BIFF) +const ZIP_FILE_HEADER = [0x50, 0x4b, 0x03, 0x04] # xlsx (OOXML) + """ openxl(filename) @@ -54,6 +66,9 @@ The returned ``ExcelFile`` handle can later be passed as the first argument to of those functions more than once, performance will be better if you open the file only once with ``openxl``. +Both legacy xls files and modern xlsx files are supported; the file format is +detected from the content of the file, not its extension. + # Example ````julia f = openxl("filename.xls") @@ -61,18 +76,85 @@ data = readxl(f, "Sheet1!A1:C4") ```` """ function openxl(filename::AbstractString) - wb = xlrd.open_workbook(filename) - return ExcelFile(wb, basename(filename)) + isfile(filename) || error("File $filename not found.") + + header = open(io -> Base.read(io, 4), filename) + + if header == OLE2_FILE_HEADER + return ExcelFile(LibXLS.openxls(filename), basename(filename)) + elseif header == ZIP_FILE_HEADER + return ExcelFile(XLSX.readxlsx(filename), basename(filename)) + else + error("$filename is not a valid Excel file.") + end +end + +function Base.close(file::ExcelFile) + file.workbook isa LibXLS.Workbook && close(file.workbook) + return nothing +end + +sheetnames(file::ExcelFile) = sheetnames(file.workbook) +sheetnames(wb::LibXLS.Workbook) = LibXLS.sheetnames(wb) +sheetnames(wb::XLSX.XLSXFile) = XLSX.sheetnames(wb) + +sheet_handle(file::ExcelFile, sheetname::AbstractString) = sheet_handle(file.workbook, sheetname) + +function sheet_handle(wb::LibXLS.Workbook, sheetname::AbstractString) + LibXLS.is_valid_sheetname(wb, sheetname) || error("Sheet $sheetname not found.") + return LibXLS.getworksheet(wb, sheetname) +end + +function sheet_handle(wb::XLSX.XLSXFile, sheetname::AbstractString) + XLSX.hassheet(wb, sheetname) || error("Sheet $sheetname not found.") + return wb[sheetname] +end + +sheet_dims(ws::LibXLS.Worksheet) = size(ws) + +function sheet_dims(ws::XLSX.Worksheet) + dim = XLSX.get_dimension(ws) + dim === nothing && return (0, 0) + return (dim.stop.row_number, dim.stop.column_number) +end + +# Map a backend cell value into the ExcelReaders vocabulary: NA for blank +# cells (and cells holding an empty string, as in previous versions), Float64 +# for all numbers, String, Bool, DateTime, Time and ExcelErrorCell. +normalize_value(::Missing) = NA +normalize_value(v::AbstractString) = isempty(v) ? NA : String(v) +normalize_value(v::Bool) = v +normalize_value(v::Real) = Float64(v) +normalize_value(v::Date) = DateTime(v) +normalize_value(v::LibXLS.CellError) = ExcelErrorCell(Int(v.code)) +normalize_value(v) = v + +function cell_value(ws::LibXLS.Worksheet, row::Integer, col::Integer) + nrows, ncols = size(ws) + (1 <= row <= nrows && 1 <= col <= ncols) || return NA + return normalize_value(ws[row, col]) end +function cell_value(ws::XLSX.Worksheet, row::Integer, col::Integer) + (1 <= row && 1 <= col) || return NA + cell = XLSX.getcell(ws, XLSX.CellRef(row, col)) + cell isa XLSX.EmptyCell && return NA + if XLSX.iserror(cell) + errortext = XLSX.get_error_string(XLSX.getval(cell)) + return ExcelErrorCell(get(ERROR_TEXT_CODES, errortext, -1)) + end + return normalize_value(XLSX.getdata(ws, cell)) +end + +isblank(v) = v isa DataValue && DataValues.isna(v) + function readxlsheet(filename::AbstractString, sheetindex::Int; args...) file = openxl(filename) return readxlsheet(file, sheetindex; args...) end function readxlsheet(file::ExcelFile, sheetindex::Int; args...) - sheetnames = file.workbook.sheet_names() - return readxlsheet(file, sheetnames[sheetindex]; args...) + return readxlsheet(file, sheetnames(file)[sheetindex]; args...) end function readxlsheet(filename::AbstractString, sheetname::AbstractString; args...) @@ -81,16 +163,14 @@ function readxlsheet(filename::AbstractString, sheetname::AbstractString; args.. end function readxlsheet(file::ExcelFile, sheetname::AbstractString; args...) - sheet = file.workbook.sheet_by_name(sheetname) - startrow, startcol, endrow, endcol = convert_args_to_row_col(sheet; args...) - - data = readxl_internal(file, sheetname, startrow, startcol, endrow, endcol) + ws = sheet_handle(file, sheetname) + startrow, startcol, endrow, endcol = convert_args_to_row_col(ws; args...) - return data + return readxl_internal(ws, startrow, startcol, endrow, endcol) end # Function converts "relative" range like skip rows/cols and size of range to "absolute" from row/col to row/col -function convert_args_to_row_col(sheet;skipstartrows::Union{Int,Symbol} = :blanks, skipstartcols::Union{Int,Symbol} = :blanks, nrows::Union{Int,Symbol} = :all, ncols::Union{Int,Symbol} = :all) +function convert_args_to_row_col(ws; skipstartrows::Union{Int,Symbol}=:blanks, skipstartcols::Union{Int,Symbol}=:blanks, nrows::Union{Int,Symbol}=:all, ncols::Union{Int,Symbol}=:all) isa(skipstartrows, Symbol) && skipstartrows != :blanks && error("Only :blank or an integer is a valid argument for skipstartrows") isa(skipstartrows, Int) && skipstartrows < 0 && error("Can't skip a negative number of rows") isa(skipstartcols, Symbol) && skipstartcols != :blanks && error("Only :blank or an integer is a valid argument for skipstartcols") @@ -99,16 +179,13 @@ function convert_args_to_row_col(sheet;skipstartrows::Union{Int,Symbol} = :blank isa(nrows, Int) && nrows < 0 && error("nrows should be :all or positive") isa(ncols, Symbol) && ncols != :all && error("Only :all or an integer is a valid argument for ncols") isa(ncols, Int) && ncols < 0 && error("ncols should be :all or positive") - sheet_rows = sheet.nrows - sheet_cols = sheet.ncols - cell_value = sheet.cell_value + sheet_rows, sheet_cols = sheet_dims(ws) if skipstartrows == :blanks startrow = -1 for cur_row in 1:sheet_rows, cur_col in 1:sheet_cols - cellval = cell_value(cur_row - 1, cur_col - 1) - if cellval != "" + if !isblank(cell_value(ws, cur_row, cur_col)) startrow = cur_row break end @@ -125,8 +202,7 @@ function convert_args_to_row_col(sheet;skipstartrows::Union{Int,Symbol} = :blank if skipstartcols == :blanks startcol = -1 for cur_col in 1:sheet_cols, cur_row in 1:sheet_rows - cellval = cell_value(cur_row - 1, cur_col - 1) - if cellval != "" + if !isblank(cell_value(ws, cur_row, cur_col)) startcol = cur_col break end @@ -167,18 +243,18 @@ end function convert_ref_to_sheet_row_col(range::AbstractString) r = r"('?[^']+'?|[^!]+)!([A-Za-z]*)(\d*)(:([A-Za-z]*)(\d*))?" m = match(r, range) - m == nothing && error("Invalid Excel range specified.") + m === nothing && error("Invalid Excel range specified.") sheetname = String(m.captures[1]) startrow = parse(Int, m.captures[3]) startcol = colnum(m.captures[2]) - if m.captures[4] == nothing + if m.captures[4] === nothing endrow = startrow endcol = startcol else endrow = parse(Int, m.captures[6]) endcol = colnum(m.captures[5]) end - if (startrow > endrow ) || (startcol > endcol) + if (startrow > endrow) || (startcol > endcol) error("Please provide rectangular region from top left to bottom right corner") end return sheetname, startrow, startcol, endrow, endcol @@ -192,49 +268,23 @@ end function readxl(file::ExcelFile, range::AbstractString) sheetname, startrow, startcol, endrow, endcol = convert_ref_to_sheet_row_col(range) - readxl_internal(file, sheetname, startrow, startcol, endrow, endcol) -end - -function get_cell_value(ws, row, col, wb) - cellval = ws.cell_value(row - 1, col - 1) - if cellval == "" - return NA - else - celltype = ws.cell_type(row - 1, col - 1) - if celltype == xlrd.XL_CELL_TEXT - return convert(String, cellval) - elseif celltype == xlrd.XL_CELL_NUMBER - return convert(Float64, cellval) - elseif celltype == xlrd.XL_CELL_DATE - date_year, date_month, date_day, date_hour, date_minute, date_sec = xlrd.xldate_as_tuple(cellval, wb.datemode) - if date_month == 0 - return Time(date_hour, date_minute, date_sec) - else - return DateTime(date_year, date_month, date_day, date_hour, date_minute, date_sec) - end - elseif celltype == xlrd.XL_CELL_BOOLEAN - return convert(Bool, cellval) - elseif celltype == xlrd.XL_CELL_ERROR - return ExcelErrorCell(cellval) - else - error("Unknown cell type") - end - end + ws = sheet_handle(file, sheetname) + readxl_internal(ws, startrow, startcol, endrow, endcol) end function readxl_internal(file::ExcelFile, sheetname::AbstractString, startrow::Integer, startcol::Integer, endrow::Integer, endcol::Integer) - wb = file.workbook - ws = wb.sheet_by_name(sheetname) + return readxl_internal(sheet_handle(file, sheetname), startrow, startcol, endrow, endcol) +end +function readxl_internal(ws, startrow::Integer, startcol::Integer, endrow::Integer, endcol::Integer) if startrow == endrow && startcol == endcol - return get_cell_value(ws, startrow, startcol, wb) + return cell_value(ws, startrow, startcol) else - data = Array{Any}(undef, endrow - startrow + 1, endcol - startcol + 1) for row in startrow:endrow for col in startcol:endcol - data[row - startrow + 1, col - startcol + 1] = get_cell_value(ws, row, col, wb) + data[row - startrow + 1, col - startcol + 1] = cell_value(ws, row, col) end end @@ -243,20 +293,14 @@ function readxl_internal(file::ExcelFile, sheetname::AbstractString, startrow::I end function readxlnames(f::ExcelFile) - return [lowercase(i.name) for i in f.workbook.name_obj_list if i.hidden == 0] + f.workbook isa XLSX.XLSXFile || error("Defined names are not supported for legacy xls files.") + return sort!(collect(keys(f.workbook.workbook.workbook_names))) end function readxlrange(f::ExcelFile, range::AbstractString) - name = f.workbook.name_map[lowercase(range)] - if length(name) != 1 - error("More than one reference per name, this case is not yet handled by ExcelReaders.") - end - - formula_text = name[1].formula_text - formula_text = replace(formula_text, "\$" => "") - formula_text = replace(formula_text, "'" => "") - - return readxl(f, formula_text) + f.workbook isa XLSX.XLSXFile || error("Defined names are not supported for legacy xls files.") + data = XLSX.getdata(f.workbook, range) + return data isa AbstractArray ? map(normalize_value, data) : normalize_value(data) end end # module diff --git a/test/TestData.xlsx b/test/TestData.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..817ae13911eadce4063a26df26c660395020e97d GIT binary patch literal 17767 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 EFGxw%9RL6T literal 0 HcmV?d00001 diff --git a/test/test_excelreaders.jl b/test/test_excelreaders.jl index f28ed9739..115d38851 100644 --- a/test/test_excelreaders.jl +++ b/test/test_excelreaders.jl @@ -1,16 +1,13 @@ @testitem "ExcelReaders" begin - using Dates, PyCall, DataValues + using Dates, DataValues -# TODO Throw julia specific exceptions for these errors - @test_throws PyCall.PyError openxl("FileThatDoesNotExist.xls") - @test_throws PyCall.PyError openxl("runtests.jl") + @test_throws ErrorException openxl("FileThatDoesNotExist.xls") + @test_throws ErrorException openxl(normpath(@__DIR__, "runtests.jl")) filename = normpath(@__DIR__, "TestData.xls") file = openxl(filename) @test file.filename == "TestData.xls" - buffer = IOBuffer() - @test sprint(show, file) == "ExcelFile " for (k, v) in Dict(0 => "#NULL!", 7 => "#DIV/0!", 23 => "#REF!", 42 => "#N/A", 29 => "#NAME?", 36 => "#NUM!", 15 => "#VALUE!") @@ -112,3 +109,85 @@ end end + +@testitem "ExcelReaders xlsx backend" begin + using Dates, DataValues + + filename = normpath(@__DIR__, "TestData.xlsx") + + file = openxl(filename) + @test file.filename == "TestData.xlsx" + @test sprint(show, file) == "ExcelFile " + + # TestData.xlsx holds the same content as TestData.xls, so the very same + # assertions must hold for both backends. + for f in [file, filename] + @test_throws ErrorException readxl(f, "Sheet1!C4:G3") + @test_throws ErrorException readxl(f, "Sheet1!G2:B5") + @test_throws ErrorException readxl(f, "Sheet1!G5:B2") + + data = readxl(f, "Sheet1!C3:N7") + @test size(data) == (5, 12) + @test data[4,1] == 2.0 + @test data[2,2] == "A" + @test data[2,3] == true + @test DataValues.isna(data[4,5]) + @test data[2,9] == Date(2015, 3, 3) + @test data[2,9] isa DateTime # backend-independent type vocabulary + @test data[3,9] == DateTime(2015, 2, 4, 10, 14) + @test data[4,9] == DateTime(1988, 4, 9, 0, 0) + @test data[5,9] == Time(15, 2, 0) + @test data[3,10] == DateTime(1950, 8, 9, 18, 40) + @test DataValues.isna(data[5,10]) + @test isa(data[2,11], ExcelErrorCell) + @test isa(data[3,11], ExcelErrorCell) + @test isa(data[4,12], ExcelErrorCell) + @test DataValues.isna(data[5,12]) + + # single cell read + @test readxl(f, "Sheet1!C4") == 1.0 + + @test_throws ErrorException readxlsheet(f, "Empty Sheet") + + data = readxlsheet(f, "Second Sheet") + @test size(data) == (6, 6) + @test data[2,1] == 1. + @test data[5,2] == "CCC" + @test data[3,3] == false + @test data[6,6] == Time(15, 2, 00) + @test DataValues.isna(data[4,3]) + @test DataValues.isna(data[4,6]) + end +end + +@testitem "Cross-backend parity" begin + using Dates, DataValues + + # The two test files hold the same content in the two file formats, so + # every cell in the shared range must come back identical from both + # backends. + xls = openxl(normpath(@__DIR__, "TestData.xls")) + xlsx = openxl(normpath(@__DIR__, "TestData.xlsx")) + + function compare_cells(data_xls, data_xlsx) + @test size(data_xls) == size(data_xlsx) + for i in eachindex(data_xls) + a, b = data_xls[i], data_xlsx[i] + if a isa ExcelErrorCell + @test b isa ExcelErrorCell + @test a.errorcode == b.errorcode + elseif a isa DataValue + @test b isa DataValue && DataValues.isna(b) + else + @test typeof(a) == typeof(b) + @test a == b + end + end + end + + compare_cells(readxl(xls, "Sheet1!C3:N7"), readxl(xlsx, "Sheet1!C3:N7")) + + for sheet in ["Second Sheet", 2] + compare_cells(readxlsheet(xls, sheet), readxlsheet(xlsx, sheet)) + end +end From 6c18b6e76469b3d78a993b8416fb384c21ae5a4f Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Fri, 28 Aug 2026 18:20:35 -0700 Subject: [PATCH 2/3] Bump version to 1.0.0-DEV Move the package off 0.x to a proper 1.0 major version, and accept 1.0 versions of Queryverse sibling packages in compat. Co-Authored-By: Claude Fable 5 --- Project.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Project.toml b/Project.toml index c0bb8f1e6..69e2e125b 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "ExcelReaders" uuid = "c04bee98-12a5-510c-87df-2a230cb6e075" -version = "0.13.0-DEV" +version = "1.0.0-DEV" [deps] DataValues = "e7dc6d0d-1eca-5fa6-8ad6-5aecde8b7ea5" @@ -9,9 +9,9 @@ LibXLS = "221edcab-ec84-5148-84a2-7385855c517f" XLSX = "fdbf4ff8-1666-58a4-91e7-1b58723a45e0" [compat] -DataValues = "0.4.4, 0.5" +DataValues = "0.4.4, 0.5, 1" Dates = "1" -LibXLS = "0.1, 0.2" +LibXLS = "0.1, 0.2, 1" XLSX = "0.12" julia = "1.12" From 85f3c6af7ad27d5bf368ad62447170810a5aaa7a Mon Sep 17 00:00:00 2001 From: David Anthoff Date: Fri, 28 Aug 2026 21:02:41 -0700 Subject: [PATCH 3/3] Restore original Julia compat range Keep supporting the Julia versions the package supported before the test item migration instead of raising the floor to 1.12. Co-Authored-By: Claude Fable 5 --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 69e2e125b..667eaf495 100644 --- a/Project.toml +++ b/Project.toml @@ -13,7 +13,7 @@ DataValues = "0.4.4, 0.5, 1" Dates = "1" LibXLS = "0.1, 0.2, 1" XLSX = "0.12" -julia = "1.12" +julia = "1.6" [extras] Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"