Skip to content

gh-108851: Fix tomllib recursion tests #108853

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Lib/test/pythoninfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def collect_sys(info_add):

call_func(info_add, 'sys.androidapilevel', sys, 'getandroidapilevel')
call_func(info_add, 'sys.windowsversion', sys, 'getwindowsversion')
call_func(info_add, 'sys.getrecursionlimit', sys, 'getrecursionlimit')

encoding = sys.getfilesystemencoding()
if hasattr(sys, 'getfilesystemencodeerrors'):
Expand Down
43 changes: 40 additions & 3 deletions Lib/test/support/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2241,6 +2241,39 @@ def check_disallow_instantiation(testcase, tp, *args, **kwds):
msg = f"cannot create '{re.escape(qualname)}' instances"
testcase.assertRaisesRegex(TypeError, msg, tp, *args, **kwds)

def get_recursion_depth():
"""Get the recursion depth of the caller function.

In the __main__ module, at the module level, it should be 1.
"""
try:
import _testinternalcapi
depth = _testinternalcapi.get_recursion_depth()
except (ImportError, RecursionError) as exc:
# sys._getframe() + frame.f_back implementation.
try:
depth = 0
frame = sys._getframe()
while frame is not None:
depth += 1
frame = frame.f_back
finally:
# Break any reference cycles.
frame = None

# Ignore get_recursion_depth() frame.
return max(depth - 1, 1)

def get_recursion_available():
"""Get the number of available frames before RecursionError.

It depends on the current recursion depth of the caller function and
sys.getrecursionlimit().
"""
limit = sys.getrecursionlimit()
depth = get_recursion_depth()
return limit - depth

@contextlib.contextmanager
def set_recursion_limit(limit):
"""Temporarily change the recursion limit."""
Expand All @@ -2251,14 +2284,18 @@ def set_recursion_limit(limit):
finally:
sys.setrecursionlimit(original_limit)

def infinite_recursion(max_depth=75):
def infinite_recursion(max_depth=100):
"""Set a lower limit for tests that interact with infinite recursions
(e.g test_ast.ASTHelpers_Test.test_recursion_direct) since on some
debug windows builds, due to not enough functions being inlined the
stack size might not handle the default recursion limit (1000). See
bpo-11105 for details."""
return set_recursion_limit(max_depth)

if max_depth < 3:
raise ValueError("max_depth must be at least 3, got {max_depth}")
depth = get_recursion_depth()
depth = max(depth - 1, 1) # Ignore infinite_recursion() frame.
limit = depth + max_depth
return set_recursion_limit(limit)

def ignore_deprecations_from(module: str, *, like: str) -> object:
token = object()
Expand Down
77 changes: 77 additions & 0 deletions Lib/test/test_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,83 @@ def test_has_strftime_extensions(self):
else:
self.assertTrue(support.has_strftime_extensions)

def test_get_recursion_depth(self):
# test support.get_recursion_depth()
code = textwrap.dedent("""
from test import support
import sys

def check(cond):
if not cond:
raise AssertionError("test failed")

# depth 1
check(support.get_recursion_depth() == 1)

# depth 2
def test_func():
check(support.get_recursion_depth() == 2)
test_func()

def test_recursive(depth, limit):
if depth >= limit:
# cannot call get_recursion_depth() at this depth,
# it can raise RecursionError
return
get_depth = support.get_recursion_depth()
print(f"test_recursive: {depth}/{limit}: "
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should subTest() used instead of printing out the values?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a script run by test_get_recursion_depth(). You should not see the output unless the test crash or fails. It's not written with unittest.

f"get_recursion_depth() says {get_depth}")
check(get_depth == depth)
test_recursive(depth + 1, limit)

# depth up to 25
with support.infinite_recursion(max_depth=25):
limit = sys.getrecursionlimit()
print(f"test with sys.getrecursionlimit()={limit}")
test_recursive(2, limit)

# depth up to 500
with support.infinite_recursion(max_depth=500):
limit = sys.getrecursionlimit()
print(f"test with sys.getrecursionlimit()={limit}")
test_recursive(2, limit)
""")
script_helper.assert_python_ok("-c", code)

def test_recursion(self):
# Test infinite_recursion() and get_recursion_available() functions.
def recursive_function(depth):
if depth:
recursive_function(depth - 1)

for max_depth in (5, 25, 250):
with support.infinite_recursion(max_depth):
available = support.get_recursion_available()

# Recursion up to 'available' additional frames should be OK.
recursive_function(available)

# Recursion up to 'available+1' additional frames must raise
# RecursionError. Avoid self.assertRaises(RecursionError) which
# can consume more than 3 frames and so raises RecursionError.
try:
recursive_function(available + 1)
except RecursionError:
pass
else:
self.fail("RecursionError was not raised")

# Test the bare minimumum: max_depth=3
with support.infinite_recursion(3):
try:
recursive_function(3)
except RecursionError:
pass
else:
self.fail("RecursionError was not raised")

#self.assertEqual(available, 2)

# XXX -follows a list of untested API
# make_legacy_pyc
# is_resource_enabled
Expand Down
65 changes: 35 additions & 30 deletions Lib/test/test_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,20 +279,29 @@ def test_switchinterval(self):
finally:
sys.setswitchinterval(orig)

