florisla: Only failing on Python 2; I'll rebase after 2.x is dropped.
florisla: File names can contain colons.

So using an underscore would break functionality if users actually have such file names.

That's why I went for the underscore approach.

Not sure if there is a better way...
ekohl: I guess one way to parse it is:
```python
section_name = 'bumpversion:file_identifier:path/to/file'
try:
    _, section_prefix, section_value = section_name_match.split(':', 3)
except ValueError:
    continue

if section_prefix == 'file' or section_prefix.startswith('file_'):
    pass
```

I also thought about `;` (so `file;identifier`) but `:` and `;` look a lot alike.
c4urself: -- there's also the json variant that's being proposed which is `file+json:<filename>` (https://github.com/c4urself/bump2version/pull/48/files)

I feel it may be (slightly) more readable to say `file+file1` and `file+file2` where the syntax is `<type>+<identifier>` and the json one could just be changed to `json(+<identifier>)?:<filename>` -- thoughts?
florisla: I would rename `file+json` from `#48` to `jsonfile`.

Agreed on the underscore not being a clear separator.  Perhaps a `#`hash is clear indication that it's a non-functional suffix?

    [bumpversion:file#first:path]
    [bumpversion:file#second:path]

I'll test if this breaks the INI parser.
florisla: Hashes are possible, but not beautiful.

    # regular
    [bumpversion:file:myfile.txt]
    # underscore is not clear
    [bumpversion:file_customized:myfile.txt]
    # hash is ugly
    [bumpversion:file#customized:myfile.txt]
    # brackets -- too many brackes
    [bumpversion:file[customized]:myfile.txt]
    # braces -- looks like a format string
    [bumpversion:file{customized}:myfile.txt]

I like how parens convey that the suffix contain optional information.

    [bumpversion:file(customized):myfile.txt]
    [bumpversion:file ( really customized ):myfile.txt]

ekohl: @florisla do you want to merge this as is, squash on merge or manually change commits for a cleaner history? Either way, it looks like there's consensus on merging this.
florisla: Thanks for reviewing! I'll rebase, squash and merge.
diff --git a/README.md b/README.md
index 4221d82..4f6fbf3 100644
--- a/README.md
+++ b/README.md
@@ -298,6 +298,11 @@ values =
 
 This configuration is in the section: `[bumpversion:file:…]`
 
+Note: The configuration file format requires each section header to be
+unique. If you want to process a certain file multiple times,
+you may append a description between parens to the `file` keyword:
+`[bumpversion:file (special one):…]`.
+
 #### `parse =`
   **default:** `(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)`
 
diff --git a/bumpversion/cli.py b/bumpversion/cli.py
index ea641de..762ec45 100644
--- a/bumpversion/cli.py
+++ b/bumpversion/cli.py
@@ -42,6 +42,16 @@ DESCRIPTION = "{}: v{} (using Python v{})".format(
 )
 VCS = [Git, Mercurial]
 
+# detect either
+# bumpversion:part:value
+# bumpversion:file:value
+# bumpversion:file(suffix):value
+# bumpversion:file ( suffix with spaces):value
+RE_DETECT_SECTION_TYPE = re.compile(
+    r"^bumpversion:"
+    r"((?P<file>file)(\s*\(\s*(?P<file_suffix>[^\):]+)\)?)?|(?P<part>part)):"
+    r"(?P<value>.+)",
+)
 
 logger_list = logging.getLogger("bumpversion.list")
 logger = logging.getLogger(__name__)
@@ -279,18 +289,18 @@ def _load_configuration(config_file, explicit_config, defaults):
 
     part_configs = {}
     files = []
-    file_or_part = re.compile("^bumpversion:(file|part):(.+)")
+
     for section_name in config.sections():
-        section_name_match = file_or_part.match(section_name)
+        section_type_match = RE_DETECT_SECTION_TYPE.match(section_name)
 
-        if not section_name_match:
+        if not section_type_match:
             continue
 
-        section_prefix, section_value = section_name_match.groups()
-
+        section_type = section_type_match.groupdict()
+        section_value = section_type.get("value")
         section_config = dict(config.items(section_name))
 
-        if section_prefix == "part":
+        if section_type.get("part"):
             ThisVersionPartConfiguration = NumericVersionPartConfiguration
 
             if "values" in section_config:
@@ -305,8 +315,7 @@ def _load_configuration(config_file, explicit_config, defaults):
             part_configs[section_value] = ThisVersionPartConfiguration(
                 **section_config
             )
-
-        elif section_prefix == "file":
+        elif section_type.get("file"):
             filename = section_value
 
             if "serialize" in section_config:

Support processing a file multiple times
For a certain file I needed to have two different serializations.

Turns out you can do that using `bumpversion.cfg` as long as the file name is written differently each time.  I'm using a `./` prefix here.

    [bumpversion:file:version.txt]
    search = Version: {current_version}
    replace = Version: {new_version}
    parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
    serialize = {major}.{minor}.{patch}

    [bumpversion:file:./version.txt]
    search = Release: {current_version}
    replace= Release: {new_version}
    parse = (?P<major>\d+)-(?P<minor>\d+)-(?P<patch>\d+)
    serialize = {major}-{minor}-{patch}

Is this something we should document?  I *think* this can solve #43 and #4, although it's perhaps not a very intuitive solution for those issues.

Thought: we could extend the `[bumpversion:file]` syntax to support multiple editions to make this more readable, like

    [bumpversion:file:version.txt:version]
    ...
    [bumpversion:file:version.txt:release]
    ....
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 71ef3a0..b1bc1a4 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -64,6 +64,17 @@ def configfile(request):
     return request.param
 
 
+@pytest.fixture(params=[
+    "file",
+    "file(suffix)",
+    "file (suffix with space)",
+    "file (suffix lacking closing paren",
+])
+def file_keyword(request):
+    """Return multiple possible styles for the bumpversion:file keyword."""
+    return request.param
+
+
 try:
     RawConfigParser(empty_lines_in_values=False)
     using_old_configparser = False
@@ -281,6 +292,22 @@ new_version: 0.10.3
     assert "0.10.3" == tmpdir.join("file2").read()
 
 
+def test_file_keyword_with_suffix_is_accepted(tmpdir, configfile, file_keyword):
+    tmpdir.join("file2").write("0.10.2")
+    tmpdir.join(configfile).write(
+        """[bumpversion]
+    current_version: 0.10.2
+    new_version: 0.10.3
+    [bumpversion:%s:file2]
+    """ % file_keyword
+    )
+
+    tmpdir.chdir()
+    main(['patch'])
+
+    assert "0.10.3" == tmpdir.join("file2").read()
+
+
 def test_multiple_config_files(tmpdir):
     tmpdir.join("file2").write("0.10.2")
     tmpdir.join("setup.cfg").write("""[bumpversion]
@@ -298,6 +325,39 @@ new_version: 0.10.4
     assert "0.10.4" == tmpdir.join("file2").read()
 
 
+def test_single_file_processed_twice(tmpdir):
+    """
+    Verify that a single file "file2" can be processed twice.
+
+    Use two file_ entries, both with a different suffix after
+    the underscore.
+    Employ different parse/serialize and search/replace configs
+    to verify correct interpretation.
+    """
+    tmpdir.join("file2").write("dots: 0.10.2\ndashes: 0-10-2")
+    tmpdir.join("setup.cfg").write("""[bumpversion]
+current_version: 0.10.2
+new_version: 0.10.3
+[bumpversion:file:file2]""")
+    tmpdir.join(".bumpversion.cfg").write(r"""[bumpversion]
+current_version: 0.10.2
+new_version: 0.10.4
+[bumpversion:file (version with dots):file2]
+search = dots: {current_version}
+replace = dots: {new_version}
+[bumpversion:file (version with dashes):file2]
+search = dashes: {current_version}
+replace = dashes: {new_version}
+parse = (?P<major>\d+)-(?P<minor>\d+)-(?P<patch>\d+)
+serialize = {major}-{minor}-{patch}
+""")
+
+    tmpdir.chdir()
+    main(['patch'])
+
+    assert "dots: 0.10.4\ndashes: 0-10-4" == tmpdir.join("file2").read()
+
+
 def test_config_file_is_updated(tmpdir):
     tmpdir.join("file3").write("0.0.13")
     tmpdir.join(".bumpversion.cfg").write("""[bumpversion]

pip install -e .
parse_log_pytest
pytest
pytest>=3.4.0 testfixtures pytest
apt-get update apt-get install -y gcc
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
-e git+https://github.com/c4urself/bump2version.git@fd54193c06fd1d6da3437151b277f7b38384debc#egg=bump2version
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
packaging @ file:///croot/packaging_1734472117206/work
pluggy @ file:///croot/pluggy_1733169602837/work
pytest @ file:///croot/pytest_1738938843180/work
testfixtures==8.3.0
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work

name: bump2version
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py39h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.2=py39h06a4308_0
  - pip=25.0=py39h06a4308_0
  - pluggy=1.5.0=py39h06a4308_0
  - pytest=8.3.4=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py39h06a4308_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - testfixtures==8.3.0
prefix: /opt/conda/envs/bump2version

tests/test_cli.py::test_file_keyword_with_suffix_is_accepted[.bumpversion.cfg-file(suffix)] tests/test_cli.py::test_file_keyword_with_suffix_is_accepted[.bumpversion.cfg-file tests/test_cli.py::test_file_keyword_with_suffix_is_accepted[setup.cfg-file(suffix)] tests/test_cli.py::test_file_keyword_with_suffix_is_accepted[setup.cfg-file tests/test_cli.py::test_single_file_processed_twice
tests/test_cli.py::test_usage_string tests/test_cli.py::test_regression_help_in_work_dir[git] tests/test_cli.py::test_regression_help_in_work_dir[hg] tests/test_cli.py::test_defaults_in_usage_with_config
tests/test_cli.py::test_usage_string_fork tests/test_cli.py::test_missing_explicit_config_file tests/test_cli.py::test_simple_replacement tests/test_cli.py::test_simple_replacement_in_utf8_file tests/test_cli.py::test_config_file tests/test_cli.py::test_default_config_files[.bumpversion.cfg] tests/test_cli.py::test_default_config_files[setup.cfg] tests/test_cli.py::test_file_keyword_with_suffix_is_accepted[.bumpversion.cfg-file] tests/test_cli.py::test_file_keyword_with_suffix_is_accepted[setup.cfg-file] tests/test_cli.py::test_multiple_config_files tests/test_cli.py::test_config_file_is_updated tests/test_cli.py::test_dry_run[git] tests/test_cli.py::test_dry_run[hg] tests/test_cli.py::test_bump_version tests/test_cli.py::test_bump_version_custom_main tests/test_cli.py::test_bump_version_custom_parse_serialize_configfile tests/test_cli.py::test_bumpversion_custom_parse_semver tests/test_cli.py::test_dirty_work_dir[git] tests/test_cli.py::test_dirty_work_dir[hg] tests/test_cli.py::test_force_dirty_work_dir[git] tests/test_cli.py::test_force_dirty_work_dir[hg] tests/test_cli.py::test_bump_major tests/test_cli.py::test_commit_and_tag[git] tests/test_cli.py::test_commit_and_tag[hg] tests/test_cli.py::test_commit_and_tag_with_configfile[git] tests/test_cli.py::test_commit_and_tag_with_configfile[hg] tests/test_cli.py::test_commit_and_not_tag_with_configfile[git-[bumpversion]\ncommit tests/test_cli.py::test_commit_and_not_tag_with_configfile[hg-[bumpversion]\ncommit tests/test_cli.py::test_commit_explicitly_false[git] tests/test_cli.py::test_commit_explicitly_false[hg] tests/test_cli.py::test_commit_configfile_true_cli_false_override[git] tests/test_cli.py::test_commit_configfile_true_cli_false_override[hg] tests/test_cli.py::test_bump_version_environment tests/test_cli.py::test_current_version_from_tag[git] tests/test_cli.py::test_current_version_from_tag_written_to_config_file[git] tests/test_cli.py::test_distance_to_latest_tag_as_part_of_new_version[git] tests/test_cli.py::test_override_vcs_current_version[git] tests/test_cli.py::test_non_existing_file tests/test_cli.py::test_non_existing_second_file tests/test_cli.py::test_read_version_tags_only[git] tests/test_cli.py::test_tag_name[git] tests/test_cli.py::test_tag_name[hg] tests/test_cli.py::test_message_from_config_file[git] tests/test_cli.py::test_message_from_config_file[hg] tests/test_cli.py::test_all_parts_in_message_and_serialize_and_tag_name_from_config_file[git] tests/test_cli.py::test_all_parts_in_message_and_serialize_and_tag_name_from_config_file[hg] tests/test_cli.py::test_all_parts_in_replace_from_config_file[git] tests/test_cli.py::test_all_parts_in_replace_from_config_file[hg] tests/test_cli.py::test_unannotated_tag[git] tests/test_cli.py::test_unannotated_tag[hg] tests/test_cli.py::test_annotated_tag[git] tests/test_cli.py::test_annotated_tag[hg] tests/test_cli.py::test_vcs_describe[git] tests/test_cli.py::test_utf8_message_from_config_file[git] tests/test_cli.py::test_utf8_message_from_config_file[hg] tests/test_cli.py::test_commit_and_tag_from_below_vcs_root[git] tests/test_cli.py::test_commit_and_tag_from_below_vcs_root[hg] tests/test_cli.py::test_non_vcs_operations_if_vcs_is_not_installed[git] tests/test_cli.py::test_non_vcs_operations_if_vcs_is_not_installed[hg] tests/test_cli.py::test_serialize_newline tests/test_cli.py::test_multiple_serialize_three_part tests/test_cli.py::test_multiple_serialize_two_part tests/test_cli.py::test_multiple_serialize_two_part_patch tests/test_cli.py::test_multiple_serialize_two_part_patch_configfile tests/test_cli.py::test_log_no_config_file_info_message tests/test_cli.py::test_log_parse_doesnt_parse_current_version tests/test_cli.py::test_log_invalid_regex_exit tests/test_cli.py::test_complex_info_logging tests/test_cli.py::test_subjunctive_dry_run_logging[git] tests/test_cli.py::test_subjunctive_dry_run_logging[hg] tests/test_cli.py::test_log_commit_message_if_no_commit_tag_but_usable_vcs[git] tests/test_cli.py::test_log_commit_message_if_no_commit_tag_but_usable_vcs[hg] tests/test_cli.py::test_listing[git] tests/test_cli.py::test_listing[hg] tests/test_cli.py::test_no_list_no_stdout[git] tests/test_cli.py::test_no_list_no_stdout[hg] tests/test_cli.py::test_bump_non_numeric_parts tests/test_cli.py::test_optional_value_from_documentation tests/test_cli.py::test_python_pre_release_release_post_release tests/test_cli.py::test_part_first_value tests/test_cli.py::test_multi_file_configuration tests/test_cli.py::test_multi_file_configuration2 tests/test_cli.py::test_search_replace_to_avoid_updating_unconcerned_lines tests/test_cli.py::test_search_replace_expanding_changelog tests/test_cli.py::test_non_matching_search_does_not_modify_file tests/test_cli.py::test_search_replace_cli tests/test_cli.py::test_deprecation_warning_files_in_global_configuration tests/test_cli.py::test_deprecation_warning_multiple_files_cli tests/test_cli.py::test_file_specific_config_inherits_parse_serialize tests/test_cli.py::test_multi_line_search_is_found tests/test_cli.py::test_configparser_empty_lines_in_values tests/test_cli.py::test_regression_tag_name_with_hyphens[git] tests/test_cli.py::test_unclean_repo_exception[git] tests/test_cli.py::test_regression_characters_after_last_label_serialize_string tests/test_cli.py::test_regression_dont_touch_capitalization_of_keys_in_config tests/test_cli.py::test_regression_new_version_cli_in_files tests/test_cli.py::test_correct_interpolation_for_setup_cfg_files[.bumpversion.cfg] tests/test_cli.py::test_correct_interpolation_for_setup_cfg_files[setup.cfg] tests/test_cli.py::test_retain_newline[.bumpversion.cfg-\n] tests/test_cli.py::test_retain_newline[.bumpversion.cfg-\r\n] tests/test_cli.py::test_retain_newline[setup.cfg-\n] tests/test_cli.py::test_retain_newline[setup.cfg-\r\n] tests/test_cli.py::TestSplitArgsInOptionalAndPositional::test_all_optional tests/test_cli.py::TestSplitArgsInOptionalAndPositional::test_all_positional tests/test_cli.py::TestSplitArgsInOptionalAndPositional::test_no_args tests/test_cli.py::TestSplitArgsInOptionalAndPositional::test_short_optionals tests/test_cli.py::TestSplitArgsInOptionalAndPositional::test_1optional_2positional tests/test_cli.py::TestSplitArgsInOptionalAndPositional::test_2optional_1positional tests/test_cli.py::TestSplitArgsInOptionalAndPositional::test_2optional_mixed_2positional
README.md bumpversion/cli.py
README.md bumpversion/cli.py