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
« prev ^ index » next coverage.py v7.3.2, created at 2024-02-24 07:45 -0600
1"""Bump My Version configuration models."""
3from __future__ import annotations
5import re
6from collections import defaultdict
7from itertools import chain
8from typing import TYPE_CHECKING, Dict, List, MutableMapping, Optional, Tuple, Union
10from pydantic import BaseModel, Field
11from pydantic_settings import BaseSettings, SettingsConfigDict
13from bumpversion.ui import get_indented_logger
14from bumpversion.versioning.models import VersionComponentSpec # NOQA: TCH001
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
21logger = get_indented_logger(__name__)
24class FileChange(BaseModel):
25 """A change to make to a file."""
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
38 def __hash__(self):
39 """Return a hash of the model."""
40 return hash(tuple(sorted(self.model_dump().items())))
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.
46 Args:
47 context: The context to use for rendering the search pattern
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
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)
72 logger.debug("Invalid regex. Searching for the default pattern: '%s'", raw_pattern)
73 logger.dedent()
75 return default, raw_pattern
78class Config(BaseSettings):
79 """Bump Version configuration."""
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
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)
126 self._resolved_filemap = None
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
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
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)
147 for file_cfg in new_files:
148 output[file_cfg.filename].append(file_cfg)
149 return output
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 )
162 @property
163 def version_config(self) -> "VersionConfig":
164 """Return the version configuration."""
165 from bumpversion.version_part import VersionConfig
167 return VersionConfig(self.parse, self.serialize, self.search, self.replace, self.parts)
169 def version_spec(self, version: Optional[str] = None) -> "VersionSpec":
170 """Return the version specification."""
171 from bumpversion.versioning.models import VersionSpec
173 return VersionSpec(self.parts)