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
« 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
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:
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:
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:
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: 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
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 @property
154 def order(self) -> List[str]:
155 """
156 Return the order of the labels in a serialization format.
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.
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])
166 def parse(self, version_string: Optional[str] = None) -> Optional[Version]:
167 """
168 Parse a version string into a Version object.
170 Args:
171 version_string: Version string to parse
173 Returns:
174 A Version object representing the string.
175 """
176 if not version_string:
177 return None
179 regexp_one_line = "".join([line.split("#")[0].strip() for line in self.parse_regex.pattern.splitlines()])
181 logger.info(
182 "Parsing version '%s' using regexp '%s'",
183 version_string,
184 regexp_one_line,
185 )
186 logger.indent()
188 match = self.parse_regex.search(version_string)
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
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)
205 logger.info("Parsed the following values: %s", key_val_string(v.values))
206 logger.dedent()
208 return v
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.
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
222 Raises:
223 FormattingError: if not serializable
224 MissingValueError: if not all parts required in the format have values
226 Returns:
227 The serialized version as a string
228 """
229 values = copy(context)
230 for k in version:
231 values[k] = version[k]
233 # TODO dump complete context on debug level
235 try:
236 # test whether all parts required in the format have values
237 serialized = serialize_format.format(**values)
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
245 keys_needing_representation = set()
247 keys = list(self.order)
248 for i, k in enumerate(keys):
249 v = values[k]
251 if not isinstance(v, VersionPart):
252 # values coming from environment variables don't need
253 # representation
254 continue
256 if not v.is_optional:
257 keys_needing_representation = set(keys[: i + 1])
259 required_by_format = set(labels_for_format(serialize_format))
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 )
268 return serialized
270 def _choose_serialize_format(self, version: Version, context: MutableMapping) -> str:
271 chosen = None
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
294 if not chosen:
295 raise KeyError("Did not find suitable serialization format")
296 logger.dedent()
297 logger.debug("Selected serialization format '%s'", chosen)
299 return chosen
301 def serialize(self, version: Version, context: MutableMapping) -> str:
302 """
303 Serialize a version to a string.
305 Args:
306 version: The version to serialize
307 context: The context to use when serializing the version
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