Skip to content

Commit 9a017f2

Browse files
committed
Add resolve_name(name, *, strict=False)
The PEP describes this as an open issue, but it's the cleanest approach.
1 parent b36a16a commit 9a017f2

4 files changed

Lines changed: 95 additions & 14 deletions

File tree

Doc/library/pkgutil.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ support.
194194
The :mod:`importlib.resources` module provides structured access to
195195
module resources.
196196

197-
.. function:: resolve_name(name)
197+
.. function:: resolve_name(name, *, strict=False)
198198

199199
Resolve a name to an object.
200200

@@ -208,6 +208,7 @@ support.
208208

209209
* ``W(.W)*``
210210
* ``W(.W)*:(W(.W)*)?``
211+
* ``W(.W)*:(W(.W)*)``
211212

212213
The first form is intended for backward compatibility only. It assumes that
213214
some part of the dotted name is a package, and the rest is an object
@@ -222,6 +223,11 @@ support.
222223
hierarchy within that package. Only one import is needed in this form. If
223224
it ends with the colon, then a module object is returned.
224225

226+
The first two forms are accepted when ``strict=False`` (the default).
227+
228+
The third form requires both the module name and callable, separated by
229+
a colon. Only this form is accepted when ``strict=True``.
230+
225231
The function will return an object (which might be a module), or raise one
226232
of the following exceptions:
227233

@@ -233,3 +239,7 @@ support.
233239
hierarchy within the imported package to get to the desired object.
234240

235241
.. versionadded:: 3.9
242+
243+
.. versionchanged:: 3.15
244+
245+
The optional keyword-only ``strict`` flag was added.

Lib/pkgutil.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
import os.path
1010
import sys
1111

12+
lazy import re
13+
14+
1215
__all__ = [
1316
'get_importer', 'iter_importers',
1417
'walk_packages', 'iter_modules', 'get_data',
@@ -398,9 +401,10 @@ def get_data(package, resource):
398401
return loader.get_data(resource_name)
399402

400403

401-
_NAME_PATTERN = None
404+
_LENIENT_PATTERN = None
405+
_STRICT_PATTERN = None
402406

403-
def resolve_name(name):
407+
def resolve_name(name, *, strict=False):
404408
"""
405409
Resolve a name to an object.
406410
@@ -410,6 +414,7 @@ def resolve_name(name):
410414
411415
W(.W)*
412416
W(.W)*:(W(.W)*)?
417+
W(.W)*:(W(.W)*)
413418
414419
The first form is intended for backward compatibility only. It assumes that
415420
some part of the dotted name is a package, and the rest is an object
@@ -424,6 +429,11 @@ def resolve_name(name):
424429
hierarchy within that package. Only one import is needed in this form. If
425430
it ends with the colon, then a module object is returned.
426431
432+
The first two forms are accepted when `strict=False` (the default).
433+
434+
The third form requires both the module name and callable, separated by
435+
a colon. Only this form is accepted when `strict=True`.
436+
427437
The function will return an object (which might be a module), or raise one
428438
of the following exceptions:
429439
@@ -432,18 +442,26 @@ def resolve_name(name):
432442
AttributeError - if a failure occurred when traversing the object hierarchy
433443
within the imported package to get to the desired object.
434444
"""
435-
global _NAME_PATTERN
436-
if _NAME_PATTERN is None:
437-
# Lazy import to speedup Python startup time
438-
import re
439-
dotted_words = r'(?!\d)(\w+)(\.(?!\d)(\w+))*'
440-
_NAME_PATTERN = re.compile(f'^(?P<pkg>{dotted_words})'
441-
f'(?P<cln>:(?P<obj>{dotted_words})?)?$',
442-
re.UNICODE)
443-
444-
m = _NAME_PATTERN.match(name)
445-
if not m:
445+
global _LENIENT_PATTERN, _STRICT_PATTERN
446+
dotted_words = r'(?!\d)(\w+)(\.(?!\d)(\w+))*'
447+
if strict:
448+
if _STRICT_PATTERN is None:
449+
_STRICT_PATTERN = re.compile(
450+
f'^(?P<pkg>{dotted_words})'
451+
f'(?P<cln>:(?P<obj>{dotted_words}))$',
452+
re.UNICODE)
453+
pattern = _STRICT_PATTERN
454+
else:
455+
if _LENIENT_PATTERN is None:
456+
_LENIENT_PATTERN = re.compile(
457+
f'^(?P<pkg>{dotted_words})'
458+
f'(?P<cln>:(?P<obj>{dotted_words})?)?$',
459+
re.UNICODE)
460+
pattern = _LENIENT_PATTERN
461+
462+
if (m := pattern.match(name)) is None:
446463
raise ValueError(f'invalid format: {name!r}')
464+
447465
gd = m.groupdict()
448466
if gd.get('cln'):
449467
# there is a colon - a one-step import is all that's needed

Lib/test/test_pkgutil.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,56 @@ def test_name_resolution(self):
322322
with self.assertRaises(exc):
323323
pkgutil.resolve_name(s)
324324

325+
def test_name_resolution_strict(self):
326+
# PEP 829: strict=True accepts only the pkg.mod:callable form
327+
# (W(.W)*:W(.W)*) -- both the colon and the callable are required.
328+
import logging
329+
import logging.handlers
330+
331+
success_cases = (
332+
('os.path:pathsep', os.path.pathsep),
333+
('logging.handlers:SysLogHandler',
334+
logging.handlers.SysLogHandler),
335+
('logging.handlers:SysLogHandler.LOG_ALERT',
336+
logging.handlers.SysLogHandler.LOG_ALERT),
337+
('builtins:int', int),
338+
('builtins:int.from_bytes', int.from_bytes),
339+
('os:path', os.path),
340+
)
341+
342+
# All of these are accepted under strict=False but must be
343+
# rejected under strict=True.
344+
failure_cases = (
345+
'os', # no colon (non-strict form)
346+
'os.path', # no colon
347+
'logging:', # colon, empty callable
348+
'os.foo:', # colon, empty callable
349+
':int', # empty package
350+
'os.path:join:extra', # extra colon
351+
'os.path.9abc:join', # invalid identifier in package
352+
'os.path:9abc', # invalid identifier in callable
353+
'', # empty
354+
'?abc:foo', # invalid character
355+
)
356+
357+
for s, expected in success_cases:
358+
with self.subTest(s=s):
359+
self.assertEqual(
360+
pkgutil.resolve_name(s, strict=True), expected)
361+
362+
for s in failure_cases:
363+
with self.subTest(s=s):
364+
with self.assertRaises(ValueError):
365+
pkgutil.resolve_name(s, strict=True)
366+
367+
# Cache independence: a strict=True call must not poison
368+
# strict=False (and vice versa). Exercise both orderings.
369+
self.assertEqual(
370+
pkgutil.resolve_name('os:path', strict=True), os.path)
371+
self.assertEqual(pkgutil.resolve_name('os.path'), os.path)
372+
self.assertEqual(
373+
pkgutil.resolve_name('os:path', strict=True), os.path)
374+
325375
def test_name_resolution_import_rebinding(self):
326376
# The same data is also used for testing import in test_import and
327377
# mock.patch in test_unittest.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
:pep:`829` (package startup configuration files) implements a new format
22
``<name>.start`` parallel to ``<name>.pth`` files, to replace ``import``
33
lines in the latter.
4+
5+
:func:`pkgutil.resolve_name` gets a new optional, keyword-only argument called
6+
``strict``. The default is ``False`` for backward compatibility.

0 commit comments

Comments
 (0)