From 376ab4636ad617e22e6e9e9e816a2c5050398757 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 31 Jul 2023 21:24:20 +0100 Subject: [PATCH 01/33] refactor a couple of methods into helper functions Signed-off-by: Grant Ramsay --- py_maker/helpers.py | 24 +++++++++++++++++++++++- py_maker/pymaker.py | 23 +++++------------------ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/py_maker/helpers.py b/py_maker/helpers.py index ed9f987f..e21db5ed 100644 --- a/py_maker/helpers.py +++ b/py_maker/helpers.py @@ -1,12 +1,18 @@ """Helpers for the config module.""" +from __future__ import annotations + +import re from datetime import datetime -from typing import Dict +from typing import TYPE_CHECKING, Dict, Union from git.config import GitConfigParser from rich import print # pylint: disable=redefined-builtin from rich.console import Console from rich.table import Table +if TYPE_CHECKING: + from pathlib import Path + def get_author_and_email_from_git() -> tuple[str, str]: """Get the author name and email from git.""" @@ -18,6 +24,22 @@ def get_author_and_email_from_git() -> tuple[str, str]: ) +def sanitize(input_str: Union[str, Path]) -> str: + """Replace any dashes in the supplied string by underscores. + + Python needs underscores in library names, not dashes. + """ + return str(input_str).replace("-", "_") + + +def get_title(key: str) -> str: + """Get a 'titlized' version of the supplied string. + + This removes dashes or underscore and titlizes each word. + """ + return re.sub("[_-]", " ", key).title() if key != "." else "" + + def pretty_attrib(attr: str) -> str: """Return a pretty version of the attribute name.""" return attr.replace("_", " ").title() diff --git a/py_maker/pymaker.py b/py_maker/pymaker.py index 183aa342..cbe4c95d 100644 --- a/py_maker/pymaker.py +++ b/py_maker/pymaker.py @@ -1,11 +1,9 @@ """Class to encapsulate the application.""" import importlib.resources as pkg_resources import os -import re import shutil import sys from pathlib import Path, PurePath -from typing import Union from git.exc import GitError from git.repo import Repo @@ -15,7 +13,7 @@ from py_maker import template from py_maker.config.settings import Settings from py_maker.constants import ExitErrors, license_names -from py_maker.helpers import get_current_year, header +from py_maker.helpers import get_current_year, get_title, header, sanitize from py_maker.prompt import Confirm, Prompt from py_maker.schema import ProjectValues @@ -39,13 +37,6 @@ def __init__(self, location: str) -> None: ) sys.exit(ExitErrors.LOCATION_ERROR) - def sanitize(self, input_str: Union[str, Path]) -> str: - """Replace any dashes in the supplied string by underscores. - - Python needs underscores in library names, not dashes. - """ - return str(input_str).replace("-", "_") - def confirm_values(self) -> bool: """Confirm the values entered by the user.""" print( @@ -56,14 +47,10 @@ def confirm_values(self) -> bool: padding: int = max(len(key) for key, _ in self.choices) + 3 for key, value in self.choices: - print(f"{self.get_title(key).rjust(padding)} : [green]{value}") + print(f"{get_title(key).rjust(padding)} : [green]{value}") return Confirm.ask("\nIs this correct?", default=True) - def get_title(self, key: str) -> str: - """Get the title for the application.""" - return re.sub("[_-]", " ", key).title() if key != "." else "" - # ------------------------------------------------------------------------ # # create the project skeleton folders. # # ------------------------------------------------------------------------ # @@ -233,14 +220,14 @@ def run(self) -> None: self.choices.name = Prompt.ask( "Name of the Application?", - default=self.get_title(PurePath(self.choices.project_dir).name), + default=get_title(PurePath(self.choices.project_dir).name), ) - pk_name = self.sanitize(self.location) + pk_name = sanitize(self.location) self.choices.package_name = Prompt.ask( "Package Name? (Use '-' for standalone script)", default=pk_name if pk_name != "." - else self.sanitize(self.choices.project_dir.name), + else sanitize(self.choices.project_dir.name), ) self.choices.description = Prompt.ask( "Description of the Application?", From 7a9a31aa95b8b814371331227c74612cd12d65cf Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 1 Aug 2023 09:19:16 +0100 Subject: [PATCH 02/33] tweak prompt to provide InvalidResponse exception this is the only exception in the Prompt class, we should provide it from our subclass Signed-off-by: Grant Ramsay --- py_maker/prompt/__init__.py | 4 +++- tests/test_main.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/py_maker/prompt/__init__.py b/py_maker/prompt/__init__.py index 6b178acd..692d78e2 100644 --- a/py_maker/prompt/__init__.py +++ b/py_maker/prompt/__init__.py @@ -1,4 +1,6 @@ """Import all prompt classes.""" +from rich.prompt import InvalidResponse + from .prompt import Confirm, Prompt -__all__ = ["Confirm", "Prompt"] +__all__ = ["Confirm", "Prompt", "InvalidResponse"] diff --git a/tests/test_main.py b/tests/test_main.py index c78947c4..2c17dd76 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -14,6 +14,7 @@ def test_app(): def test_new(): - """Test the new command with no arguments gives error.""" + """Test the new command with no arguments shows help.""" result = runner.invoke(app, ["new"]) - assert result.exit_code == 2 + assert result.exit_code == 0 + assert "--help" in result.stdout From b11f914c831a7751ec35bf40ecda5a1fec653fb9 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 1 Aug 2023 10:35:15 +0100 Subject: [PATCH 03/33] add pylint plugins for pydantic and pytest, lower false-positives Signed-off-by: Grant Ramsay --- .vscode/settings.json | 6 +++++- poetry.lock | 45 ++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 9 +++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9ef87799..576360e6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,7 +39,11 @@ "python.linting.flake8Enabled": true, "python.linting.mypyEnabled": true, "python.linting.pydocstyleArgs": ["--convention=google"], - "python.linting.pylintArgs": [], + "python.linting.pylintArgs": [ + "--load-plugins", + "pylint_pydantic", + "pylint-pytest" + ], "python.linting.pylintEnabled": false, "python.pythonPath": "./.venv/bin/python", "isort.args": ["--profile", "black", "--src=${workspaceFolder}"], diff --git a/poetry.lock b/poetry.lock index 7ee721c4..d4775f7a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1420,6 +1420,49 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\"" spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] +[[package]] +name = "pylint-plugin-utils" +version = "0.8.2" +description = "Utilities and helpers for writing Pylint plugins" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "pylint_plugin_utils-0.8.2-py3-none-any.whl", hash = "sha256:ae11664737aa2effbf26f973a9e0b6779ab7106ec0adc5fe104b0907ca04e507"}, + {file = "pylint_plugin_utils-0.8.2.tar.gz", hash = "sha256:d3cebf68a38ba3fba23a873809155562571386d4c1b03e5b4c4cc26c3eee93e4"}, +] + +[package.dependencies] +pylint = ">=1.7" + +[[package]] +name = "pylint-pydantic" +version = "0.2.4" +description = "A Pylint plugin to help Pylint understand the Pydantic" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pylint_pydantic-0.2.4-py3-none-any.whl", hash = "sha256:452b32992f47e303b432f8940d4077f1d60e8d3325351b8998d846431699111a"}, +] + +[package.dependencies] +pydantic = "<3.0" +pylint = ">2.0,<3.0" +pylint-plugin-utils = "*" + +[[package]] +name = "pylint-pytest" +version = "1.1.2" +description = "A Pylint plugin to suppress pytest-related false positives." +optional = false +python-versions = ">=3.6" +files = [ + {file = "pylint_pytest-1.1.2-py2.py3-none-any.whl", hash = "sha256:fb20ef318081cee3d5febc631a7b9c40fa356b05e4f769d6e60a337e58c8879b"}, +] + +[package.dependencies] +pylint = "*" +pytest = ">=4.6" + [[package]] name = "pymdown-extensions" version = "10.1" @@ -2153,4 +2196,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "8c3f521f5cfe8412ca0cda8d2ff9b15957ef3436e0fdc47a02ee8184f2e829ac" +content-hash = "e35d55bf906aa254db525fdba22981e6f1395dcb354e71f41a9dcf8011e187b0" diff --git a/pyproject.toml b/pyproject.toml index 8542e5bb..1696ee4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,8 @@ flake8-pytest-style = "^1.7.2" flake8-type-checking = "^2.4.1" isort = "^5.12.0" pylint = "^2.17.2" +pylint-pydantic = "^0.2.4" +pylint-pytest = "^1.1.2" pep8-naming = "^0.13.3" pre-commit = "^3.3.3" pydocstyle = "^6.3.0" @@ -138,6 +140,13 @@ exclude_dirs = [] [tool.bandit.assert_used] skips = ['*_test.py', '*/test_*.py'] +[tool.pylint.MAIN] +load-plugins = ["pylint_pytest", "pylint_pydantic"] +extension-pkg-whitelist = "pydantic" + +[tool.pylint.DESIGN] +exclude-too-few-public-methods = "pydantic" + [tool.pydocstyle] add-ignore = ["D104"] From c7ca175262f71d14e89cd01955f5c95f71e13fd6 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 1 Aug 2023 10:35:53 +0100 Subject: [PATCH 04/33] refactor exit value class as IntEnum Signed-off-by: Grant Ramsay --- py_maker/constants.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/py_maker/constants.py b/py_maker/constants.py index 1a9927b3..7e938db3 100644 --- a/py_maker/constants.py +++ b/py_maker/constants.py @@ -1,4 +1,6 @@ """Some constants needed for the rest of the App.""" +from enum import IntEnum + LICENCES: list[dict[str, str]] = [ {"name": "None", "url": ""}, {"name": "Apache2", "url": "https://opensource.org/licenses/Apache-2.0"}, @@ -16,7 +18,7 @@ license_names: list[str] = [license["name"] for license in LICENCES] -class ExitErrors: +class ExitErrors(IntEnum): """Exit errors. Error codes for the application. From 33222ceddfbd1072390546cd9e5e16c95561eab5 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 1 Aug 2023 11:14:50 +0100 Subject: [PATCH 05/33] copy files from user template if they exist Signed-off-by: Grant Ramsay --- py_maker/pymaker.py | 94 ++++++++++++++++++++++++++++----------------- pyproject.toml | 2 + 2 files changed, 60 insertions(+), 36 deletions(-) diff --git a/py_maker/pymaker.py b/py_maker/pymaker.py index cbe4c95d..55683699 100644 --- a/py_maker/pymaker.py +++ b/py_maker/pymaker.py @@ -1,9 +1,12 @@ """Class to encapsulate the application.""" +from __future__ import annotations + import importlib.resources as pkg_resources import os import shutil import sys from pathlib import Path, PurePath +from typing import TYPE_CHECKING from git.exc import GitError from git.repo import Repo @@ -17,6 +20,9 @@ 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.""" @@ -33,7 +39,7 @@ def __init__(self, location: str) -> None: if len(Path(self.location).parts) > 1: print( "[red] -> Error: Location must be a single directory name, " - "and is relative to the current direcotry.\n" + "and is relative to the current directory.\n" ) sys.exit(ExitErrors.LOCATION_ERROR) @@ -77,7 +83,46 @@ def create_folders(self) -> None: # ------------------------------------------------------------------------ # # Copy the template files to the project directory. # # ------------------------------------------------------------------------ # - def copy_template_files(self) -> None: + def copy_files(self, template_dir: Traversable, file_list: list[str]): + """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 item in file_list: + with pkg_resources.as_file(template_dir / item) as src: + if src.is_dir(): + Path(self.choices.project_dir / item).mkdir() + elif src.suffix == ".jinja": + jinja_template = jinja_env.get_template(str(item)) + dst = self.choices.project_dir / Path(item).with_suffix("") + dst.write_text( + jinja_template.render( + self.choices.model_dump(), + slug=self.choices.project_dir.name, + ) + ) + else: + dst = self.choices.project_dir / item + dst.write_text(src.read_text(encoding="UTF-8")) + + def get_file_list(self, skip_dirs, template_dir): + """Return a list of files to be copied to the project directory.""" + file_list = [ + item.relative_to(template_dir) + for item in template_dir.rglob("*") # type: ignore + if set(item.parts).isdisjoint(skip_dirs) + ] + + return file_list + + 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 @@ -86,42 +131,19 @@ def copy_template_files(self) -> None: ie: 'README.md.jinja' is copied as 'README.md' after template substitution. """ - template_dir = pkg_resources.files(template) - skip_dirs = ["__pycache__"] - file_list = [ - item.relative_to(template_dir) - for item in template_dir.rglob("*") # type: ignore[attr-defined] - if set(item.parts).isdisjoint(skip_dirs) - ] try: - # ---------------------- copy all the files ---------------------- # - jinja_env = Environment( - loader=FileSystemLoader(str(template_dir)), - autoescape=True, - trim_blocks=True, - lstrip_blocks=True, - keep_trailing_newline=True, - ) - for item in file_list: - with pkg_resources.as_file(template_dir / item) as src: - if src.is_dir(): - Path(self.choices.project_dir / item).mkdir() - elif src.suffix == ".jinja": - jinja_template = jinja_env.get_template(str(item)) - dst = self.choices.project_dir / Path(item).with_suffix( - "" - ) - dst.write_text( - jinja_template.render( - self.choices.model_dump(), - slug=self.choices.project_dir.name, - ) - ) - else: - dst = self.choices.project_dir / item - dst.write_text(src.read_text(encoding="UTF-8")) + # ---------------- copy the default template files --------------- # + template_dir = pkg_resources.files(template) + file_list = self.get_file_list(skip_dirs, template_dir) + self.copy_files(template_dir, file_list) + + # --------- copy the custom template files if they exist --------- # + custom_template_dir = Path(Path.home() / ".pymaker" / "template") + if custom_template_dir.exists(): + file_list = self.get_file_list(skip_dirs, custom_template_dir) + self.copy_files(custom_template_dir, file_list) # type: ignore # ---------------- generate the license file next. ------------- # license_env = Environment( @@ -256,7 +278,7 @@ def run(self) -> None: print() self.create_folders() - self.copy_template_files() + self.generate_template() self.create_git_repo() self.post_process() diff --git a/pyproject.toml b/pyproject.toml index 1696ee4a..07c29d17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,7 @@ extend-ignore = ["E203", "W503"] extend-select = ["TC", "TC1", "TRY"] docstring-convention = "google" type-checking-pydantic-enabled = true +classmethod-decorators = ["classmethod", "validator"] [tool.bandit] exclude_dirs = [] @@ -146,6 +147,7 @@ extension-pkg-whitelist = "pydantic" [tool.pylint.DESIGN] exclude-too-few-public-methods = "pydantic" +max-attributes = 10 [tool.pydocstyle] add-ignore = ["D104"] From d52922751d4fb772209a2769682f7b4b1292193c Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 1 Aug 2023 12:01:04 +0100 Subject: [PATCH 06/33] add a few more unit tests Signed-off-by: Grant Ramsay --- py_maker/helpers.py | 2 +- pyproject.toml | 2 ++ tests/test_config.py | 30 ++++++++++++++++++++++++++++ tests/test_constants.py | 21 ++++++++++++++++++++ tests/test_helpers.py | 42 ++++++++++++++++++++++++++++++++++++++++ tests/test_settings.py | 43 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 tests/test_config.py create mode 100644 tests/test_constants.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_settings.py diff --git a/py_maker/helpers.py b/py_maker/helpers.py index e21db5ed..f5653f88 100644 --- a/py_maker/helpers.py +++ b/py_maker/helpers.py @@ -10,7 +10,7 @@ from rich.console import Console from rich.table import Table -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from pathlib import Path diff --git a/pyproject.toml b/pyproject.toml index 07c29d17..5baa947c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,7 +153,9 @@ max-attributes = 10 add-ignore = ["D104"] [tool.pytest.ini_options] +addopts = ["--cov", "--cov-report", "term-missing", "--cov-report", "html"] filterwarnings = [] +mock_use_standalone_module = true [tool.coverage.run] # source = [] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..d6054467 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,30 @@ +"""Integration tests for the config command.""" +from unittest.mock import patch + +from typer.testing import CliRunner + +from py_maker.commands.config import app + + +def test_show_config(): + """Test the show command.""" + runner = CliRunner() + with patch("py_maker.config.settings.Settings.get_attrs") as mock_get_attrs: + mock_get_attrs.return_value = {"key1": "value1", "key2": "value2"} + result = runner.invoke(app, ["show"]) + assert result.exit_code == 0 + assert "Key1" in result.stdout + assert "value1" in result.stdout + assert "Key2" in result.stdout + assert "value2" in result.stdout + + +def test_change_config(): + """Test the change command.""" + runner = CliRunner() + with patch( + "py_maker.config.settings.Settings.change_settings" + ) as mock_change_settings: + result = runner.invoke(app, ["change"]) + assert result.exit_code == 0 + mock_change_settings.assert_called_once() diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 00000000..a8eaf7ee --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,21 @@ +"""Tests for the constants module.""" +from py_maker import constants + + +def test_licenses(): + """Test that the licenses constant is a list of dictionaries.""" + assert isinstance(constants.LICENCES, list) + assert all(isinstance(license, dict) for license in constants.LICENCES) + assert all(len(license) == 2 for license in constants.LICENCES) + + +def test_license_names(): + """Test that the license_names constant is a list of strings.""" + assert isinstance(constants.license_names, list) + assert all(isinstance(name, str) for name in constants.license_names) + + +def test_exit_errors(): + """Test that the ExitErrors enum is an IntEnum.""" + assert issubclass(constants.ExitErrors, int) + assert all(isinstance(error, int) for error in constants.ExitErrors) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 00000000..9b8ea378 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,42 @@ +"""Test the helpers module.""" "" + +from py_maker.helpers import ( + get_author_and_email_from_git, + get_current_year, + get_title, + pretty_attrib, + sanitize, +) + + +def test_get_author_and_email_from_git(): + """Test the get_author_and_email_from_git function.""" + author, email = get_author_and_email_from_git() + assert isinstance(author, str) + assert isinstance(email, str) + + +def test_sanitize(): + """Test the Sanitize function.""" + assert sanitize("my-project-name") == "my_project_name" + assert sanitize("my-project-name-1.0") == "my_project_name_1.0" + assert sanitize("my_project_name") == "my_project_name" + + +def test_get_title(): + """Test the get_title function.""" + assert get_title("my-project-name") == "My Project Name" + assert get_title("my_project_name") == "My Project Name" + assert get_title(".") == "" + + +def test_pretty_attrib(): + """Test the pretty_attrib function.""" + assert pretty_attrib("my_attribute_name") == "My Attribute Name" + assert pretty_attrib("MY_ATTRIBUTE_NAME") == "My Attribute Name" + + +def test_get_current_year(): + """Test the get_current_year function.""" + assert get_current_year().isdigit() + assert len(get_current_year()) == 4 diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 00000000..c5b1725f --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,43 @@ +"""Tests for the Settings class.""" "" +import os +import tempfile +from pathlib import Path + +import rtoml + +from py_maker.config.settings import Settings + + +def test_settings(): + """Test that the Settings class works as expected.""" + test_author = "John Doe" + # Create a temporary directory to store the settings file + with tempfile.TemporaryDirectory() as tmpdir: + # Set the settings folder to the temporary directory + settings_folder = Path(tmpdir) / ".pymaker" + os.makedirs(settings_folder) + + # Set the settings path to the temporary directory + settings_path = settings_folder / "config.toml" + + # Create a new Settings object with the temporary settings path + settings = Settings(settings_path=settings_path) + + # Test that the default settings are correct + assert settings.schema_version == "1.0" + assert settings.author_name == "" + assert settings.author_email == "" + assert settings.default_license == "" + + # Test that we can set and get a setting + settings.set("author_name", test_author) + assert settings.get("author_name") == test_author + + # Test that the settings are saved to the file + settings.save() + loaded_settings = rtoml.load(settings_path) + assert loaded_settings["pymaker"]["author_name"] == test_author + + # Test that we can load the settings from the file + settings2 = Settings(settings_path=settings_path) + assert settings2.get("author_name") == test_author From d1997a74889df5dc00ac918afba25ffb9c3b629e Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 3 Aug 2023 10:19:49 +0100 Subject: [PATCH 07/33] remove Python 3.8 from the PyPI classifiers We state >=3.9 in the deps Signed-off-by: Grant Ramsay --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5baa947c..95dfbeb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From 66f47e72c72c18010035d1c0804f47d0047baf3d Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Fri, 4 Aug 2023 09:31:50 +0100 Subject: [PATCH 08/33] update TODO Signed-off-by: Grant Ramsay --- TODO.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index bb131712..15715d7c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # TODO List -- Add testing with [Pytest](https://pytest.org) +- Add testing with [Pytest](https://pytest.org) (`IN PROGRESS`) - Add option to generate a skeleton MkDocs website for the new project - Ask for more settings ie homepage, repo, etc. and add them to the generated `pyproject.toml` file (if the new project is likely to be uploaded to PyPI) @@ -16,3 +16,9 @@ - option to dump the default template files to a local directory so they can be edited and used as custom templates, optionally dumping to the `~/.pymaker/templates` folder overwriting existing customizations. +- add some form of 'extra packages' command line option and config setting to + automatically add extra packages to the generated `pyproject.toml` file. +- add cmd line options to specify the project name, author, etc. so the user + doesn't have to enter them manually. +- add a command line option to specify the project type so the user doesn't have + to enter it manually. From 9232dc38b998ae6241c13c0978dffab0871a09ee Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Fri, 4 Aug 2023 12:00:54 +0100 Subject: [PATCH 09/33] update pre-config settings Signed-off-by: Grant Ramsay --- .pre-commit-config.yaml | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c193129..5b064486 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,22 +48,19 @@ repos: rev: v2.3.2 hooks: - id: tryceratops - - # the below is currently disabled since there seems to be a conflict with - # 'typing-extensions' versions. - # - repo: https://github.com/python-poetry/poetry - # rev: "1.5.0" - # hooks: - # - id: poetry-check - # # - id: poetry-lock - # - id: poetry-export - # args: - # [ - # "--without-hashes", - # "-f", - # "requirements.txt", - # "-o", - # "requirements.txt", - # "--with", - # "dev", - # ] + - repo: https://github.com/python-poetry/poetry + rev: "1.5.0" + hooks: + - id: poetry-check + # - id: poetry-lock + - id: poetry-export + args: + [ + "--without-hashes", + "-f", + "requirements.txt", + "-o", + "requirements.txt", + "--with", + "dev", + ] From 960c0a663ec415b1c774dd8f9744b1d39941df9c Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Fri, 4 Aug 2023 12:01:33 +0100 Subject: [PATCH 10/33] move get_file_list() from PyMaker to helper file Signed-off-by: Grant Ramsay --- py_maker/helpers.py | 16 +++++++++++++++- py_maker/pymaker.py | 36 ++++++++++++++++-------------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/py_maker/helpers.py b/py_maker/helpers.py index f5653f88..70317657 100644 --- a/py_maker/helpers.py +++ b/py_maker/helpers.py @@ -3,7 +3,7 @@ import re from datetime import datetime -from typing import TYPE_CHECKING, Dict, Union +from typing import TYPE_CHECKING, Dict, List, Union from git.config import GitConfigParser from rich import print # pylint: disable=redefined-builtin @@ -11,6 +11,7 @@ from rich.table import Table if TYPE_CHECKING: # pragma: no cover + from importlib.resources.abc import Traversable from pathlib import Path @@ -24,6 +25,19 @@ def get_author_and_email_from_git() -> tuple[str, str]: ) +def get_file_list(template_dir: Union[Traversable, Path]): + """Return a list of files to be copied to the project directory.""" + skip_dirs: List = ["__pycache__"] + + file_list: List[str] = [ + item.relative_to(template_dir) # type: ignore + for item in template_dir.rglob("*") # type: ignore + if set(item.parts).isdisjoint(skip_dirs) + ] + + return file_list + + def sanitize(input_str: Union[str, Path]) -> str: """Replace any dashes in the supplied string by underscores. diff --git a/py_maker/pymaker.py b/py_maker/pymaker.py index 55683699..2ff11cda 100644 --- a/py_maker/pymaker.py +++ b/py_maker/pymaker.py @@ -16,7 +16,13 @@ from py_maker import template from py_maker.config.settings import Settings from py_maker.constants import ExitErrors, license_names -from py_maker.helpers import get_current_year, get_title, header, sanitize +from py_maker.helpers import ( + get_current_year, + get_file_list, + get_title, + header, + sanitize, +) from py_maker.prompt import Confirm, Prompt from py_maker.schema import ProjectValues @@ -95,13 +101,13 @@ def copy_files(self, template_dir: Traversable, file_list: list[str]): lstrip_blocks=True, keep_trailing_newline=True, ) - for item in file_list: - with pkg_resources.as_file(template_dir / item) as src: + for file in file_list: + with pkg_resources.as_file(template_dir / file) as src: if src.is_dir(): - Path(self.choices.project_dir / item).mkdir() + Path(self.choices.project_dir / file).mkdir() elif src.suffix == ".jinja": - jinja_template = jinja_env.get_template(str(item)) - dst = self.choices.project_dir / Path(item).with_suffix("") + 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(), @@ -109,19 +115,9 @@ def copy_files(self, template_dir: Traversable, file_list: list[str]): ) ) else: - dst = self.choices.project_dir / item + dst = self.choices.project_dir / file dst.write_text(src.read_text(encoding="UTF-8")) - def get_file_list(self, skip_dirs, template_dir): - """Return a list of files to be copied to the project directory.""" - file_list = [ - item.relative_to(template_dir) - for item in template_dir.rglob("*") # type: ignore - if set(item.parts).isdisjoint(skip_dirs) - ] - - return file_list - def generate_template(self) -> None: """Copy the template files to the project directory. @@ -131,18 +127,18 @@ def generate_template(self) -> None: ie: 'README.md.jinja' is copied as 'README.md' after template substitution. """ - skip_dirs = ["__pycache__"] + # skip_dirs: List = ["__pycache__"] try: # ---------------- copy the default template files --------------- # template_dir = pkg_resources.files(template) - file_list = self.get_file_list(skip_dirs, template_dir) + 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(Path.home() / ".pymaker" / "template") if custom_template_dir.exists(): - file_list = self.get_file_list(skip_dirs, custom_template_dir) + file_list = get_file_list(custom_template_dir) self.copy_files(custom_template_dir, file_list) # type: ignore # ---------------- generate the license file next. ------------- # From 7f39bdc32e215f4784dbd459478c6743b1f96b6b Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Fri, 4 Aug 2023 12:02:16 +0100 Subject: [PATCH 11/33] implement dumping the template Signed-off-by: Grant Ramsay --- py_maker/commands/template.py | 59 +++++++++++++++++++ py_maker/main.py | 5 +- requirements.txt | 108 ++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 py_maker/commands/template.py create mode 100644 requirements.txt diff --git a/py_maker/commands/template.py b/py_maker/commands/template.py new file mode 100644 index 00000000..dd96108a --- /dev/null +++ b/py_maker/commands/template.py @@ -0,0 +1,59 @@ +"""Deal with template files.""" +import importlib.resources as pkg_resources +from pathlib import Path + +import typer +from rich import print # pylint: disable=redefined-builtin + +from py_maker import template +from py_maker.constants import ExitErrors +from py_maker.helpers import get_file_list, header + +app = typer.Typer(no_args_is_help=True) + + +@app.command() +def dump( + local: bool = typer.Option( + False, + "--local", + "-l", + help=( + "Dump the template files to the local directory instead of the " + "[b]~/.pymaker[/b] folder." + ), + ) +) -> None: + """Dump the template files. + + By default this will dump all the default template files to the + [b]template[/b] sub-folder in the [b]~/.pymaker[/b] folder, however, if you + specify [b]--local[/b] it will dump them to a [b]template[/b] sub-folder of + your current directory. + """ + header() + print(local) + template_source = pkg_resources.files(template) + + try: + if not local: + output_folder = Path.home() / ".pymaker" / "template" + else: + output_folder = Path.cwd() / "template" + print("Dumping template files to local folder.") + output_folder.mkdir(parents=True, exist_ok=True) + + file_list = get_file_list(template_source) + + for file in file_list: + with pkg_resources.as_file(template_source / file) as src: + if src.is_dir(): + Path(output_folder / file).mkdir( + parents=True, exist_ok=True + ) + else: + dst = output_folder / file + dst.write_text(src.read_text(encoding="utf-8")) + except OSError as err: + print(f"\n[red] -> Error dumping template:[/red] {err}") + typer.Exit(ExitErrors.OS_ERROR) diff --git a/py_maker/main.py b/py_maker/main.py index f162dcc2..ec1e872d 100644 --- a/py_maker/main.py +++ b/py_maker/main.py @@ -1,7 +1,7 @@ """Main application entry point.""" import typer -from py_maker.commands import config, new +from py_maker.commands import config, new, template app = typer.Typer( pretty_exceptions_show_locals=False, @@ -15,6 +15,9 @@ app.add_typer( config.app, name="config", help="Show or change the Configuration." ) +app.add_typer( + template.app, name="template", help="Utilities for handling template files." +) if __name__ == "__main__": app() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..fe21a949 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,108 @@ +annotated-types==0.5.0 ; python_version >= "3.9" and python_version < "4.0" +astroid==2.15.6 ; python_version >= "3.9" and python_version < "4.0" +babel==2.12.1 ; python_version >= "3.9" and python_version < "4.0" +bandit[toml]==1.7.5 ; python_version >= "3.9" and python_version < "4.0" +black==23.7.0 ; python_version >= "3.9" and python_version < "4.0" +certifi==2023.7.22 ; python_version >= "3.9" and python_version < "4.0" +cfgv==3.3.1 ; python_version >= "3.9" and python_version < "4.0" +charset-normalizer==3.2.0 ; python_version >= "3.9" and python_version < "4.0" +classify-imports==4.2.0 ; python_version >= "3.9" and python_version < "4.0" +click==8.1.6 ; python_version >= "3.9" and python_version < "4.0" +colorama==0.4.6 ; python_version >= "3.9" and python_version < "4.0" +coverage[toml]==7.2.7 ; python_version >= "3.9" and python_version < "4.0" +csscompressor==0.9.5 ; python_version >= "3.9" and python_version < "4.0" +dill==0.3.7 ; python_version >= "3.9" and python_version < "4.0" +distlib==0.3.7 ; python_version >= "3.9" and python_version < "4.0" +exceptiongroup==1.1.2 ; python_version >= "3.9" and python_version < "3.11" +execnet==2.0.2 ; python_version >= "3.9" and python_version < "4.0" +faker==19.2.0 ; python_version >= "3.9" and python_version < "4.0" +filelock==3.12.2 ; python_version >= "3.9" and python_version < "4.0" +flake8-docstrings==1.7.0 ; python_version >= "3.9" and python_version < "4.0" +flake8-plugin-utils==1.3.3 ; python_version >= "3.9" and python_version < "4.0" +flake8-pyproject==1.2.3 ; python_version >= "3.9" and python_version < "4.0" +flake8-pytest-style==1.7.2 ; python_version >= "3.9" and python_version < "4.0" +flake8-type-checking==2.4.1 ; python_version >= "3.9" and python_version < "4.0" +flake8==6.1.0 ; python_version >= "3.9" and python_version < "4.0" +ghp-import==2.1.0 ; python_version >= "3.9" and python_version < "4.0" +gitdb==4.0.10 ; python_version >= "3.9" and python_version < "4.0" +gitpython==3.1.32 ; python_version >= "3.9" and python_version < "4.0" +htmlmin2==0.1.13 ; python_version >= "3.9" and python_version < "4.0" +identify==2.5.26 ; python_version >= "3.9" and python_version < "4.0" +idna==3.4 ; python_version >= "3.9" and python_version < "4.0" +importlib-metadata==6.8.0 ; python_version >= "3.9" and python_version < "3.10" +iniconfig==2.0.0 ; python_version >= "3.9" and python_version < "4.0" +isort==5.12.0 ; python_version >= "3.9" and python_version < "4.0" +jinja2==3.1.2 ; python_version >= "3.9" and python_version < "4.0" +jsmin==3.0.1 ; python_version >= "3.9" and python_version < "4.0" +lazy-object-proxy==1.9.0 ; python_version >= "3.9" and python_version < "4.0" +markdown-it-py==3.0.0 ; python_version >= "3.9" and python_version < "4.0" +markdown==3.4.4 ; python_version >= "3.9" and python_version < "4.0" +markupsafe==2.1.3 ; python_version >= "3.9" and python_version < "4.0" +mccabe==0.7.0 ; python_version >= "3.9" and python_version < "4.0" +mdurl==0.1.2 ; python_version >= "3.9" and python_version < "4.0" +mergedeep==1.3.4 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-autorefs==0.4.1 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-git-revision-date-localized-plugin==1.2.0 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-latest-git-tag-plugin==0.1.2 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-material-extensions==1.1.1 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-material==9.1.21 ; python_version >= "3.9" and python_version < "4.0" +mkdocs-minify-plugin==0.7.0 ; python_version >= "3.9" and python_version < "4.0" +mkdocs==1.5.1 ; python_version >= "3.9" and python_version < "4.0" +mkdocstrings==0.22.0 ; python_version >= "3.9" and python_version < "4.0" +mock==5.1.0 ; python_version >= "3.9" and python_version < "4.0" +mypy-extensions==1.0.0 ; python_version >= "3.9" and python_version < "4.0" +mypy==1.4.1 ; python_version >= "3.9" and python_version < "4.0" +nodeenv==1.8.0 ; python_version >= "3.9" and python_version < "4.0" +packaging==23.1 ; python_version >= "3.9" and python_version < "4.0" +pastel==0.2.1 ; python_version >= "3.9" and python_version < "4.0" +pathspec==0.11.2 ; python_version >= "3.9" and python_version < "4.0" +pbr==5.11.1 ; python_version >= "3.9" and python_version < "4.0" +pep8-naming==0.13.3 ; python_version >= "3.9" and python_version < "4.0" +platformdirs==3.10.0 ; python_version >= "3.9" and python_version < "4.0" +pluggy==1.2.0 ; python_version >= "3.9" and python_version < "4.0" +poethepoet==0.21.1 ; python_version >= "3.9" and python_version < "4.0" +pre-commit==3.3.3 ; python_version >= "3.9" and python_version < "4.0" +pycodestyle==2.11.0 ; python_version >= "3.9" and python_version < "4.0" +pydantic-core==2.4.0 ; python_version >= "3.9" and python_version < "4.0" +pydantic==2.1.1 ; python_version >= "3.9" and python_version < "4.0" +pydocstyle==6.3.0 ; python_version >= "3.9" and python_version < "4.0" +pyflakes==3.1.0 ; python_version >= "3.9" and python_version < "4.0" +pygments==2.15.1 ; python_version >= "3.9" and python_version < "4.0" +pylint-plugin-utils==0.8.2 ; python_version >= "3.9" and python_version < "4.0" +pylint-pydantic==0.2.4 ; python_version >= "3.9" and python_version < "4.0" +pylint-pytest==1.1.2 ; python_version >= "3.9" and python_version < "4.0" +pylint==2.17.5 ; python_version >= "3.9" and python_version < "4.0" +pymdown-extensions==10.1 ; python_version >= "3.9" and python_version < "4.0" +pytest-asyncio==0.21.1 ; python_version >= "3.9" and python_version < "4.0" +pytest-cov==4.1.0 ; python_version >= "3.9" and python_version < "4.0" +pytest-mock==3.11.1 ; python_version >= "3.9" and python_version < "4.0" +pytest-randomly==3.13.0 ; python_version >= "3.9" and python_version < "4.0" +pytest-reverse==1.7.0 ; python_version >= "3.9" and python_version < "4.0" +pytest-sugar==0.9.7 ; python_version >= "3.9" and python_version < "4.0" +pytest-xdist==3.3.1 ; python_version >= "3.9" and python_version < "4.0" +pytest==7.4.0 ; python_version >= "3.9" and python_version < "4.0" +python-dateutil==2.8.2 ; python_version >= "3.9" and python_version < "4.0" +pytz==2023.3 ; python_version >= "3.9" and python_version < "4.0" +pyyaml-env-tag==0.1 ; python_version >= "3.9" and python_version < "4.0" +pyyaml==6.0.1 ; python_version >= "3.9" and python_version < "4.0" +regex==2023.6.3 ; python_version >= "3.9" and python_version < "4.0" +requests==2.31.0 ; python_version >= "3.9" and python_version < "4.0" +rich==13.5.0 ; python_version >= "3.9" and python_version < "4.0" +setuptools==68.0.0 ; python_version >= "3.9" and python_version < "4.0" +shellingham==1.5.0.post1 ; python_version >= "3.9" and python_version < "4.0" +six==1.16.0 ; python_version >= "3.9" and python_version < "4.0" +smmap==5.0.0 ; python_version >= "3.9" and python_version < "4.0" +snowballstemmer==2.2.0 ; python_version >= "3.9" and python_version < "4.0" +stevedore==5.1.0 ; python_version >= "3.9" and python_version < "4.0" +termcolor==2.3.0 ; python_version >= "3.9" and python_version < "4.0" +toml==0.10.2 ; python_version >= "3.9" and python_version < "4.0" +tomli==2.0.1 ; python_version >= "3.9" and python_version < "4.0" +tomlkit==0.12.1 ; python_version >= "3.9" and python_version < "4.0" +tryceratops==2.3.2 ; python_version >= "3.9" and python_version < "4.0" +typer[all]==0.9.0 ; python_version >= "3.9" and python_version < "4.0" +typing-extensions==4.7.1 ; python_version >= "3.9" and python_version < "4.0" +urllib3==2.0.4 ; python_version >= "3.9" and python_version < "4.0" +virtualenv==20.24.2 ; python_version >= "3.9" and python_version < "4.0" +watchdog==3.0.0 ; python_version >= "3.9" and python_version < "4.0" +wrapt==1.15.0 ; python_version >= "3.9" and python_version < "4.0" +zipp==3.16.2 ; python_version >= "3.9" and python_version < "3.10" From 13b855b393350cc0e99749f575ae3617fc1ad519 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 8 Aug 2023 19:31:01 +0100 Subject: [PATCH 12/33] docs: start updating docs Signed-off-by: Grant Ramsay --- docs/future-plans.md | 1 + docs/index.md | 9 +++++++++ docs/template/default.md | 26 ++++++++++++++++++++++++++ docs/template/modify.md | 14 ++++++++++++++ docs/template/replace.md | 16 ++++++++++++++++ mkdocs.yml | 5 +++++ 6 files changed, 71 insertions(+) create mode 100644 docs/future-plans.md create mode 100644 docs/template/default.md create mode 100644 docs/template/modify.md create mode 100644 docs/template/replace.md diff --git a/docs/future-plans.md b/docs/future-plans.md new file mode 100644 index 00000000..7cf674f9 --- /dev/null +++ b/docs/future-plans.md @@ -0,0 +1 @@ +--8<-- "TODO.md" diff --git a/docs/index.md b/docs/index.md index e605f7a5..43ad7dde 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,15 @@ type checking. [isort](https://pycqa.github.io/isort/){:target="_blank"}, [tyrceratops](https://github.com/guilatrova/tryceratops){:target="_blank"} are also installed as standard. +## Customize the generated project + +You can add extra or edited files to the generated project by adding them to the +`~/.pymaker/template` directory. The files in this directory will be copied +into the generated project, overwriting any existing files with the same name. + +It is also possible to dump the whole template into this folder or the current +folder so full customization and even removal of files is possible. + ## Pre-commit The generated project uses [pre-commit](https://pre-commit.com/) to run some diff --git a/docs/template/default.md b/docs/template/default.md new file mode 100644 index 00000000..9d54d895 --- /dev/null +++ b/docs/template/default.md @@ -0,0 +1,26 @@ +# The 'Default' Template + +By default, the generated application will have a basic template that you can +use to get started, this template is stored inside the package itself. It will +contain all you need to get started, including a basic `README.md` file. + +The dependency management is handled by +[Poetry](){:target="_blank"}, and we include a +`pyproject.toml` file with several useful dependencies: + +- [PyTest](https://docs.pytest.org/en/stable/contents.html){:target="_blank"} + for testing, along with several useful plugins. +- The [Black](https://black.readthedocs.io/en/stable/){:target="_blank"} + code formatter. +- The [Flake8](https://flake8.pycqa.org/en/latest/){:target="_blank"} linter, + along with a good selection of plugins. It is also set up to use the + `pyproject.toml` for it's configuration, and to work nicely with Black. +- [Pylint](){:target="_blank"} and + [Pydocstyle](https://www.pydocstyle.org/en/stable/){:target="_blank"} + linters. +- [MyPy](https://mypy.readthedocs.io/en/stable/){:target="_blank"} for static + type checking. +- [Isort](https://pycqa.github.io/isort/){:target="_blank"} for sorting + imports. +- [pre-commit](https://pre-commit.com/){:target="_blank"} for running checks + before committing code. diff --git a/docs/template/modify.md b/docs/template/modify.md new file mode 100644 index 00000000..98ab4c07 --- /dev/null +++ b/docs/template/modify.md @@ -0,0 +1,14 @@ +# Adding or Modifying files in the template + +If you always wish to add or change specific files in the template, you can do +so by adding them to the `~/.pymaker/template` folder. The files (and folders) +in this folder will be copied to the root of the project when the template is +generated. + +Files in this global template folder will override any files in the default +template, so you can for example change the `README.md` file, add to the +`.gitignore` or even add a complete extra folder structure. + +If you want to do a major change to the template, you can actually dump the +default template to this folder and modify or delete files as you see fit. See +the next section for more information on how to do this. diff --git a/docs/template/replace.md b/docs/template/replace.md new file mode 100644 index 00000000..da24ea59 --- /dev/null +++ b/docs/template/replace.md @@ -0,0 +1,16 @@ +# Replacing the Default Template + +Should you wish to heavily modify the default template, or even replace it +completely, you can do so by dumping the default template to the +`~/.pymaker/template` folder. This will copy all files from the default template +to the global template folder, where you can modify or delete them as you see +fit. + +To do this, run the following command: + +```console +$ pymaker template dump +``` + +This will copy the default template to the global template folder +(`~/.pymaker/template`). You can then modify or delete files as you see fit. diff --git a/mkdocs.yml b/mkdocs.yml index 8c8da015..0d33215d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,5 +55,10 @@ nav: - Installation: installation.md - Quick Start: quick-start.md - Configuration: configuration.md + - Templates: + - Default Template: template/default.md + - Modifying: template/modify.md + - Replacing: template/replace.md - Task Runner: tasks.md + - Future Plans: future-plans.md - License: license.md From 4382fb1801277e5ba52c8ae0d1b18737b1cac774 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 8 Aug 2023 19:32:15 +0100 Subject: [PATCH 13/33] convert settings into a module Signed-off-by: Grant Ramsay --- py_maker/config/__init__.py | 3 +++ py_maker/config/settings.py | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 py_maker/config/__init__.py diff --git a/py_maker/config/__init__.py b/py_maker/config/__init__.py new file mode 100644 index 00000000..6b3867a4 --- /dev/null +++ b/py_maker/config/__init__.py @@ -0,0 +1,3 @@ +from .settings import Settings + +__all__ = ["Settings"] diff --git a/py_maker/config/settings.py b/py_maker/config/settings.py index 2248eea4..264caf6d 100644 --- a/py_maker/config/settings.py +++ b/py_maker/config/settings.py @@ -29,10 +29,15 @@ class Settings: settings_path: Path = settings_folder / "config.toml" # define our settings - schema_version: str = "1.0" + # the schema_version is used to track changes to the settings file but will + # be unused until we have a stable release. Expect the schema layout to + # change at any time unitl then! + schema_version: str = "none" author_name: str = "" author_email: str = "" default_license: str = "" + no_default_template: str = "No" + template_folder: str = "" def __post_init__(self): """Create the settings folder if it doesn't exist.""" From 90c6307d9cb68dead3b4f531417b22d80a702e97 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 8 Aug 2023 19:35:08 +0100 Subject: [PATCH 14/33] small work on testing Signed-off-by: Grant Ramsay --- tests/test_pymaker.py | 46 ++++++++++++++++++++++++++++++++++++++++++ tests/test_settings.py | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/test_pymaker.py diff --git a/tests/test_pymaker.py b/tests/test_pymaker.py new file mode 100644 index 00000000..8e8468a4 --- /dev/null +++ b/tests/test_pymaker.py @@ -0,0 +1,46 @@ +"""Test the PyMaker class. + +These tests are not complete. +""" +import os +import shutil +from collections.abc import Iterator # noqa: TC003 +from pathlib import Path + +import pytest + +from py_maker.pymaker import PyMaker + + +@pytest.fixture() +def test_project_dir(tmp_path_factory) -> Iterator[Path]: + """Create a temporary directory for testing.""" + project_dir: Path = tmp_path_factory.mktemp("test_project") + yield project_dir + shutil.rmtree(project_dir) + + +@pytest.fixture() +def test_pymaker(test_project_dir) -> PyMaker: + """Create a PyMaker instance for testing.""" + pymaker = PyMaker(location="test_project") + pymaker.choices.name = "test_project" + pymaker.choices.author = "Test Author" + pymaker.choices.email = "test@example.com" + pymaker.choices.license = "MIT" + pymaker.choices.description = "A test project" + pymaker.choices.project_dir = Path(test_project_dir) / "test_project" + return pymaker + + +def test_create_folders(test_pymaker): + """Test that the create_folders method creates the project directory.""" + test_pymaker.create_folders() + assert os.path.isdir(test_pymaker.choices.project_dir) + + +def test_copy_files(test_pymaker: PyMaker): + """Test that the copy_files method copies the template files.""" + test_pymaker.create_folders() + + # TODO: Add assertions to test that the files were copied correctly diff --git a/tests/test_settings.py b/tests/test_settings.py index c5b1725f..42ed112a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -24,7 +24,7 @@ def test_settings(): settings = Settings(settings_path=settings_path) # Test that the default settings are correct - assert settings.schema_version == "1.0" + assert settings.schema_version == "none" assert settings.author_name == "" assert settings.author_email == "" assert settings.default_license == "" From 20111c462327b701fc650302109f107fda7a36c4 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 8 Aug 2023 19:47:28 +0100 Subject: [PATCH 15/33] continue WIP on CLI template command Signed-off-by: Grant Ramsay --- py_maker/commands/template.py | 42 +++++++++++++++++++++++++++++++++-- py_maker/config/settings.py | 2 +- py_maker/constants.py | 1 + py_maker/helpers.py | 2 +- py_maker/pymaker.py | 5 +++-- 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/py_maker/commands/template.py b/py_maker/commands/template.py index dd96108a..3142080e 100644 --- a/py_maker/commands/template.py +++ b/py_maker/commands/template.py @@ -6,8 +6,10 @@ from rich import print # pylint: disable=redefined-builtin from py_maker import template +from py_maker.config import Settings from py_maker.constants import ExitErrors from py_maker.helpers import get_file_list, header +from py_maker.prompt import Confirm app = typer.Typer(no_args_is_help=True) @@ -32,7 +34,6 @@ def dump( your current directory. """ header() - print(local) template_source = pkg_resources.files(template) try: @@ -40,7 +41,6 @@ def dump( output_folder = Path.home() / ".pymaker" / "template" else: output_folder = Path.cwd() / "template" - print("Dumping template files to local folder.") output_folder.mkdir(parents=True, exist_ok=True) file_list = get_file_list(template_source) @@ -54,6 +54,44 @@ def dump( else: dst = output_folder / file dst.write_text(src.read_text(encoding="utf-8")) + + print(f"[green] -> Template files dumped to:[/green] {output_folder}") + + set_default = Confirm.ask( + f"\n[green]Set default template folder to:[/green] {output_folder}?" + ) + if set_default: + s = Settings() + s.template_folder = str(output_folder) + s.save() + except OSError as err: print(f"\n[red] -> Error dumping template:[/red] {err}") typer.Exit(ExitErrors.OS_ERROR) + + +@app.command() +def default(action: str) -> None: + """Enable or disable the default template folder. + + [b]action[/b] can be either [b]enable[/b] or [b]disable[/b]. + """ + header() + s = Settings() + if action == "enable": + s.use_default_template = True + s.save() + print( + f"[green] -> Default template folder enabled:[/green] " + f"{s.template_folder}" + ) + elif action == "disable": + s.use_default_template = False + s.save() + print("[green] -> Default template folder disabled[/green]") + else: + print( + f"[red] -> Invalid action:[/red] {action}\n" + f"[red] -> Action must be either:[/red] enable or disable" + ) + typer.Exit(ExitErrors.INVALID_ACTION) diff --git a/py_maker/config/settings.py b/py_maker/config/settings.py index 264caf6d..95024bf2 100644 --- a/py_maker/config/settings.py +++ b/py_maker/config/settings.py @@ -36,7 +36,7 @@ class Settings: author_name: str = "" author_email: str = "" default_license: str = "" - no_default_template: str = "No" + use_default_template: bool = True template_folder: str = "" def __post_init__(self): diff --git a/py_maker/constants.py b/py_maker/constants.py index 7e938db3..083b2f04 100644 --- a/py_maker/constants.py +++ b/py_maker/constants.py @@ -31,3 +31,4 @@ class ExitErrors(IntEnum): PERMISSION_DENIED = 5 USER_ABORT = 6 OS_ERROR = 7 + INVALID_ACTION = 8 diff --git a/py_maker/helpers.py b/py_maker/helpers.py index 70317657..f414d265 100644 --- a/py_maker/helpers.py +++ b/py_maker/helpers.py @@ -82,5 +82,5 @@ def show_table(settings: Dict[str, str]): table.add_column("Value") for key, value in settings.items(): - table.add_row(pretty_attrib(key), value) + table.add_row(pretty_attrib(key), str(value)) console.print(table) diff --git a/py_maker/pymaker.py b/py_maker/pymaker.py index 2ff11cda..fb186a92 100644 --- a/py_maker/pymaker.py +++ b/py_maker/pymaker.py @@ -132,8 +132,9 @@ def generate_template(self) -> None: try: # ---------------- copy the default template files --------------- # template_dir = pkg_resources.files(template) - file_list = get_file_list(template_dir) - self.copy_files(template_dir, file_list) + 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(Path.home() / ".pymaker" / "template") From a95bdc52bd21b341185b73dee0a24beeb80bd7e4 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 8 Aug 2023 19:58:18 +0100 Subject: [PATCH 16/33] docs: document the CLI 'template default' cmd Signed-off-by: Grant Ramsay --- docs/template/replace.md | 26 ++++++++++++++++++++++++++ py_maker/commands/template.py | 1 + 2 files changed, 27 insertions(+) diff --git a/docs/template/replace.md b/docs/template/replace.md index da24ea59..39fcfdd5 100644 --- a/docs/template/replace.md +++ b/docs/template/replace.md @@ -1,5 +1,7 @@ # Replacing the Default Template +## Dump the Default Template + Should you wish to heavily modify the default template, or even replace it completely, you can do so by dumping the default template to the `~/.pymaker/template` folder. This will copy all files from the default template @@ -14,3 +16,27 @@ $ pymaker template dump This will copy the default template to the global template folder (`~/.pymaker/template`). You can then modify or delete files as you see fit. + +Running this command will ask you if you wish to set this exported template as +the default template. If you answer yes, then the default template will be +disabled, and ONLY the exported template will be used instead. Otherwise, both +will still be used with the exported template taking precedence. + +## Use another Template completely + +!!! note + This feature is not yet implemented. + +## Choose to use the Default Template or not + +Running the `dump` command will give you the option to disable the default +template completely and ONLY use the exported template. You can also do this (or +revert back to the default template) by running the following command: + +```console +$ pymaker template default +``` + +`enable` will enable the default template, and `disable` will disable it. Please +note that any custom templates you have created will still be used, and will +overwrite the default template if they have the same file name. diff --git a/py_maker/commands/template.py b/py_maker/commands/template.py index 3142080e..5594d69c 100644 --- a/py_maker/commands/template.py +++ b/py_maker/commands/template.py @@ -63,6 +63,7 @@ def dump( if set_default: s = Settings() s.template_folder = str(output_folder) + s.use_default_template = False s.save() except OSError as err: From 7a8571444836e88d8dde774b12ca8f0237bb1271 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Tue, 8 Aug 2023 20:13:44 +0100 Subject: [PATCH 17/33] update README and TODO Signed-off-by: Grant Ramsay --- README.md | 6 ++++++ TODO.md | 11 +++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cb3c6e6d..6554f1d6 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ More functionality will be added very shortly and the code will be refactored and cleaned up. - [Installation](#installation) +- [Documentation](#documentation) - [Usage](#usage) - [Task Runner](#task-runner) - [Customise](#customise) @@ -47,6 +48,11 @@ or use [pipx](https://pypa.github.io/pipx/) $ pipx install pyproject-maker ``` +## Documentation + +Full documentation for this project with usage examples is available at + + ## Usage To create a new project, run the following command: diff --git a/TODO.md b/TODO.md index 15715d7c..3fd0589e 100644 --- a/TODO.md +++ b/TODO.md @@ -13,12 +13,15 @@ is already taken. If it is, either abort or ask the user if they want to continue (making clear they will need to rename the package before it can be uploaded). -- option to dump the default template files to a local directory so they can be - edited and used as custom templates, optionally dumping to the - `~/.pymaker/templates` folder overwriting existing customizations. - add some form of 'extra packages' command line option and config setting to automatically add extra packages to the generated `pyproject.toml` file. - add cmd line options to specify the project name, author, etc. so the user doesn't have to enter them manually. - add a command line option to specify the project type so the user doesn't have - to enter it manually. + to enter it manually. ie `--standalone` or `--package`(latter is default and + wouldn't need to be specified). + +## Documentation + +- Add usage examples and perhaps a walk-through to the documentation. Maybe + with a YouTube video? From b62e10bcdab8683a17e11366402a0b49aabdefa2 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Wed, 9 Aug 2023 13:56:25 +0100 Subject: [PATCH 18/33] WIP class to dump a directory tree to console Signed-off-by: Grant Ramsay --- py_maker/tree/__init__.py | 3 +++ py_maker/tree/tree.py | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 py_maker/tree/__init__.py create mode 100644 py_maker/tree/tree.py diff --git a/py_maker/tree/__init__.py b/py_maker/tree/__init__.py new file mode 100644 index 00000000..473c4cfd --- /dev/null +++ b/py_maker/tree/__init__.py @@ -0,0 +1,3 @@ +from .tree import FileTree + +_all__ = ["FileTree"] diff --git a/py_maker/tree/tree.py b/py_maker/tree/tree.py new file mode 100644 index 00000000..15c35142 --- /dev/null +++ b/py_maker/tree/tree.py @@ -0,0 +1,55 @@ +"""WIP Class to display a directory tree using Rich to the console. + +Heavily influenced by the example in Rich.tree documentation. +""" + +import pathlib + +from rich import print +from rich.filesize import decimal +from rich.markup import escape +from rich.text import Text +from rich.tree import Tree + + +class FileTree: + """Display a directory tree using Rich.""" + + def __init__(self, directory: pathlib.Path) -> None: + """Initialize the FileTree class.""" + self.directory = directory + + def walk_directory(self, directory: pathlib.Path, tree: Tree) -> None: + """Recursively build a Tree with directory contents.""" + # Sort dirs first then by filename + paths = sorted( + pathlib.Path(directory).iterdir(), + key=lambda path: (path.is_file(), path.name.lower()), + ) + for path in paths: + if path.is_dir(): + style = "dim" if path.name.startswith("__") else "" + branch = tree.add( + f"[bold cyan]:open_file_folder: [link file://{path}]" + f"{escape(path.name)}", + style=style, + guide_style=style, + ) + self.walk_directory(path, branch) + else: + text_filename = Text(path.name, "green") + text_filename.stylize(f"link file://{path}") + file_size = path.stat().st_size + text_filename.append(f" ({decimal(file_size)})", "blue") + icon = "🐍 " if path.suffix == ".py" else "📄 " + tree.add(Text(icon) + text_filename) + + def show(self) -> None: + """Print a directory tree to the console.""" + tree = Tree( + f":open_file_folder: [link file://{self.directory}]" + "{self.directory}", + guide_style="bright_blue", + ) + self.walk_directory(self.directory, tree) + print(tree) From 6b87041cb32ef6f3160cd2f59b64514ebe3772ab Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Wed, 9 Aug 2023 15:53:05 +0100 Subject: [PATCH 19/33] fix missing f-string tag Signed-off-by: Grant Ramsay --- py_maker/tree/tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py_maker/tree/tree.py b/py_maker/tree/tree.py index 15c35142..efe067a0 100644 --- a/py_maker/tree/tree.py +++ b/py_maker/tree/tree.py @@ -48,7 +48,7 @@ def show(self) -> None: """Print a directory tree to the console.""" tree = Tree( f":open_file_folder: [link file://{self.directory}]" - "{self.directory}", + f"{self.directory}", guide_style="bright_blue", ) self.walk_directory(self.directory, tree) From 2f583ec72c21a591db2da7513a3f9c7777e06bdd Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Wed, 9 Aug 2023 16:54:26 +0100 Subject: [PATCH 20/33] handle missing directory or not a dir in FileTree Signed-off-by: Grant Ramsay --- py_maker/tree/tree.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/py_maker/tree/tree.py b/py_maker/tree/tree.py index efe067a0..d1ded65f 100644 --- a/py_maker/tree/tree.py +++ b/py_maker/tree/tree.py @@ -2,8 +2,7 @@ Heavily influenced by the example in Rich.tree documentation. """ - -import pathlib +from pathlib import Path from rich import print from rich.filesize import decimal @@ -15,15 +14,26 @@ class FileTree: """Display a directory tree using Rich.""" - def __init__(self, directory: pathlib.Path) -> None: + def __init__(self, directory: Path) -> None: """Initialize the FileTree class.""" - self.directory = directory + self.directory: Path = Path(directory).expanduser().resolve() + + if not self.directory.is_dir(): + raise NotADirectoryError(f"{self.directory} is not a directory.") - def walk_directory(self, directory: pathlib.Path, tree: Tree) -> None: + def walk_directory(self, directory: Path, tree: Tree) -> None: """Recursively build a Tree with directory contents.""" # Sort dirs first then by filename + try: + paths = sorted( + directory.iterdir(), + key=lambda path: (path.is_file(), path.name.lower()), + ) + except PermissionError: + # Skip directories that the user does not have permission to access + return paths = sorted( - pathlib.Path(directory).iterdir(), + Path(directory).iterdir(), key=lambda path: (path.is_file(), path.name.lower()), ) for path in paths: From 30e844de28e984fb7bddba1cf9ebc408b724e2a5 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Wed, 9 Aug 2023 17:15:00 +0100 Subject: [PATCH 21/33] refactor FileTree to use a subclass of Path adding a method to fully expand the path with user and env vars Signed-off-by: Grant Ramsay --- py_maker/tree/tree.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/py_maker/tree/tree.py b/py_maker/tree/tree.py index d1ded65f..f1f71e45 100644 --- a/py_maker/tree/tree.py +++ b/py_maker/tree/tree.py @@ -2,7 +2,8 @@ Heavily influenced by the example in Rich.tree documentation. """ -from pathlib import Path +import os +import pathlib from rich import print from rich.filesize import decimal @@ -11,12 +12,25 @@ from rich.tree import Tree +# create a subclass of pathlib.Path which has an extra method 'expand'. This +# method will expand environment variables and user home directory, and in all +# cases return an absolute path. +class Path(pathlib.Path): + """Path class with additional methods.""" + + _flavour = type(pathlib.Path())._flavour # type: ignore + + def expand(self): + """Fully expand and resolve the Path given environment variables.""" + return Path(os.path.expandvars(self)).expanduser().resolve() + + class FileTree: """Display a directory tree using Rich.""" def __init__(self, directory: Path) -> None: """Initialize the FileTree class.""" - self.directory: Path = Path(directory).expanduser().resolve() + self.directory: Path = Path(directory).expand() # type: ignore if not self.directory.is_dir(): raise NotADirectoryError(f"{self.directory} is not a directory.") From e69bb25d04f44faa0488f6655492ad993a0771e1 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Wed, 9 Aug 2023 17:28:50 +0100 Subject: [PATCH 22/33] fix some linting issues Signed-off-by: Grant Ramsay --- py_maker/commands/template.py | 20 ++++++++++---------- py_maker/config/__init__.py | 1 + py_maker/tree/__init__.py | 1 + py_maker/tree/tree.py | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/py_maker/commands/template.py b/py_maker/commands/template.py index 5594d69c..37741c65 100644 --- a/py_maker/commands/template.py +++ b/py_maker/commands/template.py @@ -61,10 +61,10 @@ def dump( f"\n[green]Set default template folder to:[/green] {output_folder}?" ) if set_default: - s = Settings() - s.template_folder = str(output_folder) - s.use_default_template = False - s.save() + settings = Settings() + settings.template_folder = str(output_folder) + settings.use_default_template = False + settings.save() except OSError as err: print(f"\n[red] -> Error dumping template:[/red] {err}") @@ -78,17 +78,17 @@ def default(action: str) -> None: [b]action[/b] can be either [b]enable[/b] or [b]disable[/b]. """ header() - s = Settings() + settings = Settings() if action == "enable": - s.use_default_template = True - s.save() + settings.use_default_template = True + settings.save() print( f"[green] -> Default template folder enabled:[/green] " - f"{s.template_folder}" + f"{settings.template_folder}" ) elif action == "disable": - s.use_default_template = False - s.save() + settings.use_default_template = False + settings.save() print("[green] -> Default template folder disabled[/green]") else: print( diff --git a/py_maker/config/__init__.py b/py_maker/config/__init__.py index 6b3867a4..2203d010 100644 --- a/py_maker/config/__init__.py +++ b/py_maker/config/__init__.py @@ -1,3 +1,4 @@ +"""Settings module.""" from .settings import Settings __all__ = ["Settings"] diff --git a/py_maker/tree/__init__.py b/py_maker/tree/__init__.py index 473c4cfd..593575cd 100644 --- a/py_maker/tree/__init__.py +++ b/py_maker/tree/__init__.py @@ -1,3 +1,4 @@ +"""A module for displaying a file tree in the terminal.""" from .tree import FileTree _all__ = ["FileTree"] diff --git a/py_maker/tree/tree.py b/py_maker/tree/tree.py index f1f71e45..c436d178 100644 --- a/py_maker/tree/tree.py +++ b/py_maker/tree/tree.py @@ -5,7 +5,7 @@ import os import pathlib -from rich import print +from rich import print # noqa: W0622 from rich.filesize import decimal from rich.markup import escape from rich.text import Text From 25e2d4d452bba06db6743221b6442ba404efa2fa Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Wed, 9 Aug 2023 17:33:12 +0100 Subject: [PATCH 23/33] use correct linted bypass switch Signed-off-by: Grant Ramsay --- py_maker/tree/tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/py_maker/tree/tree.py b/py_maker/tree/tree.py index c436d178..e5ba21cc 100644 --- a/py_maker/tree/tree.py +++ b/py_maker/tree/tree.py @@ -5,7 +5,7 @@ import os import pathlib -from rich import print # noqa: W0622 +from rich import print # pylint: disable=W0622 from rich.filesize import decimal from rich.markup import escape from rich.text import Text From bf7ffd0db51ef1b4d7124f14a73e112a5d995b44 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Wed, 9 Aug 2023 18:52:22 +0100 Subject: [PATCH 24/33] further linting fixes Signed-off-by: Grant Ramsay --- py_maker/commands/template.py | 8 ++++---- py_maker/tree/tree.py | 1 + pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/py_maker/commands/template.py b/py_maker/commands/template.py index 37741c65..31c28450 100644 --- a/py_maker/commands/template.py +++ b/py_maker/commands/template.py @@ -66,9 +66,9 @@ def dump( settings.use_default_template = False settings.save() - except OSError as err: - print(f"\n[red] -> Error dumping template:[/red] {err}") - typer.Exit(ExitErrors.OS_ERROR) + except OSError as exc: + print(f"\n[red] -> Error dumping template:[/red] {exc}") + raise typer.Exit(ExitErrors.OS_ERROR) from exc @app.command() @@ -95,4 +95,4 @@ def default(action: str) -> None: f"[red] -> Invalid action:[/red] {action}\n" f"[red] -> Action must be either:[/red] enable or disable" ) - typer.Exit(ExitErrors.INVALID_ACTION) + raise typer.Exit(ExitErrors.INVALID_ACTION) diff --git a/py_maker/tree/tree.py b/py_maker/tree/tree.py index e5ba21cc..67e08155 100644 --- a/py_maker/tree/tree.py +++ b/py_maker/tree/tree.py @@ -18,6 +18,7 @@ class Path(pathlib.Path): """Path class with additional methods.""" + # pylint: disable-next=no-member, protected-access _flavour = type(pathlib.Path())._flavour # type: ignore def expand(self): diff --git a/pyproject.toml b/pyproject.toml index 95dfbeb3..2c308178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,7 @@ mypy = "mypy **/*.py" flake8 = "flake8 **/*.py" black = "black **/*.py" try = "tryceratops **/*.py" -lint = ["black", "flake8", "pylint", "mypy", "try"] +lint = ["black", "flake8", "mypy", "try", "pylint"] "docs:publish" = "mkdocs gh-deploy" "docs:build" = "mkdocs build" From 836945e6873be7117da6624851449e4d9712e1f1 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 10 Aug 2023 11:32:40 +0100 Subject: [PATCH 25/33] settings: set template_folder by default defaults to '~/.pymaker/template' Signed-off-by: Grant Ramsay --- py_maker/config/settings.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/py_maker/config/settings.py b/py_maker/config/settings.py index 95024bf2..ea8e209c 100644 --- a/py_maker/config/settings.py +++ b/py_maker/config/settings.py @@ -35,9 +35,11 @@ class Settings: schema_version: str = "none" author_name: str = "" author_email: str = "" - default_license: str = "" + default_license: str = "MIT" use_default_template: bool = True - template_folder: str = "" + + # cant use Pathlike here as it breaks rtoml + template_folder: str = str(settings_path / "template") def __post_init__(self): """Create the settings folder if it doesn't exist.""" From 21b28281de428f5bfdad3dde57e4787064c966e8 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 10 Aug 2023 11:43:39 +0100 Subject: [PATCH 26/33] complete the CLI 'template' command' Signed-off-by: Grant Ramsay --- py_maker/commands/template.py | 88 +++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/py_maker/commands/template.py b/py_maker/commands/template.py index 31c28450..518504a0 100644 --- a/py_maker/commands/template.py +++ b/py_maker/commands/template.py @@ -39,9 +39,16 @@ def dump( try: if not local: output_folder = Path.home() / ".pymaker" / "template" - else: - output_folder = Path.cwd() / "template" output_folder.mkdir(parents=True, exist_ok=True) + else: + output_folder = Path.cwd() + + if any(output_folder.iterdir()) and not Confirm.ask( + f"[red]The output folder:[/red] {output_folder} " + f"[red]is not empty, do you want to continue?[/red]", + default=False, + ): + raise typer.Exit(ExitErrors.USER_ABORT) # noqa TRY301 file_list = get_file_list(template_source) @@ -58,12 +65,21 @@ def dump( print(f"[green] -> Template files dumped to:[/green] {output_folder}") set_default = Confirm.ask( - f"\n[green]Set default template folder to:[/green] {output_folder}?" + f"\n[green]Set the template folder to:[/green] {output_folder}?", + default=True, ) + if set_default: + print("[green] -> Template folder set[/green]") settings = Settings() settings.template_folder = str(output_folder) - settings.use_default_template = False + + if Confirm.ask( + "[green]Disable the default template folder?", default=False + ): + print("[green] -> Default template folder disabled[/green]") + settings.use_default_template = False + settings.save() except OSError as exc: @@ -73,9 +89,12 @@ def dump( @app.command() def default(action: str) -> None: - """Enable or disable the default template folder. + """Enable or disable using the INTERNAL templates. [b]action[/b] can be either [b]enable[/b] or [b]disable[/b]. + + [b]enable[/b] will enable the internal template + [b]disable[/b] will disable the internal template folder """ header() settings = Settings() @@ -96,3 +115,62 @@ def default(action: str) -> None: f"[red] -> Action must be either:[/red] enable or disable" ) raise typer.Exit(ExitErrors.INVALID_ACTION) + + +@app.command() +def set(): + """Set the template folder to the current directory. + + The [i]'Use Default Template'[/i] setting [b][red]will not be changed[/b]. + """ + header() + if not Confirm.ask( + "[red]Are you sure you want to set the template folder to the " + "current folder?[/red]", + default=False, + ): + raise typer.Exit(ExitErrors.USER_ABORT) + settings = Settings() + settings.template_folder = str(Path.cwd()) + settings.save() + + print( + f"[green] -> Template folder set to:[/green] " + f"{settings.template_folder}" + ) + print( + "[yellow] -> The 'Use Default Template' setting has not been " + "changed.\n" + "[yellow] You can change this with the '[b][i]pymaker template " + "default'[/i][/b] command.\n" + ) + + +@app.command() +def reset(): + """Reset the template folder to the default. + + This is currrently [b]~/.pymaker/template[/b]. + The [i]'Use Default Template'[/i] setting [b][red]will not be changed[/b]. + """ + header() + if not Confirm.ask( + "[red]Are you sure you want to reset the template folder to the " + "default?[/red]", + default=False, + ): + raise typer.Exit(ExitErrors.USER_ABORT) + + settings = Settings() + settings.template_folder = str(Path.home() / ".pymaker" / "template") + settings.save() + print( + f"[green] -> Template folder reset to:[/green] " + f"{settings.template_folder}" + ) + print( + "[yellow] -> The 'Use Default Template' setting has not been " + "changed.\n" + "[yellow] You can change this with the '[b][i]pymaker template " + "default'[/i][/b] command.\n" + ) From ec0278ca2bd7d6afc80c4ce1e477e2df5020e2de Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 10 Aug 2023 11:44:33 +0100 Subject: [PATCH 27/33] little tweak to the default settings Signed-off-by: Grant Ramsay --- py_maker/config/settings.py | 2 +- py_maker/pymaker.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/py_maker/config/settings.py b/py_maker/config/settings.py index ea8e209c..8b9cc0a3 100644 --- a/py_maker/config/settings.py +++ b/py_maker/config/settings.py @@ -35,7 +35,7 @@ class Settings: schema_version: str = "none" author_name: str = "" author_email: str = "" - default_license: str = "MIT" + default_license: str = "" use_default_template: bool = True # cant use Pathlike here as it breaks rtoml diff --git a/py_maker/pymaker.py b/py_maker/pymaker.py index fb186a92..1f4ea399 100644 --- a/py_maker/pymaker.py +++ b/py_maker/pymaker.py @@ -137,7 +137,7 @@ def generate_template(self) -> None: self.copy_files(template_dir, file_list) # --------- copy the custom template files if they exist --------- # - custom_template_dir = Path(Path.home() / ".pymaker" / "template") + 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) # type: ignore From f9ff24b4c13805b469bc756f98826b3653b8990a Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 10 Aug 2023 11:44:57 +0100 Subject: [PATCH 28/33] add another linter and fix warnings Signed-off-by: Grant Ramsay --- poetry.lock | 16 +++++++++++++++- pyproject.toml | 1 + requirements.txt | 1 + tests/test_pymaker.py | 3 +-- tests/test_settings.py | 3 +-- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index d4775f7a..d66e696e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -508,6 +508,20 @@ files = [ classify-imports = "*" flake8 = "*" +[[package]] +name = "flake8-use-pathlib" +version = "0.3.0" +description = "A plugin for flake8 finding use of functions that can be replaced by pathlib module." +optional = false +python-versions = ">=3.7" +files = [ + {file = "flake8-use-pathlib-0.3.0.tar.gz", hash = "sha256:0ef19f255a51601bcf04ff54f25ef8a466dff68210cd95b4f1db36a78ace5223"}, + {file = "flake8_use_pathlib-0.3.0-py3-none-any.whl", hash = "sha256:c7b6d71575b575f7d70ebf3f1d7f2dd6685e401d3280208f1db9dbb6bfa32608"}, +] + +[package.dependencies] +flake8 = ">=3.6" + [[package]] name = "ghp-import" version = "2.1.0" @@ -2196,4 +2210,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "e35d55bf906aa254db525fdba22981e6f1395dcb354e71f41a9dcf8011e187b0" +content-hash = "56fd5c59500ad381476142db3e2da93fa8c881f772635e51ec09a5e2e7760e83" diff --git a/pyproject.toml b/pyproject.toml index 2c308178..6dea72d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ flake8-docstrings = "^1.7.0" flake8-pyproject = "^1.2.3" flake8-pytest-style = "^1.7.2" flake8-type-checking = "^2.4.1" +flake8-use-pathlib = "^0.3.0" isort = "^5.12.0" pylint = "^2.17.2" pylint-pydantic = "^0.2.4" diff --git a/requirements.txt b/requirements.txt index fe21a949..271760e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ flake8-plugin-utils==1.3.3 ; python_version >= "3.9" and python_version < "4.0" flake8-pyproject==1.2.3 ; python_version >= "3.9" and python_version < "4.0" flake8-pytest-style==1.7.2 ; python_version >= "3.9" and python_version < "4.0" flake8-type-checking==2.4.1 ; python_version >= "3.9" and python_version < "4.0" +flake8-use-pathlib==0.3.0 ; python_version >= "3.9" and python_version < "4.0" flake8==6.1.0 ; python_version >= "3.9" and python_version < "4.0" ghp-import==2.1.0 ; python_version >= "3.9" and python_version < "4.0" gitdb==4.0.10 ; python_version >= "3.9" and python_version < "4.0" diff --git a/tests/test_pymaker.py b/tests/test_pymaker.py index 8e8468a4..3fad3b54 100644 --- a/tests/test_pymaker.py +++ b/tests/test_pymaker.py @@ -2,7 +2,6 @@ These tests are not complete. """ -import os import shutil from collections.abc import Iterator # noqa: TC003 from pathlib import Path @@ -36,7 +35,7 @@ def test_pymaker(test_project_dir) -> PyMaker: def test_create_folders(test_pymaker): """Test that the create_folders method creates the project directory.""" test_pymaker.create_folders() - assert os.path.isdir(test_pymaker.choices.project_dir) + assert test_pymaker.choices.project_dir.is_dir() def test_copy_files(test_pymaker: PyMaker): diff --git a/tests/test_settings.py b/tests/test_settings.py index 42ed112a..c75a6cca 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,5 +1,4 @@ """Tests for the Settings class.""" "" -import os import tempfile from pathlib import Path @@ -15,7 +14,7 @@ def test_settings(): with tempfile.TemporaryDirectory() as tmpdir: # Set the settings folder to the temporary directory settings_folder = Path(tmpdir) / ".pymaker" - os.makedirs(settings_folder) + settings_folder.mkdir() # Set the settings path to the temporary directory settings_path = settings_folder / "config.toml" From ef4ffccbde404eb3fa0f1197d7eccfc676f69860 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 10 Aug 2023 12:01:26 +0100 Subject: [PATCH 29/33] set default license to 'None' empty license was bypassing choices and causing Exception Signed-off-by: Grant Ramsay --- py_maker/config/settings.py | 2 +- py_maker/pymaker.py | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/py_maker/config/settings.py b/py_maker/config/settings.py index 8b9cc0a3..ed8a4029 100644 --- a/py_maker/config/settings.py +++ b/py_maker/config/settings.py @@ -35,7 +35,7 @@ class Settings: schema_version: str = "none" author_name: str = "" author_email: str = "" - default_license: str = "" + default_license: str = "None" use_default_template: bool = True # cant use Pathlike here as it breaks rtoml diff --git a/py_maker/pymaker.py b/py_maker/pymaker.py index 1f4ea399..a987341d 100644 --- a/py_maker/pymaker.py +++ b/py_maker/pymaker.py @@ -143,20 +143,21 @@ def generate_template(self) -> None: self.copy_files(custom_template_dir, file_list) # type: ignore # ---------------- generate the license file next. ------------- # - 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}.jinja" - ) - dst = self.choices.project_dir / "LICENSE.txt" - dst.write_text( - license_template.render( - author=self.choices.author, year=get_current_year() + if self.choices.license != "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}.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 self.choices.package_name != "-": From 8a0aa5d01f7eb6f81daa1faf24ecd529187241b7 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 10 Aug 2023 13:21:18 +0100 Subject: [PATCH 30/33] rename some commands internally CLI template 'set' was shadowing the python 'set' cmd Signed-off-by: Grant Ramsay --- py_maker/commands/template.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/py_maker/commands/template.py b/py_maker/commands/template.py index 518504a0..38b56ec6 100644 --- a/py_maker/commands/template.py +++ b/py_maker/commands/template.py @@ -117,8 +117,8 @@ def default(action: str) -> None: raise typer.Exit(ExitErrors.INVALID_ACTION) -@app.command() -def set(): +@app.command(name="set") +def set_template(): """Set the template folder to the current directory. The [i]'Use Default Template'[/i] setting [b][red]will not be changed[/b]. @@ -146,8 +146,8 @@ def set(): ) -@app.command() -def reset(): +@app.command(name="reset") +def reset_template(): """Reset the template folder to the default. This is currrently [b]~/.pymaker/template[/b]. From ae3be87cb8078a26bf46aaaa002366715efccadd Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 10 Aug 2023 14:29:38 +0100 Subject: [PATCH 31/33] update TODO Signed-off-by: Grant Ramsay --- TODO.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TODO.md b/TODO.md index 3fd0589e..5b8f1d75 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,9 @@ - add a command line option to specify the project type so the user doesn't have to enter it manually. ie `--standalone` or `--package`(latter is default and wouldn't need to be specified). +- add a command to the CLI template command to show the template files as a + tree, marking whether each file/folder is from the internal templates or the + user's templates. ## Documentation From 55555749970c831249f03d92a94662153f2e7330 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 10 Aug 2023 14:48:46 +0100 Subject: [PATCH 32/33] docs: more into on modifying the template Signed-off-by: Grant Ramsay --- docs/template/{default.md => internal.md} | 2 +- docs/template/modify.md | 7 +++---- docs/template/replace.md | 24 +++++++++++++++++++---- mkdocs.yml | 2 +- py_maker/commands/template.py | 10 +++++----- 5 files changed, 30 insertions(+), 15 deletions(-) rename docs/template/{default.md => internal.md} (98%) diff --git a/docs/template/default.md b/docs/template/internal.md similarity index 98% rename from docs/template/default.md rename to docs/template/internal.md index 9d54d895..9edeb619 100644 --- a/docs/template/default.md +++ b/docs/template/internal.md @@ -1,4 +1,4 @@ -# The 'Default' Template +# The Internal Template By default, the generated application will have a basic template that you can use to get started, this template is stored inside the package itself. It will diff --git a/docs/template/modify.md b/docs/template/modify.md index 98ab4c07..7a8da179 100644 --- a/docs/template/modify.md +++ b/docs/template/modify.md @@ -1,9 +1,8 @@ # Adding or Modifying files in the template -If you always wish to add or change specific files in the template, you can do -so by adding them to the `~/.pymaker/template` folder. The files (and folders) -in this folder will be copied to the root of the project when the template is -generated. +If you wish to add or change specific files in the template, you can do so by +adding them to the `~/.pymaker/template` folder. The files (and folders) in this +folder will be copied to the root of the project when the template is generated. Files in this global template folder will override any files in the default template, so you can for example change the `README.md` file, add to the diff --git a/docs/template/replace.md b/docs/template/replace.md index 39fcfdd5..d8c340a2 100644 --- a/docs/template/replace.md +++ b/docs/template/replace.md @@ -18,14 +18,30 @@ This will copy the default template to the global template folder (`~/.pymaker/template`). You can then modify or delete files as you see fit. Running this command will ask you if you wish to set this exported template as -the default template. If you answer yes, then the default template will be +the default template. It will then ask you if you want to disable the internal +template. If you answer yes, then the internal template will be disabled, and ONLY the exported template will be used instead. Otherwise, both will still be used with the exported template taking precedence. -## Use another Template completely +## Change the location of the Template folder -!!! note - This feature is not yet implemented. +If you wish to change the location of the template folder, you can do so in 2 +ways: + +1. By adding the `--local` flag to the above command (e.g. `pymaker template + dump --local`). This will dump the default template to the current folder, + giving you the option to disable the default template if needed. Note that + any files in the folder will be overwritten. +2. By changing to the folder containing your template and running `pymaker + template set`. This will set the current folder as the template folder and + give you the same option to disable the default template. + +You can reset the template location back to the default `~/.pymaker/template` +folder by running the following command: + +```console +$ pymaker template reset +``` ## Choose to use the Default Template or not diff --git a/mkdocs.yml b/mkdocs.yml index 0d33215d..e63dfb86 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,7 +56,7 @@ nav: - Quick Start: quick-start.md - Configuration: configuration.md - Templates: - - Default Template: template/default.md + - Internal Template: template/internal.md - Modifying: template/modify.md - Replacing: template/replace.md - Task Runner: tasks.md diff --git a/py_maker/commands/template.py b/py_maker/commands/template.py index 38b56ec6..3c23bc30 100644 --- a/py_maker/commands/template.py +++ b/py_maker/commands/template.py @@ -45,7 +45,7 @@ def dump( if any(output_folder.iterdir()) and not Confirm.ask( f"[red]The output folder:[/red] {output_folder} " - f"[red]is not empty, do you want to continue?[/red]", + "[red]is not empty, do you want to continue?[/red]", default=False, ): raise typer.Exit(ExitErrors.USER_ABORT) # noqa TRY301 @@ -102,7 +102,7 @@ def default(action: str) -> None: settings.use_default_template = True settings.save() print( - f"[green] -> Default template folder enabled:[/green] " + "[green] -> Default template folder enabled:[/green] " f"{settings.template_folder}" ) elif action == "disable": @@ -112,7 +112,7 @@ def default(action: str) -> None: else: print( f"[red] -> Invalid action:[/red] {action}\n" - f"[red] -> Action must be either:[/red] enable or disable" + "[red] -> Action must be either:[/red] enable or disable" ) raise typer.Exit(ExitErrors.INVALID_ACTION) @@ -135,7 +135,7 @@ def set_template(): settings.save() print( - f"[green] -> Template folder set to:[/green] " + "[green] -> Template folder set to:[/green] " f"{settings.template_folder}" ) print( @@ -165,7 +165,7 @@ def reset_template(): settings.template_folder = str(Path.home() / ".pymaker" / "template") settings.save() print( - f"[green] -> Template folder reset to:[/green] " + "[green] -> Template folder reset to:[/green] " f"{settings.template_folder}" ) print( From 120f9e1e3c666d5a02b85cd69688918a10ac53c5 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Thu, 10 Aug 2023 15:47:32 +0100 Subject: [PATCH 33/33] fix failing test Signed-off-by: Grant Ramsay --- tests/test_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index c75a6cca..d721164f 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -26,7 +26,7 @@ def test_settings(): assert settings.schema_version == "none" assert settings.author_name == "" assert settings.author_email == "" - assert settings.default_license == "" + assert settings.default_license == "None" # Test that we can set and get a setting settings.set("author_name", test_author)