philiprehberger-gzip_kit 0.2.1 → 0.4.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: 9fac216e7083cff42b7ae1ff40bc14622a65090bc176fdeeb142c1528e9e7df1
4
- data.tar.gz: 962f299e080f1338f585eac96a61739bfea7a989bbc4b9ac370c1a2f0e6c7db4
3
+ metadata.gz: fc38727681cc1557d80be3bdf12969c479c53bdf2b67d48b88c35286ed2bfed2
4
+ data.tar.gz: 723760091ce30d192a0bc0fe8a921dc2c5da331b3c068c7d3e5bc70edcd71c76
5
5
  SHA512:
6
- metadata.gz: d54f8f5bc6419a4e7b03c5609a05b01ee87d16577b1cc0c448b0573b184fb1e5a710d0b746959f7a8244aeba58fd2e65cc10d7246279714b82594bcf92b72d65
7
- data.tar.gz: 04134126507453cc6648e4f901e025f61b549e7a8cbe07d02181cc27121eb11df271ae4fcb34f89fcd7f60a491b1220140d5fafa5336c58194985a47ccd36bc0
6
+ metadata.gz: 4db3b719e3188b6c0011eecf3e26ea2e4f2386fb76d0ed0a2aac0f82d43a383107e2acdec7cc8adeee288aa5716ce9114678b0d468afadf991741e29a9a13f11
7
+ data.tar.gz: 363c8fdd0ca90ace3235e6c27e3f8b014391709d8208f2d5bb64a881832dfd8159159412ae2abe848a1175b46f324df183f52383f1838990262a7b40fbf05800
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-04-23
11
+
12
+ ### Added
13
+ - Optional `chunk_size:` keyword on `compress_stream`, `decompress_stream`, `compress_file`, `decompress_file` (defaults to 64 KB).
14
+ - Optional `stats:` keyword on `decompress` — returns `{ data:, ratio: }` when true.
15
+ - Module-level YARD overview and `@example` blocks on primary methods.
16
+
17
+ ## [0.3.0] - 2026-04-16
18
+
19
+ ### Added
20
+ - `GzipKit.equivalent?(blob_a, blob_b)` — returns true iff both gzip-compressed inputs decompress to equal byte strings; raises `GzipKit::Error` if either input is not valid gzip
21
+
10
22
  ## [0.2.1] - 2026-03-31
11
23
 
12
24
  ### Changed
data/README.md CHANGED
@@ -108,6 +108,41 @@ File.open("output.gz", "rb") do |input|
108
108
  end
109
109
  ```
110
110
 
111
+ ### Custom chunk size
112
+
113
+ Streaming and file methods read in 64 KB chunks by default. Override with `chunk_size:` to tune for memory constraints or throughput:
114
+
115
+ ```ruby
116
+ require "philiprehberger/gzip_kit"
117
+
118
+ # Smaller chunks for memory-constrained environments
119
+ File.open("input.txt", "rb") do |input|
120
+ File.open("output.gz", "wb") do |output|
121
+ Philiprehberger::GzipKit.compress_stream(input, output, chunk_size: 4 * 1024)
122
+ end
123
+ end
124
+
125
+ # Larger chunks for bulk throughput
126
+ Philiprehberger::GzipKit.compress_file("input.bin", "output.gz", chunk_size: 1 * 1024 * 1024)
127
+ Philiprehberger::GzipKit.decompress_file("output.gz", "restored.bin", chunk_size: 1 * 1024 * 1024)
128
+ ```
129
+
130
+ `chunk_size:` must be a positive `Integer`; any other value raises `ArgumentError`.
131
+
132
+ ### Decompress with stats
133
+
134
+ Mirror of `compress(..., stats: true)` — returns a hash with the decompressed payload and the compressed-to-decompressed size ratio:
135
+
136
+ ```ruby
137
+ require "philiprehberger/gzip_kit"
138
+
139
+ compressed = Philiprehberger::GzipKit.compress("a" * 10_000)
140
+ result = Philiprehberger::GzipKit.decompress(compressed, stats: true)
141
+ # => { data: "aaaa...", ratio: 0.0041 }
142
+ ```
143
+
144
+ The ratio is `compressed_size / decompressed_size` (inverse of compression ratio). For zero-length output, ratio is `0.0`.
145
+
111
146
  ### Stream Concatenation
112
147
 
113
148
  ```ruby
