From 95b34f33e6ae0eb0a87d5aa9a01209bd584ba08c Mon Sep 17 00:00:00 2001 From: git-jamil Date: Wed, 3 Jul 2024 15:28:10 +0600 Subject: [PATCH 1/7] v0.0.3 --- .gitignore | 170 +++++++++ MANIFEST.in | 21 + Makefile | 115 ++++++ __main__.py | 6 + docs/__init__.py | 1 + docs/_templates/package.rst_t | 51 +++ docs/advanced/boolean_variables.rst | 52 +++ docs/advanced/calling_from_python.rst | 20 + docs/advanced/choice_variables.rst | 93 +++++ docs/advanced/copy_without_render.rst | 36 ++ docs/advanced/dict_variables.rst | 66 ++++ docs/advanced/directories.rst | 24 ++ docs/advanced/hooks.rst | 125 ++++++ docs/advanced/human_readable_prompts.rst | 41 ++ docs/advanced/index.rst | 30 ++ docs/advanced/injecting_context.rst | 54 +++ docs/advanced/jinja_env.rst | 16 + docs/advanced/local_extensions.rst | 58 +++ docs/advanced/nested_config_files.rst | 86 +++++ docs/advanced/new_line_characters.rst | 28 ++ docs/advanced/private_variables.rst | 56 +++ docs/advanced/replay.rst | 60 +++ docs/advanced/suppressing_prompts.rst | 40 ++ docs/advanced/template_extensions.rst | 147 +++++++ docs/advanced/templates.rst | 34 ++ docs/advanced/templates_in_context.rst | 37 ++ docs/advanced/user_config.rst | 60 +++ docs/cli_options.rst | 7 + docs/conf.py | 373 ++++++++++++++++++ docs/cookiecutter.rst | 141 +++++++ docs/index.rst | 52 +++ docs/installation.rst | 147 +++++++ docs/overview.rst | 47 +++ docs/requirements.txt | 8 + docs/troubleshooting.rst | 42 ++ docs/tutorials/index.rst | 36 ++ docs/tutorials/tutorial1.rst | 152 ++++++++ docs/tutorials/tutorial2.rst | 107 ++++++ docs/usage.rst | 130 +++++++ dxh_py/VERSION.txt | 1 + dxh_py/__init__.py | 13 + dxh_py/__main__.py | 6 + dxh_py/cli.py | 258 ++++++++++++- dxh_py/config.py | 143 +++++++ dxh_py/environment.py | 70 ++++ dxh_py/exceptions.py | 180 +++++++++ dxh_py/extensions.py | 174 +++++++++ dxh_py/find.py | 38 ++ dxh_py/generate.py | 463 +++++++++++++++++++++++ dxh_py/hooks.py | 204 ++++++++++ dxh_py/log.py | 56 +++ dxh_py/main.py | 212 +++++++++++ dxh_py/prompt.py | 437 +++++++++++++++++++++ dxh_py/replay.py | 49 +++ dxh_py/repository.py | 138 +++++++ dxh_py/utils.py | 104 +++++ dxh_py/vcs.py | 140 +++++++ dxh_py/zipfile.py | 122 ++++++ setup.py | 100 +++-- 59 files changed, 5644 insertions(+), 33 deletions(-) create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 __main__.py create mode 100644 docs/__init__.py create mode 100644 docs/_templates/package.rst_t create mode 100644 docs/advanced/boolean_variables.rst create mode 100644 docs/advanced/calling_from_python.rst create mode 100644 docs/advanced/choice_variables.rst create mode 100644 docs/advanced/copy_without_render.rst create mode 100644 docs/advanced/dict_variables.rst create mode 100644 docs/advanced/directories.rst create mode 100644 docs/advanced/hooks.rst create mode 100644 docs/advanced/human_readable_prompts.rst create mode 100644 docs/advanced/index.rst create mode 100644 docs/advanced/injecting_context.rst create mode 100644 docs/advanced/jinja_env.rst create mode 100644 docs/advanced/local_extensions.rst create mode 100644 docs/advanced/nested_config_files.rst create mode 100644 docs/advanced/new_line_characters.rst create mode 100644 docs/advanced/private_variables.rst create mode 100644 docs/advanced/replay.rst create mode 100644 docs/advanced/suppressing_prompts.rst create mode 100644 docs/advanced/template_extensions.rst create mode 100644 docs/advanced/templates.rst create mode 100644 docs/advanced/templates_in_context.rst create mode 100644 docs/advanced/user_config.rst create mode 100644 docs/cli_options.rst create mode 100644 docs/conf.py create mode 100644 docs/cookiecutter.rst create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/overview.rst create mode 100644 docs/requirements.txt create mode 100644 docs/troubleshooting.rst create mode 100644 docs/tutorials/index.rst create mode 100644 docs/tutorials/tutorial1.rst create mode 100644 docs/tutorials/tutorial2.rst create mode 100644 docs/usage.rst create mode 100644 dxh_py/VERSION.txt mode change 100755 => 100644 dxh_py/__init__.py create mode 100644 dxh_py/__main__.py mode change 100755 => 100644 dxh_py/cli.py create mode 100644 dxh_py/config.py create mode 100644 dxh_py/environment.py create mode 100644 dxh_py/exceptions.py create mode 100644 dxh_py/extensions.py create mode 100644 dxh_py/find.py create mode 100644 dxh_py/generate.py create mode 100644 dxh_py/hooks.py create mode 100644 dxh_py/log.py create mode 100644 dxh_py/main.py create mode 100644 dxh_py/prompt.py create mode 100644 dxh_py/replay.py create mode 100644 dxh_py/repository.py create mode 100644 dxh_py/utils.py create mode 100644 dxh_py/vcs.py create mode 100644 dxh_py/zipfile.py mode change 100755 => 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edd3ae4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,170 @@ +# Adapted from https://github.com/github/gitignore/blob/main/Python.gitignore + +tests/tmp/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# VSCode settings (e.g. .vscode/settings.json containing personal preferred path to venv) +.vscode/ + +# macOS auto-generated file +.DS_Store diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..d3c8982 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,21 @@ +include AUTHORS.md +include CODE_OF_CONDUCT.md +include CONTRIBUTING.md +include HISTORY.md +include LICENSE.md +include README.md +include dxh_py/VERSION.txt + +exclude Makefile +exclude __main__.py +exclude .* +exclude codecov.yml +exclude test_requirements.txt +exclude tox.ini +exclude ruff.toml + +recursive-include tests * +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] +recursive-exclude docs * +recursive-exclude logo * diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..652f78a --- /dev/null +++ b/Makefile @@ -0,0 +1,115 @@ +PYPI_SERVER = pypitest + +define BROWSER_PYSCRIPT +import os, webbrowser, sys +try: + from urllib import pathname2url +except: + from urllib.request import pathname2url + +webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) +endef +export BROWSER_PYSCRIPT +BROWSER := python -c "$$BROWSER_PYSCRIPT" + +.DEFAULT_GOAL := help + + +.PHONY: clean-tox +clean-tox: ## Remove tox testing artifacts + @echo "+ $@" + @rm -rf .tox/ + +.PHONY: clean-coverage +clean-coverage: ## Remove coverage reports + @echo "+ $@" + @rm -rf htmlcov/ + @rm -rf .coverage + @rm -rf coverage.xml + +.PHONY: clean-pytest +clean-pytest: ## Remove pytest cache + @echo "+ $@" + @rm -rf .pytest_cache/ + +.PHONY: clean-docs-build +clean-docs-build: ## Remove local docs + @echo "+ $@" + @rm -rf docs/_build + +.PHONY: clean-build +clean-build: ## Remove build artifacts + @echo "+ $@" + @rm -fr build/ + @rm -fr dist/ + @rm -fr *.egg-info + +.PHONY: clean-pyc +clean-pyc: ## Remove Python file artifacts + @echo "+ $@" + @find . -type d -name '__pycache__' -exec rm -rf {} + + @find . -type f -name '*.py[co]' -exec rm -f {} + + @find . -name '*~' -exec rm -f {} + + +.PHONY: clean ## Remove all file artifacts +clean: clean-build clean-pyc clean-tox clean-coverage clean-pytest clean-docs-build + +.PHONY: lint +lint: ## Check code style + @echo "+ $@" + @tox -e lint + +.PHONY: test +test: ## Run tests quickly with the default Python + @echo "+ $@" + @tox -e py310 + +.PHONY: test-all +test-all: ## Run tests on every Python version + @echo "+ $@" + @tox + +.PHONY: coverage +coverage: ## Check code coverage quickly with the default Python + @echo "+ $@" + @tox -e py310 + @$(BROWSER) htmlcov/index.html + +.PHONY: docs +docs: ## Generate Sphinx HTML documentation, including API docs + @echo "+ $@" + @tox -e docs + @$(BROWSER) docs/_build/html/index.html + +.PHONY: servedocs +servedocs: ## Rebuild docs automatically + @echo "+ $@" + @tox -e servedocs + +.PHONY: submodules +submodules: ## Pull and update git submodules recursively + @echo "+ $@" + @git pull --recurse-submodules + @git submodule update --init --recursive + +.PHONY: release +release: clean ## Package and upload release + @echo "+ $@" + @python -m build + @twine upload -r $(PYPI_SERVER) dist/* + +.PHONY: sdist +sdist: clean ## Build sdist distribution + @echo "+ $@" + @python -m build --sdist + @ls -l dist + +.PHONY: wheel +wheel: clean ## Build wheel distribution + @echo "+ $@" + @python -m build --wheel + @ls -l dist + +.PHONY: help +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-16s\033[0m %s\n", $$1, $$2}' diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..c244f5b --- /dev/null +++ b/__main__.py @@ -0,0 +1,6 @@ +"""Allow dxh_py to be executable from a checkout or zip file.""" + +import runpy + +if __name__ == "__main__": + runpy.run_module("dxh_py", run_name="__main__") diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 0000000..f14bd38 --- /dev/null +++ b/docs/__init__.py @@ -0,0 +1 @@ +"""Main package for docs.""" diff --git a/docs/_templates/package.rst_t b/docs/_templates/package.rst_t new file mode 100644 index 0000000..be783a4 --- /dev/null +++ b/docs/_templates/package.rst_t @@ -0,0 +1,51 @@ +{%- macro automodule(modname, options) -%} +.. automodule:: {{ modname }} +{%- for option in options %} + :{{ option }}: +{%- endfor %} +{%- endmacro %} +{%- macro toctree(docnames) -%} +.. toctree:: + :maxdepth: {{ maxdepth }} +{% for docname in docnames %} + {{ docname }} +{%- endfor %} +{%- endmacro -%} +=== +API +=== + + +{%- if modulefirst and not is_namespace %} +{{ automodule(pkgname, automodule_options) }} +{% endif %} + +{%- if subpackages %} +Subpackages +----------- + +{{ toctree(subpackages) }} +{% endif %} + +{%- if submodules %} + +This is the dxh_py modules API documentation. + +{% if separatemodules %} +{{ toctree(submodules) }} +{% else %} +{%- for submodule in submodules %} +{% if show_headings %} +{{- [submodule, "module"] | join(" ") | e | heading(2) }} +{% endif %} +{{ automodule(submodule, automodule_options) }} +{% endfor %} +{%- endif %} +{%- endif %} + +{%- if not modulefirst and not is_namespace %} +Module contents +--------------- + +{{ automodule(pkgname, automodule_options) }} +{% endif %} diff --git a/docs/advanced/boolean_variables.rst b/docs/advanced/boolean_variables.rst new file mode 100644 index 0000000..273b539 --- /dev/null +++ b/docs/advanced/boolean_variables.rst @@ -0,0 +1,52 @@ +Boolean Variables +----------------- + +.. versionadded:: 2.2.0 + +Boolean variables are used for answering True/False questions. + +Basic Usage +~~~~~~~~~~~ + +Boolean variables are regular key / value pairs, but with the value being +``True``/``False``. + +For example, if you provide the following boolean variable in your +``dxh_py.json``:: + + { + "run_as_docker": true + } + +you will get the following user input when running dxh_py:: + + run_as_docker [True]: + +User input will be parsed by :func:`~dxh_py.prompt.read_user_yes_no`. The +following values are considered as valid user input: + + - ``True`` values: "1", "true", "t", "yes", "y", "on" + - ``False`` values: "0", "false", "f", "no", "n", "off" + +The above ``run_as_docker`` boolean variable creates ``dxh_py.run_as_docker``, +which can be used like this:: + + {%- if dxh_py.run_as_docker -%} + # In case of True add your content here + + {%- else -%} + # In case of False add your content here + + {% endif %} + +dxh_py is using `Jinja2's if conditional expression `_ to determine the correct ``run_as_docker``. + +Input Validation +~~~~~~~~~~~~~~~~ +If a non valid value is inserted to a boolean field, the following error will be printed: + +.. code-block:: bash + + run_as_docker [True]: docker + Error: docker is not a valid boolean diff --git a/docs/advanced/calling_from_python.rst b/docs/advanced/calling_from_python.rst new file mode 100644 index 0000000..7f24941 --- /dev/null +++ b/docs/advanced/calling_from_python.rst @@ -0,0 +1,20 @@ +.. _calling-from-python: + +Calling dxh_py Functions From Python +------------------------------------------ + +You can use dxh_py from Python: + +.. code-block:: python + + from dxh_py.main import dxh_py + + # Create project from the dxh_py/ template + dxh_py('dxh_py/') + + # Create project from the dxh_py.git repo template + dxh_py('https://github.com/devxhub/dxh_py.git') + +This is useful if, for example, you're writing a web framework and need to provide developers with a tool similar to `django-admin.py startproject` or `npm init`. + +See the :ref:`API Reference ` for more details. diff --git a/docs/advanced/choice_variables.rst b/docs/advanced/choice_variables.rst new file mode 100644 index 0000000..2585b50 --- /dev/null +++ b/docs/advanced/choice_variables.rst @@ -0,0 +1,93 @@ +.. _choice-variables: + +Choice Variables +---------------- + +*New in dxh_py 1.1* + +Choice variables provide different choices when creating a project. +Depending on a user's choice the template renders things differently. + +Basic Usage +~~~~~~~~~~~ + +Choice variables are regular key / value pairs, but with the value being a list of strings. + +For example, if you provide the following choice variable in your ``dxh_py.json``: + +.. code-block:: JSON + + { + "license": ["MIT", "BSD-3", "GNU GPL v3.0", "Apache Software License 2.0"] + } + +you'd get the following choices when running dxh_py:: + + Select license: + 1 - MIT + 2 - BSD-3 + 3 - GNU GPL v3.0 + 4 - Apache Software License 2.0 + Choose from 1, 2, 3, 4 [1]: + +Depending on an user's choice, a different license is rendered by dxh_py. + +The above ``license`` choice variable creates ``dxh_py.license``, which can be used like this: + +.. code-block:: html+jinja + + {%- if dxh_py.license == "MIT" -%} + # Possible license content here + + {%- elif dxh_py.license == "BSD-3" -%} + # More possible license content here + + {% endif %} + +dxh_py is using `Jinja2's if conditional expression `_ to determine the correct license. + +The created choice variable is still a regular dxh_py variable and can be used like this: + +.. code-block:: html+jinja + + + License + ------- + + Distributed under the terms of the `{{dxh_py.license}}`_ license, + +Overwriting Default Choice Values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Choice Variables are overwritable using a :ref:`user-config` file. + +For example, a choice variable can be created in ``dxh_py.json`` by using a list as value: + +.. code-block:: JSON + + { + "license": ["MIT", "BSD-3", "GNU GPL v3.0", "Apache Software License 2.0"] + } + +By default, the first entry in the values list serves as default value in the prompt. + +Setting the default ``license`` agreement to *Apache Software License 2.0* can be done using: + +.. code-block:: yaml + + default_context: + license: "Apache Software License 2.0" + +in the :ref:`user-config` file. + +The resulting prompt changes and looks like:: + + Select license: + 1 - Apache Software License 2.0 + 2 - MIT + 3 - BSD-3 + 4 - GNU GPL v3.0 + Choose from 1, 2, 3, 4 [1]: + +.. note:: + As you can see the order of the options changed from ``1 - MIT`` to ``1 - Apache Software License 2.0``. **dxh_py** takes the first value in the list as the default. diff --git a/docs/advanced/copy_without_render.rst b/docs/advanced/copy_without_render.rst new file mode 100644 index 0000000..2fd5d4e --- /dev/null +++ b/docs/advanced/copy_without_render.rst @@ -0,0 +1,36 @@ +.. _copy-without-render: + +Copy without Render +------------------- + +*New in dxh_py 1.1* + +To avoid rendering directories and files of a dxh_py, the ``_copy_without_render`` key can be used in the ``dxh_py.json``. +The value of this key accepts a list of Unix shell-style wildcards: + +.. code-block:: JSON + + { + "project_slug": "sample", + "_copy_without_render": [ + "*.html", + "*not_rendered_dir", + "rendered_dir/not_rendered_file.ini" + ] + } + +**Note**: +Only the content of the files will be copied without being rendered. +The paths are subject to rendering. +This allows you to write: + +.. code-block:: JSON + + { + "project_slug": "sample", + "_copy_without_render": [ + "{{dxh_py.repo_name}}/templates/*.html", + ] + } + +In this example, ``{{dxh_py.repo_name}}`` will be rendered as expected but the html file content will be copied without rendering. diff --git a/docs/advanced/dict_variables.rst b/docs/advanced/dict_variables.rst new file mode 100644 index 0000000..8910e26 --- /dev/null +++ b/docs/advanced/dict_variables.rst @@ -0,0 +1,66 @@ +.. _dict-variables: + +Dictionary Variables +-------------------- + +*New in dxh_py 1.5* + +Dictionary variables provide a way to define deep structured information when rendering a template. + +Basic Usage +~~~~~~~~~~~ + +Dictionary variables are, as the name suggests, dictionaries of key-value pairs. +The dictionary values can, themselves, be other dictionaries and lists - the data structure can be as deep as you need. + +For example, you could provide the following dictionary variable in your ``dxh_py.json``: + +.. code-block:: json + + { + "project_slug": "new_project", + "file_types": { + "png": { + "name": "Portable Network Graphic", + "library": "libpng", + "apps": [ + "GIMP" + ] + }, + "bmp": { + "name": "Bitmap", + "library": "libbmp", + "apps": [ + "Paint", + "GIMP" + ] + } + } + } + + +The above ``file_types`` dictionary variable creates ``dxh_py.file_types``, which can be used like this: + +.. code-block:: html+jinja + + {% for extension, details in dxh_py.file_types|dictsort %} +
+
Format name:
+
{{ details.name }}
+ +
Extension:
+
{{ extension }}
+ +
Applications:
+
+
    + {% for app in details.apps -%} +
  • {{ app }}
  • + {% endfor -%} +
