Skip to content

gh-135120: Add test.support.subTests() #135121

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 4 commits into from
Jun 6, 2025
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
25 changes: 25 additions & 0 deletions Lib/test/support/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,31 @@ def check_sizeof(test, o, size):
% (type(o), result, size)
test.assertEqual(result, size, msg)

def subTests(arg_names, arg_values, /, *, _do_cleanups=False):
"""Run multiple subtests with different parameters.
"""
single_param = False
if isinstance(arg_names, str):
arg_names = arg_names.replace(',',' ').split()
if len(arg_names) == 1:
single_param = True
arg_values = tuple(arg_values)
def decorator(func):
if isinstance(func, type):
raise TypeError('subTests() can only decorate methods, not classes')
@functools.wraps(func)
def wrapper(self, /, *args, **kwargs):
for values in arg_values:
if single_param:
values = (values,)
subtest_kwargs = dict(zip(arg_names, values))
with self.subTest(**subtest_kwargs):
func(self, *args, **kwargs, **subtest_kwargs)
if _do_cleanups:
self.doCleanups()
return wrapper
return decorator

#=======================================================================
# Decorator/context manager for running a code in a different locale,
# correctly resetting it afterwards.
Expand Down
187 changes: 88 additions & 99 deletions Lib/test/test_http_cookiejar.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import stat
import sys
import re
from test import support
from test.support import os_helper
from test.support import warnings_helper
import time
Expand Down Expand Up @@ -105,8 +106,7 @@ def test_http2time_formats(self):
self.assertEqual(http2time(s.lower()), test_t, s.lower())
self.assertEqual(http2time(s.upper()), test_t, s.upper())

def test_http2time_garbage(self):
for test in [
@support.subTests('test', [
'',
'Garbage',
'Mandag 16. September 1996',
Expand All @@ -121,10 +121,9 @@ def test_http2time_garbage(self):
'08-01-3697739',
'09 Feb 19942632 22:23:32 GMT',
'Wed, 09 Feb 1994834 22:23:32 GMT',
]:
self.assertIsNone(http2time(test),
"http2time(%s) is not None\n"
"http2time(test) %s" % (test, http2time(test)))
])
def test_http2time_garbage(self, test):
self.assertIsNone(http2time(test))

def test_http2time_redos_regression_actually_completes(self):
# LOOSE_HTTP_DATE_RE was vulnerable to malicious input which caused catastrophic backtracking (REDoS).
Expand All @@ -149,9 +148,7 @@ def parse_date(text):
self.assertEqual(parse_date("1994-02-03 19:45:29 +0530"),
(1994, 2, 3, 14, 15, 29))

def test_iso2time_formats(self):
# test iso2time for supported dates.
tests = [
@support.subTests('s', [
'1994-02-03 00:00:00 -0000', # ISO 8601 format
'1994-02-03 00:00:00 +0000', # ISO 8601 format
'1994-02-03 00:00:00', # zone is optional
Expand All @@ -164,16 +161,15 @@ def test_iso2time_formats(self):
# A few tests with extra space at various places
' 1994-02-03 ',
' 1994-02-03T00:00:00 ',
]

])
def test_iso2time_formats(self, s):
# test iso2time for supported dates.
test_t = 760233600 # assume broken POSIX counting of seconds
for s in tests:
self.assertEqual(iso2time(s), test_t, s)
self.assertEqual(iso2time(s.lower()), test_t, s.lower())
self.assertEqual(iso2time(s.upper()), test_t, s.upper())
self.assertEqual(iso2time(s), test_t, s)
self.assertEqual(iso2time(s.lower()), test_t, s.lower())
self.assertEqual(iso2time(s.upper()), test_t, s.upper())

def test_iso2time_garbage(self):
for test in [
@support.subTests('test', [
'',
'Garbage',
'Thursday, 03-Feb-94 00:00:00 GMT',
Expand All @@ -186,9 +182,9 @@ def test_iso2time_garbage(self):
'01-01-1980 00:00:62',
'01-01-1980T00:00:62',
'19800101T250000Z',
]:
self.assertIsNone(iso2time(test),
"iso2time(%r)" % test)
])
def test_iso2time_garbage(self, test):
self.assertIsNone(iso2time(test))

def test_iso2time_performance_regression(self):
# If ISO_DATE_RE regresses to quadratic complexity, this test will take a very long time to succeed.
Expand All @@ -199,24 +195,23 @@ def test_iso2time_performance_regression(self):

class HeaderTests(unittest.TestCase):

