Skip to content

Commit b2ea0e5

Browse files
authored
Fix #352: support callable and PathLike entries in default_config_files (#353)
Fix #352: support callable and PathLike entries in default_config_files
1 parent 9453a69 commit b2ea0e5

2 files changed

Lines changed: 690 additions & 109 deletions

File tree

configargparse.py

Lines changed: 197 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,7 @@ def __init__(self, *args, **kwargs):
848848
be parsed in order, with the values from each config file
849849
taking precedence over previous ones. This allows an application
850850
to look for config files in multiple standard locations such as
851-
the install directory, home directory, and current directory.
851+
the install directory, home directory, and/or current directory.
852852
Also, shell \* syntax can be used to specify all conf files in a
853853
directory. For example::
854854
@@ -857,6 +857,14 @@ def __init__(self, *args, **kwargs):
857857
"~/.my_app_config.ini",
858858
"./app_config.txt"]
859859
860+
Path entries may be strings, ``os.PathLike`` objects (e.g.
861+
``pathlib.Path``), or zero-argument callable functions that
862+
return an open file-like object containing config file
863+
contents. Any provided callable is invoked each time the parser opens
864+
config files, and the returned stream is closed by the parser
865+
after parsing. The callable must return a stream. This is useful
866+
for sourcing config from non-filesystem locations such as in-memory
867+
buffers, secrets managers, or HTTP responses.
860868
ignore_unknown_config_file_keys: If true, settings that are found
861869
in a config file but don't correspond to any defined
862870
configargparse args will be ignored. If false, they will be
@@ -923,6 +931,14 @@ def __init__(self, *args, **kwargs):
923931
hint = " (e.g. ['%s'])" % value if isinstance(value, str) else ""
924932
raise TypeError("%s must be a list%s. Got: %r" % (name, hint, value))
925933