+
+
+ {% endfor %} + + +dxh_py is using `Jinja2's for expression `_ to iterate over the items in the dictionary. diff --git a/docs/advanced/directories.rst b/docs/advanced/directories.rst new file mode 100644 index 0000000..701cf68 --- /dev/null +++ b/docs/advanced/directories.rst @@ -0,0 +1,24 @@ +.. _directories: + +Organizing dxh_pys in directories +--------------------------------------- + +*New in dxh_py 1.7* + +dxh_py introduces the ability to organize several templates in one repository or zip file, separating them by directories. +This allows using symlinks for general files. +Here's an example repository demonstrating this feature:: + + https://github.com/user/repo-name.git + ├── directory1-name/ + | ├── {{dxh_py.project_slug}}/ + | └── dxh_py.json + └── directory2-name/ + ├── {{dxh_py.project_slug}}/ + └── dxh_py.json + +To activate one of templates within a subdirectory, use the ``--directory`` option: + +.. code-block:: bash + + dxh_py https://github.com/user/repo-name.git --directory="directory1-name" diff --git a/docs/advanced/hooks.rst b/docs/advanced/hooks.rst new file mode 100644 index 0000000..e925cd8 --- /dev/null +++ b/docs/advanced/hooks.rst @@ -0,0 +1,125 @@ +Hooks +===== + +dxh_py hooks are scripts executed at specific stages during the project generation process. They are either Python or shell scripts, facilitating automated tasks like data validation, pre-processing, and post-processing. These hooks are instrumental in customizing the generated project structure and executing initial setup tasks. + +Types of Hooks +-------------- + ++------------------+------------------------------------------+------------------------------------------+--------------------+----------+ +| Hook | Execution Timing | Working Directory | Template Variables | Version | ++==================+==========================================+==========================================+====================+==========+ +| pre_prompt | Before any question is rendered. | A copy of the repository directory | No | 2.4.0 | ++------------------+------------------------------------------+------------------------------------------+--------------------+----------+ +| pre_gen_project | After questions, before template process.| Root of the generated project | Yes | 0.7.0 | ++------------------+------------------------------------------+------------------------------------------+--------------------+----------+ +| post_gen_project | After the project generation. | Root of the generated project | Yes | 0.7.0 | ++------------------+------------------------------------------+------------------------------------------+--------------------+----------+ + +Creating Hooks +-------------- + +Hooks are added to the ``hooks/`` folder of your template. Both Python and Shell scripts are supported. + +**Python Hooks Structure:** + +.. code-block:: + + dxh_py-something/ + ├── {{dxh_py.project_slug}}/ + ├── hooks + │ ├── pre_prompt.py + │ ├── pre_gen_project.py + │ └── post_gen_project.py + └── dxh_py.json + +**Shell Scripts Structure:** + +.. code-block:: + + dxh_py-something/ + ├── {{dxh_py.project_slug}}/ + ├── hooks + │ ├── pre_prompt.sh + │ ├── pre_gen_project.sh + │ └── post_gen_project.sh + └── dxh_py.json + +Python scripts are recommended for cross-platform compatibility. However, shell scripts or `.bat` files can be used for platform-specific templates. + +Hook Execution +-------------- + +Hooks should be robust and handle errors gracefully. If a hook exits with a nonzero status, the project generation halts, and the generated directory is cleaned. + +**Working Directory:** + +* ``pre_prompt``: Scripts run in the root directory of a copy of the repository directory. That allows the rewrite of ``dxh_py.json`` to your own needs. + +* ``pre_gen_project`` and ``post_gen_project``: Scripts run in the root directory of the generated project, simplifying the process of locating generated files using relative paths. + +**Template Variables:** + +The ``pre_gen_project`` and ``post_gen_project`` hooks support Jinja template rendering, similar to project templates. For instance: + +.. code-block:: python + + module_name = '{{ dxh_py.module_name }}' + +Examples +-------- + +**Pre-Prompt Sanity Check:** + +A ``pre_prompt`` hook, like the one below in ``hooks/pre_prompt.py``, ensures prerequisites, such as Docker, are installed before prompting the user. + +.. code-block:: python + + import sys + import subprocess + + def is_docker_installed() -> bool: + try: + subprocess.run(["docker", "--version"], capture_output=True, check=True) + return True + except Exception: + return False + + if __name__ == "__main__": + if not is_docker_installed(): + print("ERROR: Docker is not installed.") + sys.exit(1) + +**Validating Template Variables:** + +A ``pre_gen_project`` hook can validate template variables. The following script checks if the provided module name is valid. + +.. code-block:: python + + import re + import sys + + MODULE_REGEX = r'^[_a-zA-Z][_a-zA-Z0-9]+$' + module_name = '{{ dxh_py.module_name }}' + + if not re.match(MODULE_REGEX, module_name): + print(f'ERROR: {module_name} is not a valid Python module name!') + sys.exit(1) + +**Conditional File/Directory Removal:** + +A ``post_gen_project`` hook can conditionally control files and directories. The example below removes unnecessary files based on the selected packaging option. + +.. code-block:: python + + import os + + REMOVE_PATHS = [ + '{% if dxh_py.packaging != "pip" %}requirements.txt{% endif %}', + '{% if dxh_py.packaging != "poetry" %}poetry.lock{% endif %}', + ] + + for path in REMOVE_PATHS: + path = path.strip() + if path and os.path.exists(path): + os.unlink(path) if os.path.isfile(path) else os.rmdir(path) diff --git a/docs/advanced/human_readable_prompts.rst b/docs/advanced/human_readable_prompts.rst new file mode 100644 index 0000000..c077c44 --- /dev/null +++ b/docs/advanced/human_readable_prompts.rst @@ -0,0 +1,41 @@ +.. _human-readable-prompts: + +Human readable prompts +-------------------------------- + +You can add human-readable prompts that will be shown to the user for each variable using the ``__prompts__`` key. +For multiple choices questions you can also provide labels for each option. + +See the following dxh_py config as example: + + +.. code-block:: json + + { + "package_name": "my-package", + "module_name": "{{ dxh_py.package_name.replace('-', '_') }}", + "package_name_stylized": "{{ dxh_py.module_name.replace('_', ' ').capitalize() }}", + "short_description": "A nice python package", + "github_username": "your-org-or-username", + "full_name": "Firstname Lastname", + "email": "email@example.com", + "init_git": true, + "linting": ["ruff", "flake8", "none"], + "__prompts__": { + "package_name": "Select your package name", + "module_name": "Select your module name", + "package_name_stylized": "Stylized package name", + "short_description": "Short description", + "github_username": "GitHub username or organization", + "full_name": "Author full name", + "email": "Author email", + "command_line_interface": "Add CLI", + "init_git": "Initialize a git repository", + "linting": { + "__prompt__": "Which linting tool do you want to use?", + "ruff": "Ruff", + "flake8": "Flake8", + "none": "No linting tool" + } + } + } diff --git a/docs/advanced/index.rst b/docs/advanced/index.rst new file mode 100644 index 0000000..afa3923 --- /dev/null +++ b/docs/advanced/index.rst @@ -0,0 +1,30 @@ +.. Advanced Usage master index + +Advanced Usage +============== + +Various advanced topics regarding dxh_py usage. + +.. toctree:: + :maxdepth: 2 + + hooks + user_config + calling_from_python + injecting_context + suppressing_prompts + templates_in_context + private_variables + copy_without_render + replay + choice_variables + boolean_variables + dict_variables + templates + template_extensions + directories + jinja_env + new_line_characters + local_extensions + nested_config_files + human_readable_prompts diff --git a/docs/advanced/injecting_context.rst b/docs/advanced/injecting_context.rst new file mode 100644 index 0000000..7a6348c --- /dev/null +++ b/docs/advanced/injecting_context.rst @@ -0,0 +1,54 @@ +.. _injecting-extra-content: + +Injecting Extra Context +----------------------- + +You can specify an ``extra_context`` dictionary that will override values from ``dxh_py.json`` or ``.dxh_pyrc``: + +.. code-block:: python + + dxh_py( + 'dxh_py/', + extra_context={'project_name': 'TheGreatest'}, + ) + +This works as command-line parameters as well: + +.. code-block:: bash + + dxh_py --no-input dxh_py/ project_name=TheGreatest + +You will also need to add these keys to the ``dxh_py.json`` or ``.dxh_pyrc``. + + +Example: Injecting a Timestamp +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you have ``dxh_py.json`` that has the following keys: + +.. code-block:: JSON + + { + "timestamp": "{{ dxh_py.timestamp }}" + } + + +This Python script will dynamically inject a timestamp value as the project is +generated: + +.. code-block:: python + + from dxh_py.main import dxh_py + + from datetime import datetime + + dxh_py( + 'dxh_py-django', + extra_context={'timestamp': datetime.utcnow().isoformat()} + ) + +How this works: + +1. The script uses ``datetime`` to get the current UTC time in ISO format. +2. To generate the project, ``dxh_py()`` is called, passing the timestamp + in as context via the ``extra_context``` dict. diff --git a/docs/advanced/jinja_env.rst b/docs/advanced/jinja_env.rst new file mode 100644 index 0000000..40633f5 --- /dev/null +++ b/docs/advanced/jinja_env.rst @@ -0,0 +1,16 @@ +.. _jinja-env: + +Customizing the Jinja2 environment +---------------------------------------------- + +The special template variable ``_jinja2_env_vars`` can be used +to customize the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). + +This example shows how to control whitespace with ``lstrip_blocks`` and ``trim_blocks``: + +.. code-block:: JSON + + { + "project_slug": "sample", + "_jinja2_env_vars": {"lstrip_blocks": true, "trim_blocks": true} + } diff --git a/docs/advanced/local_extensions.rst b/docs/advanced/local_extensions.rst new file mode 100644 index 0000000..d2c283e --- /dev/null +++ b/docs/advanced/local_extensions.rst @@ -0,0 +1,58 @@ +.. _`local extensions`: + +Local Extensions +---------------- + +*New in dxh_py 2.1* + +A template may extend the dxh_py environment with local extensions. +These can be part of the template itself, providing it with more sophisticated custom tags and filters. + +To do so, a template author must specify the required extensions in ``dxh_py.json`` as follows: + +.. code-block:: json + + { + "project_slug": "Foobar", + "year": "{% now 'utc', '%Y' %}", + "_extensions": ["local_extensions.FoobarExtension"] + } + +This example uses a simple module ``local_extensions.py`` which exists in the template root, containing the following (for instance): + +.. code-block:: python + + from jinja2.ext import Extension + + + class FoobarExtension(Extension): + def __init__(self, environment): + super(FoobarExtension, self).__init__(environment) + environment.filters['foobar'] = lambda v: v * 2 + +This will register the ``foobar`` filter for the template. + +For many cases, this will be unnecessarily complicated. +It's likely that we'd only want to register a single function as a filter. For this, we can use the ``simple_filter`` decorator: + +.. code-block:: json + + { + "project_slug": "Foobar", + "year": "{% now 'utc', '%Y' %}", + "_extensions": ["local_extensions.simplefilterextension"] + } + +.. code-block:: python + + from dxh_py.utils import simple_filter + + + @simple_filter + def simplefilterextension(v): + return v * 2 + +This snippet will achieve the exact same result as the previous one. + +For complex use cases, a python module ``local_extensions`` (a folder with an ``__init__.py``) can also be created in the template root. +Here, for example, a module ``main.py`` would have to export all extensions with ``from .main import FoobarExtension, simplefilterextension`` or ``from .main import *`` in the ``__init__.py``. diff --git a/docs/advanced/nested_config_files.rst b/docs/advanced/nested_config_files.rst new file mode 100644 index 0000000..d4a0cc6 --- /dev/null +++ b/docs/advanced/nested_config_files.rst @@ -0,0 +1,86 @@ +.. _nested-config-files: + +Nested configuration files +-------------------------- + +*New in dxh_py 2.5.0* + +If you wish to create a hierarchy of templates and use dxh_py to choose among them, +you need just to specify the key ``templates`` in the main configuration file to reach +the other ones. + +Let's imagine to have the following structure:: + + main-directory/ + ├── project-1 + │ ├── dxh_py.json + │ ├── {{dxh_py.project_slug}} + | │ ├── ... + ├── package + │ ├── dxh_py.json + │ ├── {{dxh_py.project_slug}} + | │ ├── ... + └── dxh_py.json + +It is possible to specify in the main ``dxh_py.json`` how to reach the other +config files as follows: + +.. code-block:: JSON + + { + "templates": { + "project-1": { + "path": "./project-1", + "title": "Project 1", + "description": "A dxh_py template for a project" + }, + "package": { + "path": "./package", + "title": "Package", + "description": "A dxh_py template for a package" + } + } + } + +Then, when ``dxh_py`` is launched in the main directory it will ask to choose +among the possible templates: + +.. code-block:: + + Select template: + 1 - Project 1 (A dxh_py template for a project) + 2 - Package (A dxh_py template for a package) + Choose from 1, 2 [1]: + +Once a template is chosen, for example ``1``, it will continue to ask the info required by +``dxh_py.json`` in the ``project-1`` folder, such as ``project-slug`` + + +Old Format +++++++++++ + +*New in dxh_py 2.2.0* + +In the main ``dxh_py.json`` add a `template` key with the following format: + +.. code-block:: JSON + + { + "template": [ + "Project 1 (./project-1)", + "Project 2 (./project-2)" + ] + } + +Then, when ``dxh_py`` is launched in the main directory it will ask to choose +among the possible templates: + +.. code-block:: + + Select template: + 1 - Project 1 (./project-1) + 2 - Project 2 (./project-2) + Choose from 1, 2 [1]: + +Once a template is chosen, for example ``1``, it will continue to ask the info required by +``dxh_py.json`` in the ``project-1`` folder, such as ``project-slug`` diff --git a/docs/advanced/new_line_characters.rst b/docs/advanced/new_line_characters.rst new file mode 100644 index 0000000..9adad25 --- /dev/null +++ b/docs/advanced/new_line_characters.rst @@ -0,0 +1,28 @@ +.. _new-lines: + +Working with line-ends special symbols LF/CRLF +---------------------------------------------- + +*New in dxh_py 2.0* + +.. note:: + + Before version 2.0 dxh_py silently used system line end character. + LF for POSIX and CRLF for Windows. + Since version 2.0 this behaviour changed and now can be forced at template level. + +By default dxh_py checks every file at render stage and uses the same line end as in source. +This allow template developers to have both types of files in the same template. +Developers should correctly configure their ``.gitattributes`` file to avoid line-end character overwrite by git. + +The special template variable ``_new_lines`` enforces a specific line ending. +Acceptable variables: ``'\r\n'`` for CRLF and ``'\n'`` for POSIX. + +Here is example how to force line endings to CRLF on any deployment: + +.. code-block:: JSON + + { + "project_slug": "sample", + "_new_lines": "\r\n" + } diff --git a/docs/advanced/private_variables.rst b/docs/advanced/private_variables.rst new file mode 100644 index 0000000..c8ad7ad --- /dev/null +++ b/docs/advanced/private_variables.rst @@ -0,0 +1,56 @@ +.. _private-variables: + +Private Variables +----------------- + +dxh_py allows the definition private variables by prepending an underscore to the variable name. +The user will not be required to fill those variables in. +These can either be not rendered, by using a prepending underscore, or rendered, prepending a double underscore. +For example, the ``dxh_py.json``: + +.. code-block:: JSON + + { + "project_name": "Really cool project", + "_not_rendered": "{{ dxh_py.project_name|lower }}", + "__rendered": "{{ dxh_py.project_name|lower }}" + } + +Will be rendered as: + +.. code-block:: JSON + + { + "project_name": "Really cool project", + "_not_rendered": "{{ dxh_py.project_name|lower }}", + "__rendered": "really cool project" + } + +The user will only be asked for ``project_name``. + +Non-rendered private variables can be used for defining constants. +An example of where you may wish to use private **rendered** variables is creating a Python package repository and want to enforce naming consistency. +To ensure the repository and package name are based on the project name, you could create a ``dxh_py.json`` such as: + +.. code-block:: JSON + + { + "project_name": "Project Name", + "__project_slug": "{{ dxh_py.project_name|lower|replace(' ', '-') }}", + "__package_name": "{{ dxh_py.project_name|lower|replace(' ', '_') }}", + } + +Which could create a structure like this:: + + project-name + ├── Makefile + ├── README.md + ├── requirements.txt + └── src + ├── project_name + │ └── __init__.py + ├── setup.py + └── tests + └── __init__.py + +The ``README.md`` can then have a plain English project title. diff --git a/docs/advanced/replay.rst b/docs/advanced/replay.rst new file mode 100644 index 0000000..b2e8fa8 --- /dev/null +++ b/docs/advanced/replay.rst @@ -0,0 +1,60 @@ +.. _replay-feature: + +Replay Project Generation +------------------------- + +*New in dxh_py 1.1* + +On invocation **dxh_py** dumps a json file to ``~/.dxh_py_replay/`` which enables you to *replay* later on. + +In other words, it persists your **input** for a template and fetches it when you run the same template again. + +Example for a replay file (which was created via ``dxh_py gh:hackebrot/cookiedozer``): + +.. code-block:: JSON + + { + "dxh_py": { + "app_class_name": "FooBarApp", + "app_title": "Foo Bar", + "email": "raphael@example.com", + "full_name": "Raphael Pierzina", + "github_username": "hackebrot", + "kivy_version": "1.8.0", + "project_slug": "foobar", + "short_description": "A sleek slideshow app that supports swipe gestures.", + "version": "0.1.0", + "year": "2015" + } + } + +To fetch this context data without being prompted on the command line you can use either of the following methods. + +Pass the according option on the CLI: + +.. code-block:: bash + + dxh_py --replay gh:hackebrot/cookiedozer + + +Or use the Python API: + +.. code-block:: python + + from dxh_py.main import dxh_py + dxh_py('gh:hackebrot/cookiedozer', replay=True) + +This feature comes in handy if, for instance, you want to create a new project from an updated template. + +Custom replay file +~~~~~~~~~~~~~~~~~~ + +*New in dxh_py 2.0* + +To specify a custom filename, you can use the ``--replay-file`` option: + +.. code-block:: bash + + dxh_py --replay-file ./cookiedozer.json gh:hackebrot/cookiedozer + +This may be useful to run the same replay file over several machines, in tests or when a user of the template reports a problem. diff --git a/docs/advanced/suppressing_prompts.rst b/docs/advanced/suppressing_prompts.rst new file mode 100644 index 0000000..ca96c90 --- /dev/null +++ b/docs/advanced/suppressing_prompts.rst @@ -0,0 +1,40 @@ +.. _suppressing-command-line-prompts: + +Suppressing Command-Line Prompts +-------------------------------- + +To suppress the prompts asking for input, use ``no_input``. + +Note: this option will force a refresh of cached resources. + +Basic Example: Using the Defaults +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +dxh_py will pick a default value if used with ``no_input``: + +.. code-block:: python + + from dxh_py.main import dxh_py + dxh_py( + 'dxh_py-django', + no_input=True, + ) + +In this case it will be using the default defined in ``dxh_py.json`` or ``.dxh_pyrc``. + +.. note:: + values from ``dxh_py.json`` will be overridden by values from ``.dxh_pyrc`` + +Advanced Example: Defaults + Extra Context +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you combine an ``extra_context`` dict with the ``no_input`` argument, you can programmatically create the project with a set list of context parameters and without any command line prompts: + +.. code-block:: python + + dxh_py('dxh_py/', + no_input=True, + extra_context={'project_name': 'TheGreatest'}) + + +See also :ref:`injecting-extra-content` and the :ref:`API Reference ` for more details. diff --git a/docs/advanced/template_extensions.rst b/docs/advanced/template_extensions.rst new file mode 100644 index 0000000..8666b9e --- /dev/null +++ b/docs/advanced/template_extensions.rst @@ -0,0 +1,147 @@ +.. _`template extensions`: + +Template Extensions +------------------- + +*New in dxh_py 1.4* + +A template may extend the dxh_py environment with custom `Jinja2 extensions`_. +It can add extra filters, tests, globals or even extend the parser. + +To do so, a template author must specify the required extensions in ``dxh_py.json`` as follows: + +.. code-block:: json + + { + "project_slug": "Foobar", + "year": "{% now 'utc', '%Y' %}", + "_extensions": ["jinja2_time.TimeExtension"] + } + +On invocation dxh_py tries to import the extensions and add them to its environment respectively. + +In the above example, dxh_py provides the additional tag `now`_, after installing the `jinja2_time.TimeExtension`_ and enabling it in ``dxh_py.json``. + +Please note that dxh_py will **not** install any dependencies on its own! +As a user you need to make sure you have all the extensions installed, before running dxh_py on a template that requires custom Jinja2 extensions. + +By default dxh_py includes the following extensions: + +- ``dxh_py.extensions.JsonifyExtension`` +- ``dxh_py.extensions.RandomStringExtension`` +- ``dxh_py.extensions.SlugifyExtension`` +- ``dxh_py.extensions.TimeExtension`` +- ``dxh_py.extensions.UUIDExtension`` + +.. warning:: + + The above is just an example to demonstrate how this is used. There is no + need to require ``jinja2_time.TimeExtension``, since its functionality is + included by default (by ``dxh_py.extensions.TimeExtension``) without + needing an extra install. + +Jsonify extension +~~~~~~~~~~~~~~~~~ + +The ``dxh_py.extensions.JsonifyExtension`` extension provides a ``jsonify`` filter in templates that converts a Python object to JSON: + +.. code-block:: jinja + + {% {'a': True} | jsonify %} + +Would output: + +.. code-block:: json + + {"a": true} + +It supports an optional ``indent`` param, the default value is ``4``: + +.. code-block:: jinja + + {% {'a': True, 'foo': 'bar'} | jsonify(2) %} + +Would output: + +.. code-block:: json + + { + "a": true, + "foo": "bar" + } + +Random string extension +~~~~~~~~~~~~~~~~~~~~~~~ + +*New in dxh_py 1.7* + +The ``dxh_py.extensions.RandomStringExtension`` extension provides a ``random_ascii_string`` method in templates that generates a random fixed-length string, optionally with punctuation. + +Generate a random n-size character string. +Example for n=12: + +.. code-block:: jinja + + {{ random_ascii_string(12) }} + +Outputs: + +.. code-block:: text + + bIIUczoNvswh + +The second argument controls if punctuation and special characters ``!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~`` should be present in the result: + +.. code-block:: jinja + + {{ random_ascii_string(12, punctuation=True) }} + +Outputs: + +.. code-block:: text + + fQupUkY}W!)! + +Slugify extension +~~~~~~~~~~~~~~~~~ + +The ``dxh_py.extensions.SlugifyExtension`` extension provides a ``slugify`` filter in templates that converts string into its dashed ("slugified") version: + +.. code-block:: jinja + + {% "It's a random version" | slugify %} + +Would output: + +:: + + it-s-a-random-version + +It is different from a mere replace of spaces since it also treats some special characters differently such as ``'`` in the example above. +The function accepts all arguments that can be passed to the ``slugify`` function of `python-slugify`_. +For example to change the output from ``it-s-a-random-version``` to ``it_s_a_random_version``, the ``separator`` parameter would be passed: ``slugify(separator='_')``. + +.. _`Jinja2 extensions`: https://jinja.palletsprojects.com/en/latest/extensions/ +.. _`now`: https://github.com/hackebrot/jinja2-time#now-tag +.. _`jinja2_time.TimeExtension`: https://github.com/hackebrot/jinja2-time +.. _`python-slugify`: https://pypi.org/project/python-slugify + +UUID4 extension +~~~~~~~~~~~~~~~~~~~~~~~ + +*New in dxh_py 1.x* + +The ``dxh_py.extensions.UUIDExtension`` extension provides a ``uuid4()`` +method in templates that generates a uuid4. + +Generate a uuid4 string: + +.. code-block:: jinja + + {{ uuid4() }} + +Outputs: + +.. code-block:: text + + 83b5de62-31b4-4a1e-83fa-8c548de65a11 diff --git a/docs/advanced/templates.rst b/docs/advanced/templates.rst new file mode 100644 index 0000000..f0207ae --- /dev/null +++ b/docs/advanced/templates.rst @@ -0,0 +1,34 @@ +.. _templates: + +Templates inheritance (2.2+) +--------------------------------------------------- + +*New in dxh_py 2.2+* + +Sometimes you need to extend a base template with a different +configuration to avoid nested blocks. + +dxh_py introduces the ability to use common templates +using the power of jinja: `extends`, `include` and `super`. + +Here's an example repository:: + + https://github.com/user/repo-name.git + ├── {{dxh_py.project_slug}}/ + | └── file.txt + ├── templates/ + | └── base.txt + └── dxh_py.json + +every file in the `templates` directory will become referable inside the project itself, +and the path should be relative from the `templates` folder like :: + + # file.txt + {% extends "base.txt" %} + + ... or ... + + # file.txt + {% include "base.txt" %} + +see more on https://jinja.palletsprojects.com/en/2.11.x/templates/ diff --git a/docs/advanced/templates_in_context.rst b/docs/advanced/templates_in_context.rst new file mode 100644 index 0000000..645f163 --- /dev/null +++ b/docs/advanced/templates_in_context.rst @@ -0,0 +1,37 @@ +.. _templates-in-context-values: + +Templates in Context Values +-------------------------------- + +The values (but not the keys!) of `dxh_py.json` are also Jinja2 templates. +Values from user prompts are added to the context immediately, such that one context value can be derived from previous values. +This approach can potentially save your user a lot of keystrokes by providing more sensible defaults. + +Basic Example: Templates in Context +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Python packages show some patterns for their naming conventions: + +- a human-readable project name +- a lowercase, dashed repository name +- an importable, dash-less package name + +Here is a `dxh_py.json` with templated values for this pattern: + +.. code-block:: JSON + + { + "project_name": "My New Project", + "project_slug": "{{ dxh_py.project_name|lower|replace(' ', '-') }}", + "pkg_name": "{{ dxh_py.project_slug|replace('-', '') }}" + } + +If the user takes the defaults, or uses `no_input`, the templated values will be: + +- `my-new-project` +- `mynewproject` + +Or, if the user gives `Yet Another New Project`, the values will be: + +- ``yet-another-new-project`` +- ``yetanothernewproject`` diff --git a/docs/advanced/user_config.rst b/docs/advanced/user_config.rst new file mode 100644 index 0000000..727c3ca --- /dev/null +++ b/docs/advanced/user_config.rst @@ -0,0 +1,60 @@ +.. _user-config: + +User Config +=========== + +*New in dxh_py 0.7* + +If you use dxh_py a lot, you'll find it useful to have a user config file. +By default dxh_py tries to retrieve settings from a `.dxh_pyrc` file in your home directory. + +*New in dxh_py 1.3* + +You can also specify a config file on the command line via ``--config-file``. + +.. code-block:: bash + + dxh_py --config-file /home/audreyr/my-custom-config.yaml dxh_py + +Or you can set the ``dxh_py_CONFIG`` environment variable: + +.. code-block:: bash + + export dxh_py_CONFIG=/home/audreyr/my-custom-config.yaml + +If you wish to stick to the built-in config and not load any user config file at all, use the CLI option ``--default-config`` instead. +Preventing dxh_py from loading user settings is crucial for writing integration tests in an isolated environment. + +Example user config: + +.. code-block:: yaml + + default_context: + full_name: "Audrey Roy" + email: "audreyr@example.com" + github_username: "audreyr" + dxh_pys_dir: "/home/audreyr/my-custom-dxh_pys-dir/" + replay_dir: "/home/audreyr/my-custom-replay-dir/" + abbreviations: + pp: https://github.com/devxhub/dxh_py.git + gh: https://github.com/{0}.git + bb: https://bitbucket.org/{0} + +Possible settings are: + +``default_context``: + A list of key/value pairs that you want injected as context whenever you generate a project with dxh_py. + These values are treated like the defaults in ``dxh_py.json``, upon generation of any project. +``dxh_pys_dir`` + Directory where your dxh_pys are cloned to when you use dxh_py with a repo argument. +``replay_dir`` + Directory where dxh_py dumps context data to, which you can fetch later on when using the + :ref:`replay feature `. +``abbreviations`` + A list of abbreviations for dxh_pys. + Abbreviations can be simple aliases for a repo name, or can be used as a prefix, in the form ``abbr:suffix``. + Any suffix will be inserted into the expansion in place of the text ``{0}``, using standard Python string formatting. + With the above aliases, you could use the ``dxh_py`` template simply by saying ``dxh_py pp``, or ``dxh_py gh:audreyr/dxh_py``. + The ``gh`` (GitHub), ``bb`` (Bitbucket), and ``gl`` (Gitlab) abbreviations shown above are actually **built in**, and can be used without defining them yourself. + +Read also: :ref:`injecting-extra-content` diff --git a/docs/cli_options.rst b/docs/cli_options.rst new file mode 100644 index 0000000..a6ca019 --- /dev/null +++ b/docs/cli_options.rst @@ -0,0 +1,7 @@ +.. _command_line_options: + +Command Line Options +-------------------- + +.. click:: dxh_py.__main__:main + :prog: dxh_py diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..1cfb17b --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,373 @@ +"""Documentation build configuration file.""" + +# +# dxh_py documentation build configuration file, created by +# sphinx-quickstart on Thu Jul 11 11:31:49 2013. +# +# This file is execfile()d with the current directory set to its containing +# dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import os +import sys + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.abspath('.')) + +# For building docs in foreign environments where we don't have all our +# dependencies (like readthedocs), mock out imports that cause sphinx to fail. +# see: https://docs.readthedocs.io/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules + + +# Add parent dir to path +cwd = os.getcwd() +parent = os.path.dirname(cwd) +sys.path.append(parent) + +import dxh_py # noqa: E402 + +# -- General configuration ---------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or +# your custom ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.imgmath', + 'sphinx.ext.ifconfig', + 'sphinx.ext.viewcode', + 'sphinx_click.ext', + 'myst_parser', + 'sphinxcontrib.apidoc', + 'sphinx_autodoc_typehints', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = {'.rst': 'restructuredtext', '.md': 'markdown'} + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'dxh_py' +copyright = '2013-2022, Audrey Roy and dxh_py community' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = dxh_py.__version__ +# The full version, including alpha/beta/rc tags. +release = dxh_py.__version__ + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# Suppress nonlocal image warnings +suppress_warnings = ['image.nonlocal_uri'] + +# -- Options for HTML output -------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +# html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'dxh_pydoc' + + +# -- Options for LaTeX output ------------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]) +latex_documents = [ + ( + 'index', + 'dxh_py.tex', + 'dxh_py Documentation', + 'Audrey Roy and dxh_py community', + 'manual', + ), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# If true, show page references after internal links. +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_domain_indices = True + + +# -- Options for manual page output ------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + 'index', + 'dxh_py', + 'dxh_py Documentation', + ['Audrey Roy and dxh_py community'], + 1, + ) +] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ----------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + 'index', + 'dxh_py', + 'dxh_py Documentation', + 'Audrey Roy and dxh_py community', + 'dxh_py', + 'Creates projects from project templates', + 'Miscellaneous', + ), +] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# texinfo_no_detailmenu = False + + +# -- Options for Epub output -------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = 'dxh_py' +epub_author = 'Audrey Roy' +epub_publisher = 'Audrey Roy and dxh_py community' +epub_copyright = '2013-2022, Audrey Roy and dxh_py community' + +# The language of the text. It defaults to the language option +# or en if the language is not set. +# epub_language = '' + +# The scheme of the identifier. Typical schemes are ISBN or URL. +# epub_scheme = '' + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# epub_identifier = '' + +# A unique identification for the text. +# epub_uid = '' + +# A tuple containing the cover image and cover page html template filenames. +# epub_cover = () + +# A sequence of (type, uri, title) tuples for the guide element of content.opf. +# epub_guide = () + +# HTML files that should be inserted before the pages created by sphinx. +# The format is a list of tuples containing the path and title. +# epub_pre_files = [] + +# HTML files that should be inserted after the pages created by sphinx. +# The format is a list of tuples containing the path and title. +# epub_post_files = [] + +# A list of files that should not be packed into the epub file. +# epub_exclude_files = [] + +# The depth of the table of contents in toc.ncx. +# epub_tocdepth = 3 + +# Allow duplicate toc entries. +# epub_tocdup = True + +# Fix unsupported image types using the PIL. +# epub_fix_images = False + +# Scale large images. +# epub_max_image_width = 0 + +# If 'no', URL addresses will not be shown. +# epub_show_urls = 'inline' + +# If false, no index is generated. +# epub_use_index = True + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "requests": ("https://requests.readthedocs.io/en/latest/", None), + "click": ("https://click.palletsprojects.com/en/latest", None), +} +myst_enable_extensions = [ + "tasklist", + "strikethrough", + "fieldlist", +] +myst_heading_anchors = 3 +# Apidoc extension config +apidoc_module_dir = "../dxh_py" +apidoc_output_dir = "." +apidoc_toc_file = False +apidoc_extra_args = ["-t", "_templates"] + +autodoc_member_order = "groupwise" +autodoc_typehints = "none" diff --git a/docs/cookiecutter.rst b/docs/cookiecutter.rst new file mode 100644 index 0000000..18173e3 --- /dev/null +++ b/docs/cookiecutter.rst @@ -0,0 +1,141 @@ +dxh_py package +==================== + +Submodules +---------- + +dxh_py.cli module +----------------------- + +.. automodule:: dxh_py.cli + :members: + :undoc-members: + :show-inheritance: + +dxh_py.config module +-------------------------- + +.. automodule:: dxh_py.config + :members: + :undoc-members: + :show-inheritance: + +dxh_py.environment module +------------------------------- + +.. automodule:: dxh_py.environment + :members: + :undoc-members: + :show-inheritance: + +dxh_py.exceptions module +------------------------------ + +.. automodule:: dxh_py.exceptions + :members: + :undoc-members: + :show-inheritance: + +dxh_py.extensions module +------------------------------ + +.. automodule:: dxh_py.extensions + :members: + :undoc-members: + :show-inheritance: + +dxh_py.find module +------------------------ + +.. automodule:: dxh_py.find + :members: + :undoc-members: + :show-inheritance: + +dxh_py.generate module +---------------------------- + +.. automodule:: dxh_py.generate + :members: + :undoc-members: + :show-inheritance: + +dxh_py.hooks module +------------------------- + +.. automodule:: dxh_py.hooks + :members: + :undoc-members: + :show-inheritance: + +dxh_py.log module +----------------------- + +.. automodule:: dxh_py.log + :members: + :undoc-members: + :show-inheritance: + +dxh_py.main module +------------------------ + +.. automodule:: dxh_py.main + :members: + :undoc-members: + :show-inheritance: + +dxh_py.prompt module +-------------------------- + +.. automodule:: dxh_py.prompt + :members: + :undoc-members: + :show-inheritance: + +dxh_py.replay module +-------------------------- + +.. automodule:: dxh_py.replay + :members: + :undoc-members: + :show-inheritance: + +dxh_py.repository module +------------------------------ + +.. automodule:: dxh_py.repository + :members: + :undoc-members: + :show-inheritance: + +dxh_py.utils module +------------------------- + +.. automodule:: dxh_py.utils + :members: + :undoc-members: + :show-inheritance: + +dxh_py.vcs module +----------------------- + +.. automodule:: dxh_py.vcs + :members: + :undoc-members: + :show-inheritance: + +dxh_py.zipfile module +--------------------------- + +.. automodule:: dxh_py.zipfile + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: dxh_py + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..eec6af3 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,52 @@ +.. dxh_py documentation master file, created by + sphinx-quickstart on Thu Jul 11 11:31:49 2013. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +dxh_py: Better Project Templates +====================================== + +dxh_py creates projects from **dxh_pys** (project templates), e.g. Python package projects from Python package templates. + +Basics +------ + +.. toctree:: + :maxdepth: 2 + + README + overview + installation + usage + cli_options + tutorials/index + advanced/index + troubleshooting + +.. _apiref: + +API Reference +------------- + +.. toctree:: + :maxdepth: 2 + + dxh_py + +Project Info +------------ + +.. toctree:: + :maxdepth: 2 + + CONTRIBUTING + AUTHORS + HISTORY + case_studies + CODE_OF_CONDUCT + +Index +----- + +* :ref:`genindex` +* :ref:`modindex` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..5981aad --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,147 @@ +============ +Installation +============ + +Prerequisites +------------- + +* Python interpreter +* Adjust your path +* Packaging tools + +Python interpreter +^^^^^^^^^^^^^^^^^^ + +Install Python for your operating system. +On Windows and macOS this is usually necessary. +Most Linux distributions come with Python pre-installed. +Consult the official `Python documentation `_ for details. + +You can install the Python binaries from `python.org `_. +Alternatively on macOS, you can use the `homebrew `_ package manager. + +.. code-block:: bash + + brew install python3 + + +Adjust your path +^^^^^^^^^^^^^^^^ + +Ensure that your ``bin`` folder is on your path for your platform. Typically ``~/.local/`` for UNIX and macOS, or ``%APPDATA%\Python`` on Windows. (See the Python documentation for `site.USER_BASE `_ for full details.) + + +UNIX and macOS +"""""""""""""" + +For bash shells, add the following to your ``.bash_profile`` (adjust for other shells): + +.. code-block:: bash + + # Add ~/.local/ to PATH + export PATH=$HOME/.local/bin:$PATH + +Remember to load changes with ``source ~/.bash_profile`` or open a new shell session. + + +Windows +""""""" + +Ensure the directory where dxh_py will be installed is in your environment's ``Path`` in order to make it possible to invoke it from a command prompt. To do so, search for "Environment Variables" on your computer (on Windows 10, it is under ``System Properties`` --> ``Advanced``) and add that directory to the ``Path`` environment variable, using the GUI to edit path segments. + +Example segments should look like ``%APPDATA%\Python\Python3x\Scripts``, where you have your version of Python instead of ``Python3x``. + +You may need to restart your command prompt session to load the environment variables. + +.. seealso:: See `Configuring Python (on Windows) `_ for full details. + +**Unix on Windows** + + +You may also install `Windows Subsystem for Linux `_ or `GNU utilities for Win32 `_ to use Unix commands on Windows. + +Packaging tools +^^^^^^^^^^^^^^^ + +See the Python Packaging Authority's (PyPA) documentation `Requirements for Installing Packages `_ for full details. + + +Install dxh_py +-------------------- + +At the command line: + +.. code-block:: bash + + python3 -m pip install --user dxh_py + +Or, if you do not have pip: + +.. code-block:: bash + + easy_install --user dxh_py + +Though, pip is recommended, easy_install is deprecated. + +Or, if you are using conda, first add conda-forge to your channels: + +.. code-block:: bash + + conda config --add channels conda-forge + +Once the conda-forge channel has been enabled, dxh_py can be installed with: + +.. code-block:: bash + + conda install dxh_py + +Alternate installations +----------------------- + +**Homebrew (Mac OS X only):** + +.. code-block:: bash + + brew install dxh_py + +**Void Linux:** + +.. code-block:: bash + + xbps-install dxh_py + +**Pipx (Linux, OSX and Windows):** + +.. code-block:: bash + + pipx install dxh_py + + +Upgrading +--------- + +from 0.6.4 to 0.7.0 or greater +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +First, read :doc:`HISTORY` in detail. +There are a lot of major changes. +The big ones are: + +* dxh_py no longer deletes the cloned repo after generating a project. +* Cloned repos are saved into `~/.dxh_pys/`. +* You can optionally create a `~/.dxh_pyrc` config file. + + +Or with pip: + +.. code-block:: bash + + python3 -m pip install --upgrade dxh_py + +Upgrade dxh_py either with easy_install (deprecated): + +.. code-block:: bash + + easy_install --upgrade dxh_py + +Then you should be good to go. diff --git a/docs/overview.rst b/docs/overview.rst new file mode 100644 index 0000000..7612b23 --- /dev/null +++ b/docs/overview.rst @@ -0,0 +1,47 @@ +======== +Overview +======== + +dxh_py takes a template provided as a directory structure with template-files. +Templates can be located in the filesystem, as a ZIP-file or on a VCS-Server (Git/Hg) like GitHub. + +It reads a settings file and prompts the user interactively whether or not to change the settings. + +Then it takes both and generates an output directory structure from it. + +Additionally the template can provide code (Python or shell-script) to be executed before and after generation (pre-gen- and post-gen-hooks). + + +Input +----- + +This is a directory structure for a simple dxh_py:: + + dxh_py-something/ + ├── {{ dxh_py.project_name }}/ <--------- Project template + │ └── ... + ├── blah.txt <--------- Non-templated files/dirs + │ go outside + │ + └── dxh_py.json <--------- Prompts & default values + +You must have: + +- A ``dxh_py.json`` file. +- A ``{{ dxh_py.project_name }}/`` directory, where ``project_name`` is defined in your ``dxh_py.json``. + +Beyond that, you can have whatever files/directories you want. + +See https://github.com/devxhub/dxh_py for a real-world example +of this. + +Output +------ + +This is what will be generated locally, in your current directory:: + + mysomething/ <---------- Value corresponding to what you enter at the + │ project_name prompt + │ + └── ... <-------- Files corresponding to those in your + dxh_py's `{{ dxh_py.project_name }}/` dir diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..db537a3 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,8 @@ +sphinx-rtd-theme>=1.0.0 +sphinx-click>=4.1.0 +myst-parser>=0.17.2 +sphinx-autobuild>=2021.3.14 +Sphinx>=4.5.0 +sphinxcontrib-apidoc>=0.3.0 +sphinx-autodoc-typehints>=1.18.2 +typing-extensions diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst new file mode 100644 index 0000000..18972c7 --- /dev/null +++ b/docs/troubleshooting.rst @@ -0,0 +1,42 @@ +=============== +Troubleshooting +=============== + +I created a dxh_py, but it doesn't work, and I can't figure out why +------------------------------------------------------------------------- + +* Try upgrading to dxh_py 0.8.0, which prints better error + messages and has fixes for several common bugs. + +I'm having trouble generating Jinja templates from Jinja templates +------------------------------------------------------------------ + +Make sure you escape things properly, like this:: + + {{ "{{" }} + +Or this:: + + {% raw %} +

