Coverage for /Users/OORDCOR/Documents/code/bump-my-version/bumpversion/version_part.py: 23%

157 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-15 09:15 -0600

1"""Module for managing Versions and their internal parts.""" 

2import re 

3import string 

4from copy import copy 

5from typing import Any, Dict, List, MutableMapping, Optional, Union 

6 

7from click import UsageError 

8 

9from bumpversion.config.models import VersionPartConfig 

10from bumpversion.exceptions import FormattingError, InvalidVersionPartError, MissingValueError 

11from bumpversion.functions import NumericFunction, PartFunction, ValuesFunction 

12from bumpversion.ui import get_indented_logger 

13from bumpversion.utils import key_val_string, labels_for_format 

14 

15logger = get_indented_logger(__name__) 

16 

17 

18class VersionPart: 

19 """ 

20 Represent part of a version number. 

21 

22 Determines the PartFunction that rules how the part behaves when increased or reset 

23 based on the configuration given. 

24 """ 

25 

26 def __init__(self, config: VersionPartConfig, value: Union[str, int, None] = None): 

27 self._value = str(value) if value is not None else None 

28 self.config = config 

29 self.func: Optional[PartFunction] = None 

30 if config.values: 

31 str_values = [str(v) for v in config.values] 

32 str_optional_value = str(config.optional_value) if config.optional_value is not None else None 

33 str_first_value = str(config.first_value) if config.first_value is not None else None 

34 self.func = ValuesFunction(str_values, str_optional_value, str_first_value) 

35 else: 

36 self.func = NumericFunction(config.optional_value, config.first_value or "0") 

37 

38 @property 

39 def value(self) -> str: 

40 """Return the value of the part.""" 

41 return self._value or self.func.optional_value 

42 

43 def copy(self) -> "VersionPart": 

44 """Return a copy of the part.""" 

45 return VersionPart(self.config, self._value) 

46 

47 def bump(self) -> "VersionPart": 

48 """Return a part with bumped value.""" 

49 return VersionPart(self.config, self.func.bump(self.value)) 

50 

51 def null(self) -> "VersionPart": 

52 """Return a part with first value.""" 

53 return VersionPart(self.config, self.func.first_value) 

54 

55 @property 

56 def is_optional(self) -> bool: 

57 """Is the part optional?""" 

58 return self.value == self.func.optional_value 

59 

60 @property 

61 def is_independent(self) -> bool: 

62 """Is the part independent of the other parts?""" 

63 return self.config.independent 

64 

65 def __format__(self, format_spec: str) -> str: 

66 try: 

67 val = int(self.value) 

68 except ValueError: 

69 return self.value 

70 else: 

71 return int.__format__(val, format_spec) 

72 

73 def __repr__(self) -> str: 

74 return f"<bumpversion.VersionPart:{self.func.__class__.__name__}:{self.value}>" 

75 

76 def __eq__(self, other: Any) -> bool: 

77 return self.value == other.value if isinstance(other, VersionPart) else False 

78 

79 

80class Version: 

81 """The specification of a version and its parts.""" 

82 

83 def __init__(self, values: Dict[str, VersionPart], original: Optional[str] = None): 

84 self.values = values 

85 self.original = original 

86 

87 def __getitem__(self, key: str) -> VersionPart: 

88 return self.values[key] 

89 

90 def __len__(self) -> int: 

91 return len(self.values) 

92 

93 def __iter__(self): 

94 return iter(self.values) 

95 

96 def __repr__(self): 

97 return f"<bumpversion.Version:{key_val_string(self.values)}>" 

98 

99 def __eq__(self, other: Any) -> bool: 

100 return ( 

101 all(value == other.values[key] for key, value in self.values.items()) 

102 if isinstance(other, Version) 

103 else False 

104 ) 

105 

106 def bump(self, part_name: str, order: List[str]) -> "Version": 

107 """Increase the value of the given part.""" 

108 bumped = False 

109 

110 new_values = {} 

111 

112 for label in order: 

113 if label not in self.values: 

114 continue 

115 if label == part_name: 

116 new_values[label] = self.values[label].bump() 

117 bumped = True 

118 elif bumped and not self.values[label].is_independent: 

119 new_values[label] = self.values[label].null() 

120 else: 

121 new_values[label] = self.values[label].copy() 

122 

123 if not bumped: 

124 raise InvalidVersionPartError(f"No part named {part_name!r}") 

125 

126 return Version(new_values) 

127 

128 

129class VersionConfig: 

130 """ 

131 Hold a complete representation of a version string. 

132 """ 

133 

134 def __init__( 

135 self, 

136 parse: str, 

137 serialize: List[str], 

138 search: str, 

139 replace: str, 

140 part_configs: Optional[Dict[str, VersionPartConfig]] = None, 

141 ): 

142 try: 

143 self.parse_regex = re.compile(parse, re.VERBOSE) 

144 except re.error as e: 

145 raise UsageError(f"--parse '{parse}' is not a valid regex.") from e 

146 

147 self.serialize_formats = serialize 

148 self.part_configs = part_configs or {} 

149 # TODO: I think these two should be removed from the config object 

150 self.search = search 

151 self.replace = replace 

152 

153 @property 

154 def order(self) -> List[str]: 

