fix: CapturedIO.__init__ type annotations to accept Optional[StringIO] by eachimei · Pull Request #15172 · ipython/ipython

Problem

CapturedIO.__init__ declares its stdout and stderr parameters as StringIO, but capture_output.__enter__ passes None for either parameter when the corresponding capture flag is False:

# capture_output.__enter__
stdout = stderr = outputs = None
if self.stdout:
    stdout = sys.stdout = StringIO()
if self.stderr:
    stderr = sys.stderr = StringIO()
return CapturedIO(stdout, stderr, outputs)  # stdout/stderr may be None

The runtime behaviour is correct — the stdout and stderr properties already handle None gracefully:

@property
def stdout(self) -> str:
    if not self._stdout:  # handles None
        return ''
    return self._stdout.getvalue()

But the type annotations don't reflect this, causing false positives in static analysis tools.

Origin

The incorrect annotation was introduced in IPython 9.12.0 via 74904ca ("Extend MonkeyType annotations to terminal, testing, and more utils modules"). MonkeyType infers types from runtime traces, and the tracing run apparently never exercised the capture_output(stdout=False) or capture_output(stderr=False) paths — so it only observed StringIO being passed, never None.

How to reproduce

Run pyright 1.1.396+ on any code that constructs CapturedIO with None arguments, or on IPython's own capture_output.__enter__:

error: Argument of type "StringIO | None" cannot be assigned to parameter "stdout"
  of type "StringIO" in function "__init__"
    "None" is not assignable to "StringIO" (reportArgumentType)

Note: this is not caught by IPython's own mypy CI because IPython.utils.capture is listed in the mypy overrides in pyproject.toml with ignore_errors = true.

Fix

  • Change stdout: StringIOstdout: Optional[StringIO] (same for stderr) in CapturedIO.__init__.
  • Add a direct regression test that constructs CapturedIO(None, None) and asserts the expected empty-string behaviour. Existing tests (test_capture_output_no_stdout, test_capture_output_no_stderr) exercise the None path indirectly via capture_output, but never construct CapturedIO directly with None.

No runtime behaviour change.