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

Refactor the main Pymaker class #404

Merged
merged 11 commits into from
May 8, 2024
16 changes: 4 additions & 12 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,14 @@ importance.
offer to open it in the browser.
- when creating a github repo, set the homepage if specified. Options to add
tags? Offer option to create a private repo?
- add ability to add new licences to the `licenses` module. Perhaps similar to
the way we modify the templates. This will need a bit of refactoring to the
way we handle licences.

## Bugs

- no validation on the URI input fields (hompage, repository) which could cause
Poetry to fail to install the package.
- If the `.pre-commit.yaml` is updated during the install phase, the modified file
should be added to the Git repo as a new commit.
- The Pytest environment is set up to use `greenlet` but that is not installed as
a dev dependency by default, hence the tests crash.

## Back Burner

Expand All @@ -64,14 +63,7 @@ These are ideas that I may or may not implement. They are here for reference.

## Refactoring / Code Cleanup

- Refactor the `PyMaker` class as its getting a bit messy. Maybe split it into
multiple classes with specific responsibilities.
- Sort out the nested `if/else` statements in
`PyMaker.get_sanitized_package_name`.
- split the file copy and template handling functionality into it's own module
and have the `PyMaker` class use it.
- split the `ExitErrors` class into an 'errors' module, add more error types if
needed and use them throughout the code.
- None planned at this time.

## Documentation