def test_parse_ns_headers(self):
# quotes should be stripped
expected = [[('foo', 'bar'), ('expires', 2209069412), ('version', '0')]]
for hdr in [
@support.subTests('hdr', [
'foo=bar; expires=01 Jan 2040 22:23:32 GMT',
'foo=bar; expires="01 Jan 2040 22:23:32 GMT"',
]:
self.assertEqual(parse_ns_headers([hdr]), expected)

def test_parse_ns_headers_version(self):

])
def test_parse_ns_headers(self, hdr):
# quotes should be stripped
expected = [[('foo', 'bar'), ('version', '1')]]
for hdr in [
expected = [[('foo', 'bar'), ('expires', 2209069412), ('version', '0')]]
self.assertEqual(parse_ns_headers([hdr]), expected)

@support.subTests('hdr', [
'foo=bar; version="1"',
'foo=bar; Version="1"',
]:
self.assertEqual(parse_ns_headers([hdr]), expected)
])
def test_parse_ns_headers_version(self, hdr):
# quotes should be stripped
expected = [[('foo', 'bar'), ('version', '1')]]
self.assertEqual(parse_ns_headers([hdr]), expected)

def test_parse_ns_headers_special_names(self):
# names such as 'expires' are not special in first name=value pair
Expand All @@ -226,8 +221,7 @@ def test_parse_ns_headers_special_names(self):
expected = [[("expires", "01 Jan 2040 22:23:32 GMT"), ("version", "0")]]
self.assertEqual(parse_ns_headers([hdr]), expected)

def test_join_header_words(self):
for src, expected in [
@support.subTests('src,expected', [
([[("foo", None), ("bar", "baz")]], "foo; bar=baz"),
(([]), ""),
(([[]]), ""),
Expand All @@ -237,12 +231,11 @@ def test_join_header_words(self):
'n; foo="foo;_", bar=foo_bar'),
([[("n", "m"), ("foo", None)], [("bar", "foo_bar")]],
'n=m; foo, bar=foo_bar'),
]:
with self.subTest(src=src):
self.assertEqual(join_header_words(src), expected)
])
def test_join_header_words(self, src, expected):
self.assertEqual(join_header_words(src), expected)

