Coverage for src/bunches/utilities.py: 100%

54 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-30 23:02 +0000

1"""Functions for inferring names, iterating, and building subsets. 

2 

3The module only holds private helpers for the other `bunches` modules. Each is 

4described in its own docstring. 

5 

6""" 

7 

8from __future__ import annotations 

9 

10import copy 

11import inspect 

12import re 

13from collections.abc import Collection, Iterator, Sequence 

14from typing import TYPE_CHECKING, Any 

15 

16if TYPE_CHECKING: 

17 from .kinds import GenericDict, SubsetReturns 

18 

19 

20""" Private Functions """ 

21 

22 

23def _capitalify(item: str) -> str: 

24 """Converts a snake case `str` to capital case. 

25 

26 Args: 

27 item: `str` to convert. 

28 

29 Returns: 

30 'item' converted to capital case. 

31 

32 """ 

33 return item.replace('_', ' ').title().replace(' ', '') 

34 

35 

36def _is_sequence(item: Any) -> bool: 

37 """Returns if 'item' is a sequence but not a `str` or `bytes`. 

38 

39 Args: 

40 item: object or class to examine. 

41 

42 Returns: 

43 If 'item' is a sequence but not a `str` or `bytes`. 

44 

45 """ 

46 if not inspect.isclass(item): 

47 item = item.__class__ 

48 return issubclass(item, Sequence) and not issubclass(item, str | bytes) 

49 

50 

51def _iterify(item: Any) -> Iterator[Any]: 

52 """Returns `item` as an iterator, but does not iterate `str` types. 

53 

54 Args: 

55 item: item to turn into an iterator. 

56 

57 Returns: 

58 Iterator of `item`. `None` becomes an empty iterator. A `str` or `bytes` 

59 type (or any non-iterable) is wrapped so that it is returned as a 

60 single item. 

61 

62 """ 

63 if item is None: 

64 return iter(()) 

65 if isinstance(item, str | bytes): 

66 return iter([item]) 

67 try: 

68 return iter(item) 

69 except TypeError: 

70 return iter((item,)) 

71 

72 

73def _namify(item: Any, /, default: str | None = None) -> str | None: 

74 """Returns `str` name representation of 'item'. 

75 

76 The name is, in order of preference: 'item' itself if it is a `str`, the 

77 `name` attribute of an instance (if it is a `str`), the snake-cased 

78 `__name__` of a class or function, or the snake-cased name of the class of 

79 'item'. 

80 

81 Args: 

82 item: item to determine a `str` name. 

83 default: name to return if no name can be derived. Defaults to `None`. 

84 

85 Returns: 

86 A name representation of 'item' or 'default'. 

87 

88 """ 

89 if isinstance(item, str): 

90 return item 

91 if (hasattr(item, 'name') 

92 and not inspect.isclass(item) 

93 and isinstance(item.name, str)): 

94 return item.name 

95 name = getattr(item, '__name__', None) 

96 if not isinstance(name, str): 

97 name = getattr(item.__class__, '__name__', None) 

98 return _snakify(name) if isinstance(name, str) and name else default 

99 

100 

101def _return_subset( 

102 subset: Collection, 

103 existing: Collection, 

104 returns: SubsetReturns) -> Collection: 

105 """Returns a subset of an item. 

106 

107 Args: 

108 subset: native Python subset of data from a `Collection`. 

109 existing: a subclass instance of `Collection`. 

110 returns: whether to return a new instance of the class of 'existing' 

111 ('class'), a deep copy of 'existing' with 'subset' as its 

112 `contents` ('copy'), or 'subset' itself ('simple'). 

113 

114 Returns: 

115 A `Collection` with a `subset` of data. 

116 

117 Raises: 

118 ValueError: if 'returns' is not 'class', 'copy', or 'simple'. 

119 

120 """ 

121 if returns == 'class': 

122 return existing.__class__(subset) # type: ignore[call-arg] 

123 if returns == 'copy': 

124 new_collection = copy.deepcopy(existing) 

125 new_collection.contents = subset # type: ignore[attr-defined] 

126 return new_collection 

127 if returns == 'simple': 

128 return subset 

129 message = 'returns argument must be "class", "copy", or "simple"' 

130 raise ValueError(message) 

131 

132 

133def _snakify(item: str) -> str: 

134 """Converts a capitalized `str` to snake case. 

135 

136 Args: 

137 item: `str` to convert. 

138 

139 Returns: 

140 'item' converted to snake case. 

141 

142 """ 

143 item = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', item) 

144 return re.sub('([a-z0-9])([A-Z])', r'\1_\2', item).lower() 

145 

146 

147def _uniquify(key: str, dictionary: GenericDict, index: int = 1) -> str: 

148 """Creates a unique key name to avoid overwriting an item in 'dictionary'. 

149 

150 The function is 1-indexed so that the first attempt to avoid a duplicate 

151 will be: "old_name2". 

152 

153 Args: 

154 key: name of key to test. 

155 dictionary: `dict` for which a unique key name is sought. 

156 index: starting number for the suffix counter. Defaults to 1. 

157 

158 Returns: 

159 A key name that is not in 'dictionary'. 

160 

161 """ 

162 if key not in dictionary: 

163 return key 

164 counter = index 

165 while True: 

166 counter += 1 

167 candidate = f'{key}{counter}' 

168 if candidate not in dictionary: 

169 return candidate