Go Home

+ {% endraw %} + +Or this:: + + {{ {{ url_for('home') }} }} + +See https://jinja.palletsprojects.com/en/latest/templates/#escaping for more info. + +You can also use the `_copy_without_render`_ key in your `dxh_py.json` +file to escape entire files and directories. + +.. _`_copy_without_render`: http://dxh_py.readthedocs.io/en/latest/advanced/copy_without_render.html + + +Other common issues +------------------- + +TODO: add a bunch of common new user issues here. + +This document is incomplete. If you have knowledge that could help other users, +adding a section or filing an issue with details would be greatly appreciated. diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst new file mode 100644 index 0000000..6d7fe30 --- /dev/null +++ b/docs/tutorials/index.rst @@ -0,0 +1,36 @@ +==================== +Tutorials +==================== + +Tutorials by `@devxhub`_ + +.. toctree:: + :maxdepth: 2 + + tutorial1 + tutorial2 + + +External Links +-------------- + +- `Learn the Basics of dxh_py by Creating a dxh_py`_ - first steps tutorial with example template by `@BruceEckel`_ +- `Project Templates Made Easy`_ by `@pydanny`_ +- Cookiedozer Tutorials by `@hackebrot`_ + + - Part 1: `Create your own dxh_py template`_ + - Part 2: `Extending our dxh_py template`_ + - Part 3: `Wrapping up our dxh_py template`_ + + +.. _`Learn the Basics of dxh_py by Creating a dxh_py`: https://github.com/BruceEckel/Hellodxh_py1/blob/master/Readme.rst +.. _`Project Templates Made Easy`: http://www.pydanny.com/cookie-project-templates-made-easy.html + +.. _`Create your own dxh_py template`: https://raphael.codes/blog/create-your-own-dxh_py-template/ +.. _`Extending our dxh_py template`: https://raphael.codes/blog/extending-our-dxh_py-template/ +.. _`Wrapping up our dxh_py template`: https://raphael.codes/blog/wrapping-up-our-dxh_py-template/ + +.. _`@devxhub`: https://github.com/devxhub +.. _`@pydanny`: https://github.com/pydanny +.. _`@hackebrot`: https://github.com/hackebrot +.. _`@BruceEckel`: https://github.com/BruceEckel diff --git a/docs/tutorials/tutorial1.rst b/docs/tutorials/tutorial1.rst new file mode 100644 index 0000000..f1d9db6 --- /dev/null +++ b/docs/tutorials/tutorial1.rst @@ -0,0 +1,152 @@ +============================= +Getting to Know dxh_py +============================= + +.. note:: Before you begin, please install dxh_py 0.0.2 or higher. + Instructions are in :doc:`../installation`. + +dxh_py is a tool for creating projects from *dxh_pys* (project templates). + +What exactly does this mean? Read on! + +Case Study: dxh_py +----------------------------------- + +*dxh_py* is a dxh_py template that creates the starter boilerplate for a Python package. + +.. note:: There are several variations of it, but for this tutorial we'll use + the original version at https://github.com/devxhub/dxh_py/. + +Step 1: Generate a Python Package Project +------------------------------------------ + +Open your shell and cd into the directory where you'd like to create a starter Python package project. + +At the command line, run the dxh_py command, passing in the link to dxh_py's HTTPS clone URL like this: + +.. code-block:: bash + + $ dxh_py https://github.com/devxhub/dxh_py.git + +Local Cloning of Project Template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +First, dxh_py gets cloned to `~/.dxh_pys/` (or equivalent on Windows). +dxh_py does this for you, so sit back and wait. + +Local Generation of Project +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When cloning is complete, you will be prompted to enter a bunch of values, such as `full_name`, `email`, and `project_name`. +Either enter your info, or simply press return/enter to accept the default values. + +This info will be used to fill in the blanks for your project. +For example, your name and the year will be placed into the LICENSE file. + +Step 2: Explore What Got Generated +---------------------------------- + +In your current directory, you should see that a project got generated: + +.. code-block:: bash + + $ ls + boilerplate + +Looking inside the `boilerplate/` (or directory corresponding to your `project_slug`) directory, you should see something like this: + +.. code-block:: bash + + $ ls boilerplate/ + AUTHORS.rst MANIFEST.in docs tox.ini + CONTRIBUTING.rst Makefile requirements.txt + HISTORY.rst README.rst setup.py + LICENSE boilerplate tests + +That's your new project! + +If you open the AUTHORS.rst file, you should see something like this: + +.. code-block:: rst + + ======= + Credits + ======= + + Development Lead + ---------------- + + * Audrey Roy + + Contributors + ------------ + + None yet. Why not be the first? + +Notice how it was auto-populated with your (or my) name and email. + +Also take note of the fact that you are looking at a ReStructuredText file. +dxh_py can generate a project with text files of any type. + +Great, you just generated a skeleton Python package. +How did that work? + +Step 3: Observe How It Was Generated +------------------------------------ + +Let's take a look at dxh_py together. Open https://github.com/devxhub/dxh_py in a new browser window. + +{{ dxh_py.project_slug }} +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Find the directory called `{{ dxh_py.project_slug }}`. +Click on it. +Observe the files inside of it. +You should see that this directory and its contents corresponds to the project that you just generated. + +This happens in `find.py`, where the `find_template()` method looks for the first jinja-like directory name that starts with `dxh_py`. + +AUTHORS.rst +~~~~~~~~~~~ + +Look at the raw version of `{{ dxh_py.project_slug }}/AUTHORS.rst`, at +https://raw.github.com/devxhub/dxh_py/master/%7B%7Bdxh_py.project_slug%7D%7D/AUTHORS.rst. + +Observe how it corresponds to the `AUTHORS.rst` file that you generated. + +dxh_py.json +~~~~~~~~~~~~~~~~~ + +Now navigate back up to `dxh_py/` and look at the `dxh_py.json` file. + +You should see JSON that corresponds to the prompts and default values shown earlier during project generation: + +.. code-block:: json + + { + "full_name": "Audrey Roy Greenfeld", + "email": "aroy@alum.mit.edu", + "github_username": "audreyr", + "project_name": "Python Boilerplate", + "project_slug": "{{ dxh_py.project_name.lower().replace(' ', '_') }}", + "project_short_description": "Python Boilerplate contains all the boilerplate you need to create a Python package.", + "pypi_username": "{{ dxh_py.github_username }}", + "version": "0.1.0", + "use_pytest": "n", + "use_pypi_deployment_with_travis": "y", + "create_author_file": "y", + "open_source_license": ["MIT", "BSD", "ISCL", "Apache Software License 2.0", "Not open source"] + } + +Questions? +---------- + +If anything needs better explanation, please take a moment to file an issue at https://github.com/devxhub/dxh_py/issues with what could be improved +about this tutorial. + +Summary +------- + +You have learned how to use dxh_py to generate your first project from a dxh_py project template. + +In tutorial 2 (:ref:`tutorial2`), you'll see how to create dxh_pys of your own, from scratch. diff --git a/docs/tutorials/tutorial2.rst b/docs/tutorials/tutorial2.rst new file mode 100644 index 0000000..d4c7a45 --- /dev/null +++ b/docs/tutorials/tutorial2.rst @@ -0,0 +1,107 @@ +.. _tutorial2: + +================================== +Create a dxh_py From Scratch +================================== + +In this tutorial, we are creating `dxh_py-website-simple`, a dxh_py for generating simple, bare-bones websites. + +Step 1: Name Your dxh_py +------------------------------ + +Create the directory for your dxh_py and cd into it: + +.. code-block:: bash + + $ mkdir dxh_py-website-simple + $ cd dxh_py-website-simple/ + +Step 2: Create dxh_py.json +---------------------------------- + +`dxh_py.json` is a JSON file that contains fields which can be referenced in the dxh_py template. For each, default value is defined and user will be prompted for input during dxh_py execution. Only mandatory field is `project_slug` and it should comply with package naming conventions defined in `PEP8 Naming Conventions `_ . + +.. code-block:: json + + { + "project_name": "dxh_py Website Simple", + "project_slug": "{{ dxh_py.project_name.lower().replace(' ', '_') }}", + "author": "Anonymous" + } + + +Step 3: Create project_slug Directory +--------------------------------------- + +Create a directory called `{{ dxh_py.project_slug }}`. + +This value will be replaced with the repo name of projects that you generate from this dxh_py. + +Step 4: Create index.html +-------------------------- + +Inside of `{{ dxh_py.project_slug }}`, create `index.html` with following content: + +.. code-block:: html + + + + + + {{ dxh_py.project_name }} + + + +

