Repository: jupyterhub/chartpress

Issue:
add a `--file` flag to use a different `chartpress.yaml`
### Proposed change
In projects involving more than one chart, it would be useful to have a `--file` flag such that a specific `chartpress.yaml` file could be targeted, e.g. 

```
chartpress --file another-chartpress.yaml
```
### Alternative options
This behavior can be achieved by separating out charts via the directory structure and running `chartpress` in subdirectories, but this is sub-optimal, tedious and error-prone. 

### Who would use this feature?
Anyone building multiple charts in a single repo that don't all need to be built/packaged at the same cadence. 


Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2019e6d..43c6630 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -11,7 +11,7 @@
 repos:
   # Autoformat: Python code, syntax patterns are modernized
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.15.2
+    rev: v3.15.0
     hooks:
       - id: pyupgrade
         args:
@@ -19,7 +19,7 @@ repos:
 
   # Autoformat: Python code
   - repo: https://github.com/psf/black
-    rev: 24.3.0
+    rev: 24.1.1
     hooks:
       - id: black
         args:
diff --git a/chartpress.py b/chartpress.py
index fbcb43a..99d075f 100644
--- a/chartpress.py
+++ b/chartpress.py
@@ -4,6 +4,7 @@ Automate building and publishing helm charts and associated images.
 
 This is used as part of the JupyterHub and Binder projects.
 """
+
 import argparse
 import os
 import pipes
@@ -277,13 +278,13 @@ def _get_current_branchname(**kwargs):
 
 def _get_image_build_args(image_options, ns):
     """
-    Render buildArgs from chartpress.yaml that could be templates, using
+    Render buildArgs from the config file that could be templates, using
     provided namespace that contains keys with dynamic values such as
     LAST_COMMIT or TAG.
 
     Args:
     image_options (dict):
-        The dictionary for a given image from chartpress.yaml.
+        The dictionary for a given image from the config file.
         Fields in `image_options['buildArgs']` will be rendered
         and returned, if defined.
     ns (dict): the namespace used when rendering templated arguments
@@ -296,13 +297,13 @@ def _get_image_build_args(image_options, ns):
 
 def _get_image_extra_build_command_options(image_options, ns):
     """
-    Render extraBuildCommandOptions from chartpress.yaml that could be
+    Render extraBuildCommandOptions from the config file that could be
     templates, using the provided namespace that contains keys with dynamic
     values such as LAST_COMMIT or TAG.
 
     Args:
     image_options (dict):
-        The dictionary for a given image from chartpress.yaml.
+        The dictionary for a given image from the config file.
         Strings in `image_options['extraBuildCommandOptions']` will be rendered
         and returned.
     ns (dict): the namespace used when rendering templated arguments
@@ -333,7 +334,7 @@ def _get_image_dockerfile_path(name, options):
         return os.path.join(_get_image_build_context_path(name, options), "Dockerfile")
 
 
-def _get_all_image_paths(name, options):
+def _get_all_image_paths(name, options, config_path):
     """
     Returns the unique paths that when changed should trigger a rebuild of a
     chart's image. This includes the Dockerfile itself and the context of the
@@ -343,7 +344,7 @@ def _get_all_image_paths(name, options):
     Dockerfile path, and the optional others for extra paths.
     """
     paths = []
-    paths.append("chartpress.yaml")
+    paths.append(config_path)
     if options.get("rebuildOnContextPathChanges", True):
         paths.append(_get_image_build_context_path(name, options))
     paths.append(_get_image_dockerfile_path(name, options))
@@ -351,18 +352,18 @@ def _get_all_image_paths(name, options):
     return list(set(paths))
 
 
-def _get_all_chart_paths(options):
+def _get_all_chart_paths(options, config_path):
     """
     Returns the unique paths that when changed should trigger a version update
     of the chart. These paths includes all the chart's images' paths as well.
     """
     paths = []
-    paths.append("chartpress.yaml")
+    paths.append(config_path)
     paths.append(options["chartPath"])
     paths.extend(options.get("paths", []))
     if "images" in options:
         for image_name, image_config in options["images"].items():
-            paths.extend(_get_all_image_paths(image_name, image_config))
+            paths.extend(_get_all_image_paths(image_name, image_config, config_path))
     return list(set(paths))
 
 
@@ -393,7 +394,7 @@ def build_image(
         directory during the build process of the Dockerfile. This is typically
         the same folder as the Dockerfile resides in.
     dockerfile_path (str, optional):
-        Path to Dockerfile relative to chartpress.yaml's directory if not
+        Path to Dockerfile relative to the config file's directory if not
         "<context_path>/Dockerfile".
     build_args (dict, optional):
         Dictionary of docker build arguments.
@@ -603,12 +604,13 @@ def build_images(
     builder=Builder.DOCKER_BUILD,
     platforms=None,
     base_version=None,
+    config_path="chartpress.yaml",
 ):
     """Build a collection of docker images
 
     Args:
     prefix (str): the prefix to add to image names
