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

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

2 import re

3 import string

4 from copy import copy

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

6

7 from click import UsageError

8

9 from bumpversion.config.models import VersionPartConfig

10 from bumpversion.exceptions import FormattingError, InvalidVersionPartError, MissingValueError

11 from bumpversion.functions import NumericFunction, PartFunction, ValuesFunction

12 from bumpversion.ui import get_indented_logger

13 from bumpversion.utils import key_val_string, labels_for_format

14

15 logger = get_indented_logger(__name__)

16

17

18 class 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

80 class 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

129 class 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: 268 ↛ 260line 268 didn't jump to line 260, because the condition on line 268 was never false

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