@@ -120,6 +155,20 @@ combined = Philiprehberger::GzipKit.concat(part_a, part_b)
120
155
  Philiprehberger::GzipKit.decompress(combined) # => "Hello, world!"
121
156
  ```
122
157
 
158
+ ### Equivalence Check
159
+
160
+ Compare two gzip blobs by their decompressed payload — useful when the same source is recompressed at different levels or with different metadata:
161
+
162
+ ```ruby
163
+ require "philiprehberger/gzip_kit"
164
+
165
+ fast = Philiprehberger::GzipKit.compress("hello", level: Zlib::BEST_SPEED)
166
+ best = Philiprehberger::GzipKit.compress("hello", level: Zlib::BEST_COMPRESSION)
167
+
168
+ Philiprehberger::GzipKit.equivalent?(fast, best) # => true
169
+ Philiprehberger::GzipKit.equivalent?(fast, Philiprehberger::GzipKit.compress("world")) # => false
170
+ ```
171
+
123
172
  ### Header Inspection
124
173
 
125
174
  ```ruby
@@ -134,13 +183,14 @@ header = Philiprehberger::GzipKit.inspect_header(gzip_data)
134
183
  | Method | Description |
135
184
  |--------|-------------|
136
185
  | `GzipKit.compress(string, level:, stats:)` | Compress a string to gzip bytes; returns stats hash when `stats: true` |
137
- | `GzipKit.decompress(data)` | Decompress gzip bytes to a string |
186
+ | `GzipKit.decompress(data, stats:)` | Decompress gzip bytes to a string; returns `{ data:, ratio: }` when `stats: true` |
138
187
  | `GzipKit.compressed?(data)` | Check if data is gzip-compressed via magic bytes |
139
- | `GzipKit.compress_file(src, dest, level:, &block)` | Compress a file to a gzip file with optional progress callback |
140
- | `GzipKit.decompress_file(src, dest, &block)` | Decompress a gzip file with optional progress callback |
141
- | `GzipKit.compress_stream(io_in, io_out, level:)` | Streaming compression in 64KB chunks |
142
- | `GzipKit.decompress_stream(io_in, io_out)` | Streaming decompression in 64KB chunks |
188
+ | `GzipKit.compress_file(src, dest, level:, chunk_size:, &block)` | Compress a file to a gzip file with optional progress callback and chunk size |
189
+ | `GzipKit.decompress_file(src, dest, chunk_size:, &block)` | Decompress a gzip file with optional progress callback and chunk size |
190
+ | `GzipKit.compress_stream(io_in, io_out, level:, chunk_size:)` | Streaming compression (64 KB chunks by default) |
191
+ | `GzipKit.decompress_stream(io_in, io_out, chunk_size:)` | Streaming decompression (64 KB chunks by default) |
143
192
  | `GzipKit.concat(data_a, data_b)` | Concatenate two gzip-compressed strings |
193
+ | `GzipKit.equivalent?(blob_a, blob_b)` | Check whether two gzip blobs decompress to equal byte strings |
144
194
  | `GzipKit.inspect_header(data)` | Read gzip header metadata without decompressing |
145
195
 
146
196
  ## Development
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module GzipKit
5
- VERSION = '0.2.1'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -5,6 +5,22 @@ require 'stringio'
5
5
  require_relative 'gzip_kit/version'
6
6
 
7
7
  module Philiprehberger
8
+ # GzipKit provides gzip compression and decompression with streaming support.
9
+ #
10
+ # The module exposes both string-oriented and IO-oriented entry points:
11
+ #
12
+ # - {GzipKit.compress} / {GzipKit.decompress} for in-memory string data
13
+ # - {GzipKit.compress_stream} / {GzipKit.decompress_stream} for IO-to-IO streaming
14
+ # - {GzipKit.compress_file} / {GzipKit.decompress_file} for file-to-file transforms
15
+ # - {GzipKit.compressed?} / {GzipKit.inspect_header} for gzip detection and header inspection
16
+ # - {GzipKit.concat} / {GzipKit.equivalent?} for combining and comparing gzip blobs
17
+ #
18
+ # Streaming and file methods read in 64 KB chunks by default. The chunk size can be
19
+ # tuned via the +chunk_size:+ keyword when dealing with very small or very large payloads.
20
+ #
21
+ # @example Compress and decompress a string
22
+ # compressed = Philiprehberger::GzipKit.compress('hello')
23
+ # Philiprehberger::GzipKit.decompress(compressed) # => "hello"
8
24
  module GzipKit
