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", + ] 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/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 bb131712..5b8f1d75 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) @@ -13,6 +13,18 @@ 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. 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 + +- Add usage examples and perhaps a walk-through to the documentation. Maybe + with a YouTube video? 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/internal.md b/docs/template/internal.md new file mode 100644 index 00000000..9edeb619 --- /dev/null +++ b/docs/template/internal.md @@ -0,0 +1,26 @@ +# 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 +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..7a8da179 --- /dev/null +++ b/docs/template/modify.md @@ -0,0 +1,13 @@ +# Adding or Modifying files in the template + +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 +`.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..d8c340a2 --- /dev/null +++ b/docs/template/replace.md @@ -0,0 +1,58 @@ +# 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 +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. + +Running this command will ask you if you wish to set this exported template as +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. + +## Change the location of the Template folder + +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 + +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/mkdocs.yml b/mkdocs.yml index 8c8da015..e63dfb86 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,5 +55,10 @@ nav: - Installation: installation.md - Quick Start: quick-start.md - Configuration: configuration.md + - Templates: + - Internal Template: template/internal.md + - Modifying: template/modify.md + - Replacing: template/replace.md - Task Runner: tasks.md + - Future Plans: future-plans.md - License: license.md diff --git a/poetry.lock b/poetry.lock index 7ee721c4..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" @@ -1420,6 +1434,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 +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 = "8c3f521f5cfe8412ca0cda8d2ff9b15957ef3436e0fdc47a02ee8184f2e829ac" +content-hash = "56fd5c59500ad381476142db3e2da93fa8c881f772635e51ec09a5e2e7760e83" diff --git a/py_maker/commands/template.py b/py_maker/commands/template.py new file mode 100644 index 00000000..3c23bc30 --- /dev/null +++ b/py_maker/commands/template.py @@ -0,0 +1,176 @@ +"""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.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) + + +@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() + template_source = pkg_resources.files(template) + + try: + if not local: + output_folder = Path.home() / ".pymaker" / "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} " + "[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) + + 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")) + + print(f"[green] -> Template files dumped to:[/green] {output_folder}") + + set_default = Confirm.ask( + 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) + + 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: + print(f"\n[red] -> Error dumping template:[/red] {exc}") + raise typer.Exit(ExitErrors.OS_ERROR) from exc + + +@app.command() +def default(action: str) -> None: + """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() + if action == "enable": + settings.use_default_template = True + settings.save() + print( + "[green] -> Default template folder enabled:[/green] " + f"{settings.template_folder}" + ) + elif action == "disable": + settings.use_default_template = False + settings.save() + print("[green] -> Default template folder disabled[/green]") + else: + print( + f"[red] -> Invalid action:[/red] {action}\n" + "[red] -> Action must be either:[/red] enable or disable" + ) + raise typer.Exit(ExitErrors.INVALID_ACTION) + + +@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]. + """ + 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( + "[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(name="reset") +def reset_template(): + """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( + "[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" + ) diff --git a/py_maker/config/__init__.py b/py_maker/config/__init__.py new file mode 100644 index 00000000..2203d010 --- /dev/null +++ b/py_maker/config/__init__.py @@ -0,0 +1,4 @@ +"""Settings module.""" +from .settings import Settings + +__all__ = ["Settings"] diff --git a/py_maker/config/settings.py b/py_maker/config/settings.py index 2248eea4..ed8a4029 100644 --- a/py_maker/config/settings.py +++ b/py_maker/config/settings.py @@ -29,10 +29,17 @@ 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 = "" + default_license: str = "None" + use_default_template: bool = True + + # 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.""" diff --git a/py_maker/constants.py b/py_maker/constants.py index 1a9927b3..083b2f04 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. @@ -29,3 +31,4 @@ class ExitErrors: 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 ed9f987f..f414d265 100644 --- a/py_maker/helpers.py +++ b/py_maker/helpers.py @@ -1,12 +1,19 @@ """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, List, 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: # pragma: no cover + from importlib.resources.abc import Traversable + from pathlib import Path + def get_author_and_email_from_git() -> tuple[str, str]: """Get the author name and email from git.""" @@ -18,6 +25,35 @@ 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. + + 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() @@ -46,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/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/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/py_maker/pymaker.py b/py_maker/pymaker.py index 183aa342..a987341d 100644 --- a/py_maker/pymaker.py +++ b/py_maker/pymaker.py @@ -1,11 +1,12 @@ """Class to encapsulate the application.""" +from __future__ import annotations + import importlib.resources as pkg_resources import os -import re import shutil import sys from pathlib import Path, PurePath -from typing import Union +from typing import TYPE_CHECKING from git.exc import GitError from git.repo import Repo @@ -15,10 +16,19 @@ 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_file_list, + get_title, + header, + sanitize, +) 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.""" @@ -35,17 +45,10 @@ 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) - 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 +59,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. # # ------------------------------------------------------------------------ # @@ -90,7 +89,36 @@ 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 file in file_list: + with pkg_resources.as_file(template_dir / file) as src: + 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, + ) + ) + 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 @@ -99,58 +127,37 @@ 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) - ] + # skip_dirs: List = ["__pycache__"] 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) + 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) # 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 != "-": @@ -233,14 +240,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?", @@ -269,7 +276,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/py_maker/tree/__init__.py b/py_maker/tree/__init__.py new file mode 100644 index 00000000..593575cd --- /dev/null +++ b/py_maker/tree/__init__.py @@ -0,0 +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 new file mode 100644 index 00000000..67e08155 --- /dev/null +++ b/py_maker/tree/tree.py @@ -0,0 +1,80 @@ +"""WIP Class to display a directory tree using Rich to the console. + +Heavily influenced by the example in Rich.tree documentation. +""" +import os +import pathlib + +from rich import print # pylint: disable=W0622 +from rich.filesize import decimal +from rich.markup import escape +from rich.text import Text +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.""" + + # pylint: disable-next=no-member, protected-access + _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).expand() # type: ignore + + if not self.directory.is_dir(): + raise NotADirectoryError(f"{self.directory} is not a directory.") + + 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( + 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}]" + f"{self.directory}", + guide_style="bright_blue", + ) + self.walk_directory(self.directory, tree) + print(tree) diff --git a/pyproject.toml b/pyproject.toml index 8542e5bb..6dea72d7 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", @@ -57,8 +56,11 @@ 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" +pylint-pytest = "^1.1.2" pep8-naming = "^0.13.3" pre-commit = "^3.3.3" pydocstyle = "^6.3.0" @@ -98,7 +100,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" @@ -131,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 = [] @@ -138,11 +141,21 @@ 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" +max-attributes = 10 + [tool.pydocstyle] 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/requirements.txt b/requirements.txt new file mode 100644 index 00000000..271760e7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,109 @@ +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-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" +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" 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_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 diff --git a/tests/test_pymaker.py b/tests/test_pymaker.py new file mode 100644 index 00000000..3fad3b54 --- /dev/null +++ b/tests/test_pymaker.py @@ -0,0 +1,45 @@ +"""Test the PyMaker class. + +These tests are not complete. +""" +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 test_pymaker.choices.project_dir.is_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 new file mode 100644 index 00000000..d721164f --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,42 @@ +"""Tests for the Settings class.""" "" +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" + settings_folder.mkdir() + + # 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 == "none" + assert settings.author_name == "" + assert settings.author_email == "" + assert settings.default_license == "None" + + # 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