angry_io 0.3.0 → 0.5.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: 597667423eb4ee7ad9daaf21e0ee59e86f361a07e37a5b6d7bdc03a89978ead6
4
- data.tar.gz: df59a437ba441870cc0d6788748ea115d632b9c7770266a1ef901e4a966cb413
3
+ metadata.gz: 89b93a80d709137ae29eb42cfda731bd4196ad40cfc4e56f2b3a12fc2961eee9
4
+ data.tar.gz: 49b1f5f622b6a1796013f0c370391a5e3941f6ffb67b8fb15b6b762470b8a093
5
5
  SHA512:
6
- metadata.gz: 44154fa02cb72e8cbf89c5c24b3a4996a4600b0e3d17c484a50222875ea1b7fdc9c830150a7b618cee54eb05ce6cdab82fb1bc67dcebc4b42d7facd4578a5429
7
- data.tar.gz: '083c923145f4247d847613ba082f1f9d509fd7dd7cdb9bce34b5db89f722051e5ca07ef4057af6c111a63656fa3fdf8be19957dce330a1cdf98babbb2c80e046'
6
+ metadata.gz: 80fbb90830d2836b63b73302b5a7f12dad52a8a05353290cc286b5493cd75446bd343f2c660f373716d0beb6e2b130014502751213fb22fb37e36c8d6d193650
7
+ data.tar.gz: 29f6e481c7e4dde2c6e45db4ae6c03d6621e496673565144b79149057e3d74343f40ad05669bf7e381f166efb64c7ce238d681a4a60d907e4f3d16e5dafe7384
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-09-17
4
+
5
+ - Replace `AngryIO.configure` / `AngryIO::Config` with a direct `AngryIO.enabled` accessor (still a callable, default `-> { true }`). This is a breaking change: replace `AngryIO.configure { |c| c.enabled = ... }` with `AngryIO.enabled = ...`.
6
+ - Rename the core module from `AngryIo` to `AngryIO` (`AngryIO::Stream`, `AngryIO.enabled`, `AngryIO.with_real_streams`, etc.). This is a breaking change: no backwards-compatibility alias is provided, so any code referencing the old `AngryIo` constant must be updated. The lowercase gem name (`angry_io`) and require paths (`angry_io/rspec`, `angry_io/minitest`) are unchanged.
7
+
8
+ ## [0.4.0] - 2026-09-15
9
+
10
+ - 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.
11
+ - 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.
12
+ - `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.
13
+
3
14
  ## [0.3.0] - 2026-09-06
4
15
 
5
16
  - 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.
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # AngryIo
1
+ # AngryIO
2
2
 
3
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
- 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.
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
 
7
7
  ## Installation
8
8
 
@@ -29,21 +29,20 @@ Depending on your test framework, you should require either `angry_io/rspec` or
29
29
  require "angry_io/rspec" # or "angry_io/minitest"
30
30
  ```
31
31
 
32
- By default, AngryIo is always on, and every test that writes to `$stdout` or `$stderr` now raises:
32
+ By default, AngryIO is always on, and every test that writes to `$stdout` or `$stderr` now raises:
33
33
 
34
34
  ```
35
- IOError: AngryIo::Stream is not writable: "your output here"
35
+ IOError: AngryIO::Stream is not writable: "your output here"
36
36
  ```
37
37
 
38
38
  Zero-byte writes (e.g. `$stderr.print("")`) emit nothing, so they are allowed.
39
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:
40
+ If you want to allow test output in some environments but not in others — e.g., fail on CI, but allow output when testing locally so REPL debuggers like `binding.irb` or `pry` work — set the `enabled` predicate:
41
41
 
42
42
  ```ruby
43
- # On by default; turn it off in environments where you'll allow real output.
44
- AngryIo.configure do |config|
45
- config.enabled = -> { ENV["CI"] == "true" }
46
- end
43
+ # On by default; turn it off in environments where you'll allow real output
44
+ # (e.g. locally, so a debugger can write to stdout without raising).
45
+ AngryIO.enabled = -> { ENV["CI"] == "true" }
47
46
  ```
48
47
 
49
48
  ### Opting out
@@ -83,15 +82,11 @@ end
83
82
 
84
83
  ## Configuration
85
84
 
86
- `AngryIo.configure` yields a config struct with one field:
87
-
88
- | Field | Default | Description |
89
- | --- | --- | --- |
90
- | `enabled` | `-> { true }` | A callable returning whether AngryIo is active. Invoked once per test, so it can read env vars or feature flags live. |
85
+ `AngryIO.enabled` is a callable returning whether AngryIO is active. It defaults to `-> { true }` and is invoked once per test, so it can read env vars or feature flags live — e.g. `-> { ENV["CI"] == "true" }` to enforce on CI while leaving local debuggers usable.
91
86
 
92
87
  ## How it works