Expand Down
48 changes: 38 additions & 10 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,50 @@ Badge](https://app.codacy.com/project/badge/Grade/7c86940f816b455ab171dc81264768
[![Downloads](https://static.pepy.tech/personalized-badge/pyproject-maker?period=total&units=international_system&left_color=black&right_color=green&left_text=Total%20Downloads)](https://pepy.tech/project/pyproject-maker)

A fully customizable Python application to bootstrap
[Poetry](https://python-poetry.org/){:target="_blank"}-based boilerplate for you
[Poetry][poetry]{:target="_blank"}-based boilerplate for you
to start developing your Python applications quicker! Includes linting and
Pytest libraries.

It will create a new directory for your project (or use the current directory),
initialise a git repository, create a virtual environment, and install some
basic dependencies for Testing, Linting and more. Optionally, it can also
create a GitHub repository for you and push the initial commit.
create a GitHub repository for you and push the initial commit(s).

Latest Version : [{{ latest-git-tag
}}](https://pypi.org/project/pyproject-maker/){:target="_blank"}

## Testing

The generated project includes
[pytest](https://docs.pytest.org/en/latest/){:target="_blank"} and some related
[pytest][pytest]{:target="_blank"} and some related
plugins to allow you to set up testing straight away.

Write your tests in the `tests` directory and run them with `pytest`.

## Linting

The generated project includes
[Ruff](https://docs.astral.sh/ruff/){:target="_blank"} for linting and code
style formatting. [Mypy](http://mypy-lang.org/){:target="_blank"} is installed
[Ruff][ruff]{:target="_blank"} for linting and code
style formatting. [Mypy][mypy]{:target="_blank"} is installed
for type checking. These are set quite strictly by default, but you can edit the
tools configuration in the `pyproject.toml` file.

## Documentation

The generated project includes
[mkdocs][mkdocs]{:target="_blank"} and a generated skeleton for
you to get started with your documentation. The documentation is written in
Markdown and can be found in the `docs` directory. The project will also include
the
[mkdocs-material][mkdocs-material]{:target="_blank"}
theme, which is a popular theme for MkDocs used in many open-source projects.

!!! tip

The Testing, Linting and Documentation tools are all optional and can be
removed or replaced with your preferred tools. There is also a `--bare`
option to generate a project without any of these tools.

## Customize the generated project

You can add extra or edited files to the generated project by adding them to the
Expand All @@ -48,7 +64,7 @@ folder so full customization and even removal of files is possible.
## Pre-commit

The generated project uses
[pre-commit](https://pre-commit.com/){:target="_blank"} to run some checks on
[pre-commit][pre-commit]{:target="_blank"} to run some checks on
the code before it is committed. This is a great tool to help keep your code
clean.

Expand All @@ -62,7 +78,7 @@ pre-commit installed at .git/hooks/pre-commit
## GitHub Actions and Configuration

By default the generated project includes a GitHub Actions workflow to run
[Dependabot](https://dependabot.com/){:target="_blank"} to keep your
[Dependabot][dependabot]{:target="_blank"} to keep your
dependencies up to date. There are also standard templates for Pull Request and
Issues.

Expand All @@ -73,7 +89,7 @@ more.

Once you have at least one GitHub release, you can generate a `CHANGELOG.md` file
automatically from this, using the included
[github-changelog-md](https://github.com/seapagan/github-changelog-md){:target="_blank"}
[github-changelog-md][changelog]{:target="_blank"}
tool.

You can run this manually by running the following command from inside your
Expand All @@ -85,14 +101,14 @@ $ poe changelog

You need to have a GitHub Personal Access Token set in the config file, see the
instructions
[here](https://changelog.seapagan.net/installation/#setup-a-github-pat){:target="_blank"}
[here][pat]{:target="_blank"}
for more information.

## Community related files

To aid in community building, the generated project includes a
`CODE_OF_CONDUCT.md` file. This is based on the [Contributor
Covenant](https://www.contributor-covenant.org/){:target="_blank"} standard.
Covenant][covenant]{:target="_blank"} standard.

Future releases will include other Community related files (for example an
`AUTHORS` file). There are also blank `CONTRIBUTING.md` and `CHANGELOG.md`
Expand All @@ -102,3 +118,15 @@ files. The `CHANGELOG.md` file can be auto-generated.

For information on how to contribute to the project, see the `CONTRIBUTING.md`
file in the root of the repository or [on this website](contributing.md)

[poetry]: https://python-poetry.org/
[pytest]: https://docs.pytest.org/en/latest/
[ruff]: https://docs.astral.sh/ruff/
[mypy]: http://mypy-lang.org/
[pre-commit]: https://pre-commit.com/
[dependabot]: https://dependabot.com/
[changelog]: https://changelog.seapagan.net/
[pat]: https://changelog.seapagan.net/installation/#setup-a-github-pat
[mkdocs]: https://www.mkdocs.org/
[mkdocs-material]: https://squidfunk.github.io/mkdocs-material/
[covenant]: https://www.contributor-covenant.org/
2 changes: 1 addition & 1 deletion py_maker/commands/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def new(
Optional[bool],
typer.Option(
help="Create a remote repository on GitHub for the project "
"and push the initial commit.",
"and push the initial commit(s).",
show_default=False,
),
] = None,
Expand Down
155 changes: 155 additions & 0 deletions py_maker/copy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Functions to copy files and the templates to the project directory."""

import importlib.resources as pkg_resources
import shutil
import sys
from importlib.resources.abc import Traversable
from pathlib import Path
from typing import Union

from jinja2 import Environment, FileSystemLoader
from rich import print # pylint: disable=W0622

from py_maker import template
from py_maker.constants import ExitErrors
from py_maker.helpers import get_current_year, get_file_list
from py_maker.schema import ProjectValues


class ProjectGenerator:
"""Class to encapsulate generating the project from the template."""

def __init__(
self,
*,
location: str,
choices: ProjectValues,
options: dict[str, Union[bool, None]],
use_default_template: bool = True,
template_folder: str,
) -> None:
"""Initialize the Generate class."""
self.location = location
self.options = options
self.choices = choices
self.use_default_template = use_default_template
self.template_folder = template_folder

def run(self) -> None:
"""Generate the project from the template."""
self.create_folders()
self.generate_template()

def create_folders(self) -> None:
"""Create the root folder for the project."""
try:
print("--> Creating project folder ... ", end="")
if self.location != ".":
self.choices.project_dir.mkdir()
print("[green]Done[/green]")
except FileExistsError:
print(
f"\n[red] -> Error: Directory '{self.choices.project_dir}' "
"already exists.\n"
)
sys.exit(ExitErrors.DIRECTORY_EXISTS)
except PermissionError:
print(
"\n[red] -> Error: Permission denied creating directory "
f"'{self.choices.project_dir}'\n"
)
sys.exit(ExitErrors.PERMISSION_DENIED)

def copy_files(
self,
template_dir: Union[Traversable, Path],
file_list: list[Path],
) -> None:
"""Copy the template files to the project directory.

Expand the jinja templates before copying.
"""
jinja_env = Environment(
loader=FileSystemLoader(str(template_dir)),
autoescape=True,
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
)
for file in file_list:
with pkg_resources.as_file(template_dir / file) as src: # type: ignore
if src.is_dir():
Path(self.choices.project_dir / file).mkdir()
elif src.suffix == ".jinja":
jinja_template = jinja_env.get_template(str(file))
dst = self.choices.project_dir / Path(file).with_suffix("")
dst.write_text(
jinja_template.render(
self.choices.model_dump(),
slug=self.choices.project_dir.name,
options=self.options,
)
)
else:
dst = self.choices.project_dir / file
dst.write_text(src.read_text(encoding="UTF-8"))

def generate_template(
self,
) -> None:
"""Copy the template files to the project directory.

Any file that has the '.jinja' extension will be passed though the
template engine before copying. The extension will also be removed.

ie:
'README.md.jinja' is copied as 'README.md' after template substitution.
"""
try:
# ---------------- copy the default template files --------------- #
template_dir = pkg_resources.files(template)
if self.use_default_template:
file_list = get_file_list(template_dir)
self.copy_files(template_dir, file_list)

# --------- copy the custom template files if they exist --------- #
custom_template_dir = Path(self.template_folder)
if custom_template_dir.exists():
file_list = get_file_list(custom_template_dir)
self.copy_files(custom_template_dir, file_list)

# ---------------- generate the license file next. ------------- #
if self.choices.license_name != "None":
license_env = Environment(
loader=FileSystemLoader(str(template_dir / "../licenses")),
autoescape=True,
keep_trailing_newline=True,
)
license_template = license_env.get_template(
f"{self.choices.license_name}.jinja"
)
dst = self.choices.project_dir / "LICENSE.txt"
dst.write_text(
license_template.render(
author=self.choices.author, year=get_current_year()
)
)

# ---------- rename or delete the 'app' dir if required ---------- #
if not self.choices.standalone:
Path(self.choices.project_dir / "app").rename(
self.choices.project_dir / self.choices.package_name
)
else:
# move the main.py into the root project folder and delete app
Path(self.choices.project_dir / "app" / "main.py").rename(
Path(self.choices.project_dir / "main.py")
)
shutil.rmtree(self.choices.project_dir / "app")

# ----------- remove the 'test' folder if not required ----------- #
if not self.options["test"]:
shutil.rmtree(self.choices.project_dir / "tests")
except OSError as exc:
print(f"\n[red] -> {exc}")
sys.exit(ExitErrors.OS_ERROR)
35 changes: 35 additions & 0 deletions py_maker/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,20 @@
import requests
import rtoml
from git.config import GitConfigParser
from git.exc import GitError
from git.repo import Repo
from rich import print # pylint: disable=redefined-builtin
from rich.console import Console
from rich.table import Table

from py_maker.constants import ExitErrors
from py_maker.prompt.prompt import Confirm

if TYPE_CHECKING: # pragma: no cover
from importlib.resources.abc import Traversable

from py_maker.schema import ProjectValues

SUCCESS_RESPONSE = 200


Expand All @@ -42,6 +47,21 @@ def get_author_and_email_from_git() -> tuple[str, str]:
return author_name, author_email


def create_git_repo(project_dir: Path) -> bool:
"""Create a Git repository for the project and add the first commit."""
try:
print("\n--> Creating Git repository ... ", end="")
repo = Repo.init(project_dir)
repo.index.add(repo.untracked_files)
repo.index.commit("Initial Commit")
print("[green]Done[/green]")
except GitError as exc:
print("Error: ", exc)
sys.exit(ExitErrors.GIT_ERROR)
else:
return True


def get_file_list(template_dir: Union[Traversable, Path]) -> list[Path]:
"""Return a list of files to be copied to the project directory.

Expand Down Expand Up @@ -170,3 +190,18 @@ def get_app_version() -> str:
def check_cmd_exists(cmd: str) -> bool:
"""Check if the supplied shell command exists."""
return shutil.which(cmd) is not None


def confirm_values(choices: ProjectValues) -> bool:
"""Confirm the values entered by the user."""
print(
"\n[green][bold]Creating a New Python app with the below "
"settings :\n"
)

padding: int = max(len(key) for key, _ in choices) + 3

for key, value in choices:
print(f"{get_title(key).rjust(padding)} : [green]{value}")

return Confirm.ask("\nIs this correct?", default=True)
Loading