omnizip 0.3.15 → 0.3.16

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: 978a7f51735e4fd90dbb0432b933ccdd17009d74c682db94f6ee6730fe1f893a
4
- data.tar.gz: c02f967a72d720edbbc204bd6b36dc60a4c4f773aed3e308800880351a32c218
3
+ metadata.gz: 4417d9e90649e837f75b25bcee26c0721283e360d015eaf0c4194fa2957be053
4
+ data.tar.gz: 8c5fc8e793ea023d21abd9b044c3ddfb3786ee90225fe57fb322fa8cc60c5f4a
5
5
  SHA512:
6
- metadata.gz: 6664a917d2c82bca2779e62a082e1462c1dd8c36f3f4777a47a871feafd4d3bb25e7ae4eb492623a823aed8755777fce93374075a502b0a8beabe2becf9aed05
7
- data.tar.gz: ccf0ff7279dc03c4c5aec2fd6a679419c3fa275966e3b7f076e3bdd4e5890e2141664f6137df46681d29738dbf7fb25ef95e1ec3e450c0e3cfc7509dd9f759d7
6
+ metadata.gz: 2de569d977b1b47bc476efd9eb17e8f00c5b803e96451592964ee0f43bfa04caf52fd6462d02fbcaf9bd914b32436a210b1b935d24d6c1ad96a61acf119c7ca4
7
+ data.tar.gz: c070c98a67fef377cf8710f89ed922bb0a6ff5cf8edd0ac7307113f4973d07a85c5eb8dfce48210f7d570687d361f68cc61f947d4dc739588f9aee1662126269
@@ -1,8 +1,8 @@
1
1
  # 13 — lutaml-model migration
2
2
 
3
3
  Priority: **medium**.
4
- Status: TODO (large; this pass establishes the pattern, full migration
5
- is follow-up).
4
+ Status: DONE for the models that should migrate. The remaining `to_h`
5
+ methods are deliberate — see "Why 17 stay" below.
6
6
 
7
7
  ## Rule
8
8
 
