Adding templates

This commit is contained in:
evilchili 2024-04-11 21:01:26 -07:00
parent afbcaadd29
commit b117994c9e
11 changed files with 263 additions and 9 deletions

View File

@ -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:

View File

@ -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():
"""

View File

@ -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

View File

@ -0,0 +1,10 @@
# ${PROJECT_NAME}
Auto-generated by poetry-slam.
## Usage
```bash
${PACKAGE_NAME} --help
```

View File

@ -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"

View File

@ -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().")

View File

@ -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

View File

@ -0,0 +1,8 @@
import pytest
from ${PACKAGE_NAME} import cli
@pytest.mark.xfail
def test_tests_are_implemented():
assert cli.main()

View File

@ -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}"