def test_recursionlimit(self):
def test_getrecursionlimit(self):
limit = sys.getrecursionlimit()
self.assertIsInstance(limit, int)
self.assertGreater(limit, 1)

self.assertRaises(TypeError, sys.getrecursionlimit, 42)
oldlimit = sys.getrecursionlimit()
self.assertRaises(TypeError, sys.setrecursionlimit)
self.assertRaises(ValueError, sys.setrecursionlimit, -42)
sys.setrecursionlimit(10000)
self.assertEqual(sys.getrecursionlimit(), 10000)
sys.setrecursionlimit(oldlimit)

def test_setrecursionlimit(self):
old_limit = sys.getrecursionlimit()
try:
sys.setrecursionlimit(10_005)
self.assertEqual(sys.getrecursionlimit(), 10_005)

self.assertRaises(TypeError, sys.setrecursionlimit)
self.assertRaises(ValueError, sys.setrecursionlimit, -42)
finally:
sys.setrecursionlimit(old_limit)

def test_recursionlimit_recovery(self):
if hasattr(sys, 'gettrace') and sys.gettrace():
self.skipTest('fatal error if run with a trace function')

oldlimit = sys.getrecursionlimit()
old_limit = sys.getrecursionlimit()
def f():
f()
try:
Expand All @@ -311,35 +320,31 @@ def f():
with self.assertRaises(RecursionError):
f()
finally:
sys.setrecursionlimit(oldlimit)
sys.setrecursionlimit(old_limit)

@test.support.cpython_only
def test_setrecursionlimit_recursion_depth(self):
def test_setrecursionlimit_to_depth(self):
# Issue #25274: Setting a low recursion limit must be blocked if the
# current recursion depth is already higher than limit.

from _testinternalcapi import get_recursion_depth

def set_recursion_limit_at_depth(depth, limit):
recursion_depth = get_recursion_depth()
if recursion_depth >= depth:
with self.assertRaises(RecursionError) as cm:
sys.setrecursionlimit(limit)
self.assertRegex(str(cm.exception),
"cannot set the recursion limit to [0-9]+ "
"at the recursion depth [0-9]+: "
"the limit is too low")
else:
set_recursion_limit_at_depth(depth, limit)

oldlimit = sys.getrecursionlimit()
old_limit = sys.getrecursionlimit()
try:
sys.setrecursionlimit(1000)

for limit in (10, 25, 50, 75, 100, 150, 200):
set_recursion_limit_at_depth(limit, limit)
depth = support.get_recursion_depth()
with self.subTest(limit=sys.getrecursionlimit(), depth=depth):
# depth + 1 is OK
sys.setrecursionlimit(depth + 1)

# reset the limit to be able to call self.assertRaises()
# context manager
sys.setrecursionlimit(old_limit)
with self.assertRaises(RecursionError) as cm:
sys.setrecursionlimit(depth)
self.assertRegex(str(cm.exception),
"cannot set the recursion limit to [0-9]+ "
"at the recursion depth [0-9]+: "
"the limit is too low")
finally:
sys.setrecursionlimit(oldlimit)
sys.setrecursionlimit(old_limit)

def test_getwindowsversion(self):
# Raise SkipTest if sys doesn't have getwindowsversion attribute
Expand Down
27 changes: 19 additions & 8 deletions Lib/test/test_tomllib/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import sys
import tempfile
import unittest
from test import support

from . import tomllib

Expand Down Expand Up @@ -92,13 +93,23 @@ def test_deepcopy(self):
self.assertEqual(obj_copy, expected_obj)

def test_inline_array_recursion_limit(self):
# 465 with default recursion limit
nest_count = int(sys.getrecursionlimit() * 0.465)
recursive_array_toml = "arr = " + nest_count * "[" + nest_count * "]"
tomllib.loads(recursive_array_toml)
with support.infinite_recursion(max_depth=100):
available = support.get_recursion_available()
nest_count = (available // 2) - 2
# Add details if the test fails
with self.subTest(limit=sys.getrecursionlimit(),
available=available,
nest_count=nest_count):
recursive_array_toml = "arr = " + nest_count * "[" + nest_count * "]"
tomllib.loads(recursive_array_toml)

def test_inline_table_recursion_limit(self):
# 310 with default recursion limit
nest_count = int(sys.getrecursionlimit() * 0.31)
recursive_table_toml = nest_count * "key = {" + nest_count * "}"
tomllib.loads(recursive_table_toml)
with support.infinite_recursion(max_depth=100):
available = support.get_recursion_available()
nest_count = (available // 3) - 1
# Add details if the test fails
with self.subTest(limit=sys.getrecursionlimit(),
available=available,
nest_count=nest_count):
recursive_table_toml = nest_count * "key = {" + nest_count * "}"
tomllib.loads(recursive_table_toml)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add ``get_recursion_available()`` and ``get_recursion_depth()`` functions to
the :mod:`test.support` module. Patch by Victor Stinner.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fix ``test_tomllib`` recursion tests for WASI buildbots: reduce the recursion
limit and compute the maximum nested array/dict depending on the current
available recursion limit. Patch by Victor Stinner.