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