diff --git a/Doc/library/symtable.rst b/Doc/library/symtable.rst index 95f20b06b5aa1e..859687340882de 100644 --- a/Doc/library/symtable.rst +++ b/Doc/library/symtable.rst @@ -20,6 +20,8 @@ Generating Symbol Tables .. function:: symtable(code, filename, compile_type, *, module=None) Return the toplevel :class:`SymbolTable` for the Python source *code*. + *code* can be a string, a bytes object, or an AST object, + as for the builtin :func:`compile`. *filename* is the name of the file containing the code. *compile_type* is like the *mode* argument to :func:`compile`. The optional argument *module* specifies the module name. @@ -29,6 +31,9 @@ Generating Symbol Tables .. versionadded:: 3.15 Added the *module* parameter. + .. versionchanged:: next + *code* can now be an AST object. + Examining Symbol Tables ----------------------- diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index ed6d303c1e28ad..0ef281dfe1e63b 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -379,6 +379,14 @@ shlex (Contributed by Jay Berry in :gh:`148846`.) +symtable +-------- + +* :func:`symtable.symtable` now accepts an AST object, + like the builtin :func:`compile`. + (Contributed by Serhiy Storchaka in :gh:`153844`.) + + tkinter ------- diff --git a/Lib/symtable.py b/Lib/symtable.py index 9238437191c00f..18bb355d86b09e 100644 --- a/Lib/symtable.py +++ b/Lib/symtable.py @@ -20,6 +20,7 @@ def symtable(code, filename, compile_type, *, module=None): """ Return the toplevel *SymbolTable* for the source code. + *code* can be a string, a bytes object, or an AST object. *filename* is the name of the file with the code and *compile_type* is the *compile()* mode argument. """ diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index db5b89bb76ada9..e319849adf366d 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -129,12 +129,12 @@ def test_init_script(self): """ os.write(w, b'\0') - executor = self.executor_type(initializer=initscript) - before_init = os.read(r, 100) - fut = executor.submit(script) - after_init = read_msg(r) - fut.result() - after_run = read_msg(r) + with self.executor_type(initializer=initscript) as executor: + before_init = os.read(r, 100) + fut = executor.submit(script) + after_init = read_msg(r) + fut.result() + after_run = read_msg(r) self.assertEqual(before_init, b'\0') self.assertEqual(after_init, msg1) @@ -150,11 +150,11 @@ def test_init_func(self): r, w = self.pipe() os.write(w, b'\0') - executor = self.executor_type( - initializer=write_msg, initargs=(w, msg)) - before = os.read(r, 100) - executor.submit(mul, 10, 10) - after = read_msg(r) + with self.executor_type( + initializer=write_msg, initargs=(w, msg)) as executor: + before = os.read(r, 100) + executor.submit(mul, 10, 10) + after = read_msg(r) self.assertEqual(before, b'\0') self.assertEqual(after, msg) @@ -260,11 +260,10 @@ def test_submit_script(self): import os os.write({w}, __name__.encode('utf-8') + b'\\0') """ - executor = self.executor_type() - - fut = executor.submit(script) - res = fut.result() - after = read_msg(r) + with self.executor_type() as executor: + fut = executor.submit(script) + res = fut.result() + after = read_msg(r) self.assertEqual(after, b'__main__') self.assertIs(res, None) @@ -278,25 +277,24 @@ def task2(): spam += 1 return spam - executor = self.executor_type() - - fut = executor.submit(task1) - with self.assertRaises(_interpreters.NotShareableError): - fut.result() + with self.executor_type() as executor: + fut = executor.submit(task1) + with self.assertRaises(_interpreters.NotShareableError): + fut.result() - fut = executor.submit(task2) - with self.assertRaises(_interpreters.NotShareableError): - fut.result() + fut = executor.submit(task2) + with self.assertRaises(_interpreters.NotShareableError): + fut.result() def test_submit_local_instance(self): class Spam: def __init__(self): self.value = True - executor = self.executor_type() - fut = executor.submit(Spam) - with self.assertRaises(_interpreters.NotShareableError): - fut.result() + with self.executor_type() as executor: + fut = executor.submit(Spam) + with self.assertRaises(_interpreters.NotShareableError): + fut.result() def test_submit_instance_method(self): class Spam: @@ -304,15 +302,15 @@ def run(self): return True spam = Spam() - executor = self.executor_type() - fut = executor.submit(spam.run) - with self.assertRaises(_interpreters.NotShareableError): - fut.result() + with self.executor_type() as executor: + fut = executor.submit(spam.run) + with self.assertRaises(_interpreters.NotShareableError): + fut.result() def test_submit_func_globals(self): - executor = self.executor_type() - fut = executor.submit(get_current_name) - name = fut.result() + with self.executor_type() as executor: + fut = executor.submit(get_current_name) + name = fut.result() self.assertEqual(name, __name__) self.assertNotEqual(name, '__main__') diff --git a/Lib/test/test_symtable.py b/Lib/test/test_symtable.py index 8c03420c4c5e4b..ce02b27c599c42 100644 --- a/Lib/test/test_symtable.py +++ b/Lib/test/test_symtable.py @@ -2,6 +2,7 @@ Test the API of the symtable module. """ +import ast import symtable import warnings import unittest @@ -554,6 +555,77 @@ def test_loopvar_in_only_one_scope(self): self.assertEqual(len([x for x in ids if x == 'x']), 1) +class ASTInputTests(unittest.TestCase): + maxDiff = None + + def dump(self, table): + return (table.get_name(), table.get_type(), table.get_lineno(), + [repr(symbol) for symbol in table.get_symbols()], + [self.dump(child) for child in table.get_children()]) + + def test_exec(self): + top = symtable.symtable(ast.parse(TEST_CODE), "?", "exec") + self.assertIsNotNone(find_block(top, "Mine")) + + def test_eval(self): + table = symtable.symtable(ast.parse("a + b", mode="eval"), "?", "eval") + self.assertEqual(sorted(table.get_identifiers()), ["a", "b"]) + + def test_single(self): + table = symtable.symtable(ast.parse("x = 1", mode="single"), + "?", "single") + self.assertIn("x", table.get_identifiers()) + + def test_same_result_as_string(self): + cases = [ + (TEST_CODE, "exec"), + ("from __future__ import annotations\n" + "def f(x: int) -> int: return x\n", "exec"), + ("[x*y for x in a]", "eval"), + ("def f(): pass\n", "single"), + ] + for source, mode in cases: + with self.subTest(source=source, mode=mode): + from_str = symtable.symtable(source, "?", mode) + from_ast = symtable.symtable(ast.parse(source, mode=mode), + "?", mode) + self.assertEqual(self.dump(from_ast), self.dump(from_str)) + + def test_synthesized_ast(self): + # An AST created programmatically, without any source. + node = ast.Module(body=[ + ast.FunctionDef( + name="f", + args=ast.arguments(args=[ast.arg(arg="x")]), + body=[ast.Return(ast.Name("x", ast.Load()))])]) + ast.fix_missing_locations(node) + top = symtable.symtable(node, "?", "exec") + f = find_block(top, "f") + self.assertTrue(f.lookup("x").is_parameter()) + + def test_mode_mismatch(self): + tree = ast.parse("x = 1") + for mode in ("eval", "single"): + with self.subTest(mode=mode): + with self.assertRaises(TypeError): + symtable.symtable(tree, "?", mode) + with self.assertRaises(TypeError): + symtable.symtable(ast.parse("x", mode="eval"), "?", "exec") + + def test_invalid_ast(self): + node = ast.Expression(ast.Name("x", ast.Store())) + ast.fix_missing_locations(node) + with self.assertRaises(ValueError): + symtable.symtable(node, "?", "eval") + + def test_misplaced_future_import(self): + # The parser does not enforce the placement of future imports in + # an existing AST; the symbol table construction does. + tree = ast.parse("x = 1\nfrom __future__ import annotations\n") + with self.assertRaises(SyntaxError): + symtable.symtable(tree, "?", "exec") + + class CommandLineTest(unittest.TestCase): maxDiff = None diff --git a/Misc/NEWS.d/next/Library/2026-07-17-14-30-00.gh-issue-153844.pYs7Kq.rst b/Misc/NEWS.d/next/Library/2026-07-17-14-30-00.gh-issue-153844.pYs7Kq.rst new file mode 100644 index 00000000000000..cf46eec8513f67 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-17-14-30-00.gh-issue-153844.pYs7Kq.rst @@ -0,0 +1,2 @@ +:func:`symtable.symtable` now accepts an AST object, +like the builtin :func:`compile`. diff --git a/Modules/clinic/symtablemodule.c.h b/Modules/clinic/symtablemodule.c.h index 65352593f94802..00ee4315846b60 100644 --- a/Modules/clinic/symtablemodule.c.h +++ b/Modules/clinic/symtablemodule.c.h @@ -12,7 +12,9 @@ PyDoc_STRVAR(_symtable_symtable__doc__, "symtable($module, source, filename, startstr, /, *, module=None)\n" "--\n" "\n" -"Return symbol and scope dictionaries used internally by compiler."); +"Return symbol and scope dictionaries used internally by compiler.\n" +"\n" +"The source can be a string, a bytes object, or an AST object."); #define _SYMTABLE_SYMTABLE_METHODDEF \ {"symtable", _PyCFunction_CAST(_symtable_symtable), METH_FASTCALL|METH_KEYWORDS, _symtable_symtable__doc__}, @@ -95,4 +97,4 @@ _symtable_symtable(PyObject *module, PyObject *const *args, Py_ssize_t nargs, Py return return_value; } -/*[clinic end generated code: output=0137be60c487c841 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=23523cada784726e input=a9049054013a1b77]*/ diff --git a/Modules/symtablemodule.c b/Modules/symtablemodule.c index e9e1c4811b8303..7e20b5c7173ae5 100644 --- a/Modules/symtablemodule.c +++ b/Modules/symtablemodule.c @@ -1,4 +1,6 @@ #include "Python.h" +#include "pycore_ast.h" // PyAST_Check() +#include "pycore_pyarena.h" // _PyArena_New() #include "pycore_pythonrun.h" // _Py_SourceAsString() #include "pycore_symtable.h" // struct symtable @@ -9,6 +11,29 @@ module _symtable /*[clinic end generated code: output=da39a3ee5e6b4b0d input=f4685845a7100605]*/ +static struct symtable * +symtable_from_ast(PyObject *source, PyObject *filename, int compile_mode) +{ + PyArena *arena = _PyArena_New(); + if (arena == NULL) { + return NULL; + } + struct symtable *st = NULL; + mod_ty mod = PyAST_obj2mod(source, arena, compile_mode); + if (mod == NULL || !_PyAST_Validate(mod)) { + goto finally; + } + _PyFutureFeatures future; + if (!_PyFuture_FromAST(mod, filename, &future)) { + goto finally; + } + st = _PySymtable_Build(mod, filename, &future); +finally: + _PyArena_Free(arena); + return st; +} + + /*[clinic input] _symtable.symtable @@ -20,37 +45,29 @@ _symtable.symtable module as modname: object = None Return symbol and scope dictionaries used internally by compiler. + +The source can be a string, a bytes object, or an AST object. [clinic start generated code]*/ static PyObject * _symtable_symtable_impl(PyObject *module, PyObject *source, PyObject *filename, const char *startstr, PyObject *modname) -/*[clinic end generated code: output=235ec5a87a9ce178 input=fbf9adaa33c7070d]*/ +/*[clinic end generated code: output=235ec5a87a9ce178 input=6cadac0485f576a7]*/ { struct symtable *st; PyObject *t; - int start; - PyCompilerFlags cf = _PyCompilerFlags_INIT; - PyObject *source_copy = NULL; - - cf.cf_flags = PyCF_SOURCE_IS_UTF8; - - const char *str = _Py_SourceAsString(source, "symtable", "string or bytes", &cf, &source_copy); - if (str == NULL) { - return NULL; - } + int compile_mode; if (strcmp(startstr, "exec") == 0) - start = Py_file_input; + compile_mode = 0; else if (strcmp(startstr, "eval") == 0) - start = Py_eval_input; + compile_mode = 1; else if (strcmp(startstr, "single") == 0) - start = Py_single_input; + compile_mode = 2; else { PyErr_SetString(PyExc_ValueError, "symtable() arg 3 must be 'exec' or 'eval' or 'single'"); - Py_XDECREF(source_copy); return NULL; } if (modname == Py_None) { @@ -60,11 +77,30 @@ _symtable_symtable_impl(PyObject *module, PyObject *source, PyErr_Format(PyExc_TypeError, "symtable() argument 'module' must be str or None, not %T", modname); - Py_XDECREF(source_copy); return NULL; } - st = _Py_SymtableStringObjectFlags(str, filename, start, &cf, modname); - Py_XDECREF(source_copy); + + if (PyAST_Check(source)) { + st = symtable_from_ast(source, filename, compile_mode); + } + else { + static const int starts[] = { + Py_file_input, Py_eval_input, Py_single_input}; + PyCompilerFlags cf = _PyCompilerFlags_INIT; + PyObject *source_copy = NULL; + + cf.cf_flags = PyCF_SOURCE_IS_UTF8; + const char *str = _Py_SourceAsString(source, "symtable", + "string, bytes or AST", + &cf, &source_copy); + if (str == NULL) { + return NULL; + } + st = _Py_SymtableStringObjectFlags(str, filename, + starts[compile_mode], &cf, + modname); + Py_XDECREF(source_copy); + } if (st == NULL) { return NULL; }