Repository: trappitsch/box

Issue:
Allow custom build commands
Currently `box` supports building with `rye`, `hatch`, `pdm`, `flit`, and `python -m build`. However, this might be too restrictive.

Allow for a custom build command such that the user can simply select how to build with their own command chain. This could be a simple array of commands that is then sent out to `subprocess.run`.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/trappitsch/box/pull/55?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Reto+Trappitsch) Report
Attention: Patch coverage is `98.41270%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 99.74%. Comparing base [(`7009d26`)](https://app.codecov.io/gh/trappitsch/box/commit/7009d26bb871018141848ade615a42010b88b978?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Reto+Trappitsch) to head [(`b12f962`)](https://app.codecov.io/gh/trappitsch/box/commit/b12f96229cb4dbf35a53896b0e9cb4669826767b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Reto+Trappitsch).

| [Files with missing lines](https://app.codecov.io/gh/trappitsch/box/pull/55?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Reto+Trappitsch) | Patch % | Lines |
|---|---|---|
| [src/box/initialization.py](https://app.codecov.io/gh/trappitsch/box/pull/55?src=pr&el=tree&filepath=src%2Fbox%2Finitialization.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Reto+Trappitsch#diff-c3JjL2JveC9pbml0aWFsaXphdGlvbi5weQ==) | 92.30% | [1 Missing :warning: ](https://app.codecov.io/gh/trappitsch/box/pull/55?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Reto+Trappitsch) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #55      +/-   ##
==========================================
- Coverage   99.79%   99.74%   -0.05%     
==========================================
  Files          27       27              
  Lines        1905     1963      +58     
==========================================
+ Hits         1901     1958      +57     
- Misses          4        5       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/trappitsch/box/pull/55?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Reto+Trappitsch).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Reto+Trappitsch).


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 577c3e3..4c63a0c 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,3 +1,5 @@
+- Add the capability to define a completely custom builder.
+
 ## v0.4.0
 
 **If you are setting PyApp variables, this is a breaking change!**
diff --git a/docs/guide.md b/docs/guide.md
index 2d76d59..a3fc63e 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -21,7 +21,7 @@ The following table shows a list of questions, their default values, their argum
 
 | Question                                                                   | Default                                                                                                                                                                                    | Argument                         | Explanation                                                                                                                                                                                                                                                                                                                                               |
 |----------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| Choose a builder tool for the project                                      | `rye`                                                                                                                                                                                      | `-b`<br> `--builder`             | *Required:* The builder to use to package your pyproject file. Valid tools are `rye`, `hatch`, `pdm`, `build`, and `flit`. Ensure that the builder is available in the environment in which you run box.                                                                                                                                                  |
+| Choose a builder tool for the project                                      | `rye`                                                                                                                                                                                      | `-b`<br> `--builder`             | *Required:* The builder to use to package your pyproject file. Valid tools are `rye`, `hatch`, `pdm`, `build`, and `flit`. You can also set the builder to `custom`. A follow up input field will allow you to input your custom command. Ensure that the builder is available in the environment in which you run box.                                   |
 | Provide any optional dependencies for the project.                         | `None`                                                                                                                                                                                     | `--opt`<br>`--optional-deps`     | *Optional:* Set any optional dependencies for the project. These are the dependencies that you would install in square brackets, e.g., via `pip install package_name[optional]`. If there are no optional dependencies, just hit enter.                                                                                                                   |
 | Is this a GUI project?                                                     | `False`                                                                                                                                                                                    | `--gui` flag to toggle to `True` | Packaging a GUI project and a CLI project take place slightly differently in PyApp. If you package a GUI project without setting this option, a console will be shown in Windows and macos along with your GUI.                                                                                                                                           |                                                                                                                                          |
 | Please type an app entry for the project or choose one from the list below | First entry in `pyproject.toml` for `[project.gui-scripts]`, if not available, then for `[project.scripts]`. If no entry points are given, the default value is set to `package_name:run`. | `-e`<br>`--entry`                | *Required:* The entry point for the application. This is the command that will be used to start the application. If you have a `pyproject.toml` file, `box` will try and read potential entry points that you can select by passing it the digit of the list. You can also provide an entry point manually by typing it here.                             |
@@ -34,6 +34,7 @@ If you provided all the answers, your project should have been successfully init
     While the recommended way to initialize a `box` project is simply to go through the questions that are asked
     during a `box init`, you can go through initialization in `-q`/`--quiet` mode.
     To still specify variables, just set them using the arguments discussed in the table above.
+    Also have a look at the [CLI documentation](cli.md).
     And if you are happy with all the defaults, you should be good to do.
 
 !!! note
diff --git a/src/box/cli.py b/src/box/cli.py
index 137762f..ee0e75d 100644
--- a/src/box/cli.py
+++ b/src/box/cli.py
@@ -35,9 +35,17 @@ def cli():
 @click.option(
     "-b",
     "--builder",
-    type=click.Choice(PackageApp().builders),
+    type=click.Choice(PackageApp().builders_and_custom),
     help="Set the builder for the project.",
 )
+@click.option(
+    "--build-command",
+    type=str,
+    help=(
+        "If builder is set to `custom`, set the build command to specify "
+        "how the project should be built."
+    ),
+)
 @click.option(
     "-opt", "--optional-deps", help="Set optional dependencies for the project."
 )
@@ -67,6 +75,7 @@ def cli():
 def init(
     quiet,
     builder,
+    build_command,
     optional_deps,
     gui,
     entry,
@@ -78,6 +87,7 @@ def init(
     my_init = InitializeProject(
         quiet=quiet,
         builder=builder,
+        build_command=build_command,
         optional_deps=optional_deps,
         is_gui=gui,
         app_entry=entry,
diff --git a/src/box/config.py b/src/box/config.py
index 704835a..3163fed 100644
--- a/src/box/config.py
+++ b/src/box/config.py
@@ -102,7 +102,6 @@ class PyProjectParser:
             possible_entries["scripts"] = self._project["scripts"]
         except KeyError:
             pass
-        print(possible_entries)
         return possible_entries
 
     @property
diff --git a/src/box/initialization.py b/src/box/initialization.py
index bbd8fc5..022ee15 100644
--- a/src/box/initialization.py
+++ b/src/box/initialization.py
@@ -17,6 +17,7 @@ class InitializeProject:
         self,
         quiet: bool = False,
         builder: str = None,
+        build_command: str = None,
         optional_deps: str = None,
         is_gui: bool = None,
         app_entry: str = None,
@@ -28,6 +29,7 @@ class InitializeProject:
 
         :param quiet: Flag to suppress output
         :param builder: Builder tool to use.
+        :param build_command: Build command to use with custom builder.
         :param optional_deps: Optional dependencies for the project.
         :param is_gui: Flag to set the project as a GUI project.
         :param app_entry: App entry for the project.
@@ -37,6 +39,7 @@ class InitializeProject:
         """
         self._quiet = quiet
         self._builder = builder
+        self._build_command = build_command
         self._optional_deps = optional_deps
         self._is_gui = is_gui
         self._opt_paypp_vars = opt_pyapp_vars
@@ -47,6 +50,9 @@ class InitializeProject:
         self.app_entry = None
         self.pyproj = None
 
+        if self._build_command:
+            self._build_command = self._build_command.strip("'\"")
+
         self._set_pyproj()
 
     def initialize(self):
@@ -137,7 +143,7 @@ class InitializeProject:
 
     def _set_builder(self):
         """Set the builder for the project (defaults to rye)."""
-        possible_builders = PackageApp().builders
+        possible_builders = PackageApp().builders_and_custom
 
         default_builder = "rye"
         try:
@@ -147,6 +153,13 @@ class InitializeProject:
 
         if self._builder:
             builder = self._builder
+            if builder == "custom":
+                if self._build_command:
+                    builder = f"{builder}={self._build_command}"
+                else:
+                    raise click.ClickException(
+                        "Custom build command must be set (--build-command)."
+                    )
         else:
             if self._quiet:
                 builder = default_builder
@@ -156,6 +169,15 @@ class InitializeProject:
                     type=click.Choice(possible_builders),
                     default=default_builder,
                 )
+                if builder == "custom":
+                    if self._build_command:
+                        builder_cmd = self._build_command
+                    else:
+                        builder_cmd = click.prompt(
+                            "Enter the custom builder command for the project.",
+                            type=str,
+                        )
+                    builder = f"{builder}={builder_cmd}"
 
         pyproject_writer("builder", builder)
         # reload
diff --git a/src/box/packager.py b/src/box/packager.py
index 6d3f891..2a05787 100644
--- a/src/box/packager.py
+++ b/src/box/packager.py
@@ -63,9 +63,14 @@ class PackageApp:
 
     @property
     def builders(self) -> List:
-        """Return a list of supported builders and their commands."""
+        """Return a list of supported builders."""
         return list(self._builders.keys())
 
+    @property
+    def builders_and_custom(self) -> List:
+        """Return a list of supported builders and custom builder."""
+        return self.builders + ["custom"]
+
     @property
     def binary_name(self):
         return self._binary_name
@@ -77,15 +82,32 @@ class PackageApp:
             self._config = PyProjectParser()
         return self._config
 
-    def build(self):
-        """Build the project with PyApp."""
+    @property
+    def builder_command(self) -> List[str]:
+        """Get the command list to run for specific builder.
+
+        :param builder: Builder to run with.
+
+        :raises KeyError: Unknown builder.
+        """
         builder = self.config.builder
-        fmt.info(f"Building project with {builder}...")
+
+        if builder.lower().startswith("custom"):
+            cmd = builder.split("=", 1)[1].strip("'\"")
+            return cmd.split(" ")
+
         try:
-            subprocess.run(self._builders[builder], **self.subp_kwargs)
+            return self._builders[builder]
         except KeyError as e:
             raise KeyError(f"Unknown {builder=}") from e
 
+    def build(self):
+        """Build the project with PyApp."""
+        builder = self.config.builder
+        fmt.info(f"Building project with {builder}...")
+
+        subprocess.run(self.builder_command, **self.subp_kwargs)
+
         fmt.success(f"Project built with {builder}.")
 
     def package(self, pyapp_version="latest", local_source: Union[Path, str] = None):


Test patch:
diff --git a/tests/cli/test_cli_initialization.py b/tests/cli/test_cli_initialization.py
index 3894b88..068891e 100644
--- a/tests/cli/test_cli_initialization.py
+++ b/tests/cli/test_cli_initialization.py
@@ -152,6 +152,25 @@ def test_initialize_project_again(rye_project_no_box):
     assert pyproj.app_entry_type == entry_type
 
 
+def test_initialize_again_custom_builder(rye_project_no_box):
+    """If a custom builder is specified, make sure that re-initialization keeps it."""
+    build_cmd = "command -my-package --python"
+
+    runner = CliRunner()
+    result = runner.invoke(
+        cli, ["init", "-q", "-b", "custom", "--build-command", build_cmd]
+    )
+    assert result.exit_code == 0
+
+    builder_1 = PyProjectParser().builder
+
+    result = runner.invoke(cli, ["init", "-q"])
+    assert result.exit_code == 0
+
+    builder_2 = PyProjectParser().builder
+    assert builder_1 == builder_2
+
+
 def test_initialize_project_quiet(rye_project_no_box):
     """Initialize a new project quietly."""
     runner = CliRunner()
@@ -178,6 +197,60 @@ def test_initialize_project_builders(rye_project_no_box, builder):
     assert pyproj.builder == builder
 
 
+def test_initialize_project_custom_builder(rye_project_no_box):
+    """Initialize with a custom builder."""
+    runner = CliRunner()
+    result = runner.invoke(
+        cli, ["init"], input="custom\nbuild -my --package --now\n\n\nsome_entry"
+    )
+    assert result.exit_code == 0
+
+    pyproj = PyProjectParser()
+    assert pyproj.builder == "custom=build -my --package --now"
+
+
+def test_initialize_project_custom_builder_build_command_provided(rye_project_no_box):
+    """Initialize with a custom builder and build command provided (rare)."""
+    runner = CliRunner()
+    result = runner.invoke(
+        cli,
+        ["init", "--build-command", "'build -my --package --now'"],
+        input="custom\n\n\nsome_entry",
+    )
+    assert result.exit_code == 0
+
+    pyproj = PyProjectParser()
+    assert pyproj.builder == "custom=build -my --package --now"
+
+
+def test_initialize_custom_builder_option(rye_project_no_box):
+    """Initialize with custom builder and quiet."""
+    runner = CliRunner()
+    result = runner.invoke(
+        cli,
+        [
+            "init",
+            "-b",
+            "custom",
+            "--build-command",
+            "'build -my --package --now'",
+            "-q",
+        ],
+    )
+    assert result.exit_code == 0
+
+    pyproj = PyProjectParser()
+    assert pyproj.builder == "custom=build -my --package --now"
+
+
+def test_initialize_custom_builder_quiet_cmd_not_set(rye_project_no_box):
+    """Fail if builder is custom, quiet is set, but no cmd is supplied."""
+    runner = CliRunner()
+    result = runner.invoke(cli, ["init", "-b", "custom", "-q"])
+    assert result.exit_code != 0
+    assert "Custom build command must be set" in result.output
+
+
 def test_initialize_project_quiet_no_project_script(rye_project_no_box):
     """Initialize a new project quietly with app_entry as the package name."""
     runner = CliRunner()
diff --git a/tests/unit/test_packager.py b/tests/unit/test_packager.py
index 97d376b..fa80f85 100644
--- a/tests/unit/test_packager.py
+++ b/tests/unit/test_packager.py
@@ -73,6 +73,27 @@ def test_builders(min_proj_no_box, mocker, builder):
     assert packager._dist_path == expected_path
 
 
+def test_custom_builder(min_proj_no_box, mocker):
+    """Test custom builder called correctly."""
+    # mock subprocess.run
+    sp_mock = mocker.patch("subprocess.run")
+
+    build_cmd = "my -build --command=3"
+
+    # write builder to pyproject.toml file
+    pyproject_writer("builder", f"custom='{build_cmd}'")
+
+    packager = PackageApp()
+    packager.build()
+
+    sp_mock.assert_called_with(
+        build_cmd.split(" "), stdout=mocker.ANY, stderr=mocker.ANY
+    )
+
+    expected_path = min_proj_no_box.joinpath("dist")
+    assert packager._dist_path == expected_path
+
+
 def test_get_pyapp_extraction(rye_project, mocker):
     """Extract and set and path for PyApp source code."""
     mocker.patch.object(urllib.request, "urlretrieve")