progressive_io 2.0.2 → 2.1.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: e46f28055cb41f75b5a00b863d1771580ec7e610dd09d5360574e123c3b99867
4
- data.tar.gz: b9ff8b9bf2724da050eb49933eac10c9d35953c30abd823fcc0482823a27a3a8
3
+ metadata.gz: 384d400209c61ff65f9298a602c53b8b6ce6cdf837d56b58a6e4cb900e932377
4
+ data.tar.gz: 0e68bbc55b01864b300a451496ba3c4e86839ec4f34bfb7d2f7cec9cb47fb415
5
5
  SHA512:
6
- metadata.gz: e9c3bdfaf294545502b5dcde2381f67c4d343df02c64d9b56d290877f13619d283288a64517af7e5c5aa7f7359db6a0606684f693c0b8cdec9c32abfd2d27a43
7
- data.tar.gz: 14fbce5195814ef1c583c664f8550b664e60d2d46a1465ef7ced873bd980247897fea75652f36444c4ff5511c67ba8be64dcd25644a310f907cd80d9f68ba236
6
+ metadata.gz: 43468779c7d211dbacacb79918c4fb719f100c67fb1cfaf31a881ba82cf1b8681aa21e48dd0ee34d90cfda2c74d06e29410813245f0f1c4fc300cab8d5c24fc9
7
+ data.tar.gz: e1a5cf367640e3bfb6b97ec527ff2cb570a76b06425fd8df644d83f8beb7f2db5ca335c03762f473b97c70840604ee1ce9010bd321dd9b722393fa94f2e4bffa
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 2.1.0
2
+
3
+ * Report `readpartial`, `read_nonblock`, `sysread`, `getbyte`, `readbyte`, `each_char` and
4
+ `each_codepoint`, which went past the wrapper unreported.
5
+ * `IO.copy_stream` reports now. It read a wrapped File by opening its path itself, and a wrapped
6
+ IO through `to_io`, so the wrapper no longer passes on `to_path` or `to_io` - reach the
7
+ wrapped IO with `__getobj__` where you need it (`IO.select`, say). Without them
8
+ `IO.copy_stream` reads through `readpartial`, which also means no zero-copy fast path.
9
+ * Pass keyword arguments on: `gets(chomp: true)`, `readline`, `readlines`, `each` and
10
+ `each_line` used to raise `TypeError`.
11
+ * Work with pipes and sockets. Every read raised `Errno::ESPIPE`, since reporting asked the IO
12
+ for its position; for a stream without one, the bytes read so far are reported instead, and
13
+ `ungetc`/`ungetbyte` take pushed-back bytes off that count.
14
+ * Require Ruby 3.2.
15
+
1
16
  ## 2.0.2
2
17
 
3
18
  * Fix `each_line` to return an Enumerator that properly references `:each_line` method
data/README.md CHANGED
@@ -8,7 +8,9 @@ A wrapper for IO objects that allows a callback to be set which is called when a
8
8
 
9
9
  ## FEATURES/PROBLEMS
10
10
 
11
- * Wraps any IO
11
+ * Wraps any IO: files, StringIO, pipes and sockets
12
+ * Reports every kind of read, including `readpartial`, so `IO.copy_stream` reports too
13
+ * On a pipe or a socket, which have no position, reports the bytes read so far instead
12
14
 
13
15
  ## SYNOPSIS
14
16
 
@@ -42,9 +44,17 @@ io_with_progress.each do | line | # Each yielded line will call the callback blo
42
44
  end
