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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/MANUAL.md +46 -20
- data/README.md +10 -9
- data/lib/herringbone/compression.rb +9 -1
- data/lib/herringbone/encryption_configuration.rb +1 -1
- data/lib/herringbone/inferring_writer.rb +59 -17
- data/lib/herringbone/inspector.rb +2 -0
- data/lib/herringbone/key.rb +2 -3
- data/lib/herringbone/reader/column_chunk_reader.rb +2 -1
- data/lib/herringbone/reader.rb +59 -27
- data/lib/herringbone/redaction.rb +7 -0
- data/lib/herringbone/schema.rb +5 -1
- data/lib/herringbone/thrift.rb +5 -2
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +25 -6
- data/lib/herringbone.rb +23 -7
- data/rbi/herringbone.rbi +12003 -0
- data/sig/herringbone.rbs +10521 -0
- data/sig/manifest.yaml +2 -0
- metadata +12 -4
- data/lib/herringbone/simple_writer.rb +0 -190
data/sig/manifest.yaml
ADDED
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.
|
|
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
|
+
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
|