hypertabular 0.7.0

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,173 @@
1
+ module HyperTabular
2
+ # One sheet of a workbook, as Workbook#sheets lists it: its name, and whether the workbook
3
+ # hides it. A hidden sheet reads like any other.
4
+ SheetInfo = Data.define(:name, :hidden)
5
+
6
+ # How a sheet is read: whether its first row is a header (exposed through Sheet#header
7
+ # and never delivered as a row), whether a row with no cells is skipped, and the most rows
8
+ # a batch holds.
9
+ SheetOptions = Data.define(:has_header, :skip_empty_rows, :batch_rows) do
10
+ # Every option has a default; ArgumentError unless +batch_rows+ is a positive Integer.
11
+ def initialize(has_header: true, skip_empty_rows: true, batch_rows: DelimitedReader::DEFAULT_BATCH_ROWS)
12
+ raise ArgumentError, "batch_rows must be a positive Integer; got #{batch_rows.inspect}" unless
13
+ batch_rows.is_a?(Integer) && batch_rows.positive?
14
+
15
+ super
16
+ end
17
+ end
18
+
19
+ # A header, empty rows skipped, DelimitedReader::DEFAULT_BATCH_ROWS rows a batch.
20
+ SheetOptions::DEFAULT = SheetOptions.new
21
+
22
+ # An XLSX or ODS workbook held in memory, its sheets listed and its shared strings and
23
+ # styles loaded: what a Sheet reads from.
24
+ #
25
+ # book = HyperTabular::Workbook.open("orders.xlsx")
26
+ # book.sheets # => [#<data HyperTabular::SheetInfo name="Orders", hidden=false>]
27
+ # sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT, plan)
28
+ # while (batch = sheet.read) ... end
29
+ #
30
+ # A workbook that cannot be read — not a zip, encrypted, a part missing or broken — is a
31
+ # TabularError from Workbook.new. Its sheets may be read at once, each with buffers of its
32
+ # own. Not thread-safe.
33
+ class Workbook
34
+ # Reads the file at +path+ into memory and opens it.
35
+ def self.open(path)
36
+ new(File.binread(path))
37
+ end
38
+
39
+ # Opens the workbook in +bytes+, a String, which is read in place: a frozen String is
40
+ # the workbook's as it is, anything else is copied once.
41
+ def initialize(bytes)
42
+ raise ArgumentError, "bytes must be a String; got #{bytes.class}" unless bytes.is_a?(String)
43
+
44
+ bytes = bytes.b.freeze unless bytes.frozen?
45
+ @book = Runtime::Book::Opened.new(bytes)
46
+ @sheets = @book.sheets.map { |name, hidden, _part, _index| SheetInfo.new(name: name, hidden: hidden) }.freeze
47
+ rescue Runtime::Book::StructureError => e
48
+ raise TabularError.from(e.failure)
49
+ end
50
+
51
+ # The workbook's sheets, in its own order, as SheetInfo. Sheets that hold no cells
52
+ # (chart sheets, macro sheets) are not among them.
53
+ attr_reader :sheets
54
+
55
+ # Which kind of workbook this is: :xlsx or :ods.
56
+ def format
57
+ @book.format
58
+ end
59
+
60
+ # The date system the workbook's serials count in — what a date-formatted number is
61
+ # read by: :y1900 or :y1904, as HyperCast names them.
62
+ def date_system
63
+ HyperCast::EXCEL_EPOCHS.key(@book.epoch)
64
+ end
65
+
66
+ # Starts a read of one sheet — +which+ is its index in #sheets or its name — through
67
+ # +plan+, as +options+ (SheetOptions) says. With a header declared the header row is
68
+ # read here. IndexError for an index the workbook has no sheet at, KeyError for a name
69
+ # it has no sheet by.
70
+ def sheet(which, options, plan)
71
+ index =
72
+ case which
73
+ when Integer
74
+ raise IndexError, "the workbook has #{@sheets.size} sheets, and no sheet #{which}" unless
75
+ which >= 0 && which < @sheets.size
76
+
77
+ which
78
+ when String
79
+ found = @sheets.index { |sheet| sheet.name == which }
80
+ raise KeyError, "the workbook has no sheet named #{which.inspect}" if found.nil?
81
+
82
+ found
83
+ else raise ArgumentError, "which must be an Integer or a String; got #{which.inspect}"
84
+ end
85
+ Sheet.new(@book, @book.sheets[index], options, plan)
86
+ end
87
+
88
+ # The workbook in a line.
89
+ def inspect
90
+ "#<#{self.class.name} #{format} sheets=#{@sheets.size}>"
91
+ end
92
+ end
93
+
94
+ # A forward-only read of one sheet of a Workbook, a batch at a time, through a plan — into
95
+ # the same Batch delimited text is read into.
96
+ #
97
+ # A typed cell is converted directly by its door — a stored 42.0 never passes through text
98
+ # to become an Integer — and a text cell goes through the door as delimited text would. A
99
+ # sheet that is structurally broken raises a TabularError after every intact row before
100
+ # the break has been delivered, and the same error again on every later read.
101
+ class Sheet
102
+ # The SheetOptions the sheet is read with.
103
+ attr_reader :options
104
+
105
+ # The plan: the output Columns, in output order.
106
+ attr_reader :plan
107
+
108
+ # The header row's names — a typed cell said the way the text door says it — as frozen
109
+ # UTF-8 Strings, or nil when the options declare no header.
110
+ attr_reader :header
111
+
112
+ # Made by Workbook#sheet.
113
+ def initialize(book, sheet, options, plan)
114
+ raise ArgumentError, "options must be a HyperTabular::SheetOptions; got #{options.inspect}" unless
115
+ options.is_a?(SheetOptions)
116
+
117
+ @plan = Array(plan).dup.freeze
118
+ @plan.each_with_index do |column, index|
119
+ raise ArgumentError, "plan column #{index} must be a HyperTabular::Column; got #{column.inspect}" unless
120
+ column.is_a?(Column)
121
+ end
122
+ @book = book
123
+ @options = options
124
+ width = (@plan.map(&:ordinal).max || -1) + 1
125
+ @reading = Runtime::Book::Reading.new(book, sheet, options.has_header, options.skip_empty_rows,
126
+ @plan.map(&:packed), @plan.map(&:value_bytes), options.batch_rows, width)
127
+ @header = @reading.header
128
+ @pending = nil
129
+ @failure = nil
130
+ rescue Runtime::Book::StructureError => e
131
+ raise TabularError.from(e.failure)
132
+ end
133
+
134
+ # Reads the next batch: a Batch of up to the options' batch_rows rows, or nil once the
135
+ # sheet has no more.
136
+ def read
137
+ if @pending
138
+ @failure = @pending
139
+ @pending = nil
140
+ end
141
+ raise @failure if @failure
142
+
143
+ rows, cells, arena, failure = @reading.fill
144
+ if failure
145
+ broken = TabularError.from(failure)
146
+ raise @failure = broken if rows.zero?
147
+
148
+ @pending = broken
149
+ end
150
+ return nil if rows.zero?
151
+
152
+ columns = @reading.columns
153
+ values = Array.new(@plan.size) { |column| columns.values(column, rows) }
154
+ Batch.new(@plan, rows, values, Array.new(@plan.size) { |column| columns.verdicts(column, rows) },
155
+ cells, @reading.per_row, @book.strings, 0, arena, workbook: true)
156
+ end
157
+
158
+ # Every remaining batch. Without a block, an Enumerator.
159
+ def each_batch
160
+ return to_enum(:each_batch) unless block_given?
161
+
162
+ while (batch = read)
163
+ yield batch
164
+ end
165
+ self
166
+ end
167
+
168
+ # The sheet in a line.
169
+ def inspect
170
+ "#<#{self.class.name} columns=#{@plan.size}>"
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,103 @@
1
+ require "date"
2
+ require "hypercast"
3
+ require_relative "hypertabular/runtime"
4
+ require_relative "hypertabular/runtime/columns"
5
+ require_relative "hypertabular/runtime/delimited"
6
+ require_relative "hypertabular/runtime/workbook"
7
+
8
+ # Tabular parsing with a HyperCast verdict for every cell: delimited text — CSV, TSV, any
9
+ # single-byte ASCII separator — and workbooks — XLSX and ODS — read a batch at a time into
10
+ # typed columns by one Rust core (libhypertabular), called through Fiddle.
11
+ #
12
+ # plan = [HyperTabular::Column.i32(0), HyperTabular::Column.text(1)]
13
+ # reader = HyperTabular::DelimitedReader.new("id,name\n1,alice\nx,bob\n", HyperTabular::Dialect::CSV, plan)
14
+ # reader.header # => ["id", "name"]
15
+ # batch = reader.read # => #<HyperTabular::Batch rows=2 columns=2>
16
+ # batch.values(1) # => ["alice", "bob"]
17
+ # batch.get(0, 1) # => #<data HyperCast::Fault reason=:malformed, offset=0, length=1>
18
+ # batch.raw(0, 1) # => "x"
19
+ # batch.line(1) # => 3
20
+ #
21
+ # book = HyperTabular::Workbook.open("orders.xlsx")
22
+ # sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT, plan)
23
+ # sheet.read # => the same Batch
24
+ #
25
+ # Nothing is sniffed: the Dialect states the separator, the quoting and the header,
26
+ # SheetOptions a sheet's header and whether empty rows are skipped, and the plan states each
27
+ # Column's door and, for numbers, its notation. HyperCast is the judge:
28
+ # the verdicts (HyperCast::Success, HyperCast::Fault), the notation (HyperCast::NumFormat),
29
+ # the exact decimal (HyperCast::Decimal) and the declared options are that gem's own types,
30
+ # and a cell means exactly what HyperCast's door would say of the same text. A bad value is
31
+ # a verdict; a broken file is a TabularError.
32
+ module HyperTabular
33
+ # This gem's own version — kept in lockstep with hypertabular.gemspec and the core's
34
+ # rust/Cargo.toml.
35
+ VERSION = "0.7.0"
36
+
37
+ class << self
38
+ # Whether the native core loaded and exports the ABI this binding was built against.
39
+ # Probed once (a native_version round trip: the cheapest call the core has), cached,
40
+ # and never raises: a missing shared library and an unsupported platform both answer
41
+ # false. A reader keeps its own behavior — building one without the core raises the
42
+ # precise LoadError — this only answers the question quietly.
43
+ def available?
44
+ return @available unless @available.nil?
45
+
46
+ @available = begin
47
+ native_version.is_a?(String)
48
+ rescue LoadError, StandardError
49
+ false
50
+ end
51
+ end
52
+
53
+ # The version of the native core actually loaded, as "major.minor.patch" — read from
54
+ # the library itself, not from this gem, so a consumer can prove the core behind the
55
+ # reader is the one this binding was built against.
56
+ def native_version
57
+ HyperCast::Interop.version(Runtime::Delimited.version)
58
+ end
59
+ end
60
+ end
61
+
62
+ require_relative "hypertabular/dialect"
63
+ require_relative "hypertabular/column"
64
+ require_relative "hypertabular/tabular_error"
65
+ require_relative "hypertabular/batch"
66
+ require_relative "hypertabular/delimited_reader"
67
+ require_relative "hypertabular/workbook"
68
+
69
+ # --- backend selection, as hypercast makes its own: the Magnus extension, when present,
70
+ # replaces the Fiddle crossing in place. Every native call and every byte of native memory is
71
+ # behind HyperTabular::Runtime (Runtime::Delimited, Runtime::Book::Opened and ::Reading, and
72
+ # Runtime.unescape), so that is all the extension redefines (rust/src/ruby_ext.rs): the
73
+ # constructors hand back objects of its own that answer the same methods with the same bytes,
74
+ # and everything above them — the readers, the workbook, Batch and every value and verdict it
75
+ # builds — is the same Ruby on either backend. It is how the precompiled platform gems ship,
76
+ # carrying no Fiddle library at all, and how the hypertabular-wasm gem runs in ruby.wasm,
77
+ # where there is no Fiddle to call. The Fiddle definitions stay the universal zero-compile
78
+ # fallback, for a Ruby or a platform no platform gem covers.
79
+ #
80
+ # HYPERTABULAR_PURE forces Fiddle, as HYPERCAST_PURE does for hypercast. It is a testing and
81
+ # diagnostic switch — CI runs the whole suite through it — read for presence, not value.
82
+ # Inside a platform gem there is no library for it to load: BACKEND still reads :fiddle,
83
+ # HyperTabular.available? answers false, and the first reader raises the LoadError naming the
84
+ # missing library.
85
+ HyperTabular::BACKEND =
86
+ if ENV["HYPERTABULAR_PURE"]
87
+ :fiddle
88
+ else
89
+ # A platform gem carries one extension per Ruby ABI under lib/hypertabular/<minor>/, and
90
+ # `rake native:dev` stages its own build there too; CI's in-job staging drops a single
91
+ # one flat at lib/. A miss on both is not an error: it is what the Fiddle backend is for.
92
+ begin
93
+ require "hypertabular/#{RUBY_VERSION[/\d+\.\d+/]}/hypertabular_native"
94
+ :native
95
+ rescue LoadError
96
+ begin
97
+ require "hypertabular_native"
98
+ :native
99
+ rescue LoadError
100
+ :fiddle
101
+ end
102
+ end
103
+ end
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hypertabular
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.7.0
5
+ platform: ruby
6
+ authors:
7
+ - Brian Buvinghausen
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: hypercast
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.7.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.7.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: fiddle
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: yard
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.9'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.9'
68
+ description: |
69
+ CSV, TSV and any single-byte ASCII separator, and XLSX and ODS workbooks, read by a
70
+ native Rust core into typed column batches. The core owns no memory: this gem allocates the buffers once and the
71
+ core fills them in one native call per batch. Every cell is a HyperCast verdict — the
72
+ value, or a reason plus the offending span — and a file that is not rows of cells at
73
+ all is an exception raised after the intact rows. Two backends behind one surface,
74
+ selected automatically and both shipped prebuilt: a Magnus extension where a precompiled
75
+ platform gem matches, and stdlib Fiddle everywhere else. No compile on install.
76
+ executables: []
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - LICENSE
81
+ - README.md
82
+ - lib/hypertabular.rb
83
+ - lib/hypertabular/batch.rb
84
+ - lib/hypertabular/column.rb
85
+ - lib/hypertabular/delimited_reader.rb
86
+ - lib/hypertabular/dialect.rb
87
+ - lib/hypertabular/native/linux-arm64/libhypertabular.so
88
+ - lib/hypertabular/native/linux-musl-arm64/libhypertabular.so
89
+ - lib/hypertabular/native/linux-musl-x64/libhypertabular.so
90
+ - lib/hypertabular/native/linux-x64/libhypertabular.so
91
+ - lib/hypertabular/native/osx-arm64/libhypertabular.dylib
92
+ - lib/hypertabular/native/osx-x64/libhypertabular.dylib
93
+ - lib/hypertabular/native/win-arm64/hypertabular.dll
94
+ - lib/hypertabular/native/win-x64/hypertabular.dll
95
+ - lib/hypertabular/runtime.rb
96
+ - lib/hypertabular/runtime/columns.rb
97
+ - lib/hypertabular/runtime/delimited.rb
98
+ - lib/hypertabular/runtime/workbook.rb
99
+ - lib/hypertabular/tabular_error.rb
100
+ - lib/hypertabular/workbook.rb
101
+ homepage: https://github.com/SkunkWerkx/HyperTabular
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ source_code_uri: https://github.com/SkunkWerkx/HyperTabular
106
+ bug_tracker_uri: https://github.com/SkunkWerkx/HyperTabular/issues
107
+ changelog_uri: https://github.com/SkunkWerkx/HyperTabular/blob/master/CHANGELOG.md
108
+ documentation_uri: https://github.com/SkunkWerkx/HyperTabular/tree/master/ruby#readme
109
+ rubygems_mfa_required: 'true'
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '3.3'
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 4.0.20
125
+ specification_version: 4
126
+ summary: Delimited text and workbooks read a batch at a time into typed columns, a
127
+ HyperCast verdict for every cell
128
+ test_files: []