diff --git a/README.md b/README.md index c17aa2c..0684889 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,48 @@ Clone the repository and install poetry-slam locally. You need the following pre % pip3 install dist/*.whl ``` -## Usage +## Starting a New Project -### Configuring Your Project +poetry-slam can generate a boilerplate python project with a cli: + +```bash +% mkdir /tmp/test_project +% cd /tmp/test_project +% git init +Initialized empty Git repository in /tmp/test_project/.git/ + +% slam new test_project +Added poetry-slam defaults to pyproject.toml +Formatting... +Installing... +Testing... +========== test session starts ========== +platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0 +rootdir: /tmp/test_project +configfile: pyproject.toml +plugins: cov-5.0.0 +collected 1 item + +test/test_test_project.py x [100%] +test/test_test_project_cli.py x [100%] + +---------- coverage: platform linux, python 3.10.12-final-0 ---------- +Name Stmts Miss Cover Missing +------------------------------------------------------------ +src/test_project/__init__.py 0 0 100% +src/test_project/cli.py 23 9 61% 37-50, 57 +------------------------------------------------------------ +TOTAL 23 9 61% + + +========== 2 xfailed in 0.11s ========== +Building... +slam build: SUCCESS +Successfully initialized new default project test_project. +``` + + +## Configuring An Existing Project poetry-slam expects your package python source in `src/` and your tests in `test/`. @@ -37,7 +76,7 @@ packages = [ ] ``` -### Initializing poetry-slam +#### Initializing poetry-slam The first time you use poetry-slam in a new project, it's a good idea to run `slam init`. This will add opinionated defaults for the build tooling directly to your `pyproject.toml`. It will also add both pytest and pytest-cov as dependencies in your dev group. @@ -48,15 +87,13 @@ Added poetry-slam defaults to pyproject.toml % poetry update ``` -### What You Don't Need +#### What You Don't Need Aside from pytest and pytest-cov, which poetry-slam will add for you, You don't need other dependencies in your project's dev group. When you install poetry-slam you will also get isort and friends if they aren't already present, and these tools will automatically load configuration from the first `pyproject.toml` they find in your directory hierarchy. You also don't need tool-specific configuration files or global defaults, since the configs are added directly to your `pyproject.toml`. -## Usage - ### The Build Loop The most common usage and the default if no command is specified is to do a `build`, which will: @@ -108,7 +145,7 @@ You can also run individual steps; see `slam --help` for details: ``` -### Testing With Pytest +## Testing With Pytest Anything passed to `slam test` will be passed directly to pytest as command-line arguments. So for example: @@ -117,7 +154,7 @@ Anything passed to `slam test` will be passed directly to pytest as command-line ``` -### Debugging +## Debugging Get gory details with the combination of `--verbose` and `--log-level` most suitable to your liking: diff --git a/src/poetry_slam/cli.py b/src/poetry_slam/cli.py index 2e2c02e..c294f2e 100644 --- a/src/poetry_slam/cli.py +++ b/src/poetry_slam/cli.py @@ -1,4 +1,6 @@ import logging +import os +from enum import Enum from pathlib import Path from typing import Optional @@ -6,12 +8,15 @@ import typer from rich.logging import RichHandler from poetry_slam.build_tool import BuildTool +from poetry_slam.templates import TEMPLATE_ROOT, templates app = typer.Typer() app_state = dict() logger = logging.getLogger("slam.cli") +Template = Enum("Template", ((name, name) for name in templates())) + @app.callback(invoke_without_command=True) def main( @@ -39,7 +44,7 @@ def init(): """ Add opinionated defaults to your pyproject.toml. """ - defaults = Path(__file__).parent / "defaults.toml" + defaults = TEMPLATE_ROOT / "slam_defaults.toml" target = app_state["build_tool"].project_root / "pyproject.toml" if not target.exists(): raise RuntimeError(f"Could not find pyproject.toml at '{target}'") @@ -63,6 +68,34 @@ def init(): app_state["build_tool"].run_with_poetry("add", "-G", "dev", "pytest", "pytest-cov") +@app.command() +def new( + template: Template = typer.Option("default", help="The template to use."), + project_name: str = typer.Argument( + help="The name of your new project. Defaults to the current directory name" + ) +): + """ + Initialize a project with boilerplate code. + """ + tmpl = templates()[template.name] + tmpl.values = { + 'PROJECT_NAME': project_name, + 'PACKAGE_NAME': project_name, + 'DESCRIPTION': f"{project_name}: automatically generated by poetry-slam.", + 'AUTHOR_NAME': os.environ['USER'] + } + logging.debug(f"Configuring template: {tmpl}") + tmpl.apply() + init() + backup = app_state["build_tool"].project_root / "pyproject.toml.slam-orig" + logging.debug(f"Cleaning up {backup}...") + backup.unlink() + build() + backup = app_state["build_tool"].run(project_name, "--help") + print(f"Successfully initialized new {template.name} project {project_name}.") + + @app.command() def format(): """ diff --git a/src/poetry_slam/templates/__init__.py b/src/poetry_slam/templates/__init__.py new file mode 100644 index 0000000..77d9fef --- /dev/null +++ b/src/poetry_slam/templates/__init__.py @@ -0,0 +1,9 @@ +from .project_template import ProjectTemplate, TEMPLATE_ROOT + + +def templates(): + avail = dict() + for p in TEMPLATE_ROOT.iterdir(): + if p.is_dir(): + avail[p.name] = ProjectTemplate(p) + return avail diff --git a/src/poetry_slam/templates/default/README.md b/src/poetry_slam/templates/default/README.md new file mode 100644 index 0000000..87dcb88 --- /dev/null +++ b/src/poetry_slam/templates/default/README.md @@ -0,0 +1,10 @@ +# ${PROJECT_NAME} + +Auto-generated by poetry-slam. + + +## Usage + +```bash +${PACKAGE_NAME} --help +``` diff --git a/src/poetry_slam/templates/default/pyproject.toml b/src/poetry_slam/templates/default/pyproject.toml new file mode 100644 index 0000000..c69d133 --- /dev/null +++ b/src/poetry_slam/templates/default/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "${PROJECT_NAME}" +version = "1.0" +description = "${DESCRIPTION}" +authors = ["${AUTHOR_NAME}"] +readme = "README.md" +packages = [ + {include = "*", from = "src"}, +] + +[tool.poetry.dependencies] +python = "^3.10" +python-dotenv = "^0.21.0" +rich = "^13.7.0" +typer = "^0.9.0" + +[tool.poetry.group.dev.dependencies] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + + +[tool.poetry.scripts] +${PACKAGE_NAME} = "${PACKAGE_NAME}.cli:app" diff --git a/src/poetry_slam/templates/default/src/${PACKAGE_NAME}/__init__.py b/src/poetry_slam/templates/default/src/${PACKAGE_NAME}/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/poetry_slam/templates/default/src/${PACKAGE_NAME}/cli.py b/src/poetry_slam/templates/default/src/${PACKAGE_NAME}/cli.py new file mode 100644 index 0000000..ddee61c --- /dev/null +++ b/src/poetry_slam/templates/default/src/${PACKAGE_NAME}/cli.py @@ -0,0 +1,58 @@ +import io +import logging +from pathlib import Path +from typing import Optional + +import typer +from dotenv import load_dotenv +from rich.logging import RichHandler + + +CONFIG_DEFAULTS = """ +# ${PROJECT_NAME} Defaults + +LOG_LEVEL=INFO +""" + +app = typer.Typer() +app_state = dict( + config_file=Path("~/.config/${PROJECT_NAME}.conf").expanduser(), +) + +logger = logging.getLogger("${PACKAGE_NAME}.cli") + + +@app.callback(invoke_without_command=True) +def main( + context: typer.Context, + verbose: bool = typer.Option(False, help="Enable verbose output."), + log_level: str = typer.Option("error", help=" Set the log level."), + config_file: Optional[Path] = typer.Option( + app_state["config_file"], + help="Path to the ${PACKAGE_NAME} configuration file", + ), +): + """ + Configure the execution environment with global parameters. + """ + app_state["config_file"] = config_file + load_dotenv(stream=io.StringIO(CONFIG_DEFAULTS)) + load_dotenv(app_state["config_file"]) + + logging.basicConfig( + format="%(message)s", + level=getattr(logging, log_level.upper()), + handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])], + ) + app_state["verbose"] = verbose + + if context.invoked_subcommand is None: + logger.debug("No command specified; invoking default handler.") + run(context) + + +def run(context: typer.Context): + """ + The default CLI entrypoint is ${PACKAGE_NAME}.cli.run(). + """ + raise NotImplementedError("Please define ${PACKAGE_NAME}.cli.run().") diff --git a/src/poetry_slam/templates/default/test/test_${PACKAGE_NAME}.py b/src/poetry_slam/templates/default/test/test_${PACKAGE_NAME}.py new file mode 100644 index 0000000..f0bb48f --- /dev/null +++ b/src/poetry_slam/templates/default/test/test_${PACKAGE_NAME}.py @@ -0,0 +1,8 @@ +import pytest + + +@pytest.mark.xfail +def test_tests_are_implemented(): + import ${PACKAGE_NAME} + print("Yyou have not implemented any tests yet.") + assert False diff --git a/src/poetry_slam/templates/default/test/test_${PACKAGE_NAME}_cli.py b/src/poetry_slam/templates/default/test/test_${PACKAGE_NAME}_cli.py new file mode 100644 index 0000000..33acfec --- /dev/null +++ b/src/poetry_slam/templates/default/test/test_${PACKAGE_NAME}_cli.py @@ -0,0 +1,8 @@ +import pytest + +from ${PACKAGE_NAME} import cli + + +@pytest.mark.xfail +def test_tests_are_implemented(): + assert cli.main() diff --git a/src/poetry_slam/templates/project_template.py b/src/poetry_slam/templates/project_template.py new file mode 100644 index 0000000..a488379 --- /dev/null +++ b/src/poetry_slam/templates/project_template.py @@ -0,0 +1,66 @@ +import logging + +from dataclasses import dataclass, field +from pathlib import Path +from string import Template + + +TEMPLATE_ROOT = Path(__file__).parent + +logger = logging.getLogger("slam.templates") + + +@dataclass +class ProjectTemplate: + root: Path = TEMPLATE_ROOT / "default" + values: field(default_factory=dict) = None + + @property + def paths(self) -> list: + def walk(path): + files = [] + for path in path.iterdir(): + if path.is_dir(): + files += walk(path) + else: + files.append(path) + return files + return walk(self.root) + + def render(self, path) -> tuple: + """ + Return a tuple of the formatted destination path name and the file contents, if any. + """ + if path.is_dir(): + body = None + else: + body = Template((TEMPLATE_ROOT / path).read_text()).substitute(self.values) + + relpath = path.relative_to(self.root) + path_template = Template(str(relpath)).substitute(self.values) + return Path(path_template), body + + def apply(self): + """ + Apply the template to the current directory, creating any missing files and directories. + """ + if not Path(".git").exists(): + raise RuntimeError("Cannot apply a template outside of a project root (no .git present here).") + if Path("pyproject.toml").exists(): + raise RuntimeError( + "Cannot apply a template to an existing project. " + "Maybe you meant slam init, to apply defaults to your pyproject.toml?" + ) + for path in self.paths: + logger.debug(f"Processing {path}...") + target, body = self.render(path) + if not target.parent.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + if not target.exists(): + target.write_text(body) + logger.info(f"Created {target}") + else: + logger.warn(f"Skipping existing file {target}") + + def __repr__(self): + return f"{self.root}: {self.values}" diff --git a/src/poetry_slam/defaults.toml b/src/poetry_slam/templates/slam_defaults.toml similarity index 100% rename from src/poetry_slam/defaults.toml rename to src/poetry_slam/templates/slam_defaults.toml