93
88
 
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`.
89
+ `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`.
95
90
 
96
91
  ## Development
97
92
 
@@ -113,4 +108,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
113
108
 
114
109
  ## Code of Conduct
115
110
 
116
- Everyone interacting in the AngryIo project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](https://github.com/bquorning/angry_io/blob/main/CODE_OF_CONDUCT.md).
111
+ Everyone interacting in the AngryIO project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](https://github.com/bquorning/angry_io/blob/main/CODE_OF_CONDUCT.md).
@@ -3,14 +3,14 @@
3
3
  require "angry_io"
4
4
 
5
5
  # Prepends an override of +Minitest::Test#run+ that swaps $stdout/$stderr to
6
- # AngryIo::Stream instances around each test. A test class opts out by calling
6
+ # AngryIO::Stream instances around each test. A test class opts out by calling
7
7
  # +i_absolutely_need_to_write_to_stdout!+ in its body. Force-loads Minitest so
8
8
  # registration works regardless of Bundler.require ordering.
9
- module AngryIo
9
+ module AngryIO
10
10
  module Minitest
11
11
  module Adapter
12
12
  def run
13
- AngryIo.around_streams(opted_out: self.class.i_absolutely_need_to_write_to_stdout?) { super }
13
+ AngryIO.around_streams(opted_out: self.class.i_absolutely_need_to_write_to_stdout?) { super }
14
14
  end
15
15
  end
16
16
 
@@ -30,7 +30,7 @@ module AngryIo
30
30
  # real streams for the duration of the capture.
31
31
  module CaptureSubprocessIo
32
32
  def capture_subprocess_io(&block)
33
- AngryIo.with_real_streams { super }
33
+ AngryIO.with_real_streams { super }
34
34
  end
35
35
  end
36
36
 
@@ -38,9 +38,10 @@ module AngryIo
38
38
  ::Minitest::Test.extend(ClassMethods)
39
39
  ::Minitest::Test.prepend(Adapter)
40
40
  ::Minitest::Assertions.prepend(CaptureSubprocessIo)
41
+ AngryIO.hook_active_support_stream!
41
42
  end
42
43
  end
43
44
  end
44
45
 
45
46
  require "minitest"
46
- AngryIo::Minitest.setup!
47
+ AngryIO::Minitest.setup!
@@ -2,11 +2,11 @@
2
2
 
3
3
  require "angry_io"
4
4
 
5
- # Registers an +around+ hook that swaps $stdout/$stderr to AngryIo::Stream
5
+ # Registers an +around+ hook that swaps $stdout/$stderr to AngryIO::Stream
6
6
  # instances during each example, unless the example opts out via the
7
7
  # +:i_absolutely_need_to_write_to_stdout+ metadata. Force-loads rspec-core so
8
8
  # registration works regardless of Bundler.require ordering.
9
- module AngryIo
9
+ module AngryIO
10
10
  module RSpec
11
11
  # The to_*_from_any_process matchers reopen $stdout/$stderr onto Tempfiles
12
12
  # so subprocesses inherit the file descriptors, which fails on
@@ -15,11 +15,11 @@ module AngryIo
15
15
  # stream...
16
16
  module FromAnyProcess
17
17
  def to_stdout_from_any_process
18
- AngryIo.with_real_streams { super }
18
+ AngryIO.with_real_streams { super }
19
19
  end
20
20
 
21
21
  def to_stderr_from_any_process
22
- AngryIo.with_real_streams { super }
22
+ AngryIO.with_real_streams { super }
23
23
  end
24
24
  end
25
25
 
@@ -27,7 +27,7 @@ module AngryIo
27
27
  # are captured by the Tempfile instead of raising against the Angry stream.
28
28
  module CaptureStreamToTempfile
29
29
  def capture(block)
30
- AngryIo.with_real_streams { super }
30
+ AngryIO.with_real_streams { super }
31
31
  end
32
32
  end
33
33
 
@@ -36,16 +36,17 @@ module AngryIo
36
36
  config.around do |example|
37
37
  opt_out = example.metadata.key?(:i_absolutely_need_to_write_to_stdout) &&
38
38
  example.metadata[:i_absolutely_need_to_write_to_stdout]
39
- AngryIo.around_streams(opted_out: opt_out) { example.run }
39
+ AngryIO.around_streams(opted_out: opt_out) { example.run }
40
40
  end
41
41
  end
42
42
 
43
43
  ::RSpec::Matchers::BuiltIn::Output.prepend(FromAnyProcess)
44
44
  ::RSpec::Matchers::BuiltIn::CaptureStreamToTempfile.prepend(CaptureStreamToTempfile)
45
+ AngryIO.hook_active_support_stream!
45
46
  end
46
47
  end
47
48
  end
48
49
 
49
50
  require "rspec/core"
50
51
  require "rspec/expectations"
51
- AngryIo::RSpec.setup!
52
+ AngryIO::RSpec.setup!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module AngryIo
4
- VERSION = "0.3.0"
3
+ module AngryIO
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/angry_io.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "stringio"
4
4
  require_relative "angry_io/version"
5
5
 
6
- module AngryIo
6
+ module AngryIO
7
7
  # An IO that raises when you write to it, so tests can't silently pollute
8
8
  # stdout/stderr. Zero-byte writes like `$stderr.print("")` emit nothing and
9
9
  # are allowed; anything that would produce output raises an IOError.
@@ -18,7 +18,7 @@ module AngryIo
18
18
  strings.each do |string|
19
19
  next if string.to_s.empty?
20
20
 
21
- raise IOError, "AngryIo::Stream is not writable: #{string.to_s.inspect}"
21
+ raise IOError, "AngryIO::Stream is not writable: \"#{string}\""
22
22
  end
23
23
  super
24
24
  end
@@ -26,32 +26,77 @@ module AngryIo
26
26
  # StringIO#putc writes directly in C, bypassing #write. It always emits a
27
27
  # byte, so it always refuses.
28
28
  def putc(char)
29
- raise IOError, "AngryIo::Stream is not writable: #{char.inspect}"
29
+ raise IOError, "AngryIO::Stream is not writable: \"#{char}\""
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
30
62
  end
31
63
  end
32
64
 
33
- Config = Struct.new(:enabled) do
34
- def initialize
35
- self.enabled = -> { true }
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 }
36
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
37
86
  end
38
87
 
39
- @config = Config.new
88
+ @enabled = -> { true }
40
89
 
41
90
  class << self
42
- attr_reader :config
43
-
44
- def configure
45
- yield @config
46
- end
91
+ attr_accessor :enabled
47
92
 
48
- # Swap $stdout and $stderr to AngryIo::Stream instances around the given
93
+ # Swap $stdout and $stderr to AngryIO::Stream instances around the given
49
94
  # block, restoring them (and closing the buffers) in an ensure. No-ops when
50
- # +opted_out+ is true or when +config.enabled+ returns false, so the block
95
+ # +opted_out+ is true or when +enabled+ returns false, so the block
51
96
  # runs untouched.
52
97
  def around_streams(opted_out: false)
53
98
  return yield if opted_out
54
- return yield unless config.enabled.call
99
+ return yield unless enabled.call
55
100
 
56
101
  original_stdout = $stdout
57
102
  original_stderr = $stderr
@@ -74,15 +119,20 @@ module AngryIo
74
119
  end
75
120
 
76
121
  # Run the block with the pre-swap $stdout/$stderr restored, re-swapping the
77
- # AngryIo::Stream buffers afterwards. Helpers like Minitest's
122
+ # AngryIO::Stream buffers afterwards. Helpers like Minitest's
78
123
  # capture_subprocess_io need this: they reopen $stdout/$stderr onto
79
124
  # Tempfiles so subprocesses inherit the file descriptors, which only works
80
- # on real IOs. No-ops when no swap is active.
125
+ # on real IOs. No-ops when no swap is active, or when the real streams are
126
+ # already current (a nested call inside another with_real_streams — e.g.
127
+ # ActiveSupport's `capture` wrapping `quietly` — so the inner ensure doesn't
128
+ # clobber the outer's swap-back).
81
129
  def with_real_streams
82
130
  swap = Thread.current.thread_variable_get(:angry_io_swap)
83
131
  return yield unless swap
84
132
 
85
133
  real_stdout, real_stderr, stdout_buffer, stderr_buffer = swap
134
+ return yield if $stdout.equal?(real_stdout) && $stderr.equal?(real_stderr)
135
+
86
136
  $stdout = real_stdout
87
137
  $stderr = real_stderr
88
138
 
@@ -93,5 +143,31 @@ module AngryIo
93
143
  $stderr = stderr_buffer
94
144
  end
95
145
  end
146
+
147
+ # If `stream` is one of the active swap's AngryIO::Stream buffers, return the
148
+ # real stream it replaced; otherwise nil. Used by
149
+ # ActiveSupportStreamCapture#silence_stream to redirect silencing onto the
150
+ # real IO instead of the StringIO buffer.
151
+ def real_stream_for(stream)
152
+ swap = Thread.current.thread_variable_get(:angry_io_swap)
153
+ return nil unless swap
154
+
155
+ real_stdout, real_stderr, stdout_buffer, stderr_buffer = swap
156
+ if stream.equal?(stdout_buffer)
157
+ real_stdout
158
+ elsif stream.equal?(stderr_buffer)
159
+ real_stderr
160
+ end
161
+ end
162
+
163
+ # Prepend hooks so ActiveSupport::Testing::Stream#capture / #silence_stream
164
+ # run with the real $stdout/$stderr restored (see ActiveSupportStreamCapture).
165
+ # No-ops when ActiveSupport isn't loaded.
166
+ def hook_active_support_stream!
167
+ require "active_support/testing/stream"
168
+ ActiveSupport::Testing::Stream.prepend(ActiveSupportStreamCapture)
169
+ rescue LoadError
170
+ # ActiveSupport isn't available; nothing to hook.
171
+ end
96
172
  end
97
173
  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.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Quorning
@@ -9,7 +9,7 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: AngryIo replaces $stdout/$stderr during tests with an IO that raises
12
+ description: AngryIO replaces $stdout/$stderr during tests with an IO that raises
13
13
  on write, so accidental output fails loudly instead of cluttering CI logs. Ships
14
14
  RSpec and Minitest adapters that self-register, gated by a configurable enabled
15
15
  callable.
@@ -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: []