9
25
  class Error < StandardError; end
10
26
 
@@ -17,6 +33,14 @@ module Philiprehberger
17
33
  # @param level [Integer] compression level (Zlib::DEFAULT_COMPRESSION by default)
18
34
  # @param stats [Boolean] when true, return a hash with compression statistics
19
35
  # @return [String, Hash] gzip-compressed bytes, or a stats hash when stats: true
36
+ #
37
+ # @example Compress a string
38
+ # Philiprehberger::GzipKit.compress('hello, world!')
39
+ # # => "\x1F\x8B\b\x00..." (binary gzip bytes)
40
+ #
41
+ # @example Compress with stats
42
+ # Philiprehberger::GzipKit.compress('a' * 10_000, stats: true)
43
+ # # => { data: "...", ratio: 0.99, original_size: 10000, compressed_size: 41 }
20
44
  def self.compress(string, level: Zlib::DEFAULT_COMPRESSION, stats: false)
21
45
  io_out = StringIO.new
22
46
  io_out.binmode
@@ -43,9 +67,19 @@ module Philiprehberger
43
67
  # Decompress gzip bytes to a string.
44
68
  #
45
69
  # @param data [String] gzip-compressed bytes
46
- # @return [String] decompressed string
70
+ # @param stats [Boolean] when true, return a hash with decompression statistics
71
+ # @return [String, Hash] decompressed string, or a stats hash when stats: true
47
72
  # @raise [Zlib::GzipFile::Error] if the data is not valid gzip
48
- def self.decompress(data)
73
+ #
74
+ # @example Decompress gzip bytes
75
+ # compressed = Philiprehberger::GzipKit.compress('hello')
76
+ # Philiprehberger::GzipKit.decompress(compressed) # => "hello"
77
+ #
78
+ # @example Decompress with stats
79
+ # compressed = Philiprehberger::GzipKit.compress('a' * 10_000)
80
+ # Philiprehberger::GzipKit.decompress(compressed, stats: true)
81
+ # # => { data: "aaaa...", ratio: 0.0041 }
82
+ def self.decompress(data, stats: false)
49
83
  io_in = StringIO.new(data)
50
84
  io_in.binmode
51
85
  result = String.new(encoding: Encoding::BINARY)
@@ -62,7 +96,16 @@ module Philiprehberger
62
96
  end
63
97
  end
64
98
 
65
- result.force_encoding(Encoding::UTF_8)
99
+ decompressed = result.force_encoding(Encoding::UTF_8)
100
+
101
+ if stats
102
+ decompressed_size = decompressed.bytesize
103
+ compressed_size = data.bytesize
104
+ ratio = decompressed_size.zero? ? 0.0 : compressed_size.to_f / decompressed_size
105
+ { data: decompressed, ratio: ratio }
106
+ else
107
+ decompressed
108
+ end
66
109
  end
67
110
 
68
111
  # Check if data is gzip-compressed by inspecting magic bytes.
@@ -81,25 +124,29 @@ module Philiprehberger
81
124
  # @param src [String] path to the source file
82
125
  # @param dest [String] path to the destination gzip file
83
126
  # @param level [Integer] compression level (Zlib::DEFAULT_COMPRESSION by default)
127
+ # @param chunk_size [Integer] bytes per read chunk (defaults to 64 KB)
84
128
  # @yield [bytes_processed, total_bytes] progress callback
85
129
  # @yieldparam bytes_processed [Integer] bytes processed so far
86
130
  # @yieldparam total_bytes [Integer] total file size
87
131
  # @return [void]
