From 90852c1f952495733ed4c8ecb9f98d3918fd0485 Mon Sep 17 00:00:00 2001 From: gf712 Date: Mon, 13 Jul 2026 21:12:18 +0100 Subject: [PATCH 1/8] runtime: wire isatty of buffered fileio textiowrapper (such as stdout) --- src/runtime/modules/IOModule.cpp | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/runtime/modules/IOModule.cpp b/src/runtime/modules/IOModule.cpp index 80dae86a..af6c349f 100644 --- a/src/runtime/modules/IOModule.cpp +++ b/src/runtime/modules/IOModule.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #if defined(__GLIBCXX__) || defined(__GLIBCPP__) @@ -898,7 +899,7 @@ struct Buffered { if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); return static_cast(this) - ->get_method(PyString::create("seekable").unwrap()) + ->raw->get_method(PyString::create("seekable").unwrap()) .and_then([](PyObject *seekable) -> PyResult { return seekable->call(PyTuple::create().unwrap(), PyDict::create().unwrap()); }); @@ -908,7 +909,7 @@ struct Buffered { if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); return static_cast(this) - ->get_method(PyString::create("writable").unwrap()) + ->raw->get_method(PyString::create("writable").unwrap()) .and_then([](PyObject *writable) -> PyResult { return writable->call(PyTuple::create().unwrap(), PyDict::create().unwrap()); }); @@ -928,7 +929,7 @@ struct Buffered { if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); return static_cast(this) - ->get_method(PyString::create("fileno").unwrap()) + ->raw->get_method(PyString::create("fileno").unwrap()) .and_then([](PyObject *fileno) -> PyResult { return fileno->call(PyTuple::create().unwrap(), PyDict::create().unwrap()); }); @@ -938,9 +939,9 @@ struct Buffered { if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); return static_cast(this) - ->get_method(PyString::create("isatty").unwrap()) + ->raw->get_method(PyString::create("isatty").unwrap()) .and_then([](PyObject *isatty) -> PyResult { - return isatty->call(PyTuple::create().unwrap(), PyDict::create().unwrap()); + return isatty->call(nullptr, nullptr); }); } @@ -1521,6 +1522,7 @@ class BufferedWriter klass(module, "BufferedWriter", s_io_buffered_io_base) .def("write", &BufferedWriter::write) .def("flush", &BufferedWriter::flush) + .def("isatty", &Buffered::isatty) .finalize(); } module->add_symbol(PyString::create("BufferedWriter").unwrap(), s_io_buffered_writer); @@ -2293,6 +2295,11 @@ class FileIO : public RawIOBase return PyInteger::create(0); } + PyResult isatty() const + { + return Ok(::isatty(m_file_descriptor) == 1 ? py_true() : py_false()); + } + static PyType *register_type(PyModule *module) { if (!s_io_fileio) { @@ -2302,6 +2309,7 @@ class FileIO : public RawIOBase .def("close", &FileIO::close) .def("flush", &FileIO::flush) .def("readinto", &FileIO::readinto) + .def("isatty", &FileIO::isatty) .finalize(); } module->add_symbol(PyString::create("FileIO").unwrap(), s_io_fileio); @@ -3266,6 +3274,13 @@ class TextIOWrapper : public TextIOBase .and_then([](PyObject *flush_fn) { return flush_fn->call(nullptr, nullptr); }); } + PyResult isatty() + { + return m_buffer->get_method(PyString::create("isatty").unwrap()).and_then([](auto *isatty) { + return isatty->call(nullptr, nullptr); + }); + } + PyType *static_type() const override { return s_io_textiowrapper; } static PyType *register_type(PyModule *module) @@ -3276,6 +3291,7 @@ class TextIOWrapper : public TextIOBase .def("readlines", &TextIOWrapper::readlines) .def("write", &TextIOWrapper::write) .def("flush", &TextIOWrapper::flush) + .def("isatty", &TextIOWrapper::isatty) .finalize(); } module->add_symbol(PyString::create("TextIOWrapper").unwrap(), s_io_textiowrapper); From 07db39d9f58c68ace3cbf5e78844024b71513f7c Mon Sep 17 00:00:00 2001 From: gf712 Date: Mon, 13 Jul 2026 21:17:12 +0100 Subject: [PATCH 2/8] runtime: pass line_buffering arg when constructing stdout --- src/interpreter/Interpreter.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/interpreter/Interpreter.cpp b/src/interpreter/Interpreter.cpp index 54b7d8f4..be047672 100644 --- a/src/interpreter/Interpreter.cpp +++ b/src/interpreter/Interpreter.cpp @@ -4,6 +4,7 @@ #include "runtime/Import.hpp" #include "runtime/KeyError.hpp" #include "runtime/NameError.hpp" +#include "runtime/PyBool.hpp" #include "runtime/PyCode.hpp" #include "runtime/PyDict.hpp" #include "runtime/PyFrame.hpp" @@ -209,8 +210,13 @@ void Interpreter::internal_setup(const std::string &name, return buffered_writer->call(PyTuple::create(stdout).unwrap(), nullptr); }) .and_then([text_io_wrapper](PyObject *stdout_buffer_writer) { + PyObject *line_buffering = + ::isatty(STDOUT_FILENO) == 1 ? py_true() : py_false(); return text_io_wrapper->call( - PyTuple::create(stdout_buffer_writer).unwrap(), nullptr); + PyTuple::create( + stdout_buffer_writer, py_none(), py_none(), py_none(), line_buffering) + .unwrap(), + nullptr); }); ASSERT(py_stdout.is_ok()); auto py_stderr = From fee2c0352ee1cfe468ff52298220134598f98c51 Mon Sep 17 00:00:00 2001 From: gf712 Date: Sun, 26 Jul 2026 20:07:21 +0100 Subject: [PATCH 3/8] runtime: flush on newline --- src/runtime/modules/IOModule.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/runtime/modules/IOModule.cpp b/src/runtime/modules/IOModule.cpp index af6c349f..f3cd245b 100644 --- a/src/runtime/modules/IOModule.cpp +++ b/src/runtime/modules/IOModule.cpp @@ -3255,6 +3255,15 @@ class TextIOWrapper : public TextIOBase // TODO: Should use encoder m_pending_bytes = Bytes::from_unescaped_string(as(text)->to_string()); m_pending_bytes_count = m_pending_bytes->b.size(); + const auto should_flush = [this]() -> bool { + if (!m_line_buffering) { return false; } + if (std::ranges::find(m_pending_bytes->b, std::byte{ '\n' }) + != m_pending_bytes->b.end()) { + return true; + } + return std::ranges::find(m_pending_bytes->b, std::byte{ '\r' }) + != m_pending_bytes->b.end(); + }(); auto result = writeflush(); while (result.is_ok()) { ASSERT(m_pending_bytes_count >= result.unwrap()); @@ -3264,6 +3273,13 @@ class TextIOWrapper : public TextIOBase } if (result.is_err()) { return Err(result.unwrap_err()); } + if (should_flush) { + if (auto r = m_buffer->get_method(PyString::create("flush").unwrap()) + .and_then([](PyObject *f) { return f->call(nullptr, nullptr); }); + r.is_err()) { + return Err(r.unwrap_err()); + } + } return PyInteger::create(text_len); } From b20b52f8b3d641dbe2cf07e3f8da9433124b98d3 Mon Sep 17 00:00:00 2001 From: gf712 Date: Mon, 27 Jul 2026 09:32:05 +0100 Subject: [PATCH 4/8] runtime: Add atexit callbacks --- src/executable/bytecode/BytecodeProgram.cpp | 5 ++++ src/interpreter/Interpreter.cpp | 29 +++++++++++++++++++++ src/interpreter/Interpreter.hpp | 8 ++++++ src/repl/repl.cpp | 2 +- 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/executable/bytecode/BytecodeProgram.cpp b/src/executable/bytecode/BytecodeProgram.cpp index d7f9a5a9..d75eed01 100644 --- a/src/executable/bytecode/BytecodeProgram.cpp +++ b/src/executable/bytecode/BytecodeProgram.cpp @@ -144,6 +144,11 @@ int BytecodeProgram::execute(VirtualMachine *vm) std::cout << exception->format_traceback() << std::endl; } + { + [[maybe_unused]] auto push_result = vm->push_frame(1, 0, 0); + [[maybe_unused]] auto final_result = interpreter.finalise(); + } + return result.is_ok() ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/src/interpreter/Interpreter.cpp b/src/interpreter/Interpreter.cpp index be047672..6464c146 100644 --- a/src/interpreter/Interpreter.cpp +++ b/src/interpreter/Interpreter.cpp @@ -27,6 +27,7 @@ #include #include +#include #include namespace fs = std::filesystem; @@ -495,4 +496,32 @@ void Interpreter::visit_graph(::Cell::Visitor &visitor) if (m_codec_error_registry) visitor.visit(*m_codec_error_registry); if (m_codec_search_path) visitor.visit(*m_codec_search_path); if (m_codec_search_path_cache) visitor.visit(*m_codec_search_path_cache); + + for (auto [callback, args] : m_callbacks) { + if (callback) visitor.visit(*callback); + if (args) visitor.visit(*args); + } +} + +void Interpreter::register_callback(PyObject *callback, PyTuple *args) +{ + m_callbacks.emplace_back(callback, args); +} + +void Interpreter::unregister_callback(PyObject *callback) +{ + m_callbacks.erase( + std::ranges::remove(m_callbacks, callback, [](const auto &el) { return std::get<0>(el); }) + .begin(), + m_callbacks.end()); +} + +PyResult Interpreter::finalise() +{ + // TODO: capture exceptions, and raise last one + for (auto [callback, args] : m_callbacks) { + [[maybe_unused]] auto result = callback->call(args, nullptr); + } + + return Ok(std::monostate{}); } diff --git a/src/interpreter/Interpreter.hpp b/src/interpreter/Interpreter.hpp index 748ca502..542052cf 100644 --- a/src/interpreter/Interpreter.hpp +++ b/src/interpreter/Interpreter.hpp @@ -6,6 +6,7 @@ #include #include +#include class BytecodeProgram; @@ -34,6 +35,7 @@ class Interpreter py::PyDict *m_codec_search_path_cache{ nullptr }; std::string m_entry_script; std::vector m_argv; + std::vector> m_callbacks; public: struct Config @@ -89,6 +91,8 @@ class Interpreter void setup(std::shared_ptr &&program); void setup_main_interpreter(std::shared_ptr &&program); + py::PyResult finalise(); + const std::string &entry_script() const { return m_entry_script; } const std::vector &argv() const { return m_argv; } @@ -107,6 +111,10 @@ class Interpreter py::PyTuple *args, py::PyDict *kwargs); + + void register_callback(py::PyObject *callback, py::PyTuple *args); + void unregister_callback(py::PyObject *callback); + void visit_graph(::Cell::Visitor &); private: diff --git a/src/repl/repl.cpp b/src/repl/repl.cpp index fdc29b31..37852cdb 100644 --- a/src/repl/repl.cpp +++ b/src/repl/repl.cpp @@ -230,4 +230,4 @@ int main(int argc, char **argv) // } return EXIT_SUCCESS; -} \ No newline at end of file +} From 0984442191c51a399406cd09b8fbab4458ae8cc5 Mon Sep 17 00:00:00 2001 From: gf712 Date: Mon, 27 Jul 2026 09:32:42 +0100 Subject: [PATCH 5/8] runtime: flush std file descriptors at exit --- src/interpreter/Interpreter.cpp | 25 +++++++++++++--- src/runtime/modules/BuiltinsModule.cpp | 4 +-- src/runtime/modules/IOModule.cpp | 40 ++++++++++++-------------- 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/src/interpreter/Interpreter.cpp b/src/interpreter/Interpreter.cpp index 6464c146..23a6beb4 100644 --- a/src/interpreter/Interpreter.cpp +++ b/src/interpreter/Interpreter.cpp @@ -227,13 +227,30 @@ void Interpreter::internal_setup(const std::string &name, return buffered_writer->call(PyTuple::create(stderr).unwrap(), nullptr); }) .and_then([text_io_wrapper](PyObject *stderr_buffer_writer) { + auto *line_buffering = py_true(); return text_io_wrapper->call( - PyTuple::create(stderr_buffer_writer).unwrap(), nullptr); + PyTuple::create( + stderr_buffer_writer, py_none(), py_none(), py_none(), line_buffering) + .unwrap(), + nullptr); }); ASSERT(py_stderr.is_ok()); - sys->add_symbol(PyString::create("stdin").unwrap(), py_stdin.unwrap()); - sys->add_symbol(PyString::create("stdout").unwrap(), py_stdout.unwrap()); - sys->add_symbol(PyString::create("stderr").unwrap(), py_stderr.unwrap()); + + for (auto [name, obj] : std::views::zip(std::array{ "stdin", "stdout", "stderr" }, + std::array{ py_stdin.unwrap(), py_stdout.unwrap(), py_stderr.unwrap() })) { + sys->add_symbol(PyString::create(name).unwrap(), obj); + register_callback(PyNativeFunction::create( + std::string{ name } + "_exit", + [obj](PyTuple *, PyDict *) -> PyResult { + return obj->get_method(PyString::create("flush").unwrap()) + .and_then([](PyObject *flush) { + return flush->call(nullptr, nullptr); + }); + }, + obj) + .unwrap(), + nullptr); + } } if (config.requires_importlib) { diff --git a/src/runtime/modules/BuiltinsModule.cpp b/src/runtime/modules/BuiltinsModule.cpp index 32f9066c..6aa54c8d 100644 --- a/src/runtime/modules/BuiltinsModule.cpp +++ b/src/runtime/modules/BuiltinsModule.cpp @@ -92,9 +92,7 @@ PyResult print(const PyTuple *args, const PyDict *kwargs, Interprete // sys.stdout may be None when FILE* stdout isn't connected if (!file || file == py_none()) { return Ok(py_none()); } } - // TODO: flush should be false, but for now we keep it as true, since there is no flush at - // interpreter shutdown - bool flush = true; + bool flush = false; if (kwargs) { static const Value separator_keyword = String{ "sep" }; static const Value end_keyword = String{ "end" }; diff --git a/src/runtime/modules/IOModule.cpp b/src/runtime/modules/IOModule.cpp index f3cd245b..96f09580 100644 --- a/src/runtime/modules/IOModule.cpp +++ b/src/runtime/modules/IOModule.cpp @@ -2959,8 +2959,7 @@ class TextIOWrapper : public TextIOBase std::optional m_buffer_bytes; size_t m_position{ 0 }; - std::optional m_pending_bytes;// bytes to be written - size_t m_pending_bytes_count{ 0 }; + Bytes m_pending_bytes; size_t m_chunk_size{ 8192 }; @@ -3230,18 +3229,17 @@ class TextIOWrapper : public TextIOBase return Ok(result); } - PyResult writeflush() + PyResult writeflush() { - if (!m_pending_bytes.has_value()) { return Ok(0); } + if (m_pending_bytes.b.empty()) { return Ok(std::monostate{}); } auto written = m_buffer->get_method(PyString::create("write").unwrap()) .and_then([this](PyObject *write) { - return write->call(PyTuple::create(m_pending_bytes.value()).unwrap(), nullptr); + return write->call(PyTuple::create(m_pending_bytes).unwrap(), nullptr); }); if (written.is_err()) { return Err(written.unwrap_err()); } m_pending_bytes = {}; - m_pending_bytes_count = 0; - return Ok(0); + return Ok(std::monostate{}); } @@ -3252,26 +3250,25 @@ class TextIOWrapper : public TextIOBase auto *text = PyObject::from(args->elements()[0]).unwrap(); ASSERT(as(text)); const auto text_len = as(text)->size(); + // TODO: Should use encoder - m_pending_bytes = Bytes::from_unescaped_string(as(text)->to_string()); - m_pending_bytes_count = m_pending_bytes->b.size(); + auto new_bytes = Bytes::from_unescaped_string(as(text)->to_string()); + m_pending_bytes.b.insert(m_pending_bytes.b.end(), new_bytes.b.begin(), new_bytes.b.end()); + const auto should_flush = [this]() -> bool { if (!m_line_buffering) { return false; } - if (std::ranges::find(m_pending_bytes->b, std::byte{ '\n' }) - != m_pending_bytes->b.end()) { + if (std::ranges::find(m_pending_bytes.b, std::byte{ '\n' }) + != m_pending_bytes.b.end()) { return true; } - return std::ranges::find(m_pending_bytes->b, std::byte{ '\r' }) - != m_pending_bytes->b.end(); + return std::ranges::find(m_pending_bytes.b, std::byte{ '\r' }) + != m_pending_bytes.b.end(); }(); - auto result = writeflush(); - while (result.is_ok()) { - ASSERT(m_pending_bytes_count >= result.unwrap()); - m_pending_bytes_count -= result.unwrap(); - if (m_pending_bytes_count == 0) { break; } - result = writeflush(); + + if (m_pending_bytes.b.size() >= m_chunk_size || should_flush || m_write_through) { + auto result = writeflush(); + if (result.is_err()) { return Err(result.unwrap_err()); } } - if (result.is_err()) { return Err(result.unwrap_err()); } if (should_flush) { if (auto r = m_buffer->get_method(PyString::create("flush").unwrap()) @@ -3337,8 +3334,7 @@ class TextIOWrapper : public TextIOBase m_line_buffering = line_buffering; m_write_through = write_through; - m_newline = newline.value_or("'\n"); - m_pending_bytes_count = 0; + m_newline = newline.value_or("\n"); return Ok(1); } From 2d09746bd5bde4fc2b9fea55fc7e9237fe3192ae Mon Sep 17 00:00:00 2001 From: gf712 Date: Mon, 27 Jul 2026 09:54:12 +0100 Subject: [PATCH 6/8] Address review comments --- src/executable/bytecode/BytecodeProgram.cpp | 12 +- src/runtime/modules/IOModule.cpp | 165 ++++++++++++-------- 2 files changed, 107 insertions(+), 70 deletions(-) diff --git a/src/executable/bytecode/BytecodeProgram.cpp b/src/executable/bytecode/BytecodeProgram.cpp index d75eed01..29e54edd 100644 --- a/src/executable/bytecode/BytecodeProgram.cpp +++ b/src/executable/bytecode/BytecodeProgram.cpp @@ -2,7 +2,7 @@ #include "Bytecode.hpp" #include "executable/Function.hpp" #include "executable/Mangler.hpp" -#include "interpreter/InterpreterSession.hpp" +#include "interpreter/Interpreter.hpp" #include "runtime/PyCode.hpp" #include "runtime/PyFrame.hpp" #include "runtime/PyFunction.hpp" @@ -136,6 +136,11 @@ int BytecodeProgram::execute(VirtualMachine *vm) auto result = m_main_function->function()->call(*vm, interpreter); + { + ScopedStack scoped_stack{ vm->push_frame(1, 0, 0) }; + [[maybe_unused]] auto final_result = interpreter.finalise(); + } + if (result.is_err()) { // The exception propagated all the way out; the eval loop already popped it // off the (now-clean) exception stack as it unwound, so use the result value @@ -144,11 +149,6 @@ int BytecodeProgram::execute(VirtualMachine *vm) std::cout << exception->format_traceback() << std::endl; } - { - [[maybe_unused]] auto push_result = vm->push_frame(1, 0, 0); - [[maybe_unused]] auto final_result = interpreter.finalise(); - } - return result.is_ok() ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/src/runtime/modules/IOModule.cpp b/src/runtime/modules/IOModule.cpp index 96f09580..ccd684ae 100644 --- a/src/runtime/modules/IOModule.cpp +++ b/src/runtime/modules/IOModule.cpp @@ -41,24 +41,26 @@ namespace fs = std::filesystem; namespace py { namespace { - static PyType *s_io_base = nullptr; - static PyType *s_io_raw_iobase = nullptr; - static PyType *s_io_buffered_io_base = nullptr; - static PyType *s_io_buffered_reader = nullptr; - static PyType *s_io_buffered_writer = nullptr; - static PyType *s_io_buffered_rwpair = nullptr; - static PyType *s_io_buffered_random = nullptr; - static PyType *s_io_textiobase = nullptr; - static PyType *s_io_incremental_newline_decoder = nullptr; - static PyType *s_io_bytesio = nullptr; - static PyType *s_io_fileio = nullptr; - static PyType *s_io_stringio = nullptr; - static PyType *s_io_textiowrapper = nullptr; - - static PyType *s_blocking_io_error = nullptr; - static PyType *s_unsupported_operation_type = nullptr; + PyType *s_io_base = nullptr; + PyType *s_io_raw_iobase = nullptr; + PyType *s_io_buffered_io_base = nullptr; + PyType *s_io_buffered_reader = nullptr; + PyType *s_io_buffered_writer = nullptr; + PyType *s_io_buffered_rwpair = nullptr; + PyType *s_io_buffered_random = nullptr; + PyType *s_io_textiobase = nullptr; + PyType *s_io_incremental_newline_decoder = nullptr; + PyType *s_io_bytesio = nullptr; + PyType *s_io_fileio = nullptr; + PyType *s_io_stringio = nullptr; + PyType *s_io_textiowrapper = nullptr; + + PyType *s_blocking_io_error = nullptr; + PyType *s_unsupported_operation_type = nullptr; }// namespace +static constexpr size_t s_default_buffer_size = 8192; + Exception *unsupported_operation(PyTuple *args, PyDict *kwargs) { ASSERT(s_unsupported_operation_type); @@ -814,7 +816,7 @@ struct Buffered PyResult check_initialized() const { - if (!ok) { + if (!ok || !raw) { if (detached) { return Err(value_error("raw stream has been detached")); } else { @@ -1029,7 +1031,8 @@ class BufferedReader this->readable_ = true; this->writable_ = false; this->fast_closed_checks = false; - this->ok = true; + // the object only becomes usable once __init__ provides a raw stream + this->ok = raw != nullptr; } BufferedReader(PyType *type) : BufferedReader(type, nullptr, 0) {} @@ -1359,11 +1362,13 @@ class BufferedWriter BufferedWriter(PyType *type, PyObject *raw, int buffer_size) : BufferedIOBase(type) { this->raw = raw; - (void)buffer_size; + if (buffer_size > 0) { m_buffer_size = static_cast(buffer_size); } this->readable_ = false; this->writable_ = true; this->fast_closed_checks = false; - this->ok = true; + // the object only becomes usable once __init__ provides a raw stream + this->ok = raw != nullptr; + this->buffer = std::make_unique(); } BufferedWriter(PyType *type) : BufferedWriter(type, nullptr, 0) {} @@ -1397,7 +1402,7 @@ class BufferedWriter static PyResult create(PyObject *raw, int buffer_size) { auto &heap = VirtualMachine::the().heap(); - if (auto *obj = heap.allocate(s_io_buffered_reader, raw, buffer_size)) { + if (auto *obj = heap.allocate(s_io_buffered_writer, raw, buffer_size)) { return Ok(obj); } return Err(memory_error(sizeof(BufferedWriter))); @@ -1410,21 +1415,24 @@ class BufferedWriter PyResult __init__(PyTuple *args, PyDict *kwargs) { - ASSERT(!kwargs || kwargs->map().empty()); - if (!args || args->elements().empty()) { - return Err(type_error("missing required argument 'raw'")); - } - if (args->elements().size() > 1) { TODO(); } - return PyObject::from(args->elements()[0]) - .and_then([this](PyObject *raw) -> PyResult { - this->raw = raw; - this->readable_ = false; - this->writable_ = true; - this->fast_closed_checks = false; - this->ok = true; - this->buffer = std::make_unique(); - return Ok(0); - }); + auto parse_result = PyArgsParser::unpack_tuple(args, + kwargs, + "BufferedWriter", + std::integral_constant{}, + std::integral_constant{}, + static_cast(s_default_buffer_size)); + if (parse_result.is_err()) { return Err(parse_result.unwrap_err()); } + auto [raw, buffer_size] = parse_result.unwrap(); + if (buffer_size <= 0) { return Err(value_error("buffer size must be strictly positive")); } + this->raw = raw; + this->readable_ = false; + this->writable_ = true; + this->fast_closed_checks = false; + this->ok = true; + this->buffer = std::make_unique(); + m_buffer_size = static_cast(buffer_size); + m_buffered_bytes = 0; + return Ok(0); } PyResult __repr__() const @@ -1453,8 +1461,7 @@ class BufferedWriter return raw->get_method(PyString::create("write").unwrap()) .and_then([memobj](PyObject *write) { return write->call(PyTuple::create(memobj.unwrap()).unwrap(), nullptr); - }) - .and_then([](auto) { return Ok(py_none()); }); + }); } PyResult write(PyTuple *args, PyDict *kwargs) @@ -1473,41 +1480,66 @@ class BufferedWriter "write() argument must be contiguous buffer, not {}", arg->type()->name())); } - // implementation + if (auto err = check_initialized(); err.is_err()) { return Err(err.unwrap_err()); } + if (Buffered::is_closed()) { return Err(value_error("write to closed file")); } - auto written = - this->buffer->sputn(static_cast(buffer.buf->get_buffer()), buffer.len); - if (written == buffer.len) { - // fast path: everything was written - we are done - return PyInteger::create(written); + const char *start = static_cast(buffer.buf->get_buffer()); + const auto len = static_cast(buffer.len); + + // fast path: the data fits in the buffer's free space, so it is only buffered and does + // not reach the raw stream + if (m_buffered_bytes + len <= m_buffer_size) { + const auto written = this->buffer->sputn(start, len); + ASSERT(static_cast(written) == len); + m_buffered_bytes += len; + return PyInteger::create(len); } - // flush our internal buffer to raw + // the data does not fit: drain our buffer to raw first if (auto r = flush(); r.is_err()) { return r; } - // TODO: this currently writes to internal buffer and then immediately makes a copy in - // write_raw. This is because streambuf doesn't tell us how much capacity it has left, so we - // just give it as much as we can each time - ssize_t remaining = buffer.len; - char *start = static_cast(buffer.buf->get_buffer()); - // TODO: use a buffer that actually tells us how much we can write to it, rather than - // hardcode 4096 - while (remaining > 4096) { - // flush our internal buffer to raw + // write oversized data directly to raw, bypassing the buffer + size_t written = 0; + size_t remaining = len; + while (remaining > m_buffer_size) { auto r = raw_write(start + written, remaining); if (r.is_err()) { return r; } - ASSERT(as(r.unwrap())); - const auto count = as(r.unwrap())->as_size_t(); - written += count; - remaining -= count; + if (r.unwrap() == py_none()) { + // TODO: raise BlockingIOError + // a non-blocking raw stream accepted no data + return Err(os_error("write could not complete without blocking")); + } + if (!as(r.unwrap())) { + return Err(type_error( + "'{}' object cannot be interpreted as an integer", r.unwrap()->type()->name())); + } + const auto &count = as(r.unwrap())->as_big_int(); + if (count < 0 || count > remaining) { + return Err( + os_error("raw write() returned invalid length {} (should have been " + "between 0 and {})", + count.get_str(), + remaining)); + } + if (count == 0) { + // TODO: retry with a zero-byte raw write until a signal interrupts + // it. We have no signal check in this loop yet + return Err(os_error("write could not complete without blocking")); + } + written += count.get_ui(); + remaining -= count.get_ui(); } - // put the rest in our buffer. Assumes that we can write 4096 bytes - const auto count = this->buffer->sputn(start + written, remaining); - ASSERT(remaining - count == 0); + // buffer the tail, which is now guaranteed to fit + if (remaining > 0) { + const auto count = this->buffer->sputn(start + written, remaining); + ASSERT(static_cast(count) == remaining); + m_buffered_bytes += remaining; + written += remaining; + } return PyInteger::create(written); } @@ -1538,6 +1570,10 @@ class BufferedWriter PyObject::visit_graph(visitor); visit_graph_buffered(visitor); } + + private: + size_t m_buffer_size{ s_default_buffer_size }; + size_t m_buffered_bytes{ 0 }; }; template<> PyResult Buffered::flush_and_rewind() @@ -1548,6 +1584,7 @@ template<> PyResult Buffered::flush_and_rewind() PyResult BufferedWriter::flush() { + if (auto err = check_initialized(); err.is_err()) { return Err(err.unwrap_err()); } if (!writable_) { return Ok(py_none()); } std::array to_write; @@ -1568,6 +1605,7 @@ PyResult BufferedWriter::flush() return r; } } + m_buffered_bytes = 0; return Ok(py_none()); } @@ -3462,9 +3500,8 @@ PyModule *io_module() return open(arg0, "rb"); })); - // C++ standard streams currently do not provide an API to get default buffer size, and C's - // BUFSIZE doesn't have to be respected - s_io_module->add_symbol(PyString::create("DEFAULT_BUFFER_SIZE").unwrap(), Number{ 0 }); + s_io_module->add_symbol(PyString::create("DEFAULT_BUFFER_SIZE").unwrap(), + Number{ static_cast(s_default_buffer_size) }); // >> type("UnsupportedOperation", (_io.OSError, ValueError), {}) auto unsupported_operation_type = types::type()->call( From 81b0d4f3e68b180d495ee83585a1a05ac6475e9a Mon Sep 17 00:00:00 2001 From: gf712 Date: Mon, 27 Jul 2026 14:24:49 +0100 Subject: [PATCH 7/8] Add buffered writer tests --- integration/run_python_tests.sh | 15 ++ integration/tests/buffered_writer.py | 140 ++++++++++++++++++ .../expected_failures/print_then_raise.py | 6 + 3 files changed, 161 insertions(+) create mode 100644 integration/tests/buffered_writer.py create mode 100644 integration/tests/expected_failures/print_then_raise.py diff --git a/integration/run_python_tests.sh b/integration/run_python_tests.sh index 3471ceb6..e3418148 100755 --- a/integration/run_python_tests.sh +++ b/integration/run_python_tests.sh @@ -31,4 +31,19 @@ for file in $(find $SCRIPT_DIR/tests/ -maxdepth 1 -type f -name "*.py"); do fi done +# An uncaught exception must exit non-zero, and the exit-time flush of the +# buffered sys.stdout must run before the traceback is printed, so with a +# redirected stdout the script's own output comes first. +file=$SCRIPT_DIR/tests/expected_failures/print_then_raise.py +output=$(timeout 10s $PYTHON_EXECUTABLE $file --gc-frequency $GC_FREQUENCY 2>&1) +if [ $? -eq 0 ]; then + echo $file "... FAILED! (expected a non-zero exit code)" + exit_code=1 +elif [ "$(echo "$output" | head -n 1)" != "before-raise" ]; then + echo $file "... FAILED! (script output must precede the traceback, got: ${output})" + exit_code=1 +else + echo $file "... PASSED!" +fi + exit $exit_code diff --git a/integration/tests/buffered_writer.py b/integration/tests/buffered_writer.py new file mode 100644 index 00000000..ebb33ab7 --- /dev/null +++ b/integration/tests/buffered_writer.py @@ -0,0 +1,140 @@ +# BufferedWriter must respect buffer_size like CPython (Modules/_io/bufferedio.c): +# - writes that fit in the buffer's free space are only buffered (no raw write) +# - writes that don't fit first drain the buffer to the raw stream +# - payloads larger than buffer_size bypass the buffer and go straight to raw +# - the remaining tail (<= buffer_size) is buffered again +import _io + +PATH = "/tmp/pycpp_buffered_writer_test.bin" + + +def file_contents(): + f = _io.FileIO(PATH, "rb") + data = f.readall() + f.close() + return data + + +# 1. a small write stays in the buffer until flush +w = _io.BufferedWriter(_io.FileIO(PATH, "wb")) +assert w.write(b"abcd") == 4 +assert file_contents() == b"", file_contents() +w.flush() +assert file_contents() == b"abcd", file_contents() + +# 2. overflowing the buffer drains it, and an oversized payload bypasses the buffer +w = _io.BufferedWriter(_io.FileIO(PATH, "wb"), 8) +assert w.write(b"abcd") == 4 +assert file_contents() == b"", file_contents() +assert w.write(b"efghijklm") == 9 +assert file_contents() == b"abcdefghijklm", file_contents() + +# 3. a tail smaller than buffer_size stays buffered after the drain +w = _io.BufferedWriter(_io.FileIO(PATH, "wb"), 8) +assert w.write(b"abcdef") == 6 +assert w.write(b"ghi") == 3 +assert file_contents() == b"abcdef", file_contents() +w.flush() +assert file_contents() == b"abcdefghi", file_contents() + +# 4. an exact fit is fully buffered +w = _io.BufferedWriter(_io.FileIO(PATH, "wb"), 8) +assert w.write(b"12345678") == 8 +assert file_contents() == b"", file_contents() +w.flush() +assert file_contents() == b"12345678", file_contents() + +# 5. buffer_size must be strictly positive +try: + _io.BufferedWriter(_io.FileIO(PATH, "wb"), 0) + assert False, "expected ValueError" +except ValueError: + pass + + +# 6. raw can be any duck-typed object; a write() that over-reports the byte +# count must raise OSError instead of wrapping the unsigned byte counters +class OverReportingWriter: + def write(self, b): + return 1000 + + +w = _io.BufferedWriter(OverReportingWriter(), 8) +try: + w.write(b"0123456789abcdef") + assert False, "expected OSError" +except OSError: + pass + + +# 7. same for a negative count +class NegativeWriter: + def write(self, b): + return -1 + + +w = _io.BufferedWriter(NegativeWriter(), 8) +try: + w.write(b"0123456789abcdef") + assert False, "expected OSError" +except OSError: + pass + +# 8. a raw write() returning 0 makes no progress; retrying forever would hang, +# so it must raise OSError +class ZeroWriter: + def write(self, b): + return 0 + + +w = _io.BufferedWriter(ZeroWriter(), 8) +try: + w.write(b"0123456789abcdef") + assert False, "expected OSError" +except OSError: + pass + + +# 9. a raw write() returning None means the stream accepted no data without +# blocking; that is an OSError (BlockingIOError in CPython), not a crash +class NoneWriter: + def write(self, b): + return None + + +w = _io.BufferedWriter(NoneWriter(), 8) +try: + w.write(b"0123456789abcdef") + assert False, "expected OSError" +except OSError: + pass + + +# 10. any other non-int return from raw write() is a TypeError, not a crash +class StringWriter: + def write(self, b): + return "16" + + +w = _io.BufferedWriter(StringWriter(), 8) +try: + w.write(b"0123456789abcdef") + assert False, "expected TypeError" +except TypeError: + pass + +# 11. an object created via __new__ without __init__ has no raw stream; every +# I/O method must raise ValueError instead of dereferencing it +uninitialized = _io.BufferedWriter.__new__(_io.BufferedWriter) +for method in ( + uninitialized.isatty, + uninitialized.flush, + lambda: uninitialized.write(b"x"), +): + try: + method() + assert False, "expected ValueError" + except ValueError: + pass + +print("buffered_writer: ok") diff --git a/integration/tests/expected_failures/print_then_raise.py b/integration/tests/expected_failures/print_then_raise.py new file mode 100644 index 00000000..97efb1d8 --- /dev/null +++ b/integration/tests/expected_failures/print_then_raise.py @@ -0,0 +1,6 @@ +# Buffered sys.stdout must be flushed by the interpreter's exit callbacks +# *before* the uncaught-exception traceback is printed, so with a redirected +# stdout the script's own output comes first. run_python_tests.sh checks the +# output ordering and that the exit code is non-zero. +print("before-raise") +raise RuntimeError("boom") From e97e80ca27f221dc4bdaa0f58d07ce8762d4e0f4 Mon Sep 17 00:00:00 2001 From: gf712 Date: Mon, 27 Jul 2026 15:52:33 +0100 Subject: [PATCH 8/8] Address review comment --- integration/tests/buffered_writer.py | 20 +++++ src/runtime/modules/IOChecks.hpp | 37 +++++++++ src/runtime/modules/IOModule.cpp | 111 ++++++++++++--------------- 3 files changed, 106 insertions(+), 62 deletions(-) create mode 100644 src/runtime/modules/IOChecks.hpp diff --git a/integration/tests/buffered_writer.py b/integration/tests/buffered_writer.py index ebb33ab7..86d5de29 100644 --- a/integration/tests/buffered_writer.py +++ b/integration/tests/buffered_writer.py @@ -137,4 +137,24 @@ def write(self, b): except ValueError: pass +# 12. the oversized path calls back into Python once per chunk; a raw whose +# write() reallocates the source object's storage must not leave the write +# loop walking freed memory +ba = bytearray(b"A" * 100) +chunks = [] + + +class MutatingWriter: + def write(self, b): + # reallocates ba's backing storage mid-write + ba[0:1] = b"B" * 200000 + chunks.append(1) + return 1 + + +w = _io.BufferedWriter(MutatingWriter(), 8) +assert w.write(ba) == 100 +# 100 bytes, one accepted per call, until the 8 byte tail is buffered instead +assert len(chunks) == 92, len(chunks) + print("buffered_writer: ok") diff --git a/src/runtime/modules/IOChecks.hpp b/src/runtime/modules/IOChecks.hpp new file mode 100644 index 00000000..640c4f84 --- /dev/null +++ b/src/runtime/modules/IOChecks.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "runtime/Value.hpp" +#include "runtime/ValueError.hpp" + +#include + +namespace py { + +// bool is_initialized() const - required by check_initialized() +// bool is_detached() const - optional; reported separately when the object was +// initialized once and then detached +template class IOChecks +{ + const Derived &derived() const { return static_cast(*this); } + + public: + PyResult check_initialized() const + { + if (derived().is_initialized()) { return Ok(std::monostate{}); } + if constexpr (requires(const Derived &d) { d.is_detached(); }) { + if (derived().is_detached()) { + return Err(value_error("raw stream has been detached")); + } + } + return Err(value_error("I/O operation on uninitialized object")); + } + + // the guard pair an accessor wants: usable means constructed *and* still open. + // Spelling it once keeps the two checks from being chained in the wrong order. + PyResult check_usable() const + { + return check_initialized().and_then([this](auto) { return derived().check_closed(); }); + } +}; + +}// namespace py diff --git a/src/runtime/modules/IOModule.cpp b/src/runtime/modules/IOModule.cpp index ccd684ae..6a721968 100644 --- a/src/runtime/modules/IOModule.cpp +++ b/src/runtime/modules/IOModule.cpp @@ -1,3 +1,4 @@ +#include "IOChecks.hpp" #include "Modules.hpp" #include "runtime/MemoryError.hpp" #include "runtime/NotImplementedError.hpp" @@ -799,7 +800,7 @@ class BufferedIOBase : public IOBase template // requires(std::is_base_of_v) -struct Buffered +struct Buffered : IOChecks> { PyObject *raw{ nullptr }; bool ok{ false }; @@ -814,18 +815,9 @@ struct Buffered int64_t readahead() const { return valid_readbuffer() ? buffer->in_avail() : 0; } - PyResult check_initialized() const - { - if (!ok || !raw) { - if (detached) { - return Err(value_error("raw stream has been detached")); - } else { - return Err(value_error("I/O operation on uninitialized object")); - } - } + bool is_initialized() const { return ok && raw; } - return Ok(std::monostate{}); - } + bool is_detached() const { return detached; } PyResult check_closed(std::string_view err_msg) const { @@ -851,7 +843,7 @@ struct Buffered PyResult simple_flush() const { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); return raw->get_method(PyString::create("flush").unwrap()).and_then([](PyObject *flush) { return flush->call(PyTuple::create().unwrap(), PyDict::create().unwrap()); @@ -862,7 +854,7 @@ struct Buffered PyResult closed() const { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); return raw->get_attribute(PyString::create("closed").unwrap()) .and_then([](PyObject *closed) { return truthy(closed, VirtualMachine::the().interpreter()); @@ -871,7 +863,7 @@ struct Buffered PyResult close() { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); // FIXME add lock auto r = closed(); @@ -899,7 +891,7 @@ struct Buffered PyResult seekable() const { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); return static_cast(this) ->raw->get_method(PyString::create("seekable").unwrap()) .and_then([](PyObject *seekable) -> PyResult { @@ -909,7 +901,7 @@ struct Buffered PyResult writable() const { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); return static_cast(this) ->raw->get_method(PyString::create("writable").unwrap()) .and_then([](PyObject *writable) -> PyResult { @@ -919,7 +911,7 @@ struct Buffered PyResult readable() const { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); return static_cast(this) ->get_method(PyString::create("readable").unwrap()) .and_then([](PyObject *readable) -> PyResult { @@ -929,7 +921,7 @@ struct Buffered PyResult fileno() const { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); return static_cast(this) ->raw->get_method(PyString::create("fileno").unwrap()) .and_then([](PyObject *fileno) -> PyResult { @@ -939,7 +931,7 @@ struct Buffered PyResult isatty() const { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); return static_cast(this) ->raw->get_method(PyString::create("isatty").unwrap()) .and_then([](PyObject *isatty) -> PyResult { @@ -974,7 +966,7 @@ struct Buffered PyResult read(int64_t n) { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); if (n < -1) { return Err(value_error("read length must be non-negative or -1")); } @@ -991,7 +983,7 @@ struct Buffered PyResult read1(int64_t n) { - if (auto err = check_initialized(); err.is_err()) return Err(err.unwrap_err()); + if (auto err = this->check_initialized(); err.is_err()) return Err(err.unwrap_err()); if (n < 0) { // TODO: determine actual buffer size @@ -1498,6 +1490,12 @@ class BufferedWriter return PyInteger::create(len); } + // everything below re-enters Python (flush() and raw_write() both end up calling + // raw.write()), which can run arbitrary code that reallocates the source object's + // storage - e.g. a raw whose write() resizes the very bytearray being written. Take a + // snapshot now so `start` cannot dangle mid-write. + const std::vector payload{ start, start + len }; + // the data does not fit: drain our buffer to raw first if (auto r = flush(); r.is_err()) { return r; } @@ -1505,7 +1503,7 @@ class BufferedWriter size_t written = 0; size_t remaining = len; while (remaining > m_buffer_size) { - auto r = raw_write(start + written, remaining); + auto r = raw_write(payload.data() + written, remaining); if (r.is_err()) { return r; } if (r.unwrap() == py_none()) { // TODO: raise BlockingIOError @@ -1535,7 +1533,7 @@ class BufferedWriter // buffer the tail, which is now guaranteed to fit if (remaining > 0) { - const auto count = this->buffer->sputn(start + written, remaining); + const auto count = this->buffer->sputn(payload.data() + written, remaining); ASSERT(static_cast(count) == remaining); m_buffered_bytes += remaining; written += remaining; @@ -2637,8 +2635,12 @@ class IncrementalNewlineDecoder : public PyBaseObject } }; -class StringIO : public TextIOBase +class StringIO + : public TextIOBase + , public IOChecks { + friend class IOChecks; + friend ::Heap; std::stringstream m_stringstream; @@ -2721,23 +2723,17 @@ class StringIO : public TextIOBase PyResult readable() const { - return check_initialized() - .and_then([this](auto) { return check_closed(); }) - .and_then([](auto) { return Ok(py_true()); }); + return check_usable().and_then([](auto) { return Ok(py_true()); }); } PyResult writable() const { - return check_initialized() - .and_then([this](auto) { return check_closed(); }) - .and_then([](auto) { return Ok(py_true()); }); + return check_usable().and_then([](auto) { return Ok(py_true()); }); } PyResult seekable() const { - return check_initialized() - .and_then([this](auto) { return check_closed(); }) - .and_then([](auto) { return Ok(py_true()); }); + return check_usable().and_then([](auto) { return Ok(py_true()); }); } PyResult closed() const @@ -2748,16 +2744,12 @@ class StringIO : public TextIOBase PyResult line_buffering() const { - return check_initialized() - .and_then([this](auto) { return check_closed(); }) - .and_then([](auto) { return Ok(py_false()); }); + return check_usable().and_then([](auto) { return Ok(py_false()); }); } PyResult newlines() const { - return check_initialized() - .and_then([this](auto) { return check_closed(); }) - .and_then([](auto) { return Ok(py_none()); }); + return check_usable().and_then([](auto) { return Ok(py_none()); }); } PyResult close() @@ -2769,19 +2761,15 @@ class StringIO : public TextIOBase PyResult tell() { - return check_initialized() - .and_then([this](auto) { return check_closed(); }) - .and_then([this](auto) { return PyInteger::create(m_stringstream.tellg()); }); + return check_usable().and_then( + [this](auto) { return PyInteger::create(m_stringstream.tellg()); }); } PyResult read(PyTuple *args, PyDict *kwargs) { ASSERT(!kwargs || kwargs->size() == 0); - if (auto result = check_initialized().or_else([this](auto) { return check_closed(); }); - result.is_err()) { - return result; - } + if (auto result = check_usable(); result.is_err()) { return Err(result.unwrap_err()); } auto size_ = [args, this]() -> PyResult { const auto initial_pos = m_stringstream.tellg(); @@ -2817,10 +2805,7 @@ class StringIO : public TextIOBase { ASSERT(!kwargs || kwargs->size() == 0); - if (auto result = check_initialized().or_else([this](auto) { return check_closed(); }); - result.is_err()) { - return result; - } + if (auto result = check_usable(); result.is_err()) { return Err(result.unwrap_err()); } auto limit_ = [args, this]() -> PyResult { const auto initial_pos = m_stringstream.tellg(); @@ -2864,7 +2849,7 @@ class StringIO : public TextIOBase PyResult write(PyTuple *args, PyDict *kwargs) { - if (auto result = check_initialized(); result.is_err()) { return result; } + if (auto result = check_initialized(); result.is_err()) { return Err(result.unwrap_err()); } auto parse_result = PyArgsParser::unpack_tuple(args, kwargs, @@ -2876,7 +2861,7 @@ class StringIO : public TextIOBase auto [obj] = parse_result.unwrap(); - if (auto result = check_closed(); result.is_err()) { return result; } + if (auto result = check_closed(); result.is_err()) { return Err(result.unwrap_err()); } const auto size = obj->size(); m_stringstream << obj->value(); @@ -2889,7 +2874,7 @@ class StringIO : public TextIOBase { ASSERT(!kwargs || kwargs->size() == 0); - if (auto result = check_initialized(); result.is_err()) { return result; } + if (auto result = check_initialized(); result.is_err()) { return Err(result.unwrap_err()); } auto parse_result = PyArgsParser::unpack_tuple(args, kwargs, @@ -2970,23 +2955,22 @@ class StringIO : public TextIOBase return Ok(0); } - PyResult check_initialized() const - { - if (!m_ok) { return Err(value_error("I/O operation on uninitialized object")); } - return Ok(py_true()); - } + bool is_initialized() const { return m_ok; } - PyResult check_closed() const + PyResult check_closed() const { if (m_closed) { return Err(value_error("I/O operation on closed file")); } - return Ok(py_true()); + return Ok(std::monostate{}); } }; -class TextIOWrapper : public TextIOBase +class TextIOWrapper + : public TextIOBase + , public IOChecks { friend ::Heap; + friend class IOChecks; PyObject *m_buffer{ nullptr }; std::string m_errors; @@ -3327,6 +3311,7 @@ class TextIOWrapper : public TextIOBase PyResult isatty() { + if (auto err = check_initialized(); err.is_err()) { return Err(err.unwrap_err()); } return m_buffer->get_method(PyString::create("isatty").unwrap()).and_then([](auto *isatty) { return isatty->call(nullptr, nullptr); }); @@ -3376,6 +3361,8 @@ class TextIOWrapper : public TextIOBase return Ok(1); } + + bool is_initialized() const { return m_buffer != nullptr; } }; PyResult open(PyObject *file, const std::string &mode)