Skip to content

Commit

Permalink
fix(bootstrap): Improving robustness and test coverage for bootstrap …
Browse files Browse the repository at this point in the history
…poetry command (#89)

feat(bootstrap): Added algokit bootstrap all command and algokit bootstrap env command

fix(bootstrap): Improving robustness and test coverage for bootstrap poetry command

fix(bootstrap): Uses system python to invoke pipx rather than the current venv, but fall back to attempting to find base interpreter for algokit venv and see if pipx exists there

feat(init): Include option to call `algokit bootstrap all` as part of initialising a template (but don't bail if it fails)

this means lock files will be committed initially too which is a good thing

chore(bootstrap): Removing --ok-exit-code

BREAKING CHANGE: --ok-exit-code no longer exists on algokit bootstrap poetry, no need for copier templates to call algokit now so no need for this feature

testing(bootstrap): add test for system python preference

feat(bootstrap): Added prompt before installing poetry

Co-authored-by: Adam Chidlow <[email protected]>
  • Loading branch information
robdmoore and achidlow authored Dec 16, 2022
1 parent 8013159 commit a4a6823
Show file tree
Hide file tree
Showing 50 changed files with 835 additions and 77 deletions.
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ line-length = 120

[tool.pytest.ini_options]
pythonpath = ["src", "tests"]
addopts = "--cov=algokit"

[tool.mypy]
files = "src/"
Expand Down
68 changes: 19 additions & 49 deletions src/algokit/cli/bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import logging
import sys
from pathlib import Path

import click
from algokit.core import proc
from algokit.core.bootstrap import bootstrap_any_including_subdirs, bootstrap_env, bootstrap_poetry
from algokit.core.questionary_extensions import _get_confirm_default_yes_prompt

logger = logging.getLogger(__name__)

Expand All @@ -12,51 +13,20 @@ def bootstrap_group() -> None:
pass


@bootstrap_group.command("poetry", short_help="Bootstrap Python Poetry and install in the current working directory.")
@click.option(
"--ok-exit-code",
default=False,
help="Always return a 0 exit code; useful when calling as task from a Copier template to avoid template delete.",
is_flag=True,
@bootstrap_group.command(
"all", short_help="Bootstrap all aspects of the current directory and immediate sub directories by convention."
)
def poetry(*, ok_exit_code: bool) -> None:
try:
python = sys.executable
try:
proc.run(["poetry", "-V"])
except IOError:
# An IOError (such as PermissionError or FileNotFoundError) will only occur if "poetry"
# isn't an executable in the user's path, which means poetry isn't installed
try:
proc.run(["pipx", "--version"])
except IOError:
# An IOError (such as PermissionError or FileNotFoundError) will only occur if "pipx"
# isn't an executable in the user's path, which means pipx isn't installed

proc.run(
[python, "-m", "pip", "install", "--user", "pipx"],
bad_return_code_error_message="Unable to install pipx; please install"
+ " pipx manually via https://pypa.github.io/pipx/ and try `algokit bootstrap poetry` again.",
)

proc.run(
[python, "-m", "pipx", "ensurepath"],
bad_return_code_error_message="Unable to update pipx install path to global path;"
+ " please set the path manually and try `algokit bootstrap poetry` again.",
)

proc.run(
["pipx", "install", "poetry"],
bad_return_code_error_message="Unable to install poetry via pipx; please install poetry"
+ " manually via https://python-poetry.org/docs/ and try `algokit bootstrap poetry` again.",
)

logger.info("Installing Python dependencies and setting up Python virtual environment via Poetry")
proc.run(["poetry", "install"], stdout_log_level=logging.INFO)

except Exception as ex:
logger.error(ex)
if ok_exit_code:
logger.error("Error bootstrapping poetry; try running `poetry install` manually.")
else:
raise click.ClickException("Error bootstrapping poetry; try running `poetry install` manually.") from ex
def bootstrap_all() -> None:
cwd = Path.cwd()
bootstrap_any_including_subdirs(cwd, _get_confirm_default_yes_prompt)
logger.info(f"Finished bootstrapping {cwd}")


@bootstrap_group.command("env", short_help="Bootstrap .env file in the current working directory.")
def env() -> None:
bootstrap_env(Path.cwd())


@bootstrap_group.command("poetry", short_help="Bootstrap Python Poetry and install in the current working directory.")
def poetry() -> None:
bootstrap_poetry(Path.cwd(), _get_confirm_default_yes_prompt)
49 changes: 43 additions & 6 deletions src/algokit/cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from dataclasses import dataclass
from pathlib import Path

from algokit.core.bootstrap import bootstrap_any_including_subdirs
from algokit.core.questionary_extensions import _get_confirm_default_yes_prompt
from algokit.core.sandbox import DEFAULT_ALGOD_PORT, DEFAULT_ALGOD_SERVER, DEFAULT_ALGOD_TOKEN, DEFAULT_INDEXER_PORT

try:
Expand Down Expand Up @@ -118,6 +120,13 @@ def validate_dir_name(context: click.Context, param: click.Parameter, value: str
default=False,
help="Automatically choose default answers without asking when creating this template.",
)
@click.option(
"run_bootstrap",
"--bootstrap/--no-bootstrap",
is_flag=True,
default=None,
help="Whether to run `algokit bootstrap` to bootstrap the new project's dependencies.",
)
@click.option(
"answers",
"--answer",
Expand All @@ -136,6 +145,7 @@ def init_command(
use_git: bool | None,
answers: list[tuple[str, str]],
use_defaults: bool, # noqa: FBT001
run_bootstrap: bool | None,
) -> None:
"""Initializes a new project from a template."""
# TODO: in general, we should probably find a way to log all command invocations to the log file?
Expand All @@ -145,9 +155,10 @@ def init_command(
# parse the input early to prevent frustration - combined with some defaults but they can be overridden
answers_dict = DEFAULT_ANSWERS | dict(answers)

project_path, directory_name = _get_project_path(directory_name)
if not answers_dict.get("project_name"):
answers_dict = answers_dict | {"project_name": directory_name}
project_path = _get_project_path(directory_name)
directory_name = project_path.name
# provide the directory name as an answer to the template, if not explicitly overridden by user
answers_dict.setdefault("project_name", directory_name)

if template_name:
blessed_templates = _get_blessed_templates()
Expand Down Expand Up @@ -179,7 +190,23 @@ def init_command(
)

expanded_template_url = copier_worker.template.url_expanded
logger.debug(f"Project initialisation complete, final clone URL = {expanded_template_url}")
logger.debug(f"Template initialisation complete, final clone URL = {expanded_template_url}")

if run_bootstrap is None:
# if user didn't specify a bootstrap option, then assume yes if using defaults, otherwise prompt
run_bootstrap = use_defaults or _get_run_bootstrap()
if run_bootstrap:
# note: we run bootstrap before git commit so that we can commit any lock files,
# but if something goes wrong, we don't want to block
try:
bootstrap_any_including_subdirs(project_path, _get_confirm_default_yes_prompt)
except Exception:
logger.exception(
"Bootstrap failed. Once any errors above are resolved, "
f"you can run `algokit bootstrap` in {project_path}",
exc_info=True,
)

if _should_attempt_git_init(use_git_option=use_git, project_path=project_path):
_git_init(
project_path, commit_message=f"Project initialised with AlgoKit CLI using template: {expanded_template_url}"
Expand Down Expand Up @@ -226,7 +253,7 @@ def validate(self, document: prompt_toolkit.document.Document) -> None:
)


def _get_project_path(directory_name_option: str | None = None) -> tuple[Path, str]:
def _get_project_path(directory_name_option: str | None = None) -> Path:
base_path = Path.cwd()
if directory_name_option is not None:
directory_name = directory_name_option
Expand All @@ -252,7 +279,7 @@ def _get_project_path(directory_name_option: str | None = None) -> tuple[Path, s
return _get_project_path()
else:
_fail_and_bail()
return project_path, directory_name
return project_path


class GitRepoValidator(questionary.Validator):
Expand Down Expand Up @@ -337,3 +364,13 @@ def git(*args: str, bad_exit_warn_message: str) -> bool:
if git("add", "--all", bad_exit_warn_message="Failed to add generated project files"):
if git("commit", "-m", commit_message, bad_exit_warn_message="Initial commit failed"):
logger.info("🎉 Performed initial git commit successfully! 🎉")


def _get_run_bootstrap() -> bool:
return bool(
questionary.confirm(
"Do you want to run `algokit bootstrap` to bootstrap dependencies"
+ " for this new project so it can be run immediately?",
default=True,
).unsafe_ask()
)
172 changes: 172 additions & 0 deletions src/algokit/core/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import logging
import platform
import sys
from pathlib import Path
from shutil import copyfile, which
from typing import Callable, Iterator

import click
from algokit.core import proc

ENV_TEMPLATE = ".env.template"

logger = logging.getLogger(__name__)


def bootstrap_any(project_dir: Path, install_prompt: Callable[[str], bool]) -> None:
env_path = project_dir / ENV_TEMPLATE
poetry_path = project_dir / "poetry.toml"
pyproject_path = project_dir / "pyproject.toml"

logger.debug(f"Checking {project_dir} for bootstrapping needs")

if env_path.exists():
logger.debug("Running `algokit bootstrap env`")
bootstrap_env(project_dir)

if poetry_path.exists() or (pyproject_path.exists() and "[tool.poetry]" in pyproject_path.read_text("utf-8")):
logger.debug("Running `algokit bootstrap poetry`")
bootstrap_poetry(project_dir, install_prompt)


def bootstrap_any_including_subdirs(base_path: Path, install_prompt: Callable[[str], bool]) -> None:
bootstrap_any(base_path, install_prompt)

for sub_dir in sorted(base_path.iterdir()): # sort needed for test output ordering
if sub_dir.is_dir():
if sub_dir.name.lower() in [".venv", "node_modules", "__pycache__"]:
logger.debug(f"Skipping {sub_dir}")
else:
bootstrap_any(sub_dir, install_prompt)


def bootstrap_env(project_dir: Path) -> None:
env_path = project_dir / ".env"
env_template_path = project_dir / ENV_TEMPLATE

if env_path.exists():
logger.info(".env already exists; skipping bootstrap of .env")
else:
logger.debug(f"{env_path} doesn't exist yet")
if not env_template_path.exists():
logger.info("No .env or .env.template file; nothing to do here, skipping bootstrap of .env")
else:
logger.debug(f"{env_template_path} exists")
logger.info(f"Copying {env_template_path} to {env_path}")
copyfile(env_template_path, env_path)


def bootstrap_poetry(project_dir: Path, install_prompt: Callable[[str], bool]) -> None:
try:
proc.run(
["poetry", "--version"],
bad_return_code_error_message="poetry --version failed, please check your poetry install",
)
try_install_poetry = False
except IOError:
try_install_poetry = True

if try_install_poetry:
logger.info("Poetry not found; attempting to install it...")
if not install_prompt(
"We couldn't find `poetry`; can we install it for you via pipx so we can install Python dependencies?"
):
raise click.ClickException(
(
"Unable to install poetry via pipx; please install poetry "
"manually via https://python-poetry.org/docs/ and try `algokit bootstrap poetry` again."
)
)
pipx_command = _find_valid_pipx_command()
proc.run(
[*pipx_command, "install", "poetry"],
bad_return_code_error_message=(
"Unable to install poetry via pipx; please install poetry "
"manually via https://python-poetry.org/docs/ and try `algokit bootstrap poetry` again."
),
)

logger.info("Installing Python dependencies and setting up Python virtual environment via Poetry")
try:
proc.run(["poetry", "install"], stdout_log_level=logging.INFO, cwd=project_dir)
except IOError as e:
if not try_install_poetry:
raise # unexpected error, we already ran without IOError before
else:
raise click.ClickException(
(
"Unable to access Poetry on PATH after installing it via pipx; "
"check pipx installations are on your path by running `pipx ensurepath` "
"and try `algokit bootstrap poetry` again."
)
) from e


def _find_valid_pipx_command() -> list[str]:
for pipx_command in _get_candidate_pipx_commands():
try:
pipx_version_result = proc.run([*pipx_command, "--version"])
except IOError:
pass # in case of path/permission issues, go to next candidate
else:
if pipx_version_result.exit_code == 0:
return pipx_command
# If pipx isn't found in global path or python -m pipx then bail out
# this is an exceptional circumstance since pipx should always be present with algokit
# since it's installed with brew / choco as a dependency, and otherwise is used to install algokit
raise click.ClickException(
(
"Unable to find pipx install so that poetry can be installed; "
"please install pipx via https://pypa.github.io/pipx/ "
"and then try `algokit bootstrap poetry` again."
)
)


def _get_candidate_pipx_commands() -> Iterator[list[str]]:
# first try is pipx via PATH
yield ["pipx"]
# otherwise try getting an interpreter with pipx installed as a module,
# this won't work if pipx is installed in its own venv but worth a shot
for python_path in _get_python_paths():
yield [python_path, "-m", "pipx"]


def _get_python_paths() -> Iterator[str]:
for python_name in ("python3", "python"):
if python_path := which(python_name):
yield python_path
python_base_path = _get_base_python_path()
if python_base_path is not None:
yield python_base_path


def _get_base_python_path() -> str | None:
this_python: str | None = sys.executable
if not this_python:
# Not: can be empty or None... yikes! unlikely though
# https://docs.python.org/3.10/library/sys.html#sys.executable
return None
# not in venv... not recommended to install algokit this way, but okay
if sys.prefix == sys.base_prefix:
return this_python
this_python_path = Path(this_python)
# try resolving symlink, this should be default on *nix
try:
if this_python_path.is_symlink():
return str(this_python_path.resolve())
except (OSError, RuntimeError):
pass
# otherwise, try getting an internal value which should be set when running in a .venv
# this will be the value of `home = <path>` in pyvenv.cfg if it exists
if base_home := getattr(sys, "_home", None):
base_home_path = Path(base_home)
is_windows = platform.system() == "Windows"
for name in ("python", "python3", f"python3.{sys.version_info.minor}"):
candidate_path = base_home_path / name
if is_windows:
candidate_path = candidate_path.with_suffix(".exe")
if candidate_path.is_file():
return str(candidate_path)
# give up, we tried...
return this_python
9 changes: 9 additions & 0 deletions src/algokit/core/questionary_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,12 @@ def __init__(self, *validators: questionary.Validator):
def validate(self, document: prompt_toolkit.document.Document) -> None:
for validator in self._validators:
validator.validate(document)


def _get_confirm_default_yes_prompt(prompt: str) -> bool:
return bool(
questionary.confirm(
prompt,
default=True,
).unsafe_ask()
)
9 changes: 9 additions & 0 deletions tests/bootstrap/test_bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from utils.approvals import verify
from utils.click_invoker import invoke


def test_bootstrap_help():
result = invoke("bootstrap -h")

assert result.exit_code == 0
verify(result.output)
10 changes: 10 additions & 0 deletions tests/bootstrap/test_bootstrap.test_bootstrap_help.approved.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Usage: algokit bootstrap [OPTIONS] COMMAND [ARGS]...

Options:
-h, --help Show this message and exit.

Commands:
all Bootstrap all aspects of the current directory and immediate sub
directories by convention.
env Bootstrap .env file in the current working directory.
poetry Bootstrap Python Poetry and install in the current working directory.
Loading

1 comment on commit a4a6823

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/algokit
   __init__.py15753%6–13, 17–24, 32–34
   __main__.py220%1–3
src/algokit/cli
   init.py1651690%56, 203–204, 235, 238–240, 251, 289, 326, 335–337, 340–345, 360
src/algokit/core
   bootstrap.py1001684%74, 94, 149, 152, 158–172
   click_extensions.py472057%40–43, 50, 56, 67–68, 73–74, 79–80, 91, 104–114
   conf.py27967%10–17, 24, 26
   log_handlers.py68987%44–45, 50–51, 63, 112–116, 125
   proc.py44198%94
   sandbox.py106793%82, 147, 163, 178–180, 195
TOTAL9078790% 

Tests Skipped Failures Errors Time
102 0 💤 0 ❌ 0 🔥 8.764s ⏱️

Please sign in to comment.