@@ -11,72 +11,136 @@ is follow-up).
11
11
  > ... end` block. Never write `def to_h` / `def from_h` / `def to_json`
12
12
  > / `def from_json` on a model class.
13
13
 
14
- ## Inventory
15
-
16
- 26 `def to_h` occurrences across 22 files. 2 `def to_json`. None use
17
- lutaml-model today even though the gemspec declares the dependency.
18
-
19
- ### Models (lib/omnizip/models/)
20
-
21
- - `compression_options.rb` (`to_h`, `to_json`)
22
- - `algorithm_metadata.rb` (`to_h`, `to_json`)
23
- - `conversion_result.rb`
24
- - `eta_result.rb`
25
- - `filter_config.rb`
26
- - `match_result.rb`
27
- - `optimization_suggestion.rb`
28
- - `parallel_options.rb`
29
- - `performance_result.rb`
30
- - `profile_report.rb`
31
- - `progress_options.rb`
32
- - `conversion_options.rb`
33
-
34
- ### Format entry/metadata classes
35
-
36
- - `formats/xar/entry.rb`
37
- - `formats/zip/unix_extra_field.rb`
38
- - `formats/rpm/header.rb`
39
- - `metadata/entry_metadata.rb`
40
- - `metadata/archive_metadata.rb`
41
-
42
- ### Other
43
-
44
- - `progress/operation_progress.rb`
45
- - `profile/profile_registry.rb`, `custom_profile.rb`, `compression_profile.rb`
46
- - `link_handler/symbolic_link.rb`, `hard_link.rb`
47
-
48
- ## Plan (this pass)
49
-
50
- 1. Establish the pattern by migrating **two representative models**:
51
- `Omnizip::Models::CompressionOptions` and
52
- `Omnizip::Models::AlgorithmMetadata`. Convert to lutaml-model:
53
- ```ruby
54
- class CompressionOptions < Lutaml::Model::Serializable
55
- attribute :algorithm, :string
56
- attribute :level, :integer, default: 6
57
- # ...
58
- mapping do
59
- map :algorithm, to: :algorithm
60
- map :level, to: :level
61
- end
62
- end
63
- ```
64
- The framework provides `to_hash`, `to_json`, `from_hash`,
65
- `from_json` automatically remove the hand-rolled versions.
66
-
67
- 2. Delete the manual `to_h` / `to_json` from those two files.
68
-
69
- 3. Document the migration recipe in this TODO file for the remaining
70
- models.
71
-
72
- ## Acceptance
73
-
74
- - Two models migrated; their specs still pass.
75
- - `grep -rn "def to_h\|def to_json" lib/omnizip/models/` count drops by
76
- at least 2.
77
-
78
- ## Remaining work (future pass)
79
-
80
- Migrate the other ~24 files following the established recipe. The
81
- metadata/entry classes likely share structure and may collapse into a
82
- smaller set of richer models review for MECE opportunities.
14
+ ## Result
15
+
16
+ Six models now inherit `Lutaml::Model::Serializable`:
17
+
18
+ - `CompressionOptions`, `AlgorithmMetadata` (earlier pass)
19
+ - `ConversionOptions`, `ProgressOptions`, `ETAResult`, `ParallelOptions`
20
+
21
+ Counts, measured against the tree rather than estimated:
22
+
23
+ | Metric | Before | After |
24
+ | --- | --- | --- |
25
+ | `grep -rn "def to_h" lib/ \| wc -l` | 21 | 17 |
26
+ | Same under `lib/omnizip/models/` | 10 | 6 |
27
+ | `def to_json` / `from_h` / `from_json` in `lib/` | 0 | 0 |
28
+
29
+ An earlier version of this document claimed "26 `def to_h` across 22
30
+ files, 2 `def to_json`". Both were wrong; the numbers above are real.
31
+
32
+ ## The symbol-type blocker was not real
33
+
34
+ This document previously held `ConversionOptions` back pending "lutaml
35
+ symbol-type support". There is nothing to wait for. `:symbol` is a
36
+ built-in type from 0.8.0 on: it is registered in `TYPE_CODES` in
37
+ `lib/lutaml/model/type.rb` and implemented in
38
+ `lib/lutaml/model/type/symbol.rb`. It casts symbols, strings, and nil
39
+ correctly and round-trips through JSON. `ConversionOptions` uses it for
40
+ `source_format`, `target_format`, `compression`, and `filter`.
41
+
42
+ The gemspec asks for `~> 0.8` because of this. `~> 0.7` also resolved to
43
+ 0.8 on a fresh install, but it still admitted 0.7.1 through 0.7.7, where
44
+ `:symbol` is unregistered and `default: -> { detect_cpu_count }` is
45
+ evaluated against the class. Pin a build to 0.7.7 and 25 examples fail.
46
+
47
+ ## Recipe
48
+
49
+ ```ruby
50
+ require "lutaml/model"
51
+
52
+ class Thing < Lutaml::Model::Serializable
53
+ attribute :level, :integer, default: 5
54
+ attribute :name, :string
55
+
56
+ key_value do
57
+ map "level", to: :level, render_default: true, render_nil: true
58
+ map "name", to: :name, render_default: true, render_nil: true
59
+ end
60
+ end
61
+ ```
62
+
63
+ `key_value` covers hash, YAML, TOML, and JSON in one declaration.
64
+
65
+ ### Two behaviours that will catch you out
66
+
67
+ **1. `to_hash` returns String keys.** The hand-rolled `to_h` methods
68
+ returned Symbol keys. These are not interchangeable, and
69
+ `Serializable` does not define `to_h` at all calling it raises
70
+ `NoMethodError`. Removing `to_h` is a breaking change for any external
71
+ caller.
72
+
73
+ **2. `render_default: true` alone does not give you a fixed key set.**
74
+ It renders an unassigned default, but still drops a key that was
75
+ explicitly assigned `nil`:
76
+
77
+ | Mapping | fresh `.new` | after `x.a = nil` |
78
+ | --- | --- | --- |
79
+ | `render_default: true` | `{"a" => 5}` | `{}` |
80
+ | `+ render_nil: true` | `{"a" => 5, "b" => nil}` | `{"a" => nil}` |
81
+
82
+ Use **both** flags when the old `to_h` emitted every key. Use
83
+ `render_default` alone when it ended in `.compact`. Use neither when
84
+ there are no defaults and nils were dropped. There is no class-level or
85
+ global switch — `render_default:` is per-rule and defaults to `false`.
86
+
87
+ ### Other gotchas
88
+
89
+ - Lambda defaults (`default: -> { detect_cpu_count }`) are evaluated
90
+ with `self` bound to the **instance**. A private instance method
91
+ works; a class method raises `NameError`.
92
+ - `dup` shares the internal `@using_default` hash. With both render
93
+ flags set this is unobservable, so no `dup` override or
94
+ `initialize_copy` hook is needed.
95
+ - Unknown keyword arguments to `.new` are silently ignored, where a
96
+ plain-Ruby `initialize` would raise `ArgumentError`.
97
+ - `self.class.attributes` returns a `Hash{Symbol=>Attribute}`. Iterate
98
+ it with `each_key`.
99
+
100
+ ## Why 17 `to_h` methods stay
101
+
102
+ These are computed reports, projections over another object, or
103
+ wire-format builders — not serializations of the receiver's own state.
104
+ Converting them would drop the computed fields or force a second method
105
+ back onto the class. Leave them alone.
106
+
107
+ | File | Reason |
108
+ | --- | --- |
109
+ | `models/conversion_result.rb` | Three computed fields; drops a stored one |
110
+ | `models/match_result.rb` | Keys don't match attributes; renames and computes |
111
+ | `models/optimization_suggestion.rb` | Computed `priority_score`; ctor validates enums |
112
+ | `models/performance_result.rb` | Computed fields plus `timestamp.iso8601` |
113
+ | `models/profile_report.rb` | Nested computed `summary:`; maps nested results |
114
+ | `models/filter_config.rb` | Emits `:name` for `@name_sym`; binary properties; registry lookups |
115
+ | `formats/rpm/header.rb` | Tag-map projection over a collection |
116
+ | `formats/xar/entry.rb` | XAR XML attributes; octal mode, formatted timestamps |
117
+ | `formats/zip/unix_extra_field.rb` | Emits a constant tag; wire format is manual pack |
118
+ | `metadata/entry_metadata.rb` | Pure delegation; stores no attributes of its own |
119
+ | `metadata/archive_metadata.rb` | Computed aggregates |
120
+ | `profile/compression_profile.rb` | `CustomProfile#to_h` calls `super` on it |
121
+ | `profile/custom_profile.rb` | Inheritance chain over `to_h` |
122
+ | `profile/profile_registry.rb` | Registry projection under a mutex |
123
+ | `progress/operation_progress.rb` | Computed percentages and elapsed time |
124
+ | `link_handler/symbolic_link.rb` | Type discriminator; storage uses `serialize` |
125
+ | `link_handler/hard_link.rb` | Type discriminator; storage uses `serialize` |
126
+
127
+ Because these stay, `grep "def to_h" lib/` will never reach zero. That
128
+ is the intended end state, not unfinished work.
129
+
130
+ ## Follow-up work
131
+
132
+ 1. **Rename the 17.** If they are reports and projections, `to_h` is
133
+ the wrong name — that is the real defect. Suggested groupings:
134
+ `to_report` for the computed ones, `to_tag_map` / `to_attributes`
135
+ for projections, `to_xml_attributes` / `to_fields` for wire-format
136
+ builders. Each rename is itself a public-API break.
137
+ 2. **Fix `apply` in `CompressionOptions` and `AlgorithmMetadata`.**
138
+ Both iterate `self.class.attributes.each do |attr| ... attr.name`,
139
+ but `attributes` is a Hash, so the block gets a two-element Array
140
+ and `attr.name` raises `NoMethodError`. Neither has a caller or a
141
+ spec today, which is why it went unnoticed. Fix is `each_key`, plus
142
+ a spec per class.
143
+ 3. **Extract the shared `apply` loop** once (2) has landed — three
144
+ classes now carry a similar body.
145
+ 4. **Consider `FilterConfig` and the profile classes** for a proper
146
+ migration; both were judged too high-blast-radius to bundle here.
@@ -31,7 +31,7 @@ ruby --version
31
31
 
