Repository: callowayproject/bump-my-version
Pull request #94 (2023-11-18 14:26:13)

The next release of bump-my-version


Patch:
diff --git a/.github/workflows/bumpversion.yaml b/.github/workflows/bumpversion.yaml
index cc604d07..e5f2413c 100644
--- a/.github/workflows/bumpversion.yaml
+++ b/.github/workflows/bumpversion.yaml
@@ -43,12 +43,22 @@ jobs:
         if: ${{ github.event_name != 'workflow_dispatch' }}
         shell: bash
         run: |
-          if [[ $RELEASE_KIND != "no-release" ]]; then
-            bump-my-version bump -v "$RELEASE_KIND"
-            git push
-            git push --tags
-            echo "PACKAGE=true" >> $GITHUB_ENV
-          fi
+          PR_NUMBER=$(gh pr view --json number -q .number || echo "")
+          REVISION=$(git describe --tags --long | awk -F- '{print $2}')
+          export PR_NUMBER REVISION
+          case "$RELEASE_KIND" in
+            major|minor|patch)
+              bump-my-version bump "$RELEASE_KIND"
+              if [[ BUMPVERSION_DRY_RUN == "false" ]];  then
+                git push
+                git push --tags
+                echo "PACKAGE=true" >> $GITHUB_ENV
+              fi
+              ;;
+             dev)
+              echo "Intentionally not bumping version for dev release"
+              ;;
+          esac
 
       - name: Bump Version manual
         if: ${{ github.event_name == 'workflow_dispatch' }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0fe50e48..6583a7ab 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,13 +1,13 @@
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
     # Ruff version.
-    rev: 'v0.1.3'
+    rev: 'v0.1.6'
     hooks:
       - id: ruff
         args: [--fix, --exit-non-zero-on-fix]
         exclude: test.*
   - repo: https://github.com/psf/black
-    rev: 23.10.1
+    rev: 23.11.0
     hooks:
       - id: black
   - repo: https://github.com/pre-commit/pre-commit-hooks
@@ -39,11 +39,11 @@ repos:
         args: ['--baseline', '.secrets.baseline']
         additional_dependencies: ["gibberish-detector"]
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.6.1
+    rev: v1.7.1
     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:
@@ -56,6 +56,8 @@ repos:
       - id: interrogate
         exclude: test.*
   - repo: https://github.com/python-jsonschema/check-jsonschema
-    rev: 0.27.0
+    rev: 0.27.2
     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 @@ if TYPE_CHECKING:  # pragma: no-coverage
     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 click.core import Context
 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 48701fdb..00000000
--- a/bumpversion/config.py
+++ /dev/null
@@ -1,470 +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<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\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
-    tag_pattern = config.tag_name.replace("{new_version}", "*")
-    scm_info = get_scm_info(tag_pattern)
-    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<section_prefix>\\[tool\\.bumpversion]\n[^[]*current_version\\s*=\\s*)(\\"{current_version}\\")',
-        re.MULTILINE,
-    )
-    cfg_current_version_regex = re.compile(
-        f"(?P<section_prefix>\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P<version>{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<section_prefix>{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<section_prefix>"{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<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\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<section_prefix>\\[bumpversion]\n[^[]*current_version\\s*=\\s*)(?P<version>{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<section_prefix>{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 logging
 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 @@ class ConfiguredFile:
         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 @@ class ConfiguredFile:
         # 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 @@ class ConfiguredFile:
             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 @@ class ConfiguredFile:
         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 @@ class ConfiguredFile:
         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 fb1939e5..75240b2f 100644
--- a/bumpversion/scm.py
+++ b/bumpversion/scm.py
@@ -9,6 +9,8 @@ from pathlib import Path
 from tempfile import NamedTemporaryFile
 from typing import TYPE_CHECKING, ClassVar, List, MutableMapping, Optional, Type, Union
 
+from bumpversion.utils import extract_regex_flags
+
 if TYPE_CHECKING:  # pragma: no-coverage
     from bumpversion.config import Config
 
@@ -86,7 +88,7 @@ class SourceCodeManager:
         raise NotImplementedError()
 
     @classmethod
-    def latest_tag_info(cls, tag_pattern: str) -> SCMInfo:
+    def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
         """Return information about the latest tag."""
         raise NotImplementedError()
 
@@ -109,6 +111,16 @@ class SourceCodeManager:
         except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError):
             return []
 
+    @classmethod
+    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<current_version>{version_pattern})")
+        rep = f"{regex_flags}{rep}"
+        tag_regex = re.compile(rep)
+        return match["current_version"] if (match := tag_regex.match(tag)) else None
+
     @classmethod
     def commit_to_scm(
         cls,
@@ -173,7 +185,6 @@ class SourceCodeManager:
         tag_name = config.tag_name.format(**context)
         tag_message = config.tag_message.format(**context)
         existing_tags = cls.get_all_tags()
-
         do_tag = not dry_run
 
         if tag_name in existing_tags:
@@ -219,7 +230,7 @@ class Git(SourceCodeManager):
             raise DirtyWorkingDirectoryError(f"Git working directory is not clean:\n\n{joined_lines}")
 
     @classmethod
-    def latest_tag_info(cls, tag_pattern: str) -> SCMInfo:
+    def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
         """Return information about the latest tag."""
         try:
             # git-describe doesn't update the git-index, so we do that
@@ -227,7 +238,7 @@ class Git(SourceCodeManager):
         except subprocess.CalledProcessError as e:
             logger.debug("Error when running git update-index: %s", e.stderr)
             return SCMInfo(tool=cls)
-
+        tag_pattern = tag_name.replace("{new_version}", "*")
         try:
             # get info about the latest tag in git
             git_cmd = [
@@ -260,7 +271,8 @@ class Git(SourceCodeManager):
 
         info.commit_sha = describe_out.pop().lstrip("g")
         info.distance_to_latest_tag = int(describe_out.pop())
-        info.current_version = "-".join(describe_out).lstrip("v")
+        version = cls.get_version_from_tag(describe_out[-1], tag_name, parse_pattern)
+        info.current_version = version or "-".join(describe_out).lstrip("v")
 
         return info
 
@@ -298,10 +310,10 @@ class Mercurial(SourceCodeManager):
     _ALL_TAGS_COMMAND: ClassVar[List[str]] = ["hg", "log", '--rev="tag()"', '--template="{tags}\n"']
 
     @classmethod
-    def latest_tag_info(cls, tag_pattern: str) -> SCMInfo:
+    def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
         """Return information about the latest tag."""
         current_version = None
-        re_pattern = tag_pattern.replace("*", ".*")
+        re_pattern = tag_name.replace("{new_version}", ".*")
         result = subprocess.run(
             ["hg", "log", "-r", f"tag('re:{re_pattern}')", "--template", "{latesttag}\n"],  # noqa: S603, S607
             text=True,
@@ -310,7 +322,10 @@ class Mercurial(SourceCodeManager):
         )
         result.check_returncode()
         if result.stdout:
-            current_version = result.stdout.splitlines(keepends=False)[-1].lstrip("v")
+            tag_string = result.stdout.splitlines(keepends=False)[-1]
+            current_version = cls.get_version_from_tag(tag_string, tag_name, parse_pattern)
+        else:
+            logger.debug("No tags found")
         is_dirty = len(subprocess.check_output(["hg", "status", "-mard"])) != 0  # noqa: S603, S607
         return SCMInfo(tool=cls, current_version=current_version, dirty=is_dirty)
 
@@ -356,11 +371,11 @@ class Mercurial(SourceCodeManager):
         subprocess.check_output(command)  # noqa: S603
 
 
-def get_scm_info(tag_pattern: str) -> SCMInfo:
+def get_scm_info(tag_name: str, parse_pattern: str) -> SCMInfo:
     """Return a dict with the latest source code management info."""
     if Git.is_usable():
-        return Git.latest_tag_info(tag_pattern)
+        return Git.latest_tag_info(tag_name, parse_pattern)
     elif Mercurial.is_usable():
-        return Mercurial.latest_tag_info(tag_pattern)
+        return Mercurial.latest_tag_info(tag_name, parse_pattern)
     else:
         return SCMInfo()
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.bump import get_next_version
 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 typing import Any, Dict, List, MutableMapping, Optional, Union
 
 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 @@ class VersionConfig:
 
         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",


Test patch:
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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[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<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[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<major>\\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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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<major>\\\\d+)\\\\.(?P<minor>\\\\d+)\\\\.(?P<patch>\\\\d+)(\\\\-(?P<release>[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 @@ import pytest
 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 @@ TOML_EXPECTED_DIFF = (
 
 
 @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<major>\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 c21f8170..ed0bab0a 100644
--- a/tests/test_scm.py
+++ b/tests/test_scm.py
@@ -7,7 +7,7 @@ from pytest import param, LogCaptureFixture
 
 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
 
 
@@ -43,15 +43,17 @@ def test_git_latest_tag_info(git_repo: Path) -> None:
     """Should return information about the latest tag."""
     readme = git_repo.joinpath("readme.md")
     readme.touch()
-
+    tag_prefix = "app/"
+    parse_pattern = r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
+    tag_name = f"{tag_prefix}{{new_version}}"
     with inside_dir(git_repo):
-        assert scm.Git.latest_tag_info("v*") == scm.SCMInfo(tool=scm.Git)
+        assert scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern) == scm.SCMInfo(tool=scm.Git)
 
         # Add a file and tag
         subprocess.run(["git", "add", "readme.md"])
         subprocess.run(["git", "commit", "-m", "first"])
-        subprocess.run(["git", "tag", "v0.1.0"])
-        tag_info = scm.Git.latest_tag_info("v*")
+        subprocess.run(["git", "tag", f"{tag_prefix}0.1.0"])
+        tag_info = scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern)
         assert tag_info.commit_sha is not None
         assert tag_info.current_version == "0.1.0"
         assert tag_info.distance_to_latest_tag == 0
@@ -59,7 +61,7 @@ def test_git_latest_tag_info(git_repo: Path) -> None:
         # Make it dirty
         git_repo.joinpath("something.md").touch()
         subprocess.run(["git", "add", "something.md"])
-        tag_info = scm.Git.latest_tag_info("v*")
+        tag_info = scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern)
         assert tag_info.commit_sha is not None
         assert tag_info.current_version == "0.1.0"
         assert tag_info.distance_to_latest_tag == 0
@@ -137,7 +139,7 @@ def test_commit_and_tag_from_below_scm_root(repo: str, scm_command: str, scm_cla
             scm_class.tag_in_scm(config=conf, context=context)
 
         # Assert
-        tag_info = scm_class.latest_tag_info("v*")
+        tag_info = scm_class.latest_tag_info(conf.tag_name, parse_pattern=conf.parse)
         if scm_command == "git":
             assert tag_info.commit_sha is not None
             assert tag_info.distance_to_latest_tag == 0
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 @@ import pytest
 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 @@ from tests.conftest import get_config_data, inside_dir
 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):