Coverage for /Users/coordt/Documents/code/bump-my-version/bumpversion/versioning/models.py: 84%
154 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-06-12 09:26 -0500
« prev ^ index » next coverage.py v7.4.4, created at 2024-06-12 09:26 -0500
1"""Models for managing versioning of software projects."""
3from __future__ import annotations
5from collections import defaultdict, deque
6from itertools import chain
7from typing import Any, Dict, List, Optional, Tuple, Union
9from pydantic import BaseModel, model_validator
11from bumpversion.exceptions import InvalidVersionPartError
12from bumpversion.utils import key_val_string
13from bumpversion.versioning.functions import CalVerFunction, NumericFunction, PartFunction, ValuesFunction
16class VersionComponent:
17 """
18 Represent part of a version number.
20 Determines the PartFunction that rules how the part behaves when increased or reset
21 based on the configuration given.
22 """
24 def __init__(
25 self,
26 values: Optional[list] = None,
27 optional_value: Optional[str] = None,
28 first_value: Union[str, int, None] = None,
29 independent: bool = False,
30 always_increment: bool = False,
31 calver_format: Optional[str] = None,
32 source: Optional[str] = None,
33 value: Union[str, int, None] = None,
34 ):
35 self._value = str(value) if value is not None else None
36 self.func: Optional[PartFunction] = None
37 self.always_increment = always_increment
38 self.independent = True if always_increment else independent
39 self.source = source
40 self.calver_format = calver_format
41 if values: 41 ↛ 42line 41 didn't jump to line 42, because the condition on line 41 was never true
42 str_values = [str(v) for v in values]
43 str_optional_value = str(optional_value) if optional_value is not None else None
44 str_first_value = str(first_value) if first_value is not None else None
45 self.func = ValuesFunction(str_values, str_optional_value, str_first_value)
46 elif calver_format: 46 ↛ 47line 46 didn't jump to line 47, because the condition on line 46 was never true
47 self.func = CalVerFunction(calver_format)
48 self._value = self._value or self.func.first_value
49 else:
50 self.func = NumericFunction(optional_value, first_value or "0")
52 @property
53 def value(self) -> str:
54 """Return the value of the part."""
55 return self._value or self.func.optional_value
57 def copy(self) -> "VersionComponent":
58 """Return a copy of the part."""
59 return VersionComponent(
60 values=getattr(self.func, "_values", None),
61 optional_value=self.func.optional_value,
62 first_value=self.func.first_value,
63 independent=self.independent,
64 always_increment=self.always_increment,
65 calver_format=self.calver_format,
66 source=self.source,
67 value=self._value,
68 )
70 def bump(self) -> "VersionComponent":
71 """Return a part with bumped value."""
72 new_component = self.copy()
73 new_component._value = self.func.bump(self.value)
74 return new_component
76 def null(self) -> "VersionComponent":
77 """Return a part with first value."""
78 new_component = self.copy()
79 new_component._value = self.func.first_value
80 return new_component
82 @property
83 def is_optional(self) -> bool:
84 """Is the part optional?"""
85 return self.value == self.func.optional_value
87 @property
88 def is_independent(self) -> bool:
89 """Is the part independent of the other parts?"""
90 return self.independent
92 def __format__(self, format_spec: str) -> str:
93 try:
94 val = int(self.value)
95 except ValueError:
96 return self.value
97 else:
98 return int.__format__(val, format_spec)
100 def __repr__(self) -> str:
101 return f"<bumpversion.VersionPart:{self.func.__class__.__name__}:{self.value}>"
103 def __eq__(self, other: Any) -> bool:
104 return self.value == other.value if isinstance(other, VersionComponent) else False
107class VersionComponentSpec(BaseModel):
108 """
109 Configuration of a version component.
111 This is used to read in the configuration from the bumpversion config file.
112 """
114 values: Optional[list] = None
115 """The possible values for the component. If it and `calver_format` is None, the component is numeric."""
117 optional_value: Optional[str] = None # Optional.
118 """The value that is optional to include in the version.
120 - Defaults to first value in values or 0 in the case of numeric.
121 - Empty string means nothing is optional.
122 - CalVer components ignore this."""
124 first_value: Union[str, int, None] = None
125 """The first value to increment from."""
127 independent: bool = False
128 """Is the component independent of the other components?"""
130 always_increment: bool = False
131 """Should the component always increment, even if it is not necessary?"""
133 calver_format: Optional[str] = None
134 """The format string for a CalVer component."""
136 # source: Optional[str] = None # Name of environment variable or context variable to use as the source for value
137 depends_on: Optional[str] = None
138 """The name of the component this component depends on."""
140 @model_validator(mode="before")
141 @classmethod
142 def set_always_increment_with_calver(cls, data: Any) -> Any:
143 """Set always_increment to True if calver_format is present."""
144 if isinstance(data, dict) and data.get("calver_format"): 144 ↛ 145line 144 didn't jump to line 145, because the condition on line 144 was never true
145 data["always_increment"] = True
146 return data
148 def create_component(self, value: Union[str, int, None] = None) -> VersionComponent:
149 """Generate a version component from the configuration."""
150 return VersionComponent(
151 values=self.values,
152 optional_value=self.optional_value,
153 first_value=self.first_value,
154 independent=self.independent,
155 always_increment=self.always_increment,
156 calver_format=self.calver_format,
157 # source=self.source,
158 value=value,
159 )
162class VersionSpec:
163 """The specification of a version's components and their relationships."""
165 def __init__(self, components: Dict[str, VersionComponentSpec], order: Optional[List[str]] = None):
166 if not components: 166 ↛ 167line 166 didn't jump to line 167, because the condition on line 166 was never true
167 raise ValueError("A VersionSpec must have at least one component.")
168 if not order: 168 ↛ 170line 168 didn't jump to line 170, because the condition on line 168 was never false
169 order = list(components.keys())
170 if len(set(order) - set(components.keys())) > 0: 170 ↛ 171line 170 didn't jump to line 171, because the condition on line 170 was never true
171 raise ValueError("The order of components refers to items that are not in your components.")
173 self.component_configs = components
174 self.order = order
175 self.dependency_map = defaultdict(list)
176 previous_component = self.order[0]
177 self.always_increment = [name for name, config in self.component_configs.items() if config.always_increment]
178 for component in self.order[1:]:
179 if self.component_configs[component].independent: 179 ↛ 180line 179 didn't jump to line 180, because the condition on line 179 was never true
180 continue
181 elif self.component_configs[component].depends_on: 181 ↛ 182line 181 didn't jump to line 182, because the condition on line 181 was never true
182 self.dependency_map[self.component_configs[component].depends_on].append(component)
183 else:
184 self.dependency_map[previous_component].append(component)
185 previous_component = component
187 def create_version(self, values: Dict[str, str]) -> "Version":
188 """Generate a version from the given values."""
189 components = {
190 key: comp_config.create_component(value=values.get(key))
191 for key, comp_config in self.component_configs.items()
192 }
193 return Version(version_spec=self, components=components)
195 def get_dependents(self, component_name: str) -> List[str]:
196 """Return the parts that depend on the given part."""
197 stack = deque(self.dependency_map.get(component_name, []), maxlen=len(self.order))
198 visited = []
200 while stack:
201 e = stack.pop()
202 if e not in visited: 202 ↛ 200line 202 didn't jump to line 200, because the condition on line 202 was never false
203 visited.append(e)
204 stack.extendleft(self.dependency_map[e])
206 return visited
209class Version:
210 """The specification of a version and its parts."""
212 def __init__(
213 self, version_spec: VersionSpec, components: Dict[str, VersionComponent], original: Optional[str] = None
214 ):
215 self.version_spec = version_spec
216 self.components = components
217 self.original = original
219 def values(self) -> Dict[str, VersionComponent]:
220 """Return the values of the parts."""
221 return dict(self.components.items())
223 def __getitem__(self, key: str) -> VersionComponent:
224 return self.components[key]
226 def __len__(self) -> int:
227 return len(self.components)
229 def __iter__(self):
230 return iter(self.components)
232 def __repr__(self):
233 return f"<bumpversion.Version:{key_val_string(self.components)}>"
235 def __eq__(self, other: Any) -> bool:
236 return (
237 all(value == other.components[key] for key, value in self.components.items())
238 if isinstance(other, Version)
239 else False
240 )
242 def required_components(self) -> List[str]:
243 """Return the names of the parts that are required."""
244 return [key for key, value in self.components.items() if value.value != value.func.optional_value]
246 def bump(self, component_name: str) -> "Version":
247 """Increase the value of the specified component, reset its dependents, and return a new Version."""
248 if component_name not in self.components: 248 ↛ 249line 248 didn't jump to line 249, because the condition on line 248 was never true
249 raise InvalidVersionPartError(f"No part named {component_name!r}")
251 new_values = dict(self.components.items())
252 always_incr_values, components_to_reset = self._always_increment()
253 new_values.update(always_incr_values)
255 if component_name not in components_to_reset: 255 ↛ 259line 255 didn't jump to line 259, because the condition on line 255 was never false
256 new_values[component_name] = self.components[component_name].bump()
257 components_to_reset |= set(self.version_spec.get_dependents(component_name))
259 for component in components_to_reset:
260 if not self.components[component].is_independent: 260 ↛ 259line 260 didn't jump to line 259, because the condition on line 260 was never false
261 new_values[component] = self.components[component].null()
263 return Version(self.version_spec, new_values, self.original)
265 def _always_incr_dependencies(self) -> dict:
266 """Return the components that always increment and depend on the given component."""
267 return {name: self.version_spec.get_dependents(name) for name in self.version_spec.always_increment}
269 def _increment_always_incr(self) -> dict:
270 """Increase the values of the components that always increment."""
271 components = self.version_spec.always_increment
272 return {name: self.components[name].bump() for name in components}
274 def _always_increment(self) -> Tuple[dict, set]:
275 """Return the components that always increment and their dependents."""
276 values = self._increment_always_incr()
277 dependents = self._always_incr_dependencies()
278 for component_name, value in values.items(): 278 ↛ 279line 278 didn't jump to line 279, because the loop on line 278 never started
279 if value == self.components[component_name]:
280 dependents.pop(component_name, None)
281 unique_dependents = set(chain.from_iterable(dependents.values()))
282 return values, unique_dependents