32
32
  Omnizip has minimal dependencies:
33
33
 
34
- * `lutaml-model` (~> 0.7) - Model serialization
34
+ * `lutaml-model` (~> 0.8) - Model serialization
35
35
  * `marcel` (~> 1.0) - MIME type detection
36
36
 
37
37
  These are automatically installed with the gem.
@@ -25,10 +25,13 @@ module Omnizip
25
25
  class Zstandard
26
26
  # Pure Ruby Zstandard encoder (RFC 8878).
27
27
  #
28
- # Emits a single-segment frame. Each 128 KiB chunk of input
29
- # becomes one block, choosing whichever is smallest: RLE, a
30
- # Compressed_Block (Huffman-coded literals + an empty sequences
31
- # section), or a Raw_Block fallback for incompressible data.
28
+ # Emits a single-segment frame. Each 128 KiB chunk becomes one
29
+ # block, choosing whichever is smallest: RLE, a Compressed_Block
30
+ # (LZ77 match finding + Huffman literals + FSE-coded sequences),
31
+ # or a Raw_Block fallback for incompressible data. The match
32
+ # finder works over absolute positions, so blocks can reference
33
+ # earlier blocks; repeat-offset state is carried between blocks
34
+ # exactly as the decoder tracks it.
32
35
  class Encoder
33
36
  include Constants
34
37
 
@@ -83,53 +86,78 @@ module Omnizip
83
86
  end
