diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 8de460856666d7f..60ff5d7776b061d 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -60,7 +60,8 @@ def export(self, filename): lines.sort(key=lambda x: (-x[1], x[0])) - with open(filename, "w") as f: + with open(filename, "w", + encoding="utf-8", errors="surrogatepass") as f: for stack, count in lines: f.write(f"{stack} {count}\n") print(f"Collapsed stack output written to {filename}") diff --git a/Lib/test/libregrtest/utils.py b/Lib/test/libregrtest/utils.py index dfec7a59b02583b..6de2d07bcbb3b38 100644 --- a/Lib/test/libregrtest/utils.py +++ b/Lib/test/libregrtest/utils.py @@ -330,9 +330,6 @@ def get_build_info(): # Get most important configure and build options as a list of strings. # Example: ['debug', 'ASAN+MSAN'] or ['release', 'LTO+PGO']. - config_args = sysconfig.get_config_var('CONFIG_ARGS') or '' - cflags = sysconfig.get_config_var('PY_CFLAGS') or '' - cflags += ' ' + (sysconfig.get_config_var('PY_CFLAGS_NODIST') or '') ldflags_nodist = sysconfig.get_config_var('PY_LDFLAGS_NODIST') or '' build = [] @@ -351,18 +348,16 @@ def get_build_info(): free_threading = f"{free_threading} GIL={int(PYTHON_GIL)}" build.append(free_threading) - if hasattr(sys, 'gettotalrefcount'): + if support.Py_DEBUG: # --with-pydebug build.append('debug') - if '-DNDEBUG' in cflags: + if not support.built_with_c_assertions(): build.append('without_assert') else: build.append('release') - if '--with-assertions' in config_args: - build.append('with_assert') - elif '-DNDEBUG' not in cflags: + if support.built_with_c_assertions(): build.append('with_assert') # --enable-experimental-jit diff --git a/Lib/test/pythoninfo.py b/Lib/test/pythoninfo.py index ea7edb798051567..90b32aaaaf9a40a 100644 --- a/Lib/test/pythoninfo.py +++ b/Lib/test/pythoninfo.py @@ -611,14 +611,6 @@ def collect_sysconfig(info_add): value = normalize_text(value) info_add('sysconfig[%s]' % name, value) - PY_CFLAGS = sysconfig.get_config_var('PY_CFLAGS') - NDEBUG = (PY_CFLAGS and '-DNDEBUG' in PY_CFLAGS) - if NDEBUG: - text = 'ignore assertions (macro defined)' - else: - text= 'build assertions (macro not defined)' - info_add('build.NDEBUG',text) - for name in ( 'WITH_DOC_STRINGS', 'WITH_DTRACE', @@ -844,6 +836,8 @@ def collect_support(info_add): support.check_sanitizer(memory=True)) info_add('support.check_sanitizer(ub=True)', support.check_sanitizer(ub=True)) + info_add('support.built_with_c_assertions', + support.built_with_c_assertions()) def collect_support_os_helper(info_add): diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 1c28af08988d9f0..28a0ba6c666629b 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -74,7 +74,7 @@ "run_no_yield_async_fn", "run_yielding_async_fn", "async_yield", "reset_code", "on_github_actions", "requires_root_user", "requires_non_root_user", - "skip_if_double_rounding", + "skip_if_double_rounding", "built_with_c_assertions", ] @@ -3526,3 +3526,18 @@ def check_immutable_type(testcase, type): else: flags = type_getflags(type) testcase.assertTrue(flags & Py_TPFLAGS_IMMUTABLETYPE) + + +def built_with_c_assertions(): + """Check if Python was built with C assertions (assert()).""" + + if MS_WINDOWS: + # On Windows, rely on the Py_DEBUG macro to check for assertions + return Py_DEBUG + + # Check if the NDEBUG macro is defined in C compiler flags + PY_CFLAGS = (sysconfig.get_config_var('PY_CFLAGS') or '') + if '-DNDEBUG' in PY_CFLAGS: + return False + + return True diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 4c721c01a34f070..10f2a5dfb505e31 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -12,7 +12,6 @@ import gc import sys -import sysconfig import textwrap import threading import time @@ -79,13 +78,6 @@ def __init__(self, partner=None): def __tp_del__(self): pass -if sysconfig.get_config_vars().get('PY_CFLAGS', ''): - BUILD_WITH_NDEBUG = ('-DNDEBUG' in sysconfig.get_config_vars()['PY_CFLAGS']) -else: - # Usually, sys.gettotalrefcount() is only present if Python has been - # compiled in debug mode. If it's missing, expect that Python has - # been released in release mode: with NDEBUG defined. - BUILD_WITH_NDEBUG = (not hasattr(sys, 'gettotalrefcount')) ### Tests ############################################################################### @@ -1422,8 +1414,8 @@ def test_collect_garbage(self): @requires_subprocess() - @unittest.skipIf(BUILD_WITH_NDEBUG, - 'built with -NDEBUG') + @unittest.skipIf(not support.built_with_c_assertions(), + 'built without C assertions') def test_refcount_errors(self): self.preclean() # Verify the "handling" of objects with broken refcounts diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index 069a72eb1c88a6a..dcfca9a9bceb503 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -466,6 +466,28 @@ def test_collapsed_stack_collector_export(self): self.assertIn(stack1_expected, lines) self.assertIn(stack2_expected, lines) + def test_collapsed_stack_collector_export_non_ascii_names(self): + # gh-156810: frame names are written verbatim, so the output must be + # opened with an encoding that can represent non-ASCII and + # surrogate-escaped (undecodable-path) names. + collapsed_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, collapsed_out) + + collector = CollapsedStackCollector(1000) + frame = MockFrameInfo("/tmp/ba\udc80d.py", 5, "计算") + collector.collect([ + MockInterpreterInfo(0, [MockThreadInfo(1, [frame])]) + ]) + + with captured_stdout(), captured_stderr(): + collector.export(collapsed_out.name) + + with open(collapsed_out.name, encoding="utf-8", + errors="surrogatepass") as f: + content = f.read() + self.assertIn("计算", content) + self.assertIn("ba\udc80d.py", content) + def test_flamegraph_collector_basic(self): """Test basic FlamegraphCollector functionality.""" collector = FlamegraphCollector(1000) diff --git a/Misc/NEWS.d/next/Library/2026-09-02-17-28-16.gh-issue-156810.cLpEnc.rst b/Misc/NEWS.d/next/Library/2026-09-02-17-28-16.gh-issue-156810.cLpEnc.rst new file mode 100644 index 000000000000000..610b0b4fcdc1baa --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-02-17-28-16.gh-issue-156810.cLpEnc.rst @@ -0,0 +1,4 @@ +Fix a :exc:`UnicodeEncodeError` crash in the sampling profiler's +collapsed-stack export (``--collapsed``) when a sampled frame's function or +file name contains non-ASCII or surrogate-escaped characters. The output file +is now written as UTF-8. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 5630f0309d98cff..a744a885932cdf5 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -1671,17 +1671,17 @@ _textiowrapper_writeflush(textio *self) } else { assert(PyList_Check(pending)); - b = PyBytes_FromStringAndSize(NULL, self->pending_bytes_count); - if (b == NULL) { + PyBytesWriter *writer = PyBytesWriter_Create(self->pending_bytes_count); + if (writer == NULL) { return -1; } - char *buf = PyBytes_AsString(b); + char *buf = PyBytesWriter_GetData(writer); Py_ssize_t pos = 0; for (Py_ssize_t i = 0; i < PyList_GET_SIZE(pending); i++) { PyObject *obj = PyList_GET_ITEM(pending, i); - char *src; + const char *src; Py_ssize_t len; if (PyUnicode_Check(obj)) { assert(PyUnicode_IS_ASCII(obj)); @@ -1690,15 +1690,18 @@ _textiowrapper_writeflush(textio *self) } else { assert(PyBytes_Check(obj)); - if (PyBytes_AsStringAndSize(obj, &src, &len) < 0) { - Py_DECREF(b); - return -1; - } + src = PyBytes_AS_STRING(obj); + len = PyBytes_GET_SIZE(obj); } memcpy(buf + pos, src, len); pos += len; } assert(pos == self->pending_bytes_count); + + b = PyBytesWriter_Finish(writer); + if (b == NULL) { + return -1; + } } self->pending_bytes_count = 0;