Repository: callowayproject/bump-my-version (python)

Issue:
Support for CalVer
For some projects (esp. continuous release internal projects) it would be quite useful to use `bump-my-version` to generate versions in `YYYY-0M-0D-0h-0m` and other similar calendar-based formats.  See https://calver.org

We do CI/CD with multiple releases each day but tools like Sentry can use releases to understand when bugs were first created and when they were resolved.  See https://docs.sentry.io/product/releases

 We could do this with `major.minor.patch`  but we sense that we would churn through a lot of patch IDs and would constantly be mapping between version numbers and release dates.  CalVer is more self-documenting for internal DevOps purposes.

Pull request:
Adds first-class support for Calendar Versioning (CalVer)
This adds direct support for CalVer Versioning schemes.

Fixes #160 

Patch:
diff --git a/README.md b/README.md
index 5bb5ca38..09b971d9 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ Bump My Version's purpose is to:
     - Incrementing version numbers
     - serializing version numbers
     - parsing version numbers
+    - supporting SemVer, CalVer, and other versioning schemes
 - Modify project files as part of the project's development life cycle
 - Work with the project's source control system
     - Committing changes
diff --git a/bumpversion/bump.py b/bumpversion/bump.py
index eb6f1e99..fba99928 100644
--- a/bumpversion/bump.py
+++ b/bumpversion/bump.py
@@ -11,9 +11,10 @@ if TYPE_CHECKING:  # pragma: no-coverage
 from bumpversion.config import Config
 from bumpversion.config.files import update_config_file
 from bumpversion.config.files_legacy import update_ini_config_file
+from bumpversion.context import get_context
 from bumpversion.exceptions import ConfigurationError
 from bumpversion.ui import get_indented_logger
-from bumpversion.utils import get_context, key_val_string
+from bumpversion.utils import key_val_string
 
 logger = get_indented_logger(__name__)
 
@@ -96,7 +97,7 @@ 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)
     if config_file and config_file.suffix in {".cfg", ".ini"}:
-        update_ini_config_file(config_file, config.current_version, next_version_str, dry_run)
+        update_ini_config_file(config_file, config.current_version, next_version_str, dry_run)  # pragma: no-coverage
     else:
         update_config_file(config_file, config, version, next_version, ctx, dry_run)
 
diff --git a/bumpversion/cli.py b/bumpversion/cli.py
index 8798fef5..78de24f3 100644
--- a/bumpversion/cli.py
+++ b/bumpversion/cli.py
@@ -13,10 +13,11 @@ from bumpversion.bump import do_bump
 from bumpversion.config import get_configuration
 from bumpversion.config.create import create_configuration
 from bumpversion.config.files import find_config_file
+from bumpversion.context import get_context
 from bumpversion.files import ConfiguredFile, modify_files
 from bumpversion.show import do_show
 from bumpversion.ui import get_indented_logger, print_info, setup_logging
-from bumpversion.utils import get_context, get_overrides
+from bumpversion.utils import get_overrides
 from bumpversion.visualize import visualize
 
 logger = get_indented_logger(__name__)
@@ -26,7 +27,7 @@ logger = get_indented_logger(__name__)
     context_settings={
         "help_option_names": ["-h", "--help"],
     },
-    add_help_option=False,
+    add_help_option=True,
 )
 @click.version_option(version=__version__)
 @click.pass_context
