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

161 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-18 11:22 -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, Tuple, 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: 30 ↛ 31line 30 didn't jump to line 31, because the condition on line 30 was never true

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: 113 ↛ 114line 113 didn't jump to line 114, because the condition on line 113 was never true

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: 123 ↛ 124line 123 didn't jump to line 124, because the condition on line 123 was never true

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: Tuple[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 def __repr__(self) -> str: 

154 return f"<bumpversion.VersionConfig:{self.parse_regex.pattern}:{self.serialize_formats}>" 

155 

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

157 return ( 

158 self.parse_regex.pattern == other.parse_regex.pattern 

159 and self.serialize_formats == other.serialize_formats 

160 and self.part_configs == other.part_configs 

161 and self.search == other.search 

162 and self.replace == other.replace 

163 ) 

164 

165 @property 

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

167 """ 

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

169 

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

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

172 

173 Returns: 

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

175 """ 

176 return labels_for_format(self.serialize_formats[0]) 

177 

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

179 """ 

180 Parse a version string into a Version object. 

181 

182 Args: 

183 version_string: Version string to parse 

184 

185 Returns: 

186 A Version object representing the string. 

187 """ 

188 if not version_string: 188 ↛ 189line 188 didn't jump to line 189, because the condition on line 188 was never true

189 return None 

190 

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

192 

193 logger.info( 

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

195 version_string, 

196 regexp_one_line, 

197 ) 

198 logger.indent() 

199 

200 match = self.parse_regex.search(version_string) 

201 

202 if not match: 202 ↛ 203line 202 didn't jump to line 203, because the condition on line 202 was never true

203 logger.warning( 

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

205 self.parse_regex.pattern, 

206 version_string, 

207 ) 

208 return None 

209 

210 _parsed = { 

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

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

213 if key in self.part_configs 

214 } 

215 v = Version(_parsed, version_string) 

216 

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

218 logger.dedent() 

219 

220 return v 

221 

222 def _serialize( 

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

224 ) -> str: 

225 """ 

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

227 

228 Args: 

229 version: The version to serialize 

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

231 context: The context to use when serializing the version 

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

233 

234 Raises: 

235 FormattingError: if not serializable 

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

237 

238 Returns: 

239 The serialized version as a string 

240 """ 

241 values = copy(context) 

242 for k in version: 

243 values[k] = version[k] 

244 

245 # TODO dump complete context on debug level 

246 

247 try: 

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

249 serialized = serialize_format.format(**values) 

250 

251 except KeyError as e: 

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

253 raise MissingValueError( 

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

255 ) from e 

256 

257 keys_needing_representation = set() 

258 

259 keys = list(self.order) 

260 for i, k in enumerate(keys): 

261 v = values[k] 

262 

263 if not isinstance(v, VersionPart): 263 ↛ 266line 263 didn't jump to line 266, because the condition on line 263 was never true

264 # values coming from environment variables don't need 

265 # representation 

266 continue 

267 

268 if not v.is_optional: 

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

270 

271 required_by_format = set(labels_for_format(serialize_format)) 

272 

273 # try whether all parsed keys are represented 

274 if raise_if_incomplete and not keys_needing_representation <= required_by_format: 274 ↛ 275line 274 didn't jump to line 275, because the condition on line 274 was never true

275 missing_keys = keys_needing_representation ^ required_by_format 

276 raise FormattingError( 

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

278 ) 

279 

280 return serialized 

281 

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

283 chosen = None 

284 

285 logger.debug("Evaluating serialization formats") 

286 logger.indent() 

287 for serialize_format in self.serialize_formats: 

288 try: 

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

290 # Prefer shorter or first search expression. 

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

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

293 if not chosen or chosen_part_count > serialize_part_count: 293 ↛ 297line 293 didn't jump to line 297, because the condition on line 293 was never false

294 chosen = serialize_format 

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

296 else: 

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

298 except FormattingError: 

299 # If chosen, prefer shorter 

300 if not chosen: 

301 chosen = serialize_format 

302 except MissingValueError as e: 

303 logger.info(e.message) 

304 raise e 

305 

306 if not chosen: 306 ↛ 307line 306 didn't jump to line 307, because the condition on line 306 was never true

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

308 logger.dedent() 

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

310 

311 return chosen 

312 

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

314 """ 

315 Serialize a version to a string. 

316 

317 Args: 

318 version: The version to serialize 

319 context: The context to use when serializing the version 

320 

321 Returns: 

322 The serialized version as a string 

323 """ 

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

325 logger.indent() 

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

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

328 logger.dedent() 

329 return serialized