diff --git a/TODO.md b/TODO.md index e671e6d7..594970ff 100644 --- a/TODO.md +++ b/TODO.md @@ -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 @@ -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 diff --git a/docs/index.md b/docs/index.md index 70daa3b3..defa596f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,14 +8,14 @@ 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"} @@ -23,7 +23,7 @@ Latest Version : [{{ latest-git-tag ## 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`. @@ -31,11 +31,27 @@ 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 @@ -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. @@ -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. @@ -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 @@ -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` @@ -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/ diff --git a/py_maker/commands/new.py b/py_maker/commands/new.py index dee7f8c7..c8c530f4 100644 --- a/py_maker/commands/new.py +++ b/py_maker/commands/new.py @@ -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, diff --git a/py_maker/copy.py b/py_maker/copy.py new file mode 100644 index 00000000..092023e7 --- /dev/null +++ b/py_maker/copy.py @@ -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) diff --git a/py_maker/helpers.py b/py_maker/helpers.py index 1bdbd2a5..d4e1a427 100644 --- a/py_maker/helpers.py +++ b/py_maker/helpers.py @@ -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 @@ -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. @@ -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) diff --git a/py_maker/poetry.py b/py_maker/poetry.py new file mode 100644 index 00000000..0cfae91e --- /dev/null +++ b/py_maker/poetry.py @@ -0,0 +1,52 @@ +"""This file contains functions related to using Poetry.""" + +import os +import subprocess +from typing import Union + +from rich import print # pylint: disable=W0622 + +from py_maker.constants import MKDOCS_CONFIG +from py_maker.prompt.prompt import Confirm +from py_maker.schema import ProjectValues + + +def poetry_install( + options: dict[str, Union[bool, None]], choices: ProjectValues +) -> bool: + """Run poetry install if required. + + We also create the MkDocs project if enabled. + """ + if options["accept_defaults"] or Confirm.ask( + "\nShould I Run 'poetry install' now?", default=True + ): + os.chdir(choices.project_dir) + subprocess.run( + ["poetry", "install"], # noqa: S603, S607 + check=True, + ) + + if options["docs"]: + print("\n--> Creating MkDocs project") + subprocess.run( + [ # noqa: S603, S607 + "poetry", + "run", + "mkdocs", + "new", + ".", + ], + check=True, + ) + # now copy the custom mkdocs.yml file + (choices.project_dir / "mkdocs.yml").write_text( + MKDOCS_CONFIG.format(name=choices.name) + ) + return True + + print( + "[red]\n--> Skipping 'poetry install'. This also skips creating the " + "MkDocs project." + ) + return False diff --git a/py_maker/pymaker.py b/py_maker/pymaker.py index 7313de88..04e64400 100644 --- a/py_maker/pymaker.py +++ b/py_maker/pymaker.py @@ -2,38 +2,33 @@ from __future__ import annotations -import importlib.resources as pkg_resources import os import re -import shutil import subprocess # nosec import sys from pathlib import Path, PurePath -from typing import TYPE_CHECKING, Union +from typing import Union from git.exc import GitError from git.repo import Repo -from jinja2 import Environment, FileSystemLoader from rich import print # pylint: disable=W0622 -from py_maker import template from py_maker.config import get_settings -from py_maker.constants import MKDOCS_CONFIG, ExitErrors, license_names +from py_maker.constants import ExitErrors, license_names +from py_maker.copy import ProjectGenerator from py_maker.github_ctrl import GitHub from py_maker.helpers import ( + confirm_values, + create_git_repo, exists_on_pypi, - get_current_year, - get_file_list, get_title, header, sanitize, ) +from py_maker.poetry import poetry_install from py_maker.prompt import Confirm, Prompt from py_maker.schema import ProjectValues -if TYPE_CHECKING: - from importlib.resources.abc import Traversable - class PyMaker: """PyMaker class.""" @@ -62,154 +57,6 @@ def __init__( ) sys.exit(ExitErrors.LOCATION_ERROR) - def confirm_values(self) -> 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 self.choices) + 3 - - for key, value in self.choices: - print(f"{get_title(key).rjust(padding)} : [green]{value}") - - return Confirm.ask("\nIs this correct?", default=True) - - # ------------------------------------------------------------------------ # - # create the project skeleton folders. # - # ------------------------------------------------------------------------ # - 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) - - # ------------------------------------------------------------------------ # - # Copy the template files to the project directory. # - # ------------------------------------------------------------------------ # - 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.settings.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.settings.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) - - # ------------------------------------------------------------------------ # - # create the git repository for the project. # - # ------------------------------------------------------------------------ # - def create_git_repo(self) -> None: - """Create a Git repository for the project and add the first commit.""" - if not self.options["git"]: - return - try: - print("\n--> Creating Git repository ... ", end="") - repo = Repo.init(self.choices.project_dir) - repo.index.add(repo.untracked_files) - repo.index.commit("Initial Commit") - print("[green]Done[/green]") - self.git_is_run = True - except GitError as exc: - print("Error: ", exc) - sys.exit(ExitErrors.GIT_ERROR) - # ------------------------------------------------------------------------ # # display post-process messages # # ------------------------------------------------------------------------ # @@ -263,29 +110,27 @@ def get_sanitized_package_name(self, pk_name: str) -> str: self.choices.standalone = True break - # note: not happy with this nested if/else, but it works for now. - # will fix during the next refactor. - if not re.search(r"[- .]", name): - if exists_on_pypi(name): - print( - "\n[red]Warning: Package name already exists on PyPI." - ) - confirm = Confirm.ask( - "Do you want to use it anyway?", default=False - ) - if confirm: - print( - "\n[red]Warning: Using an existing package name " - "will mean it [b]cannot be uploaded to PyPI.\n" - ) - break - else: - break - else: + if re.search(r"[- .]", name): print( "\n[red]Error: Package name cannot contain dashes, dots or " "spaces. Please use Underscores if required.\n" ) + continue + + if exists_on_pypi(name): + print("\n[red]Warning: Package name already exists on PyPI.") + confirm = Confirm.ask( + "Do you want to use it anyway?", default=False + ) + if confirm: + print( + "\n[red]Warning: Using an existing package name " + "will mean it [b]cannot be uploaded to PyPI.\n" + ) + break + else: + break + return name # ------------------------------------------------------------------------ # @@ -350,48 +195,13 @@ def get_input(self) -> None: default=self.settings.default_license, ) - if not self.confirm_values(): + if not confirm_values(self.choices): # User chose not to continue print("\n[red]Aborting![/red]") sys.exit(ExitErrors.USER_ABORT) print() - # ------------------------------------------------------------------------ # - # run 'poetry install' if required. # - # ------------------------------------------------------------------------ # - def run_poetry(self) -> None: - """Run poetry install if required. - - We also create the MkDocs project if enabled. - """ - if self.options["accept_defaults"] or Confirm.ask( - "\nShould I Run 'poetry install' now?", default=True - ): - os.chdir(self.choices.project_dir) - subprocess.run( - ["poetry", "install"], # noqa: S603, S607 - check=True, - ) - self.poetry_is_run = True - - if self.options["docs"]: - print("\n--> Creating MkDocs project") - subprocess.run( - [ # noqa: S603, S607 - "poetry", - "run", - "mkdocs", - "new", - ".", - ], - check=True, - ) - # now copy the custom mkdocs.yml file - (self.choices.project_dir / "mkdocs.yml").write_text( - MKDOCS_CONFIG.format(name=self.choices.name) - ) - # ------------------------------------------------------------------------ # # optionally install and update the pre-commit hooks # # ------------------------------------------------------------------------ # @@ -514,6 +324,8 @@ def create_remote_repo(self) -> None: " See https://py-maker.seapagan.net/configuration/" "#add-a-github-personal-access-token" " for more details.\n" + "\n Also, the remote repository will [b]not[/b] be created if " + "you chose the '[b]--yes[/b]' option (accept defaults).\n" ) # ------------------------------------------------------------------------ # @@ -553,16 +365,26 @@ def run(self) -> None: self.get_input() if self.options["bare"]: - self.options["test"] = False - self.options["lint"] = False - self.options["docs"] = False - self.options["git"] = False + self.options.update( + dict.fromkeys(["test", "lint", "docs", "git"], False) + ) - self.create_folders() - self.generate_template() + # create the project skeleton folders and copy the template files. + generator = ProjectGenerator( + location=self.location, + choices=self.choices, + options=self.options, + use_default_template=self.settings.use_default_template, + template_folder=self.settings.template_folder, + ) + generator.run() - self.run_poetry() - self.create_git_repo() + self.poetry_is_run = poetry_install(self.options, self.choices) + self.git_is_run = ( + create_git_repo(self.choices.project_dir) + if self.options["git"] + else False + ) self.install_precommit() self.create_remote_repo() diff --git a/pyproject.toml b/pyproject.toml index eb1cfd23..d19ececa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,6 +155,8 @@ ignore = [ ] # These rules are too strict even for us 😝 extend-ignore = ["COM812", "ISC001"] # these are ignored for ruff formatting +[tool.ruff.lint.pylint] +max-args = 6 [tool.ruff.lint.pep8-naming] classmethod-decorators = ["pydantic.validator", "pydantic.root_validator"]