diff --git a/bumpversion/context.py b/bumpversion/context.py
new file mode 100644
index 00000000..1064bccd
--- /dev/null
+++ b/bumpversion/context.py
@@ -0,0 +1,110 @@
+"""Context for rendering messages and tags."""
+
+import calendar
+import datetime
+from collections import ChainMap
+from dataclasses import asdict
+from typing import TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:  # pragma: no-coverage
+    from bumpversion.config import Config
+    from bumpversion.scm import SCMInfo
+    from bumpversion.versioning.models import Version
+
+MONTH_ABBREVIATIONS = [abbr for abbr in calendar.month_abbr if abbr]
+
+CALVER_PATTERN_MAP = {
+    "{YYYY}": r"(?:[1-9][0-9]{3})",
+    "{YY}": r"(?:[1-9][0-9]?)",
+    "{0Y}": r"(?:[0-9]{2})",
+    "{MMM}": f'(?:{"|".join(MONTH_ABBREVIATIONS)})',
+    "{MM}": r"(?:1[0-2]|[1-9])",
+    "{0M}": r"(?:1[0-2]|0[1-9])",
+    "{DD}": r"(?:3[0-1]|[1-2][0-9]|[1-9])",
+    "{0D}": r"(?:3[0-1]|[1-2][0-9]|0[1-9])",
+    "{JJJ}": r"(?:36[0-6]|3[0-5][0-9]|[1-2][0-9][0-9]|[1-9][0-9]|[1-9])",
+    "{00J}": r"(?:36[0-6]|3[0-5][0-9]|[1-2][0-9][0-9]|0[1-9][0-9]|00[1-9])",
+    "{Q}": r"(?:[1-4])",
+    "{WW}": r"(?:5[0-3]|[1-4][0-9]|[0-9])",
+    "{0W}": r"(?:5[0-3]|[0-4][0-9])",
+    "{UU}": r"(?:5[0-3]|[1-4][0-9]|[0-9])",
+    "{0U}": r"(?:5[0-3]|[0-4][0-9])",
+    "{VV}": r"(?:5[0-3]|[1-4][0-9]|[1-9])",
+    "{0V}": r"(?:5[0-3]|[1-4][0-9]|0[1-9])",
+    "{GGGG}": r"(?:[1-9][0-9]{3})",
+    "{GG}": r"(?:[0-9][0-9]?)",
+    "{0G}": r"(?:[0-9]{2})",
+    "{INC0}": r"(?:[0-9]+)",
+    "{INC1}": r"[1-9][0-9]*",
+}
+
+
+def calver_string_to_regex(calver_format: str) -> str:
+    """Convert the calver format string to a regex pattern."""
+    pattern = calver_format
+    for key, value in CALVER_PATTERN_MAP.items():
+        pattern = pattern.replace(key, value)
+    return pattern
+
+
+def prefixed_environ() -> dict:
+    """Return a dict of the environment with keys wrapped in `${}`."""
+    import os
+
+    return {f"${key}": value for key, value in os.environ.items()}
+
+
+def base_context(scm_info: Optional["SCMInfo"] = None) -> ChainMap:
+    """The default context for rendering messages and tags."""
+    from bumpversion.scm import SCMInfo  # Including this here to avoid circular imports
+
+    scm = asdict(scm_info) if scm_info else asdict(SCMInfo())
+
+    return ChainMap(
+        {
+            "now": datetime.datetime.now(),
+            "utcnow": datetime.datetime.now(datetime.timezone.utc),
+        },
+        prefixed_environ(),
+        scm,
+        {c: c for c in ("#", ";")},
+    )
+
+
+def get_context(
+    config: "Config", current_version: Optional["Version"] = None, new_version: Optional["Version"] = None
+) -> ChainMap:
+    """Return the context for rendering messages and tags."""
+    ctx = base_context(config.scm_info)
+    ctx = ctx.new_child({"current_version": config.current_version})
+    if current_version:
+        ctx = ctx.new_child({f"current_{part}": current_version[part].value for part in current_version})
+    if new_version:
+        ctx = ctx.new_child({f"new_{part}": new_version[part].value for part in new_version})
+    return ctx
+
+
+def get_datetime_info(current_dt: datetime.datetime) -> dict:
+    """Return the full structure of the given datetime for formatting."""
+    return {
+        "YYYY": current_dt.strftime("%Y"),
+        "YY": current_dt.strftime("%y").lstrip("0") or "0",
+        "0Y": current_dt.strftime("%y"),
+        "MMM": current_dt.strftime("%b"),
+        "MM": str(current_dt.month),
+        "0M": current_dt.strftime("%m"),
+        "DD": str(current_dt.day),
+        "0D": current_dt.strftime("%d"),
+        "JJJ": current_dt.strftime("%j").lstrip("0"),
+        "00J": current_dt.strftime("%j"),
+        "Q": str((current_dt.month - 1) // 3 + 1),
+        "WW": current_dt.strftime("%W").lstrip("0") or "0",
+        "0W": current_dt.strftime("%W"),
+        "UU": current_dt.strftime("%U").lstrip("0") or "0",
+        "0U": current_dt.strftime("%U"),
+        "VV": current_dt.strftime("%V").lstrip("0") or "0",
+        "0V": current_dt.strftime("%V"),
+        "GGGG": current_dt.strftime("%G"),
+        "GG": current_dt.strftime("%G")[2:].lstrip("0") or "0",
+        "0G": current_dt.strftime("%G")[2:],
+    }
diff --git a/bumpversion/show.py b/bumpversion/show.py
index 439acf66..fca1a234 100644
--- a/bumpversion/show.py
+++ b/bumpversion/show.py
@@ -7,9 +7,10 @@ from typing import Any, Optional
 
 from bumpversion.bump import get_next_version
 from bumpversion.config import Config
+from bumpversion.context import get_context
 from bumpversion.exceptions import BadInputError
 from bumpversion.ui import print_error, print_info
-from bumpversion.utils import get_context, recursive_sort_dict
+from bumpversion.utils import recursive_sort_dict
 
 
 def output_default(value: dict) -> None:
diff --git a/bumpversion/utils.py b/bumpversion/utils.py
index 9cab66df..6a9aa6a2 100644
--- a/bumpversion/utils.py
+++ b/bumpversion/utils.py
@@ -1,15 +1,7 @@
 """General utilities."""
 
-import datetime
 import string
-from collections import ChainMap
-from dataclasses import asdict
-from typing import TYPE_CHECKING, Any, List, Optional, Tuple
-
-if TYPE_CHECKING:  # pragma: no-coverage
-    from bumpversion.config import Config
-    from bumpversion.scm import SCMInfo
-    from bumpversion.versioning.models import Version
+from typing import Any, List, Tuple
 
 
 def extract_regex_flags(regex_pattern: str) -> Tuple[str, str]:
@@ -42,48 +34,11 @@ def key_val_string(d: dict) -> str:
     return ", ".join(f"{k}={v}" for k, v in sorted(d.items()))
 
 
-def prefixed_environ() -> dict:
-    """Return a dict of the environment with keys wrapped in `${}`."""
-    import os
-
-    return {f"${key}": value for key, value in os.environ.items()}
-
-
 def labels_for_format(serialize_format: str) -> List[str]:
     """Return a list of labels for the given serialize_format."""
     return [item[1] for item in string.Formatter().parse(serialize_format) if item[1]]
 
 
-def base_context(scm_info: Optional["SCMInfo"] = None) -> ChainMap:
-    """The default context for rendering messages and tags."""
-    from bumpversion.scm import SCMInfo  # Including this here to avoid circular imports
-
-    scm = asdict(scm_info) if scm_info else asdict(SCMInfo())
-
-    return ChainMap(
-        {
-            "now": datetime.datetime.now(),
-            "utcnow": datetime.datetime.now(datetime.timezone.utc),
-        },
-        prefixed_environ(),
-        scm,
-        {c: c for c in ("#", ";")},
-    )
-
-
-def get_context(
-    config: "Config", current_version: Optional["Version"] = None, new_version: Optional["Version"] = None
-) -> ChainMap:
-    """Return the context for rendering messages and tags."""
-    ctx = base_context(config.scm_info)
-    ctx = ctx.new_child({"current_version": config.current_version})
-    if current_version:
-        ctx = ctx.new_child({f"current_{part}": current_version[part].value for part in current_version})
-    if new_version:
-        ctx = ctx.new_child({f"new_{part}": new_version[part].value for part in new_version})
-    return ctx
-
-
 def get_overrides(**kwargs) -> dict:
     """Return a dictionary containing only the overridden key-values."""
     return {key: val for key, val in kwargs.items() if val is not None}
diff --git a/bumpversion/versioning/functions.py b/bumpversion/versioning/functions.py
index 7a9cff5c..699f4acf 100644
--- a/bumpversion/versioning/functions.py
+++ b/bumpversion/versioning/functions.py
@@ -1,8 +1,11 @@
 """Generators for version parts."""
 
+import datetime
 import re
 from typing import List, Optional, Union
 
+from bumpversion.context import get_datetime_info
+
 
 class PartFunction:
     """Base class for a version part function."""
@@ -10,6 +13,7 @@ class PartFunction:
     first_value: str
     optional_value: str
     independent: bool
+    always_increment: bool
 
     def bump(self, value: str) -> str:
         """Increase the value."""
@@ -29,12 +33,29 @@ class IndependentFunction(PartFunction):
         self.first_value = str(value)
         self.optional_value = str(value)
         self.independent = True
+        self.always_increment = False
 
     def bump(self, value: Optional[str] = None) -> str:
         """Return the optional value."""
         return value or self.optional_value
 
 
+class CalVerFunction(PartFunction):
+    """This is a class that provides a CalVer function for version parts."""
+
+    def __init__(self, calver_format: str):
+        self.independent = False
+        self.calver_format = calver_format
+        self.first_value = self.bump()
+        self.optional_value = "There isn't an optional value for CalVer."
+        self.independent = False
+        self.always_increment = True
+
+    def bump(self, value: Optional[str] = None) -> str:
+        """Return the optional value."""
+        return self.calver_format.format(**get_datetime_info(datetime.datetime.now()))
+
+
 class NumericFunction(PartFunction):
     """
     This is a class that provides a numeric function for version parts.
@@ -58,6 +79,7 @@ class NumericFunction(PartFunction):
         self.first_value = str(first_value or 0)
         self.optional_value = str(optional_value or self.first_value)
         self.independent = False
+        self.always_increment = False
 
     def bump(self, value: Union[str, int]) -> str:
         """Increase the first numerical value by one."""
diff --git a/bumpversion/versioning/models.py b/bumpversion/versioning/models.py
index a37d3d99..ac0069ee 100644
--- a/bumpversion/versioning/models.py
+++ b/bumpversion/versioning/models.py
@@ -3,13 +3,14 @@
 from __future__ import annotations
 
 from collections import defaultdict, deque
-from typing import Any, Dict, List, Optional, Union
+from itertools import chain
+from typing import Any, Dict, List, Optional, Tuple, Union
 
-from pydantic import BaseModel
+from pydantic import BaseModel, model_validator
 
 from bumpversion.exceptions import InvalidVersionPartError
 from bumpversion.utils import key_val_string
-from bumpversion.versioning.functions import NumericFunction, PartFunction, ValuesFunction
+from bumpversion.versioning.functions import CalVerFunction, NumericFunction, PartFunction, ValuesFunction
 
 
 class VersionComponent:
@@ -26,18 +27,25 @@ class VersionComponent:
         optional_value: Optional[str] = None,
         first_value: Union[str, int, None] = None,
         independent: bool = False,
+        always_increment: bool = False,
+        calver_format: Optional[str] = None,
         source: Optional[str] = None,
         value: Union[str, int, None] = None,
     ):
         self._value = str(value) if value is not None else None
         self.func: Optional[PartFunction] = None
-        self.independent = independent
+        self.always_increment = always_increment
+        self.independent = True if always_increment else independent
         self.source = source
+        self.calver_format = calver_format
         if values:
             str_values = [str(v) for v in values]
             str_optional_value = str(optional_value) if optional_value is not None else None
             str_first_value = str(first_value) if first_value is not None else None
             self.func = ValuesFunction(str_values, str_optional_value, str_first_value)
+        elif calver_format:
+            self.func = CalVerFunction(calver_format)
+            self._value = self._value or self.func.first_value
         else:
             self.func = NumericFunction(optional_value, first_value or "0")
 
@@ -53,6 +61,8 @@ class VersionComponent:
             optional_value=self.func.optional_value,
             first_value=self.func.first_value,
             independent=self.independent,
+            always_increment=self.always_increment,
+            calver_format=self.calver_format,
             source=self.source,
             value=self._value,
         )
@@ -101,13 +111,39 @@ class VersionComponentSpec(BaseModel):
     This is used to read in the configuration from the bumpversion config file.
     """
 
-    values: Optional[list] = None  # Optional. Numeric is used if missing or no items in list
+    values: Optional[list] = None
+    """The possible values for the component. If it and `calver_format` is None, the component is numeric."""
+
     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
+    """The value that is optional to include in the version.
+
+    - Defaults to first value in values or 0 in the case of numeric.
+    - Empty string means nothing is optional.
+    - CalVer components ignore this."""
+
+    first_value: Union[str, int, None] = None
+    """The first value to increment from."""
+
     independent: bool = False
+    """Is the component independent of the other components?"""
+
+    always_increment: bool = False
+    """Should the component always increment, even if it is not necessary?"""
+
+    calver_format: Optional[str] = None
+    """The format string for a CalVer component."""
+
     # source: Optional[str] = None  # Name of environment variable or context variable to use as the source for value
-    depends_on: Optional[str] = None  # The name of the component this component depends on
+    depends_on: Optional[str] = None
+    """The name of the component this component depends on."""
+
+    @model_validator(mode="before")
+    @classmethod
+    def set_always_increment_with_calver(cls, data: Any) -> Any:
+        """Set always_increment to True if calver_format is present."""
+        if isinstance(data, dict) and data.get("calver_format"):
+            data["always_increment"] = True
+        return data
 
     def create_component(self, value: Union[str, int, None] = None) -> VersionComponent:
         """Generate a version component from the configuration."""
@@ -116,6 +152,8 @@ class VersionComponentSpec(BaseModel):
             optional_value=self.optional_value,
             first_value=self.first_value,
             independent=self.independent,
+            always_increment=self.always_increment,
+            calver_format=self.calver_format,
             # source=self.source,
             value=value,
         )
