angry_io 0.2.0 → 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: e481e761566636d3cc9fcf965a2d5880a3b65e818cc1312c1c62897ccd6c16a1
4
- data.tar.gz: 702fc8469bf85c8aec44fbfa861fcc86263b5bbb3bdb28b25338f26d3148caa7
3
+ metadata.gz: efd2cce37a69f805e637ea406088049b150bb0ff9192a7423100add8b7a29342
4
+ data.tar.gz: 4e25659b95836fc83544b295cf40b6428d094cf194356ff1e4e0c0b8e7c1ad42
5
5
  SHA512:
6
- metadata.gz: a862f0a1adf1077e3b7c6a43192692d22086e72f0761f7bd7629e13fc7b23908e798308ba969482b78d411686f34edee159544a2f93840d1391a18728f240c0a
7
- data.tar.gz: 2ed4b06091d5afb5a9449e5fd3acd649995a09ee2e73ca8f4bf68b9f3cd27bef67d91959b4c2b9f503b1eec0847ff7b268d502e5c071fa11e866c11103a6ac18
6
+ metadata.gz: 72682994c9e8647d1405a46aa15366c377325f31396767f6f6efe0ef6e6722f875bdce839a0094c04db309e39cb691a05d3daa43d5b4aeff2372430f1e69cee1
7
+ data.tar.gz: 51ee62f1212076d50984ed31c221760464fbd2f15990abd8f4c241fd4294bcc259bffa5b9c46ce54942ab6a5b178582272cd9c09de08dae7fa67baa874bf7e0b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-09-15
4
+
5
+ - Reopening `$stdout`/`$stderr` onto a real IO (e.g. `$stdout.reopen(File::NULL)`) no longer raises `TypeError` under `AngryIo::Stream`. `Stream#reopen` now delegates an IO argument to the original real stream it replaced (a `dup2` that survives the caller closing the other IO) and hands the global back so writes reach the reopened IO; non-IO arguments still defer to StringIO's own buffer reset. The new `AngryIo.real_stream_for` returns the real stream a swapped buffer replaced.
6
+ - ActiveSupport's `capture` and `silence_stream` (from `ActiveSupport::Testing::Stream`) now work alongside AngryIo. They use the same reopen-onto-IO pattern as `capture_subprocess_io`, so AngryIo now runs them under `with_real_streams`, substituting the real stream for the StringIO buffer that `silence_stream` binds at call time.
7
+ - `AngryIo.with_real_streams` is now reentrant: a nested call (e.g. ActiveSupport's `capture` wrapping `quietly`, whose nested `silence_stream` re-enters the helper) no longer swaps the globals back to the Angry buffers while the outer call still expects the real streams. It no-ops when the real streams are already current.
8
+
9
+ ## [0.3.0] - 2026-09-06
10
+
11
+ - Zero-byte writes (e.g. `$stderr.print("")`) no longer raise; `AngryIo::Stream` now raises only when a write would actually emit output. The error message changed from StringIO's `not opened for writing` to `AngryIo::Stream is not writable: ...`, which includes the offending output.
12
+ - Minitest's `capture_subprocess_io` and RSpec's `to_stdout_from_any_process` / `to_stderr_from_any_process` matchers now work alongside AngryIo. These helpers reopen `$stdout`/`$stderr` onto Tempfiles so subprocesses inherit the file descriptors, which fails on the StringIO-based `AngryIo::Stream`; AngryIo now restores the real streams for their duration via a new `AngryIo.with_real_streams` helper.
13
+
3
14
  ## [0.2.0] - 2026-09-04
4
15
 
5
16
  - Remove the `angry_io/enable_for_ci_true` convenience require; require `angry_io/rspec` or `angry_io/minitest` directly instead.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AngryIo
2
2
 
3
- Friends don't let friends write tests or specs that outputs to stdout/stderr. When you look at your test output, you should only see green dots, right?
3
+ Friends don't let friends write tests or specs that output to stdout/stderr. When you look at your test output, you should only see green dots, right?
4
4
 
5
5
  AngryIo replaces `$stdout` and `$stderr` during your test suite with an IO that _raises on write_, so accidental output fails loudly instead of quietly cluttering your output. It ships self-registering adapters for both RSpec and Minitest, gated by a configurable `enabled` callable.
6
6
 
@@ -32,10 +32,12 @@ require "angry_io/rspec" # or "angry_io/minitest"
32
32
  By default, AngryIo is always on, and every test that writes to `$stdout` or `$stderr` now raises:
33
33
 
34
34
  ```
35
- IOError: not opened for writing
35
+ IOError: AngryIo::Stream is not writable: "your output here"
36
36
  ```
37
37
 
38
- If you want to allow test output in some environments but not in others - e.g allow output when testing locally, but fail on CI - you can configure like this:
38
+ Zero-byte writes (e.g. `$stderr.print("")`) emit nothing, so they are allowed.
39
+
40
+ If you want to allow test output in some environments but not in others — e.g., allow output when testing locally, but fail on CI — you can configure like this:
39
41
 
40
42
  ```ruby
41
43
  # On by default; turn it off in environments where you'll allow real output.
@@ -66,9 +68,22 @@ class PrintTest < Minitest::Test
66
68
  end
67
69
  ```
68
70
 
71
+ The same call works in Minitest's spec format, inside the `describe` block:
72
+
73
+ ```ruby
74
+ # Minitest spec format — opt out a whole describe block
75
+ describe "printing" do
76
+ i_absolutely_need_to_write_to_stdout!
77
+
78
+ it "prints" do
79
+ puts "ok"
80
+ end
81
+ end
82
+ ```
83
+
69
84
  ## Configuration
70
85
 
71
- `AngryIo.configure` yields a config struct with two fields:
86
+ `AngryIo.configure` yields a config struct with one field:
72
87
 
73
88
  | Field | Default | Description |
74
89
  | --- | --- | --- |
@@ -76,7 +91,7 @@ end
76
91
 
77
92
  ## How it works
78
93
 
79
- `AngryIo::Stream` is a `StringIO` backed by a frozen empty string, which makes `StringIO` refuse writes with an `IOError`. Around each test, the adapter swaps `$stdout` and `$stderr` to fresh `AngryIo::Stream` instances and restores the originals (closing the buffers) in an `ensure`.
94
+ `AngryIo::Stream` is a `StringIO` whose write methods raise an `IOError` when given anything but an empty string. Around each test, the adapter swaps `$stdout` and `$stderr` to fresh `AngryIo::Stream` instances and restores the originals (closing the buffers) in an `ensure`.
80
95
 
81
96
  ## Development
82
97
 
@@ -24,10 +24,21 @@ module AngryIo
24
24
  end
25
25
  end
26
26
 
27
+ # capture_subprocess_io reopens $stdout/$stderr onto Tempfiles so
28
+ # subprocesses inherit the file descriptors, which fails on StringIO-based
29
+ # streams. A test calling it captures output on purpose, so restore the
30
+ # real streams for the duration of the capture.
31
+ module CaptureSubprocessIo
32
+ def capture_subprocess_io(&block)
33
+ AngryIo.with_real_streams { super }
34
+ end
35
+ end
36
+
27
37
  def self.setup!
28
- test = ::Minitest::Test
29
- test.extend(ClassMethods)
30
- test.prepend(Adapter)
38
+ ::Minitest::Test.extend(ClassMethods)
39
+ ::Minitest::Test.prepend(Adapter)
40
+ ::Minitest::Assertions.prepend(CaptureSubprocessIo)
41
+ AngryIo.hook_active_support_stream!
31
42
  end
32
43
  end
33
44
  end
@@ -8,6 +8,29 @@ require "angry_io"
8
8
  # registration works regardless of Bundler.require ordering.
9
9
  module AngryIo
10
10
  module RSpec
11
+ # The to_*_from_any_process matchers reopen $stdout/$stderr onto Tempfiles
12
+ # so subprocesses inherit the file descriptors, which fails on
13
+ # StringIO-based streams. An example using these matchers captures output
14
+ # on purpose, so restore the real streams while the matcher grabs the
15
+ # stream...
16
+ module FromAnyProcess
17
+ def to_stdout_from_any_process
18
+ AngryIo.with_real_streams { super }
19
+ end
20
+
21
+ def to_stderr_from_any_process
22
+ AngryIo.with_real_streams { super }
23
+ end
24
+ end
25
+
26
+ # ...and while the block runs, so Ruby-level writes inside the expect block
27
+ # are captured by the Tempfile instead of raising against the Angry stream.
28
+ module CaptureStreamToTempfile
29
+ def capture(block)
30
+ AngryIo.with_real_streams { super }
31
+ end
32
+ end
33
+
11
34
  def self.setup!
12
35
  ::RSpec.configure do |config|
13
36
  config.around do |example|
@@ -16,9 +39,14 @@ module AngryIo
16
39
  AngryIo.around_streams(opted_out: opt_out) { example.run }
17
40
  end
18
41
  end
42
+
43
+ ::RSpec::Matchers::BuiltIn::Output.prepend(FromAnyProcess)
44
+ ::RSpec::Matchers::BuiltIn::CaptureStreamToTempfile.prepend(CaptureStreamToTempfile)
45
+ AngryIo.hook_active_support_stream!
19
46
  end
20
47
  end
21
48
  end
22
49
 
23
50
  require "rspec/core"
51
+ require "rspec/expectations"
24
52
  AngryIo::RSpec.setup!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AngryIo
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/angry_io.rb CHANGED
@@ -5,12 +5,84 @@ require_relative "angry_io/version"
5
5
 
6
6
  module AngryIo
7
7
  # An IO that raises when you write to it, so tests can't silently pollute
8
- # stdout/stderr. Backed by a frozen empty string, which makes StringIO
9
- # refuse writes with an IOError ("not opened for writing").
8
+ # stdout/stderr. Zero-byte writes like `$stderr.print("")` emit nothing and
9
+ # are allowed; anything that would produce output raises an IOError.
10
10
  class Stream < StringIO
11
11
  def initialize
12
- super(-"")
12
+ super(+"")
13
13
  end
14
+
15
+ # StringIO's print, puts, printf, <<, syswrite and write_nonblock all
16
+ # funnel through #write, so this one guard catches them.
17
+ def write(*strings)
18
+ strings.each do |string|
19
+ next if string.to_s.empty?
20
+
21
+ raise IOError, "AngryIo::Stream is not writable: #{string.to_s.inspect}"
22
+ end
23
+ super
24
+ end
25
+
26
+ # StringIO#putc writes directly in C, bypassing #write. It always emits a
27
+ # byte, so it always refuses.
28
+ def putc(char)
29
+ raise IOError, "AngryIo::Stream is not writable: #{char.inspect}"
30
+ end
31
+
32
+ # `IO#reopen(io)` redirects a real $stdout/$stderr onto another IO (a
33
+ # socket, a File, etc.) so the process's output flows there. StringIO only
34
+ # accepts a String, so reopening onto an IO would raise
35
+ # `TypeError: can't convert IO into StringIO` — which breaks code that
36
+ # reopens the streams after a fork. Delegate to the original real stream we
37
+ # replaced (which dup2's the other IO's fd onto the stdout/stderr fd,
38
+ # surviving the caller later closing the other IO), then hand the global
39
+ # back so subsequent writes reach the reopened IO instead of this guard.
40
+ # Non-IO args defer to StringIO's own reopen (buffer reset).
41
+ def reopen(other = nil, *rest)
42
+ if other.is_a?(IO) && (swap = Thread.current.thread_variable_get(:angry_io_swap))
43
+ real_stdout, real_stderr, stdout_buffer, stderr_buffer = swap
44
+ if equal?(stdout_buffer)
45
+ real_stdout.reopen(other)
46
+ $stdout = real_stdout
47
+ elsif equal?(stderr_buffer)
48
+ real_stderr.reopen(other)
49
+ $stderr = real_stderr
50
+ else
51
+ # Not one of the swapped buffers (a standalone Stream someone assigned
52
+ # to $stdout outside the adapter): no original to delegate to, so match
53
+ # StringIO and raise TypeError instead of silently no-op'ing.
54
+ super
55
+ end
56
+ self
57
+ elsif other.nil? && rest.empty?
58
+ super() # StringIO#reopen() resets the buffer
59
+ else
60
+ super
61
+ end
62
+ end
63
+ end
64
+
65
+ # ActiveSupport::Testing::Stream#capture reopens $stdout/$stderr onto a
66
+ # Tempfile and later restores them via `reopen(dup)` — a dup2-then-restore
67
+ # pattern that only works on real IOs (Tempfile is a Delegator, and StringIO
68
+ # can't be reopened onto one). Restore the real streams for the duration of a
69
+ # capture/silence, the same way Minitest's capture_subprocess_io is handled.
70
+ module ActiveSupportStreamCapture
71
+ def capture(stream)
72
+ AngryIo.with_real_streams { super }
73
+ end
74
+
75
+ def silence_stream(stream)
76
+ # `stream` is bound at call time, so under AngryIo it's the StringIO
77
+ # buffer — which AS can't actually reopen onto IO::NULL (it just resets
78
+ # the buffer), leaking the block's writes to the real stream. Substitute
79
+ # the real stream it replaced so AS silences (and the block writes to) the
80
+ # real IO.
81
+ real = AngryIo.real_stream_for(stream)
82
+ AngryIo.with_real_streams { super(real || stream) }
83
+ end
84
+
85
+ private :capture, :silence_stream
14
86
  end
15
87
 
16
88
  Config = Struct.new(:enabled) do
@@ -42,15 +114,70 @@ module AngryIo
42
114
  stderr_buffer = Stream.new
43
115
  $stdout = stdout_buffer
44
116
  $stderr = stderr_buffer
117
+ previous_swap = Thread.current.thread_variable_get(:angry_io_swap)
118
+ Thread.current.thread_variable_set(:angry_io_swap, [original_stdout, original_stderr, stdout_buffer, stderr_buffer])
45
119
 
46
120
  begin
47
121
  yield
48
122
  ensure
123
+ Thread.current.thread_variable_set(:angry_io_swap, previous_swap)
49
124
  $stdout = original_stdout
50
125
  $stderr = original_stderr
51
126
  stdout_buffer.close
52
127
  stderr_buffer.close
53
128
  end
54
129
  end
130
+
131
+ # Run the block with the pre-swap $stdout/$stderr restored, re-swapping the
132
+ # AngryIo::Stream buffers afterwards. Helpers like Minitest's
133
+ # capture_subprocess_io need this: they reopen $stdout/$stderr onto
134
+ # Tempfiles so subprocesses inherit the file descriptors, which only works
135
+ # on real IOs. No-ops when no swap is active, or when the real streams are
136
+ # already current (a nested call inside another with_real_streams — e.g.
137
+ # ActiveSupport's `capture` wrapping `quietly` — so the inner ensure doesn't
138
+ # clobber the outer's swap-back).
139
+ def with_real_streams
140
+ swap = Thread.current.thread_variable_get(:angry_io_swap)
141
+ return yield unless swap
142
+
143
+ real_stdout, real_stderr, stdout_buffer, stderr_buffer = swap
144
+ return yield if $stdout.equal?(real_stdout) && $stderr.equal?(real_stderr)
145
+
146
+ $stdout = real_stdout
147
+ $stderr = real_stderr
148
+
149
+ begin
150
+ yield
151
+ ensure
152
+ $stdout = stdout_buffer
153
+ $stderr = stderr_buffer
154
+ end
155
+ end
156
+
157
+ # If `stream` is one of the active swap's AngryIo::Stream buffers, return the
158
+ # real stream it replaced; otherwise nil. Used by
159
+ # ActiveSupportStreamCapture#silence_stream to redirect silencing onto the
160
+ # real IO instead of the StringIO buffer.
161
+ def real_stream_for(stream)
162
+ swap = Thread.current.thread_variable_get(:angry_io_swap)
163
+ return nil unless swap
164
+
165
+ real_stdout, real_stderr, stdout_buffer, stderr_buffer = swap
166
+ if stream.equal?(stdout_buffer)
167
+ real_stdout
168
+ elsif stream.equal?(stderr_buffer)
169
+ real_stderr
170
+ end
171
+ end
172
+
173
+ # Prepend hooks so ActiveSupport::Testing::Stream#capture / #silence_stream
174
+ # run with the real $stdout/$stderr restored (see ActiveSupportStreamCapture).
175
+ # No-ops when ActiveSupport isn't loaded.
176
+ def hook_active_support_stream!
177
+ require "active_support/testing/stream"
178
+ ActiveSupport::Testing::Stream.prepend(ActiveSupportStreamCapture)
179
+ rescue LoadError
180
+ # ActiveSupport isn't available; nothing to hook.
181
+ end
55
182
  end
56
183
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: angry_io
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Quorning
@@ -49,7 +49,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
49
49
  - !ruby/object:Gem::Version
50
50
  version: '0'
51
51
  requirements: []
52
- rubygems_version: 4.0.16
52
+ rubygems_version: 4.0.20
53
53
  specification_version: 4
54
54
  summary: An IO that raises on write, to keep tests from polluting stdout/stderr.
55
55
  test_files: []