934+
for i, entry in enumerate(default_config_files):
935+
if not (isinstance(entry, (str, bytes, os.PathLike)) or callable(entry)):
936+
raise TypeError(
937+
"default_config_files[%d] must be a string, bytes, or "
938+
"os.PathLike path, or a callable that returns an open "
939+
"file-like object. Got: %r" % (i, entry)
940+
)
941+
926942
if not callable(config_file_open_func):
927943
raise TypeError(
928944
"config_file_open_func must be callable. Got: %r"
@@ -1179,64 +1195,75 @@ def parse_known_args(
11791195
for config_key in self.get_possible_config_keys(action)
11801196
}
11811197

1182-
# open the config file(s)
1198+
# open the config file(s). config_streams is a list of (stream, source_label) tuples.
11831199
config_streams = []
11841200
if config_file_contents is not None:
11851201
stream = StringIO(config_file_contents)
11861202
stream.name = "method arg"
1187-
config_streams = [stream]
1203+
config_streams = [(stream, "method arg")]
11881204
elif not skip_config_file_parsing:
11891205
config_streams = self._open_config_files(args)
11901206

11911207
# parse each config file
1192-
for stream in reversed(config_streams):
1193-
try:
1194-
config_items = self._config_file_parser.parse(stream)
1195-
except ConfigFileParserException as e:
1196-
self.error(str(e))
1197-
finally:
1198-
if hasattr(stream, "close"):
1199-
stream.close()
1200-
1201-
# add each config item to the commandline unless it's there already
1202-
config_args = []
1203-
for key, value in config_items.items():
1204-
if key in known_config_keys:
1205-
action = known_config_keys[key]
1206-
discard_this_key = already_on_command_line(
1207-
args, action.option_strings, self.prefix_chars
1208-
)
1209-
else:
1210-
action = None
1211-
discard_this_key = (
1212-
self._ignore_unknown_config_file_keys
1213-
or already_on_command_line(
1214-
args,
1215-
[
1216-
self.get_command_line_key_for_unknown_config_file_setting(
1217-
key
1218-
)
1219-
],
1220-
self.prefix_chars,
1208+
try:
1209+
for stream, source_label in reversed(config_streams):
1210+
try:
1211+
config_items = self._config_file_parser.parse(stream)
1212+
except ConfigFileParserException as e:
1213+
self.error(str(e))
1214+
1215+
# add each config item to the commandline unless it's there already
1216+
config_args = []
1217+
for key, value in config_items.items():
1218+
if key in known_config_keys:
1219+
action = known_config_keys[key]
1220+
discard_this_key = already_on_command_line(
1221+
args, action.option_strings, self.prefix_chars
1222+
)
1223+
else:
1224+
action = None
1225+
discard_this_key = (
1226+
self._ignore_unknown_config_file_keys
1227+
or already_on_command_line(
1228+
args,
1229+
[
1230+
self.get_command_line_key_for_unknown_config_file_setting(
1231+
key
1232+
)
1233+
],
1234+
self.prefix_chars,
1235+
)
12211236
)
1222-
)
1223-
1224-
# Skip empty string values for args with nargs to match YAML behavior
1225-
# where empty values are treated as None/not present (see issue #296)
1226-
if value == "" and action and action.nargs:
1227-
continue
12281237

1229-
if not discard_this_key:
1230-
config_args += self.convert_item_to_command_line_arg(
1231-
action, key, value
1232-
)
1233-
source_key = "%s|%s" % (_CONFIG_FILE_SOURCE_KEY, stream.name)
1234-
if source_key not in self._source_to_settings:
1235-
self._source_to_settings[source_key] = OrderedDict()
1236-
self._source_to_settings[source_key][key] = (action, value)
1238+
# Skip empty string values for args with nargs to match YAML behavior
1239+
# where empty values are treated as None/not present (see issue #296)
1240+
if value == "" and action and action.nargs:
1241+
continue
12371242

1238-
idx = self._find_insertion_index(args)
1239-
args = args[:idx] + config_args + args[idx:]
1243+
if not discard_this_key:
1244+
config_args += self.convert_item_to_command_line_arg(
1245+
action, key, value
1246+
)
1247+
source_key = "%s|%s" % (
1248+
_CONFIG_FILE_SOURCE_KEY,
1249+
source_label,
1250+
)
1251+
if source_key not in self._source_to_settings:
1252+
self._source_to_settings[source_key] = OrderedDict()
1253+
self._source_to_settings[source_key][key] = (action, value)
1254+
1255+
idx = self._find_insertion_index(args)
1256+
args = args[:idx] + config_args + args[idx:]
1257+
finally:
1258+
# Close every stream exactly once, regardless of whether parsing
1259+
# succeeded or aborted partway through (e.g. a custom parser
1260+
# raised a non-ConfigFileParserException).
1261+
for stream, _ in config_streams:
1262+
try:
1263+
if hasattr(stream, "close"):
1264+
stream.close()
1265+
except Exception:
1266+
pass
12401267

12411268
# save default settings for use by print_values()
12421269
default_settings = OrderedDict()
@@ -1517,75 +1544,122 @@ def _open_config_files(self, command_line_args):
15171544
command_line_args: List of all args
15181545
15191546
Returns:
1520-
list[io.IOBase]: open config files
1547+
list[tuple[io.IOBase, str]]: list of ``(stream, source_label)``
1548+
pairs. The ``source_label`` is unique per entry (file path for
1549+
path entries; ``"<entry_label>[<index>]"`` for callable entries),
1550+
so different entries cannot collapse into a single source key in
1551+
``format_values()``.
15211552
"""
15221553
# open any default config files
15231554
config_files = []
1524-
for files in map(
1525-
glob.glob, map(os.path.expanduser, self._default_config_files)
1526-
):
1527-
for f in files:
1528-
config_files.append(self._config_file_open_func(f))
1555+
try:
1556+
for i, entry in enumerate(self._default_config_files):
1557+
if isinstance(entry, (str, os.PathLike)):
1558+
# Path entries (str or PathLike). Checked before callable so
1559+
# objects implementing both __fspath__ and __call__ are
1560+
# treated as paths, matching the documented behavior.
1561+
for f in glob.glob(os.path.expanduser(os.fspath(entry))):
1562+
config_files.append((self._config_file_open_func(f), f))
1563+
else:
1564+
# Callable entry (validation in __init__ guarantees this).
1565+
entry_label = getattr(entry, "__name__", repr(entry))
1566+
try:
1567+
stream = entry()
1568+
except Exception as e:
1569+
raise ConfigFileParserException(
1570+
"default_config_files entry %r raised while being "
1571+
"called: %s" % (entry_label, e)
1572+
) from e
1573+
if stream is None:
1574+
raise TypeError(
1575+
"default_config_files entry %r returned None; "
1576+
"must return an open file-like object." % (entry_label,)
1577+
)
1578+
# Use a source label that always includes the entry index
1579+
# so two callables returning streams with the same .name
1580+
# (or even the same library-generated name from a previous
1581+
# iteration) cannot collide into one _source_to_settings
1582+
# entry. The label also doubles as stream.name when the
1583+
# stream lacks one, for readable parser error messages.
1584+
display_name = (
1585+
getattr(stream, "name", None)
1586+
if hasattr(stream, "name")
1587+
else None
1588+
)
1589+
source_label = "%s[%d]" % (display_name or entry_label, i)
1590+
# Append before attempting .name so the outer cleanup
1591+
# closes the stream if the assignment below raises.
1592+
config_files.append((stream, source_label))
1593+
if not hasattr(stream, "name"):
1594+
try:
1595+
stream.name = source_label
1596+
except Exception as e:
1597+
raise ConfigFileParserException(
1598+
"default_config_files entry %r returned a "
1599+
"stream whose .name attribute could not be "
1600+
"set: %s" % (entry_label, e)
1601+
) from e
15291602

1530-
# list actions with is_config_file_arg=True. Its possible there is more
1531-
# than one such arg.
1532-
user_config_file_arg_actions = [
1533-
a for a in self._actions if getattr(a, "is_config_file_arg", False)
1534-
]
1603+
# list actions with is_config_file_arg=True. Its possible there is
1604+
# more than one such arg.
1605+
user_config_file_arg_actions = [
1606+
a for a in self._actions if getattr(a, "is_config_file_arg", False)
1607+
]
15351608

1536-
if not user_config_file_arg_actions:
1537-
return config_files
1609+
if not user_config_file_arg_actions:
1610+
return config_files
15381611

1539-
for action in user_config_file_arg_actions:
1540-
# try to parse out the config file path by using a clean new
1541-
# ArgumentParser that only knows this one arg/action.
1542-
arg_parser = argparse.ArgumentParser(
1543-
prefix_chars=self.prefix_chars, add_help=False
1544-
)
1612+
for action in user_config_file_arg_actions:
1613+
# try to parse out the config file path by using a clean new
1614+
# ArgumentParser that only knows this one arg/action.
1615+
arg_parser = argparse.ArgumentParser(
1616+
prefix_chars=self.prefix_chars, add_help=False
1617+
)
15451618

1546-
arg_parser._add_action(action)
1619+
arg_parser._add_action(action)
15471620

1548-
# make parser not exit on error by replacing its error method.
1549-
# Otherwise it sys.exits(..) if, for example, config file
1550-
# is_required=True and user doesn't provide it.
1551-
def error_method(self, message):
1552-
pass
1621+
# make parser not exit on error by replacing its error method.
1622+
# Otherwise it sys.exits(..) if, for example, config file
1623+
# is_required=True and user doesn't provide it.
1624+
def error_method(self, message):
1625+
pass
15531626

1554-
arg_parser.error = types.MethodType(error_method, arg_parser)
1627+
arg_parser.error = types.MethodType(error_method, arg_parser)
15551628

1556-
# check whether the user provided a value
1557-
parsed_arg = arg_parser.parse_known_args(args=command_line_args)
1558-
if not parsed_arg:
1559-
continue
1560-
namespace, _ = parsed_arg
1561-
user_config_file = getattr(namespace, action.dest, None)
1629+
# check whether the user provided a value
1630+
namespace, _ = arg_parser.parse_known_args(args=command_line_args)
1631+
user_config_file = getattr(namespace, action.dest, None)
15621632

1563-
if not user_config_file:
1564-
continue
1633+
if not user_config_file:
1634+
continue
15651635

1566-
# open user-provided config file
1567-
user_config_file = os.path.expanduser(user_config_file)
1568-
try:
1569-
stream = self._config_file_open_func(user_config_file)
1570-
except Exception as e:
1571-
if len(e.args) == 2: # OSError
1572-
errno, msg = e.args
1573-
else:
1574-
msg = str(e)
1575-
# close previously opened config files
1576-
for config_file in config_files:
1577-
try:
1578-
config_file.close()
1579-
except Exception:
1580-
pass
1581-
self.error(
1582-
"Unable to open config file: %s. Error: %s"
1583-
% (user_config_file, msg)
1584-
)
1636+
# open user-provided config file
1637+
user_config_file = os.path.expanduser(user_config_file)
1638+
try:
1639+
stream = self._config_file_open_func(user_config_file)
1640+
except Exception as e:
1641+
self.error(
1642+
"Unable to open config file: %s. Error: %s"
1643+
% (user_config_file, str(e))
1644+
)
15851645

1586-
config_files += [stream]
1646+
config_files.append((stream, user_config_file))
15871647

1588-
return config_files
1648+
return config_files
1649+
except BaseException:
1650+
# If anything in the body above raises (callable failure, .name
1651+
# assignment failure, glob/open failure, an inner argparse type=
1652+
# callback raising during user-config-file parsing, self.error()
1653+
# exiting, etc.), close every stream we opened so we don't leak
1654+
# file handles. close() is idempotent for standard streams, so it
1655+
# is safe to call on streams already closed by a nested handler.
1656+
for cf, _ in config_files:
1657+
try:
1658+
if hasattr(cf, "close"):
1659+
cf.close()
1660+
except Exception:
1661+
pass
1662+
raise
15891663

15901664
def format_values(self):
15911665
"""Returns a string with all args and settings and where they came from
@@ -1606,7 +1680,7 @@ def format_values(self):
16061680
source,
16071681
settings,
16081682
) in self._source_to_settings.items(): # type: ignore[argument-error]
1609-
source = source.split("|")
1683+
source = source.split("|", 1)
16101684
source = source_key_to_display_value_map[source[0]] % tuple(source[1:])
16111685
r.write(source)
16121686
for key, (action, value) in settings.items():
@@ -1659,9 +1733,26 @@ def format_help(self):
16591733
if config_arg_string:
16601734
config_arg_string = "specified via " + config_arg_string
16611735
if default_config_files or config_arg_string:
1736+
# Mirror _open_config_files: path entries (str/PathLike)
1737+
# are checked before callable, so an object that is both
1738+
# PathLike and callable is rendered as a path here too.
1739+
# os.fsdecode() handles bytes-returning __fspath__ (PEP
1740+
# 519 allows it); the try/except handles malformed
1741+
# PathLike whose __fspath__ returns the wrong type.
1742+
def _describe_default_config_file_entry(e):
1743+
if isinstance(e, (str, os.PathLike)):
1744+
try:
1745+
return os.fsdecode(os.fspath(e))
1746+
except (TypeError, ValueError):
1747+
return repr(e)
1748+
return getattr(e, "__name__", "<callable>")
1749+
1750+
described_files = tuple(
1751+
_describe_default_config_file_entry(e)
1752+
for e in default_config_files
1753+
)
16621754
msg += " (%s)." % " or ".join(
1663-
tuple(map(str, default_config_files))
1664-
+ tuple(filter(None, [config_arg_string]))
1755+
described_files + tuple(filter(None, [config_arg_string]))
16651756
)
16661757
msg += " " + self._config_file_parser.get_syntax_description()
16671758

0 commit comments

Comments
 (0)