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

107 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-02-24 07:45 -0600

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 key_path: Optional[str] = None # If specified, and has an appropriate extension, will be treated as a data file 

37 

38 def __hash__(self): 

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

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

41 

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

43 """ 

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

45 

46 Args: 

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

48 

49 Returns: 

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

51 """ 

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

53 logger.indent() 

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

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

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

57 if not self.regex: 

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

59 logger.dedent() 

60 return default, raw_pattern 

61 

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

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

64 try: 

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

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

67 logger.dedent() 

68 return search_for_re, raw_pattern 

69 except re.error as e: 

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

71 

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

73 logger.dedent() 

74 

75 return default, raw_pattern 

76 

77 

78class Config(BaseSettings): 

79 """Bump Version configuration.""" 

80 

81 current_version: Optional[str] 

82 parse: str 

83 serialize: tuple = Field(min_length=1) 

84 search: str 

85 replace: str 

86 regex: bool 

87 ignore_missing_version: bool 

88 ignore_missing_files: bool 

89 tag: bool 

90 sign_tags: bool 

91 tag_name: str 

92 tag_message: Optional[str] 

93 allow_dirty: bool 

94 commit: bool 

95 message: str 

96 commit_args: Optional[str] 

97 scm_info: Optional["SCMInfo"] 

98 parts: Dict[str, VersionComponentSpec] 

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

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

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

102 model_config = SettingsConfigDict(env_prefix="bumpversion_") 

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

104 

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

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

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

108 files = set(self.files) 

109 for name in filenames: 

110 files.add( 

111 FileChange( 

112 filename=name, 

113 glob=None, 

114 key_path=None, 

115 parse=self.parse, 

116 serialize=self.serialize, 

117 search=self.search, 

118 replace=self.replace, 

119 regex=self.regex, 

120 ignore_missing_version=self.ignore_missing_version, 

121 ignore_missing_file=self.ignore_missing_files, 

122 ) 

123 ) 

124 self.files = list(files) 

125 

126 self._resolved_filemap = None 

127 

128 @property 

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

130 """Return the cached resolved filemap.""" 

131 if self._resolved_filemap is None: 

132 self._resolved_filemap = self._resolve_filemap() 

133 return self._resolved_filemap 

134 

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

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

137 from bumpversion.config.utils import resolve_glob_files 

138 

139 output = defaultdict(list) 

140 new_files = [] 

141 for file_cfg in self.files: 

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

143 new_files.extend(resolve_glob_files(file_cfg)) 

144 else: 

145 new_files.append(file_cfg) 

146 

147 for file_cfg in new_files: 

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

149 return output 

150 

151 @property 

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

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

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

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

156 return list( 

157 chain.from_iterable( 

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

159 ) 

160 ) 

161 

162 @property 

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

164 """Return the version configuration.""" 

165 from bumpversion.version_part import VersionConfig 

166 

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

168 

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

170 """Return the version specification.""" 

171 from bumpversion.versioning.models import VersionSpec 

172 

173 return VersionSpec(self.parts)