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

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

2

3 from __future__ import annotations

4

5 import re

6 from collections import defaultdict

7 from itertools import chain

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

9

10 from pydantic import BaseModel, Field

11 from pydantic_settings import BaseSettings, SettingsConfigDict

12

13 from bumpversion.ui import get_indented_logger

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

15

16 if 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

21 logger = get_indented_logger(__name__)

22

23

24 class 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

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