88
- def self.compress_file(src, dest, level: Zlib::DEFAULT_COMPRESSION, &block)
132
+ # @raise [ArgumentError] if chunk_size is not a positive Integer
133
+ def self.compress_file(src, dest, level: Zlib::DEFAULT_COMPRESSION, chunk_size: CHUNK_SIZE, &block)
134
+ validate_chunk_size!(chunk_size)
135
+
89
136
  File.open(src, 'rb') do |io_in|
90
137
  File.open(dest, 'wb') do |io_out|
91
138
  if block
92
139
  total_bytes = File.size(src)
93
140
  bytes_processed = 0
94
141
  gz = Zlib::GzipWriter.new(io_out, level)
95
- while (chunk = io_in.read(CHUNK_SIZE))
142
+ while (chunk = io_in.read(chunk_size))
96
143
  gz.write(chunk)
97
144
  bytes_processed += chunk.bytesize
98
145
  block.call(bytes_processed, total_bytes)
99
146
  end
100
147
  gz.finish
101
148
  else
102
- compress_stream(io_in, io_out, level: level)
149
+ compress_stream(io_in, io_out, level: level, chunk_size: chunk_size)
103
150
  end
104
151
  end
105
152
  end
@@ -109,24 +156,28 @@ module Philiprehberger
109
156
  #
110
157
  # @param src [String] path to the gzip source file
111
158
  # @param dest [String] path to the destination file
159
+ # @param chunk_size [Integer] bytes per read chunk (defaults to 64 KB)
112
160
  # @yield [bytes_processed, total_bytes] progress callback
113
161
  # @yieldparam bytes_processed [Integer] bytes decompressed so far
114
162
  # @yieldparam total_bytes [nil] always nil (total unknown until decompression completes)
115
163
  # @return [void]
116
- def self.decompress_file(src, dest, &block)
164
+ # @raise [ArgumentError] if chunk_size is not a positive Integer
165
+ def self.decompress_file(src, dest, chunk_size: CHUNK_SIZE, &block)
166
+ validate_chunk_size!(chunk_size)
167
+
117
168
  File.open(src, 'rb') do |io_in|
118
169
  File.open(dest, 'wb') do |io_out|
119
170
  if block
120
171
  gz = Zlib::GzipReader.new(io_in)
121
172
  bytes_processed = 0
122
- while (chunk = gz.read(CHUNK_SIZE))
173
+ while (chunk = gz.read(chunk_size))
123
174
  io_out.write(chunk)
124
175
  bytes_processed += chunk.bytesize
125
176
  block.call(bytes_processed, nil)
126
177
  end
127
178
  gz.close
128
179
  else
129
- decompress_stream(io_in, io_out)
180
+ decompress_stream(io_in, io_out, chunk_size: chunk_size)
130
181
  end
131
182
  end
132
183
  end
@@ -149,6 +200,24 @@ module Philiprehberger
149
200
  result
150
201
  end
151
202
 
203
+ # Check whether two gzip-compressed blobs decompress to equal byte strings.
204
+ #
205
+ # Useful for comparing gzip outputs produced at different compression levels
206
+ # or with different metadata — only the decompressed payloads are compared.
207
+ #
208
+ # @param blob_a [String] first gzip-compressed string
209
+ # @param blob_b [String] second gzip-compressed string
210
+ # @return [Boolean] true iff both blobs decompress to equal byte strings
211
+ # @raise [Error] if either input is not valid gzip
212
+ def self.equivalent?(blob_a, blob_b)
213
+ raise Error, 'first argument is not valid gzip data' unless compressed?(blob_a)
214
+ raise Error, 'second argument is not valid gzip data' unless compressed?(blob_b)
215
+
216
+ decompress(blob_a).b == decompress(blob_b).b
217
+ rescue Zlib::GzipFile::Error => e
218
+ raise Error, "failed to decompress gzip data: #{e.message}"
219
+ end
220
+
152
221
  # Inspect the gzip header without decompressing.
153
222
  #
154
223
  # @param data [String] gzip-compressed data
@@ -173,32 +242,65 @@ module Philiprehberger
173
242
  gz&.close
174
243
  end
175
244
 
176
- # Streaming compression from one IO to another, reading in 64KB chunks.
245
+ # Streaming compression from one IO to another.
177
246
  #
178
247
  # @param io_in [IO] readable input stream
179
248
  # @param io_out [IO] writable output stream