84
87
  end
85
88
 
89
+ # rubocop:disable Metrics/MethodLength
90
+ # rubocop:disable-next Metrics/AbcSize
86
91
  def write_blocks(data)
87
92
  return write_empty_last_block if data.empty?
88
93
 
94
+ params = MatchFinder.params_for_level(@level, data.bytesize)
95
+ ms = MatchFinder::MatchState.new(params[:hash_log])
96
+ ms.enable_chain(params[:chain]) if params[:chain].positive?
97
+
98
+ # Wire repeat-offset state carried across blocks; matches the
99
+ # decoder's executor state. Raw and RLE blocks leave it
100
+ # untouched.
101
+ reps = [1, 4, 8]
102
+
89
103
  offset = 0
90
104
  while offset < data.bytesize
91
- chunk = data.byteslice(offset, BLOCK_MAX_SIZE)
92
- offset += chunk.bytesize
93
- is_last = offset >= data.bytesize
94
- write_chunk(chunk, is_last)
105
+ block_end = [offset + BLOCK_MAX_SIZE, data.bytesize].min
106
+ is_last = block_end == data.bytesize
107
+ chunk = data.byteslice(offset, block_end - offset)
108
+
109
+ if chunk.bytesize >= 4 && rle_chunk?(chunk)
110
+ write_block_header(is_last ? 1 : 0, BLOCK_TYPE_RLE,
111
+ chunk.bytesize)
112
+ @output_stream.putc(chunk.getbyte(0))
113
+ else
114
+ seq_store = MatchFinder::SeqStore.new(reps.dup)
115
+ MatchFinder.compress_range(data, offset, block_end, seq_store,
116
+ ms, params[:min_match],
117
+ params[:lazy])
118
+ content, new_reps = try_compressed(seq_store, reps)
119
+ if content.nil?
120
+ write_block_header(is_last ? 1 : 0, BLOCK_TYPE_RAW,
121
+ chunk.bytesize)
122
+ @output_stream.write(chunk)
123
+ else
124
+ write_block_header(is_last ? 1 : 0, BLOCK_TYPE_COMPRESSED,
125
+ content.bytesize)
126
+ @output_stream.write(content)
127
+ reps = new_reps
128
+ end
129
+ end
130
+
131
+ offset = block_end
95
132
  end
96
133
  end
134
+ # rubocop:enable Metrics/MethodLength
97
135
 
98
136
  def write_empty_last_block
99
137
  write_block_header(1, BLOCK_TYPE_RAW, 0)
100
138
  end
101
139
 
102
- def write_chunk(chunk, is_last)
103
- if chunk.bytesize >= 4 && rle_chunk?(chunk)
104
- write_block_header(is_last ? 1 : 0, BLOCK_TYPE_RLE, chunk.bytesize)
105
- @output_stream.putc(chunk.getbyte(0))
106
- return
107
- end
108
-
109
- content = try_compressed(chunk)
110
- if content.nil?
111
- write_block_header(is_last ? 1 : 0, BLOCK_TYPE_RAW,
112
- chunk.bytesize)
113
- @output_stream.write(chunk)
114
- else
115
- write_block_header(is_last ? 1 : 0, BLOCK_TYPE_COMPRESSED,
116
- content.bytesize)
117
- @output_stream.write(content)
118
- end
119
- end
120
-
121
- # Build a Compressed_Block content: literals section plus an
122
- # empty sequences section (nbSeq = 0). Returns nil when the
123
- # result is not smaller than the raw chunk.
124
- def try_compressed(chunk)
125
- literals_section = LiteralsEncoder.encode(chunk)
126
- # A single 0x00 byte: Number_of_Sequences = 0.
127
- content = literals_section + "\x00".b
128
- return nil if content.bytesize >= chunk.bytesize
129
-
130
- content
140
+ # Build a Compressed_Block content: literals section plus the
141
+ # sequences section. Returns [content, wire_reps], or [nil,
142
+ # nil] when the result is not smaller than the raw chunk.
143
+ #
144
+ # The chunk's decompressed size is the literal bytes plus every
145
+ # sequence's match length.
146
+ #
147
+ # @param initial_reps [Array<Integer>] wire rep state the
148
+ # decoder holds before this block
149
+ def try_compressed(seq_store, initial_reps)
150
+ literals_section = LiteralsEncoder.encode(seq_store.literals)
151
+ sequences_section, wire_reps =
152
+ SequencesEncoder.encode_section(seq_store, initial_reps)
153
+ content = literals_section + sequences_section
154
+ chunk_size = seq_store.literals.bytesize +
155
+ seq_store.sequences.sum(&:match_length)
156
+ return [nil, nil] if content.bytesize >= chunk_size
157
+
158
+ [content, wire_reps]
131
159
  rescue Omnizip::CompressionError
