Skip to content

Commit

Permalink
custom build command (#55)
Browse files Browse the repository at this point in the history
* remove a rogue print

* Enable possibility to use custom builder in initialization

Full string with "custom=all the build commands" are written into pyproject.toml

* refractor getting building command into its own property

* Custom builder is now properly called when packaging

* add a test to ensure reinit works with custom builder

* extend docs to include custom builder

* add a test for covering initialization better
  • Loading branch information
trappitsch authored Aug 31, 2024
1 parent 7009d26 commit 69a21aa
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 9 deletions.
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -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!**
Expand Down
3 changes: 2 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/box/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down Expand Up @@ -67,6 +75,7 @@ def cli():
def init(
quiet,
builder,
build_command,
optional_deps,
gui,
entry,
Expand All @@ -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,
Expand Down
1 change: 0 additions & 1 deletion src/box/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ def possible_app_entries(self) -> OrderedDict:
possible_entries["scripts"] = self._project["scripts"]
except KeyError:
pass
print(possible_entries)
return possible_entries

@property
Expand Down
24 changes: 23 additions & 1 deletion src/box/initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def __init__(
self,
quiet: bool = False,
builder: str = None,
build_command: str = None,
optional_deps: str = None,
is_gui: bool = None,
app_entry: str = None,
Expand All @@ -28,6 +29,7 @@ def __init__(
: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.
Expand All @@ -37,6 +39,7 @@ def __init__(
"""
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
Expand All @@ -47,6 +50,9 @@ def __init__(
self.app_entry = None
self.pyproj = None

if self._build_command:
self._build_command = self._build_command.strip("'\"")

self._set_pyproj()

def initialize(self):
Expand Down Expand Up @@ -137,7 +143,7 @@ def _set_app_entry_type(self):

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:
Expand All @@ -147,6 +153,13 @@ def _set_builder(self):

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
Expand All @@ -156,6 +169,15 @@ def _set_builder(self):
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
Expand Down
32 changes: 27 additions & 5 deletions src/box/packager.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,14 @@ def __init__(self, verbose=False):

@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
Expand All @@ -77,15 +82,32 @@ def config(self) -> PyProjectParser:
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):
Expand Down
73 changes: 73 additions & 0 deletions tests/cli/test_cli_initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/test_packager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down

0 comments on commit 69a21aa

Please sign in to comment.