Coverage for python/lsst/utils/argparsing.py: 100%

26 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 14:40 -0700

1# This file is part of utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11# 

12 

13"""Utilities to help with argument parsing in command line interfaces.""" 

14 

15from __future__ import annotations 

16 

17__all__ = ["AppendDict"] 

18 

19import argparse 

20import copy 

21from collections.abc import Mapping 

22from typing import Any 

23 

24 

25class AppendDict(argparse.Action): 

26 """An action analogous to the built-in 'append' that appends to a `dict` 

27 instead of a `list`. 

28 

29 Inputs are assumed to be strings in the form "key=value"; any input that 

30 does not contain exactly one "=" character is invalid. If the default value 

31 is non-empty, the default key-value pairs may be overwritten by values from 

32 the command line. 

33 

34 Parameters 

35 ---------- 

36 option_strings : `str` | `list` [ `str` ] 

37 The command-line option strings that trigger this action, e.g. 

38 ``--config``. 

39 dest : `str` 

40 The name of the `~argparse.Namespace` attribute in which the 

41 accumulated `dict` is stored. 

42 nargs : `int` | `str` | `None`, optional 

43 The number of command-line arguments consumed each time the option 

44 appears, as accepted by `argparse.ArgumentParser.add_argument`. 

45 const : `typing.Any` | `None`, optional 

46 A constant value required by some ``nargs`` settings; unused by this 

47 action. 

48 default : `typing.Any` | `None`, optional 

49 The initial value of the mapping if the option does not appear on the 

50 command line. Must be a `~collections.abc.Mapping` or `None`; `None` 

51 is converted to an empty `dict`. 

52 type : `type` | `None`, optional 

53 A callable applied by argparse to each command-line argument before 

54 it is passed to this action. 

55 choices : `typing.Any` | `None`, optional 

56 A container of the allowable values for the argument. 

57 required : `bool`, optional 

58 Whether the option must appear on the command line. 

59 help : `str` | `None`, optional 

60 A brief description of the argument for usage messages. 

61 metavar : `str` | `None`, optional 

62 The name for the argument's value(s) in usage messages. 

63 """ 

64 

65 def __init__( 

66 self, 

67 option_strings: str | list[str], 

68 dest: str, 

69 nargs: int | str | None = None, 

70 const: Any | None = None, 

71 default: Any | None = None, 

72 type: type | None = None, 

73 choices: Any | None = None, 

74 required: bool = False, 

75 help: str | None = None, 

76 metavar: str | None = None, 

77 ): 

78 if default is None: 

79 default = {} 

80 if not isinstance(default, Mapping): 

81 argname = option_strings if option_strings else metavar if metavar else dest 

82 raise TypeError(f"Default for {argname} must be a mapping or None, got {default!r}.") 

83 super().__init__(option_strings, dest, nargs, const, default, type, choices, required, help, metavar) 

84 

85 def __call__( 

86 self, parser: argparse.ArgumentParser, namespace: Any, values: Any, option_string: str | None = None 

87 ) -> None: 

88 # argparse doesn't make defensive copies, so namespace.dest may be 

89 # the same object as self.default. Do the copy ourselves and avoid 

90 # modifying the object previously in namespace.dest. 

91 mapping = copy.copy(getattr(namespace, self.dest)) 

92 

93 # Sometimes values is a copy of default instead of an input??? 

94 if isinstance(values, Mapping): 

95 mapping.update(values) 

96 else: 

97 # values may be either a string or list of strings, depending on 

98 # nargs. Unsafe to test for Sequence, because a scalar string 

99 # passes. 

100 if not isinstance(values, list): 

101 values = [values] 

102 for value in values: 

103 vars = value.split("=") 

104 if len(vars) != 2: 

105 raise ValueError(f"Argument {value!r} does not match format 'key=value'.") 

106 mapping[vars[0]] = vars[1] 

107 

108 # Other half of the defensive copy. 

109 setattr(namespace, self.dest, mapping)