132
- nil
160
+ [nil, nil]
133
161
  end
134
162
 
135
163
  def rle_chunk?(chunk)
@@ -105,61 +105,75 @@ module Omnizip
105
105
  lengths
106
106
  end
107
107
 
108
- # Cap code lengths at max_len while preserving the Kraft
109
- # inequality: shorten the longest codes and lengthen the
110
- # shortest until the sum of 2^-len is <= 1.
108
+ # Cap code lengths at max_len and repair the Kraft sum to be
109
+ # EXACTLY 2^max_len, in integer arithmetic. An exact sum is
110
+ # what lets the decoder re-derive the dropped last weight as a
111
+ # clean power of two — a float-tolerance repair can leave the
112
+ # remainder non-power-of-two and the frame undecodable.
111
113
  #
112
114
  # @param lengths [Array<Integer>] mutated in place
113
115
  # @param max_len [Integer]
114
116
  # @param freqs [Array<Integer>]
115
117
  # @return [Array<Integer>]
118
+ # rubocop:disable Metrics/MethodLength
119
+ # rubocop:disable-next Metrics/AbcSize
116
120
  def limit_lengths(lengths, max_len, freqs)
117
- loop do
118
- cur_max = lengths.max || 0
119
- break if cur_max <= max_len
121
+ lengths.map! { |l| [l, max_len].min }
120
122
 
121
- longest = nil
122
- lengths.each_with_index do |l, i|
123
- next unless l == cur_max
124
- next if longest && freqs[i] >= freqs[longest]
123
+ target = 1 << max_len
124
+ kraft = lengths.sum { |l| l.positive? ? 1 << (max_len - l) : 0 }
125
125
 
126
- longest = i
126
+ # Over-subscribed: lengthen the least-frequent shortest
127
+ # codes until the sum fits.
128
+ while kraft > target
129
+ idx = kraft_adjust_index(lengths, freqs) do |l|
130
+ l.positive? && l < max_len
127
131
  end
132
+ lengths[idx] += 1
133
+ kraft -= 1 << (max_len - lengths[idx])
134
+ end
128
135
 
129
- shortest = nil
136
+ # Under-subscribed: shorten the most-frequent code that fits
137
+ # without overshooting; if none fits, lengthen one and let
138
+ # the first loop re-fit.
139
+ while kraft < target
140
+ best = nil
130
141
  lengths.each_with_index do |l, i|
131
- next unless l.positive? && l < max_len
132
- next if shortest && freqs[i] <= freqs[shortest]
142
+ next unless l > 1
133
143
 
134
- shortest = i
135
- end
144
+ contribution = 1 << (max_len - l)
145
+ next if kraft + contribution > target
146
+ next if best && freqs[i] <= freqs[best]
136
147
 
137
- if longest && shortest
138
- lengths[longest] -= 1
139
- lengths[shortest] += 1
148
+ best = i
149
+ end
150
+ if best.nil?
151
+ idx = kraft_adjust_index(lengths, freqs) do |l|
152
+ l.positive? && l < max_len
153
+ end
154
+ lengths[idx] += 1
155
+ kraft -= 1 << (max_len - lengths[idx])
140
156
  else
141
- lengths.map! { |l| [l, max_len].min }
142
- break
157
+ delta = 1 << (max_len - lengths[best])
158
+ lengths[best] -= 1
159
+ kraft += delta
143
160
  end
144
161
  end
145
162
 
146
- loop do
147
- kraft = lengths.sum { |l| l.positive? ? 2.0**-l : 0.0 }
148
- break if kraft <= 1.0 + 1e-10
149
-
150
- min_idx = nil
151
- lengths.each_with_index do |l, i|
152
- next unless l.positive? && l < max_len
153
- next if min_idx && lengths[i] >= lengths[min_idx]
163
+ lengths
164
+ end
165
+ # rubocop:enable Metrics/MethodLength
154
166
 
155
- min_idx = i
156
- end
157
- break if min_idx.nil?
167
+ # Index of the least-frequent symbol matching the block.
168
+ def kraft_adjust_index(lengths, freqs)
169
+ best = nil
170
+ lengths.each_with_index do |l, i|
171
+ next unless yield(l)
172
+ next if best && freqs[i] >= freqs[best]
158
173
 
159
- lengths[min_idx] += 1
174
+ best = i
160
175
  end
161
-
162
- lengths
176
+ best
163
177
  end
164
178
 
165
179
  # Serialize the weight table. Direct 4-bit weights when the