Coverage for /Users/OORDCOR/Documents/code/bump-my-version/bumpversion/config/models.py: 78%

105 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-18 15:08 -0600

1"""Bump My Version configuration models.""" 

2from __future__ import annotations 

3 

4import re 

5from collections import defaultdict 

6from itertools import chain 

7from typing import TYPE_CHECKING, Dict, List, MutableMapping, Optional, Tuple, Union 

8 

9from pydantic import BaseModel, Field 

10from pydantic_settings import BaseSettings, SettingsConfigDict 

11 

12from bumpversion.ui import get_indented_logger 

13 

14if TYPE_CHECKING: 14 ↛ 15line 14 didn't jump to line 15, because the condition on line 14 was never true

15 from bumpversion.scm import SCMInfo 

16 from bumpversion.version_part import VersionConfig 

17 

18logger = get_indented_logger(__name__) 

19 

20 

21class VersionPartConfig(BaseModel): 

22 """Configuration of a part of the version.""" 

23 

24 values: Optional[list] = None # Optional. Numeric is used if missing or no items in list 

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

26 # Defaults to first value. 0 in the case of numeric. Empty string means nothing is optional. 

27 first_value: Union[str, int, None] = None # Optional. Defaults to first value in values 

28 independent: bool = False 

29 

30 

31class FileChange(BaseModel): 

32 """A change to make to a file.""" 

33 

34 parse: str 

35 serialize: tuple 

36 search: str 

37 replace: str 

38 regex: bool 

39 ignore_missing_version: bool 

40 filename: Optional[str] = None 

41 glob: Optional[str] = None # Conflicts with filename. If both are specified, glob wins 

42 key_path: Optional[str] = None # If specified, and has an appropriate extension, will be treated as a data file 

43 

44 def __hash__(self): 

45 """Return a hash of the model.""" 

46 return hash(tuple(sorted(self.model_dump().items()))) 

47 

48 def get_search_pattern(self, context: MutableMapping) -> Tuple[re.Pattern, str]: 

49 """ 

50 Render the search pattern and return the compiled regex pattern and the raw pattern. 

51 

52 Args: 

53 context: The context to use for rendering the search pattern 

54 

55 Returns: 

56 A tuple of the compiled regex pattern and the raw pattern as a string. 

57 """ 

58 logger.debug("Rendering search pattern with context") 

59 logger.indent() 

60 # the default search pattern is escaped, so we can still use it in a regex 

61 raw_pattern = self.search.format(**context) 

62 default = re.compile(re.escape(raw_pattern), re.MULTILINE | re.DOTALL) 

63 if not self.regex: 63 ↛ 68line 63 didn't jump to line 68, because the condition on line 63 was never false

64 logger.debug("No RegEx flag detected. Searching for the default pattern: '%s'", default.pattern) 

65 logger.dedent() 

66 return default, raw_pattern 

67 

68 re_context = {key: re.escape(str(value)) for key, value in context.items()} 

69 regex_pattern = self.search.format(**re_context) 

70 try: 

71 search_for_re = re.compile(regex_pattern, re.MULTILINE | re.DOTALL) 

72 logger.debug("Searching for the regex: '%s'", search_for_re.pattern) 

73 logger.dedent() 

74 return search_for_re, raw_pattern 

75 except re.error as e: 

76 logger.error("Invalid regex '%s': %s.", default, e) 

77 

78 logger.debug("Invalid regex. Searching for the default pattern: '%s'", raw_pattern) 

79 logger.dedent() 

80 

81 return default, raw_pattern 

82 

83 

84class Config(BaseSettings): 

85 """Bump Version configuration.""" 

86 

87 current_version: Optional[str] 

88 parse: str 

89 serialize: tuple = Field(min_length=1) 

90 search: str 

91 replace: str 

92 regex: bool 

93 ignore_missing_version: bool 

94 tag: bool 

95 sign_tags: bool 

96 tag_name: str 

97 tag_message: Optional[str] 

98 allow_dirty: bool 

99 commit: bool 

100 message: str 

101 commit_args: Optional[str] 

102 scm_info: Optional["SCMInfo"] 

103 parts: Dict[str, VersionPartConfig] 

104 files: List[FileChange] = Field(default_factory=list) 

105 included_paths: List[str] = Field(default_factory=list) 

106 excluded_paths: List[str] = Field(default_factory=list) 

107 model_config = SettingsConfigDict(env_prefix="bumpversion_") 

108 _resolved_filemap: Optional[Dict[str, List[FileChange]]] = None 

109 

110 def add_files(self, filename: Union[str, List[str]]) -> None: 

111 """Add a filename to the list of files.""" 

112 filenames = [filename] if isinstance(filename, str) else filename 

113 files = set(self.files) 

114 for name in filenames: 

115 files.add( 

116 FileChange( 

117 filename=name, 

118 glob=None, 

119 key_path=None, 

120 parse=self.parse, 

121 serialize=self.serialize, 

122 search=self.search, 

123 replace=self.replace, 

124 regex=self.regex, 

125 ignore_missing_version=self.ignore_missing_version, 

126 ) 

127 ) 

128 self.files = list(files) 

129 

130 self._resolved_filemap = None 

131 

132 @property 

133 def resolved_filemap(self) -> Dict[str, List[FileChange]]: 

134 """Return the cached resolved filemap.""" 

135 if self._resolved_filemap is None: 

136 self._resolved_filemap = self._resolve_filemap() 

137 return self._resolved_filemap 

138 

139 def _resolve_filemap(self) -> Dict[str, List[FileChange]]: 

140 """Return a map of filenames to file configs, expanding any globs.""" 

141 from bumpversion.config.utils import resolve_glob_files 

142 

143 output = defaultdict(list) 

144 new_files = [] 

145 for file_cfg in self.files: 

146 if file_cfg.glob: 146 ↛ 147line 146 didn't jump to line 147, because the condition on line 146 was never true

147 new_files.extend(resolve_glob_files(file_cfg)) 

148 else: 

149 new_files.append(file_cfg) 

150 

151 for file_cfg in new_files: 

152 output[file_cfg.filename].append(file_cfg) 

153 return output 

154 

155 @property 

156 def files_to_modify(self) -> List[FileChange]: 

157 """Return a list of files to modify.""" 

158 files_not_excluded = [filename for filename in self.resolved_filemap if filename not in self.excluded_paths] 

159 inclusion_set = set(self.included_paths) | set(files_not_excluded) 

160 return list( 

161 chain.from_iterable( 

162 file_cfg_list for key, file_cfg_list in self.resolved_filemap.items() if key in inclusion_set 

163 ) 

164 ) 

165 

166 @property 

167 def version_config(self) -> "VersionConfig": 

168 """Return the version configuration.""" 

169 from bumpversion.version_part import VersionConfig 

170 

171 return VersionConfig(self.parse, self.serialize, self.search, self.replace, self.parts)