Repository: pantsbuild/pants

Issue:
Rename `DockerVersionContext` to something more meaningful.
Yeah, I agree. It started out as the context for the `version` field (which was dropped in favour of `image_tags`). But now, it also applies to the `repository` field (or at least a part of it), so I've been contemplating renaming it, just haven't figured out to what yet.

_Originally posted by @kaos in https://github.com/pantsbuild/pants/pull/13880#discussion_r771687530_


Discussion:

Resolving patch:
diff --git a/src/python/pants/backend/docker/goals/package_image.py b/src/python/pants/backend/docker/goals/package_image.py
--- a/src/python/pants/backend/docker/goals/package_image.py
+++ b/src/python/pants/backend/docker/goals/package_image.py
@@ -24,9 +24,9 @@
 from pants.backend.docker.util_rules.docker_build_context import (
     DockerBuildContext,
     DockerBuildContextRequest,
-    DockerVersionContext,
 )
 from pants.backend.docker.utils import format_rename_suggestion
+from pants.backend.docker.value_interpolation import DockerInterpolationContext
 from pants.core.goals.package import BuiltPackage, PackageFieldSet
 from pants.core.goals.run import RunFieldSet
 from pants.engine.addresses import Address
@@ -57,9 +57,9 @@ class DockerFieldSet(PackageFieldSet, RunFieldSet):
     source: DockerImageSourceField
     tags: DockerImageTagsField
 
-    def format_tag(self, tag: str, version_context: DockerVersionContext) -> str:
+    def format_tag(self, tag: str, interpolation_context: DockerInterpolationContext) -> str:
         try:
-            return tag.format(**version_context).lower()
+            return tag.format(**interpolation_context).lower()
         except (KeyError, ValueError) as e:
             msg = (
                 "Invalid tag value for the `image_tags` field of the `docker_image` target at "
@@ -67,12 +67,12 @@ def format_tag(self, tag: str, version_context: DockerVersionContext) -> str:
             )
             if isinstance(e, KeyError):
                 msg += f"The placeholder {e} is unknown."
-                if version_context:
-                    msg += f' Try with one of: {", ".join(sorted(version_context.keys()))}.'
+                if interpolation_context:
+                    msg += f' Try with one of: {", ".join(sorted(interpolation_context.keys()))}.'
                 else:
                     msg += (
                         " There are currently no known placeholders to use. These placeholders "
-                        "can come from `[docker].build_args` or parsed FROM instructions of "
+                        "can come from `[docker].build_args` or parsed from instructions of "
                         "your `Dockerfile`."
                     )
             else:
@@ -113,7 +113,7 @@ def image_refs(
         self,
         default_repository: str,
         registries: DockerRegistries,
-        version_context: DockerVersionContext,
+        interpolation_context: DockerInterpolationContext,
     ) -> tuple[str, ...]:
         """The image refs are the full image name, including any registry and version tag.
 
@@ -133,12 +133,12 @@ def image_refs(
         This method will always return a non-empty tuple.
         """
         repository_context = {}
-        if "build_args" in version_context:
-            repository_context["build_args"] = version_context["build_args"]
+        if "build_args" in interpolation_context:
+            repository_context["build_args"] = interpolation_context["build_args"]
 
         repository = self.format_repository(default_repository, repository_context)
         image_names = tuple(
-            ":".join(s for s in [repository, self.format_tag(tag, version_context)] if s)
+            ":".join(s for s in [repository, self.format_tag(tag, interpolation_context)] if s)
             for tag in self.tags.value or ()
         )
 