@@ -136,6 +174,7 @@ class VersionSpec:
         self.order = order
         self.dependency_map = defaultdict(list)
         previous_component = self.order[0]
+        self.always_increment = [name for name, config in self.component_configs.items() if config.always_increment]
         for component in self.order[1:]:
             if self.component_configs[component].independent:
                 continue
@@ -209,12 +248,35 @@ class Version:
         if component_name not in self.components:
             raise InvalidVersionPartError(f"No part named {component_name!r}")
 
-        components_to_reset = self.version_spec.get_dependents(component_name)
-
         new_values = dict(self.components.items())
-        new_values[component_name] = self.components[component_name].bump()
+        always_incr_values, components_to_reset = self._always_increment()
+        new_values.update(always_incr_values)
+
+        if component_name not in components_to_reset:
+            new_values[component_name] = self.components[component_name].bump()
+            components_to_reset |= set(self.version_spec.get_dependents(component_name))
+
         for component in components_to_reset:
             if not self.components[component].is_independent:
                 new_values[component] = self.components[component].null()
 
         return Version(self.version_spec, new_values, self.original)
+
+    def _always_incr_dependencies(self) -> dict:
+        """Return the components that always increment and depend on the given component."""
+        return {name: self.version_spec.get_dependents(name) for name in self.version_spec.always_increment}
+
+    def _increment_always_incr(self) -> dict:
+        """Increase the values of the components that always increment."""
+        components = self.version_spec.always_increment
+        return {name: self.components[name].bump() for name in components}
+
+    def _always_increment(self) -> Tuple[dict, set]:
+        """Return the components that always increment and their dependents."""
+        values = self._increment_always_incr()
+        dependents = self._always_incr_dependencies()
+        for component_name, value in values.items():
+            if value == self.components[component_name]:
+                dependents.pop(component_name, None)
+        unique_dependents = set(chain.from_iterable(dependents.values()))
+        return values, unique_dependents
diff --git a/bumpversion/visualize.py b/bumpversion/visualize.py
index d9c959f5..6d7c6d34 100644
--- a/bumpversion/visualize.py
+++ b/bumpversion/visualize.py
@@ -5,9 +5,9 @@ from typing import List, Optional
 
 from bumpversion.bump import get_next_version
 from bumpversion.config import Config
+from bumpversion.context import base_context, get_context
 from bumpversion.exceptions import BumpVersionError
 from bumpversion.ui import print_info