def test_split_header_words(self):
tests = [
@support.subTests('arg,expect', [
("foo", [[("foo", None)]]),
("foo=bar", [[("foo", "bar")]]),
(" foo ", [[("foo", None)]]),
Expand All @@ -259,24 +252,22 @@ def test_split_header_words(self):
(r'foo; bar=baz, spam=, foo="\,\;\"", bar= ',
[[("foo", None), ("bar", "baz")],
[("spam", "")], [("foo", ',;"')], [("bar", "")]]),
]

for arg, expect in tests:
try:
result = split_header_words([arg])
except:
import traceback, io
f = io.StringIO()
traceback.print_exc(None, f)
result = "(error -- traceback follows)\n\n%s" % f.getvalue()
self.assertEqual(result, expect, """
])
def test_split_header_words(self, arg, expect):
try:
result = split_header_words([arg])
except:
import traceback, io
f = io.StringIO()
traceback.print_exc(None, f)
result = "(error -- traceback follows)\n\n%s" % f.getvalue()
self.assertEqual(result, expect, """
When parsing: '%s'
Expected: '%s'
Got: '%s'
""" % (arg, expect, result))

def test_roundtrip(self):
tests = [
@support.subTests('arg,expect', [
("foo", "foo"),
("foo=bar", "foo=bar"),
(" foo ", "foo"),
Expand Down Expand Up @@ -309,12 +300,11 @@ def test_roundtrip(self):

('n; foo="foo;_", bar="foo,_"',
'n; foo="foo;_", bar="foo,_"'),
]

for arg, expect in tests:
input = split_header_words([arg])
res = join_header_words(input)
self.assertEqual(res, expect, """
])
def test_roundtrip(self, arg, expect):
input = split_header_words([arg])
res = join_header_words(input)
self.assertEqual(res, expect, """
When parsing: '%s'
Expected: '%s'
Got: '%s'
Expand Down Expand Up @@ -516,14 +506,7 @@ class CookieTests(unittest.TestCase):
## just the 7 special TLD's listed in their spec. And folks rely on
## that...

def test_domain_return_ok(self):
# test optimization: .domain_return_ok() should filter out most
# domains in the CookieJar before we try to access them (because that
# may require disk access -- in particular, with MSIECookieJar)
# This is only a rough check for performance reasons, so it's not too
# critical as long as it's sufficiently liberal.
pol = DefaultCookiePolicy()
for url, domain, ok in [
@support.subTests('url,domain,ok', [
("http://foo.bar.com/", "blah.com", False),
("http://foo.bar.com/", "rhubarb.blah.com", False),
("http://foo.bar.com/", "rhubarb.foo.bar.com", False),
Expand All @@ -543,11 +526,18 @@ def test_domain_return_ok(self):
("http://foo/", ".local", True),
("http://barfoo.com", ".foo.com", False),
("http://barfoo.com", "foo.com", False),
]:
request = urllib.request.Request(url)
r = pol.domain_return_ok(domain, request)
if ok: self.assertTrue(r)
else: self.assertFalse(r)
])
def test_domain_return_ok(self, url, domain, ok):
# test optimization: .domain_return_ok() should filter out most
# domains in the CookieJar before we try to access them (because that
# may require disk access -- in particular, with MSIECookieJar)
# This is only a rough check for performance reasons, so it's not too
# critical as long as it's sufficiently liberal.
pol = DefaultCookiePolicy()
request = urllib.request.Request(url)
r = pol.domain_return_ok(domain, request)
if ok: self.assertTrue(r)
else: self.assertFalse(r)

def test_missing_value(self):
# missing = sign in Cookie: header is regarded by Mozilla as a missing
Expand Down Expand Up @@ -581,10 +571,7 @@ def test_missing_value(self):
self.assertEqual(interact_netscape(c, "http://www.acme.com/foo/"),
'"spam"; eggs')

def test_rfc2109_handling(self):
# RFC 2109 cookies are handled as RFC 2965 or Netscape cookies,
# dependent on policy settings
for rfc2109_as_netscape, rfc2965, version in [
@support.subTests('rfc2109_as_netscape,rfc2965,version', [
# default according to rfc2965 if not explicitly specified
(None, False, 0),
(None, True, 1),
Expand All @@ -593,24 +580,27 @@ def test_rfc2109_handling(self):
(False, True, 1),
(True, False, 0),
(True, True, 0),
]:
policy = DefaultCookiePolicy(
rfc2109_as_netscape=rfc2109_as_netscape,
rfc2965=rfc2965)
c = CookieJar(policy)
interact_netscape(c, "http://www.example.com/", "ni=ni; Version=1")
try:
cookie = c._cookies["www.example.com"]["/"]["ni"]
except KeyError:
self.assertIsNone(version) # didn't expect a stored cookie
else:
self.assertEqual(cookie.version, version)
# 2965 cookies are unaffected
interact_2965(c, "http://www.example.com/",
"foo=bar; Version=1")
if rfc2965:
cookie2965 = c._cookies["www.example.com"]["/"]["foo"]
self.assertEqual(cookie2965.version, 1)
])
def test_rfc2109_handling(self, rfc2109_as_netscape, rfc2965, version):
# RFC 2109 cookies are handled as RFC 2965 or Netscape cookies,
# dependent on policy settings
policy = DefaultCookiePolicy(
rfc2109_as_netscape=rfc2109_as_netscape,
rfc2965=rfc2965)
c = CookieJar(policy)
interact_netscape(c, "http://www.example.com/", "ni=ni; Version=1")
try:
cookie = c._cookies["www.example.com"]["/"]["ni"]
except KeyError:
self.assertIsNone(version) # didn't expect a stored cookie
else:
self.assertEqual(cookie.version, version)
# 2965 cookies are unaffected
interact_2965(c, "http://www.example.com/",
"foo=bar; Version=1")
if rfc2965:
cookie2965 = c._cookies["www.example.com"]["/"]["foo"]
self.assertEqual(cookie2965.version, 1)

def test_ns_parser(self):
c = CookieJar()
Expand Down Expand Up @@ -778,8 +768,7 @@ def test_default_path_with_query(self):
# Cookie is sent back to the same URI.
self.assertEqual(interact_netscape(cj, uri), value)

def test_escape_path(self):
cases = [
@support.subTests('arg,result', [
# quoted safe
("/foo%2f/bar", "/foo%2F/bar"),
("/foo%2F/bar", "/foo%2F/bar"),
Expand All @@ -799,9 +788,9 @@ def test_escape_path(self):
("/foo/bar\u00fc", "/foo/bar%C3%BC"), # UTF-8 encoded
# unicode
("/foo/bar\uabcd", "/foo/bar%EA%AF%8D"), # UTF-8 encoded
]
for arg, result in cases:
self.assertEqual(escape_path(arg), result)
])
def test_escape_path(self, arg, result):
self.assertEqual(escape_path(arg), result)

def test_request_path(self):
# with parameters
Expand Down
20 changes: 2 additions & 18 deletions Lib/test/test_ntpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import unittest
import warnings
from ntpath import ALLOW_MISSING
from test import support
from test.support import TestFailed, cpython_only, os_helper
from test.support.os_helper import FakePath
from test import test_genericpath
Expand Down Expand Up @@ -78,24 +79,7 @@ def tester(fn, wantResult):


def _parameterize(*parameters):
"""Simplistic decorator to parametrize a test

Runs the decorated test multiple times in subTest, with a value from
'parameters' passed as an extra positional argument.
Calls doCleanups() after each run.

Not for general use. Intended to avoid indenting for easier backports.

See https://discuss.python.org/t/91827 for discussing generalizations.
"""
def _parametrize_decorator(func):
def _parameterized(self, *args, **kwargs):
for parameter in parameters:
with self.subTest(parameter):
func(self, *args, parameter, **kwargs)
self.doCleanups()
return _parameterized
return _parametrize_decorator
return support.subTests('kwargs', parameters, _do_cleanups=True)


class NtpathTestCase(unittest.TestCase):
Expand Down
Loading
Loading