@@ -182,7 +182,7 @@ async def build_docker_image(
     tags = field_set.image_refs(
         default_repository=options.default_repository,
         registries=options.registries(),
-        version_context=context.version_context,
+        interpolation_context=context.interpolation_context,
     )
 
     process = docker.build_image(
diff --git a/src/python/pants/backend/docker/util_rules/docker_build_context.py b/src/python/pants/backend/docker/util_rules/docker_build_context.py
--- a/src/python/pants/backend/docker/util_rules/docker_build_context.py
+++ b/src/python/pants/backend/docker/util_rules/docker_build_context.py
@@ -6,7 +6,6 @@
 import logging
 from abc import ABC
 from dataclasses import dataclass
-from typing import ClassVar, Mapping
 
 from pants.backend.docker.package_types import BuiltDockerImage
 from pants.backend.docker.subsystems.docker_options import DockerOptions
@@ -22,6 +21,11 @@
     DockerBuildEnvironmentRequest,
 )
 from pants.backend.docker.utils import suggest_renames
+from pants.backend.docker.value_interpolation import (
+    DockerBuildArgsInterpolationValue,
+    DockerInterpolationContext,
+    DockerInterpolationValue,
+)
 from pants.backend.shell.target_types import ShellSourceField
 from pants.core.goals.package import BuiltPackage, PackageFieldSet
 from pants.core.target_types import FileSourceField
@@ -42,7 +46,6 @@
     TransitiveTargetsRequest,
 )
 from pants.engine.unions import UnionRule
-from pants.util.frozendict import FrozenDict
 
 logger = logging.getLogger(__name__)
 
@@ -51,74 +54,6 @@ class DockerBuildContextError(Exception):
     pass
 
 
