herringbone 0.9.1 → 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.
data/sig/manifest.yaml ADDED
@@ -0,0 +1,2 @@
1
+ dependencies:
2
+ - name: date
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: herringbone
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-31 00:00:00.000000000 Z
10
+ date: 2026-11-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal
@@ -71,16 +71,24 @@ files:
71
71
  - lib/herringbone/restricted_readable_io.rb
72
72
  - lib/herringbone/schema.rb
73
73
  - lib/herringbone/schema/merge.rb
74
- - lib/herringbone/simple_writer.rb
75
74
  - lib/herringbone/thrift.rb
76
75
  - lib/herringbone/types.rb
77
76
  - lib/herringbone/version.rb
78
77
  - lib/herringbone/visualizer.rb
79
78
  - lib/herringbone/writer.rb
80
79
  - lib/herringbone/xxhash.rb
80
+ - rbi/herringbone.rbi
81
+ - sig/herringbone.rbs
82
+ - sig/manifest.yaml
83
+ homepage: https://github.com/julik/herringbone
81
84
  licenses:
82
85
  - MIT
83
- metadata: {}
86
+ metadata:
87
+ source_code_uri: https://github.com/julik/herringbone
88
+ changelog_uri: https://github.com/julik/herringbone/blob/main/CHANGELOG.md
89
+ documentation_uri: https://github.com/julik/herringbone/blob/main/MANUAL.md
90
+ bug_tracker_uri: https://github.com/julik/herringbone/issues
91
+ rubygems_mfa_required: 'true'
84
92
  rdoc_options: []
85
93
  require_paths:
86
94
  - lib