180
249
  # @param level [Integer] compression level (Zlib::DEFAULT_COMPRESSION by default)
250
+ # @param chunk_size [Integer] bytes per read chunk (defaults to 64 KB)
181
251
  # @return [void]
182
- def self.compress_stream(io_in, io_out, level: Zlib::DEFAULT_COMPRESSION)
252
+ # @raise [ArgumentError] if chunk_size is not a positive Integer
253
+ #
254
+ # @example Compress from one IO to another
255
+ # File.open('input.txt', 'rb') do |io_in|
256
+ # File.open('output.gz', 'wb') do |io_out|
257
+ # Philiprehberger::GzipKit.compress_stream(io_in, io_out)
258
+ # end
259
+ # end
260
+ #
261
+ # @example Tune the chunk size for small payloads
262
+ # Philiprehberger::GzipKit.compress_stream(io_in, io_out, chunk_size: 4 * 1024)
263
+ def self.compress_stream(io_in, io_out, level: Zlib::DEFAULT_COMPRESSION, chunk_size: CHUNK_SIZE)
264
+ validate_chunk_size!(chunk_size)
265
+
183
266
  gz = Zlib::GzipWriter.new(io_out, level)
184
- while (chunk = io_in.read(CHUNK_SIZE))
267
+ while (chunk = io_in.read(chunk_size))
185
268
  gz.write(chunk)
186
269
  end
187
270
  gz.finish
188
271
  end
189
272
 
190
- # Streaming decompression from one IO to another, reading in 64KB chunks.
273
+ # Streaming decompression from one IO to another.
191
274
  #
192
275
  # @param io_in [IO] readable input stream containing gzip data
193
276
  # @param io_out [IO] writable output stream
277
+ # @param chunk_size [Integer] bytes per read chunk (defaults to 64 KB)
194
278
  # @return [void]
195
- def self.decompress_stream(io_in, io_out)
279
+ # @raise [ArgumentError] if chunk_size is not a positive Integer
280
+ #
281
+ # @example Decompress from one IO to another
282
+ # File.open('output.gz', 'rb') do |io_in|
283
+ # File.open('restored.txt', 'wb') do |io_out|
284
+ # Philiprehberger::GzipKit.decompress_stream(io_in, io_out)
285
+ # end
286
+ # end
287
+ def self.decompress_stream(io_in, io_out, chunk_size: CHUNK_SIZE)
288
+ validate_chunk_size!(chunk_size)
289
+
196
290
  gz = Zlib::GzipReader.new(io_in)
197
- while (chunk = gz.read(CHUNK_SIZE))
291
+ while (chunk = gz.read(chunk_size))
198
292
  io_out.write(chunk)
199
293
  end
200
294
  ensure
201
295
  gz&.close
202
296
  end
297
+
298
+ # @api private
299
+ def self.validate_chunk_size!(chunk_size)
300
+ return if chunk_size.is_a?(Integer) && chunk_size.positive?
301
+
302
+ raise ArgumentError, "chunk_size must be a positive Integer, got: #{chunk_size.inspect}"
303
+ end
304
+ private_class_method :validate_chunk_size!
203
305
  end
204
306
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-gzip_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-31 00:00:00.000000000 Z
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Simple API for gzip compression and decompression with support for strings,
14
14
  files, and IO streams. Configurable compression level. Built on Ruby stdlib zlib.
@@ -23,11 +23,11 @@ files:
23
23
  - README.md
24
24
  - lib/philiprehberger/gzip_kit.rb
25
25
  - lib/philiprehberger/gzip_kit/version.rb
26
- homepage: https://github.com/philiprehberger/rb-gzip-kit
26
+ homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-gzip_kit
27
27
  licenses:
28
28
  - MIT
29
29
  metadata:
30
- homepage_uri: https://github.com/philiprehberger/rb-gzip-kit
30
+ homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-gzip_kit
31
31
  source_code_uri: https://github.com/philiprehberger/rb-gzip-kit
32
32
  changelog_uri: https://github.com/philiprehberger/rb-gzip-kit/blob/main/CHANGELOG.md
33
33
  bug_tracker_uri: https://github.com/philiprehberger/rb-gzip-kit/issues