-class DockerVersionContextError(ValueError):
-    @classmethod
-    def attribute_error(
-        cls, context: DockerVersionContextValue, attribute: str
-    ) -> DockerVersionContextError:
-        msg = f"The placeholder {attribute!r} is unknown."
-        if context:
-            msg += f' Try with one of: {", ".join(context.keys())}.'
-        return cls(msg)
-
-
-class DockerVersionContextValue(FrozenDict[str, str]):
-    """Dict class suitable for use as a format string context object, as it allows to use attribute
-    access rather than item access."""
-
-    _attribute_error_type: ClassVar[type[DockerVersionContextError]] = DockerVersionContextError
-
-    @classmethod
-    def create(
-        cls, value: Mapping[str, str] | DockerVersionContextValue
-    ) -> DockerVersionContextValue:
-        """Create new instance of `DockerVersionContextValue` unless `value` already is an instance
-        (or subclass) of `DockerVersionContextValue`, in which case `value` is returned as-is."""
-        if isinstance(value, cls):
-            return value
-        return cls(value)
-
-    def __getattr__(self, attribute: str) -> str:
-        if attribute not in self:
-            raise self._attribute_error_type.attribute_error(self, attribute)
-        return self[attribute]
-
-
-class DockerVersionContextBuildArgError(DockerVersionContextError):
-    @classmethod
-    def attribute_error(
-        cls, context: DockerVersionContextValue, attribute: str
-    ) -> DockerVersionContextError:
-        msg = f"The build arg {attribute!r} is undefined."
-        if context:
-            msg += f' Defined build args are: {", ".join(context.keys())}.'
-        msg += (
-            "\n\nThis build arg may be defined in `pants.toml` under `[docker].build_args`, on the "
-            "command line with `--docker-build-args` or directly on the `docker_image` target "
-            "using the `extra_build_args` field."
-        )
-        return cls(msg)
-
-
-class DockerVersionContextBuildArgsValue(DockerVersionContextValue):
-    """Version context value with specific error handling for build args."""
-
-    _attribute_error_type = DockerVersionContextBuildArgError
-
-
-class DockerVersionContext(FrozenDict[str, DockerVersionContextValue]):
-    @classmethod
-    def from_dict(
-        cls, data: Mapping[str, Mapping[str, str] | DockerVersionContextValue]
-    ) -> DockerVersionContext:
-        return DockerVersionContext(
-            {key: DockerVersionContextValue.create(value) for key, value in data.items()}
-        )
-
-    def merge(self, other: Mapping[str, Mapping[str, str]]) -> DockerVersionContext:
-        return DockerVersionContext.from_dict({**self, **other})
-
-
 class DockerContextFilesAcceptableInputsField(ABC, SourcesField):
     """This is a meta field for the context files generator, to tell the codegen machinery what
     source fields are good to use.
@@ -166,7 +101,7 @@ class DockerBuildContext:
     digest: Digest
     build_env: DockerBuildEnvironment
     dockerfile: str
-    version_context: DockerVersionContext
+    interpolation_context: DockerInterpolationContext
     copy_source_vs_context_source: tuple[tuple[str, str], ...]
 
     @classmethod
@@ -177,15 +112,15 @@ def create(
         build_env: DockerBuildEnvironment,
         dockerfile_info: DockerfileInfo,
     ) -> DockerBuildContext:
-        version_context: dict[str, dict[str, str] | DockerVersionContextValue] = {}
+        interpolation_context: dict[str, dict[str, str] | DockerInterpolationValue] = {}
 
         # FROM tags for all stages.
         for stage, tag in [tag.split(maxsplit=1) for tag in dockerfile_info.version_tags]:
             value = {"tag": tag}
-            if not version_context:
+            if not interpolation_context:
                 # Expose the first (stage0) FROM directive as the "baseimage".
-                version_context["baseimage"] = value
-            version_context[stage] = value
+                interpolation_context["baseimage"] = value
+            interpolation_context[stage] = value
 
         if build_args:
             # Extract default arg values from the parsed Dockerfile.
@@ -203,7 +138,7 @@ def create(
                 # which args are actually being used by Pants. We do pick up any defined default ARG
                 # values from the Dockerfile however, in order to not having to duplicate them in
                 # the BUILD files.
-                version_context["build_args"] = {
+                interpolation_context["build_args"] = {
                     arg_name: arg_value
                     if has_value
                     else build_env.get(arg_name, build_arg_defaults.get(arg_name))
@@ -222,8 +157,8 @@ def create(
                 ) from e
 
         # Override default value type for the `build_args` context to get helpful error messages.
-        version_context["build_args"] = DockerVersionContextBuildArgsValue(
-            version_context.get("build_args", {})
+        interpolation_context["build_args"] = DockerBuildArgsInterpolationValue(
+            interpolation_context.get("build_args", {})
         )
 
         return cls(
@@ -231,7 +166,7 @@ def create(
             digest=snapshot.digest,
             dockerfile=dockerfile_info.source,
             build_env=build_env,
-            version_context=DockerVersionContext.from_dict(version_context),
+            interpolation_context=DockerInterpolationContext.from_dict(interpolation_context),
             copy_source_vs_context_source=tuple(
                 suggest_renames(
                     tentative_paths=(
diff --git a/src/python/pants/backend/docker/value_interpolation.py b/src/python/pants/backend/docker/value_interpolation.py
new file mode 100644
--- /dev/null
+++ b/src/python/pants/backend/docker/value_interpolation.py
@@ -0,0 +1,75 @@
+# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+from __future__ import annotations
+
+from typing import ClassVar, Mapping
+
+from pants.util.frozendict import FrozenDict
+
+
+class DockerInterpolationError(ValueError):
+    @classmethod
+    def attribute_error(
+        cls, value: DockerInterpolationValue, attribute: str
+    ) -> DockerInterpolationError:
+        msg = f"The placeholder {attribute!r} is unknown."
+        if value:
+            msg += f' Try with one of: {", ".join(value.keys())}.'
+        return cls(msg)
+
+
+class DockerInterpolationValue(FrozenDict[str, str]):
+    """Dict class suitable for use as a format string context object, as it allows to use attribute
+    access rather than item access."""
+
+    _attribute_error_type: ClassVar[type[DockerInterpolationError]] = DockerInterpolationError
+
+    @classmethod
+    def create(
+        cls, value: Mapping[str, str] | DockerInterpolationValue
+    ) -> DockerInterpolationValue:
+        """Create new instance of `DockerInterpolationValue` unless `value` already is an instance
+        (or subclass) of `DockerInterpolationValue`, in which case `value` is returned as-is."""
+        if isinstance(value, cls):
+            return value
+        return cls(value)
+
+    def __getattr__(self, attribute: str) -> str:
+        if attribute not in self:
+            raise self._attribute_error_type.attribute_error(self, attribute)
+        return self[attribute]
+
+
+class DockerBuildArgInterpolationError(DockerInterpolationError):
+    @classmethod
+    def attribute_error(
+        cls, value: DockerInterpolationValue, attribute: str
+    ) -> DockerInterpolationError:
+        msg = f"The build arg {attribute!r} is undefined."
+        if value:
+            msg += f' Defined build args are: {", ".join(value.keys())}.'
+        msg += (
+            "\n\nThis build arg may be defined with the `[docker].build_args` option or directly "
+            "on the `docker_image` target using the `extra_build_args` field."
+        )
+        return cls(msg)
+
+
+class DockerBuildArgsInterpolationValue(DockerInterpolationValue):
+    """Interpolation context value with specific error handling for build args."""
+
+    _attribute_error_type = DockerBuildArgInterpolationError
+
+
+class DockerInterpolationContext(FrozenDict[str, DockerInterpolationValue]):
+    @classmethod
+    def from_dict(
+        cls, data: Mapping[str, Mapping[str, str] | DockerInterpolationValue]
+    ) -> DockerInterpolationContext:
+        return DockerInterpolationContext(
+            {key: DockerInterpolationValue.create(value) for key, value in data.items()}
+        )
+
+    def merge(self, other: Mapping[str, Mapping[str, str]]) -> DockerInterpolationContext:
+        return DockerInterpolationContext.from_dict({**self, **other})


Test patch:
diff --git a/src/python/pants/backend/docker/goals/package_image_test.py b/src/python/pants/backend/docker/goals/package_image_test.py
--- a/src/python/pants/backend/docker/goals/package_image_test.py
+++ b/src/python/pants/backend/docker/goals/package_image_test.py
@@ -29,13 +29,13 @@
 from pants.backend.docker.util_rules.docker_build_context import (
     DockerBuildContext,
     DockerBuildContextRequest,
-    DockerVersionContext,
 )
 from pants.backend.docker.util_rules.docker_build_env import (
     DockerBuildEnvironment,
     DockerBuildEnvironmentRequest,
 )
 from pants.backend.docker.util_rules.docker_build_env import rules as build_env_rules
+from pants.backend.docker.value_interpolation import DockerInterpolationContext
 from pants.engine.addresses import Address
 from pants.engine.fs import EMPTY_DIGEST, EMPTY_FILE_DIGEST, EMPTY_SNAPSHOT, Snapshot
 from pants.engine.platform import Platform
@@ -335,7 +335,7 @@ def test_build_image_with_registries(rule_runner: RuleRunner) -> None:
 
 
 def test_dynamic_image_version(rule_runner: RuleRunner) -> None:
-    version_context = DockerVersionContext.from_dict(
+    interpolation_context = DockerInterpolationContext.from_dict(
         {
             "baseimage": {"tag": "3.8"},
             "stage0": {"tag": "3.8"},
@@ -351,7 +351,7 @@ def assert_tags(name: str, *expect_tags: str) -> None:
         tags = fs.image_refs(
             "image",
             DockerRegistries.from_dict({}),
-            version_context,
+            interpolation_context,
         )
         assert expect_tags == tags
 
diff --git a/src/python/pants/backend/docker/goals/publish_test.py b/src/python/pants/backend/docker/goals/publish_test.py
--- a/src/python/pants/backend/docker/goals/publish_test.py
+++ b/src/python/pants/backend/docker/goals/publish_test.py
@@ -17,7 +17,7 @@
 from pants.backend.docker.target_types import DockerImageTarget
 from pants.backend.docker.util_rules import docker_binary
 from pants.backend.docker.util_rules.docker_binary import DockerBinary
-from pants.backend.docker.util_rules.docker_build_context import DockerVersionContext
+from pants.backend.docker.value_interpolation import DockerInterpolationContext
 from pants.core.goals.package import BuiltPackage
 from pants.core.goals.publish import PublishPackages, PublishProcesses
 from pants.engine.addresses import Address
@@ -62,7 +62,7 @@ def build(tgt: DockerImageTarget, options: DockerOptions):
                     fs.image_refs(
                         options.default_repository,
                         options.registries(),
-                        DockerVersionContext(),
+                        DockerInterpolationContext(),
                     ),
                 ),
             ),
diff --git a/src/python/pants/backend/docker/util_rules/docker_build_context_test.py b/src/python/pants/backend/docker/util_rules/docker_build_context_test.py
--- a/src/python/pants/backend/docker/util_rules/docker_build_context_test.py
+++ b/src/python/pants/backend/docker/util_rules/docker_build_context_test.py
@@ -22,9 +22,11 @@
 from pants.backend.docker.util_rules.docker_build_context import (
     DockerBuildContext,
     DockerBuildContextRequest,
-    DockerVersionContext,
-    DockerVersionContextBuildArgsValue,
-    DockerVersionContextValue,
+)
+from pants.backend.docker.value_interpolation import (
+    DockerBuildArgsInterpolationValue,
+    DockerInterpolationContext,
+    DockerInterpolationValue,
 )
 from pants.backend.python import target_types_rules
 from pants.backend.python.goals import package_pex_binary
@@ -84,7 +86,8 @@ def assert_build_context(
     *,
     build_upstream_images: bool = False,
     expected_files: list[str],
-    expected_version_context: dict[str, dict[str, str] | DockerVersionContextValue] | None = None,
+    expected_interpolation_context: dict[str, dict[str, str] | DockerInterpolationValue]
+    | None = None,
     pants_args: list[str] | None = None,
     runner_options: dict[str, Any] | None = None,
 ) -> DockerBuildContext:
@@ -104,12 +107,14 @@ def assert_build_context(
 
     snapshot = rule_runner.request(Snapshot, [context.digest])
     assert sorted(expected_files) == sorted(snapshot.files)
-    if expected_version_context is not None:
-        if "build_args" in expected_version_context:
-            expected_version_context["build_args"] = DockerVersionContextBuildArgsValue(
-                expected_version_context["build_args"]
+    if expected_interpolation_context is not None:
+        if "build_args" in expected_interpolation_context:
+            expected_interpolation_context["build_args"] = DockerBuildArgsInterpolationValue(
+                expected_interpolation_context["build_args"]
             )
-        assert context.version_context == DockerVersionContext.from_dict(expected_version_context)
+        assert context.interpolation_context == DockerInterpolationContext.from_dict(
+            expected_interpolation_context
+        )
     return context
 
 
@@ -202,7 +207,7 @@ def test_from_image_build_arg_dependency(rule_runner: RuleRunner) -> None:
         Address("src/downstream", target_name="image"),
         expected_files=["src/downstream/Dockerfile"],
         build_upstream_images=True,
-        expected_version_context={
+        expected_interpolation_context={
             "baseimage": {"tag": "latest"},
             "stage0": {"tag": "latest"},
             "build_args": {
@@ -264,7 +269,7 @@ def test_packaged_pex_path(rule_runner: RuleRunner) -> None:
     )
 
 
-def test_version_context_from_dockerfile(rule_runner: RuleRunner) -> None:
+def test_interpolation_context_from_dockerfile(rule_runner: RuleRunner) -> None:
     rule_runner.write_files(
         {
             "src/docker/BUILD": "docker_image()",
@@ -283,7 +288,7 @@ def test_version_context_from_dockerfile(rule_runner: RuleRunner) -> None:
         rule_runner,
         Address("src/docker"),
         expected_files=["src/docker/Dockerfile"],
-        expected_version_context={
+        expected_interpolation_context={
             "baseimage": {"tag": "3.8"},
             "stage0": {"tag": "3.8"},
             "interim": {"tag": "latest"},
@@ -316,7 +321,7 @@ def test_synthetic_dockerfile(rule_runner: RuleRunner) -> None:
         rule_runner,
         Address("src/docker"),
         expected_files=["src/docker/Dockerfile.docker"],
-        expected_version_context={
+        expected_interpolation_context={
             "baseimage": {"tag": "3.8"},
             "stage0": {"tag": "3.8"},
             "interim": {"tag": "latest"},
@@ -392,7 +397,7 @@ def test_build_arg_defaults_from_dockerfile(rule_runner: RuleRunner) -> None:
             },
         },
         expected_files=["src/docker/Dockerfile"],
-        expected_version_context={
+        expected_interpolation_context={
             "baseimage": {"tag": "${base_version}"},
             "stage0": {"tag": "${base_version}"},
             "build_args": {
@@ -535,4 +540,4 @@ def test_build_arg_behavior(
     expectation: ContextManager,
 ) -> None:
     with expectation:
-        assert fmt_string.format(**build_context.version_context) == result
+        assert fmt_string.format(**build_context.interpolation_context) == result