hypertabular-wasm 0.9.0 → 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5640b48d2db902a13bddf2b875eca7ca06f3d2fa0a318414c1ce166db2c5d365
4
- data.tar.gz: f5bced5d2e07a2da961f749a94cfb96cf6023fcd58325095dc8efd06dabe1b7b
3
+ metadata.gz: a5f5417b9520fde201cba9a16cdf1f873689538554633d8f3a60c2ad8304d4f9
4
+ data.tar.gz: 81cd168e899fa6fb4347a6d89b575c3bc16364360fa97afd96075b943465b10e
5
5
  SHA512:
6
- metadata.gz: ab47d6aaecbb62db8d315e0ea26b5bf87e52758235427df25923dc9d20a5cbab902f80a5e7fbff59bad525b8ff593698cc1e8972b2a6d44668693852ea007cee
7
- data.tar.gz: 4a67aa99cc3957a732df52578da3e14390424744389ad91c8baa065020bc9ebc074860f8f2529e495bc4e067d95c01c8b35567049bd93203b169aea9255df861
6
+ metadata.gz: 3de75793df8238ae3af084000369f50deee80048c2e6b1caf3919c2fa8646f737cbe2df47dd98f940f0436c3e6d99628aa4a9dc2f6bb0f98ade720d510a000ba
7
+ data.tar.gz: f85379e972e936f0e3346124f0e8cb2d3303b89bcf09911c0de3eb41745c8dddd850fac845067ca7786634043082b4547e94e0b246725a53f6ae80231351a5c5
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # hypertabular
2
2
 
3
- Delimited text — CSV, TSV, any single-byte ASCII separator — and workbooks — XLSX and ODS —
3
+ Delimited text — CSV, TSV, any single-byte ASCII separator — and workbooks — XLSX, ODS and Numbers —
4
4
  read a batch at a time into typed columns, with a
