@@ -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