-from bumpversion.utils import base_context, get_context
 
 BOX_CHARS = {
     "ascii": ["+", "+", "+", "+", "+", "+", "+", "+", "-", "|", "+"],
diff --git a/docsrc/howtos/calver.md b/docsrc/howtos/calver.md
new file mode 100644
index 00000000..3a63a57d
--- /dev/null
+++ b/docsrc/howtos/calver.md
@@ -0,0 +1,79 @@
+# Using Calendar Versioning (CalVer)
+
+Calendar Versioning (CalVer) is a versioning scheme that uses a date-based version number. 
+
+For this example, we will use the following format: `YYYY.MM.DD.patch`. It will yield numbers like:
+
+- `2022.2.1` for the first patch of February 1, 2022
+- `2022.2.1.1` for the second patch of February 1, 2022
+
+
+## Initial configuration
+
+```toml title=".bumpversion.toml"
+[tool.bumpversion]
+current_version = "2024.3.1.4"
+parse = """(?x)                     # Verbose mode
+    (?P<release>                    # The release part
+        (?:[1-9][0-9]{3})\\.        # YYYY.
+        (?:1[0-2]|[1-9])\\.         # MM.
+        (?:3[0-1]|[1-2][0-9]|[1-9]) # DD
+    )
+    (?:\\.(?P<patch>\\d+))?         # .patch, optional
+"""
+serialize = ["{release}.{patch}", "{release}"]
+
+[tool.bumpversion.parts.release]
+calver_format = "{YYYY}.{MM}.{DD}"
+```
+
+You can look up the regular expressions for the CalVer format in the [CalVer reference](../reference/calver_reference.md).
+
+## Expected behavior
+
+You can find out more about the logic behind the CalVer incrementing in the [CalVer reference](../reference/calver_reference.md#calver-incrementing-logic).
+
+### Bumping the release resets the patch part
+
+When you bump the calendar version, the patch is reset to 0 _even if the release did not change._
+
+```console title="Bumping the release resets patch"
+$ date -I      
+2024-03-1
+$ bump-my-version show-bump
+2024.3.1.4 ── bump ─┬─ release ─ 2024.3.1
+                    ╰─ patch ─── 2024.3.1.5
+```
+
+The next day:
+
+```console title="Bumping the release resets patch, the next day"
+$ date -I      
+2024-03-2
+$ bump-my-version show-bump
+2024.3.1.4 ── bump ─┬─ release ─ 2024.3.2
+                    ╰─ patch ─── 2024.3.2
+```
+
+### The result of a bump to patch depends on the date
+
+Calendar Versioned parts are updated with every bump, regardless of the part being bumped. If you are bumping the version within the same time period (in this example, the same day), the `release` part will not change. So bumping the `patch` part will increment the `patch` part only.
+
+
+```console title="Bumping patch on the same day"
+$ date -I      
+2024-03-1
+$ bump-my-version show-bump
+2024.3.1.4 ── bump ─┬─ release ─ 2024.3.1
+                    ╰─ patch ─── 2024.3.1.5
+```
+
+However, if you bump the version on the next day, the `release` part will also be updated.
+
+```console title="Bumping patch on the next day"
+$ date -I      
+2024-03-2
+$ bump-my-version show-bump
+2024.3.1.4 ── bump ─┬─ release ─ 2024.3.2
+                    ╰─ patch ─── 2024.3.2
+```
diff --git a/docsrc/reference/calver_reference.md b/docsrc/reference/calver_reference.md
new file mode 100644
index 00000000..7a0c8ed3
--- /dev/null
+++ b/docsrc/reference/calver_reference.md
@@ -0,0 +1,91 @@
+# Calendar versioning reference
+
+## Calendar versioning codes
+
+The following table lists the available format codes for calendar versioning (CalVer) schemes. The codes can be used to define the version format in the `calver_format` configuration options. Formatting codes, surrounded by `{ }` can be combined to create a custom version format. For example, the format `YYYY.MM.DD` can be defined as `"{YYYY}.{MM}.{DD}"`.
+
+| Code   | Example(s)          | Comment                                       |
+|--------|---------------------|-----------------------------------------------|
+| `YYYY` | 2000, 2001, …, 2099 | Full year                                     |
+| `YY`   | 0, 1, 2, …, 99      | Short year as integer                         |
+| `0Y`   | 00, 01, 02, …, 99   | Short Year, zero-padded                       |
+| `MMM`  | Jan, Feb, jan, fév  | Month abbreviation, locale-based              |
+| `MM`   | 1, 2, …, 12         | Month as integer                              |
+| `0M`   | 01, 02, …, 12       | Month, zero-padded                            |
+| `DD`   | 1, 2, …, 31         | Day of month as integer                       |
+| `0D`   | 01, 02, …, 31       | Day of month, zero-padded                     |
+| `JJJ`  | 1, 2, 3, …, 366     | Day of year as integer                        |
+| `00J`  | 001, 002, …, 366    | Day of year, zero-padded                      |
+| `Q`    | 1, 2, 3, 4          | Quarter                                       |
+| `WW`   | 0, 1, 2, …, 53      | Week number, Monday is first day              |
+| `0W`   | 00, 01, 02, …, 53   | Week number, Monday is first day, zero-padded |
+| `UU`   | 0, 1, 2, …, 53      | Week number, Sunday is first day              |
+| `0U`   | 00, 01, 02, …, 53   | Week number, Sunday is first day, zero-padded |
+| `VV`   | 1, 2, …, 53         | ISO 8601 week number as integer               |
+| `0V`   | 01, 02, …, 53       | ISO 8601 week number, zero-padded             |
+| `GGGG` | 2000, 2001, …, 2099 | ISO 8601 week-based year                      |
+| `GG`   | 0, 1, 2, …, 99      | ISO 8601 short week-based year as integer     |
+| `0G`   | 01, 02, …, 99       | ISO 8601 short week-based year, zero-padded   |
+
+```toml title="Example configuration"
+[tool.bumpversion.parts.release]
+calver_format = "{YYYY}.{MM}.{DD}"
+```
+
+
+## Parsing CalVer versions
+
+Using the following chart, we can set up the version parsing:
+
+| Code   | Regex                                                             |
+|--------|-------------------------------------------------------------------|
+| `YYYY` | `(?:[1-9][0-9]{3})`                                               |
+| `YY`   | `(?:[1-9][0-9]?)`                                                 |
+| `0Y`   | `(?:[0-9]{2})`                                                    |
+| `MMM`  | See below                                                         |
+| `MM`   | `(?:1[0-2]\|[1-9])`                                               |
+| `0M`   | `(?:1[0-2]\|0[1-9])`                                              |
+| `DD`   | `(?:3[0-1]\|[1-2][0-9]\|[1-9])`                                   |
+| `0D`   | `(?:3[0-1]\|[1-2][0-9]\|0[1-9])`                                  |
+| `JJJ`  | `(?:36[0-6]\|3[0-5][0-9]\|[1-2][0-9][0-9]\|[1-9][0-9]\|[1-9])`    |
+| `00J`  | `(?:36[0-6]\|3[0-5][0-9]\|[1-2][0-9][0-9]\|0[1-9][0-9]\|00[1-9])` |
+| `Q`    | `(?:[1-4])`                                                       |
+| `WW`   | `(?:5[0-3]\|[1-4][0-9]\|[0-9])`                                   |
+| `0W`   | `(?:5[0-3]\|[0-4][0-9])`                                          |
+| `UU`   | `(?:5[0-3]\|[1-4][0-9]\|[0-9])`                                   |
+| `0U`   | `(?:5[0-3]\|[0-4][0-9])`                                          |
+| `VV`   | `(?:5[0-3]\|[1-4][0-9]\|[1-9])`                                   |
+| `0V`   | `(?:5[0-3]\|[1-4][0-9]\|0[1-9])`                                  |
+| `GGGG` | `(?:[1-9][0-9]{3})`                                               |
+| `GG`   | `(?:[0-9][0-9]?)`                                                 |
+| `0G`   | `(?:[0-9]{2})`                                                    |
+
+!!! Note "Month abbreviations"
+
+    The month abbreviation is locale-based. Here are some examples:
+
+    `(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)` for English
+
+    `(?:jan|fév|mar|avr|mai|jui|jui|aoû|sep|oct|nov|déc)` for French
+
+You can use these regular expressions to parse CalVer versions in your project. For example, the following `parse` configuration can be used to parse a version string in the format `YYYY.MM.DD` as the `release` part of the version string:
+
+```toml
+[tool.bumpversion]
+parse = """(?x)                      # Verbose mode
+    (?P<release>
+        (?:[1-9][0-9]{3})\\.         # YYYY.
+        (?:1[0-2]|[1-9])\\.          # MM.
+        (?:3[0-1]|[1-2][0-9]|[1-9])  # DD
+    )
+"""
+```
+
+## CalVer incrementing logic
+
+- CalVer version components are marked as `always_increment` by default.
+- When bumping a version, you specify which component to increment. It is called the target component.
+- When bumping a version, the components marked as `always_increment` are incremented first.
+- If an `always_increment` component's value changed, its dependent components are marked for reset to their default values.
+- If the target component is in the set of components marked for reset, the target component is reset to its default value.
+- If the target component is not in the set of components marked for reset, the target component is incremented and its dependent components are reset to their default values.
diff --git a/docsrc/reference/configuration.md b/docsrc/reference/configuration.md
index 7e93ffa4..f856d859 100644
--- a/docsrc/reference/configuration.md
+++ b/docsrc/reference/configuration.md
@@ -506,6 +506,35 @@ When the part is reset, the value will be set to the value specified here.
 When this value is set to `True`, the part is not reset when other parts are incremented. Its incrementation is
 independent of the other parts. It is useful when you have a build number in your version that is incremented independently of the actual version.
 
+### always_increment
+
+::: field-list
+    required
+    : No
+
+    default
+    : `False` (`True` if `calver_format` is set)
+
+    type
+    : boolean
+
+When this value is set to `True`, the part is always incremented when the version is bumped, regardless of the target part.
+
+
+### calver_format
+
+::: field-list
+    required
+    : No
+    
+    default
+    : empty
+    
+    type
+    : string
+
+The `calver_format` is a string that specifies the format of the version part. It is used to determine the next value when bumping the version. The format is a string that uses the placeholders defined in the [CalVer reference](calver_reference.md#calver-format).
+
 ### Examples
 
 === "TOML"
diff --git a/pyproject.toml b/pyproject.toml
index bc465578..dfe1080c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -76,10 +76,12 @@ docs = [
 ]
 test = [
     "coverage",
+    "freezegun",
     "pre-commit",
     "pytest-cov",
     "pytest",
     "pytest-mock",
+    "pytest-sugar",
 ]
 
 [tool.setuptools.dynamic]


Test patch:
diff --git a/tests/fixtures/basic_cfg_expected.txt b/tests/fixtures/basic_cfg_expected.txt
index 4ef4dc51..c33ea7db 100644
--- a/tests/fixtures/basic_cfg_expected.txt
+++ b/tests/fixtures/basic_cfg_expected.txt
@@ -41,22 +41,30 @@
  'included_paths': [],
  'message': 'Bump version: {current_version} → {new_version}',
  'parse': '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?',
- 'parts': {'major': {'depends_on': None,
+ 'parts': {'major': {'always_increment': False,
+                     'calver_format': None,
+                     'depends_on': None,
                      'first_value': None,
                      'independent': False,
                      'optional_value': None,
                      'values': None},
-           'minor': {'depends_on': None,
+           'minor': {'always_increment': False,
+                     'calver_format': None,
+                     'depends_on': None,
                      'first_value': None,
                      'independent': False,
                      'optional_value': None,
                      'values': None},
-           'patch': {'depends_on': None,
+           'patch': {'always_increment': False,
+                     'calver_format': None,
+                     'depends_on': None,
                      'first_value': None,
                      'independent': False,
                      'optional_value': None,
                      'values': None},
-           'release': {'depends_on': None,
+           'release': {'always_increment': False,
+                       'calver_format': None,
+                       'depends_on': None,
                        'first_value': None,
                        'independent': False,
                        'optional_value': 'gamma',
diff --git a/tests/fixtures/basic_cfg_expected.yaml b/tests/fixtures/basic_cfg_expected.yaml
index ef6ba4be..cad4c496 100644
--- a/tests/fixtures/basic_cfg_expected.yaml
+++ b/tests/fixtures/basic_cfg_expected.yaml
@@ -49,24 +49,32 @@ message: "Bump version: {current_version} → {new_version}"
 parse: "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?"
 parts:
   major:
+    always_increment: false
+    calver_format: null
     depends_on: null
     first_value: null
     independent: false
     optional_value: null
     values: null
   minor:
+    always_increment: false
+    calver_format: null
     depends_on: null
     first_value: null
     independent: false
     optional_value: null
     values: null
   patch:
+    always_increment: false
+    calver_format: null
     depends_on: null
     first_value: null
     independent: false
     optional_value: null
     values: null
   release:
+    always_increment: false
+    calver_format: null
     depends_on: null
     first_value: null
     independent: false
diff --git a/tests/fixtures/basic_cfg_expected_full.json b/tests/fixtures/basic_cfg_expected_full.json
index c1e3a4c7..eb2bc2aa 100644
--- a/tests/fixtures/basic_cfg_expected_full.json
+++ b/tests/fixtures/basic_cfg_expected_full.json
@@ -58,6 +58,8 @@
   "parse": "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(\\-(?P<release>[a-z]+))?",
   "parts": {
     "major": {
+      "always_increment": false,
+      "calver_format": null,
       "depends_on": null,
       "first_value": null,
       "independent": false,
@@ -65,6 +67,8 @@
       "values": null
     },
     "minor": {
+      "always_increment": false,
+      "calver_format": null,
       "depends_on": null,
       "first_value": null,
       "independent": false,
@@ -72,6 +76,8 @@
       "values": null
     },
     "patch": {
+      "always_increment": false,
+      "calver_format": null,
       "depends_on": null,
       "first_value": null,
       "independent": false,
@@ -79,6 +85,8 @@
       "values": null
     },
     "release": {
+      "always_increment": false,
+      "calver_format": null,
       "depends_on": null,
       "first_value": null,
       "independent": false,
diff --git a/tests/test_config/test_files.py b/tests/test_config/test_files.py
index 10cd44cc..5fbd7d2d 100644
--- a/tests/test_config/test_files.py
+++ b/tests/test_config/test_files.py
@@ -8,7 +8,7 @@ from click.testing import CliRunner, Result
 import pytest
 from pytest import LogCaptureFixture, param, TempPathFactory
 
-from bumpversion.utils import get_context
+from bumpversion.context import get_context
 from bumpversion import config
 from bumpversion.config.files import find_config_file, CONFIG_FILE_SEARCH_ORDER
 import bumpversion.config.utils
@@ -198,7 +198,7 @@ def test_pep440_config(git_repo: Path, fixtures_path: Path):
     """
     Check the PEP440 config file.
     """
-    from bumpversion.utils import get_context
+    from bumpversion.context import get_context
     from bumpversion.bump import get_next_version
     from bumpversion import cli
     import subprocess
diff --git a/tests/test_files.py b/tests/test_files.py
index 7faac753..f6bf33fd 100644
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -13,7 +13,7 @@ from pytest import param
 
 from bumpversion import exceptions, files, config, bump
 from bumpversion.config.models import FileChange
-from bumpversion.utils import get_context
+from bumpversion.context import get_context
 from bumpversion.exceptions import VersionNotFoundError
 from bumpversion.version_part import VersionConfig
 from tests.conftest import get_config_data, inside_dir
diff --git a/tests/test_version_part.py b/tests/test_version_part.py
index 8234d6a1..f6092a46 100644
--- a/tests/test_version_part.py
+++ b/tests/test_version_part.py
@@ -4,7 +4,7 @@ import pytest
 from click import UsageError
 
 from bumpversion import exceptions
-from bumpversion.utils import get_context
+from bumpversion.context import get_context
 from tests.conftest import get_config_data, inside_dir
 
 
diff --git a/tests/test_versioning/test_functions.py b/tests/test_versioning/test_functions.py
index d561d49c..93eda2e9 100644
--- a/tests/test_versioning/test_functions.py
+++ b/tests/test_versioning/test_functions.py
@@ -1,7 +1,7 @@
 import pytest
 from pytest import param
-
-from bumpversion.versioning.functions import NumericFunction, ValuesFunction, IndependentFunction
+from freezegun import freeze_time
+from bumpversion.versioning.functions import NumericFunction, ValuesFunction, IndependentFunction, CalVerFunction
 
 
 # NumericFunction
@@ -145,3 +145,66 @@ class TestIndependentFunction:
         def test_bump_with_no_value_returns_initial_value(self):
             func = IndependentFunction("1")
             assert func.bump() == "1"
+
+
+class TestCalVerFunction:
+    """The calver function manages incrementing and resetting calver version parts."""
+
+    @freeze_time("2020-05-01")
+    def test_creation_sets_first_value_and_optional_value(self):
+        func = CalVerFunction("{YYYY}.{MM}")
+        assert func.optional_value == "There isn't an optional value for CalVer."
+        assert func.first_value == "2020.5"
+        assert func.calver_format == "{YYYY}.{MM}"
+
+    @freeze_time("2020-05-01")
+    def test_bump_with_value_ignores_value(self):
+        func = CalVerFunction("{YYYY}.{MM}.{DD}")
+        assert func.bump("123456") == "2020.5.1"
+
+    @pytest.mark.parametrize(
+        ["calver", "expected"],
+        [
+            param("{YYYY}", "2002", id="{YYYY}"),
+            param("{YY}", "2", id="{YY}"),
+            param("{0Y}", "02", id="{0Y}"),
+            param("{MMM}", "May", id="{MMM}"),
+            param("{MM}", "5", id="{MM}"),
+            param("{0M}", "05", id="{0M}"),
+            param("{DD}", "1", id="{DD}"),
+            param("{0D}", "01", id="{0D}"),
+            param("{JJJ}", "121", id="{JJJ}"),
+            param("{00J}", "121", id="{00J}"),
+            param("{Q}", "2", id="{Q}"),
+            param("{WW}", "17", id="{WW}"),
+            param("{0W}", "17", id="{0W}"),
+            param("{UU}", "17", id="{UU}"),
+            param("{0U}", "17", id="{0U}"),
+            param("{VV}", "18", id="{VV}"),
+            param("{0V}", "18", id="{0V}"),
+            param("{GGGG}", "2002", id="{GGGG}"),
+            param("{GG}", "2", id="{GG}"),
+            param("{0G}", "02", id="{0G}"),
+        ],
+    )
+    @freeze_time("2002-05-01")
+    def test_calver_formatting_renders_correctly(self, calver: str, expected: str):
+        """Test that the calver is formatted correctly."""
+        func = CalVerFunction(calver)
+        assert func.bump() == expected
+
+    @pytest.mark.parametrize(
+        ["calver", "expected"],
+        [
+            param("{YYYY}", "2000", id="{YYYY}"),
+            param("{YY}", "0", id="{YY}"),
+            param("{0Y}", "00", id="{0Y}"),
+            param("{GGGG}", "1999", id="{GGGG}"),
+            param("{GG}", "99", id="{GG}"),
+            param("{0G}", "99", id="{0G}"),
+        ],
+    )
+    @freeze_time("2000-01-01")
+    def test_century_years_return_zeros(self, calver: str, expected: str):
+        func = CalVerFunction(calver)
+        assert func.bump() == expected
diff --git a/tests/test_versioning/test_models_version.py b/tests/test_versioning/test_models_version.py
index 381729d1..a1046bef 100644
--- a/tests/test_versioning/test_models_version.py
+++ b/tests/test_versioning/test_models_version.py
@@ -1,5 +1,7 @@
 """Tests for the Version class."""
 
+from freezegun import freeze_time
+
 from bumpversion.versioning.models import VersionComponentSpec
 from bumpversion.versioning.models import VersionSpec
 import pytest
@@ -7,61 +9,75 @@ from pytest import param
 
 
 @pytest.fixture
-def version_spec():
+def semver_version_spec():
     """Return a version spec."""
     config = {
         "major": VersionComponentSpec(),
         "minor": VersionComponentSpec(),
         "patch": VersionComponentSpec(),
         "build": VersionComponentSpec(optional_value="0", independent=True),
+        "auto": VersionComponentSpec(optional_value="0", independent=True, always_increment=True),
+    }
+
+    return VersionSpec(config)
+
+
+@pytest.fixture
+def calver_version_spec():
+    """Return a calver version spec."""
+    config = {
+        "release": VersionComponentSpec(calver_format="{YYYY}.{MM}.{DD}"),
+        "patch": VersionComponentSpec(),
+        "build": VersionComponentSpec(optional_value="0", independent=True),
     }
 
     return VersionSpec(config)
 
 
-class TestVersion:
-    """Test how the Version model should behave."""
+class TestSemVerVersion:
+    """Test how a SemVer-style Version model should behave."""
 
-    def test_version_acts_like_a_dict(self, version_spec: VersionSpec):
+    def test_acts_like_a_dict(self, semver_version_spec: VersionSpec):
         """You can get version components by name."""
         # Arrange
-        version = version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
+        version = semver_version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
 
         # Assert
         assert version["major"].value == "1"
         assert version["minor"].value == "2"
         assert version["patch"].value == "3"
         assert version["build"].value == "0"
+        assert version["auto"].value == "0"
 
         with pytest.raises(KeyError):
             version["invalid"]
 
-    def test_version_has_a_length(self, version_spec: VersionSpec):
+    def test_length_is_number_of_parts(self, semver_version_spec: VersionSpec):
         # Arrange
-        version = version_spec.create_version({"major": "1"})
+        version = semver_version_spec.create_version({"major": "1"})
 
         # Assert
-        assert len(version) == 4
+        assert len(version) == 5
 
-    def test_version_is_iterable(self, version_spec: VersionSpec):
+    def test_is_iterable(self, semver_version_spec: VersionSpec):
         # Arrange
-        version = version_spec.create_version({"major": "1"})
+        version = semver_version_spec.create_version({"major": "1"})
 
         # Assert
-        assert list(version) == ["major", "minor", "patch", "build"]
+        assert list(version) == ["major", "minor", "patch", "build", "auto"]
 
-    def test_version_has_a_string_representation(self, version_spec: VersionSpec):
+    def test_has_a_string_representation(self, semver_version_spec: VersionSpec):
         # Arrange
-        version = version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
+        version = semver_version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
 
         # Assert
-        assert repr(version) == "<bumpversion.Version:build=0, major=1, minor=2, patch=3>"
+        assert repr(version) == "<bumpversion.Version:auto=0, build=0, major=1, minor=2, patch=3>"
 
-    def test_version_has_an_equality_comparison(self, version_spec: VersionSpec):
+    def test_has_an_equality_comparison(self, semver_version_spec: VersionSpec):
         # Arrange
-        version1 = version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
-        version2 = version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
-        version3 = version_spec.create_version({"major": "1", "minor": "2", "patch": "4"})
+        version1 = semver_version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
+        version2 = semver_version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
+        version3 = semver_version_spec.create_version({"major": "1", "minor": "2", "patch": "4"})
 
         # Assert
         assert version1 == version2
@@ -70,10 +86,10 @@ class TestVersion:
     class TestBump:
         """Tests of the bump method."""
 
-        def test_bump_does_not_change_original_version(self, version_spec: VersionSpec):
+        def test_does_not_change_original_version(self, semver_version_spec: VersionSpec):
             """Bumping a version does not change the original version."""
             # Arrange
-            version1 = version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
+            version1 = semver_version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
 
             # Act
             version2 = version1.bump("patch")
@@ -82,10 +98,10 @@ class TestVersion:
             assert version1["patch"].value == "3"
             assert version2["patch"].value == "4"
 
-        def test_bump_returns_a_new_version(self, version_spec: VersionSpec):
+        def test_returns_a_new_version(self, semver_version_spec: VersionSpec):
             """Bumping a version returns a new version."""
             # Arrange
-            version1 = version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
+            version1 = semver_version_spec.create_version({"major": "1", "minor": "2", "patch": "3"})
 
             # Act
             version2 = version1.bump("patch")
@@ -93,10 +109,12 @@ class TestVersion:
             # Assert
             assert version1 is not version2
 
-        def test_bump_changes_component_and_dependents(self, version_spec: VersionSpec):
+        def test_changes_component_and_dependents(self, semver_version_spec: VersionSpec):
             """Bumping a version bumps the specified component and changes its dependents."""
             # Arrange
-            version1 = version_spec.create_version({"major": "1", "minor": "2", "patch": "3", "build": "4"})
+            version1 = semver_version_spec.create_version(
+                {"major": "1", "minor": "2", "patch": "3", "build": "4", "auto": "5"}
+            )
 
             # Act
             patch_version = version1.bump("patch")
@@ -109,10 +127,10 @@ class TestVersion:
             minor_version_str = ".".join([item.value for item in minor_version.components.values()])
             major_version_str = ".".join([item.value for item in major_version.components.values()])
             build_version_str = ".".join([item.value for item in build_version.components.values()])
-            assert patch_version_str == "1.2.4.4"
-            assert minor_version_str == "1.3.0.4"
-            assert major_version_str == "2.0.0.4"
-            assert build_version_str == "1.2.3.5"
+            assert patch_version_str == "1.2.4.4.6"
+            assert minor_version_str == "1.3.0.4.6"
+            assert major_version_str == "2.0.0.4.6"
+            assert build_version_str == "1.2.3.5.6"
 
     class TestRequiredComponents:
         """Tests of the required_keys function."""
@@ -130,10 +148,151 @@ class TestVersion:
                 param({"major": "0", "minor": "0", "patch": "3", "build": ""}, ["patch"], id="patch"),
             ],
         )
-        def test_returns_required_component_names(self, version_spec: VersionSpec, values: dict, expected: list):
+        def test_returns_required_component_names(
+            self, semver_version_spec: VersionSpec, values: dict, expected: list
+        ):
+            """The required_keys function returns all required keys."""
+            # Arrange
+            version = semver_version_spec.create_version(values)
+
+            # Act
+            required_components = version.required_components()
+
+            # Assert
+            assert required_components == expected
+
+
+class TestCalVerVersion:
+    """Test how a CalVer-style Version model should behave."""
+
+    @freeze_time("2020-05-01")
+    def test_acts_like_a_dict(self, calver_version_spec: VersionSpec):
+        """You can get version components by name."""
+        # Arrange
+        version = calver_version_spec.create_version({"release": "2020.4.1", "patch": "3", "build": "10"})
+
+        # Assert
+        assert version["release"].value == "2020.4.1"
+        assert version["patch"].value == "3"
+        assert version["build"].value == "10"
+
+        with pytest.raises(KeyError):
+            version["invalid"]
+
+    def test_length_is_number_of_parts(self, calver_version_spec: VersionSpec):
+        # Arrange
+        version = calver_version_spec.create_version({"release": "2020.4.1"})
+
+        # Assert
+        assert len(version) == 3
+
+    def test_version_is_iterable(self, calver_version_spec: VersionSpec):
+        # Arrange
+        version = calver_version_spec.create_version({"release": "2020.4.1"})
+
+        # Assert
+        assert list(version) == ["release", "patch", "build"]
+
+    def test_has_a_string_representation(self, calver_version_spec: VersionSpec):
+        # Arrange
+        version = calver_version_spec.create_version({"release": "2020.4.1", "patch": "3"})
+
+        # Assert
+        assert repr(version) == "<bumpversion.Version:build=0, patch=3, release=2020.4.1>"
+
+    def test_version_has_an_equality_comparison(self, calver_version_spec: VersionSpec):
+        # Arrange
+        version1 = calver_version_spec.create_version({"release": "2020.4.1", "patch": "3"})
+        version2 = calver_version_spec.create_version({"release": "2020.4.1", "patch": "3"})
+        version3 = calver_version_spec.create_version({"release": "2020.5.1"})
+
+        # Assert
+        assert version1 == version2
+        assert version1 != version3
+
+    class TestBump:
+        """Tests of the bump method."""
+
+        @freeze_time("2020-05-01")
+        def test_bump_does_not_change_original_version(self, calver_version_spec: VersionSpec):
+            """Bumping a version does not change the original version."""
+            # Arrange
+            version1 = calver_version_spec.create_version({"release": "2020.4.1", "patch": "3", "build": "10"})
+
+            # Act
+            version2 = version1.bump("patch")
+
+            # Assert
+            assert version1["patch"].value == "3"
+            assert version1["release"].value == "2020.4.1"
+            assert version2["patch"].value == "0"
+            assert version2["release"].value == "2020.5.1"
+
+        @freeze_time("2020-05-01")
+        def test_bump_returns_a_new_version(self, calver_version_spec: VersionSpec):
+            """Bumping a version returns a new version."""
+            # Arrange
+            version1 = calver_version_spec.create_version({"release": "2020.4.1", "patch": "3"})
+
+            # Act
+            version2 = version1.bump("patch")
+
+            assert version2["release"].value == "2020.5.1"
+            assert version2["patch"].value == "0"
+
+            # Assert
+            assert version1 is not version2
+
+        @freeze_time("2020-05-01")
+        def test_bump_always_increments_calver(self, calver_version_spec: VersionSpec):
+            """Bumping a version always increments the calver."""
+            # Arrange
+            version1 = calver_version_spec.create_version({"release": "2020.4.1", "patch": "3", "build": "10"})
+
+            # Act
+            version2 = version1.bump("patch")
+
+            # Assert
+            assert version2["release"].value == "2020.5.1"
+            assert version2["patch"].value == "0"
+
+        @freeze_time("2020-05-01")
+        def test_bump_changes_component_and_dependents(self, calver_version_spec: VersionSpec):
+            """Bumping a version bumps the specified component and changes its dependents."""
+            # Arrange
+            version1 = calver_version_spec.create_version({"release": "2020.5.1", "patch": "3", "build": "4"})
+
+            # Act
+            patch_version = version1.bump("patch")
+            release_version = version1.bump("release")
+            build_version = version1.bump("build")
+
+            # Assert
+            patch_version_str = ".".join([item.value for item in patch_version.components.values()])
+            release_version_str = ".".join([item.value for item in release_version.components.values()])
+            build_version_str = ".".join([item.value for item in build_version.components.values()])
+            assert patch_version_str == "2020.5.1.4.4"
+            assert release_version_str == "2020.5.1.0.4"
+            assert build_version_str == "2020.5.1.3.5"
+
+    class TestRequiredComponents:
+        """Tests of the required_keys function."""
+
+        @pytest.mark.parametrize(
+            ["values", "expected"],
+            [
+                param({"release": "2020.4.1", "patch": "3"}, ["release", "patch"], id="release-patch"),
+                param({"release": "2020.4.1", "build": "4"}, ["release", "build"], id="release-build"),
+                param({"release": "2020.4.1"}, ["release"], id="release"),
+            ],
+        )
+        @freeze_time("2020-05-01")
+        def test_returns_required_component_names(
+            self, calver_version_spec: VersionSpec, values: dict, expected: list
+        ):
             """The required_keys function returns all required keys."""
             # Arrange
-            version = version_spec.create_version(values)
+            version = calver_version_spec.create_version(values)
 
             # Act
             required_components = version.required_components()
diff --git a/tests/test_versioning/test_models_versioncomponent.py b/tests/test_versioning/test_models_versioncomponent.py
index b100d521..67510661 100644
--- a/tests/test_versioning/test_models_versioncomponent.py
+++ b/tests/test_versioning/test_models_versioncomponent.py
@@ -1,18 +1,19 @@
 """Tests of the VersionPart model."""
 
+from freezegun import freeze_time
+
 from bumpversion.versioning.models import VersionComponentSpec
-from bumpversion.versioning.functions import ValuesFunction, NumericFunction
+from bumpversion.versioning.functions import ValuesFunction, NumericFunction, CalVerFunction
 import pytest
 
+params = {
+    "numeric": [{"optional_value": "0", "first_value": "0"}],
+    "values": [{"values": ["alpha", "beta", "gamma"]}],
+    "calver": [{"calver_format": "{YYYY}.{MM}.{DD}"}],
+}
+
 
-@pytest.fixture(
-    params=[
-        {"optional_value": "0", "first_value": "0"},
-        {"first_value": "1"},
-        {"values": ["alpha", "beta", "gamma"]},
-        {"values": ["alpha", "gamma"]},
-    ]
-)
+@pytest.fixture
 def version_component_config(request):
     """Return a three-part and a two-part version part configuration."""
     return VersionComponentSpec(**request.param)
@@ -20,45 +21,143 @@ def version_component_config(request):
 
 class TestVersionComponent:
     class TestCreation:
-        def test_none_value_uses_optional_value(self, version_component_config):
+        @pytest.mark.parametrize(
+            ["version_component_config"],
+            [
+                params["numeric"],
+                params["values"],
+            ],
+            ids=["numeric", "values"],
+            indirect=True,
+        )
+        def test_none_value_uses_optional_value(self, version_component_config: VersionComponentSpec):
+            """When passing in no value, the optional value is used."""
             vp = version_component_config.create_component()
             assert vp.value == vp.func.optional_value
             assert vp._value is None
 
+        @pytest.mark.parametrize(
+            ["version_component_config"],
+            [params["calver"]],
+            ids=["calver"],
+            indirect=True,
+        )
+        @freeze_time("2020-05-01")
+        def test_none_value_uses_current_date(self, version_component_config: VersionComponentSpec):
+            """When passing in no value, the current date is used for CalVer components."""
+            vp = version_component_config.create_component()
+            assert vp.value == "2020.5.1"
+            assert vp._value == "2020.5.1"
+
         def test_config_with_values_selects_values_function(self):
             values = ["0", "1", "2"]
             vp = VersionComponentSpec(values=values).create_component()
             assert isinstance(vp.func, ValuesFunction)
 
-        def test_config_without_values_selects_numeric_function(self):
+        def test_config_with_calver_selects_calver_function(self):
+            vp = VersionComponentSpec(calver_format="{YYYY}.{MM}").create_component()
+            assert isinstance(vp.func, CalVerFunction)
+
+        def test_config_defaults_to_numeric_function(self):
             vp = VersionComponentSpec().create_component()
             assert isinstance(vp.func, NumericFunction)
 
+    @pytest.mark.parametrize(
+        ["version_component_config"],
+        [
+            params["numeric"],
+            params["values"],
+            params["calver"],
+        ],
+        ids=["numeric", "values", "calver"],
+        indirect=True,
+    )
     def test_copy_returns_new_version_part(self, version_component_config):
         vp = version_component_config.create_component(version_component_config.first_value)
         vc = vp.copy()
         assert vp.value == vc.value
         assert id(vp) != id(vc)
 
-    def test_bump_increments_value(self, version_component_config):
+    @pytest.mark.parametrize(
+        ["version_component_config"],
+        [params["numeric"]],
+        ids=["numeric"],
+        indirect=True,
+    )
+    def test_bump_increments_numeric_value(self, version_component_config):
         vp = version_component_config.create_component(version_component_config.first_value)
         vc = vp.bump()
-        if version_component_config.values:
-            assert vc.value == str(version_component_config.values[1])
-        else:
-            assert vc.value == str(int(version_component_config.first_value) + 1)
+        assert vc.value == str(int(version_component_config.first_value) + 1)
 
+    @pytest.mark.parametrize(
+        ["version_component_config"],
+        [params["values"]],
+        ids=["values"],
+        indirect=True,
+    )
+    def test_bump_selects_next_value(self, version_component_config):
+        vp = version_component_config.create_component(version_component_config.first_value)
+        vc = vp.bump()
+        assert vc.value == str(version_component_config.values[1])
+
+    @pytest.mark.parametrize(
+        ["version_component_config"],
+        [
+            params["numeric"],
+            params["values"],
+            params["calver"],
+        ],
+        ids=["numeric", "values", "calver"],
+        indirect=True,
+    )
     def test_non_first_value_is_not_optional(self, version_component_config):
         assert not version_component_config.create_component(version_component_config.first_value).bump().is_optional
 
+    @pytest.mark.parametrize(
+        ["version_component_config"],
+        [params["calver"]],
+        ids=["calver"],
+        indirect=True,
+    )
+    def test_calver_is_not_optional(self, version_component_config):
+        vc = version_component_config.create_component(version_component_config.first_value)
+        assert not vc.is_optional
+
+    @pytest.mark.parametrize(
+        ["version_component_config"],
+        [params["numeric"], params["values"]],
+        ids=["numeric", "values"],
+        indirect=True,
+    )
     def test_first_value_is_optional(self, version_component_config):
-        assert version_component_config.create_component(version_component_config.first_value).is_optional
+        vc = version_component_config.create_component(version_component_config.first_value)
+        assert vc.is_optional
 
+    @pytest.mark.parametrize(
+        ["version_component_config"],
+        [
+            params["numeric"],
+            params["values"],
+            params["calver"],
+        ],
+        ids=["numeric", "values", "calver"],
+        indirect=True,
+    )
     def test_versionparts_with_same_settings_are_equal(self, version_component_config):
         version1 = version_component_config.create_component(version_component_config.first_value)
         version2 = version_component_config.create_component(version_component_config.first_value)
         assert version1 == version2
 
+    @pytest.mark.parametrize(
+        ["version_component_config"],
+        [
+            params["numeric"],
+            params["values"],
+            params["calver"],
+        ],
+        ids=["numeric", "values", "calver"],
+        indirect=True,
+    )
     def test_null_resets_value_to_first_value(self, version_component_config):
         version1 = version_component_config.create_component(version_component_config.first_value)
         version2 = version1.bump().null()