5
5
  [HyperCast](https://github.com/SkunkWerkx/HyperCast) verdict for every cell.
6
6
 
@@ -51,21 +51,25 @@ A plan known up front can be handed over at once —
51
51
  `DelimitedReader.open(path, dialect, plan)`, `book.sheet(which, options, plan)` — which binds it
52
52
  before the header is read.
53
53
 
54
- - **The header** (`reader.header`, `sheet.header`) is a `HyperTabular::Header`: the frozen
55
- Array of frozen names it always was, which also says where a name is. `header.ordinal(name)`
54
+ - **The header** (`reader.header`, `sheet.header`) is a `HyperTabular::Header`: a frozen
55
+ Array of frozen names, which also says where a name is. `header.ordinal(name)`
56
56
  is the first column with exactly that name — case and spaces included — and a `KeyError`
57
57
  naming it when there is none (or, with a block, the block's value, as `Hash#fetch` does);
58
58
  `header.find_ordinal(name)` answers `nil` instead.
59
59
  - **Binding** happens once, before the first read: `bind` again raises `RuntimeError`, and so
60
60
  does a `read` before `bind` — which `bind` then cures. A plan that is not Columns is an
61
- `ArgumentError` and leaves the reader unbound. `bound?` says which it is. A `nil` plan
61
+ `ArgumentError` and leaves the reader unbound; so is, once a delimited header has fixed
62
+ the width every record has, a column at or past it, named in the message (a plan handed
63
+ to the constructor is held to the header as soon as it is read). Without a header such a
64
+ column reads as empty, and a plan reaching past 256 source columns reads shorter batches,
65
+ as many rows as about 8 MiB of cell table holds. `bound?` says which it is. A `nil` plan
62
66
  means "bind later"; `[]` is an empty plan, bound at once.
63
67
  `reader.column_count` is the header's count, or the first record's once it has been read.
64
68
  - **Rows.** `batch.each`, `reader.each` and `sheet.each` yield a `HyperTabular::Row` —
65
69
  `index`, `line`, `get(column)` (also `[]` and `verdict`), `value(column)`, `raw(column)`,
66
70
  `to_a` — each answering what the batch's own accessor does for that row. A batch is a copy,
67
71
  so a row stays good after the read has moved on. A Row deconstructs as its verdicts, for
68
- `case row in [HyperCast::Success(value: id), *]`. `reader.each_row` still yields each row as
72
+ `case row in [HyperCast::Success(value: id), *]`. `reader.each_row` yields each row as
69
73
  a frozen Array of verdicts, and `sheet.each_batch` walks a sheet's batches.
70
74
  - **A workbook from an IO.** `Workbook.new(io)` reads anything with `#read` to its end into a
71
75
  String the workbook owns, as soon as it is opened; `close_source: true` closes the IO once it
@@ -140,7 +144,7 @@ Selection happens once, at `require`, in that order. Both backends read a batch
140
144
  call and hand back the same bytes; everything above that crossing — the readers, the
141
145
  workbook, `Batch` and every value and verdict it builds — is the same Ruby on either, and
142
146
  `spec/native_backend_spec.rb` holds the two to the same answer over every cell of both corpora.
143
- So the extension is not where a read gets faster (the crossing was already once per batch);
147
+ So the extension is not where a read gets faster (either backend crosses once per batch);
144
148
  what it buys is a platform gem with no shared library for Fiddle to find, and Ruby in the
145
149
  browser, where there is no Fiddle at all.
146
150
 
@@ -188,6 +192,12 @@ delimited text from a String or any IO.
188
192
  build naming the ones the gem carries. Built and tested against ruby_wasm 2.10.
189
193
  - **No Rust toolchain.** The gem's `extconf.rb` only hands rbwasm the prebuilt archive. The
190
194
  first `rbwasm build` compiles Ruby itself and takes 15–20 minutes; later builds reuse it.
195
+ - **macOS with Homebrew: limit pkg-config to `openssl@3`.** Run the build as
196
+ `PKG_CONFIG_LIBDIR="$(brew --prefix openssl@3)/lib/pkgconfig" bundle exec rbwasm build …`.
197
+ Without it Homebrew's pkg-config files reach into the build twice: its `openssl` is OpenSSL
198
+ 4, which the native Ruby rbwasm builds first does not compile against, and its `libyaml`
199
+ is a macOS library that psych then links into the wasm interpreter, which fails for Ruby
200
+ 4.0.
191
201
  - **Static linking only,** into ruby.wasm's default `wasm32-unknown-wasip1` interpreter.
192
202
  - **Beside HyperCast.** Every Rust extension that carries std defines a few of the same
193
203
  symbols (`rust_eh_personality`, rb-sys's `ruby_abi_version`, one of std's); this gem and
@@ -209,3 +219,37 @@ bundle exec rake native:dev # builds the Magnus extension for this R
209
219
  bundle exec rspec # the extension, and its agreement with Fiddle
210
220
  bundle exec rake docs:check
211
221
  ```
222
+
223
+ ## Verifying provenance
224
+
225
+ Every gem RubyGems.org serves — the universal fallback, each of the seven precompiled platform
226
+ gems and `hypertabular-wasm` — verifies from the release tag alone: the release attestation,
227
+ signed by this repository's `release.yml` at the tag, names every one of them, with the
228
+ release inventory as its predicate.
229
+
230
+ ```sh
231
+ gem fetch hypertabular -v <version> --platform <platform> # or omit --platform for the universal gem
232
+ gh attestation verify hypertabular-<version>-<platform>.gem --repo SkunkWerkx/HyperTabular \
233
+ --source-ref refs/tags/v<version> --predicate-type https://github.com/SkunkWerkx/.github/predicates/release/v1
234
+ ```
235
+
236
+ Each gem's build provenance is this repository's too: the `ruby-pack` job attests
237
+ `ruby/pkg/*.gem` as soon as they are built and `rubygems-publish` pushes those preserved
238
+ bytes, so plain `--repo` verifies it:
239
+
240
+ ```sh
241
+ gh attestation verify hypertabular-<version>-<platform>.gem --repo SkunkWerkx/HyperTabular
242
+ ```
243
+
244
+ That is the release's second layer of checking, not the only one: before any gem is built, the
245
+ same job verifies every native binary it packs — the Fiddle libraries, the Magnus extensions
246
+ (one per Ruby ABI per platform), the `wasm32-wasip1` archives — against *their own* build
247
+ provenance, signed from `SkunkWerkx/.github` by `hyper-build-native.yml` and
248
+ `hyper-build-wasm.yml` (so that check needs `--signer-repo SkunkWerkx/.github` added), and
249
+ refuses to proceed on an unverified one. RubyGems.org has no unpublish and no
250
+ duplicate-version overwrite, so all of this happens while a bad artifact is still reversible,
251
+ and the push itself is a separate dispatch from the preserved bytes. The release run then
252
+ re-fetches every gem from rubygems.org and fails unless each matches the attested bytes
253
+ exactly, turning "rubygems.org stores an upload verbatim" into a per-release check rather than
254
+ an assumption. The root README's [Provenance](../README.md#provenance) section has the whole
255
+ picture.
@@ -32,7 +32,8 @@ module HyperTabular
32
32
  # HyperCast::NumFormat (ArgumentError), an unknown door or an undeclared option
33
33
  # (KeyError, as HyperCast raises for the same).
34
34
  def initialize(ordinal:, door:, declared: nil, format: nil)
35
- raise ArgumentError, "ordinal must be an Integer in 0..#{Column::MAX_ORDINAL}; got #{ordinal.inspect}" unless
35
+ raise ArgumentError, "ordinal must be an Integer at least 0, and an ordinal must be below 2^20 " \
36
+ "(1,048,576); got #{ordinal.inspect}" unless
36
37
  ordinal.is_a?(Integer) && ordinal.between?(0, Column::MAX_ORDINAL)
37
38
 
38
39
  DOORS.fetch(door)
@@ -159,8 +160,10 @@ module HyperTabular
159
160
  end
160
161
  end
161
162
 
162
- # The widest source ordinal a column can name.
163
- Column::MAX_ORDINAL = (1 << 31) - 1
163
+ # The widest source ordinal a column can name: an ordinal must be below 2^20. No workbook
164
+ # format has that many columns (XLSX and ODS stop at 16,384, Numbers at 1,000), and at the
165
+ # cap one row of cell table is at most 8 MiB.
166
+ Column::MAX_ORDINAL = (1 << 20) - 1
164
167
 
165
168
  # The doors that read a HyperCast::NumFormat: the integers, the reals and the decimal.
166
169
  Column::NUMERIC = %i[i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 decimal].freeze
@@ -114,6 +114,8 @@ module HyperTabular
114
114
  @failure = nil
115
115
  take(source)
116
116
  @header = dialect.has_header ? read_header : nil
117
+ # A plan bound before the header was read is held to it as soon as it is.
118
+ within_header(planned) if planned
117
119
  end
118
120
 
119
121
  # The header's names, when the dialect declares a header: a Header — the frozen Array of
@@ -130,13 +132,16 @@ module HyperTabular
130
132
  # Columns, in output order, typically built from the #header's ordinals — once, before
131
133
  # the first read. Returns the reader.
132
134
  #
133
- # ArgumentError for a plan that is not Columns, which leaves the reader unbound;
134
- # RuntimeError if the reader already has a plan; IOError on a closed reader.
135
+ # ArgumentError for a plan that is not Columns, or that reads a column at or past the
136
+ # width the header gives every record, which leaves the reader unbound; RuntimeError if
137
+ # the reader already has a plan; IOError on a closed reader.
135
138
  def bind(plan)
136
139
  raise IOError, "closed reader" if @closed
137
140
  raise "the reader already has a plan: a plan is bound once" if bound?
138
141
 
139
- bind_kernel(Column.plan(plan))
142
+ plan = Column.plan(plan)
143
+ within_header(plan)
144
+ bind_kernel(plan)
140
145
  self
141
146
  end
142
147
 
@@ -243,6 +248,19 @@ module HyperTabular
243
248
  UNBOUND_MESSAGE = "the reader has no plan yet: bind one before reading".freeze
244
249
  private_constant :UNBOUND, :UNBOUND_MESSAGE
245
250
 
251
+ # ArgumentError for the first column of +plan+ at or past the width the header has given
252
+ # every record: such a column could only ever be empty.
253
+ def within_header(plan)
254
+ width = @header.nil? ? nil : column_count
255
+ return if width.nil?
256
+
257
+ index = plan.index { |column| column.ordinal >= width }
258
+ return if index.nil?
259
+
260
+ raise ArgumentError, "plan column #{index} reads source column #{plan[index].ordinal}, " \
261
+ "past the #{width} columns the header gives every record"
262
+ end
263
+
246
264
  # Sizes the kernel's arrays for +plan+ (already checked to be Columns) and takes it.
247
265
  def bind_kernel(plan)
248
266
  # Cell-table entries one row takes: the widest ordinal the plan reads, plus two.
@@ -43,6 +43,17 @@ module HyperTabular
43
43
 
44
44
  ARENA_BYTES = 4096
45
45
  NAMES = 64
46
+ # The cell-table entries a reader holds at most, unless one row takes more: what a
47
+ # batch of 4096 rows takes for a plan reaching 256 source columns, so that an ordinary
48
+ # plan's batches are full, and a plan reaching far past any column a record holds
49
+ # costs one row's entries rather than a batch of them.
50
+ CELL_BUDGET = 4096 * 257
51
+
52
+ # The rows of +per_row+ entries the cell table holds: as many as the budget allows,
53
+ # at least one and at most a batch. The core fills only the rows the table holds.
54
+ def self.cell_rows(per_row, batch_rows)
55
+ (CELL_BUDGET / [per_row, 1].max).clamp(1, batch_rows)
56
+ end
46
57
 
47
58
  CONTRACT = "hypertabular: libhypertabular reported a contract violation — a binding bug, " \
48
59
  "please report it".freeze
@@ -57,6 +68,9 @@ module HyperTabular
57
68
  # #bind.
58
69
  attr_reader :columns, :per_row
59
70
 
71
+ # The cell-table entries the reader holds.
72
+ def cell_entries = @cells_cap
73
+
60
74
  # The loaded core's version word, major << 16 | minor << 8 | patch.
61
75
  def self.version
62
76
  Runtime.function(:hypertabular_version).call
@@ -87,7 +101,7 @@ module HyperTabular
87
101
  @batch_rows = batch_rows
88
102
  @columns = Columns.new(specs, sizes, batch_rows)
89
103
  @per_row = per_row
90
- @cells_cap = per_row * batch_rows
104
+ @cells_cap = per_row * Delimited.cell_rows(per_row, batch_rows)
91
105
  @cells = Runtime.buffer(SPAN_BYTES * @cells_cap)
92
106
  end
93
107
 
@@ -142,12 +156,14 @@ module HyperTabular
142
156
  needed = finished
143
157
  case code
144
158
  when OK
145
- @cramped = @rows.positive? && @rows < @batch_rows && @arena_used * 2 >= @arena_cap
159
+ # A batch the cell table ends early (CELL_BUDGET) is no reason to grow the arena.
160
+ held = [@batch_rows, @cells_cap / @per_row].min
161
+ @cramped = @rows.positive? && @rows < held && @arena_used * 2 >= @arena_cap
146
162
  return code
147
163
  when ERR_STRUCTURE then return code
148
164
  when ERR_CELLS
149
165
  @per_row = [@per_row, needed].max
150
- @cells_cap = @per_row * @batch_rows
166
+ @cells_cap = @per_row * Delimited.cell_rows(@per_row, @batch_rows)
151
167
  @cells = Runtime.buffer(SPAN_BYTES * @cells_cap)
152
168
  when ERR_ARENA then grow_arena(needed)
153
169
  else raise CONTRACT
@@ -139,7 +139,7 @@ module HyperTabular
139
139
  styles: :hypertabular_workbook_styles
140
140
  }.freeze
141
141
 
142
- # :xlsx or :ods; the date system's code (1 for 1900, 2 for 1904); the sheets as
142
+ # :xlsx, :ods or :numbers; the date system's code (1 for 1900, 2 for 1904); the sheets as
143
143
  # [name, hidden, part, index]; the shared strings' bytes; the state template every
144
144
  # sheet copies; and the tables a sheet's calls are handed.
145
145
  attr_reader :format, :epoch, :sheets, :strings, :state, :tables, :container, :length
@@ -175,7 +175,7 @@ module HyperTabular
175
175
  format, epoch, strings_bytes, _count, needed, *failure = @out[0, OPENED_BYTES].unpack(OPENED)
176
176
  case code
177
177
  when OK
178
- @format = format == 2 ? :ods : :xlsx
178
+ @format = { 2 => :ods, 3 => :numbers }.fetch(format, :xlsx)
179
179
  @epoch = epoch
180
180
  return strings_bytes
181
181
  when ERR_WINDOW then scratch.grow_window(needed)
@@ -23,7 +23,7 @@ module HyperTabular
23
23
  KINDS = {
24
24
  1 => :unclosed_quote, 2 => :column_count, 3 => :row_too_long, 16 => :not_a_zip, 17 => :container,
25
25
  18 => :encrypted, 19 => :method, 20 => :missing_part, 21 => :xml, 22 => :deflate,
26
- 23 => :not_a_workbook, 24 => :shared_string, 25 => :too_large
26
+ 23 => :not_a_workbook, 24 => :shared_string, 25 => :too_large, 26 => :iwa
27
27
  }.freeze
28
28
 
29
29
  # What is wrong: :unclosed_quote (the input ended inside a quoted cell), :column_count
@@ -32,7 +32,11 @@ module HyperTabular
32
32
  # :container (the zip's own structure is broken), :encrypted, :method (a part is
33
33
  # compressed by neither stored nor deflate), :missing_part, :xml (a part ends inside an
34
34
  # XML construct), :deflate (a part is not a whole deflate stream), :not_a_workbook,
35
- # :shared_string (a cell names a shared string the table does not have) or :too_large.
35
+ # :shared_string (a cell names a shared string the table does not have), :too_large or
36
+ # :iwa (a Numbers part is not what the IWA packaging says: found is 1 for a broken chunk
37
+ # or Snappy block, 2 for a malformed message, 3 for an object a reference names that is
38
+ # not there, expected its identifier, 4 for cell storage of a version the core does not
39
+ # read, expected the version).
36
40
  attr_reader :kind
37
41
 
38
42
  # Zero-based index of the offending record — the header and skipped blank lines
@@ -89,9 +93,11 @@ module HyperTabular
89
93
  when :missing_part then "Part #{record}, which the workbook cannot be read without, is missing."
90
94
  when :xml then "Part #{record} of the workbook ends inside an XML construct (byte #{byte})."
91
95
  when :deflate then "Part #{record} of the workbook is not a whole deflate stream (byte #{byte})."
92
- when :not_a_workbook then "The zip is neither an XLSX nor an ODS workbook."
96
+ when :not_a_workbook then "The zip is not an XLSX, ODS or Numbers workbook."
93
97
  when :shared_string then "Row #{line} names shared string #{found}; the table has #{expected}."
94
98
  when :too_large then "The workbook holds more text than a batch can address."
99
+ when :iwa
100
+ "Part #{record} of the Numbers document is not what the IWA packaging says (byte #{byte}, detail #{found})."
95
101
  else "The workbook's zip structure is broken."
96
102
  end
97
103
  end
@@ -19,7 +19,7 @@ module HyperTabular
19
19
  # A header, empty rows skipped, DelimitedReader::DEFAULT_BATCH_ROWS rows a batch.
20
20
  SheetOptions::DEFAULT = SheetOptions.new
21
21
 
22
- # An XLSX or ODS workbook held in memory, its sheets listed and its shared strings and
22
+ # An XLSX, ODS or Numbers workbook held in memory, its sheets listed and its shared strings and
23
23
  # styles loaded: what a Sheet reads from.
24
24
  #
25
25
  # book = HyperTabular::Workbook.open("orders.xlsx")
@@ -64,7 +64,7 @@ module HyperTabular
64
64
  # (chart sheets, macro sheets) are not among them.
65
65
  attr_reader :sheets
66
66
 
67
- # Which kind of workbook this is: :xlsx or :ods.
67
+ # Which kind of workbook this is: :xlsx, :ods or :numbers.
68
68
  def format
69
69
  @book.format
70
70
  end
data/lib/hypertabular.rb CHANGED
@@ -6,7 +6,7 @@ require_relative "hypertabular/runtime/delimited"
6
6
  require_relative "hypertabular/runtime/workbook"
7
7
 
8
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
9
+ # single-byte ASCII separator — and workbooks — XLSX, ODS and Numbers — read a batch at a time into
10
10
  # typed columns by one Rust core (libhypertabular), called through Fiddle.
11
11
  #
12
12
  # plan = [HyperTabular::Column.i32(0), HyperTabular::Column.text(1)]
@@ -38,7 +38,7 @@ require_relative "hypertabular/runtime/workbook"
38
38
  module HyperTabular
39
39
  # This gem's own version — kept in lockstep with hypertabular.gemspec and the core's
40
40
  # rust/Cargo.toml.
41
- VERSION = "0.9.0"
41
+ VERSION = "1.0.0"
42
42
 
43
43
  class << self
44
44
  # Whether the native core loaded and exports the ABI this binding was built against.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hypertabular-wasm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Buvinghausen
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.9.0
18
+ version: '1.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.9.0
25
+ version: '1.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rake
28
28
  requirement: !ruby/object:Gem::Requirement