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
« 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
7from click import UsageError
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
15logger = get_indented_logger(__name__)
18class VersionPart:
19 """
20 Represent part of a version number.
22 Determines the PartFunction that rules how the part behaves when increased or reset
23 based on the configuration given.
24 """
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")
38 @property
39 def value(self) -> str:
40 """Return the value of the part."""
41 return self._value or self.func.optional_value
43 def copy(self) -> "VersionPart":
44 """Return a copy of the part."""
45 return VersionPart(self.config, self._value)
47 def bump(self) -> "VersionPart":
48 """Return a part with bumped value."""
49 return VersionPart(self.config, self.func.bump(self.value))
51 def null(self) -> "VersionPart":
52 """Return a part with first value."""
53 return VersionPart(self.config, self.func.first_value)
55 @property
56 def is_optional(self) -> bool:
57 """Is the part optional?"""
58 return self.value == self.func.optional_value
60 @property
61 def is_independent(self) -> bool:
62 """Is the part independent of the other parts?"""
63 return self.config.independent
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)
73 def __repr__(self) -> str:
74 return f"<bumpversion.VersionPart:{self.func.__class__.__name__}:{self.value}>"
76 def __eq__(self, other: Any) -> bool:
77 return self.value == other.value if isinstance(other, VersionPart) else False
80class Version:
81 """The specification of a version and its parts."""
83 def __init__(self, values: Dict[str, VersionPart], original: Optional[str] = None):
84 self.values = values
85 self.original = original
87 def __getitem__(self, key: str) -> VersionPart:
88 return self.values[key]
90 def __len__(self) -> int:
91 return len(self.values)
93 def __iter__(self):
94 return iter(self.values)
96 def __repr__(self):
97 return f"<bumpversion.Version:{key_val_string(self.values)}>"
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 )
106 def bump(self, part_name: str, order: List[str]) -> "Version":
107 """Increase the value of the given part."""
108 bumped = False
110 new_values = {}
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()
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}")
126 return Version(new_values)
129class VersionConfig:
130 """
131 Hold a complete representation of a version string.
132 """
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
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
153 def __repr__(self) -> str:
154 return f"<bumpversion.VersionConfig:{self.parse_regex.pattern}:{self.serialize_formats}>"
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 )
165 @property
166 def order(self) -> List[str]:
167 """
168 Return the order of the labels in a serialization format.
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.
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])
178 def parse(self, version_string: Optional[str] = None) -> Optional[Version]:
179 """
180 Parse a version string into a Version object.
182 Args:
183 version_string: Version string to parse
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
191 regexp_one_line = "".join([line.split("#")[0].strip() for line in self.parse_regex.pattern.splitlines()])
193 logger.info(
194 "Parsing version '%s' using regexp '%s'",
195 version_string,
196 regexp_one_line,
197 )
198 logger.indent()
200 match = self.parse_regex.search(version_string)
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
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)
217 logger.info("Parsed the following values: %s", key_val_string(v.values))
218 logger.dedent()
220 return v
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.
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
234 Raises:
235 FormattingError: if not serializable
236 MissingValueError: if not all parts required in the format have values
238 Returns:
239 The serialized version as a string
240 """
241 values = copy(context)
242 for k in version:
243 values[k] = version[k]
245 # TODO dump complete context on debug level
247 try:
248 # test whether all parts required in the format have values
249 serialized = serialize_format.format(**values)
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
257 keys_needing_representation = set()
259 keys = list(self.order)
260 for i, k in enumerate(keys):
261 v = values[k]
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
268 if not v.is_optional:
269 keys_needing_representation = set(keys[: i + 1])
271 required_by_format = set(labels_for_format(serialize_format))
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 )
280 return serialized
282 def _choose_serialize_format(self, version: Version, context: MutableMapping) -> str:
283 chosen = None
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
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)
311 return chosen
313 def serialize(self, version: Version, context: MutableMapping) -> str:
314 """
315 Serialize a version to a string.
317 Args:
318 version: The version to serialize
319 context: The context to use when serializing the version
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