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

110 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-06-12 09:26 -0500

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

2 

3from __future__ import annotations 

4 

5import re 

6from collections import defaultdict 

7from itertools import chain 

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

9 

10from pydantic import BaseModel, Field 

11from pydantic_settings import BaseSettings, SettingsConfigDict 

12 

13from bumpversion.ui import get_indented_logger 

14from bumpversion.versioning.models import VersionComponentSpec # NOQA: TCH001 

15 

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

17 from bumpversion.scm import SCMInfo 

18 from bumpversion.version_part import VersionConfig 

19 from bumpversion.versioning.models import VersionSpec 

20 

21logger = get_indented_logger(__name__) 

22 

23 

24class FileChange(BaseModel): 

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

26 

27 parse: str 

28 serialize: tuple 

29 search: str 

30 replace: str 

31 regex: bool 

32 ignore_missing_version: bool 

33 ignore_missing_file: bool 

34 filename: Optional[str] = None 

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

36 glob_exclude: Optional[tuple] = None 

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

38 include_bumps: Optional[tuple] = None 

39 exclude_bumps: Optional[tuple] = None 

40 

41 def __hash__(self): 

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

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

44 

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

46 """ 

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

48 

49 Args: 

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

51 

52 Returns: 

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

54 """ 

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

56 logger.indent() 

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

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

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

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

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

62 logger.dedent() 

63 return default, raw_pattern 

64 

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

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

67 try: 

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

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

70 logger.dedent() 

71 return search_for_re, raw_pattern 

72 except re.error as e: 

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

74 

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

76 logger.dedent() 

77 

78 return default, raw_pattern 

79 

80 

81class Config(BaseSettings): 

82 """Bump Version configuration.""" 

83 

84 current_version: Optional[str] 

85 parse: str 

86 serialize: tuple = Field(min_length=1) 

87 search: str 

88 replace: str 

89 regex: bool 

90 ignore_missing_version: bool 

91 ignore_missing_files: bool 

92 tag: bool 

93 sign_tags: bool 

94 tag_name: str 

95 tag_message: Optional[str] 

96 allow_dirty: bool 

97 commit: bool 

98 message: str 

99 commit_args: Optional[str] 

100 scm_info: Optional["SCMInfo"] 

101 parts: Dict[str, VersionComponentSpec] 

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

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

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

105 model_config = SettingsConfigDict(env_prefix="bumpversion_") 

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

107 

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

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

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

111 files = set(self.files) 

112 for name in filenames: 

113 files.add( 

114 FileChange( 

115 filename=name, 

116 glob=None, 

117 key_path=None, 

118 parse=self.parse, 

119 serialize=self.serialize, 

120 search=self.search, 

121 replace=self.replace, 

122 regex=self.regex, 

123 ignore_missing_version=self.ignore_missing_version, 

124 ignore_missing_file=self.ignore_missing_files, 

125 include_bumps=tuple(self.parts.keys()), 

126 exclude_bumps=(), 

127 ) 

128 ) 

129 self.files = list(files) 

130 

131 self._resolved_filemap = None 

132 

133 @property 

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

135 """Return the cached resolved filemap.""" 

136 if self._resolved_filemap is None: 

137 self._resolved_filemap = self._resolve_filemap() 

138 return self._resolved_filemap 

139 

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

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

142 from bumpversion.config.utils import resolve_glob_files 

143 

144 output = defaultdict(list) 

145 new_files = [] 

146 for file_cfg in self.files: 

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

148 new_files.extend(resolve_glob_files(file_cfg)) 

149 else: 

150 new_files.append(file_cfg) 

151 

152 for file_cfg in new_files: 

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

154 return output 

155 

156 @property 

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

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

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

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

161 return list( 

162 chain.from_iterable( 

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

164 ) 

165 ) 

166 

167 @property 

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

169 """Return the version configuration.""" 

170 from bumpversion.version_part import VersionConfig 

171 

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

173 

174 def version_spec(self, version: Optional[str] = None) -> "VersionSpec": 

175 """Return the version specification.""" 

176 from bumpversion.versioning.models import VersionSpec 

177 

178 return VersionSpec(self.parts)