{{ dxh_py.project_name }}

+

by {{ dxh_py.author }}

+ + + +Step 5: Pack dxh_py into ZIP +---------------------------------- +There are many ways to run dxh_py templates, and they are described in details in `Usage chapter `_. In this tutorial we are going to ZIP dxh_py and then run it for testing. + +By running following command `dxh_py.zip` will get generated which can be used to run dxh_py. Script will generate `dxh_py.zip` ZIP file and echo full path to the file. + +.. code-block:: bash + + $ (SOURCE_DIR=$(basename $PWD) ZIP=dxh_py.zip && # Set variables + pushd .. && # Set parent directory as working directory + zip -r $ZIP $SOURCE_DIR --exclude $SOURCE_DIR/$ZIP --quiet && # ZIP dxh_py + mv $ZIP $SOURCE_DIR/$ZIP && # Move ZIP to original directory + popd && # Restore original work directory + echo "dxh_py full path: $PWD/$ZIP") + +Step 6: Run dxh_py +------------------------ +Set your work directory to whatever directory you would like to run dxh_py at. Use dxh_py full path and run the following command: + +.. code-block:: bash + + $ dxh_py + +You can expect similar output: + +.. code-block:: bash + + $ dxh_py /Users/admin/dxh_py-website-simple/dxh_py.zip + project_name [dxh_py Website Simple]: Test web + project_slug [test_web]: + author [Anonymous]: dxh_py Developer + +Resulting directory should be inside your work directory with a name that matches `project_slug` you defined. Inside that directory there should be `index.html` with generated source: + +.. code-block:: html + + + + + + Test web + + + +

Test web

+

by dxh_py Developer

+ + diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..a4007d7 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,130 @@ +===== +Usage +===== + +Grab a dxh_py template +---------------------------- + +First, clone a dxh_py project template:: + + $ git clone https://github.com/devxhub/dxh_py.git + +Make your changes +----------------- + +Modify the variables defined in `dxh_py.json`. + +Open up the skeleton project. If you need to change it around a bit, do so. + +You probably also want to create a repo, name it differently, and push it as +your own new dxh_py project template, for handy future use. + +Generate your project +--------------------- + +Then generate your project from the project template:: + + $ dxh_py dxh_py/ + +The only argument is the input directory. (The output directory is generated +by rendering that, and it can't be the same as the input directory.) + +.. note:: see :ref:`command_line_options` for extra command line arguments + +Try it out! + + + +Works directly with git and hg (mercurial) repos too +------------------------------------------------------ + +To create a project from the dxh_py.git repo template:: + + $ dxh_py gh:devxhub/dxh_py + +dxh_py knows abbreviations for Github (``gh``), Bitbucket (``bb``), and +GitLab (``gl``) projects, but you can also give it the full URL to any +repository:: + + $ dxh_py https://github.com/devxhub/dxh_py.git + $ dxh_py git+ssh://git@github.com/devxhub/dxh_py.git + $ dxh_py hg+ssh://hg@bitbucket.org/audreyr/dxh_py + +You will be prompted to enter a bunch of project config values. (These are +defined in the project's `dxh_py.json`.) + +Then, dxh_py will generate a project from the template, using the values +that you entered. It will be placed in your current directory. + +And if you want to specify a branch you can do that with:: + + $ dxh_py https://github.com/devxhub/dxh_py.git --checkout develop + +Works with private repos +------------------------ + +If you want to work with repos that are not hosted in github or bitbucket you can indicate explicitly the +type of repo that you want to use prepending `hg+` or `git+` to repo url:: + + $ dxh_py hg+https://example.com/repo + +In addition, one can provide a path to the dxh_py stored +on a local server:: + + $ dxh_py file://server/folder/project.git + +Works with Zip files +-------------------- + +You can also distribute dxh_py templates as Zip files. To use a Zip file +template, point dxh_py at a Zip file on your local machine:: + + $ dxh_py /path/to/template.zip + +Or, if the Zip file is online:: + + $ dxh_py https://example.com/path/to/template.zip + +If the template has already been downloaded, or a template with the same name +has already been downloaded, you will be prompted to delete the existing +template before proceeding. + +The Zip file contents should be the same as a git/hg repository for a template - +that is, the zipfile should unpack into a top level directory that contains the +name of the template. The name of the zipfile doesn't have to match the name of +the template - for example, you can label a zipfile with a version number, but +omit the version number from the directory inside the Zip file. + +If you want to see an example Zipfile, find any dxh_py repository on Github +and download that repository as a zip file - Github repository downloads are in +a valid format for dxh_py. + +Password-protected Zip files +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If your repository Zip file is password protected, dxh_py will prompt you +for that password whenever the template is used. + +Alternatively, if you want to use a password-protected Zip file in an +automated environment, you can export the `dxh_py_REPO_PASSWORD` +environment variable; the value of that environment variable will be used +whenever a password is required. + +Keeping your dxh_pys organized +------------------------------------ + +As of the dxh_py 0.7.0 release: + +* Whenever you generate a project with a dxh_py, the resulting project + is output to your current directory. + +* Your cloned dxh_pys are stored by default in your `~/.dxh_pys/` + directory (or Windows equivalent). The location is configurable: see + :doc:`advanced/user_config` for details. + +Pre-0.7.0, this is how it worked: + +* Whenever you generate a project with a dxh_py, the resulting project + is output to your current directory. + +* Cloned dxh_pys were not saved locally. diff --git a/dxh_py/VERSION.txt b/dxh_py/VERSION.txt new file mode 100644 index 0000000..6812f81 --- /dev/null +++ b/dxh_py/VERSION.txt @@ -0,0 +1 @@ +0.0.3 \ No newline at end of file diff --git a/dxh_py/__init__.py b/dxh_py/__init__.py old mode 100755 new mode 100644 index e69de29..58edfba --- a/dxh_py/__init__.py +++ b/dxh_py/__init__.py @@ -0,0 +1,13 @@ +"""Main package for dxh_py.""" + +from pathlib import Path + + +def _get_version() -> str: + """Read VERSION.txt and return its contents.""" + path = Path(__file__).parent.resolve() + version_file = path / "VERSION.txt" + return version_file.read_text(encoding="utf-8").strip() + + +__version__ = _get_version() diff --git a/dxh_py/__main__.py b/dxh_py/__main__.py new file mode 100644 index 0000000..a5176ed --- /dev/null +++ b/dxh_py/__main__.py @@ -0,0 +1,6 @@ +"""Allow dxh_py to be executable through `python -m dxh_py`.""" + +from dxh_py.cli import main + +if __name__ == "__main__": + main(prog_name="dxh_py") diff --git a/dxh_py/cli.py b/dxh_py/cli.py old mode 100755 new mode 100644 index fe5da76..08d5277 --- a/dxh_py/cli.py +++ b/dxh_py/cli.py @@ -1,17 +1,257 @@ +"""Main `dxh_py` CLI.""" + +from __future__ import annotations + +import json +import os import sys -from cookiecutter.main import cookiecutter +from collections import OrderedDict +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterable + + from click import Context, Parameter + from typing_extensions import Literal + + +import click + +from dxh_py import __version__ +from dxh_py.config import get_user_config +from dxh_py.exceptions import ( + ContextDecodingException, + EmptyDirNameException, + FailedHookException, + InvalidModeException, + InvalidZipRepository, + OutputDirExistsException, + RepositoryCloneFailed, + RepositoryNotFound, + UndefinedVariableInTemplate, + UnknownExtension, +) +from dxh_py.log import configure_logger +from dxh_py.main import dxh_py + + +def version_msg() -> str: + """Return the dxh_py version, location and Python powering it.""" + python_version = sys.version + location = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return f"dxh_py {__version__} from {location} (Python {python_version})" + + +def validate_extra_context( + _ctx: Context, _param: Parameter, value: Iterable[str] +) -> OrderedDict[str, str] | None: + """Validate extra context.""" + for string in value: + if '=' not in string: + raise click.BadParameter( + f"EXTRA_CONTEXT should contain items of the form key=value; " + f"'{string}' doesn't match that form" + ) + + # Convert tuple -- e.g.: ('program_name=foobar', 'startsecs=66') + # to dict -- e.g.: {'program_name': 'foobar', 'startsecs': '66'} + return OrderedDict(s.split('=', 1) for s in value) or None -def main(): - if len(sys.argv) != 2: - print("Usage: dxh_py ") - sys.exit(1) - template_url = sys.argv[1] +def list_installed_templates( + default_config: bool | dict[str, Any], passed_config_file: str | None +) -> None: + """List installed (locally cloned) templates. Use dxh_py --list-installed.""" + config = get_user_config(passed_config_file, default_config) + dxh_py_folder: str = config['dxh_pys_dir'] + if not os.path.exists(dxh_py_folder): + click.echo( + f"Error: Cannot list installed templates. " + f"Folder does not exist: {dxh_py_folder}" + ) + sys.exit(-1) + + template_names = [ + folder + for folder in os.listdir(dxh_py_folder) + if os.path.exists( + os.path.join(dxh_py_folder, folder, 'dxh_py.json') + ) + ] + click.echo(f'{len(template_names)} installed templates: ') + for name in template_names: + click.echo(f' * {name}') + + +@click.command(context_settings={"help_option_names": ['-h', '--help']}) +@click.version_option(__version__, '-V', '--version', message=version_msg()) +@click.argument('template', required=False) +@click.argument('extra_context', nargs=-1, callback=validate_extra_context) +@click.option( + '--no-input', + is_flag=True, + help='Do not prompt for parameters and only use dxh_py.json file content. ' + 'Defaults to deleting any cached resources and redownloading them. ' + 'Cannot be combined with the --replay flag.', +) +@click.option( + '-c', + '--checkout', + help='branch, tag or commit to checkout after git clone', +) +@click.option( + '--directory', + help='Directory within repo that holds dxh_py.json file ' + 'for advanced repositories with multi templates in it', +) +@click.option( + '-v', '--verbose', is_flag=True, help='Print debug information', default=False +) +@click.option( + '--replay', + is_flag=True, + help='Do not prompt for parameters and only use information entered previously. ' + 'Cannot be combined with the --no-input flag or with extra configuration passed.', +) +@click.option( + '--replay-file', + type=click.Path(), + default=None, + help='Use this file for replay instead of the default.', +) +@click.option( + '-f', + '--overwrite-if-exists', + is_flag=True, + help='Overwrite the contents of the output directory if it already exists', +) +@click.option( + '-s', + '--skip-if-file-exists', + is_flag=True, + help='Skip the files in the corresponding directories if they already exist', + default=False, +) +@click.option( + '-o', + '--output-dir', + default='.', + type=click.Path(), + help='Where to output the generated project dir into', +) +@click.option( + '--config-file', type=click.Path(), default=None, help='User configuration file' +) +@click.option( + '--default-config', + is_flag=True, + help='Do not load a config file. Use the defaults instead', +) +@click.option( + '--debug-file', + type=click.Path(), + default=None, + help='File to be used as a stream for DEBUG logging', +) +@click.option( + '--accept-hooks', + type=click.Choice(['yes', 'ask', 'no']), + default='yes', + help='Accept pre/post hooks', +) +@click.option( + '-l', '--list-installed', is_flag=True, help='List currently installed templates.' +) +@click.option( + '--keep-project-on-failure', + is_flag=True, + help='Do not delete project folder on failure', +) +def main( + template: str, + extra_context: dict[str, Any], + no_input: bool, + checkout: str, + verbose: bool, + replay: bool | str, + overwrite_if_exists: bool, + output_dir: str, + config_file: str | None, + default_config: bool, + debug_file: str | None, + directory: str, + skip_if_file_exists: bool, + accept_hooks: Literal['yes', 'ask', 'no'], + replay_file: str | None, + list_installed: bool, + keep_project_on_failure: bool, +) -> None: + """Create a project from a dxh_py project template (TEMPLATE). + + dxh_py is free and open source software, developed and managed by + volunteers. If you would like to help out or fund the project, please get + in touch at https://github.com/dxh_py/dxh_py. + """ + # Commands that should work without arguments + if list_installed: + list_installed_templates(default_config, config_file) + sys.exit(0) + + # Raising usage, after all commands that should work without args. + if not template or template.lower() == 'help': + click.echo(click.get_current_context().get_help()) + sys.exit(0) + + configure_logger(stream_level='DEBUG' if verbose else 'INFO', debug_file=debug_file) + + # If needed, prompt the user to ask whether or not they want to execute + # the pre/post hooks. + if accept_hooks == "ask": + _accept_hooks = click.confirm("Do you want to execute hooks?") + else: + _accept_hooks = accept_hooks == "yes" + + if replay_file: + replay = replay_file + try: - cookiecutter(template_url) - except Exception as e: - print(f"An error occurred: {e}") + dxh_py( + template, + checkout, + no_input, + extra_context=extra_context, + replay=replay, + overwrite_if_exists=overwrite_if_exists, + output_dir=output_dir, + config_file=config_file, + default_config=default_config, + password=os.environ.get('dxh_py_REPO_PASSWORD'), + directory=directory, + skip_if_file_exists=skip_if_file_exists, + accept_hooks=_accept_hooks, + keep_project_on_failure=keep_project_on_failure, + ) + except ( + ContextDecodingException, + OutputDirExistsException, + EmptyDirNameException, + InvalidModeException, + FailedHookException, + UnknownExtension, + InvalidZipRepository, + RepositoryNotFound, + RepositoryCloneFailed, + ) as e: + click.echo(e) sys.exit(1) + except UndefinedVariableInTemplate as undefined_err: + click.echo(f'{undefined_err.message}') + click.echo(f'Error message: {undefined_err.error.message}') + + context_str = json.dumps(undefined_err.context, indent=4, sort_keys=True) + click.echo(f'Context: {context_str}') + sys.exit(1) + if __name__ == "__main__": main() diff --git a/dxh_py/config.py b/dxh_py/config.py new file mode 100644 index 0000000..bb84a0c --- /dev/null +++ b/dxh_py/config.py @@ -0,0 +1,143 @@ +"""Global configuration handling.""" + +from __future__ import annotations + +import collections +import copy +import logging +import os +from typing import TYPE_CHECKING, Any + +import yaml + +from dxh_py.exceptions import ConfigDoesNotExistException, InvalidConfiguration + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + +USER_CONFIG_PATH = os.path.expanduser('~/.dxh_pyrc') + +BUILTIN_ABBREVIATIONS = { + 'gh': 'https://github.com/{0}.git', + 'gl': 'https://gitlab.com/{0}.git', + 'bb': 'https://bitbucket.org/{0}', +} + +DEFAULT_CONFIG = { + 'dxh_pys_dir': os.path.expanduser('~/.dxh_pys/'), + 'replay_dir': os.path.expanduser('~/.dxh_py_replay/'), + 'default_context': collections.OrderedDict([]), + 'abbreviations': BUILTIN_ABBREVIATIONS, +} + + +def _expand_path(path: str) -> str: + """Expand both environment variables and user home in the given path.""" + path = os.path.expandvars(path) + path = os.path.expanduser(path) + return path + + +def merge_configs(default: dict[str, Any], overwrite: dict[str, Any]) -> dict[str, Any]: + """Recursively update a dict with the key/value pair of another. + + Dict values that are dictionaries themselves will be updated, whilst + preserving existing keys. + """ + new_config = copy.deepcopy(default) + + for k, v in overwrite.items(): + # Make sure to preserve existing items in + # nested dicts, for example `abbreviations` + if isinstance(v, dict): + new_config[k] = merge_configs(default.get(k, {}), v) + else: + new_config[k] = v + + return new_config + + +def get_config(config_path: Path | str) -> dict[str, Any]: + """Retrieve the config from the specified path, returning a config dict.""" + if not os.path.exists(config_path): + raise ConfigDoesNotExistException(f'Config file {config_path} does not exist.') + + logger.debug('config_path is %s', config_path) + with open(config_path, encoding='utf-8') as file_handle: + try: + yaml_dict = yaml.safe_load(file_handle) or {} + except yaml.YAMLError as e: + raise InvalidConfiguration( + f'Unable to parse YAML file {config_path}.' + ) from e + if not isinstance(yaml_dict, dict): + raise InvalidConfiguration( + f'Top-level element of YAML file {config_path} should be an object.' + ) + + config_dict = merge_configs(DEFAULT_CONFIG, yaml_dict) + + raw_replay_dir = config_dict['replay_dir'] + config_dict['replay_dir'] = _expand_path(raw_replay_dir) + + raw_cookies_dir = config_dict['dxh_pys_dir'] + config_dict['dxh_pys_dir'] = _expand_path(raw_cookies_dir) + + return config_dict + + +def get_user_config( + config_file: str | None = None, + default_config: bool | dict[str, Any] = False, +) -> dict[str, Any]: + """Return the user config as a dict. + + If ``default_config`` is True, ignore ``config_file`` and return default + values for the config parameters. + + If ``default_config`` is a dict, merge values with default values and return them + for the config parameters. + + If a path to a ``config_file`` is given, that is different from the default + location, load the user config from that. + + Otherwise look up the config file path in the ``dxh_py_CONFIG`` + environment variable. If set, load the config from this path. This will + raise an error if the specified path is not valid. + + If the environment variable is not set, try the default config file path + before falling back to the default config values. + """ + # Do NOT load a config. Merge provided values with defaults and return them instead + if default_config and isinstance(default_config, dict): + return merge_configs(DEFAULT_CONFIG, default_config) + + # Do NOT load a config. Return defaults instead. + if default_config: + logger.debug("Force ignoring user config with default_config switch.") + return copy.copy(DEFAULT_CONFIG) + + # Load the given config file + if config_file and config_file is not USER_CONFIG_PATH: + logger.debug("Loading custom config from %s.", config_file) + return get_config(config_file) + + try: + # Does the user set up a config environment variable? + env_config_file = os.environ['dxh_py_CONFIG'] + except KeyError: + # Load an optional user config if it exists + # otherwise return the defaults + if os.path.exists(USER_CONFIG_PATH): + logger.debug("Loading config from %s.", USER_CONFIG_PATH) + return get_config(USER_CONFIG_PATH) + else: + logger.debug("User config not found. Loading default config.") + return copy.copy(DEFAULT_CONFIG) + else: + # There is a config environment variable. Try to load it. + # Do not check for existence, so invalid file paths raise an error. + logger.debug("User config not found or not specified. Loading default config.") + return get_config(env_config_file) diff --git a/dxh_py/environment.py b/dxh_py/environment.py new file mode 100644 index 0000000..8270d84 --- /dev/null +++ b/dxh_py/environment.py @@ -0,0 +1,70 @@ +"""Jinja2 environment and extensions loading.""" + +from __future__ import annotations + +from typing import Any + +from jinja2 import Environment, StrictUndefined + +from dxh_py.exceptions import UnknownExtension + + +class ExtensionLoaderMixin: + """Mixin providing sane loading of extensions specified in a given context. + + The context is being extracted from the keyword arguments before calling + the next parent class in line of the child. + """ + + def __init__(self, *, context: dict[str, Any] | None = None, **kwargs: Any) -> None: + """Initialize the Jinja2 Environment object while loading extensions. + + Does the following: + + 1. Establishes default_extensions (currently just a Time feature) + 2. Reads extensions set in the dxh_py.json _extensions key. + 3. Attempts to load the extensions. Provides useful error if fails. + """ + context = context or {} + + default_extensions = [ + 'dxh_py.extensions.JsonifyExtension', + 'dxh_py.extensions.RandomStringExtension', + 'dxh_py.extensions.SlugifyExtension', + 'dxh_py.extensions.TimeExtension', + 'dxh_py.extensions.UUIDExtension', + ] + extensions = default_extensions + self._read_extensions(context) + + try: + super().__init__(extensions=extensions, **kwargs) # type: ignore[call-arg] + except ImportError as err: + raise UnknownExtension(f'Unable to load extension: {err}') from err + + def _read_extensions(self, context: dict[str, Any]) -> list[str]: + """Return list of extensions as str to be passed on to the Jinja2 env. + + If context does not contain the relevant info, return an empty + list instead. + """ + try: + extensions = context['dxh_py']['_extensions'] + except KeyError: + return [] + else: + return [str(ext) for ext in extensions] + + +class StrictEnvironment(ExtensionLoaderMixin, Environment): + """Create strict Jinja2 environment. + + Jinja2 environment will raise error on undefined variable in template- + rendering context. + """ + + def __init__(self, **kwargs: Any) -> None: + """Set the standard dxh_py StrictEnvironment. + + Also loading extensions defined in dxh_py.json's _extensions key. + """ + super().__init__(undefined=StrictUndefined, **kwargs) diff --git a/dxh_py/exceptions.py b/dxh_py/exceptions.py new file mode 100644 index 0000000..2175261 --- /dev/null +++ b/dxh_py/exceptions.py @@ -0,0 +1,180 @@ +"""All exceptions used in the dxh_py code base are defined here.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from jinja2 import TemplateError + + +class dxh_pyException(Exception): + """ + Base exception class. + + All dxh_py-specific exceptions should subclass this class. + """ + + +class NonTemplatedInputDirException(dxh_pyException): + """ + Exception for when a project's input dir is not templated. + + The name of the input directory should always contain a string that is + rendered to something else, so that input_dir != output_dir. + """ + + +class UnknownTemplateDirException(dxh_pyException): + """ + Exception for ambiguous project template directory. + + Raised when dxh_py cannot determine which directory is the project + template, e.g. more than one dir appears to be a template dir. + """ + + # unused locally + + +class MissingProjectDir(dxh_pyException): + """ + Exception for missing generated project directory. + + Raised during cleanup when remove_repo() can't find a generated project + directory inside of a repo. + """ + + # unused locally + + +class ConfigDoesNotExistException(dxh_pyException): + """ + Exception for missing config file. + + Raised when get_config() is passed a path to a config file, but no file + is found at that path. + """ + + +class InvalidConfiguration(dxh_pyException): + """ + Exception for invalid configuration file. + + Raised if the global configuration file is not valid YAML or is + badly constructed. + """ + + +class UnknownRepoType(dxh_pyException): + """ + Exception for unknown repo types. + + Raised if a repo's type cannot be determined. + """ + + +class VCSNotInstalled(dxh_pyException): + """ + Exception when version control is unavailable. + + Raised if the version control system (git or hg) is not installed. + """ + + +class ContextDecodingException(dxh_pyException): + """ + Exception for failed JSON decoding. + + Raised when a project's JSON context file can not be decoded. + """ + + +class OutputDirExistsException(dxh_pyException): + """ + Exception for existing output directory. + + Raised when the output directory of the project exists already. + """ + + +class EmptyDirNameException(dxh_pyException): + """ + Exception for a empty directory name. + + Raised when the directory name provided is empty. + """ + + +class InvalidModeException(dxh_pyException): + """ + Exception for incompatible modes. + + Raised when dxh_py is called with both `no_input==True` and + `replay==True` at the same time. + """ + + +class FailedHookException(dxh_pyException): + """ + Exception for hook failures. + + Raised when a hook script fails. + """ + + +class UndefinedVariableInTemplate(dxh_pyException): + """ + Exception for out-of-scope variables. + + Raised when a template uses a variable which is not defined in the + context. + """ + + def __init__( + self, message: str, error: TemplateError, context: dict[str, Any] + ) -> None: + """Exception for out-of-scope variables.""" + self.message = message + self.error = error + self.context = context + + def __str__(self) -> str: + """Text representation of UndefinedVariableInTemplate.""" + return ( + f"{self.message}. " + f"Error message: {self.error.message}. " + f"Context: {self.context}" + ) + + +class UnknownExtension(dxh_pyException): + """ + Exception for un-importable extension. + + Raised when an environment is unable to import a required extension. + """ + + +class RepositoryNotFound(dxh_pyException): + """ + Exception for missing repo. + + Raised when the specified dxh_py repository doesn't exist. + """ + + +class RepositoryCloneFailed(dxh_pyException): + """ + Exception for un-cloneable repo. + + Raised when a dxh_py template can't be cloned. + """ + + +class InvalidZipRepository(dxh_pyException): + """ + Exception for bad zip repo. + + Raised when the specified dxh_py repository isn't a valid + Zip archive. + """ diff --git a/dxh_py/extensions.py b/dxh_py/extensions.py new file mode 100644 index 0000000..ae1fd9e --- /dev/null +++ b/dxh_py/extensions.py @@ -0,0 +1,174 @@ +"""Jinja2 extensions.""" + +from __future__ import annotations + +import json +import string +import uuid +from secrets import choice +from typing import TYPE_CHECKING, Any, Iterable + +import arrow +from jinja2 import Environment, nodes +from jinja2.ext import Extension +from slugify import slugify as pyslugify +from slugify.slugify import DEFAULT_SEPARATOR + +if TYPE_CHECKING: + import re + + from jinja2.parser import Parser + + +class JsonifyExtension(Extension): + """Jinja2 extension to convert a Python object to JSON.""" + + def __init__(self, environment: Environment) -> None: + """Initialize the extension with the given environment.""" + super().__init__(environment) + + def jsonify(obj: Any, indent: int = 4) -> str: + return json.dumps(obj, sort_keys=True, indent=indent) + + environment.filters['jsonify'] = jsonify + + +class RandomStringExtension(Extension): + """Jinja2 extension to create a random string.""" + + def __init__(self, environment: Environment) -> None: + """Jinja2 Extension Constructor.""" + super().__init__(environment) + + def random_ascii_string(length: int, punctuation: bool = False) -> str: + if punctuation: + corpus = f'{string.ascii_letters}{string.punctuation}' + else: + corpus = string.ascii_letters + return "".join(choice(corpus) for _ in range(length)) + + environment.globals.update(random_ascii_string=random_ascii_string) + + +class SlugifyExtension(Extension): + """Jinja2 Extension to slugify string.""" + + def __init__(self, environment: Environment) -> None: + """Jinja2 Extension constructor.""" + super().__init__(environment) + + def slugify( + value: str, + entities: bool = True, + decimal: bool = True, + hexadecimal: bool = True, + max_length: int = 0, + word_boundary: bool = False, + separator: str = DEFAULT_SEPARATOR, + save_order: bool = False, + stopwords: Iterable[str] = (), + regex_pattern: re.Pattern[str] | str | None = None, + lowercase: bool = True, + replacements: Iterable[Iterable[str]] = (), + allow_unicode: bool = False, + ) -> str: + """Slugifies the value.""" + return pyslugify( + value, + entities, + decimal, + hexadecimal, + max_length, + word_boundary, + separator, + save_order, + stopwords, + regex_pattern, + lowercase, + replacements, + allow_unicode, + ) + + environment.filters['slugify'] = slugify + + +class UUIDExtension(Extension): + """Jinja2 Extension to generate uuid4 string.""" + + def __init__(self, environment: Environment) -> None: + """Jinja2 Extension constructor.""" + super().__init__(environment) + + def uuid4() -> str: + """Generate UUID4.""" + return str(uuid.uuid4()) + + environment.globals.update(uuid4=uuid4) + + +class TimeExtension(Extension): + """Jinja2 Extension for dates and times.""" + + tags = {'now'} + + def __init__(self, environment: Environment) -> None: + """Jinja2 Extension constructor.""" + super().__init__(environment) + + environment.extend(datetime_format='%Y-%m-%d') + + def _datetime( + self, + timezone: str, + operator: str, + offset: str, + datetime_format: str | None, + ) -> str: + d = arrow.now(timezone) + + # parse shift params from offset and include operator + shift_params = {} + for param in offset.split(','): + interval, value = param.split('=') + shift_params[interval.strip()] = float(operator + value.strip()) + d = d.shift(**shift_params) + + if datetime_format is None: + datetime_format = self.environment.datetime_format # type: ignore[attr-defined] + return d.strftime(datetime_format) + + def _now(self, timezone: str, datetime_format: str | None) -> str: + if datetime_format is None: + datetime_format = self.environment.datetime_format # type: ignore[attr-defined] + return arrow.now(timezone).strftime(datetime_format) + + def parse(self, parser: Parser) -> nodes.Output: + """Parse datetime template and add datetime value.""" + lineno = next(parser.stream).lineno + + node = parser.parse_expression() + + if parser.stream.skip_if('comma'): + datetime_format = parser.parse_expression() + else: + datetime_format = nodes.Const(None) + + if isinstance(node, nodes.Add): + call_method = self.call_method( + '_datetime', + [node.left, nodes.Const('+'), node.right, datetime_format], + lineno=lineno, + ) + elif isinstance(node, nodes.Sub): + call_method = self.call_method( + '_datetime', + [node.left, nodes.Const('-'), node.right, datetime_format], + lineno=lineno, + ) + else: + call_method = self.call_method( + '_now', + [node, datetime_format], + lineno=lineno, + ) + return nodes.Output([call_method], lineno=lineno) diff --git a/dxh_py/find.py b/dxh_py/find.py new file mode 100644 index 0000000..61f3085 --- /dev/null +++ b/dxh_py/find.py @@ -0,0 +1,38 @@ +"""Functions for finding dxh_py templates and other components.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from dxh_py.exceptions import NonTemplatedInputDirException + +if TYPE_CHECKING: + from jinja2 import Environment + +logger = logging.getLogger(__name__) + + +def find_template(repo_dir: Path | str, env: Environment) -> Path: + """Determine which child directory of ``repo_dir`` is the project template. + + :param repo_dir: Local directory of newly cloned repo. + :return: Relative path to project template. + """ + logger.debug('Searching %s for the project template.', repo_dir) + + for str_path in os.listdir(repo_dir): + if ( + 'dxh_py' in str_path + and env.variable_start_string in str_path + and env.variable_end_string in str_path + ): + project_template = Path(repo_dir, str_path) + break + else: + raise NonTemplatedInputDirException + + logger.debug('The project template appears to be %s', project_template) + return project_template diff --git a/dxh_py/generate.py b/dxh_py/generate.py new file mode 100644 index 0000000..9936d12 --- /dev/null +++ b/dxh_py/generate.py @@ -0,0 +1,463 @@ +"""Functions for generating a project from a project template.""" + +from __future__ import annotations + +import fnmatch +import json +import logging +import os +import shutil +import warnings +from collections import OrderedDict +from pathlib import Path +from typing import Any + +from binaryornot.check import is_binary +from jinja2 import Environment, FileSystemLoader +from jinja2.exceptions import TemplateSyntaxError, UndefinedError +from rich.prompt import InvalidResponse + +from dxh_py.exceptions import ( + ContextDecodingException, + EmptyDirNameException, + OutputDirExistsException, + UndefinedVariableInTemplate, +) +from dxh_py.find import find_template +from dxh_py.hooks import run_hook_from_repo_dir +from dxh_py.prompt import YesNoPrompt +from dxh_py.utils import ( + create_env_with_context, + make_sure_path_exists, + rmtree, + work_in, +) + +logger = logging.getLogger(__name__) + + +def is_copy_only_path(path: str, context: dict[str, Any]) -> bool: + """Check whether the given `path` should only be copied and not rendered. + + Returns True if `path` matches a pattern in the given `context` dict, + otherwise False. + + :param path: A file-system path referring to a file or dir that + should be rendered or just copied. + :param context: dxh_py context. + """ + try: + for dont_render in context['dxh_py']['_copy_without_render']: + if fnmatch.fnmatch(path, dont_render): + return True + except KeyError: + return False + + return False + + +def apply_overwrites_to_context( + context: dict[str, Any], + overwrite_context: dict[str, Any], + *, + in_dictionary_variable: bool = False, +) -> None: + """Modify the given context in place based on the overwrite_context.""" + for variable, overwrite in overwrite_context.items(): + if variable not in context: + if not in_dictionary_variable: + # We are dealing with a new variable on first level, ignore + continue + # We are dealing with a new dictionary variable in a deeper level + context[variable] = overwrite + + context_value = context[variable] + if isinstance(context_value, list): + if in_dictionary_variable: + context[variable] = overwrite + continue + if isinstance(overwrite, list): + # We are dealing with a multichoice variable + # Let's confirm all choices are valid for the given context + if set(overwrite).issubset(set(context_value)): + context[variable] = overwrite + else: + raise ValueError( + f"{overwrite} provided for multi-choice variable " + f"{variable}, but valid choices are {context_value}" + ) + else: + # We are dealing with a choice variable + if overwrite in context_value: + # This overwrite is actually valid for the given context + # Let's set it as default (by definition first item in list) + # see ``dxh_py.prompt.prompt_choice_for_config`` + context_value.remove(overwrite) + context_value.insert(0, overwrite) + else: + raise ValueError( + f"{overwrite} provided for choice variable " + f"{variable}, but the choices are {context_value}." + ) + elif isinstance(context_value, dict) and isinstance(overwrite, dict): + # Partially overwrite some keys in original dict + apply_overwrites_to_context( + context_value, overwrite, in_dictionary_variable=True + ) + context[variable] = context_value + elif isinstance(context_value, bool) and isinstance(overwrite, str): + # We are dealing with a boolean variable + # Convert overwrite to its boolean counterpart + try: + context[variable] = YesNoPrompt().process_response(overwrite) + except InvalidResponse as err: + raise ValueError( + f"{overwrite} provided for variable " + f"{variable} could not be converted to a boolean." + ) from err + else: + # Simply overwrite the value for this variable + context[variable] = overwrite + + +def generate_context( + context_file: str = 'dxh_py.json', + default_context: dict[str, Any] | None = None, + extra_context: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Generate the context for a dxh_py project template. + + Loads the JSON file as a Python object, with key being the JSON filename. + + :param context_file: JSON file containing key/value pairs for populating + the dxh_py's variables. + :param default_context: Dictionary containing config to take into account. + :param extra_context: Dictionary containing configuration overrides + """ + context = OrderedDict([]) + + try: + with open(context_file, encoding='utf-8') as file_handle: + obj = json.load(file_handle, object_pairs_hook=OrderedDict) + except ValueError as e: + # JSON decoding error. Let's throw a new exception that is more + # friendly for the developer or user. + full_fpath = os.path.abspath(context_file) + json_exc_message = str(e) + our_exc_message = ( + f"JSON decoding error while loading '{full_fpath}'. " + f"Decoding error details: '{json_exc_message}'" + ) + raise ContextDecodingException(our_exc_message) from e + + # Add the Python object to the context dictionary + file_name = os.path.split(context_file)[1] + file_stem = file_name.split('.')[0] + context[file_stem] = obj + + # Overwrite context variable defaults with the default context from the + # user's global config, if available + if default_context: + try: + apply_overwrites_to_context(obj, default_context) + except ValueError as error: + warnings.warn(f"Invalid default received: {error}") + if extra_context: + apply_overwrites_to_context(obj, extra_context) + + logger.debug('Context generated is %s', context) + return context + + +def generate_file( + project_dir: str, + infile: str, + context: dict[str, Any], + env: Environment, + skip_if_file_exists: bool = False, +) -> None: + """Render filename of infile as name of outfile, handle infile correctly. + + Dealing with infile appropriately: + + a. If infile is a binary file, copy it over without rendering. + b. If infile is a text file, render its contents and write the + rendered infile to outfile. + + Precondition: + + When calling `generate_file()`, the root template dir must be the + current working directory. Using `utils.work_in()` is the recommended + way to perform this directory change. + + :param project_dir: Absolute path to the resulting generated project. + :param infile: Input file to generate the file from. Relative to the root + template dir. + :param context: Dict for populating the dxh_py's variables. + :param env: Jinja2 template execution environment. + """ + logger.debug('Processing file %s', infile) + + # Render the path to the output file (not including the root project dir) + outfile_tmpl = env.from_string(infile) + + outfile = os.path.join(project_dir, outfile_tmpl.render(**context)) + file_name_is_empty = os.path.isdir(outfile) + if file_name_is_empty: + logger.debug('The resulting file name is empty: %s', outfile) + return + + if skip_if_file_exists and os.path.exists(outfile): + logger.debug('The resulting file already exists: %s', outfile) + return + + logger.debug('Created file at %s', outfile) + + # Just copy over binary files. Don't render. + logger.debug("Check %s to see if it's a binary", infile) + if is_binary(infile): + logger.debug('Copying binary %s to %s without rendering', infile, outfile) + shutil.copyfile(infile, outfile) + shutil.copymode(infile, outfile) + return + + # Force fwd slashes on Windows for get_template + # This is a by-design Jinja issue + infile_fwd_slashes = infile.replace(os.path.sep, '/') + + # Render the file + try: + tmpl = env.get_template(infile_fwd_slashes) + except TemplateSyntaxError as exception: + # Disable translated so that printed exception contains verbose + # information about syntax error location + exception.translated = False + raise + rendered_file = tmpl.render(**context) + + if context['dxh_py'].get('_new_lines', False): + # Use `_new_lines` from context, if configured. + newline = context['dxh_py']['_new_lines'] + logger.debug('Using configured newline character %s', repr(newline)) + else: + # Detect original file newline to output the rendered file. + # Note that newlines can be a tuple if file contains mixed line endings. + # In this case, we pick the first line ending we detected. + with open(infile, encoding='utf-8') as rd: + rd.readline() # Read only the first line to load a 'newlines' value. + newline = rd.newlines[0] if isinstance(rd.newlines, tuple) else rd.newlines + logger.debug('Using detected newline character %s', repr(newline)) + + logger.debug('Writing contents to file %s', outfile) + + with open(outfile, 'w', encoding='utf-8', newline=newline) as fh: + fh.write(rendered_file) + + # Apply file permissions to output file + shutil.copymode(infile, outfile) + + +def render_and_create_dir( + dirname: str, + context: dict[str, Any], + output_dir: Path | str, + environment: Environment, + overwrite_if_exists: bool = False, +) -> tuple[Path, bool]: + """Render name of a directory, create the directory, return its path.""" + if not dirname or dirname == "": + msg = 'Error: directory name is empty' + raise EmptyDirNameException(msg) + + name_tmpl = environment.from_string(dirname) + rendered_dirname = name_tmpl.render(**context) + + dir_to_create = Path(output_dir, rendered_dirname) + + logger.debug( + 'Rendered dir %s must exist in output_dir %s', dir_to_create, output_dir + ) + + output_dir_exists = dir_to_create.exists() + + if output_dir_exists: + if overwrite_if_exists: + logger.debug( + 'Output directory %s already exists, overwriting it', dir_to_create + ) + else: + msg = f'Error: "{dir_to_create}" directory already exists' + raise OutputDirExistsException(msg) + else: + make_sure_path_exists(dir_to_create) + + return dir_to_create, not output_dir_exists + + +def _run_hook_from_repo_dir( + repo_dir: str, + hook_name: str, + project_dir: Path | str, + context: dict[str, Any], + delete_project_on_failure: bool, +) -> None: + """Run hook from repo directory, clean project directory if hook fails. + + :param repo_dir: Project template input directory. + :param hook_name: The hook to execute. + :param project_dir: The directory to execute the script from. + :param context: dxh_py project context. + :param delete_project_on_failure: Delete the project directory on hook + failure? + """ + warnings.warn( + "The '_run_hook_from_repo_dir' function is deprecated, " + "use 'dxh_py.hooks.run_hook_from_repo_dir' instead", + DeprecationWarning, + 2, + ) + run_hook_from_repo_dir( + repo_dir, hook_name, project_dir, context, delete_project_on_failure + ) + + +def generate_files( + repo_dir: Path | str, + context: dict[str, Any] | None = None, + output_dir: Path | str = '.', + overwrite_if_exists: bool = False, + skip_if_file_exists: bool = False, + accept_hooks: bool = True, + keep_project_on_failure: bool = False, +) -> str: + """Render the templates and saves them to files. + + :param repo_dir: Project template input directory. + :param context: Dict for populating the template's variables. + :param output_dir: Where to output the generated project dir into. + :param overwrite_if_exists: Overwrite the contents of the output directory + if it exists. + :param skip_if_file_exists: Skip the files in the corresponding directories + if they already exist + :param accept_hooks: Accept pre and post hooks if set to `True`. + :param keep_project_on_failure: If `True` keep generated project directory even when + generation fails + """ + context = context or OrderedDict([]) + + env = create_env_with_context(context) + + template_dir = find_template(repo_dir, env) + logger.debug('Generating project from %s...', template_dir) + + unrendered_dir = os.path.split(template_dir)[1] + try: + project_dir: Path | str + project_dir, output_directory_created = render_and_create_dir( + unrendered_dir, context, output_dir, env, overwrite_if_exists + ) + except UndefinedError as err: + msg = f"Unable to create project directory '{unrendered_dir}'" + raise UndefinedVariableInTemplate(msg, err, context) from err + + # We want the Jinja path and the OS paths to match. Consequently, we'll: + # + CD to the template folder + # + Set Jinja's path to '.' + # + # In order to build our files to the correct folder(s), we'll use an + # absolute path for the target folder (project_dir) + + project_dir = os.path.abspath(project_dir) + logger.debug('Project directory is %s', project_dir) + + # if we created the output directory, then it's ok to remove it + # if rendering fails + delete_project_on_failure = output_directory_created and not keep_project_on_failure + + if accept_hooks: + run_hook_from_repo_dir( + repo_dir, 'pre_gen_project', project_dir, context, delete_project_on_failure + ) + + with work_in(template_dir): + env.loader = FileSystemLoader(['.', '../templates']) + + for root, dirs, files in os.walk('.'): + # We must separate the two types of dirs into different lists. + # The reason is that we don't want ``os.walk`` to go through the + # unrendered directories, since they will just be copied. + copy_dirs = [] + render_dirs = [] + + for d in dirs: + d_ = os.path.normpath(os.path.join(root, d)) + # We check the full path, because that's how it can be + # specified in the ``_copy_without_render`` setting, but + # we store just the dir name + if is_copy_only_path(d_, context): + logger.debug('Found copy only path %s', d) + copy_dirs.append(d) + else: + render_dirs.append(d) + + for copy_dir in copy_dirs: + indir = os.path.normpath(os.path.join(root, copy_dir)) + outdir = os.path.normpath(os.path.join(project_dir, indir)) + outdir = env.from_string(outdir).render(**context) + logger.debug('Copying dir %s to %s without rendering', indir, outdir) + + # The outdir is not the root dir, it is the dir which marked as copy + # only in the config file. If the program hits this line, which means + # the overwrite_if_exists = True, and root dir exists + if os.path.isdir(outdir): + shutil.rmtree(outdir) + shutil.copytree(indir, outdir) + + # We mutate ``dirs``, because we only want to go through these dirs + # recursively + dirs[:] = render_dirs + for d in dirs: + unrendered_dir = os.path.join(project_dir, root, d) + try: + render_and_create_dir( + unrendered_dir, context, output_dir, env, overwrite_if_exists + ) + except UndefinedError as err: + if delete_project_on_failure: + rmtree(project_dir) + _dir = os.path.relpath(unrendered_dir, output_dir) + msg = f"Unable to create directory '{_dir}'" + raise UndefinedVariableInTemplate(msg, err, context) from err + + for f in files: + infile = os.path.normpath(os.path.join(root, f)) + if is_copy_only_path(infile, context): + outfile_tmpl = env.from_string(infile) + outfile_rendered = outfile_tmpl.render(**context) + outfile = os.path.join(project_dir, outfile_rendered) + logger.debug( + 'Copying file %s to %s without rendering', infile, outfile + ) + shutil.copyfile(infile, outfile) + shutil.copymode(infile, outfile) + continue + try: + generate_file( + project_dir, infile, context, env, skip_if_file_exists + ) + except UndefinedError as err: + if delete_project_on_failure: + rmtree(project_dir) + msg = f"Unable to create file '{infile}'" + raise UndefinedVariableInTemplate(msg, err, context) from err + + if accept_hooks: + run_hook_from_repo_dir( + repo_dir, + 'post_gen_project', + project_dir, + context, + delete_project_on_failure, + ) + + return project_dir diff --git a/dxh_py/hooks.py b/dxh_py/hooks.py new file mode 100644 index 0000000..9921a04 --- /dev/null +++ b/dxh_py/hooks.py @@ -0,0 +1,204 @@ +"""Functions for discovering and executing various dxh_py hooks.""" + +from __future__ import annotations + +import errno +import logging +import os +import subprocess +import sys +import tempfile +from typing import TYPE_CHECKING, Any + +from jinja2.exceptions import UndefinedError + +from dxh_py import utils +from dxh_py.exceptions import FailedHookException +from dxh_py.utils import ( + create_env_with_context, + create_tmp_repo_dir, + rmtree, + work_in, +) + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + +_HOOKS = [ + 'pre_prompt', + 'pre_gen_project', + 'post_gen_project', +] +EXIT_SUCCESS = 0 + + +def valid_hook(hook_file: str, hook_name: str) -> bool: + """Determine if a hook file is valid. + + :param hook_file: The hook file to consider for validity + :param hook_name: The hook to find + :return: The hook file validity + """ + filename = os.path.basename(hook_file) + basename = os.path.splitext(filename)[0] + matching_hook = basename == hook_name + supported_hook = basename in _HOOKS + backup_file = filename.endswith('~') + + return matching_hook and supported_hook and not backup_file + + +def find_hook(hook_name: str, hooks_dir: str = 'hooks') -> list[str] | None: + """Return a dict of all hook scripts provided. + + Must be called with the project template as the current working directory. + Dict's key will be the hook/script's name, without extension, while values + will be the absolute path to the script. Missing scripts will not be + included in the returned dict. + + :param hook_name: The hook to find + :param hooks_dir: The hook directory in the template + :return: The absolute path to the hook script or None + """ + logger.debug('hooks_dir is %s', os.path.abspath(hooks_dir)) + + if not os.path.isdir(hooks_dir): + logger.debug('No hooks/dir in template_dir') + return None + + scripts = [ + os.path.abspath(os.path.join(hooks_dir, hook_file)) + for hook_file in os.listdir(hooks_dir) + if valid_hook(hook_file, hook_name) + ] + + if len(scripts) == 0: + return None + return scripts + + +def run_script(script_path: str, cwd: Path | str = '.') -> None: + """Execute a script from a working directory. + + :param script_path: Absolute path to the script to run. + :param cwd: The directory to run the script from. + """ + run_thru_shell = sys.platform.startswith('win') + if script_path.endswith('.py'): + script_command = [sys.executable, script_path] + else: + script_command = [script_path] + + utils.make_executable(script_path) + + try: + proc = subprocess.Popen(script_command, shell=run_thru_shell, cwd=cwd) # nosec + exit_status = proc.wait() + if exit_status != EXIT_SUCCESS: + raise FailedHookException( + f'Hook script failed (exit status: {exit_status})' + ) + except OSError as err: + if err.errno == errno.ENOEXEC: + raise FailedHookException( + 'Hook script failed, might be an empty file or missing a shebang' + ) from err + raise FailedHookException(f'Hook script failed (error: {err})') from err + + +def run_script_with_context( + script_path: Path | str, cwd: Path | str, context: dict[str, Any] +) -> None: + """Execute a script after rendering it with Jinja. + + :param script_path: Absolute path to the script to run. + :param cwd: The directory to run the script from. + :param context: dxh_py project template context. + """ + _, extension = os.path.splitext(script_path) + + with open(script_path, encoding='utf-8') as file: + contents = file.read() + + with tempfile.NamedTemporaryFile(delete=False, mode='wb', suffix=extension) as temp: + env = create_env_with_context(context) + template = env.from_string(contents) + output = template.render(**context) + temp.write(output.encode('utf-8')) + + run_script(temp.name, cwd) + + +def run_hook(hook_name: str, project_dir: Path | str, context: dict[str, Any]) -> None: + """ + Try to find and execute a hook from the specified project directory. + + :param hook_name: The hook to execute. + :param project_dir: The directory to execute the script from. + :param context: dxh_py project context. + """ + scripts = find_hook(hook_name) + if not scripts: + logger.debug('No %s hook found', hook_name) + return + logger.debug('Running hook %s', hook_name) + for script in scripts: + run_script_with_context(script, project_dir, context) + + +def run_hook_from_repo_dir( + repo_dir: Path | str, + hook_name: str, + project_dir: Path | str, + context: dict[str, Any], + delete_project_on_failure: bool, +) -> None: + """Run hook from repo directory, clean project directory if hook fails. + + :param repo_dir: Project template input directory. + :param hook_name: The hook to execute. + :param project_dir: The directory to execute the script from. + :param context: dxh_py project context. + :param delete_project_on_failure: Delete the project directory on hook + failure? + """ + with work_in(repo_dir): + try: + run_hook(hook_name, project_dir, context) + except ( + FailedHookException, + UndefinedError, + ): + if delete_project_on_failure: + rmtree(project_dir) + logger.error( + "Stopping generation because %s hook " + "script didn't exit successfully", + hook_name, + ) + raise + + +def run_pre_prompt_hook(repo_dir: Path | str) -> Path | str: + """Run pre_prompt hook from repo directory. + + :param repo_dir: Project template input directory. + """ + # Check if we have a valid pre_prompt script + with work_in(repo_dir): + scripts = find_hook('pre_prompt') + if not scripts: + return repo_dir + + # Create a temporary directory + repo_dir = create_tmp_repo_dir(repo_dir) + with work_in(repo_dir): + scripts = find_hook('pre_prompt') or [] + for script in scripts: + try: + run_script(script, str(repo_dir)) + except FailedHookException as e: # noqa: PERF203 + raise FailedHookException('Pre-Prompt Hook script failed') from e + return repo_dir diff --git a/dxh_py/log.py b/dxh_py/log.py new file mode 100644 index 0000000..a0af973 --- /dev/null +++ b/dxh_py/log.py @@ -0,0 +1,56 @@ +"""Module for setting up logging.""" + +from __future__ import annotations + +import logging +import sys + +LOG_LEVELS = { + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL, +} + +LOG_FORMATS = { + 'DEBUG': '%(levelname)s %(name)s: %(message)s', + 'INFO': '%(levelname)s: %(message)s', +} + + +def configure_logger( + stream_level: str = 'DEBUG', debug_file: str | None = None +) -> logging.Logger: + """Configure logging for dxh_py. + + Set up logging to stdout with given level. If ``debug_file`` is given set + up logging to file with DEBUG level. + """ + # Set up 'dxh_py' logger + logger = logging.getLogger('dxh_py') + logger.setLevel(logging.DEBUG) + + # Remove all attached handlers, in case there was + # a logger with using the name 'dxh_py' + del logger.handlers[:] + + # Create a file handler if a log file is provided + if debug_file is not None: + debug_formatter = logging.Formatter(LOG_FORMATS['DEBUG']) + file_handler = logging.FileHandler(debug_file) + file_handler.setLevel(LOG_LEVELS['DEBUG']) + file_handler.setFormatter(debug_formatter) + logger.addHandler(file_handler) + + # Get settings based on the given stream_level + log_formatter = logging.Formatter(LOG_FORMATS[stream_level]) + log_level = LOG_LEVELS[stream_level] + + # Create a stream handler + stream_handler = logging.StreamHandler(stream=sys.stdout) + stream_handler.setLevel(log_level) + stream_handler.setFormatter(log_formatter) + logger.addHandler(stream_handler) + + return logger diff --git a/dxh_py/main.py b/dxh_py/main.py new file mode 100644 index 0000000..4c6c6c2 --- /dev/null +++ b/dxh_py/main.py @@ -0,0 +1,212 @@ +""" +Main entry point for the `dxh_py` command. + +The code in this module is also a good example of how to use dxh_py as a +library rather than a script. +""" + +from __future__ import annotations + +import logging +import os +import sys +from copy import copy +from pathlib import Path +from typing import Any + +from dxh_py.config import get_user_config +from dxh_py.exceptions import InvalidModeException +from dxh_py.generate import generate_context, generate_files +from dxh_py.hooks import run_pre_prompt_hook +from dxh_py.prompt import choose_nested_template, prompt_for_config +from dxh_py.replay import dump, load +from dxh_py.repository import determine_repo_dir +from dxh_py.utils import rmtree + +logger = logging.getLogger(__name__) + + +def dxh_py( + template: str, + checkout: str | None = None, + no_input: bool = False, + extra_context: dict[str, Any] | None = None, + replay: bool | str | None = None, + overwrite_if_exists: bool = False, + output_dir: str = '.', + config_file: str | None = None, + default_config: bool = False, + password: str | None = None, + directory: str | None = None, + skip_if_file_exists: bool = False, + accept_hooks: bool = True, + keep_project_on_failure: bool = False, +) -> str: + """ + Run dxh_py just as if using it from the command line. + + :param template: A directory containing a project template directory, + or a URL to a git repository. + :param checkout: The branch, tag or commit ID to checkout after clone. + :param no_input: Do not prompt for user input. + Use default values for template parameters taken from `dxh_py.json`, user + config and `extra_dict`. Force a refresh of cached resources. + :param extra_context: A dictionary of context that overrides default + and user configuration. + :param replay: Do not prompt for input, instead read from saved json. If + ``True`` read from the ``replay_dir``. + if it exists + :param overwrite_if_exists: Overwrite the contents of the output directory + if it exists. + :param output_dir: Where to output the generated project dir into. + :param config_file: User configuration file path. + :param default_config: Use default values rather than a config file. + :param password: The password to use when extracting the repository. + :param directory: Relative path to a dxh_py template in a repository. + :param skip_if_file_exists: Skip the files in the corresponding directories + if they already exist. + :param accept_hooks: Accept pre and post hooks if set to `True`. + :param keep_project_on_failure: If `True` keep generated project directory even when + generation fails + """ + if replay and ((no_input is not False) or (extra_context is not None)): + err_msg = ( + "You can not use both replay and no_input or extra_context " + "at the same time." + ) + raise InvalidModeException(err_msg) + + config_dict = get_user_config( + config_file=config_file, + default_config=default_config, + ) + base_repo_dir, cleanup_base_repo_dir = determine_repo_dir( + template=template, + abbreviations=config_dict['abbreviations'], + clone_to_dir=config_dict['dxh_pys_dir'], + checkout=checkout, + no_input=no_input, + password=password, + directory=directory, + ) + repo_dir, cleanup = base_repo_dir, cleanup_base_repo_dir + # Run pre_prompt hook + repo_dir = str(run_pre_prompt_hook(base_repo_dir)) if accept_hooks else repo_dir + # Always remove temporary dir if it was created + cleanup = repo_dir != base_repo_dir + + import_patch = _patch_import_path_for_repo(repo_dir) + template_name = os.path.basename(os.path.abspath(repo_dir)) + if replay: + with import_patch: + if isinstance(replay, bool): + context_from_replayfile = load(config_dict['replay_dir'], template_name) + else: + path, template_name = os.path.split(os.path.splitext(replay)[0]) + context_from_replayfile = load(path, template_name) + + context_file = os.path.join(repo_dir, 'dxh_py.json') + logger.debug('context_file is %s', context_file) + + if replay: + context = generate_context( + context_file=context_file, + default_context=config_dict['default_context'], + extra_context=None, + ) + logger.debug('replayfile context: %s', context_from_replayfile) + items_for_prompting = { + k: v + for k, v in context['dxh_py'].items() + if k not in context_from_replayfile['dxh_py'] + } + context_for_prompting = {} + context_for_prompting['dxh_py'] = items_for_prompting + context = context_from_replayfile + logger.debug('prompting context: %s', context_for_prompting) + else: + context = generate_context( + context_file=context_file, + default_context=config_dict['default_context'], + extra_context=extra_context, + ) + context_for_prompting = context + # preserve the original dxh_py options + # print(context['dxh_py']) + context['_dxh_py'] = { + k: v for k, v in context['dxh_py'].items() if not k.startswith("_") + } + + # prompt the user to manually configure at the command line. + # except when 'no-input' flag is set + + with import_patch: + if {"template", "templates"} & set(context["dxh_py"].keys()): + nested_template = choose_nested_template(context, repo_dir, no_input) + return dxh_py( + template=nested_template, + checkout=checkout, + no_input=no_input, + extra_context=extra_context, + replay=replay, + overwrite_if_exists=overwrite_if_exists, + output_dir=output_dir, + config_file=config_file, + default_config=default_config, + password=password, + directory=directory, + skip_if_file_exists=skip_if_file_exists, + accept_hooks=accept_hooks, + keep_project_on_failure=keep_project_on_failure, + ) + if context_for_prompting['dxh_py']: + context['dxh_py'].update( + prompt_for_config(context_for_prompting, no_input) + ) + + logger.debug('context is %s', context) + + # include template dir or url in the context dict + context['dxh_py']['_template'] = template + + # include output+dir in the context dict + context['dxh_py']['_output_dir'] = os.path.abspath(output_dir) + + # include repo dir or url in the context dict + context['dxh_py']['_repo_dir'] = f"{repo_dir}" + + # include checkout details in the context dict + context['dxh_py']['_checkout'] = checkout + + dump(config_dict['replay_dir'], template_name, context) + + # Create project from local context and project template. + with import_patch: + result = generate_files( + repo_dir=repo_dir, + context=context, + overwrite_if_exists=overwrite_if_exists, + skip_if_file_exists=skip_if_file_exists, + output_dir=output_dir, + accept_hooks=accept_hooks, + keep_project_on_failure=keep_project_on_failure, + ) + + # Cleanup (if required) + if cleanup: + rmtree(repo_dir) + if cleanup_base_repo_dir: + rmtree(base_repo_dir) + return result + + +class _patch_import_path_for_repo: # noqa: N801 + def __init__(self, repo_dir: Path | str) -> None: + self._repo_dir = f"{repo_dir}" if isinstance(repo_dir, Path) else repo_dir + + def __enter__(self) -> None: + self._path = copy(sys.path) + sys.path.append(self._repo_dir) + + def __exit__(self, type, value, traceback): # type: ignore[no-untyped-def] + sys.path = self._path diff --git a/dxh_py/prompt.py b/dxh_py/prompt.py new file mode 100644 index 0000000..b60f2fe --- /dev/null +++ b/dxh_py/prompt.py @@ -0,0 +1,437 @@ +"""Functions for prompting the user for project info.""" + +from __future__ import annotations + +import json +import os +import re +import sys +from collections import OrderedDict +from itertools import starmap +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Union + +from jinja2.exceptions import UndefinedError +from rich.prompt import Confirm, InvalidResponse, Prompt, PromptBase +from typing_extensions import TypeAlias + +from dxh_py.exceptions import UndefinedVariableInTemplate +from dxh_py.utils import create_env_with_context, rmtree + +if TYPE_CHECKING: + from jinja2 import Environment + + +def read_user_variable(var_name: str, default_value, prompts=None, prefix: str = ""): + """Prompt user for variable and return the entered value or given default. + + :param str var_name: Variable of the context to query the user + :param default_value: Value that will be returned if no input happens + """ + question = ( + prompts[var_name] + if prompts and var_name in prompts and prompts[var_name] + else var_name + ) + + while True: + variable = Prompt.ask(f"{prefix}{question}", default=default_value) + if variable is not None: + break + + return variable + + +class YesNoPrompt(Confirm): + """A prompt that returns a boolean for yes/no questions.""" + + yes_choices = ["1", "true", "t", "yes", "y", "on"] + no_choices = ["0", "false", "f", "no", "n", "off"] + + def process_response(self, value: str) -> bool: + """Convert choices to a bool.""" + value = value.strip().lower() + if value in self.yes_choices: + return True + elif value in self.no_choices: + return False + else: + raise InvalidResponse(self.validate_error_message) + + +def read_user_yes_no(var_name, default_value, prompts=None, prefix: str = ""): + """Prompt the user to reply with 'yes' or 'no' (or equivalent values). + + - These input values will be converted to ``True``: + "1", "true", "t", "yes", "y", "on" + - These input values will be converted to ``False``: + "0", "false", "f", "no", "n", "off" + + Actual parsing done by :func:`prompt`; Check this function codebase change in + case of unexpected behaviour. + + :param str question: Question to the user + :param default_value: Value that will be returned if no input happens + """ + question = ( + prompts[var_name] + if prompts and var_name in prompts and prompts[var_name] + else var_name + ) + return YesNoPrompt.ask(f"{prefix}{question}", default=default_value) + + +def read_repo_password(question: str) -> str: + """Prompt the user to enter a password. + + :param question: Question to the user + """ + return Prompt.ask(question, password=True) + + +def read_user_choice(var_name: str, options: list, prompts=None, prefix: str = ""): + """Prompt the user to choose from several options for the given variable. + + The first item will be returned if no input happens. + + :param var_name: Variable as specified in the context + :param list options: Sequence of options that are available to select from + :return: Exactly one item of ``options`` that has been chosen by the user + """ + if not options: + raise ValueError + + choice_map = OrderedDict((f'{i}', value) for i, value in enumerate(options, 1)) + choices = choice_map.keys() + + question = f"Select {var_name}" + + choice_lines: Iterator[str] = starmap( + " [bold magenta]{}[/] - [bold]{}[/]".format, choice_map.items() + ) + + # Handle if human-readable prompt is provided + if prompts and var_name in prompts: + if isinstance(prompts[var_name], str): + question = prompts[var_name] + else: + if "__prompt__" in prompts[var_name]: + question = prompts[var_name]["__prompt__"] + choice_lines = ( + f" [bold magenta]{i}[/] - [bold]{prompts[var_name][p]}[/]" + if p in prompts[var_name] + else f" [bold magenta]{i}[/] - [bold]{p}[/]" + for i, p in choice_map.items() + ) + + prompt = '\n'.join( + ( + f"{prefix}{question}", + "\n".join(choice_lines), + " Choose from", + ) + ) + + user_choice = Prompt.ask(prompt, choices=list(choices), default=next(iter(choices))) + return choice_map[user_choice] + + +DEFAULT_DISPLAY = 'default' + + +def process_json(user_value: str): + """Load user-supplied value as a JSON dict. + + :param user_value: User-supplied value to load as a JSON dict + """ + try: + user_dict = json.loads(user_value, object_pairs_hook=OrderedDict) + except Exception as error: + # Leave it up to click to ask the user again + raise InvalidResponse('Unable to decode to JSON.') from error + + if not isinstance(user_dict, dict): + # Leave it up to click to ask the user again + raise InvalidResponse('Requires JSON dict.') + + return user_dict + + +class JsonPrompt(PromptBase[dict]): + """A prompt that returns a dict from JSON string.""" + + default = None + response_type = dict + validate_error_message = "[prompt.invalid] Please enter a valid JSON string" + + @staticmethod + def process_response(value: str) -> dict[str, Any]: + """Convert choices to a dict.""" + return process_json(value) + + +def read_user_dict(var_name: str, default_value, prompts=None, prefix: str = ""): + """Prompt the user to provide a dictionary of data. + + :param var_name: Variable as specified in the context + :param default_value: Value that will be returned if no input is provided + :return: A Python dictionary to use in the context. + """ + if not isinstance(default_value, dict): + raise TypeError + + question = ( + prompts[var_name] + if prompts and var_name in prompts and prompts[var_name] + else var_name + ) + user_value = JsonPrompt.ask( + f"{prefix}{question} [cyan bold]({DEFAULT_DISPLAY})[/]", + default=default_value, + show_default=False, + ) + return user_value + + +_Raw: TypeAlias = Union[bool, Dict["_Raw", "_Raw"], List["_Raw"], str, None] + + +def render_variable( + env: Environment, + raw: _Raw, + dxh_py_dict: dict[str, Any], +) -> str: + """Render the next variable to be displayed in the user prompt. + + Inside the prompting taken from the dxh_py.json file, this renders + the next variable. For example, if a project_name is "Peanut Butter + Cookie", the repo_name could be be rendered with: + + `{{ dxh_py.project_name.replace(" ", "_") }}`. + + This is then presented to the user as the default. + + :param Environment env: A Jinja2 Environment object. + :param raw: The next value to be prompted for by the user. + :param dict dxh_py_dict: The current context as it's gradually + being populated with variables. + :return: The rendered value for the default variable. + """ + if raw is None or isinstance(raw, bool): + return raw + elif isinstance(raw, dict): + return { + render_variable(env, k, dxh_py_dict): render_variable( + env, v, dxh_py_dict + ) + for k, v in raw.items() + } + elif isinstance(raw, list): + return [render_variable(env, v, dxh_py_dict) for v in raw] + elif not isinstance(raw, str): + raw = str(raw) + + template = env.from_string(raw) + + return template.render(dxh_py=dxh_py_dict) + + +def _prompts_from_options(options: dict) -> dict: + """Process template options and return friendly prompt information.""" + prompts = {"__prompt__": "Select a template"} + for option_key, option_value in options.items(): + title = str(option_value.get("title", option_key)) + description = option_value.get("description", option_key) + label = title if title == description else f"{title} ({description})" + prompts[option_key] = label + return prompts + + +def prompt_choice_for_template( + key: str, options: dict, no_input: bool +) -> OrderedDict[str, Any]: + """Prompt user with a set of options to choose from. + + :param no_input: Do not prompt for user input and return the first available option. + """ + opts = list(options.keys()) + prompts = {"templates": _prompts_from_options(options)} + return opts[0] if no_input else read_user_choice(key, opts, prompts, "") + + +def prompt_choice_for_config( + dxh_py_dict: dict[str, Any], + env: Environment, + key: str, + options, + no_input: bool, + prompts=None, + prefix: str = "", +) -> OrderedDict[str, Any] | str: + """Prompt user with a set of options to choose from. + + :param no_input: Do not prompt for user input and return the first available option. + """ + rendered_options = [render_variable(env, raw, dxh_py_dict) for raw in options] + if no_input: + return rendered_options[0] + return read_user_choice(key, rendered_options, prompts, prefix) + + +def prompt_for_config( + context: dict[str, Any], no_input: bool = False +) -> OrderedDict[str, Any]: + """Prompt user to enter a new config. + + :param dict context: Source for field names and sample values. + :param no_input: Do not prompt for user input and use only values from context. + """ + dxh_py_dict = OrderedDict([]) + env = create_env_with_context(context) + prompts = context['dxh_py'].pop('__prompts__', {}) + + # First pass: Handle simple and raw variables, plus choices. + # These must be done first because the dictionaries keys and + # values might refer to them. + count = 0 + all_prompts = context['dxh_py'].items() + visible_prompts = [k for k, _ in all_prompts if not k.startswith("_")] + size = len(visible_prompts) + for key, raw in all_prompts: + if key.startswith('_') and not key.startswith('__'): + dxh_py_dict[key] = raw + continue + elif key.startswith('__'): + dxh_py_dict[key] = render_variable(env, raw, dxh_py_dict) + continue + + if not isinstance(raw, dict): + count += 1 + prefix = f" [dim][{count}/{size}][/] " + + try: + if isinstance(raw, list): + # We are dealing with a choice variable + val = prompt_choice_for_config( + dxh_py_dict, env, key, raw, no_input, prompts, prefix + ) + dxh_py_dict[key] = val + elif isinstance(raw, bool): + # We are dealing with a boolean variable + if no_input: + dxh_py_dict[key] = render_variable( + env, raw, dxh_py_dict + ) + else: + dxh_py_dict[key] = read_user_yes_no(key, raw, prompts, prefix) + elif not isinstance(raw, dict): + # We are dealing with a regular variable + val = render_variable(env, raw, dxh_py_dict) + + if not no_input: + val = read_user_variable(key, val, prompts, prefix) + + dxh_py_dict[key] = val + except UndefinedError as err: + msg = f"Unable to render variable '{key}'" + raise UndefinedVariableInTemplate(msg, err, context) from err + + # Second pass; handle the dictionaries. + for key, raw in context['dxh_py'].items(): + # Skip private type dicts not to be rendered. + if key.startswith('_') and not key.startswith('__'): + continue + + try: + if isinstance(raw, dict): + # We are dealing with a dict variable + count += 1 + prefix = f" [dim][{count}/{size}][/] " + val = render_variable(env, raw, dxh_py_dict) + + if not no_input and not key.startswith('__'): + val = read_user_dict(key, val, prompts, prefix) + + dxh_py_dict[key] = val + except UndefinedError as err: + msg = f"Unable to render variable '{key}'" + raise UndefinedVariableInTemplate(msg, err, context) from err + + return dxh_py_dict + + +def choose_nested_template( + context: dict[str, Any], repo_dir: Path | str, no_input: bool = False +) -> str: + """Prompt user to select the nested template to use. + + :param context: Source for field names and sample values. + :param repo_dir: Repository directory. + :param no_input: Do not prompt for user input and use only values from context. + :returns: Path to the selected template. + """ + dxh_py_dict: OrderedDict[str, Any] = OrderedDict([]) + env = create_env_with_context(context) + prefix = "" + prompts = context['dxh_py'].pop('__prompts__', {}) + key = "templates" + config = context['dxh_py'].get(key, {}) + if config: + # Pass + val = prompt_choice_for_template(key, config, no_input) + template = config[val]["path"] + else: + # Old style + key = "template" + config = context['dxh_py'].get(key, []) + val = prompt_choice_for_config( + dxh_py_dict, env, key, config, no_input, prompts, prefix + ) + template = re.search(r'\((.+)\)', val).group(1) + + template = Path(template) if template else None + if not (template and not template.is_absolute()): + raise ValueError("Illegal template path") + + repo_dir = Path(repo_dir).resolve() + template_path = (repo_dir / template).resolve() + # Return path as string + return f"{template_path}" + + +def prompt_and_delete(path: Path | str, no_input: bool = False) -> bool: + """ + Ask user if it's okay to delete the previously-downloaded file/directory. + + If yes, delete it. If no, checks to see if the old version should be + reused. If yes, it's reused; otherwise, dxh_py exits. + + :param path: Previously downloaded zipfile. + :param no_input: Suppress prompt to delete repo and just delete it. + :return: True if the content was deleted + """ + # Suppress prompt if called via API + if no_input: + ok_to_delete = True + else: + question = ( + f"You've downloaded {path} before. Is it okay to delete and re-download it?" + ) + + ok_to_delete = read_user_yes_no(question, 'yes') + + if ok_to_delete: + if os.path.isdir(path): + rmtree(path) + else: + os.remove(path) + return True + else: + ok_to_reuse = read_user_yes_no( + "Do you want to re-use the existing version?", 'yes' + ) + + if ok_to_reuse: + return False + + sys.exit() diff --git a/dxh_py/replay.py b/dxh_py/replay.py new file mode 100644 index 0000000..7ceb70c --- /dev/null +++ b/dxh_py/replay.py @@ -0,0 +1,49 @@ +""" +dxh_py.replay. + +------------------- +""" + +from __future__ import annotations + +import json +import os +from typing import TYPE_CHECKING, Any + +from dxh_py.utils import make_sure_path_exists + +if TYPE_CHECKING: + from pathlib import Path + + +def get_file_name(replay_dir: Path | str, template_name: str) -> str: + """Get the name of file.""" + suffix = '.json' if not template_name.endswith('.json') else '' + file_name = f'{template_name}{suffix}' + return os.path.join(replay_dir, file_name) + + +def dump(replay_dir: Path | str, template_name: str, context: dict[str, Any]) -> None: + """Write json data to file.""" + make_sure_path_exists(replay_dir) + + if 'dxh_py' not in context: + raise ValueError('Context is required to contain a dxh_py key') + + replay_file = get_file_name(replay_dir, template_name) + + with open(replay_file, 'w', encoding="utf-8") as outfile: + json.dump(context, outfile, indent=2) + + +def load(replay_dir: Path | str, template_name: str) -> dict[str, Any]: + """Read json data from file.""" + replay_file = get_file_name(replay_dir, template_name) + + with open(replay_file, encoding="utf-8") as infile: + context: dict[str, Any] = json.load(infile) + + if 'dxh_py' not in context: + raise ValueError('Context is required to contain a dxh_py key') + + return context diff --git a/dxh_py/repository.py b/dxh_py/repository.py new file mode 100644 index 0000000..4f35044 --- /dev/null +++ b/dxh_py/repository.py @@ -0,0 +1,138 @@ +"""dxh_py repository functions.""" + +from __future__ import annotations + +import os +import re +from typing import TYPE_CHECKING + +from dxh_py.exceptions import RepositoryNotFound +from dxh_py.vcs import clone +from dxh_py.zipfile import unzip + +if TYPE_CHECKING: + from pathlib import Path + +REPO_REGEX = re.compile( + r""" +# something like git:// ssh:// file:// etc. +((((git|hg)\+)?(git|ssh|file|https?):(//)?) + | # or + (\w+@[\w\.]+) # something like user@... +) +""", + re.VERBOSE, +) + + +def is_repo_url(value: str) -> bool: + """Return True if value is a repository URL.""" + return bool(REPO_REGEX.match(value)) + + +def is_zip_file(value: str) -> bool: + """Return True if value is a zip file.""" + return value.lower().endswith('.zip') + + +def expand_abbreviations(template: str, abbreviations: dict[str, str]) -> str: + """Expand abbreviations in a template name. + + :param template: The project template name. + :param abbreviations: Abbreviation definitions. + """ + if template in abbreviations: + return abbreviations[template] + + # Split on colon. If there is no colon, rest will be empty + # and prefix will be the whole template + prefix, _sep, rest = template.partition(':') + if prefix in abbreviations: + return abbreviations[prefix].format(rest) + + return template + + +def repository_has_dxh_py_json(repo_directory: str) -> bool: + """Determine if `repo_directory` contains a `dxh_py.json` file. + + :param repo_directory: The candidate repository directory. + :return: True if the `repo_directory` is valid, else False. + """ + repo_directory_exists = os.path.isdir(repo_directory) + + repo_config_exists = os.path.isfile( + os.path.join(repo_directory, 'dxh_py.json') + ) + return repo_directory_exists and repo_config_exists + + +def determine_repo_dir( + template: str, + abbreviations: dict[str, str], + clone_to_dir: Path | str, + checkout: str | None, + no_input: bool, + password: str | None = None, + directory: str | None = None, +) -> tuple[str, bool]: + """ + Locate the repository directory from a template reference. + + Applies repository abbreviations to the template reference. + If the template refers to a repository URL, clone it. + If the template is a path to a local repository, use it. + + :param template: A directory containing a project template directory, + or a URL to a git repository. + :param abbreviations: A dictionary of repository abbreviation + definitions. + :param clone_to_dir: The directory to clone the repository into. + :param checkout: The branch, tag or commit ID to checkout after clone. + :param no_input: Do not prompt for user input and eventually force a refresh of + cached resources. + :param password: The password to use when extracting the repository. + :param directory: Directory within repo where dxh_py.json lives. + :return: A tuple containing the dxh_py template directory, and + a boolean describing whether that directory should be cleaned up + after the template has been instantiated. + :raises: `RepositoryNotFound` if a repository directory could not be found. + """ + template = expand_abbreviations(template, abbreviations) + + if is_zip_file(template): + unzipped_dir = unzip( + zip_uri=template, + is_url=is_repo_url(template), + clone_to_dir=clone_to_dir, + no_input=no_input, + password=password, + ) + repository_candidates = [unzipped_dir] + cleanup = True + elif is_repo_url(template): + cloned_repo = clone( + repo_url=template, + checkout=checkout, + clone_to_dir=clone_to_dir, + no_input=no_input, + ) + repository_candidates = [cloned_repo] + cleanup = False + else: + repository_candidates = [template, os.path.join(clone_to_dir, template)] + cleanup = False + + if directory: + repository_candidates = [ + os.path.join(s, directory) for s in repository_candidates + ] + + for repo_candidate in repository_candidates: + if repository_has_dxh_py_json(repo_candidate): + return repo_candidate, cleanup + + raise RepositoryNotFound( + 'A valid repository for "{}" could not be found in the following ' + 'locations:\n{}'.format(template, '\n'.join(repository_candidates)) + ) diff --git a/dxh_py/utils.py b/dxh_py/utils.py new file mode 100644 index 0000000..2f2e0bf --- /dev/null +++ b/dxh_py/utils.py @@ -0,0 +1,104 @@ +"""Helper functions used throughout dxh_py.""" + +from __future__ import annotations + +import contextlib +import logging +import os +import shutil +import stat +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any, Iterator + +from jinja2.ext import Extension + +from dxh_py.environment import StrictEnvironment + +if TYPE_CHECKING: + from jinja2 import Environment + +logger = logging.getLogger(__name__) + + +def force_delete(func, path, _exc_info) -> None: # type: ignore[no-untyped-def] + """Error handler for `shutil.rmtree()` equivalent to `rm -rf`. + + Usage: `shutil.rmtree(path, onerror=force_delete)` + From https://docs.python.org/3/library/shutil.html#rmtree-example + """ + os.chmod(path, stat.S_IWRITE) + func(path) + + +def rmtree(path: Path | str) -> None: + """Remove a directory and all its contents. Like rm -rf on Unix. + + :param path: A directory path. + """ + shutil.rmtree(path, onerror=force_delete) + + +def make_sure_path_exists(path: Path | str) -> None: + """Ensure that a directory exists. + + :param path: A directory tree path for creation. + """ + logger.debug('Making sure path exists (creates tree if not exist): %s', path) + try: + Path(path).mkdir(parents=True, exist_ok=True) + except OSError as error: + raise OSError(f'Unable to create directory at {path}') from error + + +@contextlib.contextmanager +def work_in(dirname: Path | str | None = None) -> Iterator[None]: + """Context manager version of os.chdir. + + When exited, returns to the working directory prior to entering. + """ + curdir = os.getcwd() + try: + if dirname is not None: + os.chdir(dirname) + yield + finally: + os.chdir(curdir) + + +def make_executable(script_path: Path | str) -> None: + """Make `script_path` executable. + + :param script_path: The file to change + """ + status = os.stat(script_path) + os.chmod(script_path, status.st_mode | stat.S_IEXEC) + + +def simple_filter(filter_function) -> type[Extension]: # type: ignore[no-untyped-def] + """Decorate a function to wrap it in a simplified jinja2 extension.""" + + class SimpleFilterExtension(Extension): + def __init__(self, environment: Environment) -> None: + super().__init__(environment) + environment.filters[filter_function.__name__] = filter_function + + SimpleFilterExtension.__name__ = filter_function.__name__ + return SimpleFilterExtension + + +def create_tmp_repo_dir(repo_dir: Path | str) -> Path: + """Create a temporary dir with a copy of the contents of repo_dir.""" + repo_dir = Path(repo_dir).resolve() + base_dir = tempfile.mkdtemp(prefix='dxh_py') + new_dir = f"{base_dir}/{repo_dir.name}" + logger.debug(f'Copying repo_dir from {repo_dir} to {new_dir}') + shutil.copytree(repo_dir, new_dir) + return Path(new_dir) + + +def create_env_with_context(context: dict[str, Any]) -> StrictEnvironment: + """Create a jinja environment using the provided context.""" + envvars = context.get('dxh_py', {}).get('_jinja2_env_vars', {}) + + return StrictEnvironment(context=context, keep_trailing_newline=True, **envvars) diff --git a/dxh_py/vcs.py b/dxh_py/vcs.py new file mode 100644 index 0000000..3e09ed4 --- /dev/null +++ b/dxh_py/vcs.py @@ -0,0 +1,140 @@ +"""Helper functions for working with version control systems.""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path +from shutil import which +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing_extensions import Literal + +from dxh_py.exceptions import ( + RepositoryCloneFailed, + RepositoryNotFound, + UnknownRepoType, + VCSNotInstalled, +) +from dxh_py.prompt import prompt_and_delete +from dxh_py.utils import make_sure_path_exists + +logger = logging.getLogger(__name__) + + +BRANCH_ERRORS = [ + 'error: pathspec', + 'unknown revision', +] + + +def identify_repo(repo_url: str) -> tuple[Literal["git", "hg"], str]: + """Determine if `repo_url` should be treated as a URL to a git or hg repo. + + Repos can be identified by prepending "hg+" or "git+" to the repo URL. + + :param repo_url: Repo URL of unknown type. + :returns: ('git', repo_url), ('hg', repo_url), or None. + """ + repo_url_values = repo_url.split('+') + if len(repo_url_values) == 2: + repo_type = repo_url_values[0] + if repo_type in ["git", "hg"]: + return repo_type, repo_url_values[1] # type: ignore[return-value] + else: + raise UnknownRepoType + else: + if 'git' in repo_url: + return 'git', repo_url + elif 'bitbucket' in repo_url: + return 'hg', repo_url + else: + raise UnknownRepoType + + +def is_vcs_installed(repo_type: str) -> bool: + """ + Check if the version control system for a repo type is installed. + + :param repo_type: + """ + return bool(which(repo_type)) + + +def clone( + repo_url: str, + checkout: str | None = None, + clone_to_dir: Path | str = ".", + no_input: bool = False, +) -> str: + """Clone a repo to the current directory. + + :param repo_url: Repo URL of unknown type. + :param checkout: The branch, tag or commit ID to checkout after clone. + :param clone_to_dir: The directory to clone to. + Defaults to the current directory. + :param no_input: Do not prompt for user input and eventually force a refresh of + cached resources. + :returns: str with path to the new directory of the repository. + """ + # Ensure that clone_to_dir exists + clone_to_dir = Path(clone_to_dir).expanduser() + make_sure_path_exists(clone_to_dir) + + # identify the repo_type + repo_type, repo_url = identify_repo(repo_url) + + # check that the appropriate VCS for the repo_type is installed + if not is_vcs_installed(repo_type): + msg = f"'{repo_type}' is not installed." + raise VCSNotInstalled(msg) + + repo_url = repo_url.rstrip('/') + repo_name = os.path.split(repo_url)[1] + if repo_type == 'git': + repo_name = repo_name.split(':')[-1].rsplit('.git')[0] + repo_dir = os.path.normpath(os.path.join(clone_to_dir, repo_name)) + if repo_type == 'hg': + repo_dir = os.path.normpath(os.path.join(clone_to_dir, repo_name)) + logger.debug(f'repo_dir is {repo_dir}') + + if os.path.isdir(repo_dir): + clone = prompt_and_delete(repo_dir, no_input=no_input) + else: + clone = True + + if clone: + try: + subprocess.check_output( + [repo_type, 'clone', repo_url], + cwd=clone_to_dir, + stderr=subprocess.STDOUT, + ) + if checkout is not None: + checkout_params = [checkout] + # Avoid Mercurial "--config" and "--debugger" injection vulnerability + if repo_type == "hg": + checkout_params.insert(0, "--") + subprocess.check_output( + [repo_type, 'checkout', *checkout_params], + cwd=repo_dir, + stderr=subprocess.STDOUT, + ) + except subprocess.CalledProcessError as clone_error: + output = clone_error.output.decode('utf-8') + if 'not found' in output.lower(): + raise RepositoryNotFound( + f'The repository {repo_url} could not be found, ' + 'have you made a typo?' + ) from clone_error + if any(error in output for error in BRANCH_ERRORS): + raise RepositoryCloneFailed( + f'The {checkout} branch of repository ' + f'{repo_url} could not found, have you made a typo?' + ) from clone_error + logger.error('git clone failed with error: %s', output) + raise + + return repo_dir diff --git a/dxh_py/zipfile.py b/dxh_py/zipfile.py new file mode 100644 index 0000000..4699f1e --- /dev/null +++ b/dxh_py/zipfile.py @@ -0,0 +1,122 @@ +"""Utility functions for handling and fetching repo archives in zip format.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from zipfile import BadZipFile, ZipFile + +import requests + +from dxh_py.exceptions import InvalidZipRepository +from dxh_py.prompt import prompt_and_delete, read_repo_password +from dxh_py.utils import make_sure_path_exists + + +def unzip( + zip_uri: str, + is_url: bool, + clone_to_dir: Path | str = ".", + no_input: bool = False, + password: str | None = None, +) -> str: + """Download and unpack a zipfile at a given URI. + + This will download the zipfile to the dxh_py repository, + and unpack into a temporary directory. + + :param zip_uri: The URI for the zipfile. + :param is_url: Is the zip URI a URL or a file? + :param clone_to_dir: The dxh_py repository directory + to put the archive into. + :param no_input: Do not prompt for user input and eventually force a refresh of + cached resources. + :param password: The password to use when unpacking the repository. + """ + # Ensure that clone_to_dir exists + clone_to_dir = Path(clone_to_dir).expanduser() + make_sure_path_exists(clone_to_dir) + + if is_url: + # Build the name of the cached zipfile, + # and prompt to delete if it already exists. + identifier = zip_uri.rsplit('/', 1)[1] + zip_path = os.path.join(clone_to_dir, identifier) + + if os.path.exists(zip_path): + download = prompt_and_delete(zip_path, no_input=no_input) + else: + download = True + + if download: + # (Re) download the zipfile + r = requests.get(zip_uri, stream=True, timeout=100) + with open(zip_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=1024): + if chunk: # filter out keep-alive new chunks + f.write(chunk) + else: + # Just use the local zipfile as-is. + zip_path = os.path.abspath(zip_uri) + + # Now unpack the repository. The zipfile will be unpacked + # into a temporary directory + try: + zip_file = ZipFile(zip_path) + + if len(zip_file.namelist()) == 0: + raise InvalidZipRepository(f'Zip repository {zip_uri} is empty') + + # The first record in the zipfile should be the directory entry for + # the archive. If it isn't a directory, there's a problem. + first_filename = zip_file.namelist()[0] + if not first_filename.endswith('/'): + raise InvalidZipRepository( + f"Zip repository {zip_uri} does not include a top-level directory" + ) + + # Construct the final target directory + project_name = first_filename[:-1] + unzip_base = tempfile.mkdtemp() + unzip_path = os.path.join(unzip_base, project_name) + + # Extract the zip file into the temporary directory + try: + zip_file.extractall(path=unzip_base) + except RuntimeError as runtime_err: + # File is password protected; try to get a password from the + # environment; if that doesn't work, ask the user. + if password is not None: + try: + zip_file.extractall(path=unzip_base, pwd=password.encode('utf-8')) + except RuntimeError as e: + raise InvalidZipRepository( + 'Invalid password provided for protected repository' + ) from e + elif no_input: + raise InvalidZipRepository( + 'Unable to unlock password protected repository' + ) from runtime_err + else: + retry: int | None = 0 + while retry is not None: + try: + password = read_repo_password('Repo password') + zip_file.extractall( + path=unzip_base, pwd=password.encode('utf-8') + ) + retry = None + except RuntimeError as e: # noqa: PERF203 + retry += 1 # type: ignore[operator] + if retry == 3: + raise InvalidZipRepository( + 'Invalid password provided for protected repository' + ) from e + + except BadZipFile as e: + raise InvalidZipRepository( + f'Zip repository {zip_uri} is not a valid zip archive:' + ) from e + + return unzip_path diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 88bf870..3e8d115 --- a/setup.py +++ b/setup.py @@ -1,33 +1,85 @@ -import os -from setuptools import setup, find_packages +"""dxh_py distutils configuration.""" + +from pathlib import Path + +from setuptools import setup + + +def _get_version() -> str: + """Read dxh_py/VERSION.txt and return its contents.""" + path = Path("dxh_py").resolve() + version_file = path / "VERSION.txt" + return version_file.read_text().strip() + + +version = _get_version() + + +with open('README.md', encoding='utf-8') as readme_file: + readme = readme_file.read() + + +requirements = [ + 'binaryornot>=0.4.4', + 'Jinja2>=2.7,<4.0.0', + 'click>=7.0,<9.0.0', + 'pyyaml>=5.3.1', + 'python-slugify>=4.0.0', + 'requests>=2.23.0', + 'arrow', + 'rich', +] setup( name='dxh_py', - version='0.0.1', - packages=find_packages(), - install_requires=[ - 'cookiecutter>=2.0.0' - ], - entry_points={ - 'console_scripts': [ - 'dxh_py=dxh_py.cli:main', - ], - }, + version=version, + description='A Python CLI tool to generate PyPI packages effortlessly.', + long_description=readme, + long_description_content_type='text/markdown', author='DEVxHUB', author_email='tech@devxhub.com', - description='A python CLI to generate pypi packages.', - long_description=open('README.md').read() if os.path.exists('README.md') else '', - long_description_content_type='text/markdown', url='https://github.com/devxhub/dxh-py', + packages=['dxh_py'], + package_dir={'dxh_py': 'dxh_py'}, + entry_points={ + 'console_scripts': [ + 'dxh_py = dxh_py.__main__:main' + ] + }, + include_package_data=True, + python_requires='>=3.7', + install_requires=requirements, + license='MIT', + zip_safe=False, classifiers=[ - 'Programming Language :: Python :: 3', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python", + "Topic :: Software Development", + ], + keywords=[ + "dxh_py", + "Python", + "projects", + "project templates", + "Jinja2", + "skeleton", + "scaffolding", + "project directory", + "package", + "packaging", ], - license='MIT', - python_requires='>=3.6', - include_package_data=True, - package_data={ - '': ['LICENSE.md'], - }, ) From 29a59bd944e9f69766205f98a96e8c2e108d4076 Mon Sep 17 00:00:00 2001 From: git-jamil Date: Wed, 3 Jul 2024 15:48:59 +0600 Subject: [PATCH 2/7] update --- MANIFEST.in | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index d3c8982..e085066 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,3 @@ -include AUTHORS.md -include CODE_OF_CONDUCT.md -include CONTRIBUTING.md -include HISTORY.md include LICENSE.md include README.md include dxh_py/VERSION.txt @@ -9,13 +5,7 @@ include dxh_py/VERSION.txt exclude Makefile exclude __main__.py exclude .* -exclude codecov.yml -exclude test_requirements.txt -exclude tox.ini -exclude ruff.toml -recursive-include tests * recursive-exclude * __pycache__ recursive-exclude * *.py[co] -recursive-exclude docs * -recursive-exclude logo * +recursive-exclude docs * \ No newline at end of file From d1adf0c337bc82fd92641a75a3894e1da2054f29 Mon Sep 17 00:00:00 2001 From: git-jamil Date: Wed, 3 Jul 2024 15:51:59 +0600 Subject: [PATCH 3/7] update bool_var and hook --- docs/advanced/boolean_variables.rst | 2 +- docs/advanced/hooks.rst | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/advanced/boolean_variables.rst b/docs/advanced/boolean_variables.rst index 273b539..ef684ff 100644 --- a/docs/advanced/boolean_variables.rst +++ b/docs/advanced/boolean_variables.rst @@ -1,7 +1,7 @@ Boolean Variables ----------------- -.. versionadded:: 2.2.0 +.. versionadded:: 0.0.3 Boolean variables are used for answering True/False questions. diff --git a/docs/advanced/hooks.rst b/docs/advanced/hooks.rst index e925cd8..496e44b 100644 --- a/docs/advanced/hooks.rst +++ b/docs/advanced/hooks.rst @@ -6,15 +6,15 @@ dxh_py hooks are scripts executed at specific stages during the project generati Types of Hooks -------------- -+------------------+------------------------------------------+------------------------------------------+--------------------+----------+ -| Hook | Execution Timing | Working Directory | Template Variables | Version | -+==================+==========================================+==========================================+====================+==========+ -| pre_prompt | Before any question is rendered. | A copy of the repository directory | No | 2.4.0 | -+------------------+------------------------------------------+------------------------------------------+--------------------+----------+ -| pre_gen_project | After questions, before template process.| Root of the generated project | Yes | 0.7.0 | -+------------------+------------------------------------------+------------------------------------------+--------------------+----------+ -| post_gen_project | After the project generation. | Root of the generated project | Yes | 0.7.0 | -+------------------+------------------------------------------+------------------------------------------+--------------------+----------+ ++------------------+------------------------------------------+------------------------------------------+--------------------+ +| Hook | Execution Timing | Working Directory | Template Variables | ++==================+==========================================+==========================================+====================+ +| pre_prompt | Before any question is rendered. | A copy of the repository directory | No | ++------------------+------------------------------------------+------------------------------------------+--------------------+ +| pre_gen_project | After questions, before template process.| Root of the generated project | Yes | ++------------------+------------------------------------------+------------------------------------------+--------------------+ +| post_gen_project | After the project generation. | Root of the generated project | Yes | ++------------------+------------------------------------------+------------------------------------------+--------------------+ Creating Hooks -------------- From d848a84eb1c13a23422361c4abfcaa7fb4af9ae1 Mon Sep 17 00:00:00 2001 From: git-jamil Date: Wed, 3 Jul 2024 16:09:30 +0600 Subject: [PATCH 4/7] update Newlinechar, reply and tutorial1 --- docs/advanced/new_line_characters.rst | 7 +--- docs/advanced/replay.rst | 6 ++-- docs/tutorials/tutorial1.rst | 48 +++++---------------------- 3 files changed, 12 insertions(+), 49 deletions(-) diff --git a/docs/advanced/new_line_characters.rst b/docs/advanced/new_line_characters.rst index 9adad25..3bd2013 100644 --- a/docs/advanced/new_line_characters.rst +++ b/docs/advanced/new_line_characters.rst @@ -3,17 +3,12 @@ Working with line-ends special symbols LF/CRLF ---------------------------------------------- -*New in dxh_py 2.0* +*New in dxh_py 0.0.3* .. note:: - Before version 2.0 dxh_py silently used system line end character. - LF for POSIX and CRLF for Windows. - Since version 2.0 this behaviour changed and now can be forced at template level. - By default dxh_py checks every file at render stage and uses the same line end as in source. This allow template developers to have both types of files in the same template. -Developers should correctly configure their ``.gitattributes`` file to avoid line-end character overwrite by git. The special template variable ``_new_lines`` enforces a specific line ending. Acceptable variables: ``'\r\n'`` for CRLF and ``'\n'`` for POSIX. diff --git a/docs/advanced/replay.rst b/docs/advanced/replay.rst index b2e8fa8..853d387 100644 --- a/docs/advanced/replay.rst +++ b/docs/advanced/replay.rst @@ -3,13 +3,13 @@ Replay Project Generation ------------------------- -*New in dxh_py 1.1* +*New in dxh_py 0.0.3* On invocation **dxh_py** dumps a json file to ``~/.dxh_py_replay/`` which enables you to *replay* later on. In other words, it persists your **input** for a template and fetches it when you run the same template again. -Example for a replay file (which was created via ``dxh_py gh:hackebrot/cookiedozer``): +Example for a replay file: .. code-block:: JSON @@ -49,7 +49,7 @@ This feature comes in handy if, for instance, you want to create a new project f Custom replay file ~~~~~~~~~~~~~~~~~~ -*New in dxh_py 2.0* +*New in dxh_py 0.0.3* To specify a custom filename, you can use the ``--replay-file`` option: diff --git a/docs/tutorials/tutorial1.rst b/docs/tutorials/tutorial1.rst index f1d9db6..4719532 100644 --- a/docs/tutorials/tutorial1.rst +++ b/docs/tutorials/tutorial1.rst @@ -58,36 +58,12 @@ Looking inside the `boilerplate/` (or directory corresponding to your `project_s .. code-block:: bash $ ls boilerplate/ - AUTHORS.rst MANIFEST.in docs tox.ini - CONTRIBUTING.rst Makefile requirements.txt - HISTORY.rst README.rst setup.py - LICENSE boilerplate tests + MANIFEST.in docs + Makefile requirements.txt + README.rst setup.py + LICENSE.md boilerplate That's your new project! - -If you open the AUTHORS.rst file, you should see something like this: - -.. code-block:: rst - - ======= - Credits - ======= - - Development Lead - ---------------- - - * Audrey Roy - - Contributors - ------------ - - None yet. Why not be the first? - -Notice how it was auto-populated with your (or my) name and email. - -Also take note of the fact that you are looking at a ReStructuredText file. -dxh_py can generate a project with text files of any type. - Great, you just generated a skeleton Python package. How did that work? @@ -106,14 +82,6 @@ You should see that this directory and its contents corresponds to the project t This happens in `find.py`, where the `find_template()` method looks for the first jinja-like directory name that starts with `dxh_py`. -AUTHORS.rst -~~~~~~~~~~~ - -Look at the raw version of `{{ dxh_py.project_slug }}/AUTHORS.rst`, at -https://raw.github.com/devxhub/dxh_py/master/%7B%7Bdxh_py.project_slug%7D%7D/AUTHORS.rst. - -Observe how it corresponds to the `AUTHORS.rst` file that you generated. - dxh_py.json ~~~~~~~~~~~~~~~~~ @@ -124,10 +92,10 @@ You should see JSON that corresponds to the prompts and default values shown ear .. code-block:: json { - "full_name": "Audrey Roy Greenfeld", - "email": "aroy@alum.mit.edu", - "github_username": "audreyr", - "project_name": "Python Boilerplate", + "full_name": "DEVxHUB", + "email": "tech@devxhub.com", + "github_username": "devxhub", + "project_name": "Django Boilerplate", "project_slug": "{{ dxh_py.project_name.lower().replace(' ', '_') }}", "project_short_description": "Python Boilerplate contains all the boilerplate you need to create a Python package.", "pypi_username": "{{ dxh_py.github_username }}", From 9db68690367a91b0d1b3257136f1770f8b63a554 Mon Sep 17 00:00:00 2001 From: git-jamil Date: Wed, 3 Jul 2024 16:13:10 +0600 Subject: [PATCH 5/7] version info updated --- docs/advanced/choice_variables.rst | 2 +- docs/advanced/copy_without_render.rst | 2 +- docs/advanced/dict_variables.rst | 2 +- docs/advanced/directories.rst | 2 +- docs/advanced/local_extensions.rst | 2 +- docs/advanced/nested_config_files.rst | 4 ++-- docs/advanced/template_extensions.rst | 6 +++--- docs/advanced/templates.rst | 4 ++-- docs/advanced/user_config.rst | 4 ++-- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/advanced/choice_variables.rst b/docs/advanced/choice_variables.rst index 2585b50..1a8dddc 100644 --- a/docs/advanced/choice_variables.rst +++ b/docs/advanced/choice_variables.rst @@ -3,7 +3,7 @@ Choice Variables ---------------- -*New in dxh_py 1.1* +*New in dxh_py 0.0.3* Choice variables provide different choices when creating a project. Depending on a user's choice the template renders things differently. diff --git a/docs/advanced/copy_without_render.rst b/docs/advanced/copy_without_render.rst index 2fd5d4e..c1392a9 100644 --- a/docs/advanced/copy_without_render.rst +++ b/docs/advanced/copy_without_render.rst @@ -3,7 +3,7 @@ Copy without Render ------------------- -*New in dxh_py 1.1* +*New in dxh_py 0.0.3* To avoid rendering directories and files of a dxh_py, the ``_copy_without_render`` key can be used in the ``dxh_py.json``. The value of this key accepts a list of Unix shell-style wildcards: diff --git a/docs/advanced/dict_variables.rst b/docs/advanced/dict_variables.rst index 8910e26..4d56bf3 100644 --- a/docs/advanced/dict_variables.rst +++ b/docs/advanced/dict_variables.rst @@ -3,7 +3,7 @@ Dictionary Variables -------------------- -*New in dxh_py 1.5* +*New in dxh_py 0.0.3* Dictionary variables provide a way to define deep structured information when rendering a template. diff --git a/docs/advanced/directories.rst b/docs/advanced/directories.rst index 701cf68..ef48e5e 100644 --- a/docs/advanced/directories.rst +++ b/docs/advanced/directories.rst @@ -3,7 +3,7 @@ Organizing dxh_pys in directories --------------------------------------- -*New in dxh_py 1.7* +*New in dxh_py 0.0.3* dxh_py introduces the ability to organize several templates in one repository or zip file, separating them by directories. This allows using symlinks for general files. diff --git a/docs/advanced/local_extensions.rst b/docs/advanced/local_extensions.rst index d2c283e..188bcce 100644 --- a/docs/advanced/local_extensions.rst +++ b/docs/advanced/local_extensions.rst @@ -3,7 +3,7 @@ Local Extensions ---------------- -*New in dxh_py 2.1* +*New in dxh_py 0.0.3* A template may extend the dxh_py environment with local extensions. These can be part of the template itself, providing it with more sophisticated custom tags and filters. diff --git a/docs/advanced/nested_config_files.rst b/docs/advanced/nested_config_files.rst index d4a0cc6..9edaf9d 100644 --- a/docs/advanced/nested_config_files.rst +++ b/docs/advanced/nested_config_files.rst @@ -3,7 +3,7 @@ Nested configuration files -------------------------- -*New in dxh_py 2.5.0* +*New in dxh_py 0.0.3* If you wish to create a hierarchy of templates and use dxh_py to choose among them, you need just to specify the key ``templates`` in the main configuration file to reach @@ -59,7 +59,7 @@ Once a template is chosen, for example ``1``, it will continue to ask the info r Old Format ++++++++++ -*New in dxh_py 2.2.0* +*New in dxh_py 0.0.3* In the main ``dxh_py.json`` add a `template` key with the following format: diff --git a/docs/advanced/template_extensions.rst b/docs/advanced/template_extensions.rst index 8666b9e..cccd972 100644 --- a/docs/advanced/template_extensions.rst +++ b/docs/advanced/template_extensions.rst @@ -3,7 +3,7 @@ Template Extensions ------------------- -*New in dxh_py 1.4* +*New in dxh_py 0.0.3* A template may extend the dxh_py environment with custom `Jinja2 extensions`_. It can add extra filters, tests, globals or even extend the parser. @@ -73,7 +73,7 @@ Would output: Random string extension ~~~~~~~~~~~~~~~~~~~~~~~ -*New in dxh_py 1.7* +*New in dxh_py 0.0.3* The ``dxh_py.extensions.RandomStringExtension`` extension provides a ``random_ascii_string`` method in templates that generates a random fixed-length string, optionally with punctuation. @@ -129,7 +129,7 @@ For example to change the output from ``it-s-a-random-version``` to ``it_s_a_ran UUID4 extension ~~~~~~~~~~~~~~~~~~~~~~~ -*New in dxh_py 1.x* +*New in dxh_py 0.0.3* The ``dxh_py.extensions.UUIDExtension`` extension provides a ``uuid4()`` method in templates that generates a uuid4. diff --git a/docs/advanced/templates.rst b/docs/advanced/templates.rst index f0207ae..112b809 100644 --- a/docs/advanced/templates.rst +++ b/docs/advanced/templates.rst @@ -1,9 +1,9 @@ .. _templates: -Templates inheritance (2.2+) +Templates inheritance (0.0.2+) --------------------------------------------------- -*New in dxh_py 2.2+* +*New in dxh_py 0.0.2+* Sometimes you need to extend a base template with a different configuration to avoid nested blocks. diff --git a/docs/advanced/user_config.rst b/docs/advanced/user_config.rst index 727c3ca..0c1d229 100644 --- a/docs/advanced/user_config.rst +++ b/docs/advanced/user_config.rst @@ -3,12 +3,12 @@ User Config =========== -*New in dxh_py 0.7* +*New in dxh_py 0.0.3* If you use dxh_py a lot, you'll find it useful to have a user config file. By default dxh_py tries to retrieve settings from a `.dxh_pyrc` file in your home directory. -*New in dxh_py 1.3* +*New in dxh_py 0.0.3* You can also specify a config file on the command line via ``--config-file``. From cc59c15834e1ac427198c38c1956f0d0a0be5669 Mon Sep 17 00:00:00 2001 From: git-jamil Date: Wed, 3 Jul 2024 16:19:06 +0600 Subject: [PATCH 6/7] update conf.py --- docs/conf.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 1cfb17b..f706ca6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,8 +1,7 @@ """Documentation build configuration file.""" # -# dxh_py documentation build configuration file, created by -# sphinx-quickstart on Thu Jul 11 11:31:49 2013. +# dxh_py documentation build configuration file. # # This file is execfile()d with the current directory set to its containing # dir. @@ -297,9 +296,9 @@ # Bibliographic Dublin Core info. epub_title = 'dxh_py' -epub_author = 'Audrey Roy' -epub_publisher = 'Audrey Roy and dxh_py community' -epub_copyright = '2013-2022, Audrey Roy and dxh_py community' +epub_author = 'DEVxHUB' +epub_publisher = 'DEVxHUB and dxh_py community' +epub_copyright = '2024- , DEVxHUB and dxh_py community' # The language of the text. It defaults to the language option # or en if the language is not set. From 6b3429fe62119be73e7bef164aa21f80dbabbae6 Mon Sep 17 00:00:00 2001 From: git-jamil Date: Wed, 3 Jul 2024 16:31:28 +0600 Subject: [PATCH 7/7] conf cli update --- docs/advanced/user_config.rst | 16 ++++++++-------- docs/conf.py | 8 ++++---- docs/index.rst | 3 +-- docs/usage.rst | 1 - dxh_py/cli.py | 2 +- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/docs/advanced/user_config.rst b/docs/advanced/user_config.rst index 0c1d229..92ab342 100644 --- a/docs/advanced/user_config.rst +++ b/docs/advanced/user_config.rst @@ -14,13 +14,13 @@ You can also specify a config file on the command line via ``--config-file``. .. code-block:: bash - dxh_py --config-file /home/audreyr/my-custom-config.yaml dxh_py + dxh_py --config-file /home/devxhub/my-custom-config.yaml dxh_py Or you can set the ``dxh_py_CONFIG`` environment variable: .. code-block:: bash - export dxh_py_CONFIG=/home/audreyr/my-custom-config.yaml + export dxh_py_CONFIG=/home/devxhub/my-custom-config.yaml If you wish to stick to the built-in config and not load any user config file at all, use the CLI option ``--default-config`` instead. Preventing dxh_py from loading user settings is crucial for writing integration tests in an isolated environment. @@ -30,11 +30,11 @@ Example user config: .. code-block:: yaml default_context: - full_name: "Audrey Roy" - email: "audreyr@example.com" - github_username: "audreyr" - dxh_pys_dir: "/home/audreyr/my-custom-dxh_pys-dir/" - replay_dir: "/home/audreyr/my-custom-replay-dir/" + full_name: "DEVxHUB" + email: "tech@devxhub.com" + github_username: "devxhub" + dxh_pys_dir: "/home/devxhub/my-custom-dxh_pys-dir/" + replay_dir: "/home/devxhub/my-custom-replay-dir/" abbreviations: pp: https://github.com/devxhub/dxh_py.git gh: https://github.com/{0}.git @@ -54,7 +54,7 @@ Possible settings are: A list of abbreviations for dxh_pys. Abbreviations can be simple aliases for a repo name, or can be used as a prefix, in the form ``abbr:suffix``. Any suffix will be inserted into the expansion in place of the text ``{0}``, using standard Python string formatting. - With the above aliases, you could use the ``dxh_py`` template simply by saying ``dxh_py pp``, or ``dxh_py gh:audreyr/dxh_py``. + With the above aliases, you could use the ``dxh_py`` template simply by saying ``dxh_py pp``, or ``dxh_py gh:devxhub/dxh_py``. The ``gh`` (GitHub), ``bb`` (Bitbucket), and ``gl`` (Gitlab) abbreviations shown above are actually **built in**, and can be used without defining them yourself. Read also: :ref:`injecting-extra-content` diff --git a/docs/conf.py b/docs/conf.py index f706ca6..a67ecf3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -69,7 +69,7 @@ # General information about the project. project = 'dxh_py' -copyright = '2013-2022, Audrey Roy and dxh_py community' +copyright = '2024, Devxhub and dxh_py community' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -218,7 +218,7 @@ 'index', 'dxh_py.tex', 'dxh_py Documentation', - 'Audrey Roy and dxh_py community', + 'Devxhub and dxh_py community', 'manual', ), ] @@ -253,7 +253,7 @@ 'index', 'dxh_py', 'dxh_py Documentation', - ['Audrey Roy and dxh_py community'], + ['Devxhub and dxh_py community'], 1, ) ] @@ -272,7 +272,7 @@ 'index', 'dxh_py', 'dxh_py Documentation', - 'Audrey Roy and dxh_py community', + 'Devxhub and dxh_py community', 'dxh_py', 'Creates projects from project templates', 'Miscellaneous', diff --git a/docs/index.rst b/docs/index.rst index eec6af3..87c89f0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,5 +1,4 @@ -.. dxh_py documentation master file, created by - sphinx-quickstart on Thu Jul 11 11:31:49 2013. +.. dxh_py documentation master file. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. diff --git a/docs/usage.rst b/docs/usage.rst index a4007d7..4fe4bc6 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -48,7 +48,6 @@ repository:: $ dxh_py https://github.com/devxhub/dxh_py.git $ dxh_py git+ssh://git@github.com/devxhub/dxh_py.git - $ dxh_py hg+ssh://hg@bitbucket.org/audreyr/dxh_py You will be prompted to enter a bunch of project config values. (These are defined in the project's `dxh_py.json`.) diff --git a/dxh_py/cli.py b/dxh_py/cli.py index 08d5277..ed6e02f 100644 --- a/dxh_py/cli.py +++ b/dxh_py/cli.py @@ -190,7 +190,7 @@ def main( dxh_py is free and open source software, developed and managed by volunteers. If you would like to help out or fund the project, please get - in touch at https://github.com/dxh_py/dxh_py. + in touch at https://github.com/devxhub/dxh_py. """ # Commands that should work without arguments if list_installed: