diff --git a/.vscode/terms.txt b/.vscode/terms.txt index ba6c990f..97b077da 100644 --- a/.vscode/terms.txt +++ b/.vscode/terms.txt @@ -30,6 +30,7 @@ pypa pyproject pytest sampleproject +secho sendline setuptools SPHINXBUILD diff --git a/MANIFEST.in b/MANIFEST.in index 02787bcc..7f549aae 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ prune template prune */__pycache__ exclude copier.yaml +graft src/init_python_project/template diff --git a/Makefile b/Makefile index 2cc26223..086231fa 100644 --- a/Makefile +++ b/Makefile @@ -10,32 +10,30 @@ DOC_EXAMPLES = docs/examples/mkdocs docs/examples/sphinx docs/examples/default d examples: ## build all published examples examples: $(PUBLISHED_EXAMPLES) -COPIER_ARGS?=--trust --vcs-ref=HEAD -COPIER_DEFAULT_VALUES=-d "project_name=Sample Project" -d "package_name=sample_project" -build/examples/%: COPIER_DEFAULT_VALUES += --defaults +INIT_PYTHON_PROJECT_ARGS=--project-name="Sample Project" build/examples/%: EXAMPLE_DIR:=$@ -build/examples/github: COPIER_DEFAULT_VALUES+=-d user_name=jannismain -d remote=github -d remote_url=git@github.com:jannismain/python-project-template-example.git -build/examples/gitlab%: COPIER_DEFAULT_VALUES+=-d user_name=mkj -build/examples/gitlab_fhg: COPIER_DEFAULT_VALUES+= -d remote=gitlab-fhg -d remote_url=git@gitlab.cc-asp.fraunhofer.de:mkj/sample-project.git -build/examples/gitlab_iis: COPIER_DEFAULT_VALUES+= -d remote=gitlab-iis -d remote_url=git@git01.iis.fhg.de:mkj/sample-project.git -build/examples/gitlab_iis_sphinx: COPIER_DEFAULT_VALUES+= -d remote=gitlab-iis -d remote_url=git@git01.iis.fhg.de:mkj/sample-project-sphinx.git -d docs=sphinx +build/examples/github: INIT_PYTHON_PROJECT_ARGS+=--user-name=jannismain --remote=github --remote-url=git@github.com:jannismain/python-project-template-example.git +build/examples/gitlab%: INIT_PYTHON_PROJECT_ARGS+=--user-name mkj +build/examples/gitlab_fhg: INIT_PYTHON_PROJECT_ARGS+=--remote=gitlab-fhg --remote-url=git@gitlab.cc-asp.fraunhofer.de:mkj/sample-project.git +build/examples/gitlab_iis: INIT_PYTHON_PROJECT_ARGS+=--remote=gitlab-iis --remote-url=git@git01.iis.fhg.de:mkj/sample-project.git +build/examples/gitlab_iis_sphinx: INIT_PYTHON_PROJECT_ARGS+=--remote=gitlab-iis --remote-url=git@git01.iis.fhg.de:mkj/sample-project-sphinx.git --docs=sphinx -$(PUBLISHED_EXAMPLES): +$(PUBLISHED_EXAMPLES): uncopy-template copy-template @echo "Recreating '$@'..." @rm -rf "$@" && mkdir -p "$@" - @copier copy ${COPIER_ARGS} ${COPIER_DEFAULT_VALUES} . "$@" + init-python-project "$@" ${INIT_PYTHON_PROJECT_ARGS} --defaults --yes --verbose $(MAKE) example-setup EXAMPLE_DIR="$@" -docs/examples/mkdocs: COPIER_DEFAULT_VALUES+=-d docs=mkdocs -docs/examples/sphinx: COPIER_DEFAULT_VALUES+=-d docs=sphinx -docs/examples/minimal: COPIER_DEFAULT_VALUES+=-d docs=none -d use_precommit=False -d use_bumpversion=False -docs/examples/full: COPIER_DEFAULT_VALUES+=-d docs=mkdocs -d use_precommit=True -d use_bumpversion=True -docs/examples/gitlab: COPIER_DEFAULT_VALUES+=-d remote=gitlab-iis - -$(DOC_EXAMPLES): +docs/examples/mkdocs: INIT_PYTHON_PROJECT_ARGS+=--docs mkdocs +docs/examples/sphinx: INIT_PYTHON_PROJECT_ARGS+=--docs sphinx +docs/examples/minimal: INIT_PYTHON_PROJECT_ARGS+=--docs none --no-precommit --no-bumpversion +docs/examples/full: INIT_PYTHON_PROJECT_ARGS+=--docs mkdocs --precommit --bumpversion +docs/examples/gitlab: INIT_PYTHON_PROJECT_ARGS+=--docs mkdocs --precommit --bumpversion --remote gitlab-iis +doc-examples: $(DOC_EXAMPLES) +$(DOC_EXAMPLES): uncopy-template copy-template @echo "Recreating '$@'..." @rm -rf "$@" && mkdir -p "$@" - @copier copy ${COPIER_ARGS} --defaults -d user_name=mkj ${COPIER_DEFAULT_VALUES} . "$@" + init-python-project "$@" --user-name mkj ${INIT_PYTHON_PROJECT_ARGS} --defaults --yes --verbose @cd $@ &&\ python -m venv .venv || echo "Couldn't setup virtual environment" &&\ . .venv/bin/activate &&\ @@ -66,7 +64,7 @@ examples-clean: ## remove all published examples build/example: ## build individual example for manual testing (will prompt for values!) rm -rf "$@" - copier copy ${COPIER_ARGS} ${COPIER_DEFAULT_VALUES} . "$@" + init-python-project "$@" ${INIT_PYTHON_PROJECT_ARGS} $(MAKE) example-setup EXAMPLE_DIR="$@" @@ -124,8 +122,10 @@ install-build: build copy-template: @cp -r ${TEMPLATE_SRC} ${TEMPLATE_DEST} @cp copier.yaml ${PKGDIR}/. -build-clean: ## remove build artifacts - @rm -rf ${BUILDDIR} ${PKGDIR}/template ${PKGDIR}/copier.yaml +uncopy-template: + @rm -rf ${TEMPLATE_DEST} ${PKGDIR}/copier.yaml +build-clean: uncopy-template ## remove build artifacts + @rm -rf ${BUILDDIR} .PHONY: release release-test release-tag release-pypi release-github release: release-test release-tag build release-pypi release-github diff --git a/copier.yaml b/copier.yaml index bb0ba671..3def6910 100644 --- a/copier.yaml +++ b/copier.yaml @@ -51,7 +51,7 @@ package_name: Finally, the package name is repeated across multiple configuration and documentation files. -use_precommit: +precommit: type: bool default: true help: Use pre-commit to run checks on each commit? @@ -64,7 +64,7 @@ use_precommit: Most formatters and some linters are able to fix issues automatically. So you can simply review the changes those tools made, stage them and commit again. -use_bumpversion: +bumpversion: type: bool default: false help: Use bumpversion to manage semantic version across multiple files? @@ -178,4 +178,4 @@ _tasks: - "rm -rf context" - "git init --initial-branch={{default_branch}}" - "git remote add origin {{remote_url}} || true" - - "{% if use_precommit %}pre-commit install || echo 'Error during installation of pre-commit hooks. Is pre-commit installed?'{% endif %}" + - "{% if precommit %}pre-commit install || echo 'Error during installation of pre-commit hooks. Is pre-commit installed?'{% endif %}" diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md index b8678a78..1d60c933 100644 --- a/docs/user-guide/getting-started.md +++ b/docs/user-guide/getting-started.md @@ -1,16 +1,22 @@ {{ includex('README.md', start_match='Prerequisites', end_match='')}} -## Using [copier] directly +??? note "Using [pipx]" -```console -copier copy --trust https://git01.iis.fhg.de/mkj/project-template.git my_new_project -``` + ```{.sh .copy} + pipx run init-python-project + ``` -*Note: `--trust` is required because the template uses [tasks][] to setup your git repository for you.* +[pipx]: https://pypa.github.io/pipx/ -[tasks]: https://git01.iis.fhg.de/mkj/project-template/-/blob/main/copier.yaml +??? note "Using [copier]" + + The underlying template is built using [copier]. This means you can also use the copier template directly like this: -*Note: If you have [pipx][] installed (you should, it is good), you can simply use `pipx run copier` out of the box.* + ```{.sh .copy} + copier copy --trust https://git01.iis.fhg.de/mkj/project-template.git my_new_project + ``` + *Note: `--trust` is required because the template uses [tasks] to setup your git repository for you.* + +[tasks]: https://git01.iis.fhg.de/mkj/project-template/-/blob/main/copier.yaml [copier]: https://github.com/copier-org/copier -[pipx]: https://pypa.github.io/pipx/ diff --git a/src/init_python_project/cli.py b/src/init_python_project/cli.py index 689e26d1..42acfc26 100644 --- a/src/init_python_project/cli.py +++ b/src/init_python_project/cli.py @@ -1,7 +1,11 @@ +import logging import sys +from enum import StrEnum from pathlib import Path +from subprocess import check_output from typing import Annotated, Optional +import typer from copier import run_copy from typer import Argument, Option, Typer, colors, confirm, style @@ -16,18 +20,124 @@ def version_callback(value: bool) -> None: sys.exit(0) +class DocumentationTool(StrEnum): + "which documentation tool to use" + mkdocs = "mkdocs" + sphinx = "sphinx" + none = "none" + + +class DocumentationTemplate(StrEnum): + "which documentation template to use" + sphinx_fhg_iis = "sphinx-fhg-iis" + builtin = "none" + + +class RemotePlatform(StrEnum): + "which remote platform to configure" + github = "github" + gitlab_fhg = "gitlab-fhg" + gitlab_iis = "gitlab-iis" + + +def CustomOptional(_type=bool, help="", custom_flag: str | list = None, **kwargs): + if issubclass(_type, StrEnum): + kwargs = {"case_sensitive": False, **kwargs} + if not help: + help = _type.__doc__ + + kwargs = {"show_default": False, "help": help, **kwargs} + + if custom_flag is None: + return Annotated[Optional[_type], Option(**kwargs)] + + if isinstance(custom_flag, str): + custom_flag = [custom_flag] + return Annotated[Optional[_type], Option(*custom_flag, **kwargs)] + + @app.command(name="init-python-project") def cli( + # data passed to the underlying copier template target_path: Path = Argument("new-project"), + project_name: CustomOptional(str, "project name (title case with spaces)") = None, + package_name: CustomOptional(str, "Python package name (lowercase with underscores)") = None, + user_name: CustomOptional(str, "your user name") = None, + docs: CustomOptional(DocumentationTool) = None, + docs_template: CustomOptional(DocumentationTemplate) = None, + remote: CustomOptional(RemotePlatform) = None, + remote_url: CustomOptional(str, "ssh url where your repository will be hosted on") = None, + precommit: CustomOptional(bool, "include pre-commit hooks") = None, + bumpversion: CustomOptional(bool, "include bumpversion configuration") = None, + # arguments that affect project creation + defaults: Annotated[ + bool, Option("--defaults", "-d", help="automatically accept all default options") + ] = False, + dry_run: Annotated[bool, Option("--dry-run", help="do not actually create project")] = False, + always_confirm: Annotated[ + bool, Option("--yes", "-y", help="answer any confirmation request with yes") + ] = False, version: Annotated[ - Optional[bool], Option("--version", callback=version_callback, is_eager=True) + Optional[bool], + Option("--version", callback=version_callback, is_eager=True, help="show version and exit"), + ] = None, + verbose: Annotated[ + Optional[bool], + typer.Option( + "--verbose", + "-v", + callback=lambda x: logging.basicConfig( + level=logging.INFO if x else logging.WARN, format="%(message)s" + ), + is_eager=True, + help="show more information", + ), + ] = False, + copier_args: Annotated[ + Optional[list[str]], + typer.Option("--copier-arg", help="anything you want to pass to copier"), ] = None, ) -> None: - """Executes the CLI command to create a new project.""" - target_path.mkdir(exist_ok=True) + """Executes the CLI command to create a new project. + + For a list of supported copier arguments, see + https://copier.readthedocs.io/en/stable/reference/main/#copier.main.Worker. + + Note that `src_path`, `dest_path`, `vcs_ref`, `data`, `defaults`, `user_defaults` and `unsafe` + are already set by this command. Further, `--dry-run` corresponds to copier's `--pretend` and + `--yes` implies copier's `--overwrite`. + """ + + if docs_template not in [None, "none"] and ( + docs is None or (docs is not None and not docs_template.value.startswith(docs.value)) + ): + typer.secho( + f"Error: selected template ({docs_template}) not compatible " + f"with documentation tool ({docs})", + fg=colors.RED, + err=True, + ) + raise typer.Exit(1) + + # cast enums to their values + for option in "docs remote".split(): + if locals()[option] is not None: + locals()[option] = locals()[option].value + + # assemble values provided by the user + data = {} + for ( + option + ) in "project_name package_name user_name docs remote remote_url precommit bumpversion".split(): + value = locals()[option] + if value is not None: + logging.info("%s: %s", option, value) + data[option] = value + if ( target_path.is_dir() and any(target_path.iterdir()) + and not always_confirm and not confirm( style( f"Target directory '{target_path}' is not empty! Continue?", @@ -37,10 +147,29 @@ def cli( ): sys.exit(1) + # parse copier args + copier_args = { + k.replace("--", "").replace("-", "_"): v + for k, v in ( + arg.split("=") if "=" in arg else arg.split() if " " in arg else (arg, True) + for arg in (copier_args or []) + ) + } + run_copy( src_path=str(Path(__file__).parent.absolute()), dst_path=target_path, unsafe=True, + data=data, + user_defaults=dict( + user_name=check_output(["whoami"]).decode().strip() if user_name is None else user_name, + project_name=target_path.name.replace("-", " ").replace("_", " ").title(), + ), + defaults=defaults, + overwrite=always_confirm, + pretend=dry_run or copier_args.pop("pretend", False), + quiet=True, + **copier_args, ) diff --git a/template/Makefile.jinja b/template/Makefile.jinja index 64cdbf1f..051ae109 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -1,7 +1,7 @@ .PHONY: install-dev install-dev: ## install project including all development dependencies - pip install -e .[test,dev] - pip install -r docs/requirements.txt + pip install -e .[test,dev]{% if docs != 'none' %} + pip install -r docs/requirements.txt{% endif %} .PHONY: maintainability maintainability: ## run maintainability checks diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index bfa8c0c8..fa53c5d4 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -70,7 +70,7 @@ dependencies = ["click"] # Similar to `dependencies` above, these must be valid existing # projects. [project.optional-dependencies] -dev = ["black", "radon", "ruff"{% if use_bumpversion %}, "bump2version"{% endif %}] +dev = ["black", "radon", "ruff"{% if bumpversion %}, "bump2version"{% endif %}] test = ["pytest", "pytest-cov", "coverage[toml]"] # The following would provide a command line executable which executes diff --git a/template/{% if use_bumpversion %}.bumpversion.cfg{% endif %}.jinja b/template/{% if bumpversion %}.bumpversion.cfg{% endif %}.jinja similarity index 100% rename from template/{% if use_bumpversion %}.bumpversion.cfg{% endif %}.jinja rename to template/{% if bumpversion %}.bumpversion.cfg{% endif %}.jinja diff --git a/template/{% if docs == 'mkdocs' %}docs{% endif %}/requirements.txt b/template/{% if docs == 'mkdocs' %}docs{% endif %}/requirements.txt index 5090e64c..4a3f3f67 100644 --- a/template/{% if docs == 'mkdocs' %}docs{% endif %}/requirements.txt +++ b/template/{% if docs == 'mkdocs' %}docs{% endif %}/requirements.txt @@ -1,3 +1,4 @@ +mkdocs mkdocs-material mkdocstrings[python] mkdocs-git-revision-date-localized-plugin diff --git a/template/{% if use_precommit %}.pre-commit-config.yaml{% endif %}.jinja b/template/{% if precommit %}.pre-commit-config.yaml{% endif %}.jinja similarity index 100% rename from template/{% if use_precommit %}.pre-commit-config.yaml{% endif %}.jinja rename to template/{% if precommit %}.pre-commit-config.yaml{% endif %}.jinja diff --git a/tests/test_template.py b/tests/test_template.py index 0bbe7134..5f54bbe7 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -39,7 +39,7 @@ def venv(tmp_path): @pytest.mark.slow -@pytest.mark.parametrize("use_precommit", [True, False], ids=["pre-commit", "no pre-commit"]) +@pytest.mark.parametrize("precommit", [True, False], ids=["pre-commit", "no pre-commit"]) @pytest.mark.parametrize( "docs,docs_template", SUPPORTED_DOCS_TEMPLATES_COMBINATIONS, @@ -48,7 +48,7 @@ def venv(tmp_path): def test_template_generation( venv: VirtualEnvironment, tmp_path: Path, - use_precommit: bool, + precommit: bool, docs: str, docs_template: str, remote: str, @@ -59,7 +59,7 @@ def test_template_generation( str(tmp_path), data=dict( **required_static_data, - use_precommit=use_precommit, + precommit=precommit, docs=docs, docs_template=docs_template, remote=remote, @@ -81,7 +81,7 @@ def test_template_generation( assert fp_changelog.is_file(), "new projects should have a CHANGELOG file" fp_precommit_config = tmp_path / ".pre-commit-config.yaml" - assert fp_precommit_config.is_file() == use_precommit + assert fp_precommit_config.is_file() == precommit fp_git = tmp_path / ".git" assert fp_git.is_dir(), "new projects should be git repositories" @@ -318,21 +318,21 @@ def read_last_commit_msg(cwd: Path | str = None): return check_output(["git", "log", "-1", "--pretty=%B"], cwd=str(cwd or ".")).decode().strip() -@pytest.mark.parametrize("use_bumpversion", [True, False], ids=["bumpversion", "no bumpversion"]) -def test_bumpversion_option(venv: VirtualEnvironment, tmp_path: Path, use_bumpversion: bool): +@pytest.mark.parametrize("bumpversion", [True, False], ids=["bumpversion", "no-bumpversion"]) +def test_bumpversion_option(venv: VirtualEnvironment, tmp_path: Path, bumpversion: bool): run_copy( str(fp_template), str(tmp_path), data=dict( **required_static_data, - use_bumpversion=use_bumpversion, - use_precommit=False, # makes testing easier + bumpversion=bumpversion, + precommit=False, # makes testing easier ), unsafe=True, defaults=True, vcs_ref="HEAD", ) - if not use_bumpversion: + if not bumpversion: assert not (tmp_path / ".bumpversion.cfg").is_file() return