Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multiple builders #14

Merged
merged 6 commits into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/box/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def package(verbose):
"""Build the project, then package it with PyApp."""
ut.check_boxproject()
my_packager = PackageApp(verbose=verbose)
my_packager.check_requirements()
my_packager.build()
my_packager.package()
binary_file = my_packager.binary_name
Expand Down
19 changes: 11 additions & 8 deletions src/box/initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from box.config import PyProjectParser, pyproject_writer
import box.formatters as fmt
from box.packager import PackageApp


class InitializeProject:
Expand Down Expand Up @@ -39,15 +40,17 @@ def initialize(self):
fmt.success("Project initialized.")

def _set_builder(self):
"""Set the builder for the project."""
try:
_ = self.pyproj.rye
pyproject_writer("builder", "rye")
except KeyError:
raise click.ClickException(
"No builder tool was found in configuration. "
"Currently only `rye` is supported."
"""Set the builder for the project (defaults to rye)."""
possible_builders = PackageApp().builders.keys()
if self._quiet:
builder = "rye"
else:
builder = click.prompt(
"Choose a builder tool for the project.",
type=click.Choice(possible_builders),
default="rye",
)
pyproject_writer("builder", builder)
# reload
self._set_pyproj()

Expand Down
70 changes: 45 additions & 25 deletions src/box/packager.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,42 +33,62 @@ def __init__(self, verbose=False):
self.binary_name = None # name of the binary file at the end of packaging

# self._builder = box_config.builder
self._dist_path = None
self._dist_path = Path.cwd().joinpath("dist")
self._pyapp_path = None

# supported builders
self._builders = {
"rye": ["rye", "build", "--out", f"{self._dist_path}", "--sdist"],
"hatch": ["hatch", "build", "-t", "sdist"],
"pdm": ["pdm", "build", "--no-wheel", "-d", f"{self._dist_path}"],
"build": [
ut.cmd_python(),
"-m",
"build",
"--sdist",
"--outdir",
f"{self._dist_path}",
],
"flit": ["flit", "build", "--format", "sdist"],
}

self._build_dir = Path.cwd().joinpath(BUILD_DIR_NAME)
self._release_dir = Path.cwd().joinpath(RELEASE_DIR_NAME)
self._build_dir.mkdir(exist_ok=True)
self._release_dir.mkdir(parents=True, exist_ok=True)

self._config = PyProjectParser()
self._config = None

@property
def builders(self):
"""Return a dictionary with supported builders and their commands."""
return self._builders

self._check_requirements()
@property
def config(self) -> PyProjectParser:
"""Return the project configuration."""
if self._config is None:
self._config = PyProjectParser()
return self._config

def build(self):
"""Build the project with PyApp."""
builder = self._config.builder
builder = self.config.builder
fmt.info(f"Building project with {builder}...")
if builder == "rye":
self._build_rye()
else:
raise ValueError("Unknown builder")
try:
subprocess.run(self._builders[builder], **self.subp_kwargs)
except KeyError as e:
raise KeyError("Unknown builder") from e

fmt.success(f"Project built with {builder}.")

def package(self):
"""Package the project with PyApp."""
fmt.info("Hold on, packaging the project with PyApp...")
self._build_dir.mkdir(exist_ok=True)
self._release_dir.mkdir(parents=True, exist_ok=True)
self._get_pyapp()
self._set_env()
self._package_pyapp()

def _build_rye(self):
"""Build the project with rye."""
subprocess.run(["rye", "build"], **self.subp_kwargs)

self._dist_path = Path.cwd().joinpath("dist")

def _get_pyapp(self):
"""Download the PyApp source code and extract to `build/pyapp-latest` folder.

Expand Down Expand Up @@ -147,9 +167,9 @@ def _package_pyapp(self):
if not binary_path.is_file():
binary_path = binary_path.with_suffix(".exe") # we are probably on windows!
suffix = ".exe"
self.binary_name = self._release_dir.joinpath(
self._config.name_pkg
).with_suffix(suffix)
self.binary_name = self._release_dir.joinpath(self.config.name_pkg).with_suffix(
suffix
)
shutil.move(binary_path, self.binary_name)

def _set_env(self):
Expand All @@ -162,22 +182,22 @@ def _set_env(self):
# find the tar.gz file in dist folder with correct version number
dist_file = None
for file in self._dist_path.iterdir():
if self._config.version in file.name and file.suffix == ".gz":
if self.config.version in file.name and file.suffix == ".gz":
dist_file = file
break

# set variables
os.environ["PYAPP_PROJECT_NAME"] = self._config.name_pkg
os.environ["PYAPP_PROJECT_VERSION"] = self._config.version
os.environ["PYAPP_PROJECT_NAME"] = self.config.name_pkg
os.environ["PYAPP_PROJECT_VERSION"] = self.config.version
os.environ["PYAPP_PROJECT_PATH"] = str(dist_file)
# fixme: this whole thing is a hack. give options for entry, see PyApp docs
os.environ["PYAPP_EXEC_SPEC"] = self._config.app_entry
if value := self._config.optional_dependencies:
os.environ["PYAPP_EXEC_SPEC"] = self.config.app_entry
if value := self.config.optional_dependencies:
os.environ["PYAPP_PIP_OPTIONAL_DEPS"] = value

# STATIC METHODS #
@staticmethod
def _check_requirements():
def check_requirements():
"""Check if all requirements are installed."""
# check for cargo
try:
Expand Down
16 changes: 16 additions & 0 deletions src/box/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ def check_pyproject() -> None:
raise ClickException("No pyproject.toml file found.")


def cmd_python() -> str:
"""Get the command to run Python on the current operating system.

:return: Command to run Python
"""
return "py" if is_windows() else "python"


def is_windows() -> bool:
"""Check if the operating system is Windows.

:return: True if Windows, False otherwise
"""
return os.name == "nt"


@contextmanager
def set_dir(dir: Path) -> None:
"""Context manager to change directory to a specific one and then go back on exit.
Expand Down
39 changes: 17 additions & 22 deletions tests/cli/test_cli_initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@

from box.cli import cli
from box.config import PyProjectParser
from box.packager import PackageApp


@pytest.mark.parametrize("app_entry", ["\nhello", "gui\n127\nhello"])
@pytest.mark.parametrize("app_entry", ["\n\nhello", "\ngui\n127\nhello"])
def test_initialize_project_app_entry_typed(rye_project_no_box, app_entry):
"""Initialize a new project."""
# modify pyproject.toml to contain an app entry
Expand Down Expand Up @@ -38,6 +39,9 @@ def test_initialize_project_app_entry_typed(rye_project_no_box, app_entry):
if (deps_exp := app_entry.split("\n")[0]) != "":
assert pyproj.optional_dependencies == deps_exp

# assert that default builder is set to rye
assert pyproj.builder == "rye"


def test_initialize_project_quiet(rye_project_no_box):
"""Initialize a new project quietly."""
Expand All @@ -51,6 +55,18 @@ def test_initialize_project_quiet(rye_project_no_box):
assert pyproj.is_box_project


@pytest.mark.parametrize("builder", PackageApp().builders.keys())
def test_initialize_project_builders(rye_project_no_box, builder):
"""Initialize a new project with a specific builder."""
runner = CliRunner()
result = runner.invoke(cli, ["init"], input=f"{builder}\n\nsome_entry")
assert result.exit_code == 0

# assert that default builder is set to rye
pyproj = PyProjectParser()
assert pyproj.builder == builder


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 All @@ -63,27 +79,6 @@ def test_initialize_project_quiet_no_project_script(rye_project_no_box):
assert pyproj.is_box_project


# EXCEPTIONS #


def test_no_builder():
"""Abort if no builder tooling was found."""
runner = CliRunner()
with runner.isolated_filesystem():
# write toml file with no builder
with open("pyproject.toml", "w") as f:
f.write(
"""[project]
name = "myapp"
version = "0.1.0"
"""
)

result = runner.invoke(cli, ["init", "-q"])
assert result.exit_code != 0
assert result.output.__contains__("No builder tool was found in configuration.")


def test_pyproject_does_not_exist():
"""Abort if no `pyroject.toml` file is found."""
runner = CliRunner()
Expand Down
19 changes: 18 additions & 1 deletion tests/cli/test_cli_packager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Test builder with CLI - system calls mostly mocked, full build in unit tests

import os
from pathlib import Path
import urllib.request

from click.testing import CliRunner
Expand Down Expand Up @@ -52,7 +54,22 @@ def test_package_project(rye_project, mocker, verbose):
assert result.output.__contains__("Project successfully packaged.")

# assert system calls
sp_run_mock.assert_any_call(["rye", "build"], **subp_kwargs)
sp_run_mock.assert_any_call(
["rye", "build", "--out", f"{Path.cwd().joinpath('dist')}", "--sdist"],
**subp_kwargs,
)
sp_run_mock.assert_called_with(
["cargo", "build", "--release"], cwd=pyapp_dir, **subp_kwargs
)


def test_cargo_not_found(rye_project, mocker):
"""Test that cargo not found raises an exception."""
# mock $PATH to remove cargo
mocker.patch.dict(os.environ, {"PATH": ""})

runner = CliRunner()
result = runner.invoke(cli, "package")

assert result.exit_code == 1
assert "cargo not found" in result.output
19 changes: 19 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@
from box.config import pyproject_writer


@pytest.fixture
def min_proj_no_box(tmp_path):
"""Create a minimal project with a `pyproject.toml` file."""
current_dir = Path().absolute()
os.chdir(tmp_path)
toml_data = """[project]
name = "myapp"
version = "0.1.0"
dependencies = []
"""
with open("pyproject.toml", "w") as f:
f.write(toml_data)

yield tmp_path

# clean up
os.chdir(current_dir)


@pytest.fixture
def tmp_path_chdir(tmp_path):
"""Change directory to tmp_path and set back at end."""
Expand Down
23 changes: 11 additions & 12 deletions tests/func/test_packager.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,23 @@ def create_pyapp_source(project_path: Path) -> Path:
# TESTS #


def test_cargo_not_found(rye_project, mocker):
"""Test that cargo not found raises an exception."""
# mock $PATH to remove cargo
mocker.patch.dict(os.environ, {"PATH": ""})
with pytest.raises(click.ClickException):
PackageApp()


def test_build_rye(rye_project, mocker):
"""Test that rye build is called and dist folder is found."""
@pytest.mark.parametrize("builder", ["rye", "hatch", "build", "flit", "pdm"])
def test_builders(min_proj_no_box, mocker, builder):
"""Test all builders are called correctly."""
# mock subprocess.run
sp_mock = mocker.patch("subprocess.run")

# write builder to pyproject.toml file
pyproject_writer("builder", builder)

packager = PackageApp()
packager.build()

sp_mock.assert_called_with(["rye", "build"], stdout=mocker.ANY, stderr=mocker.ANY)
sp_mock.assert_called_with(
packager.builders[builder], stdout=mocker.ANY, stderr=mocker.ANY
)

expected_path = rye_project.joinpath("dist")
expected_path = min_proj_no_box.joinpath("dist")
assert packager._dist_path == expected_path


Expand Down Expand Up @@ -130,6 +128,7 @@ def test_get_pyapp_no_file_found(rye_project, mocker):
url_mock = mocker.patch.object(urllib.request, "urlretrieve")

packager = PackageApp()
packager._build_dir.mkdir(parents=True, exist_ok=True) # avoid error
with pytest.raises(click.ClickException) as e:
packager._get_pyapp()

Expand Down
11 changes: 11 additions & 0 deletions tests/func/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@
import box.utils as ut


@pytest.mark.parametrize(
"os_python",
[["nt", "py"], ["posix", "python"]],
)
def test_cmd_python(mocker, os_python):
"""Get python on mulitple operating systems."""
# mock os.name
mocker.patch("os.name", os_python[0])
assert ut.cmd_python() == os_python[1]


def test_check_boxproject(rye_project):
"""Check if the box project is already initialized."""
ut.check_boxproject()
Expand Down
Loading