calx 0.1.0-aarch64-linux-musl

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 140561c3ddd5e54b185453902865df6117dddeedfba08114064b4c0140d693b9
4
+ data.tar.gz: a81fd382888e85d2aa60914666433b46289d65fe7cb20be255a8a4acf7cfe433
5
+ SHA512:
6
+ metadata.gz: 18d2e0ebf146822682362e334d6a8ded014f0f5cf7b2deae5359bb04588212b2a6038a38129a09d49aca9c5f3a4563097ddb34b6026579459be704c41bb11a11
7
+ data.tar.gz: 8248f680ce6c5c202e3e9bc49a8f82cb3555c0dc2864a9710e63686f5f95c2abe9597cbf1f02f71da24a0a59f0107d1395b7dcea30c0b5a5528244b46f62da6b
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-11-05
6
+
7
+ - First release: `Calx.each_row`, `Calx.read` and `Calx.sheet_names` for CSV, TSV, xlsx, xlsm, xlsb, xls and ods files.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Zac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # calx
2
+
3
+ [![CI](https://github.com/7a6163/calx/actions/workflows/main.yml/badge.svg)](https://github.com/7a6163/calx/actions/workflows/main.yml)
4
+ [![codecov](https://codecov.io/gh/7a6163/calx/graph/badge.svg)](https://codecov.io/gh/7a6163/calx)
5
+
6
+ Read CSV, TSV and spreadsheets (xlsx, xlsm, xlsb, xls, ods) row by row from Ruby with a Rust engine ([csv](https://github.com/BurntSushi/rust-csv) and [calamine](https://github.com/tafia/calamine), via [magnus](https://github.com/matsadler/magnus)). The CSV reader is 4–16× faster than Ruby's `CSV`, and the xlsx reader 10–27× faster than simple_xlsx_reader, roo and creek ([benchmarks](#performance)).
7
+
8
+ It only reads. To write xlsx files, see its sibling [fast_xlsx](https://github.com/7a6163/fast_xlsx).
9
+
10
+ > **Status: 0.x.** The API may still change before 1.0.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ bundle add calx
16
+ ```
17
+
18
+ Requires CRuby 3.3 or later. Installing from source needs a Rust toolchain.
19
+
20
+ ## Usage
21
+
22
+ ```ruby
23
+ require "calx"
24
+
25
+ # One Array per row; the format comes from the file extension.
26
+ Calx.each_row("sales.csv") { |row| p row } # ["North", "120", "9.5"]
27
+ Calx.each_row("sales.xlsx") { |row| p row } # ["North", 120, 9.5, #<Date 2026-07-03>]
28
+
29
+ rows = Calx.read("sales.xlsx", sheet: "July") # every row at once
30
+ Calx.sheet_names("sales.xlsx") # ["June", "July"]
31
+ Calx.each_row("big.csv").first(10) # an Enumerator without a block
32
+ ```
33
+
34
+ Options:
35
+
36
+ | Option | For | |
37
+ |---|---|---|
38
+ | `format:` | all | `:csv`, `:tsv`, `:xlsx`, `:xlsm`, `:xlsb`, `:xls` or `:ods`, for files whose extension doesn't tell (uploads saved as temp files) |
39
+ | `sheet:` | spreadsheets | the sheet's index (from 0) or name; the first sheet by default |
40
+ | `col_sep:` | CSV | a one-byte separator; `","` (`"\t"` for `.tsv`) by default |
41
+ | `encoding:` | CSV | the file's encoding, such as `"Big5"` or `"Shift_JIS"`; UTF-8 by default |
42
+
43
+ `path` can be a String or anything with `#to_path` (Pathname, File, Tempfile, Rails' `UploadedFile`).
44
+
45
+ ### What you get back
46
+
47
+ - **CSV**: every cell is a String, as with Ruby's `CSV`. A UTF-8 byte order mark is skipped. Rows can have different lengths. A file that isn't valid UTF-8 raises `Calx::Error`, with a hint to pass `encoding:`.
48
+ - **Spreadsheets**: String, Integer, Float, `true`/`false`, `Date`, `Time` or `nil`.
49
+ - Whole numbers come back as Integer (xlsx stores `42` and `42.0` the same way).
50
+ - A date with no time of day is a `Date`; one with a time is a local `Time` (Excel stores no time zone).
51
+ - Durations (`[h]:mm` formats) are the Float number of days Excel stores; in ods files they are ISO 8601 Strings such as `"PT10H10M10S"`.
52
+ - Formulas give the value Excel last saved. Error cells give their text, such as `"#N/A"`.
53
+ - Rows count from the sheet's first row and `row[0]` is always column A, even when the data starts lower: rows above it are `[]` and cells left of it `nil`. Every other row is padded with `nil` to the width of the used range.
54
+
55
+ ### Errors
56
+
57
+ `TypeError` for an argument of the wrong type, `ArgumentError` for a value that isn't allowed (an unknown format, sheet name or encoding), `IndexError` for a sheet index past the last sheet, `Errno::ENOENT` for a missing file, and `Calx::Error` for a file that can't be read.
58
+
59
+ ### Limits
60
+
61
+ - Read only: no styles, comments or merged-cell ranges, and no editing an existing file.
62
+ - A spreadsheet's sheet is loaded into memory whole before its rows are yielded. A CSV file is read as it goes, except with `encoding:`, which reads the whole file to transcode it.
63
+
64
+ ## Performance
65
+
66
+ 200,000 rows read with each library, Ruby 3.4.11 with YJIT, Apple Silicon (arm64). Best of 5 runs for CSV, 3 for xlsx; every run must read all 200,000 rows. Lower is better.
67
+
68
+ **CSV, 10 columns of numbers and short strings** (12.5 MB)
69
+
70
+ | Library | Method | Time | vs calx |
71
+ |---|---|---:|---:|
72
+ | **calx** 0.1.0 | `Calx.each_row` | **51 ms** | 1.0× |
73
+ | **calx** 0.1.0 | `Calx.read` | **49 ms** | 1.0× |
74
+ | csv 3.3.7 | `CSV.foreach` | 231 ms | 4.5× slower |
75
+ | csv 3.3.7 | `CSV.read` | 327 ms | 6.4× slower |
76
+
77
+ **CSV, 10 columns with quotes, commas, newlines and CJK** (26.2 MB)
78
+
79
+ | Library | Method | Time | vs calx |
80
+ |---|---|---:|---:|
81
+ | **calx** 0.1.0 | `Calx.each_row` | **64 ms** | 1.0× |
82
+ | **calx** 0.1.0 | `Calx.read` | **64 ms** | 1.0× |
83
+ | csv 3.3.7 | `CSV.foreach` | 865 ms | 13.4× slower |
84
+ | csv 3.3.7 | `CSV.read` | 1,012 ms | 15.7× slower |
85
+
86
+ **xlsx, 5 columns: integer, string, float, date, string** (6.0 MB, written by fast_xlsx)
87
+
88
+ | Library | Method | Time | vs calx |
89
+ |---|---|---:|---:|
90
+ | **calx** 0.1.0 | `Calx.each_row` | **222 ms** | 1.0× |
91
+ | fastsheet 0.2.0 (GitHub) | `Sheet.new(path).rows` | 281 ms | 1.3× slower |
92
+ | simple_xlsx_reader 5.1.0 | `rows.each` | 2,411 ms | 10.8× slower |
93
+ | roo 4.0.0 | `each_row_streaming` | 5,117 ms | 23.0× slower |
94
+ | creek 2.6.3 | `simple_rows` | 6,014 ms | 27.0× slower |
95
+
96
+ Ruby's `CSV` slows down most on quoted fields, which real exports and uploads usually have. The libraries don't return quite the same thing: roo yields cell objects and creek yields a Hash per row, while calx and simple_xlsx_reader yield Arrays of values.
97
+
98
+ fastsheet also uses calamine, so it is close on speed, but it builds every row before returning any and its values differ: whole numbers come back as Float (`1.0`), dates as a `Time` at midnight UTC (so `2026-01-02 08:00 +0800` in Taipei, and the previous day west of UTC), and error cells as `nil`. It reads the first sheet only and no CSV. Its current version is not on RubyGems (0.1.1 there is from 2017), so the benchmark includes it only when it is installed from [GitHub](https://github.com/dmk/fastsheet).
99
+
100
+ Most of calx's time goes into making the Ruby Strings and Arrays: parsing alone is about 5× faster again. Times move by 10–30% between runs, so treat the ratios as rough. Run it yourself:
101
+
102
+ ```bash
103
+ bundle exec rake compile
104
+ BUNDLE_GEMFILE=bench/Gemfile bundle install
105
+ BUNDLE_GEMFILE=bench/Gemfile bundle exec ruby --yjit bench/read.rb [rows]
106
+ ```
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ bin/setup
112
+ bundle exec rake # compile + test + rubocop
113
+ bundle exec rbs -r date -I sig validate
114
+ cargo clippy --manifest-path ext/calx/Cargo.toml --all-targets -- -D warnings
115
+ ```
116
+
117
+ All behaviour is tested from Ruby (`test/`); there are no Rust unit tests.
118
+
119
+ Coverage of the Rust extension while the Ruby tests run (needs [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov) and `rustup component add llvm-tools-preview`). CI uploads it to Codecov.
120
+
121
+ ```bash
122
+ rm -rf tmp lib/calx/calx.bundle # force an instrumented rebuild
123
+ eval "$(cargo llvm-cov show-env --sh)"
124
+ bundle exec rake compile test
125
+ cargo llvm-cov report --release # or --lcov / --html
126
+ ```
127
+
128
+ Rebuild in a clean shell afterwards (`rm -rf tmp && bundle exec rake compile`) so the everyday build is not instrumented.
129
+
130
+ Mutation tests ([cargo-mutants](https://mutants.rs)) change the Rust source one small edit at a time and check that a Ruby test fails for each change. `ext/calx/tests/ruby_suite.rs` connects the two: it rebuilds the extension and runs the Ruby suite, so `cargo test` covers the Ruby tests too. Mutants that can't change behaviour are excluded in `.cargo/mutants.toml`.
131
+
132
+ ```bash
133
+ cargo install cargo-mutants
134
+ cargo mutants --file ext/calx/src/lib.rs --jobs 4 # ~1.5 minutes; results in mutants.out/
135
+ ```
136
+
137
+ Mutants listed in `mutants.out/missed.txt` point at behaviour no test checks. The `Mutation tests` workflow runs this weekly and on demand. Dependabot opens weekly update PRs for the gems, crates and GitHub Actions.
138
+
139
+ The xls, xlsb, ods and some xlsx files in `test/fixtures` come from calamine's test suite (MIT).
140
+
141
+ Releasing: bump `lib/calx/version.rb`, update the CHANGELOG, and push a `vX.Y.Z` tag. The `Build gems` workflow builds the platform gems and publishes them to RubyGems.
142
+
143
+ ## License
144
+
145
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ require "rb_sys/extensiontask"
13
+
14
+ task build: :compile
15
+
16
+ GEMSPEC = Gem::Specification.load("calx.gemspec")
17
+
18
+ RbSys::ExtensionTask.new("calx", GEMSPEC) do |ext|
19
+ ext.lib_dir = "lib/calx"
20
+ end
21
+
22
+ task default: %i[compile test rubocop]
Binary file
Binary file
Binary file
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calx
4
+ # The gem version.
5
+ VERSION = "0.1.0"
6
+ end
data/lib/calx.rb ADDED
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require_relative "calx/version"
6
+
7
+ # Reads CSV, TSV and spreadsheets (xlsx, xlsm, xlsb, xls, ods) row by row
8
+ # with a Rust core (the csv and calamine crates).
9
+ #
10
+ # Errors: TypeError for an argument of the wrong type, ArgumentError for a
11
+ # value that isn't allowed, IndexError for a sheet index past the last sheet,
12
+ # Errno::ENOENT for a missing file, and Calx::Error for a file that
13
+ # can't be read.
14
+ module Calx
15
+ # Raised when a file can't be read: corrupt, the wrong format, or (CSV) not
16
+ # in the encoding given.
17
+ class Error < StandardError; end
18
+
19
+ # The native extension looks up Calx::Error, so load it after Error is defined.
20
+ # Precompiled gems ship one binary per Ruby version (calx/3.4/calx);
21
+ # a gem compiled on install has a single calx/calx.
22
+ begin
23
+ require "calx/#{RUBY_VERSION[/\d+\.\d+/]}/calx"
24
+ rescue LoadError
25
+ require "calx/calx"
26
+ end
27
+
28
+ # File extensions and the format each one is read as.
29
+ FORMATS = {
30
+ ".csv" => :csv, ".tsv" => :tsv,
31
+ ".xlsx" => :xlsx, ".xlsm" => :xlsm, ".xlsb" => :xlsb, ".xls" => :xls, ".ods" => :ods
32
+ }.freeze
33
+
34
+ class << self
35
+ # Yields each row of a sheet as an Array.
36
+ #
37
+ # CSV and TSV cells are Strings. Spreadsheet cells are String, Integer,
38
+ # Float, true/false, Date (a date with no time of day), Time (local time:
39
+ # Excel stores no zone) or nil (empty); error cells are their text, such as
40
+ # "#N/A", and formulas give the value Excel last saved. Row 0 is the
41
+ # sheet's first row and row[0] its column A, even when the data starts lower.
42
+ #
43
+ # @param path [String, #to_path] the file (a Pathname, File or Tempfile works)
44
+ # @param format [Symbol, nil] one of the values of {FORMATS}; by default
45
+ # taken from the file extension
46
+ # @param sheet [Integer, String, nil] spreadsheets only: the sheet's index
47
+ # (from 0) or name; the first sheet by default
48
+ # @param col_sep [String, nil] CSV only: the one-byte separator; "," for
49
+ # CSV and "\t" for TSV by default
50
+ # @param encoding [String, Encoding, nil] CSV only: the file's encoding,
51
+ # such as "Big5" or "Shift_JIS"; UTF-8 by default. A UTF-8 byte order
52
+ # mark is skipped.
53
+ # @yieldparam row [Array]
54
+ # @return [nil, Enumerator] an Enumerator when no block is given
55
+ def each_row(path, format: nil, sheet: nil, col_sep: nil, encoding: nil, &block)
56
+ return enum_for(__method__, path, format:, sheet:, col_sep:, encoding:) unless block
57
+
58
+ path = file_path(path)
59
+ case (format = format_of(path, format))
60
+ when :csv, :tsv
61
+ raise ArgumentError, "sheet: is only for spreadsheets" if sheet
62
+
63
+ each_csv_row(path, format, col_sep, encoding, &block)
64
+ else
65
+ raise ArgumentError, "col_sep: and encoding: are only for CSV files" if col_sep || encoding
66
+
67
+ index, name = sheet_ref(sheet)
68
+ _each_workbook_row(path, format.to_s, index, name, &block)
69
+ end
70
+ nil
71
+ end
72
+
73
+ # Every row of a sheet; takes the options of {each_row}.
74
+ # @return [Array<Array>]
75
+ def read(path, **)
76
+ each_row(path, **).to_a
77
+ end
78
+
79
+ # The names of a spreadsheet's sheets, in order.
80
+ # @param path [String, #to_path]
81
+ # @param format [Symbol, nil] as in {each_row}
82
+ # @return [Array<String>]
83
+ def sheet_names(path, format: nil)
84
+ path = file_path(path)
85
+ format = format_of(path, format)
86
+ raise ArgumentError, "a #{format.upcase} file has no sheets" if %i[csv tsv].include?(format)
87
+
88
+ _sheet_names(path, format.to_s)
89
+ end
90
+
91
+ private
92
+
93
+ def file_path(path)
94
+ path = path.to_path if path.respond_to?(:to_path)
95
+ raise TypeError, "path must be a String or respond to #to_path, not #{path.class}" unless path.is_a?(String)
96
+ raise Errno::ENOENT, path unless File.file?(path)
97
+
98
+ path
99
+ end
100
+
101
+ def format_of(path, format)
102
+ return FORMATS.fetch(File.extname(path).downcase) { raise ArgumentError, unknown_format(path) } unless format
103
+ unless FORMATS.value?(format)
104
+ raise ArgumentError,
105
+ "unknown format #{format.inspect} (#{FORMATS.values.join(", ")})"
106
+ end
107
+
108
+ format
109
+ end
110
+
111
+ def unknown_format(path)
112
+ "can't tell the format of #{path.inspect} from its extension; pass format: (#{FORMATS.values.join(", ")})"
113
+ end
114
+
115
+ def sheet_ref(sheet)
116
+ case sheet
117
+ when nil then [nil, nil]
118
+ when String then [nil, sheet]
119
+ when Integer
120
+ raise IndexError, "sheet index #{sheet} is negative" if sheet.negative?
121
+
122
+ [sheet, nil]
123
+ else raise TypeError, "sheet must be an Integer or String, not #{sheet.class}"
124
+ end
125
+ end
126
+
127
+ def each_csv_row(path, format, col_sep, encoding, &)
128
+ col_sep ||= format == :tsv ? "\t" : ","
129
+ raise TypeError, "col_sep must be a String, not #{col_sep.class}" unless col_sep.is_a?(String)
130
+ raise ArgumentError, "col_sep must be one byte, not #{col_sep.inspect}" unless col_sep.bytesize == 1
131
+
132
+ # Transcoded in Ruby, then parsed as UTF-8: the whole file is read into memory.
133
+ if encoding
134
+ encoding = Encoding.find(encoding) # ArgumentError for an unknown name
135
+ bom = "BOM|" if encoding.name.start_with?("UTF-")
136
+ data = File.read(path, encoding: "#{bom}#{encoding}:UTF-8")
137
+ end
138
+ _each_csv_row(path, data, col_sep.ord, &)
139
+ end
140
+ end
141
+ end
data/sig/calx.rbs ADDED
@@ -0,0 +1,21 @@
1
+ module Calx
2
+ VERSION: String
3
+ FORMATS: Hash[String, Symbol]
4
+
5
+ class Error < StandardError
6
+ end
7
+
8
+ type path = String | _ToPath
9
+ type cell = String | Integer | Float | bool | Date | Time | nil
10
+
11
+ interface _ToPath
12
+ def to_path: () -> String
13
+ end
14
+
15
+ def self.each_row: (path path, ?format: Symbol?, ?sheet: (Integer | String)?, ?col_sep: String?, ?encoding: (String | Encoding)?) { (Array[cell] row) -> void } -> nil
16
+ | (path path, ?format: Symbol?, ?sheet: (Integer | String)?, ?col_sep: String?, ?encoding: (String | Encoding)?) -> Enumerator[Array[cell], nil]
17
+
18
+ def self.read: (path path, ?format: Symbol?, ?sheet: (Integer | String)?, ?col_sep: String?, ?encoding: (String | Encoding)?) -> Array[Array[cell]]
19
+
20
+ def self.sheet_names: (path path, ?format: Symbol?) -> Array[String]
21
+ end
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: calx
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: aarch64-linux-musl
6
+ authors:
7
+ - Zac
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-05 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Reads CSV, TSV, xlsx, xlsm, xlsb, xls and ods files row by row through
14
+ a native Rust extension (the csv and calamine crates), several times faster than
15
+ Ruby's CSV.
16
+ email:
17
+ - 579103+7a6163@users.noreply.github.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - LICENSE.txt
24
+ - README.md
25
+ - Rakefile
26
+ - lib/calx.rb
27
+ - lib/calx/3.3/calx.so
28
+ - lib/calx/3.4/calx.so
29
+ - lib/calx/4.0/calx.so
30
+ - lib/calx/version.rb
31
+ - sig/calx.rbs
32
+ homepage: https://github.com/7a6163/calx
33
+ licenses:
34
+ - MIT
35
+ metadata:
36
+ homepage_uri: https://github.com/7a6163/calx
37
+ source_code_uri: https://github.com/7a6163/calx
38
+ changelog_uri: https://github.com/7a6163/calx/blob/main/CHANGELOG.md
39
+ rubygems_mfa_required: 'true'
40
+ post_install_message:
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.3'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: 4.1.dev
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 3.3.22
57
+ requirements: []
58
+ rubygems_version: 3.5.23
59
+ signing_key:
60
+ specification_version: 4
61
+ summary: Fast CSV and spreadsheet reader for Ruby with a Rust engine
62
+ test_files: []