io.StringIO - это "файл в памяти" для строк (str). Почти как BytesIO, но уже текстовый, с теми же методами, что у обычного текстового файла (read, write, readline, seek, итерация по строкам).
Разберёмся по паттернам io.StringIO: от самых частых до более хитрых.
Самый базовый паттерн: нужно что-то, что умеет писать/читать строки, как файл - используем StringIO.
from io import StringIO f = StringIO() f.write("hello ") f.write("world\n") f.seek(0) print(f.read()) # "hello world\n"
Когда использовать:
Классика: функция читает/пишет в файл - в тестах подсовываем StringIO.
from io import StringIO def process_file(f): # f - любой текстовый поток: open(...), StringIO и т.п. lines = [line.strip() for line in f] return ",".join(lines) def test_process_file(): fake = StringIO("a\nb\nc\n") assert process_file(fake) == "a,b,c"
Плюсы:
read).Паттерн: временно заменить sys.stdout/sys.stderr на StringIO, чтобы поймать вывод.
import sys from io import StringIO buf = StringIO() old_stdout = sys.stdout sys.stdout = buf try: print("hello") print("world") finally: sys.stdout = old_stdout captured = buf.getvalue() print("Captured:", repr(captured)) # "hello\nworld\n"
Частый сценарий:
Паттерн: накапливать куски текста через write, а в конце делать getvalue().
from io import StringIO buf = StringIO() for i in range(5): buf.write(f"line {i}\n") result = buf.getvalue() print(result)
Почему так: конкатенация "строка" + "строка" в цикле создаёт много временных объектов; StringIO под капотом делает это более оптимально.
Множество функций/библиотек принимают параметр типа file-like object, например:
csv.reader / csv.writer),configparser,import csv from io import StringIO csv_text = "name,age\nAlice,30\nBob,25\n" f = StringIO(csv_text) reader = csv.DictReader(f) for row in reader: print(row) # {'name': 'Alice', 'age': '30'}, ...
f = StringIO() writer = csv.writer(f) writer.writerow(["name", "age"]) writer.writerow(["Alice", 30]) writer.writerow(["Bob", 25]) csv_text = f.getvalue() print(csv_text)
Хочется "писать лог" в стильном API (write, print(..., file=...)), а потом всё это отправить письмом/по сети/записать в одно место.
from io import StringIO from datetime import datetime log = StringIO() def log_msg(msg): ts = datetime.now().isoformat(timespec="seconds") print(f"[{ts}] {msg}", file=log) log_msg("start") log_msg("doing stuff") log_msg("finish") ## потом, например, отправляем содержимое body = log.getvalue() print("EMAIL BODY:\n", body)
Любой print умеет выводить в произвольный поток через file=.
from io import StringIO buf = StringIO() print("hello", "world", file=buf) print("another line", file=buf) text = buf.getvalue() print(repr(text)) # 'hello world\nanother line\n'
Паттерн:
print(..., file=...);StringIO и получаете результат как строку.StringIOЧасто удобно не создавать каждый раз новый объект, а "очищать" и использовать снова:
from io import StringIO buf = StringIO() buf.write("old data") buf.seek(0) buf.truncate(0) # стереть содержимое buf.write("new data") buf.seek(0) print(buf.read()) # "new data"
Паттерн:
seek(0) + truncate(0) - очистить;seek(0) - перемотать для чтения.Если есть большая строка, но её удобно парсить как поток:
from io import StringIO data = "line1\nline2\nline3\n" f = StringIO(data) for line in f: print(">", line.strip())
Паттерн: StringIO как удобный итератор по строкам вместо splitlines() - особенно, если нужно сложное чтение (read, readline, смешанное с seek).
Кратко:
StringIO:
str);str.BytesIO:
работает с bytes;
нужен, когда:
TextIOWrapper с явной кодировкой.Частый паттерн:
BytesIO (байты),TextIOWrapper => результат читаешь/пишешь как str.А StringIO - когда байты вообще не нужны, всё уже текст.
StringIOИногда нужно делать что-то ещё при записи/чтении:
Часто удобно наследоваться от StringIO:
from io import StringIO class CountingStringIO(StringIO): @property def length(self): pos = self.tell() self.seek(0, 2) # конец end = self.tell() self.seek(pos) return end buf = CountingStringIO() buf.write("hello") buf.write(" мир") print(buf.length) # 11
Или сделать декоратор, который оборачивает обычный StringIO/TextIOBase, но это уже другая история.
sys.stdin вручнуюДопустим, есть функция, которая читает из input():
def ask_name(): name = input("Your name: ") print(f"Hello, {name}!") return name
В тесте можно подменить sys.stdin на StringIO:
import sys from io import StringIO def test_ask_name(): fake_input = StringIO("Alice\n") # то, что 'напечатает' пользователь old_stdin = sys.stdin sys.stdin = fake_input try: name = ask_name() finally: sys.stdin = old_stdin # обязательно восстановить! assert name == "Alice"
Что тут происходит:
input() читает из sys.stdin;StringIO, в котором лежит строка "Alice\n";Чтобы не забывать восстанавливать sys.stdin, удобно сделать маленький контекстный менеджер:
import sys from io import StringIO from contextlib import contextmanager @contextmanager def fake_stdin(text: str): """ Временно подменяет sys.stdin на StringIO(text). Возвращает объект StringIO (чтобы можно было ещё и читать, если нужно). """ old_stdin = sys.stdin buf = StringIO(text) sys.stdin = buf try: yield buf finally: sys.stdin = old_stdin
Использование:
def ask_two_numbers(): a = int(input("A: ")) b = int(input("B: ")) return a + b def test_ask_two_numbers(): # имитируем ввод: "2\n3\n" with fake_stdin("2\n3\n"): s = ask_two_numbers() assert s == 5
input() подрядВажно: всё, что input() читает, идёт по строкам - до \n. Поэтому для нескольких вызовов input() просто кладём несколько строк в StringIO:
from io import StringIO import sys def dialog(): name = input("Name: ") city = input("City: ") return f"{name} from {city}" def test_dialog(): fake_input = StringIO("Alice\nRiga\n") old_stdin = sys.stdin sys.stdin = fake_input try: res = dialog() finally: sys.stdin = old_stdin assert res == "Alice from Riga"
stdin для функции, которая сама читает sys.stdin напрямуюЕсли функция не использует input(), а сама делает sys.stdin.read() / for line in sys.stdin, паттерн тот же:
import sys from io import StringIO def read_all_stdin_upper(): data = sys.stdin.read() return data.upper() def test_read_all_stdin_upper(): fake_stdin = StringIO("hello\nworld") old_stdin = sys.stdin sys.stdin = fake_stdin try: res = read_all_stdin_upper() finally: sys.stdin = old_stdin assert res == "HELLO\nWORLD"
pytestС pytest обычно делают так:
def test_ask_name(monkeypatch): from io import StringIO fake_input = StringIO("Alice\n") monkeypatch.setattr("sys.stdin", fake_input) name = ask_name() assert name == "Alice"
Но идея всё та же: StringIO => в sys.stdin => функция думает, что общается с пользователем.