@@ -1,190 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Herringbone
4
- # Writes Parquet the way the CSV gem writes CSV: name the columns, then append rows as Arrays.
5
- # The column types are inferred from the first Schema::INFER_SAMPLE rows (see Schema.infer), so
6
- # no schema has to be declared.
7
- #
8
- # File.open("people.parquet", "wb") do |file|
9
- # Herringbone::SimpleWriter.open(file) do |sw|
10
- # sw.headers!(:id, :name, :age)
11
- # sw << [123, "John", 12]
12
- # sw << { id: 124, name: "Jane" } # Hashes work too; missing columns are nulls
13
- # end
14
- # end
15
- #
16
- # Every column is nullable. A column that is nil in every sampled row becomes a string, and a file
17
- # with headers but no rows has only string columns. Only the sample is held in memory; later rows
18
- # are written as they come. A row that does not fit the inferred types raises SchemaMismatch,
19
- # explaining what was inferred and how to declare the column, and the writer stops there (the
20
- # file is left without a footer). Unlike CSV, the headers are mandatory: a Parquet file always
21
- # has named columns.
22
- #
23
- # Columns declared in a block (Schema::Builder DSL) replace inferred ones, e.g. for a column that
24
- # holds both numbers and text:
25
- #
26
- # Herringbone::SimpleWriter.new(io) { |s| s.string :code }
27
- #
28
- # #encrypt! encrypts the file with one key (see Key):
29
- #
30
- # Herringbone::SimpleWriter.open(file) do |sw|
31
- # sw.encrypt!(key: ENV["PARQUET_KEY"]) # the key's hex, or a Herringbone::Key
32
- # sw.headers!(:id, :name)
33
- # sw << [1, "John"]
34
- # end
35
- class SimpleWriter
36
- # Opens a writer, yields it and closes it, finishing the file. If the block raises, the file is
37
- # left unfinished (no footer), as with Writer.open. To declare columns, use #initialize and #close.
38
- #
39
- # @param io [IO, #write] destination; written sequentially, never closed
40
- # @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
41
- # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
42
- # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
43
- # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
44
- # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
45
- # (other Writer options are passed on as well)
46
- # @yield [writer] the open writer
47
- # @yieldparam writer [SimpleWriter]
48
- # @yieldreturn [Object] returned by open
49
- # @return [SimpleWriter, Object] the writer without a block, the block's value with one
50
- def self.open(io, **options)
51
- writer = new(io, **options)
52
- return writer unless block_given?
53
- begin
54
- result = yield writer
55
- rescue Exception # rubocop:disable Lint/RescueException -- also abort on Interrupt
56
- writer.abort
57
- raise
58
- end
59
- writer.close
60
- result
61
- end
62
-
63
- # @return [Array<String>, nil] the column names, nil until #headers! is called
64
- attr_reader :headers
65
-
66
- # @param io [IO, #write] destination; nothing is written to it until the column types are known
67
- # @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
68
- # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
69
- # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
70
- # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
71
- # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
72
- # (other Writer options are passed on as well)
73
- # @yield [s] optional, declares columns (named in #headers!) that replace inferred ones
74
- # @yieldparam s [Schema::Builder] the builder to declare columns on
75
- # @yieldreturn [void]
76
- # @raise [MissingCodecError] when the codec's optional gem is not loaded
77
- # @raise [ArgumentError] when the block takes no parameter
78
- def initialize(io, **options, &overrides)
79
- Schema::Builder.check_block!(overrides, "Herringbone::SimpleWriter.new(io) { |s| s.string :code }")
80
- @headers = nil
81
- @encrypted = !!options[:encryption]
82
- @overrides = overrides
83
- fix = "Herringbone::SimpleWriter.new(io) { |s| s.%s }"
84
- @writer = InferringWriter.new(io, fix: fix, **options) { |sample| schema_for(sample) }
85
- end
86
-
87
- # Names the columns, in the order row Arrays list their values. Must be called once, before
88
- # the first row.
89
- #
90
- # @param names [Array<String, Symbol>] column names
91
- # @return [self]
92
- # @raise [ArgumentError] when called twice, with no names, or with a name given twice
93
- def headers!(*names)
94
- raise ArgumentError, "headers! was already called" if @headers
95
- names = names.flatten.map(&:to_s)
96
- raise ArgumentError, "headers! needs at least one column name" if names.empty?
97
- duplicates = names.tally.select { |_, n| n > 1 }.keys
98
- raise ArgumentError, "Duplicate column names: #{duplicates.join(", ")}" unless duplicates.empty?
99
- @headers = names.freeze
100
- self
101
- end
102
-
103
- # Encrypts the file with one key, the way most Parquet readers can decrypt with that key (see
104
- # EncryptionConfiguration.simple). Must be called before the first row. For a new key, pass
105
- # +key: Herringbone::Key.generate+ and keep it (+key.hex+): the file can't be read without it.
106
- #
107
- # @param key [Key, String] a key, or its hex (32 or 64 digits; raw bytes go through Key.new)
108
- # @return [Key] the key the file is encrypted with
109
- # @raise [ArgumentError] after the first row, when called twice or after +encryption:+ was
110
- # given, for a String that is not the hex of a key, or a 192-bit key
111
- def encrypt!(key:)
112
- raise ArgumentError, "encrypt! must be called before the first row" if rows_written.positive?
113
- raise ArgumentError, "The file is already encrypted (encrypt! or encryption:)" if @encrypted
114
- key = Key.from(key)
115
- @writer.encryption = EncryptionConfiguration.simple(key)
116
- @encrypted = true
117
- key
118
- end
119
-
120
- # Appends a row: an Array with one value per header, in header order, or a Hash keyed by
121
- # header (String or Symbol keys; missing columns are nulls).
122
- #
123
- # @param row [Array, Hash]
124
- # @return [self]
125
- # @raise [ArgumentError] when #headers! was not called, an Array has the wrong length or a Hash
126
- # has keys that are not headers
127
- # @raise [SchemaMismatch] when a value does not fit its column's inferred type
128
- def <<(row)
129
- raise ArgumentError, "Call headers! with the column names before writing rows" unless @headers
130
- @writer << values_of(row)
131
- self
132
- end
133
-
134
- # @return [Integer] rows written so far, including those held back to infer the column types
135
- def rows_written = @writer.rows_written
136
-
137
- # Writes any rows still held back and finishes the file. The IO is not closed.
138
- #
139
- # @return [void]
140
- # @raise [ArgumentError] when #headers! was never called
141
- def close
142
- raise ArgumentError, "Call headers! with the column names before closing" unless @headers
143
- @writer.close
144
- end
145
-
146
- # Stops without finishing the file. What was already written stays in the IO.
147
- #
148
- # @return [void]
149
- def abort = @writer.abort
150
-
151
- # Short summary for the console, without the rows
152
- #
153
- # @return [String] the number of columns and the underlying writer's summary
154
- def inspect
155
- "#<#{self.class.name} columns=#{@headers&.size.inspect} writer=#{@writer.inspect}>"
156
- end
157
-
158
- private
159
-
160
- # @param row [Array, Hash] row given to #<<
161
- # @return [Array] its values in header order
162
- # @raise [ArgumentError] when it does not match the headers
163
- def values_of(row)
164
- case row
165
- when Array
166
- unless row.size == @headers.size
167
- raise ArgumentError, "Expected #{@headers.size} values (#{@headers.join(", ")}), got #{row.size}"
168
- end
169
- row
170
- when Hash
171
- unknown = row.keys.map(&:to_s) - @headers
172
- raise ArgumentError, "Not among the headers (#{@headers.join(", ")}): #{unknown.join(", ")}" unless unknown.empty?
173
- @headers.map { |name| row.fetch(name) { row[name.to_sym] } }
174
- else
175
- raise ArgumentError, "Rows must be Arrays or Hashes, got #{row.class}"
176
- end
177
- end
178
-
179
- # @param sample [Array<Array>] held-back rows, values in header order
180
- # @return [Schema] inferred from the sample; string columns when it is empty
181
- # @raise [ArgumentError] when a column cannot be inferred or a declared column is not a header
182
- def schema_for(sample)
183
- rows = sample.empty? ? [@headers.to_h { |name| [name, nil] }] : sample.map { |values| @headers.zip(values).to_h }
184
- schema = Schema.infer(rows, &@overrides)
185
- extra = schema.fields.map(&:name) - @headers
186
- raise ArgumentError, "Declared columns not among the headers: #{extra.join(", ")}" unless extra.empty?
187
- schema
188
- end
189
- end
190
- end