43
45
  ```
44
46
 
47
+ Copying reports as it goes:
48
+
49
+ ```ruby
50
+ IO.copy_stream(ProgressiveIO.new(File.open("/bigfile.dat")) { |pos| pbar.set(pos) }, destination)
51
+ ```
52
+
53
+ The wrapper does not pass on `to_io` or `to_path`, since `IO.copy_stream` would use them to read the file past it. To get at the wrapped IO, use `__getobj__`.
54
+
45
55
  ## REQUIREMENTS
46
56
 
47
- * Ruby 1.8.6+
57
+ * Ruby 3.2+
48
58
 
49
59
  ## INSTALL
50
60
 
@@ -3,45 +3,52 @@
3
3
  require 'delegate'
4
4
 
5
5
  # A wrapper class that provides progress tracking for IO operations.
6
- #
6
+ #
7
7
  # This class wraps an IO object and calls a progress block whenever data is read,
8
8
  # allowing you to track reading progress for operations like file uploads or downloads.
9
- #
9
+ #
10
+ # The block receives the position after each read. A pipe, a socket or a TTY has no position,
11
+ # so for those it receives the number of bytes read through the wrapper so far instead.
12
+ #
10
13
  # @example Basic usage with a file
11
14
  # file = File.open('large_file.txt')
12
15
  # progress_io = ProgressiveIO.new(file) do |current_pos|
13
16
  # puts "Read #{current_pos} bytes"
14
17
  # end
15
- #
18
+ #
16
19
  # progress_io.each_line do |line|
17
20
  # # Process each line
18
21
  # end
19
- #
22
+ #
20
23
  # @example Usage with a StringIO
21
24
  # string_io = StringIO.new("Hello\nWorld\n")
22
25
  # progress_io = ProgressiveIO.new(string_io) do |current_pos|
23
26
  # puts "Read #{current_pos} bytes"
24
27
  # end
25
- #
28
+ #
26
29
  # content = progress_io.read
27
- #
30
+ #
31
+ # @example Copying, which reads through readpartial
32
+ # IO.copy_stream(ProgressiveIO.new(source) { |pos| bar.update(pos) }, destination)
33
+ #
28
34
  # @since 2.0.0
29
35
  class ProgressiveIO < SimpleDelegator
30
36
  # The version of the ProgressiveIO library
31
- VERSION = '2.0.2'
32
-
37
+ VERSION = '2.1.0'
38
+
33
39
  # @return [Proc, nil] The progress callback block that will be called when data is read
34
40
  # The block receives one parameter: current position
35
41
  attr_accessor :progress_block
36
-
42
+
37
43
  # Creates a new ProgressiveIO wrapper around an IO object.
38
- #
39
- # @param with_io [IO] The IO object to wrap (File, StringIO, etc.)
44
+ #
45
+ # @param with_io [IO] The IO object to wrap (File, StringIO, a pipe, etc.)
40
46
  # @param blk [Proc, nil] Optional block that will be called when data is read
41
47
  # The block receives one parameter: current position
42
48
  # @yield [current_pos] The progress callback
43
- # @yieldparam current_pos [Integer] The current position in the IO stream
44
- #
49
+ # @yieldparam current_pos [Integer] The current position in the IO stream, or the bytes
50
+ # read so far for a stream that has no position
51
+ #
45
52
  # @example
46
53
  # file = File.open('data.txt')
47
54
  # progress_io = ProgressiveIO.new(file) do |pos|
@@ -50,170 +57,324 @@ class ProgressiveIO < SimpleDelegator
50
57
  def initialize(with_io, &blk)
51
58
  super(with_io)
52
59
  @progress_block = blk.to_proc if blk
60
+ @positioned = nil
61
+ @consumed = 0
53
62
  end
54
-
63
+
55
64
  # Iterates over the IO stream line by line, calling the progress block for each line.
56
- #
65
+ #
57
66
  # @param args [Array] Arguments to pass to the underlying IO#each method (separator and/or limit)
67
+ # @param kwargs [Hash] Keyword arguments to pass on, such as `chomp: true`
58
68
  # @yield [line] Each line from the IO stream
59
69
  # @yieldparam line [String] A line from the IO stream
60
70
  # @return [Enumerator] An enumerator if no block is given
61
- #
71
+ #
62
72
  # @example
63
73
  # progress_io.each do |line|
64
74
  # puts "Processing: #{line.chomp}"
65
75
  # end
66
- #
76
+ #
67
77
  # @example Using as an Enumerator
68
78
  # progress_io.each.with_index do |line, idx|
69
79
  # puts "Line #{idx}: #{line.chomp}"
70
80
  # end
71
- def each(*args, &blk)
72
- return enum_for(__method__, *args) unless block_given?
73
-
81
+ def each(*args, **kwargs, &blk)
82
+ return enum_for(__method__, *args, **kwargs) unless block_given?
83
+
74
84
  # Report offset at each call of the iterator
75
- super(*args) do |line|
76
- yield(line).tap { notify_read }
85
+ super(*args, **kwargs) do |line|
86
+ yield(line).tap { notify_read(line, chomped: kwargs[:chomp]) }
77
87
  end
78
88
  end
79
-
89
+
80
90
  # Iterates over the IO stream line by line, calling the progress block for each line.
81
91
  # This is an alias-like method for {#each} that ensures proper Enumerator behavior.
82
- #
92
+ #
83
93
  # @param args [Array] Arguments to pass to the underlying IO#each_line method (separator and/or limit)
94
+ # @param kwargs [Hash] Keyword arguments to pass on, such as `chomp: true`
84
95
  # @yield [line] Each line from the IO stream
85
96
  # @yieldparam line [String] A line from the IO stream
86
97
  # @return [Enumerator] An enumerator if no block is given
87
- #
98
+ #
88
99
  # @example
89
100
  # progress_io.each_line do |line|
90
101
  # puts "Processing: #{line.chomp}"
91
102
  # end
92
- #
103
+ #
93
104
  # @example Using as an Enumerator
94
105
  # progress_io.each_line.with_index do |line, idx|
95
106
  # puts "Line #{idx}: #{line.chomp}"
96
107
  # end
97
- def each_line(*args, &blk)
98
- return enum_for(__method__, *args) unless block_given?
99
-
100
- each(*args, &blk)
108
+ def each_line(*args, **kwargs, &blk)
109
+ return enum_for(__method__, *args, **kwargs) unless block_given?
110
+
111
+ each(*args, **kwargs, &blk)
101
112
  end
102
-
113
+
103
114
  # Iterates over the IO stream byte by byte, calling the progress block for each byte.
104
- #
115
+ #
105
116
  # @yield [byte] Each byte from the IO stream
106
117
  # @yieldparam byte [Integer] A byte from the IO stream (0-255)
107
118
  # @return [Enumerator] An enumerator if no block is given
108
- #
119
+ #
109
120
  # @example
110
121
  # progress_io.each_byte do |byte|
111
122
  # puts "Byte: #{byte}"
112
123
  # end
113
- #
124
+ #
114
125
  # @example Using as an Enumerator
115
126
  # progress_io.each_byte.with_index do |byte, idx|
116
127
  # puts "Byte #{idx}: #{byte}"
117
128
  # end
118
129
  def each_byte(&blk)
119
130
  return enum_for(__method__) unless block_given?
120
-
131
+
121
132
  # Report offset at each call of the iterator
122
- super { |b| yield(b).tap { notify_read } }
133
+ super { |b| yield(b).tap { notify_read(b) } }
134
+ end
135
+
136
+ # Iterates over the IO stream character by character, calling the progress block for each.
137
+ #
138
+ # @yield [char] Each character from the IO stream
139
+ # @yieldparam char [String] A character from the IO stream
140
+ # @return [Enumerator] An enumerator if no block is given
141
+ # @since 2.1.0
142
+ def each_char(&blk)
143
+ return enum_for(__method__) unless block_given?
144
+
145
+ super { |c| yield(c).tap { notify_read(c) } }
146
+ end
147
+
148
+ # Iterates over the IO stream codepoint by codepoint, calling the progress block for each.
149
+ #
150
+ # @yield [codepoint] Each codepoint from the IO stream
151
+ # @yieldparam codepoint [Integer] A codepoint from the IO stream
152
+ # @return [Enumerator] An enumerator if no block is given
153
+ # @since 2.1.0
154
+ def each_codepoint(&blk)
155
+ return enum_for(__method__) unless block_given?
156
+
157
+ super { |c| yield(c).tap { notify_read(codepoint_string(c)) } }
123
158
  end
124
-
159
+
125
160
  # Reads a single character from the IO stream.
126
- #
161
+ #
127
162
  # @return [String, nil] The next character or nil if at end of stream
128
163
  # @see IO#getc
129
164
  def getc
130
- super.tap { notify_read }
165
+ super.tap { |c| notify_read(c) }
131
166
  end
132
-
167
+
168
+ # Reads a single byte from the IO stream.
169
+ #
170
+ # @return [Integer, nil] The next byte or nil if at end of stream
171
+ # @see IO#getbyte
172
+ # @since 2.1.0
173
+ def getbyte
174
+ super.tap { |b| notify_read(b) }
175
+ end
176
+
133
177
  # Reads a line from the IO stream.
134
- #
178
+ #
135
179
  # @param args [Array] Arguments to pass to the underlying IO#gets method
180
+ # @param kwargs [Hash] Keyword arguments to pass on, such as `chomp: true`
136
181
  # @return [String, nil] The next line or nil if at end of stream
137
182
  # @see IO#gets
138
- def gets(*args)
139
- super(*args).tap { notify_read }
183
+ def gets(*args, **kwargs)
184
+ super(*args, **kwargs).tap { |line| notify_read(line, chomped: kwargs[:chomp]) }
140
185
  end
141
-
186
+
142
187
  # Reads data from the IO stream.
143
- #
188
+ #
144
189
  # @param a [Array] Arguments to pass to the underlying IO#read method
145
190
  # @return [String, nil] The read data or nil if at end of stream
146
191
  # @see IO#read
147
192
  def read(*a)
148
- super(*a).tap { notify_read }
193
+ super(*a).tap { |data| notify_read(data) }
149
194
  end
150
-
195
+
196
+ # Reads at most a number of bytes, blocking only if none are available. IO.copy_stream reads
197
+ # through this when its source is not an IO itself.
198
+ #
199
+ # @param a [Array] Arguments to pass to the underlying IO#readpartial method
200
+ # @return [String] The read data
201
+ # @raise [EOFError] If at end of stream
202
+ # @see IO#readpartial
203
+ # @since 2.1.0
204
+ def readpartial(*a)
205
+ super(*a).tap { |data| notify_read(data) }
206
+ end
207
+
208
+ # Reads at most a number of bytes without blocking.
209
+ #
210
+ # @param a [Array] Arguments to pass to the underlying IO#read_nonblock method
211
+ # @param kwargs [Hash] Keyword arguments to pass on, such as `exception: false`
212
+ # @return [String, Symbol, nil] The read data, or `:wait_readable` with `exception: false`
213
+ # @see IO#read_nonblock
214
+ # @since 2.1.0
215
+ def read_nonblock(*a, **kwargs)
216
+ super(*a, **kwargs).tap { |data| notify_read(data) }
217
+ end
218
+
219
+ # Reads bytes with a low-level read, bypassing buffering.
220
+ #
221
+ # @param a [Array] Arguments to pass to the underlying IO#sysread method
222
+ # @return [String] The read data
223
+ # @raise [EOFError] If at end of stream
224
+ # @see IO#sysread
225
+ # @since 2.1.0
226
+ def sysread(*a)
227
+ super(*a).tap { |data| notify_read(data) }
228
+ end
229
+
151
230
  # Reads a specific number of bytes from the IO stream.
152
- #
231
+ #
153
232
  # @param a [Array] Arguments to pass to the underlying IO#readbytes method
154
233
  # @return [String] The read bytes
155
234
  # @see IO#readbytes
156
235
  def readbytes(*a)
157
- super(*a).tap { notify_read }
236
+ super(*a).tap { |data| notify_read(data) }
237
+ end
238
+
239
+ # Reads a single byte from the IO stream.
240
+ #
241
+ # @return [Integer] The next byte
242
+ # @raise [EOFError] If at end of stream
243
+ # @see IO#readbyte
244
+ # @since 2.1.0
245
+ def readbyte
246
+ super.tap { |b| notify_read(b) }
158
247
  end
159
-
248
+
160
249
  # Reads a single character from the IO stream.
161
- #
250
+ #
162
251
  # @return [String] The next character
163
252
  # @raise [EOFError] If at end of stream
164
253
  # @see IO#readchar
165
254
  def readchar
166
- super.tap { notify_read }
255
+ super.tap { |c| notify_read(c) }
167
256
  end
168
-
257
+
169
258
  # Reads a line from the IO stream.
170
- #
259
+ #
171
260
  # @param a [Array] Arguments to pass to the underlying IO#readline method
261
+ # @param kwargs [Hash] Keyword arguments to pass on, such as `chomp: true`
172
262
  # @return [String] The next line
173
263
  # @raise [EOFError] If at end of stream
174
264
  # @see IO#readline
175
- def readline(*a)
176
- super(*a).tap { notify_read }
265
+ def readline(*a, **kwargs)
266
+ super(*a, **kwargs).tap { |line| notify_read(line, chomped: kwargs[:chomp]) }
177
267
  end
178
-
268
+
179
269
  # Reads all lines from the IO stream.
180
- #
270
+ #
181
271
  # @param a [Array] Arguments to pass to the underlying IO#readlines method
272
+ # @param kwargs [Hash] Keyword arguments to pass on, such as `chomp: true`
182
273
  # @return [Array<String>] Array of lines
183
274
  # @see IO#readlines
184
- def readlines(*a)
185
- super(*a).tap { notify_read }
275
+ def readlines(*a, **kwargs)
276
+ super(*a, **kwargs).tap { |lines| notify_read(lines, chomped: kwargs[:chomp]) }
277
+ end
278
+
279
+ # Pushes a character back onto the stream. Not a read, so it is not reported - but on a stream
280
+ # without a position it is taken off the bytes read, so it is not counted twice.
281
+ #
282
+ # @param c [String, Integer] The character to push back
283
+ # @return [nil]
284
+ # @see IO#ungetc
285
+ # @since 2.1.0
286
+ def ungetc(c)
287
+ super(c).tap { unread(c) }
288
+ end
289
+
290
+ # Pushes a byte (or bytes) back onto the stream, see {#ungetc}.
291
+ #
292
+ # @param b [String, Integer] The byte or bytes to push back
293
+ # @return [nil]
294
+ # @see IO#ungetbyte
295
+ # @since 2.1.0
296
+ def ungetbyte(b)
297
+ super(b).tap { unread(b) }
298
+ end
299
+
300
+ # Conversions the wrapper does not pass on. Given `to_path`, IO.copy_stream opens the file by
301
+ # name itself, and given `to_io` (IO.select and the like too) it reads the wrapped IO directly
302
+ # - past the wrapper either way, with nothing reported. Without them IO.copy_stream reads
303
+ # through {#readpartial}. To get at the wrapped IO, use `__getobj__`.
304
+ #
305
+ # @since 2.1.0
306
+ WITHHELD = [:to_io, :to_path].freeze
307
+
308
+ def respond_to_missing?(name, include_private = false)
309
+ !WITHHELD.include?(name) && super
186
310
  end
187
-
311
+
312
+ # Keyword arguments reach the wrapped IO through here, as they do in Delegator's own
313
+ ruby2_keywords def method_missing(name, *args, &blk)
314
+ raise NoMethodError.new("ProgressiveIO does not hand out the IO it wraps, so that reads go through it - use __getobj__", name) if WITHHELD.include?(name)
315
+ super
316
+ end
317
+
188
318
  # Seeks to a position in the IO stream.
189
- #
319
+ #
190
320
  # @param a [Array] Arguments to pass to the underlying IO#seek method
191
321
  # @return [Integer] The new position
192
322
  # @see IO#seek
193
323
  def seek(*a)
194
324
  super(*a)
195
325
  end
196
-
197
- # def ungetc(*a)
198
- # super(*a).tap { notify_read }
199
- # end
200
-
326
+
201
327
  # Sets the position in the IO stream.
202
- #
328
+ #
203
329
  # @param p [Integer] The new position
204
330
  # @return [Integer] The new position
205
331
  # @see IO#pos=
206
332
  def pos=(p)
207
333
  super(p).tap { notify_read }
208
334
  end
209
-
335
+
210
336
  private
211
-
212
- # Calls the progress block with current position.
213
- # This method is called whenever data is read from the IO stream.
214
- #
337
+
338
+ # Calls the progress block with the current position. A stream without one (a pipe, a
339
+ # socket) raises when asked for it, and from then on the bytes read so far stand in.
340
+ #
341
+ # @param data [String, Integer, Array, nil] What the read returned
342
+ # @param chomped [Boolean] Whether the read took line separators off what it returned, which
343
+ # still came off the stream
215
344
  # @return [void]
216
- def notify_read
217
- @progress_block.call(pos) if @progress_block
345
+ def notify_read(data = nil, chomped: false)
346
+ position = positioned? ? pos : (@consumed += bytes_in(data, chomped))
347
+ @progress_block.call(position) if @progress_block
348
+ end
349
+
350
+ def positioned?
351
+ return @positioned unless @positioned.nil?
352
+ pos
353
+ @positioned = true
354
+ rescue Errno::ESPIPE, IOError
355
+ @positioned = false
356
+ end
357
+
358
+ def unread(data)
359
+ @consumed = [@consumed - bytes_in(data, false), 0].max unless positioned?
360
+ end
361
+
362
+ # With chomp, a line came off the stream with a separator that is not in it anymore. Which
363
+ # one is not known here; "\n" is the default and by far the likeliest, and the last line
364
+ # of a stream may have had none - so on a stream without a position, a chomped count can
365
+ # be a byte off per line, and the last one high by one
366
+ def bytes_in(data, chomped)
367
+ case data
368
+ when String then data.bytesize + (chomped ? 1 : 0)
369
+ when Integer then 1
370
+ when Array then data.sum { |item| bytes_in(item, chomped) }
371
+ else 0
372
+ end
373
+ end
374
+
375
+ def codepoint_string(codepoint)
376
+ codepoint.chr(external_encoding || Encoding::UTF_8)
377
+ rescue RangeError, EncodingError, NoMethodError
378
+ "?"
218
379
  end
219
380
  end
@@ -3,6 +3,7 @@ require_relative "../lib/progressive_io"
3
3
  require "flexmock"
4
4
  require "flexmock/minitest"
5
5
  require "stringio"
6
+ require "tempfile"
6
7
 
7
8
  class TestProgressiveIO < Minitest::Test
8
9
 
@@ -192,12 +193,7 @@ class TestProgressiveIO < Minitest::Test
192
193
  io.progress_block = lambda do | offset |
193
194
  assert_equal 1, offset
194
195
  end
195
-
196
- if RUBY_VERSION < "1.9"
197
- assert_equal 49, io.getc
198
- else
199
- assert_equal "1", io.getc
200
- end
196
+ assert_equal "1", io.readchar
201
197
  end
202
198
 
203
199
 
@@ -256,12 +252,13 @@ class TestProgressiveIO < Minitest::Test
256
252
  assert_equal [[22]], m
257
253
  end
258
254
 
259
- def test_seek
260
- io = e("Mary\nHad\nA little\nLamb")
261
- io.progress_block = lambda do | offset |
262
- assert_equal 6, offset
263
- end
255
+ def test_seek_is_not_a_read
256
+ io, m = e("Mary\nHad\nA little\nLamb"), []
257
+ io.progress_block = lambda { |offset| m << offset }
264
258
  io.seek(6)
259
+ assert_empty m
260
+ io.read(2)
261
+ assert_equal [8], m
265
262
  end
266
263
 
267
264
  # def test_ungetc
@@ -276,6 +273,151 @@ class TestProgressiveIO < Minitest::Test
276
273
  # assert_equal [[1], [0]], m
277
274
  # end
278
275
 
276
+ def reported(io)
277
+ [].tap { |m| io.progress_block = lambda { |offset| m << offset } }
278
+ end
279
+
280
+ def test_readpartial
281
+ io = e("Mary\nHad\nA little\nLamb")
282
+ m = reported(io)
283
+ assert_equal "Mary", io.readpartial(4)
284
+ assert_equal [4], m
285
+ end
286
+
287
+ def test_read_nonblock
288
+ io = e("Mary\nHad\nA little\nLamb")
289
+ m = reported(io)
290
+ assert_equal "Mary", io.read_nonblock(4)
291
+ assert_equal "\nHad", io.read_nonblock(4, exception: false)
292
+ assert_equal [4, 8], m
293
+ end
294
+
295
+ def test_sysread
296
+ io = e("Mary\nHad\nA little\nLamb")
297
+ m = reported(io)
298
+ assert_equal "Mary", io.sysread(4)
299
+ assert_equal [4], m
300
+ end
301
+
302
+ def test_getbyte_and_readbyte
303
+ io = e("123")
304
+ m = reported(io)
305
+ assert_equal 49, io.getbyte
306
+ assert_equal 50, io.readbyte
307
+ assert_equal [1, 2], m
308
+ end
309
+
310
+ def test_each_char
311
+ io = e("abc")
312
+ m = reported(io)
313
+ assert_equal %w[a b c], io.each_char.to_a
314
+ assert_equal [1, 2, 3], m
315
+ end
316
+
317
+ def test_each_codepoint
318
+ io = e("ab")
319
+ m = reported(io)
320
+ assert_equal [97, 98], io.each_codepoint.to_a
321
+ assert_equal [1, 2], m
322
+ end
323
+
324
+ # IO.copy_stream reads anything that is not an IO with readpartial
325
+ def test_copy_stream_is_reported
326
+ io = e("Mary\nHad\nA little\nLamb")
327
+ m = reported(io)
328
+ out = StringIO.new
329
+ IO.copy_stream(io, out)
330
+ assert_equal "Mary\nHad\nA little\nLamb", out.string
331
+ assert_equal 22, m.last
332
+ end
333
+
334
+ # A real IO would be handed to IO.copy_stream through to_io and read past the wrapper
335
+ def test_copy_stream_from_a_file_is_reported
336
+ Tempfile.create("progressive") do |f|
337
+ f.write("x" * 100_000)
338
+ f.flush
339
+ io = ProgressiveIO.new(File.open(f.path))
340
+ m = reported(io)
341
+ IO.copy_stream(io, StringIO.new)
342
+ assert_equal 100_000, m.last
343
+ refute io.respond_to?(:to_io)
344
+ refute io.respond_to?(:to_path)
345
+ assert_raises(NoMethodError) { io.to_io }
346
+ assert_kind_of File, io.__getobj__
347
+ io.__getobj__.close
348
+ end
349
+ end
350
+
351
+ def test_keyword_arguments_are_passed_on
352
+ io = e("Mary\nHad\nA little\nLamb")
353
+ m = reported(io)
354
+ assert_equal "Mary", io.gets(chomp: true)
355
+ assert_equal "Had", io.readline(chomp: true)
356
+ assert_equal ["A little", "Lamb"], io.readlines(chomp: true)
357
+ assert_equal [5, 9, 22], m
358
+
359
+ assert_equal ["Mary", "Had"], e("Mary\nHad\n").each_line(chomp: true).to_a
360
+ lines = []
361
+ e("Mary\nHad\n").each(chomp: true) { |line| lines << line }
362
+ assert_equal ["Mary", "Had"], lines
363
+ end
364
+
365
+ # A pipe or a socket has no position to report, so what has been read so far stands in for it
366
+ def test_streams_without_a_position_report_the_bytes_read
367
+ r, w = IO.pipe
368
+ w.write("Mary\nHad\nA little\nLamb")
369
+ w.close
370
+ io = ProgressiveIO.new(r)
371
+ m = reported(io)
372
+ assert_equal "Mary\n", io.gets
373
+ assert_equal "Had", io.read(3)
374
+ assert_equal "\n".ord, io.getbyte
375
+ assert_equal ["A little\n", "Lamb"], io.each_line.to_a
376
+ assert_equal [5, 8, 9, 18, 22], m
377
+ ensure
378
+ r.close if r
379
+ end
380
+
381
+ def test_bytes_pushed_back_into_a_stream_are_not_counted_twice
382
+ r, w = IO.pipe
383
+ w.write("Mary")
384
+ w.close
385
+ io = ProgressiveIO.new(r)
386
+ m = reported(io)
387
+ io.getc
388
+ io.ungetc("M")
389
+ assert_equal "Mary", io.read
390
+ assert_equal [1, 4], m
391
+ ensure
392
+ r.close if r
393
+ end
394
+
395
+ def test_copy_stream_from_a_pipe
396
+ r, w = IO.pipe
397
+ # More than a pipe holds, so it has to be written while it is read
398
+ writer = Thread.new { w.write("x" * 200_000); w.close }
399
+ io = ProgressiveIO.new(r)
400
+ m = reported(io)
401
+ IO.copy_stream(io, StringIO.new)
402
+ writer.join
403
+ assert_equal 200_000, m.last
404
+ ensure
405
+ r.close if r
406
+ end
407
+
408
+ def test_counts_reads_made_before_a_block_was_set
409
+ r, w = IO.pipe
410
+ w.write("Mary\nHad\n")
411
+ w.close
412
+ io = ProgressiveIO.new(r)
413
+ io.gets
414
+ m = reported(io)
415
+ io.gets
416
+ assert_equal [9], m
417
+ ensure
418
+ r.close if r
419
+ end
420
+
279
421
  def test_poseq
280
422
  io = e("Mary\nHad\nA little\nLamb")
281
423
  m = []
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: progressive_io
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.2
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-01-13 00:00:00.000000000 Z
10
+ date: 2026-10-30 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake
@@ -77,14 +77,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
77
77
  requirements:
78
78
  - - ">="
79
79
  - !ruby/object:Gem::Version
80
- version: 1.8.7
80
+ version: '3.2'
81
81
  required_rubygems_version: !ruby/object:Gem::Requirement
82
82
  requirements:
83
83
  - - ">="
84
84
  - !ruby/object:Gem::Version
85
85
  version: '0'
86
86
  requirements: []
87
- rubygems_version: 3.6.6
87
+ rubygems_version: 3.6.2
88
88
  specification_version: 4
89
89
  summary: An IO wrapper that sends reports on the offset to a callback, on each read
90
90
  operation