diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 755c9190..1a44249f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: hooks: - id: mypy args: [--no-strict-optional, --ignore-missing-imports] - additional_dependencies: ["pydantic<2.0", "toml", "types-all"] + additional_dependencies: ["pydantic>2.0", "toml", "types-all"] - repo: https://github.com/jsh9/pydoclint rev: 0.3.8 hooks: @@ -59,3 +59,5 @@ repos: rev: 0.27.1 hooks: - id: check-azure-pipelines +ci: + autofix_prs: false diff --git a/bumpversion/bump.py b/bumpversion/bump.py index 7ad24af3..595be314 100644 --- a/bumpversion/bump.py +++ b/bumpversion/bump.py @@ -8,7 +8,8 @@ from bumpversion.files import ConfiguredFile from bumpversion.version_part import Version -from bumpversion.config import Config, update_config_file +from bumpversion.config import Config +from bumpversion.config.files import update_config_file, update_ini_config_file from bumpversion.exceptions import ConfigurationError from bumpversion.utils import get_context, key_val_string @@ -81,7 +82,10 @@ def do_bump( configured_files = resolve_file_config(config.files_to_modify, config.version_config) modify_files(configured_files, version, next_version, ctx, dry_run) - update_config_file(config_file, config.current_version, next_version_str, dry_run) + if config_file and config_file.suffix in {".cfg", ".ini"}: + update_ini_config_file(config_file, config.current_version, next_version_str, dry_run) + else: + update_config_file(config_file, config, version, next_version, ctx, dry_run) ctx = get_context(config, version, next_version) ctx["new_version"] = next_version_str diff --git a/bumpversion/cli.py b/bumpversion/cli.py index 7578a815..6a82c5b2 100644 --- a/bumpversion/cli.py +++ b/bumpversion/cli.py @@ -8,11 +8,11 @@ from bumpversion import __version__ from bumpversion.aliases import AliasedGroup from bumpversion.bump import do_bump -from bumpversion.config import find_config_file, get_configuration +from bumpversion.config import get_configuration +from bumpversion.config.files import find_config_file from bumpversion.files import ConfiguredFile, modify_files -from bumpversion.logging import setup_logging from bumpversion.show import do_show, log_list -from bumpversion.ui import print_warning +from bumpversion.ui import print_warning, setup_logging from bumpversion.utils import get_context, get_overrides logger = logging.getLogger(__name__) diff --git a/bumpversion/config.py b/bumpversion/config.py deleted file mode 100644 index a22e0960..00000000 --- a/bumpversion/config.py +++ /dev/null @@ -1,469 +0,0 @@ -"""Configuration management.""" -from __future__ import annotations - -import glob -import itertools -import logging -import re -from difflib import context_diff -from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union - -from bumpversion.ui import print_warning -from bumpversion.utils import labels_for_format - -if TYPE_CHECKING: # pragma: no-coverage - from bumpversion.scm import SCMInfo - from bumpversion.version_part import VersionConfig - -from pydantic import BaseModel, Field -from pydantic_settings import BaseSettings, SettingsConfigDict - -from bumpversion.exceptions import ConfigurationError - -logger = logging.getLogger(__name__) - - -class VersionPartConfig(BaseModel): - """Configuration of a part of the version.""" - - values: Optional[list] = None # Optional. Numeric is used if missing or no items in list - optional_value: Optional[str] = None # Optional. - # Defaults to first value. 0 in the case of numeric. Empty string means nothing is optional. - first_value: Union[str, int, None] = None # Optional. Defaults to first value in values - independent: bool = False - - -class FileConfig(BaseModel): - """Search and replace file config.""" - - filename: Optional[str] = None - glob: Optional[str] = None # Conflicts with filename. If both are specified, glob wins - parse: Optional[str] = None # If different from outer scope - serialize: Optional[List[str]] = None # If different from outer scope - search: Optional[str] = None # If different from outer scope - replace: Optional[str] = None # If different from outer scope - regex: Optional[bool] = None # If different from outer scope - ignore_missing_version: Optional[bool] = None - - -class Config(BaseSettings): - """Bump Version configuration.""" - - current_version: Optional[str] - parse: str - serialize: List[str] = Field(min_length=1) - search: str - replace: str - regex: bool - ignore_missing_version: bool - tag: bool - sign_tags: bool - tag_name: str - tag_message: Optional[str] - allow_dirty: bool - commit: bool - message: str - commit_args: Optional[str] - scm_info: Optional["SCMInfo"] - parts: Dict[str, VersionPartConfig] - files: List[FileConfig] - included_paths: List[str] = Field(default_factory=list) - excluded_paths: List[str] = Field(default_factory=list) - model_config = SettingsConfigDict(env_prefix="bumpversion_") - - def add_files(self, filename: Union[str, List[str]]) -> None: - """Add a filename to the list of files.""" - filenames = [filename] if isinstance(filename, str) else filename - for name in filenames: - if name in self.resolved_filemap: - continue - self.files.append( - FileConfig( - filename=name, - glob=None, - parse=self.parse, - serialize=self.serialize, - search=self.search, - replace=self.replace, - regex=self.regex, - ignore_missing_version=self.ignore_missing_version, - ) - ) - - @property - def resolved_filemap(self) -> Dict[str, FileConfig]: - """Return a map of filenames to file configs, expanding any globs.""" - new_files = [] - for file_cfg in self.files: - if file_cfg.glob: - new_files.extend(get_glob_files(file_cfg)) - else: - new_files.append(file_cfg) - - return {file_cfg.filename: file_cfg for file_cfg in new_files} - - @property - def files_to_modify(self) -> List[FileConfig]: - """Return a list of files to modify.""" - files_not_excluded = [ - file_cfg.filename - for file_cfg in self.resolved_filemap.values() - if file_cfg.filename not in self.excluded_paths - ] - inclusion_set = set(self.included_paths) | set(files_not_excluded) - return [file_cfg for file_cfg in self.resolved_filemap.values() if file_cfg.filename in inclusion_set] - - @property - def version_config(self) -> "VersionConfig": - """Return the version configuration.""" - from bumpversion.version_part import VersionConfig - - return VersionConfig(self.parse, self.serialize, self.search, self.replace, self.parts) - - -DEFAULTS = { - "current_version": None, - "parse": r"(?P\d+)\.(?P\d+)\.(?P\d+)", - "serialize": ["{major}.{minor}.{patch}"], - "search": "{current_version}", - "replace": "{new_version}", - "regex": False, - "ignore_missing_version": False, - "tag": False, - "sign_tags": False, - "tag_name": "v{new_version}", - "tag_message": "Bump version: {current_version} → {new_version}", - "allow_dirty": False, - "commit": False, - "message": "Bump version: {current_version} → {new_version}", - "commit_args": None, - "scm_info": None, - "parts": {}, - "files": [], -} - -CONFIG_FILE_SEARCH_ORDER = ( - Path(".bumpversion.cfg"), - Path(".bumpversion.toml"), - Path("setup.cfg"), - Path("pyproject.toml"), -) - - -def get_all_file_configs(config_dict: dict) -> List[FileConfig]: - """Make sure all version parts are included.""" - defaults = { - "parse": config_dict["parse"], - "serialize": config_dict["serialize"], - "search": config_dict["search"], - "replace": config_dict["replace"], - "ignore_missing_version": config_dict["ignore_missing_version"], - "regex": config_dict["regex"], - } - files = [{k: v for k, v in filecfg.items() if v is not None} for filecfg in config_dict["files"]] - for f in files: - f.update({k: v for k, v in defaults.items() if k not in f}) - return [FileConfig(**f) for f in files] - - -def get_configuration(config_file: Union[str, Path, None] = None, **overrides) -> Config: - """ - Return the configuration based on any configuration files and overrides. - - Args: - config_file: An explicit configuration file to use, otherwise search for one - **overrides: Specific configuration key-values to override in the configuration - - Returns: - The configuration - """ - from bumpversion.scm import SCMInfo, SourceCodeManager, get_scm_info # noqa: F401 - - config_dict = DEFAULTS.copy() - parsed_config = read_config_file(config_file) if config_file else {} - - # We want to strip out unrecognized key-values to avoid inadvertent issues - config_dict.update({key: val for key, val in parsed_config.items() if key in DEFAULTS.keys()}) - - allowed_overrides = set(DEFAULTS.keys()) - config_dict.update({key: val for key, val in overrides.items() if key in allowed_overrides}) - - # Set any missing version parts - config_dict["parts"] = get_all_part_configs(config_dict) - - # Set any missing file configuration - config_dict["files"] = get_all_file_configs(config_dict) - - # Resolve the SCMInfo class for Pydantic's BaseSettings - Config.model_rebuild() - config = Config(**config_dict) # type: ignore[arg-type] - - # Get the information about the SCM - scm_info = get_scm_info(config.tag_name, config.parse) - config.scm_info = scm_info - - # Update and verify the current_version - config.current_version = check_current_version(config) - - return config - - -def get_all_part_configs(config_dict: dict) -> Dict[str, VersionPartConfig]: - """Make sure all version parts are included.""" - serialize = config_dict["serialize"] - parts = config_dict["parts"] - all_labels = set(itertools.chain.from_iterable([labels_for_format(fmt) for fmt in serialize])) - return { - label: VersionPartConfig(**parts[label]) if label in parts else VersionPartConfig() # type: ignore[call-arg] - for label in all_labels - } - - -def check_current_version(config: Config) -> str: - """ - Returns the current version. - - If the current version is not specified in the config file, command line or env variable, - it attempts to retrieve it via a tag. - - Args: - config: The current configuration dictionary. - - Returns: - The version number - - Raises: - ConfigurationError: If it can't find the current version - """ - current_version = config.current_version - scm_info = config.scm_info - - if current_version is None and scm_info.current_version: - return scm_info.current_version - elif current_version and scm_info.current_version and current_version != scm_info.current_version: - logger.warning( - "Specified version (%s) does not match last tagged version (%s)", - current_version, - scm_info.current_version, - ) - return current_version - elif current_version: - return current_version - - raise ConfigurationError("Unable to determine the current version.") - - -def find_config_file(explicit_file: Union[str, Path, None] = None) -> Union[Path, None]: - """ - Find the configuration file, if it exists. - - If no explicit configuration file is passed, it will search in several files to - find its configuration. - - Args: - explicit_file: The configuration file to explicitly use. - - Returns: - The configuration file path - """ - search_paths = [Path(explicit_file)] if explicit_file else CONFIG_FILE_SEARCH_ORDER - return next( - (cfg_file for cfg_file in search_paths if cfg_file.exists() and "bumpversion]" in cfg_file.read_text()), - None, - ) - - -def read_config_file(config_file: Union[str, Path, None] = None) -> Dict[str, Any]: - """ - Read the configuration file, if it exists. - - If no explicit configuration file is passed, it will search in several files to - find its configuration. - - Args: - config_file: The configuration file to explicitly use. - - Returns: - A dictionary of read key-values - """ - if not config_file: - logger.info("No configuration file found.") - return {} - - logger.info("Reading config file %s:", config_file) - config_path = Path(config_file) - if config_path.suffix == ".cfg": - print_warning("The .cfg file format is deprecated. Please use .toml instead.") - return read_ini_file(config_path) - elif config_path.suffix == ".toml": - return read_toml_file(config_path) - return {} - - -def read_ini_file(file_path: Path) -> Dict[str, Any]: # noqa: C901 - """ - Parse an INI file and return a dictionary of sections and their options. - - Args: - file_path: The path to the INI file. - - Returns: - dict: A dictionary of sections and their options. - """ - import configparser - - from bumpversion import autocast - - # Create a ConfigParser object and read the INI file - config_parser = configparser.RawConfigParser() - if file_path.name == "setup.cfg": - config_parser = configparser.ConfigParser() - - config_parser.read(file_path) - - # Create an empty dictionary to hold the parsed sections and options - bumpversion_options: Dict[str, Any] = {"files": [], "parts": {}} - - # Loop through each section in the INI file - for section_name in config_parser.sections(): - if not section_name.startswith("bumpversion"): - continue - - section_parts = section_name.split(":") - num_parts = len(section_parts) - options = {key: autocast.autocast_value(val) for key, val in config_parser.items(section_name)} - - if num_parts == 1: # bumpversion section - bumpversion_options.update(options) - serialize = bumpversion_options.get("serialize", []) - if "message" in bumpversion_options and isinstance(bumpversion_options["message"], list): - bumpversion_options["message"] = ",".join(bumpversion_options["message"]) - if not isinstance(serialize, list): - bumpversion_options["serialize"] = [serialize] - elif num_parts > 1 and section_parts[1].startswith("file"): - file_options = { - "filename": section_parts[2], - } - file_options.update(options) - if "replace" in file_options and isinstance(file_options["replace"], list): - file_options["replace"] = "\n".join(file_options["replace"]) - bumpversion_options["files"].append(file_options) - elif num_parts > 1 and section_parts[1].startswith("glob"): - file_options = { - "glob": section_parts[2], - } - file_options.update(options) - if "replace" in file_options and isinstance(file_options["replace"], list): - file_options["replace"] = "\n".join(file_options["replace"]) - bumpversion_options["files"].append(file_options) - elif num_parts > 1 and section_parts[1].startswith("part"): - bumpversion_options["parts"][section_parts[2]] = options - - # Return the dictionary of sections and options - return bumpversion_options - - -def read_toml_file(file_path: Path) -> Dict[str, Any]: - """ - Parse a TOML file and return the `bumpversion` section. - - Args: - file_path: The path to the TOML file. - - Returns: - dict: A dictionary of the `bumpversion` section. - """ - import tomlkit - - # Load the TOML file - toml_data = tomlkit.parse(file_path.read_text()).unwrap() - - return toml_data.get("tool", {}).get("bumpversion", {}) - - -def update_config_file( - config_file: Union[str, Path, None], current_version: str, new_version: str, dry_run: bool = False -) -> None: - """ - Update the current_version key in the configuration file. - - If no explicit configuration file is passed, it will search in several files to - find its configuration. - - Instead of parsing and re-writing the config file with new information, it will use - a regular expression to just replace the current_version value. The idea is it will - avoid unintentional changes (like formatting) to the config file. - - Args: - config_file: The configuration file to explicitly use. - current_version: The serialized current version. - new_version: The serialized new version. - dry_run: True if the update should be a dry run. - """ - toml_current_version_regex = re.compile( - f'(?P\\[tool\\.bumpversion]\n[^[]*current_version\\s*=\\s*)(\\"{current_version}\\")', - re.MULTILINE, - ) - cfg_current_version_regex = re.compile( - f"(?P\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P{current_version})", - re.MULTILINE, - ) - - if not config_file: - logger.info("No configuration file found to update.") - return - - config_path = Path(config_file) - existing_config = config_path.read_text() - if config_path.suffix == ".cfg" and cfg_current_version_regex.search(existing_config): - sub_str = f"\\g{new_version}" - new_config = cfg_current_version_regex.sub(sub_str, existing_config) - elif config_path.suffix == ".toml" and toml_current_version_regex.search(existing_config): - sub_str = f'\\g"{new_version}"' - new_config = toml_current_version_regex.sub(sub_str, existing_config) - else: - logger.info("Could not find the current version in the config file: %s.", config_path) - return - - logger.info( - "%s to config file %s:", - "Would write" if dry_run else "Writing", - config_path, - ) - - logger.info( - "\n".join( - list( - context_diff( - existing_config.splitlines(), - new_config.splitlines(), - fromfile=f"before {config_path}", - tofile=f"after {config_path}", - lineterm="", - ) - ) - ) - ) - - if not dry_run: - config_path.write_text(new_config) - - -def get_glob_files(file_cfg: FileConfig) -> List[FileConfig]: - """ - Return a list of files that match the glob pattern. - - Args: - file_cfg: The file configuration containing the glob pattern - - Returns: - A list of resolved file configurations according to the pattern. - """ - files = [] - for filename_glob in glob.glob(file_cfg.glob, recursive=True): - new_file_cfg = file_cfg.copy() - new_file_cfg.filename = filename_glob - new_file_cfg.glob = None - files.append(new_file_cfg) - return files diff --git a/bumpversion/config/__init__.py b/bumpversion/config/__init__.py new file mode 100644 index 00000000..21cbda1d --- /dev/null +++ b/bumpversion/config/__init__.py @@ -0,0 +1,112 @@ +"""Configuration management.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Union + +from bumpversion.config.files import read_config_file +from bumpversion.config.models import Config +from bumpversion.exceptions import ConfigurationError + +if TYPE_CHECKING: # pragma: no-coverage + from pathlib import Path + +logger = logging.getLogger(__name__) + +DEFAULTS = { + "current_version": None, + "parse": r"(?P\d+)\.(?P\d+)\.(?P\d+)", + "serialize": ["{major}.{minor}.{patch}"], + "search": "{current_version}", + "replace": "{new_version}", + "regex": False, + "ignore_missing_version": False, + "tag": False, + "sign_tags": False, + "tag_name": "v{new_version}", + "tag_message": "Bump version: {current_version} → {new_version}", + "allow_dirty": False, + "commit": False, + "message": "Bump version: {current_version} → {new_version}", + "commit_args": None, + "scm_info": None, + "parts": {}, + "files": [], +} + + +def get_configuration(config_file: Union[str, Path, None] = None, **overrides) -> Config: + """ + Return the configuration based on any configuration files and overrides. + + Args: + config_file: An explicit configuration file to use, otherwise search for one + **overrides: Specific configuration key-values to override in the configuration + + Returns: + The configuration + """ + from bumpversion.config.utils import get_all_file_configs, get_all_part_configs + from bumpversion.scm import SCMInfo, SourceCodeManager, get_scm_info # noqa: F401 + + config_dict = DEFAULTS.copy() + parsed_config = read_config_file(config_file) if config_file else {} + + # We want to strip out unrecognized key-values to avoid inadvertent issues + config_dict.update({key: val for key, val in parsed_config.items() if key in DEFAULTS.keys()}) + + allowed_overrides = set(DEFAULTS.keys()) + config_dict.update({key: val for key, val in overrides.items() if key in allowed_overrides}) + + # Set any missing version parts + config_dict["parts"] = get_all_part_configs(config_dict) + + # Set any missing file configuration + config_dict["files"] = get_all_file_configs(config_dict) + + # Resolve the SCMInfo class for Pydantic's BaseSettings + Config.model_rebuild() + config = Config(**config_dict) # type: ignore[arg-type] + + # Get the information about the SCM + scm_info = get_scm_info(config.tag_name, config.parse) + config.scm_info = scm_info + + # Update and verify the current_version + config.current_version = check_current_version(config) + + return config + + +def check_current_version(config: Config) -> str: + """ + Returns the current version. + + If the current version is not specified in the config file, command line or env variable, + it attempts to retrieve it via a tag. + + Args: + config: The current configuration dictionary. + + Returns: + The version number + + Raises: + ConfigurationError: If it can't find the current version + """ + current_version = config.current_version + scm_info = config.scm_info + + if current_version is None and scm_info.current_version: + return scm_info.current_version + elif current_version and scm_info.current_version and current_version != scm_info.current_version: + logger.warning( + "Specified version (%s) does not match last tagged version (%s)", + current_version, + scm_info.current_version, + ) + return current_version + elif current_version: + return current_version + + raise ConfigurationError("Unable to determine the current version.") diff --git a/bumpversion/config/files.py b/bumpversion/config/files.py new file mode 100644 index 00000000..124ddb92 --- /dev/null +++ b/bumpversion/config/files.py @@ -0,0 +1,253 @@ +"""Contains methods for finding and reading configuration files.""" + +from __future__ import annotations + +import logging +import re +from difflib import context_diff +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, MutableMapping, Union + +from bumpversion.ui import print_warning + +if TYPE_CHECKING: # pragma: no-coverage + from bumpversion.config.models import Config + from bumpversion.version_part import Version + +logger = logging.getLogger(__name__) + +CONFIG_FILE_SEARCH_ORDER = ( + Path(".bumpversion.cfg"), + Path(".bumpversion.toml"), + Path("setup.cfg"), + Path("pyproject.toml"), +) + + +def find_config_file(explicit_file: Union[str, Path, None] = None) -> Union[Path, None]: + """ + Find the configuration file, if it exists. + + If no explicit configuration file is passed, it will search in several files to + find its configuration. + + Args: + explicit_file: The configuration file to explicitly use. + + Returns: + The configuration file path + """ + search_paths = [Path(explicit_file)] if explicit_file else CONFIG_FILE_SEARCH_ORDER + return next( + (cfg_file for cfg_file in search_paths if cfg_file.exists() and "bumpversion]" in cfg_file.read_text()), + None, + ) + + +def read_config_file(config_file: Union[str, Path, None] = None) -> Dict[str, Any]: + """ + Read the configuration file, if it exists. + + If no explicit configuration file is passed, it will search in several files to + find its configuration. + + Args: + config_file: The configuration file to explicitly use. + + Returns: + A dictionary of read key-values + """ + if not config_file: + logger.info("No configuration file found.") + return {} + + logger.info("Reading config file %s:", config_file) + config_path = Path(config_file) + if config_path.suffix == ".cfg": + print_warning("The .cfg file format is deprecated. Please use .toml instead.") + return read_ini_file(config_path) + elif config_path.suffix == ".toml": + return read_toml_file(config_path) + return {} + + +def read_ini_file(file_path: Path) -> Dict[str, Any]: # noqa: C901 + """ + Parse an INI file and return a dictionary of sections and their options. + + Args: + file_path: The path to the INI file. + + Returns: + dict: A dictionary of sections and their options. + """ + import configparser + + from bumpversion import autocast + + # Create a ConfigParser object and read the INI file + config_parser = configparser.RawConfigParser() + if file_path.name == "setup.cfg": + config_parser = configparser.ConfigParser() + + config_parser.read(file_path) + + # Create an empty dictionary to hold the parsed sections and options + bumpversion_options: Dict[str, Any] = {"files": [], "parts": {}} + + # Loop through each section in the INI file + for section_name in config_parser.sections(): + if not section_name.startswith("bumpversion"): + continue + + section_parts = section_name.split(":") + num_parts = len(section_parts) + options = {key: autocast.autocast_value(val) for key, val in config_parser.items(section_name)} + + if num_parts == 1: # bumpversion section + bumpversion_options.update(options) + serialize = bumpversion_options.get("serialize", []) + if "message" in bumpversion_options and isinstance(bumpversion_options["message"], list): + bumpversion_options["message"] = ",".join(bumpversion_options["message"]) + if not isinstance(serialize, list): + bumpversion_options["serialize"] = [serialize] + elif num_parts > 1 and section_parts[1].startswith("file"): + file_options = { + "filename": section_parts[2], + } + file_options.update(options) + if "replace" in file_options and isinstance(file_options["replace"], list): + file_options["replace"] = "\n".join(file_options["replace"]) + bumpversion_options["files"].append(file_options) + elif num_parts > 1 and section_parts[1].startswith("glob"): + file_options = { + "glob": section_parts[2], + } + file_options.update(options) + if "replace" in file_options and isinstance(file_options["replace"], list): + file_options["replace"] = "\n".join(file_options["replace"]) + bumpversion_options["files"].append(file_options) + elif num_parts > 1 and section_parts[1].startswith("part"): + bumpversion_options["parts"][section_parts[2]] = options + + # Return the dictionary of sections and options + return bumpversion_options + + +def read_toml_file(file_path: Path) -> Dict[str, Any]: + """ + Parse a TOML file and return the `bumpversion` section. + + Args: + file_path: The path to the TOML file. + + Returns: + dict: A dictionary of the `bumpversion` section. + """ + import tomlkit + + # Load the TOML file + toml_data = tomlkit.parse(file_path.read_text()).unwrap() + + return toml_data.get("tool", {}).get("bumpversion", {}) + + +def update_config_file( + config_file: Union[str, Path], + config: Config, + current_version: Version, + new_version: Version, + context: MutableMapping, + dry_run: bool = False, +) -> None: + """ + Update the current_version key in the configuration file. + + Args: + config_file: The configuration file to explicitly use. + config: The configuration to use. + current_version: The current version. + new_version: The new version. + context: The context to use for serialization. + dry_run: True if the update should be a dry run. + """ + from bumpversion.config.models import FileConfig + from bumpversion.files import DataFileUpdater + + if not config_file: + logger.info("No configuration file found to update.") + return + + config_path = Path(config_file) + if config_path.suffix != ".toml": + logger.info("Could not find the current version in the config file: %s.", config_path) + return + + # TODO: Eventually this should be transformed into another default "files_to_modify" entry + datafile_config = FileConfig( + filename=str(config_path), + key_path="tool.bumpversion.current_version", + search=config.search, + replace=config.replace, + regex=config.regex, + ignore_missing_version=config.ignore_missing_version, + serialize=config.serialize, + parse=config.parse, + ) + + updater = DataFileUpdater(datafile_config, config.version_config.part_configs) + updater.update_file(current_version, new_version, context, dry_run) + + +def update_ini_config_file( + config_file: Union[str, Path], current_version: str, new_version: str, dry_run: bool = False +) -> None: + """ + Update the current_version key in the configuration file. + + Instead of parsing and re-writing the config file with new information, it will use + a regular expression to just replace the current_version value. The idea is it will + avoid unintentional changes (like formatting) to the config file. + + Args: + config_file: The configuration file to explicitly use. + current_version: The serialized current version. + new_version: The serialized new version. + dry_run: True if the update should be a dry run. + """ + cfg_current_version_regex = re.compile( + f"(?P\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P{current_version})", + re.MULTILINE, + ) + + config_path = Path(config_file) + existing_config = config_path.read_text() + if config_path.suffix == ".cfg" and cfg_current_version_regex.search(existing_config): + sub_str = f"\\g{new_version}" + new_config = cfg_current_version_regex.sub(sub_str, existing_config) + else: + logger.info("Could not find the current version in the config file: %s.", config_path) + return + + logger.info( + "%s to config file %s:", + "Would write" if dry_run else "Writing", + config_path, + ) + + logger.info( + "\n".join( + list( + context_diff( + existing_config.splitlines(), + new_config.splitlines(), + fromfile=f"before {config_path}", + tofile=f"after {config_path}", + lineterm="", + ) + ) + ) + ) + + if not dry_run: + config_path.write_text(new_config) diff --git a/bumpversion/config/models.py b/bumpversion/config/models.py new file mode 100644 index 00000000..efc88832 --- /dev/null +++ b/bumpversion/config/models.py @@ -0,0 +1,113 @@ +"""Bump My Version configuration models.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List, Optional, Union + +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +if TYPE_CHECKING: + from bumpversion.scm import SCMInfo + from bumpversion.version_part import VersionConfig + + +class VersionPartConfig(BaseModel): + """Configuration of a part of the version.""" + + values: Optional[list] = None # Optional. Numeric is used if missing or no items in list + optional_value: Optional[str] = None # Optional. + # Defaults to first value. 0 in the case of numeric. Empty string means nothing is optional. + first_value: Union[str, int, None] = None # Optional. Defaults to first value in values + independent: bool = False + + +class FileConfig(BaseModel): + """Search and replace file config.""" + + parse: str + serialize: List[str] + search: str + replace: str + regex: bool + ignore_missing_version: bool + filename: Optional[str] = None + glob: Optional[str] = None # Conflicts with filename. If both are specified, glob wins + key_path: Optional[str] = None # If specified, and has an appropriate extension, will be treated as a data file + + +class Config(BaseSettings): + """Bump Version configuration.""" + + current_version: Optional[str] + parse: str + serialize: List[str] = Field(min_length=1) + search: str + replace: str + regex: bool + ignore_missing_version: bool + tag: bool + sign_tags: bool + tag_name: str + tag_message: Optional[str] + allow_dirty: bool + commit: bool + message: str + commit_args: Optional[str] + scm_info: Optional["SCMInfo"] + parts: Dict[str, VersionPartConfig] + files: List[FileConfig] + included_paths: List[str] = Field(default_factory=list) + excluded_paths: List[str] = Field(default_factory=list) + model_config = SettingsConfigDict(env_prefix="bumpversion_") + + def add_files(self, filename: Union[str, List[str]]) -> None: + """Add a filename to the list of files.""" + filenames = [filename] if isinstance(filename, str) else filename + for name in filenames: + if name in self.resolved_filemap: + continue + self.files.append( + FileConfig( + filename=name, + glob=None, + key_path=None, + parse=self.parse, + serialize=self.serialize, + search=self.search, + replace=self.replace, + regex=self.regex, + ignore_missing_version=self.ignore_missing_version, + ) + ) + + @property + def resolved_filemap(self) -> Dict[str, FileConfig]: + """Return a map of filenames to file configs, expanding any globs.""" + from bumpversion.config.utils import resolve_glob_files + + new_files = [] + for file_cfg in self.files: + if file_cfg.glob: + new_files.extend(resolve_glob_files(file_cfg)) + else: + new_files.append(file_cfg) + + return {file_cfg.filename: file_cfg for file_cfg in new_files} + + @property + def files_to_modify(self) -> List[FileConfig]: + """Return a list of files to modify.""" + files_not_excluded = [ + file_cfg.filename + for file_cfg in self.resolved_filemap.values() + if file_cfg.filename not in self.excluded_paths + ] + inclusion_set = set(self.included_paths) | set(files_not_excluded) + return [file_cfg for file_cfg in self.resolved_filemap.values() if file_cfg.filename in inclusion_set] + + @property + def version_config(self) -> "VersionConfig": + """Return the version configuration.""" + from bumpversion.version_part import VersionConfig + + return VersionConfig(self.parse, self.serialize, self.search, self.replace, self.parts) diff --git a/bumpversion/config/utils.py b/bumpversion/config/utils.py new file mode 100644 index 00000000..a0b41e63 --- /dev/null +++ b/bumpversion/config/utils.py @@ -0,0 +1,55 @@ +"""Helper functions for the config module.""" +from __future__ import annotations + +import glob +import itertools +from typing import Dict, List + +from bumpversion.config.models import FileConfig, VersionPartConfig +from bumpversion.utils import labels_for_format + + +def get_all_file_configs(config_dict: dict) -> List[FileConfig]: + """Make sure all version parts are included.""" + defaults = { + "parse": config_dict["parse"], + "serialize": config_dict["serialize"], + "search": config_dict["search"], + "replace": config_dict["replace"], + "ignore_missing_version": config_dict["ignore_missing_version"], + "regex": config_dict["regex"], + } + files = [{k: v for k, v in filecfg.items() if v is not None} for filecfg in config_dict["files"]] + for f in files: + f.update({k: v for k, v in defaults.items() if k not in f}) + return [FileConfig(**f) for f in files] + + +def get_all_part_configs(config_dict: dict) -> Dict[str, VersionPartConfig]: + """Make sure all version parts are included.""" + serialize = config_dict["serialize"] + parts = config_dict["parts"] + all_labels = set(itertools.chain.from_iterable([labels_for_format(fmt) for fmt in serialize])) + return { + label: VersionPartConfig(**parts[label]) if label in parts else VersionPartConfig() # type: ignore[call-arg] + for label in all_labels + } + + +def resolve_glob_files(file_cfg: FileConfig) -> List[FileConfig]: + """ + Return a list of file configurations that match the glob pattern. + + Args: + file_cfg: The file configuration containing the glob pattern + + Returns: + A list of resolved file configurations according to the pattern. + """ + files = [] + for filename_glob in glob.glob(file_cfg.glob, recursive=True): + new_file_cfg = file_cfg.model_copy() + new_file_cfg.filename = filename_glob + new_file_cfg.glob = None + files.append(new_file_cfg) + return files diff --git a/bumpversion/files.py b/bumpversion/files.py index f5e8b23f..855e786f 100644 --- a/bumpversion/files.py +++ b/bumpversion/files.py @@ -3,15 +3,94 @@ import re from copy import deepcopy from difflib import context_diff -from typing import List, MutableMapping, Optional, Tuple +from pathlib import Path +from typing import Dict, List, MutableMapping, Optional, Tuple -from bumpversion.config import FileConfig +from bumpversion.config.models import FileConfig, VersionPartConfig from bumpversion.exceptions import VersionNotFoundError from bumpversion.version_part import Version, VersionConfig logger = logging.getLogger(__name__) +def get_search_pattern(search_str: str, context: MutableMapping, use_regex: bool = False) -> Tuple[re.Pattern, str]: + """ + Render the search pattern and return the compiled regex pattern and the raw pattern. + + Args: + search_str: A string containing the search pattern as a format string + context: The context to use for rendering the search pattern + use_regex: If True, the search pattern is treated as a regex pattern + + Returns: + A tuple of the compiled regex pattern and the raw pattern as a string. + """ + # the default search pattern is escaped, so we can still use it in a regex + raw_pattern = search_str.format(**context) + default = re.compile(re.escape(raw_pattern), re.MULTILINE | re.DOTALL) + if not use_regex: + logger.debug("No RegEx flag detected. Searching for the default pattern: '%s'", default.pattern) + return default, raw_pattern + + re_context = {key: re.escape(str(value)) for key, value in context.items()} + regex_pattern = search_str.format(**re_context) + try: + search_for_re = re.compile(regex_pattern, re.MULTILINE | re.DOTALL) + logger.debug("Searching for the regex: '%s'", search_for_re.pattern) + return search_for_re, raw_pattern + except re.error as e: + logger.error("Invalid regex '%s': %s.", default, e) + + logger.debug("Invalid regex. Searching for the default pattern: '%s'", raw_pattern) + return default, raw_pattern + + +def contains_pattern(search: re.Pattern, contents: str) -> bool: + """Does the search pattern match any part of the contents?""" + if not search or not contents: + return False + + for m in re.finditer(search, contents): + line_no = contents.count("\n", 0, m.start(0)) + 1 + logger.info( + "Found '%s' at line %s: %s", + search, + line_no, + m.string[m.start() : m.end(0)], + ) + return True + return False + + +def log_changes(file_path: str, file_content_before: str, file_content_after: str, dry_run: bool = False) -> None: + """ + Log the changes that would be made to the file. + + Args: + file_path: The path to the file + file_content_before: The file contents before the change + file_content_after: The file contents after the change + dry_run: True if this is a report-only job + """ + if file_content_before != file_content_after: + logger.info("%s file %s:", "Would change" if dry_run else "Changing", file_path) + logger.info( + "\n".join( + list( + context_diff( + file_content_before.splitlines(), + file_content_after.splitlines(), + fromfile=f"before {file_path}", + tofile=f"after {file_path}", + lineterm="", + ) + ) + ) + ) + else: + logger.info("%s file %s", "Would not change" if dry_run else "Not changing", file_path) + + class ConfiguredFile: """A file to modify in a configured way.""" @@ -63,9 +142,9 @@ def contains_version(self, version: Version, context: MutableMapping) -> bool: Returns: True if the version number is in fact present. """ - search_expression, raw_search_expression = self.get_search_pattern(context) - - if self.contains(search_expression): + search_expression, raw_search_expression = get_search_pattern(self.search, context, self.regex) + file_contents = self.get_file_contents() + if contains_pattern(search_expression, file_contents): return True # the `search` pattern did not match, but the original supplied @@ -76,7 +155,7 @@ def contains_version(self, version: Version, context: MutableMapping) -> bool: # very specific parts of the file search_pattern_is_default = self.search == self.version_config.search - if search_pattern_is_default and self.contains(re.compile(re.escape(version.original))): + if search_pattern_is_default and contains_pattern(re.compile(re.escape(version.original)), file_contents): # The original version is present, and we're not looking for something # more specific -> this is accepted as a match return True @@ -86,25 +165,6 @@ def contains_version(self, version: Version, context: MutableMapping) -> bool: return False raise VersionNotFoundError(f"Did not find '{raw_search_expression}' in file: '{self.path}'") - def contains(self, search: re.Pattern) -> bool: - """Does the work of the contains_version method.""" - if not search: - return False - - contents = self.get_file_contents() - - for m in re.finditer(search, contents): - line_no = contents.count("\n", 0, m.start(0)) + 1 - logger.info( - "Found '%s' in %s at line %s: %s", - search, - self.path, - line_no, - m.string[m.start() : m.end(0)], - ) - return True - return False - def replace_version( self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False ) -> None: @@ -115,7 +175,7 @@ def replace_version( if new_version: context["new_version"] = self.version_config.serialize(new_version, context) - search_for, raw_search_pattern = self.get_search_pattern(context) + search_for, raw_search_pattern = get_search_pattern(self.search, context, self.regex) replace_with = self.version_config.replace.format(**context) file_content_after = search_for.sub(replace_with, file_content_before) @@ -123,51 +183,14 @@ def replace_version( if file_content_before == file_content_after and current_version.original: og_context = deepcopy(context) og_context["current_version"] = current_version.original - search_for_og, og_raw_search_pattern = self.get_search_pattern(og_context) + search_for_og, og_raw_search_pattern = get_search_pattern(self.search, og_context, self.regex) file_content_after = search_for_og.sub(replace_with, file_content_before) - if file_content_before != file_content_after: - logger.info("%s file %s:", "Would change" if dry_run else "Changing", self.path) - logger.info( - "\n".join( - list( - context_diff( - file_content_before.splitlines(), - file_content_after.splitlines(), - fromfile=f"before {self.path}", - tofile=f"after {self.path}", - lineterm="", - ) - ) - ) - ) - else: - logger.info("%s file %s", "Would not change" if dry_run else "Not changing", self.path) + log_changes(self.path, file_content_before, file_content_after, dry_run) if not dry_run: # pragma: no-coverage self.write_file_contents(file_content_after) - def get_search_pattern(self, context: MutableMapping) -> Tuple[re.Pattern, str]: - """Compile and return the regex if it is valid, otherwise return the string.""" - # the default search pattern is escaped, so we can still use it in a regex - raw_pattern = self.version_config.search.format(**context) - default = re.compile(re.escape(raw_pattern), re.MULTILINE | re.DOTALL) - if not self.regex: - logger.debug("No RegEx flag detected. Searching for the default pattern: '%s'", default.pattern) - return default, raw_pattern - - re_context = {key: re.escape(str(value)) for key, value in context.items()} - regex_pattern = self.version_config.search.format(**re_context) - try: - search_for_re = re.compile(regex_pattern, re.MULTILINE | re.DOTALL) - logger.debug("Searching for the regex: '%s'", search_for_re.pattern) - return search_for_re, raw_pattern - except re.error as e: - logger.error("Invalid regex '%s' for file %s: %s.", default, self.path, e) - - logger.debug("Searching for the default pattern: '%s'", raw_pattern) - return default, raw_pattern - def __str__(self) -> str: # pragma: no-coverage return self.path @@ -226,3 +249,91 @@ def _check_files_contain_version( for f in files: context["current_version"] = f.version_config.serialize(current_version, context) f.contains_version(current_version, context) + + +class FileUpdater: + """A class to handle updating files.""" + + def __init__( + self, + file_cfg: FileConfig, + version_config: VersionConfig, + search: Optional[str] = None, + replace: Optional[str] = None, + ) -> None: + self.path = file_cfg.filename + self.version_config = version_config + self.parse = file_cfg.parse or version_config.parse_regex.pattern + self.serialize = file_cfg.serialize or version_config.serialize_formats + self.search = search or file_cfg.search or version_config.search + self.replace = replace or file_cfg.replace or version_config.replace + self.regex = file_cfg.regex or False + self.ignore_missing_version = file_cfg.ignore_missing_version or False + self.version_config = VersionConfig( + self.parse, self.serialize, self.search, self.replace, version_config.part_configs + ) + self._newlines: Optional[str] = None + + def update_file( + self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False + ) -> None: + """Update the files.""" + # TODO: Implement this + pass + + +class DataFileUpdater: + """A class to handle updating files.""" + + def __init__( + self, + file_cfg: FileConfig, + version_part_configs: Dict[str, VersionPartConfig], + ) -> None: + self.path = Path(file_cfg.filename) + self.key_path = file_cfg.key_path + self.search = file_cfg.search + self.replace = file_cfg.replace + self.regex = file_cfg.regex + self.ignore_missing_version = file_cfg.ignore_missing_version + self.version_config = VersionConfig( + file_cfg.parse, file_cfg.serialize, file_cfg.search, file_cfg.replace, version_part_configs + ) + + def update_file( + self, current_version: Version, new_version: Version, context: MutableMapping, dry_run: bool = False + ) -> None: + """Update the files.""" + new_context = deepcopy(context) + new_context["current_version"] = self.version_config.serialize(current_version, context) + new_context["new_version"] = self.version_config.serialize(new_version, context) + search_for, raw_search_pattern = get_search_pattern(self.search, new_context, self.regex) + replace_with = self.replace.format(**new_context) + if self.path.suffix == ".toml": + self._update_toml_file(search_for, raw_search_pattern, replace_with, dry_run) + + def _update_toml_file( + self, search_for: re.Pattern, raw_search_pattern: str, replace_with: str, dry_run: bool = False + ) -> None: + """Update a TOML file.""" + import dotted + import tomlkit + + toml_data = tomlkit.parse(self.path.read_text()) + value_before = dotted.get(toml_data, self.key_path) + + if value_before is None: + raise KeyError(f"Key path '{self.key_path}' does not exist in {self.path}") + elif not contains_pattern(search_for, value_before) and not self.ignore_missing_version: + raise ValueError( + f"Key '{self.key_path}' in {self.path} does not contain the correct contents: {raw_search_pattern}" + ) + + new_value = search_for.sub(replace_with, value_before) + log_changes(f"{self.path}:{self.key_path}", value_before, new_value, dry_run) + + if dry_run: + return + + dotted.update(toml_data, self.key_path, new_value) + self.path.write_text(tomlkit.dumps(toml_data)) diff --git a/bumpversion/logging.py b/bumpversion/logging.py deleted file mode 100644 index e4d5680e..00000000 --- a/bumpversion/logging.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Logging configuration for bumpversion.""" -import logging - -import click -from rich.logging import RichHandler - -logger = logging.getLogger("bumpversion") - -VERBOSITY = { - 0: logging.WARNING, - 1: logging.INFO, - 2: logging.DEBUG, -} - - -def setup_logging(verbose: int = 0) -> None: - """Configure the logging.""" - logging.basicConfig( - level=VERBOSITY.get(verbose, logging.DEBUG), - format="%(message)s", - datefmt="[%X]", - handlers=[ - RichHandler( - rich_tracebacks=True, show_level=False, show_path=False, show_time=False, tracebacks_suppress=[click] - ) - ], - ) - root_logger = logging.getLogger("") - root_logger.setLevel(VERBOSITY.get(verbose, logging.DEBUG)) diff --git a/bumpversion/scm.py b/bumpversion/scm.py index 69165c58..cf4d6d9e 100644 --- a/bumpversion/scm.py +++ b/bumpversion/scm.py @@ -9,6 +9,8 @@ from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING, ClassVar, List, MutableMapping, Optional, Type, Union +from utils import extract_regex_flags + if TYPE_CHECKING: # pragma: no-coverage from bumpversion.config import Config @@ -113,7 +115,9 @@ def get_all_tags(cls) -> List[str]: def get_version_from_tag(cls, tag: str, tag_name: str, parse_pattern: str) -> Optional[str]: """Return the version from a tag.""" version_pattern = parse_pattern.replace("\\\\", "\\") + version_pattern, regex_flags = extract_regex_flags(version_pattern) rep = tag_name.replace("{new_version}", f"(?P{version_pattern})") + rep = f"{regex_flags}{rep}" tag_regex = re.compile(rep) return match["current_version"] if (match := tag_regex.match(tag)) else None diff --git a/bumpversion/show.py b/bumpversion/show.py index e29c177b..31f75800 100644 --- a/bumpversion/show.py +++ b/bumpversion/show.py @@ -8,7 +8,7 @@ from bumpversion.config import Config from bumpversion.exceptions import BadInputError from bumpversion.ui import print_error, print_info -from bumpversion.utils import get_context +from bumpversion.utils import get_context, recursive_sort_dict def output_default(value: dict) -> None: @@ -17,7 +17,7 @@ def output_default(value: dict) -> None: print_info(next(iter(value.values()))) else: buffer = StringIO() - pprint(value, stream=buffer) # noqa: T203 + pprint(value, stream=buffer, sort_dicts=True) # noqa: T203 print_info(buffer.getvalue()) @@ -25,7 +25,7 @@ def output_yaml(value: dict) -> None: """Output the value as yaml.""" from bumpversion.yaml_dump import dump - print_info(dump(value)) + print_info(dump(recursive_sort_dict(value))) def output_json(value: dict) -> None: @@ -117,13 +117,14 @@ def log_list(config: Config, version_part: Optional[str], new_version: Optional[ print_info(f"new_version={next_version_str}") - for key, value in config.dict(exclude={"scm_info", "parts"}).items(): + config_dict = recursive_sort_dict(config.model_dump(exclude={"scm_info", "parts"})) + for key, value in config_dict.items(): print_info(f"{key}={value}") def do_show(*args, config: Config, format_: str = "default", increment: Optional[str] = None) -> None: """Show current version or configuration information.""" - config_dict = config.dict() + config_dict = config.model_dump() ctx = get_context(config) if increment: diff --git a/bumpversion/ui.py b/bumpversion/ui.py index c0bd932a..fa0278a3 100644 --- a/bumpversion/ui.py +++ b/bumpversion/ui.py @@ -1,5 +1,33 @@ """Utilities for user interface.""" +import logging + +import click from click import UsageError, secho +from rich.logging import RichHandler + +logger = logging.getLogger("bumpversion") + +VERBOSITY = { + 0: logging.WARNING, + 1: logging.INFO, + 2: logging.DEBUG, +} + + +def setup_logging(verbose: int = 0) -> None: + """Configure the logging.""" + logging.basicConfig( + level=VERBOSITY.get(verbose, logging.DEBUG), + format="%(message)s", + datefmt="[%X]", + handlers=[ + RichHandler( + rich_tracebacks=True, show_level=False, show_path=False, show_time=False, tracebacks_suppress=[click] + ) + ], + ) + root_logger = logging.getLogger("") + root_logger.setLevel(VERBOSITY.get(verbose, logging.DEBUG)) def print_info(msg: str) -> None: diff --git a/bumpversion/utils.py b/bumpversion/utils.py index f16dda00..5243b1a0 100644 --- a/bumpversion/utils.py +++ b/bumpversion/utils.py @@ -2,13 +2,38 @@ import string from collections import ChainMap from dataclasses import asdict -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Any, List, Optional, Tuple if TYPE_CHECKING: # pragma: no-coverage from bumpversion.config import Config from bumpversion.version_part import Version +def extract_regex_flags(regex_pattern: str) -> Tuple[str, str]: + """ + Extract the regex flags from the regex pattern. + + Args: + regex_pattern: The pattern that might start with regex flags + + Returns: + A tuple of the regex pattern without the flag string and regex flag string + """ + import re + + flag_pattern = r"^(\(\?[aiLmsux]+\))" + bits = re.split(flag_pattern, regex_pattern) + return (regex_pattern, "") if len(bits) == 1 else (bits[2], bits[1]) + + +def recursive_sort_dict(input_value: Any) -> Any: + """Sort a dictionary recursively.""" + if not isinstance(input_value, dict): + return input_value + + return {key: recursive_sort_dict(input_value[key]) for key in sorted(input_value.keys())} + + def key_val_string(d: dict) -> str: """Render the dictionary as a comma-delimited key=value string.""" return ", ".join(f"{k}={v}" for k, v in sorted(d.items())) diff --git a/bumpversion/version_part.py b/bumpversion/version_part.py index 6c57e16f..41c2d054 100644 --- a/bumpversion/version_part.py +++ b/bumpversion/version_part.py @@ -7,7 +7,7 @@ from click import UsageError -from bumpversion.config import VersionPartConfig +from bumpversion.config.models import VersionPartConfig from bumpversion.exceptions import FormattingError, InvalidVersionPartError, MissingValueError from bumpversion.functions import NumericFunction, PartFunction, ValuesFunction from bumpversion.utils import key_val_string, labels_for_format @@ -146,6 +146,7 @@ def __init__( self.serialize_formats = serialize self.part_configs = part_configs or {} + # TODO: I think these two should be removed from the config object self.search = search self.replace = replace diff --git a/pyproject.toml b/pyproject.toml index 9c277bbc..c3fa388e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ keywords = ["bumpversion", "version", "release"] dynamic = ["version"] dependencies = [ "click", - "pydantic", + "dotted-notation", + "pydantic>=2.0.0", "pydantic-settings", "rich-click", "rich", diff --git a/tests/fixtures/basic_cfg_expected.txt b/tests/fixtures/basic_cfg_expected.txt index c302b7f3..b8d31ca7 100644 --- a/tests/fixtures/basic_cfg_expected.txt +++ b/tests/fixtures/basic_cfg_expected.txt @@ -6,6 +6,7 @@ 'files': [{'filename': 'setup.py', 'glob': None, 'ignore_missing_version': False, + 'key_path': None, 'parse': '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\-(?P[a-z]+))?', 'regex': False, 'replace': '{new_version}', @@ -15,6 +16,7 @@ {'filename': 'bumpversion/__init__.py', 'glob': None, 'ignore_missing_version': False, + 'key_path': None, 'parse': '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\-(?P[a-z]+))?', 'regex': False, 'replace': '{new_version}', @@ -24,6 +26,7 @@ {'filename': 'CHANGELOG.md', 'glob': None, 'ignore_missing_version': False, + 'key_path': None, 'parse': '(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\-(?P[a-z]+))?', 'regex': False, 'replace': '**unreleased**\n**v{new_version}**', diff --git a/tests/fixtures/basic_cfg_expected.yaml b/tests/fixtures/basic_cfg_expected.yaml index 917b89bf..28e53dbe 100644 --- a/tests/fixtures/basic_cfg_expected.yaml +++ b/tests/fixtures/basic_cfg_expected.yaml @@ -8,6 +8,7 @@ files: - filename: "setup.py" glob: null ignore_missing_version: false + key_path: null parse: "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\-(?P[a-z]+))?" regex: false replace: "{new_version}" @@ -18,6 +19,7 @@ files: - filename: "bumpversion/__init__.py" glob: null ignore_missing_version: false + key_path: null parse: "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\-(?P[a-z]+))?" regex: false replace: "{new_version}" @@ -28,6 +30,7 @@ files: - filename: "CHANGELOG.md" glob: null ignore_missing_version: false + key_path: null parse: "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\-(?P[a-z]+))?" regex: false replace: "**unreleased**\n**v{new_version}**" diff --git a/tests/fixtures/basic_cfg_expected_full.json b/tests/fixtures/basic_cfg_expected_full.json index 0ebc34a9..b7baefe3 100644 --- a/tests/fixtures/basic_cfg_expected_full.json +++ b/tests/fixtures/basic_cfg_expected_full.json @@ -9,6 +9,7 @@ "filename": "setup.py", "glob": null, "ignore_missing_version": false, + "key_path": null, "parse": "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\-(?P[a-z]+))?", "regex": false, "replace": "{new_version}", @@ -22,6 +23,7 @@ "filename": "bumpversion/__init__.py", "glob": null, "ignore_missing_version": false, + "key_path": null, "parse": "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\-(?P[a-z]+))?", "regex": false, "replace": "{new_version}", @@ -35,6 +37,7 @@ "filename": "CHANGELOG.md", "glob": null, "ignore_missing_version": false, + "key_path": null, "parse": "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\-(?P[a-z]+))?", "regex": false, "replace": "**unreleased**\n**v{new_version}**", diff --git a/tests/fixtures/file_config_overrides.toml b/tests/fixtures/file_config_overrides.toml new file mode 100644 index 00000000..6dec2e51 --- /dev/null +++ b/tests/fixtures/file_config_overrides.toml @@ -0,0 +1,31 @@ +[tool.bumpversion] +current_version = "0.0.1" +ignore_missing_version = true +regex = true + +[[tool.bumpversion.files]] +filename = "should_contain_defaults.txt" + +[[tool.bumpversion.files]] +filename = "should_override_search.txt" +search = "**unreleased**" + +[[tool.bumpversion.files]] +filename = "should_override_replace.txt" +replace = "**unreleased**" + +[[tool.bumpversion.files]] +filename = "should_override_parse.txt" +parse = "version(?P\\d+)" + +[[tool.bumpversion.files]] +filename = "should_override_serialize.txt" +serialize = ["{major}"] + +[[tool.bumpversion.files]] +filename = "should_override_ignore_missing.txt" +ignore_missing_version = false + +[[tool.bumpversion.files]] +filename = "should_override_regex.txt" +regex = false diff --git a/tests/fixtures/partial_version_strings.toml b/tests/fixtures/partial_version_strings.toml new file mode 100644 index 00000000..542f485a --- /dev/null +++ b/tests/fixtures/partial_version_strings.toml @@ -0,0 +1,28 @@ +[project] +name = "sample-repo" +version = "0.0.2" +description = "" +authors = [ + {name = "Someone", email = "someone@example.com"}, +] +dependencies = [] +requires-python = ">=3.11" +readme = "README.md" +license = {text = "MIT"} + +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.pdm.dev-dependencies] +lint = [ + "ruff==0.0.292", # Comments should be saved +] +build = [ + "bump-my-version>=0.12.0", +] + +[tool.bumpversion] +commit = false +tag = false +current_version = "0.0.2" diff --git a/tests/test_bump.py b/tests/test_bump.py index 66f584d5..0affee64 100644 --- a/tests/test_bump.py +++ b/tests/test_bump.py @@ -81,9 +81,10 @@ def test_do_bump_with_version_part(mock_update_config_file, mock_modify_files): "bar.txt", } assert mock_update_config_file.call_args[0][0] is None - assert mock_update_config_file.call_args[0][1] == config.current_version - assert mock_update_config_file.call_args[0][2] == "2.0.0" - assert mock_update_config_file.call_args[0][3] is False + assert mock_update_config_file.call_args[0][1] == config + assert mock_update_config_file.call_args[0][2] == current_version + assert mock_update_config_file.call_args[0][3] == current_version.bump(version_part, version_config.order) + assert mock_update_config_file.call_args[0][5] is False @patch("bumpversion.files.modify_files") @@ -111,9 +112,10 @@ def test_do_bump_with_new_version(mock_update_config_file, mock_modify_files): mock_update_config_file.assert_called_once() assert mock_update_config_file.call_args[0][0] is None - assert mock_update_config_file.call_args[0][1] is config.current_version - assert mock_update_config_file.call_args[0][2] == "2.0.0" - assert mock_update_config_file.call_args[0][3] is True + assert mock_update_config_file.call_args[0][1] == config + assert mock_update_config_file.call_args[0][2] == current_version + assert mock_update_config_file.call_args[0][3] == version_config.parse(new_version) + assert mock_update_config_file.call_args[0][5] is dry_run @patch("bumpversion.files.modify_files") diff --git a/tests/test_cli.py b/tests/test_cli.py index 2c831e04..e1d99271 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -218,19 +218,24 @@ def test_listing_with_version_part(tmp_path: Path, fixtures_path: Path): "message=Bump version: {current_version} → {new_version}", "commit_args=None", ( - "files=[" - "{'filename': 'setup.py', 'glob': None, 'parse': " - "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', 'serialize': " - "['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', " - "'replace': '{new_version}', 'regex': False, 'ignore_missing_version': False}, " - "{'filename': 'bumpversion/__init__.py', 'glob': None, 'parse': " - "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', 'serialize': " - "['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', " - "'replace': '{new_version}', 'regex': False, 'ignore_missing_version': False}, " - "{'filename': 'CHANGELOG.md', 'glob': None, 'parse': " - "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', 'serialize': " - "['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '**unreleased**', " - "'replace': '**unreleased**\\n**v{new_version}**', 'regex': False, 'ignore_missing_version': False}]" + "files=[{'parse': " + "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', " + "'serialize': ['{major}.{minor}.{patch}-{release}', " + "'{major}.{minor}.{patch}'], 'search': '{current_version}', 'replace': " + "'{new_version}', 'regex': False, 'ignore_missing_version': False, " + "'filename': 'setup.py', 'glob': None, 'key_path': None}, {'parse': " + "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', " + "'serialize': ['{major}.{minor}.{patch}-{release}', " + "'{major}.{minor}.{patch}'], 'search': '{current_version}', 'replace': " + "'{new_version}', 'regex': False, 'ignore_missing_version': False, " + "'filename': 'bumpversion/__init__.py', 'glob': None, 'key_path': None}, " + "{'parse': " + "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', " + "'serialize': ['{major}.{minor}.{patch}-{release}', " + "'{major}.{minor}.{patch}'], 'search': '**unreleased**', 'replace': " + "'**unreleased**\\n**v{new_version}**', 'regex': False, " + "'ignore_missing_version': False, 'filename': 'CHANGELOG.md', 'glob': None, " + "'key_path': None}]" ), } @@ -273,19 +278,24 @@ def test_listing_without_version_part(tmp_path: Path, fixtures_path: Path): "message=Bump version: {current_version} → {new_version}", "commit_args=None", ( - "files=[" - "{'filename': 'setup.py', 'glob': None, 'parse': " - "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', 'serialize': " - "['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', " - "'replace': '{new_version}', 'regex': False, 'ignore_missing_version': False}, " - "{'filename': 'bumpversion/__init__.py', 'glob': None, 'parse': " - "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', 'serialize': " - "['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '{current_version}', " - "'replace': '{new_version}', 'regex': False, 'ignore_missing_version': False}, " - "{'filename': 'CHANGELOG.md', 'glob': None, 'parse': " - "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', 'serialize': " - "['{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'], 'search': '**unreleased**', " - "'replace': '**unreleased**\\n**v{new_version}**', 'regex': False, 'ignore_missing_version': False}]" + "files=[{'parse': " + "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', " + "'serialize': ['{major}.{minor}.{patch}-{release}', " + "'{major}.{minor}.{patch}'], 'search': '{current_version}', 'replace': " + "'{new_version}', 'regex': False, 'ignore_missing_version': False, " + "'filename': 'setup.py', 'glob': None, 'key_path': None}, {'parse': " + "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', " + "'serialize': ['{major}.{minor}.{patch}-{release}', " + "'{major}.{minor}.{patch}'], 'search': '{current_version}', 'replace': " + "'{new_version}', 'regex': False, 'ignore_missing_version': False, " + "'filename': 'bumpversion/__init__.py', 'glob': None, 'key_path': None}, " + "{'parse': " + "'(?P\\\\d+)\\\\.(?P\\\\d+)\\\\.(?P\\\\d+)(\\\\-(?P[a-z]+))?', " + "'serialize': ['{major}.{minor}.{patch}-{release}', " + "'{major}.{minor}.{patch}'], 'search': '**unreleased**', 'replace': " + "'**unreleased**\\n**v{new_version}**', 'regex': False, " + "'ignore_missing_version': False, 'filename': 'CHANGELOG.md', 'glob': None, " + "'key_path': None}]" ), } diff --git a/tests/test_config.py b/tests/test_config.py index 8e926baf..6516c7ce 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,6 +8,9 @@ from click.testing import CliRunner, Result from pytest import param +import bumpversion.config.files +import bumpversion.config.utils +from bumpversion.utils import get_context from bumpversion import config from tests.conftest import inside_dir, get_config_data @@ -39,7 +42,7 @@ def cfg_file_keyword(request): ) def test_read_ini_file(conf_file: str, expected_file: str, fixtures_path: Path) -> None: """Parsing the config file should match the expected results.""" - result = config.read_ini_file(fixtures_path.joinpath(conf_file)) + result = bumpversion.config.files.read_ini_file(fixtures_path.joinpath(conf_file)) expected = json.loads(fixtures_path.joinpath(expected_file).read_text()) assert result == expected @@ -52,7 +55,7 @@ def test_read_ini_file(conf_file: str, expected_file: str, fixtures_path: Path) ) def test_read_toml_file(conf_file: str, expected_file: str, fixtures_path: Path) -> None: """Parsing the config file should match the expected results.""" - result = config.read_toml_file(fixtures_path.joinpath(conf_file)) + result = bumpversion.config.files.read_toml_file(fixtures_path.joinpath(conf_file)) expected = json.loads(fixtures_path.joinpath(expected_file).read_text()) assert result == expected @@ -129,7 +132,7 @@ def test_multiple_config_files(tmp_path: Path): "]\n" ) with inside_dir(tmp_path): - cfg_file = config.find_config_file() + cfg_file = bumpversion.config.files.find_config_file() cfg = config.get_configuration(cfg_file) assert cfg.current_version == "0.10.5" @@ -175,23 +178,53 @@ def test_utf8_message_from_config_file(tmp_path: Path, cfg_file): @pytest.mark.parametrize( - ["cfg_file_name", "expected_diff"], [ - (".bumpversion.cfg", CFG_EXPECTED_DIFF), - ("setup.cfg", CFG_EXPECTED_DIFF), - ("pyproject.toml", TOML_EXPECTED_DIFF), + "cfg_file_name", + ], + [ + ("pyproject.toml",), ], ) -def test_update_config_file(tmp_path: Path, cfg_file_name: str, expected_diff: str, fixtures_path: Path) -> None: +def test_update_config_file(tmp_path: Path, cfg_file_name: str, fixtures_path: Path) -> None: """ Make sure only the version string is updated in the config file. """ + expected_diff = TOML_EXPECTED_DIFF cfg_path = tmp_path / cfg_file_name orig_path = fixtures_path / f"basic_cfg{cfg_path.suffix}" cfg_path.write_text(orig_path.read_text()) original_content = orig_path.read_text().splitlines(keepends=True) + with inside_dir(tmp_path): + cfg = config.get_configuration(cfg_path) + ctx = get_context(cfg) + current_version = cfg.version_config.parse("1.0.0") + new_version = cfg.version_config.parse("1.0.1") + bumpversion.config.files.update_config_file(cfg_path, cfg, current_version, new_version, ctx) + new_content = cfg_path.read_text().splitlines(keepends=True) + difference = difflib.context_diff(original_content, new_content, n=0) + assert "".join(difference) == expected_diff + - config.update_config_file(cfg_path, "1.0.0", "1.0.1") +@pytest.mark.parametrize( + [ + "cfg_file_name", + ], + [ + (".bumpversion.cfg",), + ("setup.cfg",), + ], +) +def test_update_ini_config_file(tmp_path: Path, cfg_file_name: str, fixtures_path: Path) -> None: + """ + Make sure only the version string is updated in the config file. + """ + expected_diff = CFG_EXPECTED_DIFF + cfg_path = tmp_path / cfg_file_name + orig_path = fixtures_path / f"basic_cfg{cfg_path.suffix}" + cfg_path.write_text(orig_path.read_text()) + original_content = orig_path.read_text().splitlines(keepends=True) + + bumpversion.config.files.update_ini_config_file(cfg_path, "1.0.0", "1.0.1") new_content = cfg_path.read_text().splitlines(keepends=True) difference = difflib.context_diff(original_content, new_content, n=0) assert "".join(difference) == expected_diff @@ -269,8 +302,63 @@ def test_get_glob_files(glob_pattern: str, file_list: set, fixtures_path: Path): } conf, version_config, current_version = get_config_data(overrides) with inside_dir(fixtures_path.joinpath("glob")): - result = config.get_glob_files(conf.files[0]) + result = bumpversion.config.utils.resolve_glob_files(conf.files[0]) assert len(result) == len(file_list) for f in result: assert Path(f.filename) in file_list + + +def test_file_overrides_config(fixtures_path: Path): + """If a file has a different config, it should override the main config.""" + cfg_file = fixtures_path / "file_config_overrides.toml" + conf = config.get_configuration(cfg_file) + file_map = {f.filename: f for f in conf.files} + assert file_map["should_contain_defaults.txt"].parse == conf.parse + assert file_map["should_contain_defaults.txt"].serialize == conf.serialize + assert file_map["should_contain_defaults.txt"].search == conf.search + assert file_map["should_contain_defaults.txt"].replace == conf.replace + assert file_map["should_contain_defaults.txt"].regex == conf.regex + assert file_map["should_contain_defaults.txt"].ignore_missing_version == conf.ignore_missing_version + + assert file_map["should_override_search.txt"].parse == conf.parse + assert file_map["should_override_search.txt"].serialize == conf.serialize + assert file_map["should_override_search.txt"].search == "**unreleased**" + assert file_map["should_override_search.txt"].replace == conf.replace + assert file_map["should_override_search.txt"].regex == conf.regex + assert file_map["should_override_search.txt"].ignore_missing_version == conf.ignore_missing_version + + assert file_map["should_override_replace.txt"].parse == conf.parse + assert file_map["should_override_replace.txt"].serialize == conf.serialize + assert file_map["should_override_replace.txt"].search == conf.search + assert file_map["should_override_replace.txt"].replace == "**unreleased**" + assert file_map["should_override_replace.txt"].regex == conf.regex + assert file_map["should_override_replace.txt"].ignore_missing_version == conf.ignore_missing_version + + assert file_map["should_override_parse.txt"].parse == "version(?P\d+)" + assert file_map["should_override_parse.txt"].serialize == conf.serialize + assert file_map["should_override_parse.txt"].search == conf.search + assert file_map["should_override_parse.txt"].replace == conf.replace + assert file_map["should_override_parse.txt"].regex == conf.regex + assert file_map["should_override_parse.txt"].ignore_missing_version == conf.ignore_missing_version + + assert file_map["should_override_serialize.txt"].parse == conf.parse + assert file_map["should_override_serialize.txt"].serialize == ["{major}"] + assert file_map["should_override_serialize.txt"].search == conf.search + assert file_map["should_override_serialize.txt"].replace == conf.replace + assert file_map["should_override_serialize.txt"].regex == conf.regex + assert file_map["should_override_serialize.txt"].ignore_missing_version == conf.ignore_missing_version + + assert file_map["should_override_ignore_missing.txt"].parse == conf.parse + assert file_map["should_override_ignore_missing.txt"].serialize == conf.serialize + assert file_map["should_override_ignore_missing.txt"].search == conf.search + assert file_map["should_override_ignore_missing.txt"].replace == conf.replace + assert file_map["should_override_ignore_missing.txt"].regex == conf.regex + assert file_map["should_override_ignore_missing.txt"].ignore_missing_version is False + + assert file_map["should_override_regex.txt"].parse == conf.parse + assert file_map["should_override_regex.txt"].serialize == conf.serialize + assert file_map["should_override_regex.txt"].search == conf.search + assert file_map["should_override_regex.txt"].replace == conf.replace + assert file_map["should_override_regex.txt"].regex is False + assert file_map["should_override_regex.txt"].ignore_missing_version == conf.ignore_missing_version diff --git a/tests/test_files.py b/tests/test_files.py index f7344b38..3227bb05 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -2,13 +2,16 @@ import os import shutil from datetime import datetime, timezone +from difflib import context_diff from pathlib import Path from textwrap import dedent +import tomlkit import pytest from pytest import param from bumpversion import exceptions, files, config, bump +from bumpversion.config.models import FileConfig from bumpversion.utils import get_context from bumpversion.exceptions import VersionNotFoundError from bumpversion.version_part import VersionConfig @@ -429,3 +432,58 @@ def test_bad_regex_search(tmp_path: Path, caplog) -> None: # Assert assert version_path.read_text() == "Score: A+ ( '1.2.4'" assert "Invalid regex" in caplog.text + + +def test_datafileupdater_replaces_key(tmp_path: Path, fixtures_path: Path) -> None: + """A key specific key is replaced and nothing else is touched.""" + # Arrange + config_path = tmp_path / "pyproject.toml" + fixture_path = fixtures_path / "partial_version_strings.toml" + shutil.copy(fixture_path, config_path) + + contents_before = config_path.read_text() + conf = config.get_configuration(config_file=config_path, files=[{"filename": str(config_path)}]) + version_config = VersionConfig(conf.parse, conf.serialize, conf.search, conf.replace, conf.parts) + current_version = version_config.parse(conf.current_version) + new_version = current_version.bump("minor", version_config.order) + datafile_config = FileConfig( + filename=str(config_path), + key_path="tool.bumpversion.current_version", + search=conf.search, + replace=conf.replace, + regex=conf.regex, + ignore_missing_version=conf.ignore_missing_version, + serialize=conf.serialize, + parse=conf.parse, + ) + + # Act + files.DataFileUpdater(datafile_config, version_config.part_configs).update_file( + current_version, new_version, get_context(conf) + ) + + # Assert + contents_after = config_path.read_text() + toml_data = tomlkit.parse(config_path.read_text()).unwrap() + actual_difference = list( + context_diff( + contents_before.splitlines(), + contents_after.splitlines(), + fromfile="before", + tofile="after", + n=0, + lineterm="", + ) + ) + expected_difference = [ + "*** before", + "--- after", + "***************", + "*** 28 ****", + '! current_version = "0.0.2"', + "--- 28 ----", + '! current_version = "0.1.0"', + ] + assert actual_difference == expected_difference + assert toml_data["tool"]["pdm"]["dev-dependencies"]["lint"] == ["ruff==0.0.292"] + assert toml_data["tool"]["bumpversion"]["current_version"] == "0.1.0" diff --git a/tests/test_scm.py b/tests/test_scm.py index a272672a..ed0bab0a 100644 --- a/tests/test_scm.py +++ b/tests/test_scm.py @@ -7,7 +7,7 @@ from bumpversion import scm from bumpversion.exceptions import DirtyWorkingDirectoryError -from bumpversion.logging import setup_logging +from bumpversion.ui import setup_logging from tests.conftest import get_config_data, inside_dir diff --git a/tests/test_version_part.py b/tests/test_version_part.py index 559cb1e9..085101ed 100644 --- a/tests/test_version_part.py +++ b/tests/test_version_part.py @@ -5,7 +5,8 @@ from click import UsageError from pytest import LogCaptureFixture, param -from bumpversion import config, exceptions +from bumpversion.config.models import VersionPartConfig +from bumpversion import exceptions from bumpversion.utils import get_context from bumpversion.version_part import VersionPart from tests.conftest import get_config_data, inside_dir @@ -21,9 +22,9 @@ def version_part_config(request): """Return a three-part and a two-part version part configuration.""" if request.param is None: - return config.VersionPartConfig() + return VersionPartConfig() else: - return config.VersionPartConfig(values=request.param) + return VersionPartConfig(values=request.param) def test_version_part_init(version_part_config):