155 """ 

156 Return the order of the labels in a serialization format. 

157 

158 Currently, order depends on the first given serialization format. 

159 This seems like a good idea because this should be the most complete format. 

160 

161 Returns: 

162 A list of version part labels in the order they should be rendered. 

163 """ 

164 return labels_for_format(self.serialize_formats[0]) 

165 

166 def parse(self, version_string: Optional[str] = None) -> Optional[Version]: 

167 """ 

168 Parse a version string into a Version object. 

169 

170 Args: 

171 version_string: Version string to parse 

172 

173 Returns: 

174 A Version object representing the string. 

175 """ 

176 if not version_string: 

177 return None 

178 

179 regexp_one_line = "".join([line.split("#")[0].strip() for line in self.parse_regex.pattern.splitlines()]) 

180 

181 logger.info( 

182 "Parsing version '%s' using regexp '%s'", 

183 version_string, 

184 regexp_one_line, 

185 ) 

186 logger.indent() 

187 

188 match = self.parse_regex.search(version_string) 

189 

190 if not match: 

191 logger.warning( 

192 "Evaluating 'parse' option: '%s' does not parse current version '%s'", 

193 self.parse_regex.pattern, 

194 version_string, 

195 ) 

196 return None 

197 

198 _parsed = { 

199 key: VersionPart(self.part_configs[key], value) 

200 for key, value in match.groupdict().items() 

201 if key in self.part_configs 

202 } 

203 v = Version(_parsed, version_string) 

204 

205 logger.info("Parsed the following values: %s", key_val_string(v.values)) 

206 logger.dedent() 

207 

208 return v 

209 

210 def _serialize( 

211 self, version: Version, serialize_format: str, context: MutableMapping, raise_if_incomplete: bool = False 

212 ) -> str: 

213 """ 

214 Attempts to serialize a version with the given serialization format. 

215 

216 Args: 

217 version: The version to serialize 

218 serialize_format: The serialization format to use, using Python's format string syntax 

219 context: The context to use when serializing the version 

220 raise_if_incomplete: Whether to raise an error if the version is incomplete 

221 

222 Raises: 

223 FormattingError: if not serializable 

224 MissingValueError: if not all parts required in the format have values 

225 

226 Returns: 

227 The serialized version as a string 

228 """ 

229 values = copy(context) 

230 for k in version: 

231 values[k] = version[k] 

232 

233 # TODO dump complete context on debug level 

234 

235 try: 

236 # test whether all parts required in the format have values 

237 serialized = serialize_format.format(**values) 

238 

239 except KeyError as e: 

240 missing_key = getattr(e, "message", e.args[0]) 

241 raise MissingValueError( 

242 f"Did not find key {missing_key!r} in {version!r} when serializing version number" 

243 ) from e 

244 

245 keys_needing_representation = set() 

246 

247 keys = list(self.order) 

248 for i, k in enumerate(keys): 

249 v = values[k] 

250 

251 if not isinstance(v, VersionPart): 

252 # values coming from environment variables don't need 

253 # representation 

254 continue 

255 

256 if not v.is_optional: 

257 keys_needing_representation = set(keys[: i + 1]) 

258 

259 required_by_format = set(labels_for_format(serialize_format)) 

260 

261 # try whether all parsed keys are represented 

262 if raise_if_incomplete and not keys_needing_representation <= required_by_format: 

263 missing_keys = keys_needing_representation ^ required_by_format 

264 raise FormattingError( 

265 f"""Could not represent '{"', '".join(missing_keys)}' in format '{serialize_format}'""" 

266 ) 

267 

268 return serialized 

269 

270 def _choose_serialize_format(self, version: Version, context: MutableMapping) -> str: 

271 chosen = None 

272 

273 logger.debug("Evaluating serialization formats") 

274 logger.indent() 

275 for serialize_format in self.serialize_formats: 

276 try: 

277 self._serialize(version, serialize_format, context, raise_if_incomplete=True) 

278 # Prefer shorter or first search expression. 

279 chosen_part_count = len(list(string.Formatter().parse(chosen))) if chosen else None 

280 serialize_part_count = len(list(string.Formatter().parse(serialize_format))) 

281 if not chosen or chosen_part_count > serialize_part_count: 

282 chosen = serialize_format 

283 logger.debug("Found '%s' to be a usable serialization format", chosen) 

284 else: 

285 logger.debug("Found '%s' usable serialization format, but it's longer", serialize_format) 

286 except FormattingError: 

287 # If chosen, prefer shorter 

288 if not chosen: 

289 chosen = serialize_format 

290 except MissingValueError as e: 

291 logger.info(e.message) 

292 raise e 

293 

294 if not chosen: 

295 raise KeyError("Did not find suitable serialization format") 

296 logger.dedent() 

297 logger.debug("Selected serialization format '%s'", chosen) 

298 

299 return chosen 

300 

301 def serialize(self, version: Version, context: MutableMapping) -> str: 

302 """ 

303 Serialize a version to a string. 

304 

305 Args: 

306 version: The version to serialize 

307 context: The context to use when serializing the version 

308 

309 Returns: 

310 The serialized version as a string 

311 """ 

312 logger.debug("Serializing version '%s'", version) 

313 logger.indent() 

314 serialized = self._serialize(version, self._choose_serialize_format(version, context), context) 

315 logger.debug("Serialized to '%s'", serialized) 

316 logger.dedent() 

317 return serialized