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

1"""Models for managing versioning of software projects.""" 

2 

3from __future__ import annotations 

4 

5from collections import defaultdict, deque 

6from itertools import chain 

7from typing import Any, Dict, List, Optional, Tuple, Union 

8 

9from pydantic import BaseModel, model_validator 

10 

11from bumpversion.exceptions import InvalidVersionPartError 

12from bumpversion.utils import key_val_string 

13from bumpversion.versioning.functions import CalVerFunction, NumericFunction, PartFunction, ValuesFunction 

14 

15 

16class VersionComponent: 

17 """ 

18 Represent part of a version number. 

19 

20 Determines the PartFunction that rules how the part behaves when increased or reset 

21 based on the configuration given. 

22 """ 

23 

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") 

51 

52 @property 

53 def value(self) -> str: 

54 """Return the value of the part.""" 

55 return self._value or self.func.optional_value 

56 

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 ) 

69 

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 

75 

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 

81 

82 @property 

83 def is_optional(self) -> bool: 

84 """Is the part optional?""" 

85 return self.value == self.func.optional_value 

86 

87 @property 

88 def is_independent(self) -> bool: 

89 """Is the part independent of the other parts?""" 

90 return self.independent 

91 

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) 

99 

100 def __repr__(self) -> str: 

101 return f"<bumpversion.VersionPart:{self.func.__class__.__name__}:{self.value}>" 

102 

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

104 return self.value == other.value if isinstance(other, VersionComponent) else False 

105 

106 

107class VersionComponentSpec(BaseModel): 

108 """ 

109 Configuration of a version component. 

110 

111 This is used to read in the configuration from the bumpversion config file. 

112 """ 

113 

114 values: Optional[list] = None 

115 """The possible values for the component. If it and `calver_format` is None, the component is numeric.""" 

116 

117 optional_value: Optional[str] = None # Optional. 

118 """The value that is optional to include in the version. 

119 

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.""" 

123 

124 first_value: Union[str, int, None] = None 

125 """The first value to increment from.""" 

126 

127 independent: bool = False 

128 """Is the component independent of the other components?""" 

129 

130 always_increment: bool = False 

131 """Should the component always increment, even if it is not necessary?""" 

132 

133 calver_format: Optional[str] = None 

134 """The format string for a CalVer component.""" 

135 

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.""" 

139 

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 

147 

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 ) 

160 

161 

162class VersionSpec: 

163 """The specification of a version's components and their relationships.""" 

164 

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.") 

172 

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 

186 

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) 

194 

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 = [] 

199 

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]) 

205 

206 return visited 

207 

208 

209class Version: 

210 """The specification of a version and its parts.""" 

211 

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 

218 

219 def values(self) -> Dict[str, VersionComponent]: 

220 """Return the values of the parts.""" 

221 return dict(self.components.items()) 

222 

223 def __getitem__(self, key: str) -> VersionComponent: 

224 return self.components[key] 

225 

226 def __len__(self) -> int: 

227 return len(self.components) 

228 

229 def __iter__(self): 

230 return iter(self.components) 

231 

232 def __repr__(self): 

233 return f"<bumpversion.Version:{key_val_string(self.components)}>" 

234 

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 ) 

241 

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] 

245 

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}") 

250 

251 new_values = dict(self.components.items()) 

252 always_incr_values, components_to_reset = self._always_increment() 

253 new_values.update(always_incr_values) 

254 

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)) 

258 

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() 

262 

263 return Version(self.version_spec, new_values, self.original) 

264 

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} 

268 

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} 

273 

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