-    images (dict): dict of image-specs from chartpress.yaml
+    images (dict): dict of image-specs from config file.
     tag (str):
         Specific tag to use instead of the last modified commit.
         If unspecified the tag for each image will be the hash of the last commit
@@ -643,6 +645,8 @@ def build_images(
     base_version (str):
         The base version string (before '.git'), used when useChartVersion is True
         instead of the tag found via `git describe`.
+    config_path (str):
+        Path to the chartpress config file (default: "chartpress.yaml").
     """
     if platforms:
         # for later use of set operations like .difference()
@@ -650,9 +654,9 @@ def build_images(
 
     values_file_modifications = {}
     for name, options in images.items():
-        # include chartpress.yaml in the image paths to inspect as
-        # chartpress.yaml can contain build args influencing the image
-        all_image_paths = _get_all_image_paths(name, options)
+        # include config file in the image paths to inspect as
+        # it can contain build args influencing the image
+        all_image_paths = _get_all_image_paths(name, options, config_path)
 
         if tag is None:
             image_tag = _get_identifier_from_paths(
@@ -1056,7 +1060,7 @@ def _check_or_get_base_version(base_version):
     # check ordering with latest tag
     # do not check on a tagged commit
     if tag and count:
-        sort_error = f"baseVersion {base_version} is not greater than latest tag {tag}. Please update baseVersion config in chartpress.yaml."
+        sort_error = f"baseVersion {base_version} is not greater than latest tag {tag}. Please update baseVersion in config."
         if tag_match:
             base_version_number = _version_number(base_version_groups)
             if base_version_number < tag_version_number:
@@ -1143,7 +1147,7 @@ def main(argv=None):
     argparser.add_argument(
         "--reset",
         action="store_true",
-        help="Skip image build step and reset Chart.yaml's version field and values.yaml's image tags. What it resets to can be configured in chartpress.yaml with the resetTag and resetVersion configurations.",
+        help="Skip image build step and reset Chart.yaml's version field and values.yaml's image tags. What it resets to can be configured in your config file with the resetTag and resetVersion configurations.",
     )
     skip_or_force_build_group = argparser.add_mutually_exclusive_group()
     skip_or_force_build_group.add_argument(
@@ -1182,6 +1186,14 @@ def main(argv=None):
         action="store_true",
         help="print list of images to stdout. Images will not be built.",
     )
+
+    argparser.add_argument(
+        "--config",
+        type=str,
+        default="chartpress.yaml",
+        help="Path to the configuration file",
+    )
+
     argparser.add_argument(
         "--version",
         action="version",
@@ -1192,16 +1204,21 @@ def main(argv=None):
     if args.builder == Builder.DOCKER_BUILD and args.platform:
         argparser.error(f"--platform is not supported with {Builder.DOCKER_BUILD}")
 
+    if args.config:
+        # check that config exists and is readable
+        with open(args.config):
+            pass
+
     if args.reset:
-        # reset conflicts with everything
+        # reset conflicts with everything except the configuration file
         # this could probably be clearer by using subparsers
         argv = list(argv or sys.argv[1:])
+        argv.remove("--reset")
+        argv = _remove_config_arg(argv)
         if len(argv) > 1:
-            argv = list(argv)
-            argv.remove("--reset")
-            extra_args = " ".join(shlex.quote(arg) for arg in argv if arg != "--reset")
+            extra_args = " ".join(shlex.quote(arg) for arg in argv)
             argparser.error(
-                f"`chartpress --reset` takes no additional arguments: {extra_args}"
+                f"`chartpress --reset` can only be used with `--config` and no additional arguments: {extra_args}"
             )
 
     # allow simple checks for whether publish will happen
@@ -1212,11 +1229,11 @@ def main(argv=None):
         args.no_build = True
         args.publish_chart = False
 
-    with open("chartpress.yaml") as f:
+    with open(args.config) as f:
         config = yaml.load(f)
 
     # main logic
-    # - loop through each chart listed in chartpress.yaml
+    # - loop through each chart listed in the config file
     #   - build chart.yaml (--reset)
     #   - build images (--skip-build | --reset)
     #     - push images (--push)
@@ -1248,7 +1265,7 @@ def main(argv=None):
             # update Chart.yaml with a version
             chart_version = build_chart(
                 chart["chartPath"],
-                paths=_get_all_chart_paths(chart),
+                paths=_get_all_chart_paths(chart, args.config),
                 version=forced_version,
                 base_version=base_version,
                 long=args.long,
@@ -1307,5 +1324,23 @@ def main(argv=None):
             )
 
 
+def _remove_config_arg(argv):
+    argv = [*argv]
+
+    # get the index for --config, --config=something, or None
+    config_idx = next(
+        (i for i, arg in enumerate(argv) if arg.startswith("--config")),
+        None,
+    )
+    if config_idx is not None:
+        # remove the --config argument (and its value if passed with =)
+        argv.pop(config_idx)
+        if not argv[config_idx].startswith("--") and config_idx < len(argv):
+            # remove the value of the --config argument if it was passed separately
+            argv.pop(config_idx)
+
+    return argv
+
+
 if __name__ == "__main__":
     main()


Test patch:
diff --git a/tests/test_repo_interactions.py b/tests/test_repo_interactions.py
index fa3d82a..d4128dd 100644
--- a/tests/test_repo_interactions.py
+++ b/tests/test_repo_interactions.py
@@ -1,6 +1,8 @@
+import contextlib
 import os
 import subprocess
 import sys
+import tempfile
 
 import pytest
 from conftest import cache_clear
@@ -29,17 +31,22 @@ def test_git_repo_fixture(git_repo):
     assert os.path.isfile("index.yaml")
 
 
+@pytest.mark.parametrize("config_name", ["chartpress.yaml", "chartpress.alt.yaml"])
 @pytest.mark.parametrize("base_version", [None, "0.0.1-0.dev"])
-def test_chartpress_run(git_repo, capfd, base_version):
+def test_chartpress_run(git_repo, capfd, base_version, config_name):
     """Run chartpress and inspect the output."""
+    using_default_config = config_name == "chartpress.yaml"
+    args = [] if using_default_config else ["--config", config_name]
+    if not using_default_config:
+        os.rename("chartpress.yaml", config_name)
 
-    with open("chartpress.yaml") as f:
+    with open(config_name) as f:
         chartpress_config = yaml.load(f)
 
     chart = chartpress_config["charts"][0]
     if base_version:
         chart["baseVersion"] = base_version
-        with open("chartpress.yaml", "w") as f:
+        with open(config_name, "w") as f:
             yaml.dump(chartpress_config, f)
 
     # summarize information from git_repo
@@ -48,8 +55,8 @@ def test_chartpress_run(git_repo, capfd, base_version):
     branch = "main"
     check_version(tag)
 
-    # run chartpress
-    out = _capture_output([], capfd)
+    # run chartpress, with parameterized config file name
+    out = _capture_output(args, capfd)
     print(out)
     # verify image was built
     # verify the fallback tag of "0.0.1" when a tag is missing
@@ -74,30 +81,30 @@ def test_chartpress_run(git_repo, capfd, base_version):
     # verify usage of chartpress.yaml's resetVersion and resetTag
     reset_version = chart["resetVersion"]
     reset_tag = chart["resetTag"]
-    out = _capture_output(["--reset"], capfd)
+    out = _capture_output([*args, "--reset"], capfd)
     assert f"Updating testchart/Chart.yaml: version: {reset_version}" in out
     assert (
         f"Updating testchart/values.yaml: image: testchart/testimage:{reset_tag}" in out
     )
 
     # verify that we don't need to rebuild the image
-    out = _capture_output([], capfd)
+    out = _capture_output(args, capfd)
     assert "Skipping build" in out
 
     # verify usage of --force-build
-    out = _capture_output(["--force-build"], capfd)
+    out = _capture_output([*args, "--force-build"], capfd)
     assert "Successfully tagged" in out or "naming to" in out
 
     # verify usage --skip-build and --tag
     tag = "1.2.3-test.tag"
-    out = _capture_output(["--skip-build", "--tag", tag], capfd)
+    out = _capture_output([*args, "--skip-build", "--tag", tag], capfd)
     assert "Successfully tagged" not in out or "naming to" in out
     assert f"Updating testchart/Chart.yaml: version: {tag}" in out
     assert f"Updating testchart/values.yaml: image: testchart/testimage:{tag}" in out
 
     # verify a real git tag is detected
     git_repo.create_tag(tag, message=tag)
-    out = _capture_output(["--skip-build"], capfd)
+    out = _capture_output([*args, "--skip-build"], capfd)
 
     # This assertion verifies chartpress has considered the git tag by the fact
     # that no values required to be updated. No values should be updated as the
@@ -108,12 +115,12 @@ def test_chartpress_run(git_repo, capfd, base_version):
     # Run again, but from a clean repo (versions in git don't match tag)
     # Should produce the same result
     git_repo.git.checkout(tag, "--", "testchart")
-    out = _capture_output(["--skip-build"], capfd)
+    out = _capture_output([*args, "--skip-build"], capfd)
     assert f"Updating testchart/Chart.yaml: version: {tag}\n" in out
     assert f"Updating testchart/values.yaml: image: testchart/testimage:{tag}\n" in out
 
     # verify usage of --long
-    out = _capture_output(["--skip-build", "--long"], capfd)
+    out = _capture_output([*args, "--skip-build", "--long"], capfd)
     assert f"Updating testchart/Chart.yaml: version: {tag}.git.1.h{sha}" in out
     assert (
         f"Updating testchart/values.yaml: image: testchart/testimage:{tag}.git.1.h{sha}"
@@ -121,13 +128,16 @@ def test_chartpress_run(git_repo, capfd, base_version):
     )
 
     # verify usage of --image-prefix
-    out = _capture_output(["--skip-build", "--image-prefix", "test-prefix/"], capfd)
+    out = _capture_output(
+        [*args, "--skip-build", "--image-prefix", "test-prefix/"], capfd
+    )
     assert f"Updating testchart/Chart.yaml: version: {tag}" in out
     assert f"Updating testchart/values.yaml: image: test-prefix/testimage:{tag}" in out
 
     # verify usage of --publish-chart and --extra-message
     out = _capture_output(
         [
+            *args,
             "--skip-build",
             "--publish-chart",
             "--extra-message",
@@ -165,6 +175,7 @@ def test_chartpress_run(git_repo, capfd, base_version):
     # repo already
     out = _capture_output(
         [
+            *args,
             "--skip-build",
             "--publish-chart",
         ],
@@ -177,6 +188,7 @@ def test_chartpress_run(git_repo, capfd, base_version):
     # chart repo already
     out = _capture_output(
         [
+            *args,
             "--skip-build",
             "--force-publish-chart",
         ],
@@ -197,6 +209,7 @@ def test_chartpress_run(git_repo, capfd, base_version):
         with pytest.raises(ValueError):
             out = _capture_output(
                 [
+                    *args,
                     "--skip-build",
                 ],
                 capfd,
@@ -204,19 +217,21 @@ def test_chartpress_run(git_repo, capfd, base_version):
         with pytest.raises(ValueError):
             out = _capture_output(
                 [
+                    *args,
                     "--reset",
                 ],
                 capfd,
             )
         # update baseVersion
         chart["baseVersion"] = next_tag = "1.2.4-0.dev"
-        with open("chartpress.yaml", "w") as f:
+        with open(config_name, "w") as f:
             yaml.dump(chartpress_config, f)
     else:
         next_tag = tag
 
     out = _capture_output(
         [
+            *args,
             "--skip-build",
             "--publish-chart",
         ],
@@ -549,3 +564,15 @@ def test_reset_exclusive(git_repo, capfd):
         chartpress.main(["--reset", "--tag", "1.2.3"])
     out, err = capfd.readouterr()
     assert "no additional arguments" in err
+
+    with pytest.raises(SystemExit):
+        chartpress.main(["--reset", "--config=chartpress.yaml", "--tag", "1.2.3"])
+    out, err = capfd.readouterr()
+    assert "no additional arguments" in err
+    assert "chartpress.yaml" not in err
+
+    with pytest.raises(SystemExit):
+        chartpress.main(["--reset", "--config", "chartpress.yaml", "--tag", "1.2.3"])
+    out, err = capfd.readouterr()
+    assert "no additional arguments" in err
+    assert "chartpress.yaml" not in err