├── tests ├── __init__.py ├── fixtures │ ├── __init__.py │ ├── add_mode.py │ ├── config.py │ ├── updates.py │ ├── subdir_style.py │ ├── renderable.py │ ├── git.py │ ├── cli.py │ ├── local_branch_situation.py │ ├── transaction.py │ └── repo_path.py ├── integration │ ├── __init__.py │ ├── cli │ │ ├── __init__.py │ │ ├── fxt.py │ │ ├── test_update.py │ │ └── test_check.py │ ├── fixtures │ │ ├── __init__.py │ │ └── repo.py │ ├── test_init_project_from.py │ ├── undoables.py │ ├── test_main.py │ └── test_update.py ├── input_files │ ├── templates │ │ ├── copiers │ │ │ ├── one │ │ │ │ ├── {{ q1 }}.txt.jinja │ │ │ │ ├── README.md │ │ │ │ ├── {{ _copier_conf.answers_file }}.jinja │ │ │ │ └── copier.yml │ │ │ ├── with-tasks │ │ │ │ ├── {{ q1 }}.txt.jinja │ │ │ │ ├── my-script.sh │ │ │ │ └── copier.yml │ │ │ ├── output-subdir │ │ │ │ ├── output │ │ │ │ │ └── {{ qone }}.txt.jinja │ │ │ │ ├── not-rendered.txt │ │ │ │ └── copier.yml │ │ │ └── from-cookiecutter-one │ │ │ │ ├── {{ a }} │ │ │ │ └── text.txt.jinja │ │ │ │ └── copier.yaml │ │ └── cookiecutters │ │ │ ├── two │ │ │ ├── {{ cookiecutter.a }} │ │ │ │ └── text2.txt │ │ │ └── cookiecutter.json │ │ │ ├── one │ │ │ ├── cookiecutter.json │ │ │ └── {{ cookiecutter.a }} │ │ │ │ └── text.txt │ │ │ └── with-hooks │ │ │ ├── cookiecutter.json │ │ │ ├── hooks │ │ │ ├── pre_gen_project.py │ │ │ └── post_gen_project.sh │ │ │ └── {{ cookiecutter.a }} │ │ │ └── text.txt │ ├── flexlate-project.json │ ├── projects │ │ └── nested │ │ │ ├── flexlate-project.json │ │ │ ├── subdir │ │ │ ├── flexlate-project.json │ │ │ └── flexlate.json │ │ │ └── flexlate.json │ ├── project_configs │ │ └── root │ │ │ ├── 1 │ │ │ └── flexlate-project.json │ │ │ └── flexlate-project.json │ └── configs │ │ ├── flexlate.json │ │ └── subdir2 │ │ └── flexlate.json ├── test_placeholder.py ├── test_temp_path.py ├── ext_click.py ├── confutils.py ├── ext_subprocess.py ├── fileutils.py ├── conftest.py ├── test_pusher.py ├── test_template_path.py ├── test_checker.py ├── test_user_config_manager.py ├── fs_checks.py ├── gitutils.py ├── test_bootstrapper.py ├── dirutils.py └── config.py ├── .dockerignore ├── examples ├── __init__.py └── README.rst ├── flexlate ├── finder │ ├── __init__.py │ ├── specific │ │ ├── __init__.py │ │ ├── base.py │ │ ├── git.py │ │ ├── cookiecutter.py │ │ └── copier.py │ └── multi.py ├── render │ ├── __init__.py │ ├── specific │ │ ├── __init__.py │ │ ├── base.py │ │ └── cookiecutter.py │ ├── renderable.py │ └── multi.py ├── types.py ├── update │ └── __init__.py ├── template │ ├── __init__.py │ ├── types.py │ ├── copier.py │ ├── hashing.py │ ├── cookiecutter.py │ └── base.py ├── transactions │ ├── __init__.py │ └── undoer.py ├── template_config │ ├── __init__.py │ ├── cookiecutter.py │ ├── base.py │ └── copier.py ├── constants.py ├── __init__.py ├── cli_utils.py ├── get_version.py ├── template_data.py ├── styles.py ├── temp_path.py ├── add_mode.py ├── logger.py ├── error_handler.py ├── template_path.py ├── syncer.py ├── pusher.py ├── checker.py ├── user_config_manager.py ├── path_ops.py ├── exc.py └── bootstrapper.py ├── docsrc ├── directives │ ├── __init__.py │ └── auto_summary.py ├── download-logo.sh ├── .gitignore ├── source │ ├── _static │ │ └── images │ │ │ ├── flexlate-pr-diff.png │ │ │ ├── flexlate-pr-opened.png │ │ │ ├── github-web-editor.png │ │ │ ├── flexlate-conflict-pr-opened.png │ │ │ ├── flexlate-update-workflow-run.png │ │ │ ├── flexlate-after-merge-workflow-run.png │ │ │ ├── git-tree-after-conflict-resolution.png │ │ │ ├── git-tree-after-merge-flexlate-branches.png │ │ │ └── flexlate-conflict-pr-highlight-web-editor.png │ ├── tutorial │ │ ├── index.md │ │ ├── get-started │ │ │ ├── index.md │ │ │ ├── installing.md │ │ │ ├── existing-project.md │ │ │ ├── new-project.md │ │ │ └── add-to-project.md │ │ ├── developing-templates.md │ │ ├── undoing.md │ │ ├── arbitrary-changes.md │ │ ├── updating.md │ │ └── saving.md │ └── index.md ├── generate-cli-command-reference.sh ├── nb-examples.sh ├── binder_requirements.sh ├── autobuild.sh ├── dev-server.sh ├── make.bat ├── Makefile └── apidoc │ └── templates │ └── package.rst_t ├── lint-requirements.txt ├── test-requirements.txt ├── env.sh ├── MANIFEST.in ├── setup.cfg ├── run-docker.sh ├── codecov.yml ├── .flake8 ├── .husky ├── pre-commit └── commit-msg ├── name.py ├── directory.py ├── pyproject.toml ├── binder_requirements.py ├── mypy.ini ├── .commitlintrc.yaml ├── flexlate-project.json ├── .github ├── actions │ ├── lint-and-test │ │ └── action.yml │ ├── test-coverage │ │ └── action.yml │ ├── install-dependencies │ │ └── action.yml │ └── build-and-deploy-docs │ │ └── action.yml ├── workflows │ ├── update-major-version-tag.yml │ ├── sync-labels.yml │ ├── flexlate-update.yml │ ├── flexlate-after-merge.yml │ ├── docs.yml │ └── package.yml ├── labels.yml └── release-drafter.yml ├── version.py ├── is_maintainer.py ├── package.json ├── .releaserc.yaml ├── Pipfile ├── .cruft.json ├── get_logo.py ├── scripts └── workflows-updated.sh ├── upload.py ├── Dockerfile ├── .gitignore ├── LICENSE.md ├── setup.py ├── noxfile.py ├── flexlate.json ├── nbexamples └── ipynb_to_gallery.py └── conf.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flexlate/finder/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flexlate/render/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flexlate/types.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /flexlate/update/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docsrc/directives/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flexlate/template/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flexlate/transactions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flexlate/finder/specific/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flexlate/render/specific/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flexlate/template_config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lint-requirements.txt: -------------------------------------------------------------------------------- 1 | black 2 | isort -------------------------------------------------------------------------------- /tests/integration/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov -------------------------------------------------------------------------------- /env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | eval "$(python conf.py)"; -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include conf.py 2 | include version.py -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | version = 0.14.7 3 | 4 | -------------------------------------------------------------------------------- /run-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker run -it --rm flexlate bash -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/one/{{ q1 }}.txt.jinja: -------------------------------------------------------------------------------- 1 | {{ q2 }} -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/one/README.md: -------------------------------------------------------------------------------- 1 | some existing content -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/with-tasks/{{ q1 }}.txt.jinja: -------------------------------------------------------------------------------- 1 | {{ q1 }} -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | 2 | ignore: 3 | - "*" 4 | - "tests/**" 5 | - "examples/**" -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/one/{{ _copier_conf.answers_file }}.jinja: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,.nox,__pycache__,dist,docs,docsrc/source/conf.py -------------------------------------------------------------------------------- /docsrc/download-logo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd .. 3 | python get_logo.py 4 | cd docsrc 5 | -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/output-subdir/output/{{ qone }}.txt.jinja: -------------------------------------------------------------------------------- 1 | {{ qtwo }} -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /name.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | import conf 3 | 4 | print(conf.PACKAGE_NAME) 5 | -------------------------------------------------------------------------------- /tests/input_files/templates/cookiecutters/two/{{ cookiecutter.a }}/text2.txt: -------------------------------------------------------------------------------- 1 | {{ cookiecutter.d }} -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/from-cookiecutter-one/{{ a }}/text.txt.jinja: -------------------------------------------------------------------------------- 1 | {{ a }}{{ c }} -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/with-tasks/my-script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "Executed script" -------------------------------------------------------------------------------- /examples/README.rst: -------------------------------------------------------------------------------- 1 | This is my gallery 2 | ================== 3 | 4 | Below is a gallery of examples -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/output-subdir/not-rendered.txt: -------------------------------------------------------------------------------- 1 | this file will not be in the output -------------------------------------------------------------------------------- /tests/test_placeholder.py: -------------------------------------------------------------------------------- 1 | # TODO: Add real tests 2 | def test_placeholder(): 3 | assert True 4 | -------------------------------------------------------------------------------- /directory.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | import conf 3 | 4 | print(conf.PACKAGE_DIRECTORY) 5 | -------------------------------------------------------------------------------- /tests/input_files/templates/cookiecutters/one/cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": "b", 3 | "c": "" 4 | } -------------------------------------------------------------------------------- /tests/input_files/templates/cookiecutters/two/cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": "b", 3 | "d": "e" 4 | } -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /tests/input_files/templates/cookiecutters/with-hooks/cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": "b", 3 | "c": "" 4 | } -------------------------------------------------------------------------------- /tests/input_files/templates/cookiecutters/one/{{ cookiecutter.a }}/text.txt: -------------------------------------------------------------------------------- 1 | {{ cookiecutter.a }}{{ cookiecutter.c }} -------------------------------------------------------------------------------- /tests/input_files/templates/cookiecutters/with-hooks/hooks/pre_gen_project.py: -------------------------------------------------------------------------------- 1 | print("Run pre gen project hook") 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | include = 'flexlate.*\.pyi?$|tests.*\.pyi?$' 3 | 4 | [tool.isort] 5 | profile = "black" -------------------------------------------------------------------------------- /tests/input_files/templates/cookiecutters/with-hooks/{{ cookiecutter.a }}/text.txt: -------------------------------------------------------------------------------- 1 | {{ cookiecutter.a }}{{ cookiecutter.c }} -------------------------------------------------------------------------------- /flexlate/constants.py: -------------------------------------------------------------------------------- 1 | DEFAULT_MERGED_BRANCH_NAME = "flexlate-output" 2 | DEFAULT_TEMPLATE_BRANCH_NAME = "flexlate-templates" 3 | -------------------------------------------------------------------------------- /flexlate/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A composable, maintainable system for managing templates 3 | """ 4 | from flexlate.main import Flexlate 5 | -------------------------------------------------------------------------------- /tests/input_files/templates/cookiecutters/with-hooks/hooks/post_gen_project.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "Run post gen project hook" 3 | cat text.txt -------------------------------------------------------------------------------- /docsrc/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | source/api 3 | source/binder/requirements.txt 4 | source/_static/images/logo.svg 5 | source/commands.md 6 | source/cache -------------------------------------------------------------------------------- /flexlate/cli_utils.py: -------------------------------------------------------------------------------- 1 | from rich.prompt import Confirm 2 | 3 | 4 | def confirm_user(prompt: str) -> bool: 5 | return Confirm.ask(prompt) 6 | -------------------------------------------------------------------------------- /binder_requirements.py: -------------------------------------------------------------------------------- 1 | import conf 2 | 3 | if __name__ == "__main__": 4 | for package in conf.BINDER_ENVIRONMENT_REQUIRES: 5 | print(package) 6 | -------------------------------------------------------------------------------- /docsrc/source/_static/images/flexlate-pr-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickderobertis/flexlate/HEAD/docsrc/source/_static/images/flexlate-pr-diff.png -------------------------------------------------------------------------------- /docsrc/generate-cli-command-reference.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd .. 3 | typer flexlate/cli.py utils docs --name fxt --output docsrc/source/commands.md 4 | cd docsrc 5 | -------------------------------------------------------------------------------- /docsrc/source/_static/images/flexlate-pr-opened.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickderobertis/flexlate/HEAD/docsrc/source/_static/images/flexlate-pr-opened.png -------------------------------------------------------------------------------- /docsrc/source/_static/images/github-web-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickderobertis/flexlate/HEAD/docsrc/source/_static/images/github-web-editor.png -------------------------------------------------------------------------------- /flexlate/template_config/cookiecutter.py: -------------------------------------------------------------------------------- 1 | from flexlate.template_config.base import TemplateConfig 2 | 3 | 4 | class CookiecutterConfig(TemplateConfig): 5 | pass 6 | -------------------------------------------------------------------------------- /docsrc/nb-examples.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd .. 3 | cp -R examples _examples 4 | python ./nbexamples/ipynb_to_gallery.py ./nbexamples/ --out-folder ./_examples --replace 5 | cd docsrc 6 | -------------------------------------------------------------------------------- /docsrc/source/_static/images/flexlate-conflict-pr-opened.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickderobertis/flexlate/HEAD/docsrc/source/_static/images/flexlate-conflict-pr-opened.png -------------------------------------------------------------------------------- /docsrc/source/_static/images/flexlate-update-workflow-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickderobertis/flexlate/HEAD/docsrc/source/_static/images/flexlate-update-workflow-run.png -------------------------------------------------------------------------------- /docsrc/binder_requirements.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd .. 3 | mkdir -p docsrc/source/binder/ 4 | echo "$(python binder_requirements.py)" > docsrc/source/binder/requirements.txt 5 | cd docsrc 6 | -------------------------------------------------------------------------------- /docsrc/source/_static/images/flexlate-after-merge-workflow-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickderobertis/flexlate/HEAD/docsrc/source/_static/images/flexlate-after-merge-workflow-run.png -------------------------------------------------------------------------------- /docsrc/source/_static/images/git-tree-after-conflict-resolution.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickderobertis/flexlate/HEAD/docsrc/source/_static/images/git-tree-after-conflict-resolution.png -------------------------------------------------------------------------------- /docsrc/source/_static/images/git-tree-after-merge-flexlate-branches.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickderobertis/flexlate/HEAD/docsrc/source/_static/images/git-tree-after-merge-flexlate-branches.png -------------------------------------------------------------------------------- /docsrc/source/_static/images/flexlate-conflict-pr-highlight-web-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickderobertis/flexlate/HEAD/docsrc/source/_static/images/flexlate-conflict-pr-highlight-web-editor.png -------------------------------------------------------------------------------- /flexlate/template/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class TemplateType(str, Enum): 5 | BASE = "base, should be overriden" 6 | COOKIECUTTER = "cookiecutter" 7 | COPIER = "copier" 8 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | # Global options: 2 | 3 | [mypy] 4 | ignore_missing_imports = True 5 | files = flexlate 6 | 7 | [mypy-pkg_resources.*] 8 | ignore_missing_imports = True 9 | 10 | # Per-module options: 11 | 12 | -------------------------------------------------------------------------------- /flexlate/get_version.py: -------------------------------------------------------------------------------- 1 | # TODO: get version statically inside package by restructuring template 2 | import pkg_resources 3 | 4 | 5 | def get_flexlate_version() -> str: 6 | return pkg_resources.get_distribution("flexlate").version 7 | -------------------------------------------------------------------------------- /.commitlintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: 2 | - "@commitlint/config-conventional" 3 | 4 | rules: 5 | subject-case: [2, "always", ["sentence-case"]] 6 | footer-max-line-length: [0, "always", 100] 7 | footer-max-length: [0, "always", 100] 8 | header-max-length: [0, "always", 100] 9 | -------------------------------------------------------------------------------- /flexlate-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "projects": [ 3 | { 4 | "path": ".", 5 | "default_add_mode": "local", 6 | "merged_branch_name": "flexlate-output", 7 | "template_branch_name": "flexlate-templates", 8 | "remote": "origin" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /tests/input_files/flexlate-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "projects": [ 3 | { 4 | "path": ".", 5 | "default_add_mode": "local", 6 | "merged_branch_name": "flexlate-output", 7 | "template_branch_name": "flexlate-templates", 8 | "remote": "origin" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /tests/input_files/projects/nested/flexlate-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "projects": [ 3 | { 4 | "path": ".", 5 | "default_add_mode": "local", 6 | "merged_branch_name": "flexlate-output", 7 | "template_branch_name": "flexlate-templates", 8 | "remote": "origin" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /tests/integration/cli/fxt.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from tests.integration.cli_stub import ExceptionHandling 4 | from tests.integration.cli_stub import fxt as _fxt 5 | 6 | # Ignore exceptions so that we can test error codes instead 7 | fxt = partial(_fxt, exception_handling=ExceptionHandling.IGNORE) 8 | -------------------------------------------------------------------------------- /.github/actions/lint-and-test/action.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Test 2 | description: "Runs isort, black, flake8, mypy, pytest. Be sure to run install-dependencies first" 3 | runs: 4 | using: composite 5 | steps: 6 | - name: Check code formatting, linting, and run tests 7 | shell: bash 8 | run: pipenv run nox 9 | -------------------------------------------------------------------------------- /tests/input_files/project_configs/root/1/flexlate-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "projects": [ 3 | { 4 | "path": ".", 5 | "default_add_mode": "local", 6 | "merged_branch_name": "flexlate-output", 7 | "template_branch_name": "flexlate-templates", 8 | "remote": "origin" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /tests/input_files/project_configs/root/flexlate-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "projects": [ 3 | { 4 | "path": "2", 5 | "default_add_mode": "local", 6 | "merged_branch_name": "flexlate-output", 7 | "template_branch_name": "flexlate-templates", 8 | "remote": "origin" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /tests/fixtures/add_mode.py: -------------------------------------------------------------------------------- 1 | from typing import Final, List 2 | 3 | import pytest 4 | 5 | from flexlate.add_mode import AddMode 6 | 7 | all_add_modes: Final[List[AddMode]] = list(AddMode) 8 | 9 | 10 | @pytest.fixture(scope="module", params=all_add_modes) 11 | def add_mode(request) -> AddMode: 12 | return request.param 13 | -------------------------------------------------------------------------------- /tests/fixtures/config.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | import pytest 4 | 5 | from tests import config, gen_configs 6 | from tests.dirutils import wipe_generated_folder 7 | 8 | 9 | @pytest.fixture 10 | def generated_dir_with_configs(): 11 | gen_configs.main(config.GENERATED_FILES_DIR, config.GENERATED_FILES_DIR) 12 | yield 13 | -------------------------------------------------------------------------------- /tests/input_files/projects/nested/subdir/flexlate-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "projects": [ 3 | { 4 | "path": ".", 5 | "default_add_mode": "local", 6 | "merged_branch_name": "flexlate-output-subdir", 7 | "template_branch_name": "flexlate-templates-subdir", 8 | "remote": "origin-subdir" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /tests/test_temp_path.py: -------------------------------------------------------------------------------- 1 | from flexlate.temp_path import create_temp_path 2 | 3 | 4 | def test_temp_path_resolves_to_be_the_same_path(): 5 | # Note that this would work with tempfile.TemporaryDirectory on Linux and Windows, but not MacOS 6 | # that uses symlinks in temp paths 7 | with create_temp_path() as path: 8 | assert path == path.resolve() 9 | -------------------------------------------------------------------------------- /.github/workflows/update-major-version-tag.yml: -------------------------------------------------------------------------------- 1 | name: Keep the versions up-to-date 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | jobs: 8 | actions-tagger: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: Actions-R-Us/actions-tagger@v2 12 | with: 13 | publish_latest_tag: true 14 | token: ${{ secrets.GH_TOKEN }} 15 | -------------------------------------------------------------------------------- /flexlate/template_config/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from flexlate.template_data import TemplateData 4 | 5 | 6 | class TemplateConfig(abc.ABC): 7 | def __init__(self, defaults: TemplateData): 8 | self.defaults = defaults 9 | 10 | def __eq__(self, other): 11 | try: 12 | return all([self.defaults == other.defaults]) 13 | except AttributeError: 14 | return False 15 | -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/from-cookiecutter-one/copier.yaml: -------------------------------------------------------------------------------- 1 | # Configure jinja2 defaults to make syntax highlighters lives easier 2 | _templates_suffix: .jinja 3 | _envops: 4 | block_end_string: "%}" 5 | block_start_string: "{%" 6 | comment_end_string: "#}" 7 | comment_start_string: "{#" 8 | keep_trailing_newline: true 9 | variable_end_string: "}}" 10 | variable_start_string: "{{" 11 | 12 | 13 | a: b 14 | c: 15 | type: str -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # Labels here will be synced to the repo 2 | - name: maintenance 3 | description: Changes which don't directly support a feature 4 | color: 4ec76e 5 | - name: no auto merge 6 | description: Prevent CI from automerging PR from maintainer 7 | color: edae40 8 | - name: automated issue 9 | description: Issue raised by a bot 10 | color: bf40ed 11 | - name: automated pr 12 | description: PR raised by a bot 13 | color: e622cf -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/output-subdir/copier.yml: -------------------------------------------------------------------------------- 1 | _subdirectory: output 2 | 3 | # Configure jinja2 defaults to make syntax highlighters lives easier 4 | _templates_suffix: .jinja 5 | _envops: 6 | block_end_string: "%}" 7 | block_start_string: "{%" 8 | comment_end_string: "#}" 9 | comment_start_string: "{#" 10 | keep_trailing_newline: true 11 | variable_end_string: "}}" 12 | variable_start_string: "{{" 13 | 14 | qone: aone 15 | 16 | qtwo: atwo -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - .github/labels.yml 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: micnncim/action-label-syncer@v1 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | with: 17 | manifest: .github/labels.yml 18 | prune: false -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/with-tasks/copier.yml: -------------------------------------------------------------------------------- 1 | # Configure jinja2 defaults to make syntax highlighters lives easier 2 | _templates_suffix: .jinja 3 | _envops: 4 | block_end_string: "%}" 5 | block_start_string: "{%" 6 | comment_end_string: "#}" 7 | comment_start_string: "{#" 8 | keep_trailing_newline: true 9 | variable_end_string: "}}" 10 | variable_start_string: "{{" 11 | 12 | _tasks: 13 | - "chmod +x my-script.sh" 14 | 15 | q1: a1 16 | 17 | 18 | -------------------------------------------------------------------------------- /flexlate/template_config/copier.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from flexlate.template_config.base import TemplateConfig 4 | from flexlate.template_data import TemplateData 5 | 6 | 7 | class CopierConfig(TemplateConfig): 8 | def __init__( 9 | self, defaults: TemplateData, render_relative_root_in_template: Path = Path(".") 10 | ): 11 | super().__init__(defaults) 12 | self.render_relative_root_in_template = render_relative_root_in_template 13 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | setup_cfg_path = Path(__file__).parent / "setup.cfg" 4 | 5 | 6 | def get_version() -> str: 7 | if not setup_cfg_path.exists(): 8 | return "0.0.1" 9 | with open(setup_cfg_path) as f: 10 | for line in f: 11 | if line.startswith("version"): 12 | return line.split("=")[1].strip() 13 | raise ValueError("could not find version in setup.cfg") 14 | 15 | 16 | if __name__ == "__main__": 17 | print(get_version()) 18 | -------------------------------------------------------------------------------- /docsrc/directives/auto_summary.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from sphinx.ext.autosummary import Autosummary 4 | 5 | 6 | class AutoSummaryNameOnly(Autosummary): 7 | def get_table(self, items: List[Tuple[str, str, str, str]]): 8 | new_items = [] 9 | for name, sig, summary, real_name in items: 10 | name_parts = name.split(".") 11 | new_name = name_parts[-1] 12 | new_items.append((new_name, sig, summary, real_name)) 13 | return super().get_table(new_items) 14 | -------------------------------------------------------------------------------- /tests/input_files/templates/copiers/one/copier.yml: -------------------------------------------------------------------------------- 1 | # Configure jinja2 defaults to make syntax highlighters lives easier 2 | _templates_suffix: .jinja 3 | _envops: 4 | block_end_string: "%}" 5 | block_start_string: "{%" 6 | comment_end_string: "#}" 7 | comment_start_string: "{#" 8 | keep_trailing_newline: true 9 | variable_end_string: "}}" 10 | variable_start_string: "{{" 11 | 12 | q1: a1 13 | 14 | q2: 15 | type: int 16 | choices: 17 | 1: null 18 | 2: 2 19 | "three": 3 20 | default: 1 21 | 22 | q3: 23 | type: str 24 | -------------------------------------------------------------------------------- /is_maintainer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script to check within Github Actions whether current actor is one of the package maintainers 3 | """ 4 | import os 5 | 6 | from conf import REPO_MAINTAINERS 7 | 8 | if __name__ == "__main__": 9 | user = os.environ["GITHUB_PR_USER"] 10 | if user in REPO_MAINTAINERS: 11 | print(f"Github PR user {user} was in maintainers, will auto merge PR") 12 | exit(0) 13 | print( 14 | f"Github PR user was {user}, not in maintainers {REPO_MAINTAINERS}, so will not auto merge PR" 15 | ) 16 | exit(1) 17 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$NEXT_PATCH_VERSION 🌈' 2 | tag-template: 'v$NEXT_PATCH_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - 'bugfix' 12 | - 'bug' 13 | - title: '🧰 Maintenance' 14 | label: 'maintenance' 15 | - title: '📖 Documentation' 16 | label: 'documentation' 17 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 18 | template: | 19 | ## Changes 20 | 21 | $CHANGES 22 | -------------------------------------------------------------------------------- /flexlate/template_data.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Sequence 2 | 3 | TemplateData = Dict[str, Any] 4 | 5 | 6 | def merge_data( 7 | overrides: Sequence[TemplateData], defaults: Sequence[TemplateData] 8 | ) -> List[TemplateData]: 9 | out_data: List[TemplateData] = [] 10 | for i, default_data in enumerate(defaults): 11 | try: 12 | override_data = overrides[i] 13 | except IndexError: 14 | override_data = {} 15 | out_data.append({**default_data, **override_data}) 16 | return out_data 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "output", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "prepare": "husky install && pipx install black || true && pipx install isort || true" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "@commitlint/cli": "^16.2.3", 13 | "@commitlint/config-conventional": "^16.2.1", 14 | "husky": "^7.0.4", 15 | "lint-staged": "^12.4.1" 16 | }, 17 | "lint-staged": { 18 | "*.py": "pipenv run nox -s format_files -- " 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/index.md: -------------------------------------------------------------------------------- 1 | # Flexlate User Guide 2 | 3 | If you have not already read over [Flexlate core concepts](../core-concepts.md), 4 | it can help to get an understanding of how it works before you start using it. 5 | Once you are ready to use it, follow the 6 | [getting started guide](get-started/index.md), before coming back to learn 7 | about other Flexlate workflows. 8 | 9 | ```{toctree} 10 | --- 11 | maxdepth: 2 12 | --- 13 | 14 | get-started/index 15 | updating 16 | saving 17 | undoing 18 | arbitrary-changes 19 | ci-automation 20 | developing-templates 21 | ``` -------------------------------------------------------------------------------- /flexlate/styles.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from rich.console import Console 4 | 5 | console: Final[Console] = Console() 6 | 7 | 8 | ACTION_REQUIRED_STYLE: Final[str] = "[yellow]:pencil:" 9 | QUESTION_STYLE: Final[str] = ":question:" 10 | ALERT_STYLE: Final[str] = "[red]:x:" 11 | INFO_STYLE: Final[str] = "" 12 | SUCCESS_STYLE: Final[str] = "[green]:heavy_check_mark:" 13 | 14 | 15 | def styled(message: str, style: str) -> str: 16 | return f"{style} {message}" 17 | 18 | 19 | def print_styled(message: str, style: str): 20 | console.print(styled(message, style)) 21 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/get-started/index.md: -------------------------------------------------------------------------------- 1 | # Getting started with flexlate 2 | 3 | There are mutliple ways to get started with flexlate, depending on the 4 | nature of the project you want to use it for and how you want to use it. 5 | First follow the installation guide, then pick the guide that matches what 6 | you are trying to do. 7 | 8 | ```{toctree} 9 | --- 10 | maxdepth: 1 11 | --- 12 | 13 | installing 14 | new-project 15 | existing-project 16 | add-to-project 17 | 18 | ``` 19 | 20 | ## Next Steps 21 | 22 | See how to [update your project](../updating.md) to a newer template version. 23 | -------------------------------------------------------------------------------- /tests/fixtures/updates.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flexlate.template.cookiecutter import CookiecutterTemplate 4 | from flexlate.update.template import TemplateUpdate 5 | from tests import config 6 | from tests.fixtures.template import cookiecutter_one_template 7 | 8 | 9 | @pytest.fixture 10 | def cookiecutter_one_update_no_data( 11 | cookiecutter_one_template: CookiecutterTemplate, 12 | ) -> TemplateUpdate: 13 | return TemplateUpdate( 14 | template=cookiecutter_one_template, 15 | config_location=config.GENERATED_FILES_DIR / "flexlate.json", 16 | index=0, 17 | ) 18 | -------------------------------------------------------------------------------- /.releaserc.yaml: -------------------------------------------------------------------------------- 1 | branches: 2 | - '+([0-9])?(.{+([0-9]),x}).x' 3 | - master 4 | - name: beta 5 | prerelease: true 6 | - name: alpha 7 | prerelease: true 8 | 9 | repositoryUrl: https://github.com/nickderobertis/flexlate 10 | 11 | plugins: 12 | - "@semantic-release/commit-analyzer" 13 | - "@semantic-release/release-notes-generator" 14 | - "semantic-release-pypi" 15 | - "@semantic-release/github" 16 | - [ 17 | "@semantic-release/git", 18 | { 19 | assets: [ "setup.cfg" ], 20 | message: "chore(release): Bump to ${nextRelease.version}\n\n${nextRelease.notes}", 21 | } 22 | ] -------------------------------------------------------------------------------- /docsrc/autobuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Note: You likely should be running dev-server.sh directly instead of this 3 | # 4 | # Run this script to auto-build the documentation with changes to source files 5 | # It requires that you have the watchmedo utility installed globally. 6 | # Run `pipx install watchdog[watchmedo]` to install. 7 | 8 | WATCH_FILES="source ../flexlate ../README.md" 9 | 10 | echo "Starting documentation autoreloader watching $WATCH_FILES" 11 | watchmedo shell-command \ 12 | --patterns="*.py;*.css;*.js;*.md;*.rst" \ 13 | --recursive \ 14 | --command="make github" \ 15 | --drop \ 16 | $WATCH_FILES 17 | -------------------------------------------------------------------------------- /tests/ext_click.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from click.testing import Result 4 | 5 | 6 | def result_to_message(result: Result) -> str: 7 | output_parts: List[str] = [] 8 | if result.stdout: 9 | output_parts.append(f"stdout:\n{result.stdout}") 10 | try: 11 | output_parts.append(f"sterr:\n{result.stderr}") 12 | except ValueError as e: 13 | if "stderr not separately captured" in str(e): 14 | pass 15 | else: 16 | raise e 17 | if result.exception: 18 | output_parts.append(f"exception:\n{result.exception}") 19 | return "\n".join(output_parts) 20 | -------------------------------------------------------------------------------- /flexlate/finder/specific/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from pathlib import Path 3 | from typing import Protocol, TypeVar, Union 4 | 5 | from flexlate.template.base import Template 6 | from flexlate.template_config.base import TemplateConfig 7 | 8 | T = TypeVar("T", bound=Template) 9 | 10 | # TODO: figure out how to type TemplateFinder properly 11 | class TemplateFinder(Protocol[T]): # type: ignore 12 | def find(self, path: str, local_path: Path, **template_kwargs) -> T: 13 | ... 14 | 15 | def get_config(self, directory: Path) -> TemplateConfig: 16 | ... 17 | 18 | def matches_template_type(self, path: Path) -> bool: 19 | ... 20 | -------------------------------------------------------------------------------- /tests/input_files/projects/nested/flexlate.json: -------------------------------------------------------------------------------- 1 | { 2 | "template_sources": [ 3 | { 4 | "name": "one", 5 | "path": "../../templates/cookiecutters/one", 6 | "type": "cookiecutter", 7 | "version": null, 8 | "git_url": null, 9 | "target_version": null, 10 | "render_relative_root_in_output": "{{ cookiecutter.a }}", 11 | "render_relative_root_in_template": "{{ cookiecutter.a }}" 12 | } 13 | ], 14 | "applied_templates": [ 15 | { 16 | "name": "one", 17 | "data": { 18 | "a": "b", 19 | "c": "" 20 | }, 21 | "version": "d512c7e14e83cb4bc8d4e5ae06bb357e", 22 | "add_mode": "local", 23 | "root": "." 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /tests/input_files/projects/nested/subdir/flexlate.json: -------------------------------------------------------------------------------- 1 | { 2 | "template_sources": [ 3 | { 4 | "name": "two", 5 | "path": "../../input_files/templates/cookiecutters/two", 6 | "type": "cookiecutter", 7 | "version": null, 8 | "git_url": null, 9 | "target_version": null, 10 | "render_relative_root_in_output": "{{ cookiecutter.a }}", 11 | "render_relative_root_in_template": "{{ cookiecutter.a }}" 12 | } 13 | ], 14 | "applied_templates": [ 15 | { 16 | "name": "two", 17 | "data": { 18 | "a": "b", 19 | "d": "e" 20 | }, 21 | "version": "some version", 22 | "add_mode": "local", 23 | "root": "." 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /.github/actions/test-coverage/action.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Test 2 | description: "Runs isort, black, flake8, mypy, pytest. Be sure to run install-dependencies first" 3 | inputs: 4 | codecov-token: 5 | required: true 6 | description: "The codecov token to use for uploading coverage reports." 7 | runs: 8 | using: composite 9 | steps: 10 | - name: Run tests with coverage 11 | shell: bash 12 | run: pipenv run nox -s test_coverage 13 | - name: Upload coverage to Codecov 14 | uses: codecov/codecov-action@v2 15 | with: 16 | token: ${{ inputs.codecov-token }} 17 | file: ./coverage.xml 18 | flags: unittests 19 | name: codecov-pytest 20 | yml: ./codecov.yml 21 | -------------------------------------------------------------------------------- /docsrc/dev-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run this script to auto-reload the documentation with changes to source files 3 | # Requirements: 4 | # The watchmedo utility must installed globally: 5 | # Run `pipx install watchdog[watchmedo]` to install. 6 | # The live-server utility must also be installed globally: 7 | # Run `npm install -g live-server` to install. 8 | 9 | 10 | # Ensure that the auto-build exits when the script exits 11 | trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT 12 | 13 | echo "Starting dev server" 14 | 15 | # Watches package, docsrc, and README.md to build docs with Sphinx into a static HTML site 16 | ./autobuild.sh & 17 | 18 | # Watches static site output and live reloads browser in response to it 19 | live-server ../docs 20 | -------------------------------------------------------------------------------- /tests/confutils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from flexlate.config import FlexlateConfig 5 | 6 | 7 | # NOTE: currently unused, consider removing 8 | def patch_config_location(config: FlexlateConfig, new_folder: Path): 9 | orig_project_root = os.path.commonpath( 10 | [conf.settings.config_location for conf in config.child_configs] 11 | ) 12 | config.settings.custom_config_folder = new_folder 13 | for child_config in config.child_configs: 14 | orig_child_folder = child_config.settings.config_location.parent 15 | orig_relative_folder = orig_child_folder.relative_to(orig_project_root) 16 | new_relative_folder = new_folder / orig_relative_folder 17 | child_config.settings.custom_config_folder = new_relative_folder 18 | -------------------------------------------------------------------------------- /tests/fixtures/subdir_style.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Final, List, Optional 3 | 4 | import pytest 5 | 6 | 7 | class SubdirStyle(str, Enum): 8 | CD = "cd" 9 | PROVIDE_RELATIVE = "provide relative" 10 | PROVIDE_ABSOLUTE = "provide absolute" 11 | 12 | 13 | all_subdir_styles: Final[List[SubdirStyle]] = list(SubdirStyle) 14 | all_subdir_styles_or_none: Final[List[Optional[SubdirStyle]]] = [ 15 | None, 16 | *all_subdir_styles, 17 | ] 18 | 19 | 20 | @pytest.fixture(scope="module", params=all_subdir_styles) 21 | def subdir_style(request) -> SubdirStyle: 22 | return request.param 23 | 24 | 25 | @pytest.fixture(scope="module", params=all_subdir_styles_or_none) 26 | def subdir_style_or_none(request) -> Optional[SubdirStyle]: 27 | return request.param 28 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | sphinx = "*" 10 | sphinx-autobuild = "*" 11 | sphinx-autodoc-typehints = "*" 12 | sphinxcontrib-fulltoc = "*" 13 | sphinx-paramlinks = "*" 14 | sphinx-gallery = "*" 15 | sphinx-copybutton = "*" 16 | twine = "*" 17 | pytest = "*" 18 | mypy = "*" 19 | pypandoc = "*" 20 | myst-parser = "*" 21 | sphinx-material = "*" 22 | nox = "*" 23 | flake8 = "*" 24 | py-app-conf = "*" 25 | cookiecutter = "*" 26 | gitpython = "*" 27 | typer = "*" 28 | rich = "*" 29 | copier = "<6" 30 | markupsafe = "<2.1" 31 | click = "<8.1" 32 | typer-cli = "*" 33 | pexpect = "*" 34 | types-pyyaml = "*" 35 | sphinxext-remoteliteralinclude = "*" 36 | sphinx-terminhtml = "*" 37 | flexlate = {editable = true, path = "."} 38 | -------------------------------------------------------------------------------- /docsrc/source/index.md: -------------------------------------------------------------------------------- 1 | % flexlate documentation master file, created by 2 | % copier-pypi-sphinx-flexlate. 3 | % You can adapt this file completely to your liking, but it should at least 4 | % contain the root `toctree` directive. 5 | 6 | # Welcome to Flexlate documentation! 7 | 8 | ```{include} ../../README.md 9 | ``` 10 | 11 | For more information on getting started, take a look at the tutorial and examples. 12 | 13 | ## Tutorial and Examples 14 | 15 | ```{toctree} 16 | --- 17 | maxdepth: 2 18 | --- 19 | 20 | core-concepts 21 | tutorial/index 22 | FAQs 23 | CLI Reference 24 | ``` 25 | 26 | ## API Documentation 27 | 28 | ```{eval-rst} 29 | .. toctree:: Python API Reference 30 | :maxdepth: 1 31 | ``` 32 | 33 | ## Indices 34 | 35 | - {ref}`genindex` 36 | - {ref}`modindex` 37 | - {ref}`search` 38 | -------------------------------------------------------------------------------- /.cruft.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "https://github.com/nickderobertis/cookiecutter-pypi-sphinx", 3 | "commit": "091fcff1b2a425adc13a75fc587fbbb190b7ca3a", 4 | "checkout": null, 5 | "context": { 6 | "cookiecutter": { 7 | "package_name": "flexlate", 8 | "package_directory": "flexlate", 9 | "full_name": "Flexlate", 10 | "repo_name": "flexlate", 11 | "repo_username": "nickderobertis", 12 | "short_description": "A composable, maintainable system for managing templates", 13 | "package_author": "Nick DeRobertis", 14 | "author_email": "whoopnip@gmail.com", 15 | "google_analytics_id": "", 16 | "logo_url": "", 17 | "install_packages": "py-app-conf cookiecutter GitPython", 18 | "_template": "https://github.com/nickderobertis/cookiecutter-pypi-sphinx" 19 | } 20 | }, 21 | "directory": null 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/flexlate-update.yml: -------------------------------------------------------------------------------- 1 | name: Update Template using Flexlate 2 | 3 | on: 4 | schedule: 5 | - cron: "0 3 * * *" # every day at 3:00 AM 6 | workflow_dispatch: 7 | 8 | jobs: 9 | templateUpdate: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | max-parallel: 1 13 | matrix: 14 | python-version: [3.8] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | ref: ${{ github.ref_name }} 20 | fetch-depth: 0 21 | token: ${{ secrets.gh_token }} 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - uses: nickderobertis/flexlate-update-action@v1 27 | with: 28 | gh_token: ${{ secrets.gh_token }} 29 | main_branch_name: master 30 | -------------------------------------------------------------------------------- /flexlate/render/specific/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from pathlib import Path 3 | from typing import Optional, Protocol, Type, TypeVar 4 | 5 | from flexlate.render.renderable import Renderable 6 | from flexlate.template.base import Template 7 | from flexlate.template.types import TemplateType 8 | from flexlate.template_data import TemplateData 9 | 10 | T = TypeVar("T", bound=Template) 11 | 12 | 13 | class SpecificTemplateRenderer(Protocol[T]): 14 | # Override these in subclass 15 | _template_cls: Type[T] 16 | _template_type: TemplateType = TemplateType.BASE 17 | 18 | def render( 19 | self, 20 | renderable: Renderable[T], 21 | no_input: bool = False, 22 | ) -> TemplateData: 23 | ... 24 | 25 | def render_string( 26 | self, 27 | string: str, 28 | renderable: Renderable[T], 29 | ) -> str: 30 | ... 31 | -------------------------------------------------------------------------------- /flexlate/temp_path.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import shutil 3 | import tempfile 4 | from pathlib import Path 5 | from typing import Iterator 6 | 7 | 8 | @contextlib.contextmanager 9 | def create_temp_path() -> Iterator[Path]: 10 | """ 11 | Returns a temporary folder path 12 | 13 | Use this instead of tempfile.TemporaryDirectory because: 14 | 1. That returns a string and not a path 15 | 2. On MacOS, the temp directory has a symlink. This resolves the symlink so that 16 | there won't be any mismatch in resolved paths. 17 | 3. On Windows, the temp directory can fail to delete with a PermissionError. This function 18 | will try to delete the temp directory, but if it fails with an error it will just ignore it. 19 | """ 20 | temp_dir = tempfile.TemporaryDirectory() 21 | temp_path = Path(temp_dir.name).resolve() 22 | yield temp_path 23 | shutil.rmtree(temp_path, ignore_errors=True) 24 | -------------------------------------------------------------------------------- /docsrc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=dero 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/developing-templates.md: -------------------------------------------------------------------------------- 1 | # Developing your Own Templates 2 | 3 | Flexlate supports both [`cookiecutter`](https://github.com/cookiecutter/cookiecutter) 4 | and [`copier`](https://github.com/copier-org/copier) templates, so you can 5 | write your own templates following the guides for those tools. 6 | 7 | ## Flexlate Development Tools 8 | 9 | Flexlate does have a [corresponding development tool](https://nickderobertis.github.io/flexlate-dev/). 10 | Install it and run `dfxt serve` to start a development server in your template 11 | project, that will render the project with Flexlate in a temporary directory 12 | and auto-update it as you make changes in the template. 13 | 14 | It also has a `dfxt publish` command that will render your template and 15 | create a new project with it. This is useful along with a 16 | CI pipeline to automatically create and sync example repositories with 17 | output from your template. 18 | 19 | -------------------------------------------------------------------------------- /get_logo.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | import requests 5 | 6 | import conf 7 | 8 | DOCS_OUT_FOLDER = pathlib.Path("docsrc") / "source" / "_static" / "images" 9 | DOCS_OUT_PATH = DOCS_OUT_FOLDER / "logo.svg" 10 | 11 | 12 | def download_logo(logo_url=conf.PACKAGE_LOGO_URL, out_path: str = str(DOCS_OUT_PATH)): 13 | if not logo_url: 14 | return 15 | 16 | print(f"Downloading logo from {logo_url}") 17 | response = requests.get(logo_url) 18 | if response.status_code != 200: 19 | raise NoLogoAtUrlException(logo_url) 20 | 21 | content = response.content.decode("utf8") 22 | with open(out_path, "w") as f: 23 | f.write(content) 24 | print(f"Logo outputted to {out_path}") 25 | 26 | 27 | class NoLogoAtUrlException(Exception): 28 | pass 29 | 30 | 31 | if __name__ == "__main__": 32 | if not os.path.exists(DOCS_OUT_FOLDER): 33 | os.makedirs(DOCS_OUT_FOLDER) 34 | 35 | download_logo() 36 | -------------------------------------------------------------------------------- /tests/input_files/configs/flexlate.json: -------------------------------------------------------------------------------- 1 | { 2 | "template_sources": [ 3 | { 4 | "name": "one", 5 | "path": "../input_files/templates/cookiecutters/one", 6 | "type": "cookiecutter", 7 | "version": null, 8 | "git_url": null, 9 | "target_version": null, 10 | "render_relative_root_in_output": "{{ cookiecutter.a }}", 11 | "render_relative_root_in_template": "{{ cookiecutter.a }}" 12 | } 13 | ], 14 | "applied_templates": [ 15 | { 16 | "name": "one", 17 | "data": { 18 | "a": "b", 19 | "c": "" 20 | }, 21 | "version": "d512c7e14e83cb4bc8d4e5ae06bb357e", 22 | "add_mode": "local", 23 | "root": "." 24 | }, 25 | { 26 | "name": "one", 27 | "data": { 28 | "a": "b", 29 | "c": "" 30 | }, 31 | "version": "d512c7e14e83cb4bc8d4e5ae06bb357e", 32 | "add_mode": "project", 33 | "root": "subdir1" 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /flexlate/finder/specific/git.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Dict, Optional, Union 3 | 4 | from git import Repo 5 | 6 | from flexlate.ext_git import get_current_version 7 | from flexlate.template_path import is_repo_url 8 | 9 | 10 | def get_version_from_source_path( 11 | path: Union[str, Path], 12 | local_path: Path, 13 | ) -> Optional[str]: 14 | if is_repo_url(str(path)): 15 | # Get version from repo 16 | return get_current_version(Repo(str(local_path))) 17 | return None 18 | 19 | 20 | def get_git_url_from_source_path( 21 | path: Union[str, Path], 22 | template_kwargs: Dict[str, Any], 23 | ) -> Optional[str]: 24 | if "git_url" in template_kwargs: 25 | potential_url = template_kwargs.pop("git_url") 26 | if potential_url: 27 | return potential_url 28 | if is_repo_url(str(path)): 29 | # Get version from repo 30 | return str(path) 31 | return None 32 | -------------------------------------------------------------------------------- /tests/input_files/configs/subdir2/flexlate.json: -------------------------------------------------------------------------------- 1 | { 2 | "template_sources": [ 3 | { 4 | "name": "two", 5 | "path": "../../input_files/templates/cookiecutters/two", 6 | "type": "cookiecutter", 7 | "version": null, 8 | "git_url": null, 9 | "target_version": null, 10 | "render_relative_root_in_output": "{{ cookiecutter.a }}", 11 | "render_relative_root_in_template": "{{ cookiecutter.a }}" 12 | } 13 | ], 14 | "applied_templates": [ 15 | { 16 | "name": "two", 17 | "data": { 18 | "a": "b", 19 | "d": "e" 20 | }, 21 | "version": "some version", 22 | "add_mode": "local", 23 | "root": "." 24 | }, 25 | { 26 | "name": "one", 27 | "data": { 28 | "a": "b", 29 | "c": "something" 30 | }, 31 | "version": "d512c7e14e83cb4bc8d4e5ae06bb357e", 32 | "add_mode": "local", 33 | "root": "subdir2_2" 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /flexlate/add_mode.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from pathlib import Path 3 | 4 | 5 | class AddMode(str, Enum): 6 | LOCAL = "local" 7 | PROJECT = "project" 8 | USER = "user" 9 | 10 | 11 | def get_expanded_out_root( 12 | out_root: Path, 13 | project_root: Path, 14 | render_relative_root_in_output: Path, 15 | add_mode: AddMode, 16 | ) -> Path: 17 | if add_mode == AddMode.USER: 18 | # Always use full absolute paths for user 19 | return out_root.absolute() 20 | if add_mode == AddMode.PROJECT: 21 | # Return a project-relative path for project 22 | return out_root.absolute().relative_to(project_root.absolute()) 23 | if add_mode == AddMode.LOCAL: 24 | # Needs to be the relative path to get back to template root from the 25 | # render relative root 26 | return Path(*[".." for _ in range(len(render_relative_root_in_output.parts))]) 27 | raise ValueError(f"unsupported add mode {add_mode}") 28 | -------------------------------------------------------------------------------- /scripts/workflows-updated.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$(git diff --stat HEAD -- .github/workflows/)" ]; then 4 | echo "Updates to workflows detected."; 5 | echo ::set-output name=workflow_updated::true; 6 | cat << EOF > temp-issue-template.md; 7 | --- 8 | title: Manual Update to Files from Cookiecutter Needed 9 | labels: automated issue, maintenance 10 | --- 11 | The template from the [Cookiecutter which created this project][1] must be updated using Cruft. 12 | 13 | Normally this is an automated process, but the current updates include changes to the 14 | Github Actions workflow files, and Github Actions does not allow those to be updated 15 | by another workflow. 16 | 17 | Run \`pipenv run cruft update -s\` then manually review and update the changes, before pushing a PR 18 | for this. 19 | 20 | [1]: https://github.com/nickderobertis/cookiecutter-pypi-sphinx 21 | 22 | EOF 23 | else 24 | echo "No updates to workflows."; 25 | echo ::set-output name=workflow_updated::false; 26 | fi; -------------------------------------------------------------------------------- /tests/ext_subprocess.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | import subprocess 3 | from typing import Dict, List, Optional, Union 4 | 5 | from flexlate.template_data import TemplateData 6 | 7 | 8 | def run( 9 | command: str, input_data: Optional[Dict[str, str]] = None 10 | ) -> subprocess.CompletedProcess: 11 | split_command = shlex.split(command) 12 | input = get_text_input_from_data(input_data) 13 | return subprocess.run(split_command, check=True, input=input, encoding="utf8") 14 | 15 | 16 | def get_text_input_from_data( 17 | input_data: Optional[Union[TemplateData, List[TemplateData]]] 18 | ) -> Optional[str]: 19 | if input_data is None: 20 | return None 21 | if isinstance(input_data, list): 22 | return "\n".join(_template_data_to_text_input(data) for data in input_data) 23 | return _template_data_to_text_input(input_data) 24 | 25 | 26 | def _template_data_to_text_input(input_data: TemplateData) -> str: 27 | return "\n".join(str(value) for value in input_data.values()) 28 | -------------------------------------------------------------------------------- /.github/workflows/flexlate-after-merge.yml: -------------------------------------------------------------------------------- 1 | name: Flexlate After-Merge 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - flexlate-output-** 7 | types: [closed] 8 | workflow_dispatch: 9 | inputs: 10 | branch: 11 | description: "The name of the base branch that the Flexlate branches were created on" 12 | required: false 13 | type: string 14 | default: template-patches 15 | 16 | jobs: 17 | merge_flexlate_branches: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | max-parallel: 1 21 | matrix: 22 | python-version: [3.8] 23 | 24 | if: (github.event.pull_request.merged == true || github.event.inputs.branch ) 25 | steps: 26 | - uses: actions/checkout@v3 27 | with: 28 | ref: master 29 | fetch-depth: 0 30 | - uses: nickderobertis/flexlate-merge-action@v1 31 | with: 32 | branch_name: ${{ inputs.branch }} 33 | gh_token: ${{ secrets.GH_TOKEN }} 34 | main_branch: master 35 | -------------------------------------------------------------------------------- /upload.py: -------------------------------------------------------------------------------- 1 | from distutils.core import run_setup 2 | from subprocess import run 3 | 4 | import conf 5 | import version 6 | 7 | DISTRIBUTION_NAME = f"{conf.PACKAGE_NAME}-{version.__version__}" 8 | DISTRIBUTION_PATH = f"dist/{DISTRIBUTION_NAME}.tar.gz" 9 | 10 | 11 | def twine(main_command: str): 12 | command = f"twine {main_command} {DISTRIBUTION_PATH}" 13 | run(command, shell=True, check=True) 14 | 15 | 16 | def upload_app(build_only: bool = False): 17 | run_setup("setup.py", script_args=["sdist", "bdist_wheel"]) 18 | if build_only: 19 | return 20 | twine("upload") 21 | 22 | 23 | if __name__ == "__main__": 24 | import argparse 25 | 26 | parser = argparse.ArgumentParser( 27 | description=f"Build and upload {conf.PACKAGE_NAME} to PyPi" 28 | ) 29 | parser.add_argument( 30 | "--build-only", 31 | action="store_true", 32 | help="Only build the package, do not upload", 33 | ) 34 | 35 | args = parser.parse_args() 36 | 37 | upload_app(build_only=args.build_only) 38 | -------------------------------------------------------------------------------- /.github/actions/install-dependencies/action.yml: -------------------------------------------------------------------------------- 1 | name: Install Python Dependencies 2 | description: "Installs Python dependencies for the project." 3 | inputs: 4 | python-version: 5 | required: true 6 | description: "The Python version to install dependencies for." 7 | runs: 8 | using: composite 9 | steps: 10 | - name: Install pipenv 11 | shell: bash 12 | run: pipx install pipenv==2022.3.24 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | cache: pipenv 18 | - uses: actions/cache@v3 19 | name: Cache global dependencies 20 | with: 21 | path: ${{ env.pythonLocation }} 22 | key: ${{ env.pythonLocation }}-${{ hashFiles('*-requirements.txt') }} 23 | - name: Install main dependencies 24 | shell: bash 25 | run: | 26 | pipenv --python ${{ matrix.python-version }} sync 27 | - name: Install linting dependencies 28 | shell: bash 29 | run: | 30 | pip install -r lint-requirements.txt 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | # Install git 2.35.1 as that is what is in CI 4 | RUN apt update 5 | RUN apt install make libssl-dev libghc-zlib-dev libcurl4-gnutls-dev libexpat1-dev gettext unzip -y 6 | RUN wget https://github.com/git/git/archive/v2.35.1.zip -O git.zip && \ 7 | unzip git.zip && \ 8 | cd git-* && \ 9 | make prefix=/usr/local all && \ 10 | make prefix=/usr/local install 11 | RUN echo "Git version $(git --version)" 12 | 13 | WORKDIR app 14 | 15 | RUN pip install pipenv 16 | 17 | COPY Pipfile . 18 | COPY Pipfile.lock . 19 | 20 | RUN pipenv sync 21 | 22 | # Set up a sample repo for cli testing 23 | RUN mkdir cli-testing && \ 24 | cd cli-testing && \ 25 | git init && \ 26 | git config --global user.email "flexlate-git@nickderobertis.com" && \ 27 | git config --global user.name "flexlate-git" && \ 28 | touch README.md && \ 29 | git add -A && \ 30 | git commit -m "Initial commit" 31 | 32 | COPY . . 33 | RUN pipenv run python upload.py --build-only 34 | RUN pip install dist/flexlate*.whl 35 | RUN fxt --install-completion bash 36 | 37 | WORKDIR cli-testing -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # Editor files 61 | .idea 62 | 63 | .mypy_cache 64 | docs 65 | docsrc/source/auto_examples 66 | _examples 67 | **/.ipynb_checkpoints 68 | .vscode 69 | .nox 70 | node_modules -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Nick DeRobertis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/actions/build-and-deploy-docs/action.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Docs 2 | description: "Installs docs-specific dependencies, builds the docs, and deploys them. Be sure to run install-dependencies first" 3 | inputs: 4 | gh-token: 5 | required: true 6 | description: "The GitHub token to use for authentication." 7 | runs: 8 | using: composite 9 | steps: 10 | - name: Install docs-specific dependencies 11 | shell: bash 12 | run: | 13 | sudo apt-get install pandoc -y 14 | # Need to set Git committer to run terminal examples in docs 15 | - name: Set Git Committer 16 | shell: bash 17 | run: | 18 | git config --global user.email "flexlate-git@nickderobertis.com" 19 | git config --global user.name "flexlate-git" 20 | git config --global init.defaultBranch main 21 | - name: Build Documentation 22 | shell: bash 23 | run: | 24 | pipenv run nox -s docs 25 | echo "" > docs/.nojekyll 26 | - name: Deploy Documentation 27 | uses: peaceiris/actions-gh-pages@v2.5.0 28 | env: 29 | GITHUB_TOKEN: ${{ inputs.gh-token }} 30 | PUBLISH_BRANCH: gh-pages 31 | PUBLISH_DIR: ./docs 32 | -------------------------------------------------------------------------------- /flexlate/template/copier.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | from flexlate.template.base import Template 5 | from flexlate.template.types import TemplateType 6 | from flexlate.template_config.copier import CopierConfig 7 | 8 | 9 | class CopierTemplate(Template): 10 | _type = TemplateType.COPIER 11 | 12 | def __init__( 13 | self, 14 | config: CopierConfig, 15 | path: Path, 16 | name: Optional[str] = None, 17 | version: Optional[str] = None, 18 | target_version: Optional[str] = None, 19 | git_url: Optional[str] = None, 20 | template_source_path: Optional[str] = None, 21 | render_relative_root_in_template: Path = Path("."), 22 | ): 23 | super().__init__( 24 | config, 25 | path, 26 | name=name, 27 | version=version, 28 | target_version=target_version, 29 | git_url=git_url, 30 | template_source_path=template_source_path, 31 | render_relative_root_in_template=render_relative_root_in_template, 32 | # Render relative root in output is always . for Copier 33 | render_relative_root_in_output=Path("."), 34 | ) 35 | -------------------------------------------------------------------------------- /flexlate/template/hashing.py: -------------------------------------------------------------------------------- 1 | # Adapted from https://stackoverflow.com/a/54477583/6276321 2 | import hashlib 3 | from pathlib import Path 4 | from typing import Union 5 | 6 | from _hashlib import HASH as Hash 7 | 8 | 9 | def md5_update_from_file(filename: Union[str, Path], hash: Hash) -> Hash: 10 | assert Path(filename).is_file() 11 | with open(str(filename), "rb") as f: 12 | for chunk in iter(lambda: f.read(4096), b""): 13 | hash.update(chunk) 14 | return hash 15 | 16 | 17 | def md5_file(filename: Union[str, Path]) -> str: 18 | return str(md5_update_from_file(filename, hashlib.md5()).hexdigest()) # type: ignore 19 | 20 | 21 | def md5_update_from_dir(directory: Union[str, Path], hash: Hash) -> Hash: 22 | assert Path(directory).is_dir() 23 | for path in sorted(Path(directory).iterdir(), key=lambda p: str(p).lower()): 24 | hash.update(path.name.encode()) 25 | if path.is_file(): 26 | hash = md5_update_from_file(path, hash) 27 | elif path.is_dir(): 28 | hash = md5_update_from_dir(path, hash) 29 | return hash 30 | 31 | 32 | def md5_dir(directory: Union[str, Path]) -> str: 33 | return str(md5_update_from_dir(directory, hashlib.md5()).hexdigest()) # type: ignore 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | import conf 4 | 5 | extra_kwargs = {} 6 | 7 | entry_points = None 8 | if conf.CONSOLE_SCRIPTS: 9 | entry_points = dict(console_scripts=conf.CONSOLE_SCRIPTS) 10 | 11 | extras_require = None 12 | if conf.OPTIONAL_PACKAGE_INSTALL_REQUIRES: 13 | extras_require = conf.OPTIONAL_PACKAGE_INSTALL_REQUIRES 14 | 15 | long_description = conf.PACKAGE_DESCRIPTION 16 | if conf.PACKAGE_DESCRIPTION.strip().lower() == "auto": 17 | with open("README.md", "r") as f: 18 | long_description = f.read() 19 | extra_kwargs["long_description_content_type"] = "text/markdown" 20 | 21 | setup( 22 | name=conf.PACKAGE_NAME, 23 | description=conf.PACKAGE_SHORT_DESCRIPTION, 24 | long_description=long_description, 25 | author=conf.PACKAGE_AUTHOR, 26 | author_email=conf.PACKAGE_AUTHOR_EMAIL, 27 | license=conf.PACKAGE_LICENSE, 28 | packages=find_packages(), 29 | include_package_data=True, 30 | classifiers=conf.PACKAGE_CLASSIFIERS, 31 | install_requires=conf.PACKAGE_INSTALL_REQUIRES, 32 | extras_require=extras_require, 33 | project_urls=conf.PACKAGE_URLS, 34 | url=conf.PACKAGE_URLS["Code"], 35 | scripts=conf.SCRIPTS, 36 | entry_points=entry_points, 37 | **extra_kwargs 38 | ) 39 | -------------------------------------------------------------------------------- /tests/integration/cli/test_update.py: -------------------------------------------------------------------------------- 1 | from git import Repo 2 | 3 | from flexlate.path_ops import change_directory_to 4 | from tests import config 5 | from tests.integration.cli.fxt import fxt 6 | from tests.integration.fixtures.repo import * 7 | 8 | 9 | def test_update_returns_code_0_for_update_without_conflict( 10 | repo_with_copier_remote_version_one_and_no_target_version: Repo, 11 | ): 12 | with change_directory_to(config.GENERATED_REPO_DIR): 13 | result = fxt(["update", "--no-input", "--abort"]) 14 | assert result.exit_code == 0 15 | 16 | 17 | def test_update_returns_code_1_for_template_conflict_with_abort( 18 | repo_with_copier_remote_version_one_no_target_version_and_will_have_a_conflict_on_update: Repo, 19 | ): 20 | with change_directory_to(config.GENERATED_REPO_DIR): 21 | result = fxt(["update", "--no-input", "--abort"]) 22 | assert result.exit_code == 1 23 | 24 | 25 | def test_update_returns_code_1_for_template_conflict_with_abort_and_no_cleanup( 26 | repo_with_copier_remote_version_one_no_target_version_and_will_have_a_conflict_on_update: Repo, 27 | ): 28 | with change_directory_to(config.GENERATED_REPO_DIR): 29 | result = fxt(["update", "--no-input", "--abort", "--no-cleanup"]) 30 | assert result.exit_code == 1 31 | -------------------------------------------------------------------------------- /flexlate/template/cookiecutter.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | from flexlate.template.base import Template 5 | from flexlate.template.types import TemplateType 6 | from flexlate.template_config.cookiecutter import CookiecutterConfig 7 | 8 | 9 | class CookiecutterTemplate(Template): 10 | _type = TemplateType.COOKIECUTTER 11 | 12 | def __init__( 13 | self, 14 | config: CookiecutterConfig, 15 | path: Path, 16 | render_relative_root: Path, 17 | name: Optional[str] = None, 18 | version: Optional[str] = None, 19 | target_version: Optional[str] = None, 20 | git_url: Optional[str] = None, 21 | template_source_path: Optional[str] = None, 22 | ): 23 | super().__init__( 24 | config, 25 | path, 26 | name=name, 27 | version=version, 28 | target_version=target_version, 29 | git_url=git_url, 30 | template_source_path=template_source_path, 31 | # Output relative root is always the same as template relative root for Cookiecutter 32 | render_relative_root_in_output=render_relative_root, 33 | render_relative_root_in_template=render_relative_root, 34 | ) 35 | -------------------------------------------------------------------------------- /docsrc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = flexlate 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | autodoc: 18 | @sphinx-apidoc -M -o ./source/api -t ./apidoc/templates "../$(SPHINXPROJ)" 19 | 20 | cleandoc: 21 | @rm -rf ./source/api 22 | @rm -rf ./source/stubs 23 | @rm -rf ./build 24 | @rm -rf ./source/auto_examples 25 | @rm -rf ../_examples 26 | @rm -rf ./source/binder/requirements.txt 27 | @rm -rf ./source/commands.md 28 | 29 | github: 30 | @make cleandoc 31 | @./binder_requirements.sh 32 | @./nb-examples.sh 33 | @./download-logo.sh 34 | @./generate-cli-command-reference.sh 35 | @make doctest 36 | @make autodoc 37 | @make html 38 | @cp -a build/html/. ../docs 39 | 40 | # Catch-all target: route all unknown targets to Sphinx using the new 41 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 42 | %: Makefile 43 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 44 | 45 | -------------------------------------------------------------------------------- /flexlate/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | 4 | from pydantic import BaseSettings, validator 5 | from rich.logging import RichHandler 6 | 7 | 8 | class LogLevel(str, Enum): 9 | INFO = "INFO" 10 | DEBUG = "DEBUG" 11 | 12 | 13 | class LoggingConfig(BaseSettings): 14 | level: LogLevel = LogLevel.INFO 15 | 16 | class Config: 17 | env_prefix = "FLEXLATE_LOG_" 18 | 19 | @validator("level", pre=True) 20 | def cast_log_level(cls, v): 21 | if isinstance(v, LogLevel): 22 | return v 23 | level = str(v).casefold().strip() 24 | if level == "info": 25 | return LogLevel.INFO 26 | elif level == "debug": 27 | return LogLevel.DEBUG 28 | raise ValueError(f"invalid log level {level}") 29 | 30 | 31 | LOGGING_CONFIG = LoggingConfig() 32 | 33 | logging.basicConfig( 34 | level=LOGGING_CONFIG.level.value, 35 | format="%(message)s", 36 | datefmt="[%X]", 37 | handlers=[RichHandler(rich_tracebacks=True)], 38 | ) 39 | 40 | log = logging.getLogger("flexlate") 41 | 42 | if __name__ == "__main__": 43 | log.info("info level") 44 | log.debug("debug level") 45 | try: 46 | raise ValueError("exception") 47 | except ValueError as e: 48 | log.exception(e) 49 | -------------------------------------------------------------------------------- /tests/fileutils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | from tests import config 5 | 6 | 7 | def _generated_text_content( 8 | folder: str, file: str, gen_dir: Optional[Path] = None 9 | ) -> str: 10 | gen_dir = gen_dir or config.GENERATED_FILES_DIR 11 | path = gen_dir / folder / file 12 | assert path.exists() 13 | return path.read_text() 14 | 15 | 16 | def cookiecutter_one_generated_text_content( 17 | folder: str = "b", file: str = "text.txt", gen_dir: Optional[Path] = None 18 | ) -> str: 19 | gen_dir = gen_dir or config.GENERATED_FILES_DIR 20 | return _generated_text_content(folder, file, gen_dir=gen_dir) 21 | 22 | 23 | def cookiecutter_two_generated_text_content( 24 | folder: str = "b", file: str = "text2.txt", gen_dir: Optional[Path] = None 25 | ) -> str: 26 | gen_dir = gen_dir or config.GENERATED_FILES_DIR 27 | return _generated_text_content(folder, file, gen_dir=gen_dir) 28 | 29 | 30 | def preprend_cookiecutter_one_generated_text(content: str): 31 | COOKIECUTTER_ONE_GENERATED_TEXT_PATH = config.GENERATED_REPO_DIR / "b" / "text.txt" 32 | current_content = COOKIECUTTER_ONE_GENERATED_TEXT_PATH.read_text() 33 | full_content = content + current_content 34 | COOKIECUTTER_ONE_GENERATED_TEXT_PATH.write_text(full_content) 35 | -------------------------------------------------------------------------------- /tests/integration/cli/test_check.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from flexlate.template.copier import CopierTemplate 4 | from tests import config 5 | from tests.fixtures.template import * 6 | from tests.fixtures.templated_repo import * 7 | from tests.fixtures.transaction import * 8 | from tests.integration.cli_stub import ExceptionHandling 9 | from tests.integration.cli_stub import fxt as _fxt 10 | 11 | # Ignore exceptions so that we can test error codes instead 12 | fxt = partial(_fxt, exception_handling=ExceptionHandling.IGNORE) 13 | 14 | 15 | def test_check_returns_code_1_when_updates_available( 16 | repo_with_cookiecutter_remote_version_one_template_source_and_no_target_version: Repo, 17 | copier_one_template: CopierTemplate, 18 | add_source_transaction: FlexlateTransaction, 19 | ): 20 | with change_directory_to(config.GENERATED_REPO_DIR): 21 | result = fxt("check") 22 | assert result.exit_code == 1 23 | 24 | 25 | def test_check_returns_code_0_when_no_updates_available( 26 | repo_with_cookiecutter_remote_version_one_template_source: Repo, 27 | copier_one_template: CopierTemplate, 28 | add_source_transaction: FlexlateTransaction, 29 | ): 30 | with change_directory_to(config.GENERATED_REPO_DIR): 31 | result = fxt("check") 32 | assert result.exit_code == 0 33 | -------------------------------------------------------------------------------- /docsrc/apidoc/templates/package.rst_t: -------------------------------------------------------------------------------- 1 | {%- macro automodule(modname, options) -%} 2 | .. automodule:: {{ modname }} 3 | {%- for option in options %} 4 | :{{ option }}: 5 | {%- endfor %} 6 | {%- endmacro %} 7 | 8 | {%- macro toctree(docnames) -%} 9 | .. toctree:: 10 | {% for docname in docnames %} 11 | {{ docname }} 12 | {%- endfor %} 13 | {%- endmacro %} 14 | 15 | {%- if is_namespace %} 16 | {{- [pkgname, "namespace"] | join(" ") | e | heading }} 17 | {% else %} 18 | {{- [pkgname, "package"] | join(" ") | e | heading }} 19 | {% endif %} 20 | 21 | {%- if modulefirst and not is_namespace %} 22 | {{ automodule(pkgname, automodule_options) }} 23 | {% endif %} 24 | 25 | {%- if subpackages %} 26 | Subpackages 27 | ----------- 28 | 29 | {{ toctree(subpackages) }} 30 | {% endif %} 31 | 32 | {%- if submodules %} 33 | Submodules 34 | ---------- 35 | {% if separatemodules %} 36 | {{ toctree(submodules) }} 37 | {%- else %} 38 | {%- for submodule in submodules %} 39 | {% if show_headings %} 40 | {{- [submodule, "module"] | join(" ") | e | heading(2) }} 41 | {% endif %} 42 | {{ automodule(submodule, automodule_options) }} 43 | {% endfor %} 44 | {%- endif %} 45 | {% endif %} 46 | 47 | {%- if not modulefirst and not is_namespace %} 48 | Module contents 49 | --------------- 50 | 51 | {{ automodule(pkgname, automodule_options) }} 52 | {% endif %} -------------------------------------------------------------------------------- /flexlate/finder/multi.py: -------------------------------------------------------------------------------- 1 | from typing import Final, List, Optional, Sequence 2 | 3 | from flexlate.exc import InvalidTemplatePathException 4 | from flexlate.finder.specific.base import TemplateFinder 5 | from flexlate.finder.specific.cookiecutter import CookiecutterFinder 6 | 7 | # TODO: add a way for user to extend specific finders 8 | from flexlate.finder.specific.copier import CopierFinder 9 | from flexlate.template.base import Template 10 | from flexlate.template_path import get_local_repo_path_and_name_cloning_if_repo_url 11 | 12 | SPECIFIC_FINDERS: Final[List[TemplateFinder]] = [ 13 | CookiecutterFinder(), 14 | CopierFinder(), 15 | ] 16 | 17 | 18 | class MultiFinder: 19 | def find( 20 | self, 21 | path: str, 22 | version: Optional[str] = None, 23 | finders: Optional[Sequence[TemplateFinder]] = None, 24 | ) -> Template: 25 | local_path, name = get_local_repo_path_and_name_cloning_if_repo_url( 26 | path, version 27 | ) 28 | finders = finders or SPECIFIC_FINDERS 29 | for finder in finders: 30 | if finder.matches_template_type(local_path): 31 | return finder.find(path, local_path, version=version, name=name) 32 | raise InvalidTemplatePathException( 33 | f"could not find a template at {path} with any of the registered template finders" 34 | ) 35 | -------------------------------------------------------------------------------- /tests/fixtures/renderable.py: -------------------------------------------------------------------------------- 1 | from flexlate.render.renderable import Renderable 2 | from flexlate.template.copier import CopierTemplate 3 | from tests import config 4 | from tests.fixtures.template import * 5 | 6 | 7 | @pytest.fixture 8 | def cookiecutter_one_renderable( 9 | cookiecutter_one_template: CookiecutterTemplate, 10 | ) -> Renderable: 11 | return Renderable( 12 | template=cookiecutter_one_template, out_root=config.GENERATED_FILES_DIR 13 | ) 14 | 15 | 16 | @pytest.fixture 17 | def copier_one_renderable( 18 | copier_one_template: CopierTemplate, 19 | ) -> Renderable: 20 | return Renderable(template=copier_one_template, out_root=config.GENERATED_FILES_DIR) 21 | 22 | 23 | @pytest.fixture 24 | def copier_output_subdir_renderable( 25 | copier_output_subdir_template: CopierTemplate, 26 | ) -> Renderable: 27 | return Renderable( 28 | template=copier_output_subdir_template, out_root=config.GENERATED_FILES_DIR 29 | ) 30 | 31 | 32 | @pytest.fixture 33 | def cookiecutter_two_renderable( 34 | cookiecutter_two_template: CookiecutterTemplate, 35 | ) -> Renderable: 36 | return Renderable( 37 | template=cookiecutter_two_template, out_root=config.GENERATED_FILES_DIR 38 | ) 39 | 40 | 41 | @pytest.fixture 42 | def cookiecutter_local_renderables( 43 | cookiecutter_one_renderable: Renderable, cookiecutter_two_renderable: Renderable 44 | ) -> List[Renderable]: 45 | return [cookiecutter_one_renderable, cookiecutter_two_renderable] 46 | -------------------------------------------------------------------------------- /tests/fixtures/git.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from git import Repo 3 | 4 | from flexlate.ext_git import stage_and_commit_all 5 | from flexlate.path_ops import change_directory_to 6 | from tests import config 7 | from tests.dirutils import wipe_generated_folder 8 | from tests.gitutils import ( 9 | add_dummy_file2_to_repo, 10 | add_dummy_file_to_repo, 11 | add_gitignore_and_ignored_file_to_repo, 12 | create_empty_repo, 13 | ) 14 | 15 | 16 | @pytest.fixture 17 | def empty_generated_repo() -> Repo: 18 | wipe_generated_folder() 19 | yield create_empty_repo() 20 | 21 | 22 | @pytest.fixture 23 | def repo_with_placeholder_committed(empty_generated_repo: Repo) -> Repo: 24 | repo = empty_generated_repo 25 | with change_directory_to(config.GENERATED_REPO_DIR): 26 | add_dummy_file_to_repo(repo) 27 | stage_and_commit_all(repo, "Initial commit") 28 | yield repo 29 | 30 | 31 | @pytest.fixture 32 | def dirty_repo(repo_with_placeholder_committed: Repo) -> Repo: 33 | repo = repo_with_placeholder_committed 34 | with change_directory_to(config.GENERATED_REPO_DIR): 35 | add_dummy_file2_to_repo(repo) 36 | yield repo 37 | 38 | 39 | @pytest.fixture 40 | def repo_with_gitignore(repo_with_placeholder_committed: Repo) -> Repo: 41 | repo = repo_with_placeholder_committed 42 | with change_directory_to(config.GENERATED_REPO_DIR): 43 | add_gitignore_and_ignored_file_to_repo(repo) 44 | stage_and_commit_all(repo, "Add sample .gitignore with ignored file") 45 | yield repo 46 | -------------------------------------------------------------------------------- /tests/fixtures/cli.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import Final, List 4 | 5 | import pytest 6 | 7 | from flexlate.main import Flexlate 8 | from tests.integration.cli_stub import CLIStubFlexlate, ExceptionHandling 9 | 10 | 11 | class FlexlateType(str, Enum): 12 | APP = "app" 13 | CLI = "cli" 14 | 15 | 16 | @dataclass 17 | class FlexlateFixture: 18 | flexlate: Flexlate 19 | type: FlexlateType 20 | 21 | 22 | flexlate_app_fixture: Final[FlexlateFixture] = FlexlateFixture( 23 | flexlate=Flexlate(), type=FlexlateType.APP 24 | ) 25 | 26 | # NOTE: If any integration tests are failing, temporarily comment the CLIStubFlexlate 27 | # fixture while debugging, as the CLI tests are the same but much harder to debug 28 | flexlate_fixtures: Final[List[FlexlateFixture]] = [ 29 | flexlate_app_fixture, 30 | FlexlateFixture(flexlate=CLIStubFlexlate(), type=FlexlateType.CLI), 31 | ] 32 | 33 | flexlate_ignore_cli_exceptions_fixtures: Final[List[FlexlateFixture]] = [ 34 | flexlate_app_fixture, 35 | FlexlateFixture( 36 | flexlate=CLIStubFlexlate(exception_handling=ExceptionHandling.IGNORE), 37 | type=FlexlateType.CLI, 38 | ), 39 | ] 40 | 41 | 42 | @pytest.fixture(scope="module", params=flexlate_fixtures) 43 | def flexlates(request) -> FlexlateFixture: 44 | return request.param 45 | 46 | 47 | @pytest.fixture(scope="module", params=flexlate_ignore_cli_exceptions_fixtures) 48 | def flexlates_ignore_cli_exceptions(request) -> FlexlateFixture: 49 | return request.param 50 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/get-started/installing.md: -------------------------------------------------------------------------------- 1 | # Installing Flexlate 2 | 3 | Flexlate is a Python package that includes the `fxt` command line utility. 4 | If you do not have Python, you will need to [install it](https://www.python.org/downloads/) 5 | first (required version is `>=3.8`). 6 | 7 | The recommended way to install Flexlate is with [`pipx`](https://github.com/pypa/pipx), 8 | though it can also be installed with `pip`. 9 | 10 | ``` 11 | pipx install flexlate 12 | ``` 13 | 14 | Or, if you don't have/don't want to install `pipx`: 15 | 16 | ``` 17 | pip install flexlate 18 | ``` 19 | 20 | Before using Flexlate, you will also need to have 21 | [Git installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). 22 | 23 | ## Next Steps 24 | 25 | There are multiple ways to get started with Flexlate, depending on your 26 | project situation and goals. Pick the guide that best fits your situation, 27 | then move on to the other concepts in the [user guide](../index.md). 28 | 29 | - If you want to create a new project with Flexlate, you should follow 30 | the [new project](new-project.md) guide. 31 | - If you already 32 | have a project that was previously generated from a template using 33 | `cookiecutter` or `copier` and you want to update it with Flexlate, 34 | you should follow the [existing project guide](existing-project.md). 35 | - If you want to use Flexlate to add new templates within an existing 36 | project that was not generated from `cookiecutter` or `copier`, you 37 | should follow the [add to project guide](add-to-project.md). 38 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Build and Push Docs 3 | 4 | on: 5 | push: 6 | paths: 7 | - "docsrc/**" 8 | - "README.md" 9 | branches: 10 | - master 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | max-parallel: 1 17 | matrix: 18 | python-version: [3.8] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: ./.github/actions/install-dependencies 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - uses: ./.github/actions/lint-and-test 26 | 27 | deploy: 28 | needs: test 29 | 30 | runs-on: ubuntu-latest 31 | strategy: 32 | max-parallel: 1 33 | matrix: 34 | python-version: [3.8] 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | - uses: ./.github/actions/install-dependencies 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | - uses: ./.github/actions/build-and-deploy-docs 42 | with: 43 | gh-token: ${{ secrets.GH_TOKEN }} 44 | 45 | coverage: 46 | needs: deploy 47 | runs-on: ubuntu-latest 48 | strategy: 49 | max-parallel: 1 50 | matrix: 51 | python-version: [3.8] 52 | steps: 53 | - uses: actions/checkout@v3 54 | - uses: ./.github/actions/install-dependencies 55 | with: 56 | python-version: ${{ matrix.python-version }} 57 | - name: Run coverage and upload to Codecov.io 58 | uses: ./.github/actions/test-coverage 59 | with: 60 | codecov-token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /flexlate/error_handler.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextlib import contextmanager 3 | from functools import wraps 4 | from types import TracebackType 5 | from typing import Callable, Tuple, Type 6 | 7 | from flexlate.styles import ALERT_STYLE, print_styled 8 | 9 | 10 | def simple_output_for_exceptions(*exceptions: Type[BaseException]): 11 | exception_handler = _create_exception_handler(exceptions) 12 | 13 | def _simple_output_for_exceptions(func): 14 | @wraps(func) 15 | def wrapper(*args, **kwargs): 16 | with _handle_exceptions_with(exception_handler): 17 | return func(*args, **kwargs) 18 | 19 | return wrapper 20 | 21 | return _simple_output_for_exceptions 22 | 23 | 24 | ExceptionHandler = Callable[[Type[BaseException], BaseException, TracebackType], None] 25 | 26 | 27 | @contextmanager 28 | def _handle_exceptions_with(exc_handler: ExceptionHandler): 29 | "Sets a custom exception handler for the scope of a 'with' block." 30 | sys.excepthook = exc_handler # type: ignore 31 | yield 32 | sys.excepthook = sys.__excepthook__ # type: ignore 33 | 34 | 35 | def _create_exception_handler( 36 | exceptions: Tuple[Type[BaseException], ...] 37 | ) -> ExceptionHandler: 38 | def handle_specific_exceptions( 39 | type_: Type[BaseException], value: BaseException, traceback: TracebackType 40 | ): 41 | if isinstance(value, exceptions): 42 | print_styled(f"{type_.__name__}: {value}", ALERT_STYLE) 43 | else: 44 | sys.__excepthook__(type_, value, traceback) 45 | 46 | return handle_specific_exceptions 47 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from flexlate import template_path 6 | from tests import config 7 | from tests.dirutils import create_temp_path_without_cleanup, wipe_generated_folder 8 | 9 | is_ci = os.getenv("CI", False) 10 | use_temp_dir_as_generated_folder = is_ci 11 | 12 | 13 | @pytest.fixture(scope="function", autouse=True) 14 | def before_each(monkeypatch): 15 | # Set git committer for generated repos during tests (necessary for CI) 16 | monkeypatch.setenv("GIT_AUTHOR_NAME", "flexlate-git") 17 | monkeypatch.setenv("GIT_COMMITTER_NAME", "flexlate-git") 18 | monkeypatch.setenv("GIT_AUTHOR_EMAIL", "flexlate-git@nickderobertis.com") 19 | monkeypatch.setenv("GIT_COMMITTER_EMAIL", "flexlate-git@nickderobertis.com") 20 | # Fix for pycharm test runner that runs tests in tests folder 21 | os.chdir(config.PROJECT_DIR) 22 | # If using temp dir as generated folder, overwrite the location 23 | if use_temp_dir_as_generated_folder: 24 | # Create the temp dir but don't cleanup, allow the OS to clean it up 25 | # Doing this because was hitting permission errors on Windows trying to clean 26 | # up generated test files 27 | temp_path = create_temp_path_without_cleanup() 28 | config.GENERATED_FILES_DIR = temp_path 29 | config.GENERATED_REPO_DIR = temp_path / "project" 30 | config.USING_TEMP_DIR_AS_GENERATED_DIR = True 31 | # Save templates in generated folder rather than user dir 32 | monkeypatch.setattr(template_path, "CLONED_REPO_FOLDER", config.GENERATED_FILES_DIR) 33 | 34 | 35 | @pytest.fixture(scope="function", autouse=True) 36 | def after_each(): 37 | yield 38 | wipe_generated_folder() 39 | -------------------------------------------------------------------------------- /tests/integration/test_init_project_from.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from flexlate import Flexlate 4 | from flexlate.path_ops import change_directory_to 5 | from tests import config 6 | from tests.integration.fixtures.template_source import ( 7 | COPIER_LOCAL_FIXTURE, 8 | TemplateSourceFixture, 9 | template_source_in_dir_if_local_template, 10 | template_source_with_temp_dir_if_local_template, 11 | ) 12 | from tests.integration.template_source_checks import ( 13 | assert_root_template_source_output_is_correct, 14 | assert_template_source_output_is_correct, 15 | ) 16 | 17 | fxt = Flexlate() 18 | 19 | 20 | def _init_project_from_template_dir_into_parent(template_source: TemplateSourceFixture): 21 | template_path = Path(template_source.path) 22 | with change_directory_to(template_path): 23 | folder = fxt.init_project_from( 24 | ".", path=Path(".."), no_input=True, data=template_source.input_data 25 | ) 26 | 27 | project_path = template_path.parent / folder 28 | assert_template_source_output_is_correct( 29 | template_source, project_path, override_template_source_path="../one" 30 | ) 31 | 32 | 33 | def test_init_project_from_template_dir_in_temp_dir_into_parent(): 34 | with template_source_with_temp_dir_if_local_template( 35 | COPIER_LOCAL_FIXTURE 36 | ) as template_source: 37 | _init_project_from_template_dir_into_parent(template_source) 38 | 39 | 40 | def test_init_project_from_template_dir_in_user_dir_into_parent(): 41 | with template_source_in_dir_if_local_template( 42 | COPIER_LOCAL_FIXTURE, 43 | config.GENERATED_FILES_DIR, 44 | ) as template_source: 45 | _init_project_from_template_dir_into_parent(template_source) 46 | -------------------------------------------------------------------------------- /tests/fixtures/local_branch_situation.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Final, List 3 | 4 | import pytest 5 | from git import Repo 6 | 7 | from flexlate.ext_git import delete_local_branch 8 | from tests.gitutils import reset_n_commits_without_checkout 9 | 10 | 11 | class LocalBranchSituation(str, Enum): 12 | UP_TO_DATE = "up to date" 13 | DELETED = "deleted" 14 | OUT_OF_DATE = "out of date" 15 | 16 | def apply(self, repo: Repo, branch_name: str): 17 | apply_local_branch_situation(repo, branch_name, self) 18 | 19 | 20 | all_local_branch_situations: Final[List[LocalBranchSituation]] = list( 21 | LocalBranchSituation 22 | ) 23 | 24 | 25 | @pytest.fixture(scope="module", params=all_local_branch_situations) 26 | def local_branch_situation(request) -> LocalBranchSituation: 27 | return request.param 28 | 29 | 30 | # TODO: see if there is a better way to structure multiple parameterized fixtures 31 | # that actually resolve to the same value 32 | @pytest.fixture(scope="module", params=all_local_branch_situations) 33 | def template_branch_situation(request) -> LocalBranchSituation: 34 | return request.param 35 | 36 | 37 | @pytest.fixture(scope="module", params=all_local_branch_situations) 38 | def output_branch_situation(request) -> LocalBranchSituation: 39 | return request.param 40 | 41 | 42 | def apply_local_branch_situation( 43 | repo: Repo, branch_name: str, situation: LocalBranchSituation 44 | ): 45 | if situation == LocalBranchSituation.UP_TO_DATE: 46 | return 47 | if situation == LocalBranchSituation.DELETED: 48 | return delete_local_branch(repo, branch_name) 49 | if situation == LocalBranchSituation.OUT_OF_DATE: 50 | return reset_n_commits_without_checkout(repo, branch_name) 51 | raise NotImplementedError(f"no handling for local branch situation {situation}") 52 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/get-started/existing-project.md: -------------------------------------------------------------------------------- 1 | # Add Flexlate to a Project Already Generated from a Template 2 | 3 | This guide covers taking 4 | a project that was previously generated from a template using 5 | `cookiecutter` or `copier` and hooking it up with Flexlate to enable updating. 6 | 7 | If you want to create a new project with Flexlate, you should follow 8 | the [new project](new-project.md) guide. 9 | If you want to use Flexlate to add new templates within an existing 10 | project that was not generated from `cookiecutter` or `copier`, you 11 | should follow the [add to project guide](add-to-project.md). 12 | 13 | ## `fxt bootstrap` 14 | 15 | The [`fxt bootstrap`](../../commands.md#fxt-bootstrap) command is used 16 | to bootstrap a project that was previously generated from a template 17 | so that it can be used with Flexlate. 18 | 19 | ```{run-git-terminal} 20 | --- 21 | setup: "copier https://github.com/nickderobertis/copier-simple-example.git . --data question1='my answer' --data question2='10' && git add -A && git commit -m 'Add template files without Flexlate'" 22 | input: "[None,'my answer\\n10']" 23 | --- 24 | ls 25 | fxt bootstrap https://github.com/nickderobertis/copier-simple-example 26 | ls 27 | ``` 28 | 29 | We can see that it prompts based on the questions in the template, be 30 | sure to enter these exactly as they are in your project to avoid 31 | unnecessary merge conflicts. 32 | 33 | If your project is not already at the newest version of the template, 34 | you can add the `--version` option to pass a specific version. 35 | 36 | ```shell 37 | fxt bootstrap https://github.com/nickderobertis/copier-simple-example --version c7e1ba1bfb141e9c577e7c21ee4a5d3ae5dde04d 38 | ``` 39 | 40 | See the [command reference](../../commands.md#fxt-bootstrap) for full details. 41 | 42 | ## Next Steps 43 | 44 | See how to [update your project](../updating.md) to a newer template version. -------------------------------------------------------------------------------- /flexlate/template_path.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Optional, Tuple 4 | 5 | import appdirs 6 | 7 | from flexlate.exc import InvalidTemplatePathException 8 | from flexlate.ext_git import checkout_version, clone_repo_at_version_get_repo_and_name 9 | 10 | CLONED_REPO_FOLDER = Path(appdirs.user_data_dir("flexlate")) 11 | 12 | REPO_REGEX = re.compile( 13 | r""" 14 | # something like git:// ssh:// file:// etc. 15 | ((((git|hg)\+)?(git|ssh|file|https?):(//)?) 16 | | # or 17 | (\w+@[\w.]+) # something like user@... 18 | ) 19 | """, 20 | re.VERBOSE, 21 | ) 22 | 23 | 24 | def is_repo_url(value): 25 | """Return True if value is a repository URL.""" 26 | return bool(REPO_REGEX.match(value)) 27 | 28 | 29 | def is_local_template(path: str) -> bool: 30 | return Path(path).exists() 31 | 32 | 33 | def get_local_repo_path_and_name_cloning_if_repo_url( 34 | path: str, version: Optional[str] = None, dst_folder: Optional[Path] = None 35 | ) -> Tuple[Path, str]: 36 | if dst_folder is None: 37 | # Setting here rather than in default params so that mocking 38 | # during tests is possible 39 | dst_folder = CLONED_REPO_FOLDER 40 | 41 | if is_local_template(path): 42 | local_path = Path(path) 43 | return local_path, local_path.resolve().name 44 | 45 | if not is_repo_url(path): 46 | raise InvalidTemplatePathException( 47 | f"Template path {path} is not a valid local path or remote url" 48 | ) 49 | 50 | # Must be a repo url, clone it and return the cloned path 51 | repo, name = clone_repo_at_version_get_repo_and_name( 52 | path, dst_folder, version=version 53 | ) 54 | 55 | # For type narrowing 56 | if repo.working_dir is None: 57 | raise ValueError("repo working dir cannot be None") 58 | return Path(repo.working_dir), name 59 | -------------------------------------------------------------------------------- /tests/integration/undoables.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Callable, Final, List 3 | 4 | from flexlate.main import Flexlate 5 | from tests.config import COOKIECUTTER_REMOTE_NAME, COPIER_ONE_DIR, COPIER_ONE_NAME 6 | from tests.integration.fixtures.template_source import COOKIECUTTER_REMOTE_FIXTURE 7 | 8 | 9 | @dataclass 10 | class UndoableOperation: 11 | operation: Callable[[Flexlate, bool], None] 12 | num_transactions: int = 1 13 | 14 | @property 15 | def name(self) -> str: 16 | return self.operation.__name__ 17 | 18 | 19 | def add_template_source(fxt: Flexlate, is_cli: bool): 20 | fxt.add_template_source(str(COPIER_ONE_DIR)) 21 | 22 | 23 | def apply_template_and_add(fxt: Flexlate, is_cli: bool): 24 | fxt.apply_template_and_add(COOKIECUTTER_REMOTE_NAME, no_input=True) 25 | 26 | 27 | def remove_template_source(fxt: Flexlate, is_cli: bool): 28 | # Must add the template source so it can be removed. It will be two undo operations 29 | fxt.add_template_source(str(COPIER_ONE_DIR)) 30 | fxt.remove_template_source(COPIER_ONE_NAME) 31 | 32 | 33 | def remove_applied_template_and_output(fxt: Flexlate, is_cli: bool): 34 | # Must add the applied template output so it can be removed. It will be two undo operations 35 | fxt.apply_template_and_add(COOKIECUTTER_REMOTE_NAME, no_input=True) 36 | fxt.remove_applied_template_and_output(COOKIECUTTER_REMOTE_NAME) 37 | 38 | 39 | def update(fxt: Flexlate, is_cli: bool): 40 | no_input = not is_cli 41 | fxt.update(data=[COOKIECUTTER_REMOTE_FIXTURE.update_input_data], no_input=no_input) 42 | 43 | 44 | UNDOABLE_OPERATIONS: Final[List[UndoableOperation]] = [ 45 | UndoableOperation(add_template_source), 46 | UndoableOperation(apply_template_and_add), 47 | UndoableOperation(remove_template_source, num_transactions=2), 48 | UndoableOperation(remove_applied_template_and_output, num_transactions=2), 49 | UndoableOperation(update), 50 | ] 51 | -------------------------------------------------------------------------------- /flexlate/finder/specific/cookiecutter.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from cookiecutter.find import find_template 7 | 8 | from flexlate.finder.specific.base import TemplateFinder 9 | from flexlate.finder.specific.git import ( 10 | get_git_url_from_source_path, 11 | get_version_from_source_path, 12 | ) 13 | from flexlate.template.cookiecutter import CookiecutterTemplate 14 | from flexlate.template_config.cookiecutter import CookiecutterConfig 15 | 16 | 17 | class CookiecutterFinder(TemplateFinder[CookiecutterTemplate]): 18 | def find( 19 | self, path: str, local_path: Path, **template_kwargs 20 | ) -> CookiecutterTemplate: 21 | git_version: Optional[str] = None 22 | if "version" in template_kwargs: 23 | git_version = template_kwargs.pop("version") 24 | config = self.get_config(local_path) 25 | version = get_version_from_source_path(path, local_path) or git_version 26 | git_url = get_git_url_from_source_path(path, template_kwargs) 27 | template_source_path = git_url if git_url else path 28 | absolute_template_dir = find_template(local_path) 29 | relative_template_dir = Path( 30 | os.path.relpath(absolute_template_dir, local_path.resolve()) 31 | ) 32 | return CookiecutterTemplate( 33 | config, 34 | local_path, 35 | relative_template_dir, 36 | version=version, 37 | git_url=git_url, 38 | template_source_path=template_source_path, 39 | **template_kwargs 40 | ) 41 | 42 | def get_config(self, directory: Path) -> CookiecutterConfig: 43 | config_path = directory / "cookiecutter.json" 44 | data = json.loads(config_path.read_text()) 45 | return CookiecutterConfig(data) 46 | 47 | def matches_template_type(self, path: Path) -> bool: 48 | return (path / "cookiecutter.json").exists() 49 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/get-started/new-project.md: -------------------------------------------------------------------------------- 1 | # Create a New Project with Flexlate 2 | 3 | This guide covers creating a new project with Flexlate. 4 | 5 | If you already 6 | have a project that was previously generated from a template using 7 | `cookiecutter` or `copier` and you want to update it with Flexlate, 8 | you should follow the [existing project guide](existing-project.md). 9 | If you want to use Flexlate to add new templates within an existing 10 | project that was not generated from `cookiecutter` or `copier`, you 11 | should follow the [add to project guide](add-to-project.md). 12 | 13 | ## `fxt init-from` 14 | 15 | The [`fxt init-from`](../../commands.md#fxt-init-from) command is used to create 16 | a new Flexlate project from a template. It will also create a new folder 17 | and initialize a git repository in it before adding the Flexlate output. 18 | Let's give it a try with a very minimal example template: 19 | 20 | ```{run-git-terminal} 21 | --- 22 | input: "my answer\n10\nmy-project" 23 | --- 24 | fxt init-from https://github.com/nickderobertis/copier-simple-example 25 | cd my-project 26 | ls 27 | ``` 28 | 29 | We can see that it prompts based on the questions in the template, and 30 | then for the name of the generated folder. 31 | 32 | ```{note} 33 | Cookiecutter templates will not prompt for a folder name, it is 34 | already determined by the template questions. 35 | ``` 36 | 37 | We can see that it generated the project complete with a Flexlate 38 | project config and config for the applied template. 39 | 40 | If you want to generate the project from a template at a specific version, 41 | `--version` can be used, for example: 42 | 43 | ```shell 44 | fxt init-from --version c7e1ba1bfb141e9c577e7c21ee4a5d3ae5dde04d https://github.com/nickderobertis/copier-simple-example 45 | ``` 46 | 47 | See the [command reference](../../commands.md#fxt-init-from) for full details. 48 | 49 | ## Next Steps 50 | 51 | See how to [update your project](../updating.md) to a newer template version. 52 | -------------------------------------------------------------------------------- /flexlate/render/renderable.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Generic, Optional, TypeVar 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | from flexlate.config import AppliedTemplateWithSource 7 | from flexlate.template.base import Template 8 | from flexlate.template_data import TemplateData 9 | 10 | T = TypeVar("T", bound=Template) 11 | 12 | 13 | class Renderable(BaseModel, Generic[T]): 14 | template: T 15 | data: TemplateData = Field(default_factory=dict) 16 | out_root: Path = Path(".") 17 | skip_prompts: bool = False 18 | 19 | @classmethod 20 | def from_applied_template_with_source( 21 | cls, 22 | applied_template_with_source: AppliedTemplateWithSource, 23 | data: Optional[TemplateData] = None, 24 | ) -> "Renderable": 25 | template, data_from_config = applied_template_with_source.to_template_and_data() 26 | all_data = {**data_from_config, **(data or {})} 27 | return cls( 28 | template=template, 29 | data=all_data, 30 | out_root=applied_template_with_source.applied_template.root, 31 | ) 32 | 33 | class Config: 34 | arbitrary_types_allowed = True 35 | 36 | def __eq__(self, other): 37 | try: 38 | return all( 39 | [ 40 | # Assumed that templates have unique names by this point 41 | # Comparing only name to avoid issues with comparing between temp repo and main repo, etc. 42 | self.template.name == other.template.name, 43 | self.data == other.data, 44 | self.skip_prompts == other.skip_prompts, 45 | # Resolve out root again for issues comparing between temp repo and main repo, etc. 46 | # Put this last as it is the most expensive check 47 | self.out_root.resolve() == other.out_root.resolve(), 48 | ] 49 | ) 50 | except AttributeError: 51 | return False 52 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/undoing.md: -------------------------------------------------------------------------------- 1 | # Undo Flexlate Operations 2 | 3 | Flexlate commits to branches as it makes changes, so reversing changes 4 | is not as simple as resetting the working directory. 5 | 6 | There are two ways to undo a Flexlate operation: via 7 | [`fxt undo`](../commands.md#fxt-undo) or by deleting the Flexlate 8 | feature branches. 9 | 10 | ## Undo Operations with `fxt undo` 11 | 12 | Think of `fxt undo` as `CTRL/CMD + Z` for Flexlate. It reverses the Git 13 | history to undo the last transaction. Flexlate puts transaction markers in 14 | all its commit messages, and it will only undo commits with a marker 15 | or merging a commit with a marker. 16 | 17 | ```{warning} 18 | Flexlate has multiple protections in place to avoid deleting your 19 | changes, but it still **deletes Git history**. It is recommended to 20 | only use this command if you are following a feature branch workflow. 21 | ``` 22 | 23 | ```{run-fxt-terminal} 24 | --- 25 | setup: "fxt add source https://github.com/nickderobertis/copier-simple-example" 26 | input: "my answer\n10" 27 | --- 28 | fxt add output copier-simple-example 29 | ls 30 | fxt undo 31 | ls 32 | ``` 33 | 34 | ## Undo Operations by Deleting Feature Branches 35 | 36 | Flexlate saves the history of your operations on the 37 | [Flexlate feature branches](../core-concepts.md#flexlate-feature-branches) 38 | that need to be [merged into the main branches to permanently save](saving.md). 39 | If you have not yet merged the feature branches into the main branches, and you 40 | want to reverse all operations you've made on this feature branch, you can 41 | simply delete the Flexlate feature branches. 42 | 43 | ```{run-fxt-terminal} 44 | --- 45 | setup: "fxt add source https://github.com/nickderobertis/copier-simple-example && fxt merge" 46 | input: "[None, 'my answer\\n10']" 47 | --- 48 | git checkout -b my-feature 49 | fxt add output copier-simple-example 50 | ls 51 | git checkout main 52 | git branch -D my-feature 53 | git branch -D flexlate-templates-my-feature 54 | git branch -D flexlate-output-my-feature 55 | ls 56 | ``` -------------------------------------------------------------------------------- /tests/test_pusher.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from typing import Sequence 3 | 4 | from git import Repo 5 | 6 | from flexlate.pusher import Pusher 7 | from tests import config 8 | from tests.dirutils import assert_dir_trees_are_equal 9 | from tests.fixtures.templated_repo import * 10 | from tests.gitutils import add_local_remote, add_remote 11 | 12 | 13 | def test_push_feature_flexlate_branches( 14 | repo_with_template_branch_from_cookiecutter_one_and_feature_flexlate_branches: Repo, 15 | ): 16 | repo = repo_with_template_branch_from_cookiecutter_one_and_feature_flexlate_branches 17 | feature_merged_branch_name = get_flexlate_branch_name( 18 | repo, DEFAULT_MERGED_BRANCH_NAME 19 | ) 20 | feature_template_branch_name = get_flexlate_branch_name( 21 | repo, DEFAULT_TEMPLATE_BRANCH_NAME 22 | ) 23 | pusher = Pusher() 24 | with add_local_remote_and_check_branches_on_exit( 25 | repo, [feature_merged_branch_name, feature_template_branch_name] 26 | ): 27 | pusher.push_feature_flexlate_branches(repo) 28 | 29 | 30 | def test_push_main_flexlate_branches( 31 | repo_with_template_branch_from_cookiecutter_one: Repo, 32 | ): 33 | repo = repo_with_template_branch_from_cookiecutter_one 34 | pusher = Pusher() 35 | with add_local_remote_and_check_branches_on_exit( 36 | repo, [DEFAULT_MERGED_BRANCH_NAME, DEFAULT_TEMPLATE_BRANCH_NAME] 37 | ): 38 | pusher.push_main_flexlate_branches(repo) 39 | 40 | 41 | @contextlib.contextmanager 42 | def add_local_remote_and_check_branches_on_exit( 43 | repo: Repo, branch_names: Sequence[str] 44 | ): 45 | remote_path = config.GENERATED_FILES_DIR / "remote" 46 | remote_repo = add_local_remote(repo, remote_path=remote_path) 47 | 48 | yield 49 | 50 | for branch_name in branch_names: 51 | branch = repo.branches[branch_name] # type: ignore 52 | remote_branch = remote_repo.branches[branch_name] # type: ignore 53 | branch.checkout() 54 | remote_branch.checkout() 55 | assert_dir_trees_are_equal(config.GENERATED_REPO_DIR, remote_path) 56 | -------------------------------------------------------------------------------- /flexlate/syncer.py: -------------------------------------------------------------------------------- 1 | from git import Repo 2 | 3 | from flexlate import exc 4 | from flexlate.config_manager import ConfigManager 5 | from flexlate.constants import DEFAULT_MERGED_BRANCH_NAME, DEFAULT_TEMPLATE_BRANCH_NAME 6 | from flexlate.render.multi import MultiRenderer 7 | from flexlate.styles import INFO_STYLE, SUCCESS_STYLE, print_styled 8 | from flexlate.transactions.transaction import FlexlateTransaction 9 | from flexlate.update.main import Updater 10 | 11 | 12 | class Syncer: 13 | def sync_local_changes_to_flexlate_branches( 14 | self, 15 | repo: Repo, 16 | transaction: FlexlateTransaction, 17 | merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 18 | base_merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 19 | template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 20 | base_template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 21 | no_input: bool = False, 22 | remote: str = "origin", 23 | updater: Updater = Updater(), 24 | renderer: MultiRenderer = MultiRenderer(), 25 | config_manager: ConfigManager = ConfigManager(), 26 | ): 27 | print_styled("Syncing local changes to flexlate branches", INFO_STYLE) 28 | try: 29 | updater.update( 30 | repo, 31 | [], 32 | transaction, 33 | merged_branch_name=merged_branch_name, 34 | base_merged_branch_name=base_merged_branch_name, 35 | template_branch_name=template_branch_name, 36 | base_template_branch_name=base_template_branch_name, 37 | no_input=no_input, 38 | full_rerender=True, 39 | remote=remote, 40 | renderer=renderer, 41 | config_manager=config_manager, 42 | ) 43 | except exc.TriedToCommitButNoChangesException as e: 44 | raise exc.UnnecessarySyncException("Everything is up to date") from e 45 | print_styled( 46 | "Successfully synced local changes to flexlate branches", SUCCESS_STYLE 47 | ) 48 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | nox.options.sessions = ["format", "lint", "test"] 4 | 5 | 6 | @nox.session(python=False) 7 | def format(session): 8 | if session.posargs: 9 | files = session.posargs 10 | else: 11 | files = ["."] 12 | 13 | if session.interactive: 14 | # When run as user, format the files in place 15 | _format_in_place(session, files) 16 | else: 17 | # When run from CI, fail the check if formatting is not correct 18 | session.run("isort", "--check-only", *files) 19 | session.run("black", "--check", *files) 20 | 21 | 22 | @nox.session(python=False) 23 | def format_files(session): 24 | if session.posargs: 25 | files = session.posargs 26 | else: 27 | files = ["."] 28 | 29 | _format_in_place(session, files) 30 | 31 | 32 | def _format_in_place(session, files): 33 | session.run("isort", *files) 34 | session.run("black", *files) 35 | 36 | 37 | @nox.session(python=False) 38 | def lint(session): 39 | session.run( 40 | "flake8", "--count", "--select=E9,F63,F7,F82", "--show-source", "--statistics" 41 | ) 42 | session.run( 43 | "flake8", 44 | "--count", 45 | "--exit-zero", 46 | "--max-complexity=10", 47 | "--max-line-length=127", 48 | "--statistics", 49 | ) 50 | session.run("mypy") 51 | 52 | 53 | @nox.session 54 | def test(session): 55 | session.install( 56 | "-r", "test-requirements.txt", "--upgrade", "--upgrade-strategy", "eager" 57 | ) 58 | session.install(".") 59 | session.run("pytest", *session.posargs) 60 | 61 | 62 | @nox.session 63 | def test_coverage(session): 64 | session.install( 65 | "-r", "test-requirements.txt", "--upgrade", "--upgrade-strategy", "eager" 66 | ) 67 | session.install(".") 68 | session.run("pytest", "--cov=./", "--cov-report=xml") 69 | 70 | 71 | @nox.session(python=False) 72 | def docs(session): 73 | session.chdir("docsrc") 74 | session.run("make", "github") 75 | if session.interactive: 76 | session.run("ls", "-l") 77 | session.run("bash", "./dev-server.sh") 78 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/get-started/add-to-project.md: -------------------------------------------------------------------------------- 1 | # Add New Templates within An Existing Project 2 | 3 | This guide covers using Flexlate to add new templates within an existing 4 | project that was not generated from `cookiecutter` or `copier`. 5 | 6 | If you already 7 | have a project that was previously generated from a template using 8 | `cookiecutter` or `copier` and you want to update it with Flexlate, 9 | you should follow the [existing project guide](existing-project.md). 10 | If you want to create a new project with Flexlate, you should follow 11 | the [new project](new-project.md) guide. 12 | 13 | ## Initialize the Flexlate Project 14 | 15 | The [`fxt init`](../../commands.md#fxt-bootstrap) command is used 16 | to initialize a new Flexlate project. You must have a Flexlate project 17 | before you can add any template sources or outputs. 18 | 19 | ```{run-git-terminal} 20 | fxt init 21 | ``` 22 | 23 | ## Add the Template Source 24 | 25 | The [`fxt add source`](../../commands.md#fxt-add-source) command registers 26 | a template source in the Flexlate configuration file. 27 | 28 | ```{run-fxt-terminal} 29 | fxt add source https://github.com/nickderobertis/copier-simple-example 30 | cat flexlate.json 31 | ``` 32 | 33 | You can also specify the `--version` option to specify which version of 34 | the template you would like to be able to output. 35 | 36 | ```shell 37 | fxt add source https://github.com/nickderobertis/copier-simple-example --version c7e1ba1bfb141e9c577e7c21ee4a5d3ae5dde04d 38 | ``` 39 | 40 | ## Add Template Output(s) 41 | 42 | The [`fxt add output`](../../commands.md#fxt-add-output) command renders the 43 | template at the specified location. 44 | 45 | ```{run-fxt-terminal} 46 | --- 47 | setup: "fxt add source https://github.com/nickderobertis/copier-simple-example" 48 | input: "my answer\n10" 49 | --- 50 | fxt add output copier-simple-example some/path 51 | ls some/path 52 | ``` 53 | 54 | If you don't specify a path to render the template to, the template will be 55 | rendered in the current working directory. 56 | 57 | ```shell 58 | fxt add output copier-simple-example 59 | ``` 60 | 61 | ## Next Steps 62 | 63 | See how to [update your project](../updating.md) to a newer template version. -------------------------------------------------------------------------------- /flexlate.json: -------------------------------------------------------------------------------- 1 | { 2 | "template_sources": [ 3 | { 4 | "name": "copier-pypi-sphinx-flexlate", 5 | "path": "https://github.com/nickderobertis/copier-pypi-sphinx-flexlate", 6 | "type": "copier", 7 | "version": "c0687ae24d563ce6c9ead4267eaa06176f186014", 8 | "git_url": "https://github.com/nickderobertis/copier-pypi-sphinx-flexlate", 9 | "target_version": "c0687ae24d563ce6c9ead4267eaa06176f186014", 10 | "render_relative_root_in_output": ".", 11 | "render_relative_root_in_template": "{{ repo_name }}" 12 | }, 13 | { 14 | "name": "copier-flexlate-github-actions", 15 | "path": "https://github.com/nickderobertis/copier-flexlate-github-actions", 16 | "type": "copier", 17 | "version": "9743929a6beca33d162bdb936a6f462b6ed417d0", 18 | "git_url": "https://github.com/nickderobertis/copier-flexlate-github-actions", 19 | "target_version": null, 20 | "render_relative_root_in_output": ".", 21 | "render_relative_root_in_template": "output" 22 | } 23 | ], 24 | "applied_templates": [ 25 | { 26 | "name": "copier-pypi-sphinx-flexlate", 27 | "data": { 28 | "package_name": "flexlate", 29 | "package_directory": "flexlate", 30 | "full_name": "Flexlate", 31 | "repo_name": "flexlate", 32 | "repo_username": "nickderobertis", 33 | "short_description": "A composable, maintainable system for managing templates", 34 | "main_branch_name": "master", 35 | "package_author": "Nick DeRobertis", 36 | "author_email": "whoopnip@gmail.com", 37 | "google_analytics_id": "", 38 | "os_support": [ 39 | "ubuntu", 40 | "macos" 41 | ], 42 | "minimum_python_version": "3.8", 43 | "logo_url": "", 44 | "install_packages": "py-app-conf cookiecutter GitPython" 45 | }, 46 | "version": "c0687ae24d563ce6c9ead4267eaa06176f186014", 47 | "add_mode": "local", 48 | "root": "." 49 | }, 50 | { 51 | "name": "copier-flexlate-github-actions", 52 | "data": { 53 | "main_branch_name": "master" 54 | }, 55 | "version": "9743929a6beca33d162bdb936a6f462b6ed417d0", 56 | "add_mode": "local", 57 | "root": "." 58 | } 59 | ] 60 | } -------------------------------------------------------------------------------- /tests/test_template_path.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from flexlate.exc import InvalidTemplatePathException 6 | from flexlate.template_path import ( 7 | get_local_repo_path_and_name_cloning_if_repo_url, 8 | is_local_template, 9 | is_repo_url, 10 | ) 11 | from tests import config 12 | from tests.dirutils import wipe_generated_folder 13 | from tests.fixtures.repo_path import ( 14 | RepoPathFixture, 15 | repo_path_fixture, 16 | repo_path_non_ssh_fixture, 17 | ) 18 | 19 | 20 | def test_is_repo_url(repo_path_fixture: RepoPathFixture): 21 | assert is_repo_url(repo_path_fixture.path) == repo_path_fixture.is_repo_url 22 | 23 | 24 | def test_is_local_path(repo_path_fixture: RepoPathFixture): 25 | assert is_local_template(repo_path_fixture.path) == repo_path_fixture.is_local_path 26 | 27 | 28 | # TODO: figure out how to test cloning SSH urls 29 | def test_get_local_repo_path_cloning_if_repo_url( 30 | repo_path_non_ssh_fixture: RepoPathFixture, 31 | ): 32 | repo_path_fixture = repo_path_non_ssh_fixture 33 | wipe_generated_folder() 34 | if not repo_path_fixture.is_repo_url and not repo_path_fixture.is_local_path: 35 | # Invalid path test 36 | with pytest.raises(InvalidTemplatePathException): 37 | get_local_repo_path_and_name_cloning_if_repo_url( 38 | repo_path_fixture.path, config.GENERATED_FILES_DIR 39 | ) 40 | # Path was invalid so nothing else to check, end test 41 | return 42 | 43 | # Must be valid template path, local or remote 44 | for version in repo_path_fixture.versions: 45 | local_path, name = get_local_repo_path_and_name_cloning_if_repo_url( 46 | repo_path_fixture.path, version, config.GENERATED_FILES_DIR 47 | ) 48 | assert name == repo_path_fixture.name 49 | if repo_path_fixture.is_local_path: 50 | assert local_path == Path(repo_path_fixture.path) 51 | elif repo_path_fixture.is_repo_url: 52 | assert local_path == config.GENERATED_FILES_DIR / repo_path_fixture.name / ( 53 | version or repo_path_fixture.default_version 54 | ) 55 | repo_path_fixture.assert_was_cloned_correctly(local_path, version) 56 | wipe_generated_folder() 57 | -------------------------------------------------------------------------------- /tests/test_checker.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import pytest 4 | from git import Repo 5 | 6 | from flexlate.checker import Checker 7 | from flexlate.config import FlexlateConfig 8 | from flexlate.exc import TemplateNotRegisteredException 9 | from flexlate.template.copier import CopierTemplate 10 | from tests import config 11 | from tests.config import COOKIECUTTER_REMOTE_NAME, COPIER_ONE_NAME 12 | from tests.fixtures.template import * 13 | from tests.fixtures.templated_repo import * 14 | from tests.fixtures.transaction import * 15 | 16 | 17 | @pytest.mark.parametrize("names", [None, [COOKIECUTTER_REMOTE_NAME], [COPIER_ONE_NAME]]) 18 | def test_check_for_remote_template_update( 19 | names: Optional[List[str]], 20 | repo_with_cookiecutter_remote_version_one_template_source_and_no_target_version: Repo, 21 | copier_one_template: CopierTemplate, 22 | add_source_transaction: FlexlateTransaction, 23 | ): 24 | repo = ( 25 | repo_with_cookiecutter_remote_version_one_template_source_and_no_target_version 26 | ) 27 | template = copier_one_template 28 | checker = Checker() 29 | adder = Adder() 30 | 31 | # Add a source that is already up to date 32 | adder.add_template_source( 33 | repo, template, add_source_transaction, out_root=config.GENERATED_REPO_DIR 34 | ) 35 | 36 | new_versions = checker.find_new_versions_for_template_sources( 37 | names=names, project_root=config.GENERATED_REPO_DIR 38 | ).update_version_dict 39 | if names is None or names == [COOKIECUTTER_REMOTE_NAME]: 40 | assert new_versions == {COOKIECUTTER_REMOTE_NAME: COOKIECUTTER_REMOTE_VERSION_2} 41 | else: 42 | assert new_versions == {} 43 | 44 | 45 | def test_check_for_update_with_no_template_sources( 46 | repo_with_placeholder_committed: Repo, 47 | ): 48 | checker = Checker() 49 | new_versions = checker.find_new_versions_for_template_sources( 50 | project_root=config.GENERATED_REPO_DIR 51 | ).update_version_dict 52 | assert new_versions == {} 53 | 54 | 55 | def test_check_for_update_template_that_does_not_exist( 56 | repo_with_placeholder_committed: Repo, 57 | ): 58 | checker = Checker() 59 | with pytest.raises(TemplateNotRegisteredException): 60 | checker.find_new_versions_for_template_sources( 61 | names=["some-fake-template"], project_root=config.GENERATED_REPO_DIR 62 | ) 63 | -------------------------------------------------------------------------------- /flexlate/pusher.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Sequence 2 | 3 | from git import Repo 4 | 5 | from flexlate.branch_update import get_flexlate_branch_name_for_feature_branch 6 | from flexlate.constants import DEFAULT_MERGED_BRANCH_NAME, DEFAULT_TEMPLATE_BRANCH_NAME 7 | from flexlate.ext_git import branch_exists, push_to_remote 8 | from flexlate.styles import ALERT_STYLE, INFO_STYLE, SUCCESS_STYLE, print_styled 9 | 10 | 11 | class Pusher: 12 | def push_main_flexlate_branches( 13 | self, 14 | repo: Repo, 15 | remote: str = "origin", 16 | merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 17 | template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 18 | ): 19 | _push_branches_to_remote( 20 | repo, [template_branch_name, merged_branch_name], remote=remote 21 | ) 22 | 23 | def push_feature_flexlate_branches( 24 | self, 25 | repo: Repo, 26 | feature_branch: Optional[str] = None, 27 | remote: str = "origin", 28 | merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 29 | template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 30 | ): 31 | branch_name = feature_branch or repo.active_branch.name 32 | feature_merged_branch_name = get_flexlate_branch_name_for_feature_branch( 33 | branch_name, merged_branch_name 34 | ) 35 | feature_template_branch_name = get_flexlate_branch_name_for_feature_branch( 36 | branch_name, template_branch_name 37 | ) 38 | _push_branches_to_remote( 39 | repo, 40 | [feature_template_branch_name, feature_merged_branch_name], 41 | remote=remote, 42 | ) 43 | 44 | 45 | def _push_branches_to_remote( 46 | repo: Repo, branch_names: Sequence[str], remote: str = "origin" 47 | ): 48 | for branch in branch_names: 49 | if not branch_exists(repo, branch): 50 | print_styled( 51 | f"Could not push branch {branch} as it does not exist", ALERT_STYLE 52 | ) 53 | return 54 | and_branches = " and ".join(branch_names) 55 | print_styled( 56 | f"Pushing {and_branches} to remote {remote}", 57 | INFO_STYLE, 58 | ) 59 | for branch in branch_names: 60 | push_to_remote(repo, branch, remote_name=remote) 61 | print_styled("Successfully pushed branches to remote", SUCCESS_STYLE) 62 | -------------------------------------------------------------------------------- /tests/fixtures/transaction.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | import pytest 4 | 5 | from flexlate.transactions.transaction import FlexlateTransaction, TransactionType 6 | 7 | ADD_SOURCE_ID = UUID("93f984ca-6e8f-45e9-b9b0-aebebfe798c1") 8 | ADD_OUTPUT_ID = UUID("86465f4d-9752-4ae5-aaa7-791b4c814e8d") 9 | ADD_SOURCE_AND_OUTPUT_ID = UUID("bf4cd42c-10b1-4bf9-a15f-294f5be738b0") 10 | REMOVE_SOURCE_ID = UUID("c034ec63-d2b5-4d8c-aef1-f96e29a6f5d1") 11 | REMOVE_OUTPUT_ID = UUID("79715a11-a3c4-40b1-a49b-9d8388e5c28d") 12 | UPDATE_TRANSACTION_ID = UUID("347711b7-3bf9-484e-be52-df488f3cf598") 13 | SYNC_TRANSACTION_ID = UUID("4825ce35-1a03-43de-ad8a-1ecc0ed68b62") 14 | BOOTSTRAP_TRANSACTION_ID = UUID("37c61224-2b8d-4ee5-8846-49d5474a40bd") 15 | UPDATE_TARGET_VERSION_ID = UUID("a5632854-48b4-4f82-904b-bff81dc40b02") 16 | 17 | 18 | @pytest.fixture 19 | def add_source_transaction() -> FlexlateTransaction: 20 | yield FlexlateTransaction(type=TransactionType.ADD_SOURCE, id=ADD_SOURCE_ID) 21 | 22 | 23 | @pytest.fixture 24 | def add_output_transaction() -> FlexlateTransaction: 25 | yield FlexlateTransaction(type=TransactionType.ADD_OUTPUT, id=ADD_OUTPUT_ID) 26 | 27 | 28 | @pytest.fixture 29 | def add_source_and_output_transaction() -> FlexlateTransaction: 30 | yield FlexlateTransaction( 31 | type=TransactionType.ADD_SOURCE_AND_OUTPUT, id=ADD_SOURCE_AND_OUTPUT_ID 32 | ) 33 | 34 | 35 | @pytest.fixture 36 | def remove_source_transaction() -> FlexlateTransaction: 37 | yield FlexlateTransaction(type=TransactionType.REMOVE_SOURCE, id=REMOVE_SOURCE_ID) 38 | 39 | 40 | @pytest.fixture 41 | def remove_output_transaction() -> FlexlateTransaction: 42 | yield FlexlateTransaction(type=TransactionType.REMOVE_OUTPUT, id=REMOVE_OUTPUT_ID) 43 | 44 | 45 | @pytest.fixture 46 | def update_transaction() -> FlexlateTransaction: 47 | yield FlexlateTransaction(type=TransactionType.UPDATE, id=UPDATE_TRANSACTION_ID) 48 | 49 | 50 | @pytest.fixture 51 | def sync_transaction() -> FlexlateTransaction: 52 | yield FlexlateTransaction(type=TransactionType.SYNC, id=SYNC_TRANSACTION_ID) 53 | 54 | 55 | @pytest.fixture 56 | def bootstrap_transaction() -> FlexlateTransaction: 57 | yield FlexlateTransaction( 58 | type=TransactionType.BOOTSTRAP, id=BOOTSTRAP_TRANSACTION_ID 59 | ) 60 | 61 | 62 | @pytest.fixture 63 | def update_target_version_transaction() -> FlexlateTransaction: 64 | yield FlexlateTransaction( 65 | type=TransactionType.UPDATE_TARGET_VERSION, id=UPDATE_TARGET_VERSION_ID 66 | ) 67 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Test, Build and Push Python Package and Docs 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | - alpha 9 | - beta 10 | - "*.*.*" 11 | - "*.*" 12 | 13 | jobs: 14 | test: 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, ] 18 | python-version: ["3.8", "3.9", "3.10", ] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: ./.github/actions/install-dependencies 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - uses: ./.github/actions/lint-and-test 26 | 27 | collectTODO: 28 | needs: test 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: "actions/checkout@master" 32 | - name: "TODO to Issue" 33 | uses: "alstr/todo-to-issue-action@v4.3" 34 | id: "todo" 35 | 36 | deploy: 37 | needs: test 38 | runs-on: ubuntu-latest 39 | strategy: 40 | max-parallel: 1 41 | matrix: 42 | python-version: [3.8] 43 | steps: 44 | - uses: actions/checkout@v3 45 | - name: Install dependencies to build package 46 | run: pip install --upgrade --upgrade-strategy eager setuptools wheel twine 47 | - name: Semantic Release 48 | id: semantic-release 49 | uses: cycjimmy/semantic-release-action@v3 50 | with: 51 | extra_plugins: | 52 | semantic-release-pypi 53 | @semantic-release/git 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 56 | PYPI_TOKEN: ${{ secrets.PYPI_PASSWORD }} 57 | - uses: ./.github/actions/install-dependencies 58 | if: steps.semantic-release.outputs.new_release_published == 'true' 59 | with: 60 | python-version: ${{ matrix.python-version }} 61 | - name: Deploy docs 62 | if: steps.semantic-release.outputs.new_release_published == 'true' 63 | uses: ./.github/actions/build-and-deploy-docs 64 | with: 65 | gh-token: ${{ secrets.GH_TOKEN }} 66 | 67 | coverage: 68 | needs: deploy 69 | runs-on: ubuntu-latest 70 | strategy: 71 | max-parallel: 1 72 | matrix: 73 | python-version: [3.8] 74 | steps: 75 | - uses: actions/checkout@v3 76 | - uses: ./.github/actions/install-dependencies 77 | with: 78 | python-version: ${{ matrix.python-version }} 79 | - name: Run coverage and upload to Codecov.io 80 | uses: ./.github/actions/test-coverage 81 | with: 82 | codecov-token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /tests/integration/fixtures/repo.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from git import Repo 5 | 6 | from flexlate.config import FlexlateConfig 7 | from flexlate.ext_git import stage_and_commit_all 8 | from flexlate.main import Flexlate 9 | from flexlate.path_ops import change_directory_to 10 | from flexlate.user_config_manager import UserConfigManager 11 | from tests import config 12 | from tests.config import ( 13 | COOKIECUTTER_REMOTE_URL, 14 | COOKIECUTTER_REMOTE_VERSION_1, 15 | COPIER_REMOTE_NAME, 16 | COPIER_REMOTE_URL, 17 | COPIER_REMOTE_VERSION_1, 18 | ) 19 | from tests.fixtures.git import * 20 | 21 | _fxt = Flexlate() 22 | 23 | 24 | @pytest.fixture 25 | def repo_with_default_flexlate_project(repo_with_placeholder_committed: Repo) -> Repo: 26 | repo = repo_with_placeholder_committed 27 | with change_directory_to(config.GENERATED_REPO_DIR): 28 | _fxt.init_project() 29 | yield repo 30 | 31 | 32 | @pytest.fixture 33 | def repo_with_copier_remote_version_one( 34 | repo_with_default_flexlate_project: Repo, 35 | ) -> Repo: 36 | repo = repo_with_default_flexlate_project 37 | with change_directory_to(config.GENERATED_REPO_DIR): 38 | _fxt.add_template_source( 39 | COPIER_REMOTE_URL, target_version=COPIER_REMOTE_VERSION_1 40 | ) 41 | # TODO: Allow updating template sources even when there are no applied templates 42 | # It should not be necessary to add this template for the update cli integration tests to pass 43 | # Need to rework the process of getting updates, right now it relies on there being applied templates 44 | _fxt.apply_template_and_add(COPIER_REMOTE_NAME, no_input=True) 45 | yield repo 46 | 47 | 48 | @pytest.fixture 49 | def repo_with_copier_remote_version_one_and_no_target_version( 50 | repo_with_copier_remote_version_one: Repo, 51 | ) -> Repo: 52 | repo = repo_with_copier_remote_version_one 53 | with change_directory_to(config.GENERATED_REPO_DIR): 54 | _fxt.update_template_source_target_version( 55 | COPIER_REMOTE_NAME, None, project_path=config.GENERATED_REPO_DIR 56 | ) 57 | yield repo 58 | 59 | 60 | @pytest.fixture 61 | def repo_with_copier_remote_version_one_no_target_version_and_will_have_a_conflict_on_update( 62 | repo_with_copier_remote_version_one_and_no_target_version: Repo, 63 | ) -> Repo: 64 | repo = repo_with_copier_remote_version_one_and_no_target_version 65 | # Reformat the flexlate config to cause a conflict 66 | config_path = config.GENERATED_REPO_DIR / "flexlate.json" 67 | config_data = json.loads(config_path.read_text()) 68 | config_path.write_text(json.dumps(config_data, indent=4)) 69 | stage_and_commit_all(repo, "Reformat flexlate config to cause a conflict on update") 70 | yield repo 71 | -------------------------------------------------------------------------------- /flexlate/finder/specific/copier.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Dict, Optional, TypedDict 3 | 4 | from copier.config.factory import filter_config 5 | from copier.config.user_data import load_config_data 6 | 7 | from flexlate.finder.specific.base import TemplateFinder 8 | from flexlate.finder.specific.git import ( 9 | get_git_url_from_source_path, 10 | get_version_from_source_path, 11 | ) 12 | from flexlate.template.copier import CopierTemplate 13 | from flexlate.template_config.copier import CopierConfig 14 | from flexlate.template_data import TemplateData 15 | 16 | 17 | class CopierFinder(TemplateFinder[CopierTemplate]): 18 | def find(self, path: str, local_path: Path, **template_kwargs) -> CopierTemplate: 19 | # TODO: determine why passing target_version through kwargs was not necessary for copier 20 | # Had to do that for cookiecutter, but tests were passing without any changes here. 21 | git_version: Optional[str] = template_kwargs.get("version") 22 | custom_name: Optional[str] = template_kwargs.get("name") 23 | name = custom_name or local_path.name 24 | config = self.get_config(local_path) 25 | version = get_version_from_source_path(path, local_path) or git_version 26 | git_url = get_git_url_from_source_path(path, template_kwargs) 27 | template_source_path = git_url if git_url else path 28 | return CopierTemplate( 29 | config, 30 | local_path, 31 | name=name, 32 | version=version, 33 | target_version=git_version, 34 | git_url=git_url, 35 | template_source_path=template_source_path, 36 | render_relative_root_in_template=config.render_relative_root_in_template, 37 | ) 38 | 39 | def get_config(self, directory: Path) -> CopierConfig: 40 | raw_data = load_config_data(directory, quiet=True) 41 | defaults: QuestionsWithDefaults 42 | _, defaults = filter_config(raw_data) 43 | data: TemplateData = {} 44 | for key, value in defaults.items(): 45 | if "default" in value: 46 | data[key] = value["default"] 47 | else: 48 | data[key] = None 49 | render_relative_root_in_template: Path = Path(".") 50 | if "_subdirectory" in raw_data: 51 | render_relative_root_in_template = Path(raw_data["_subdirectory"]) 52 | return CopierConfig( 53 | data, render_relative_root_in_template=render_relative_root_in_template 54 | ) 55 | 56 | def matches_template_type(self, path: Path) -> bool: 57 | return (path / "copier.yml").exists() or (path / "copier.yaml").exists() 58 | 59 | 60 | class DefaultData(TypedDict, total=False): 61 | default: str 62 | 63 | 64 | QuestionsWithDefaults = Dict[str, DefaultData] 65 | -------------------------------------------------------------------------------- /tests/test_user_config_manager.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from typing import Optional 3 | from unittest.mock import patch 4 | 5 | import appdirs 6 | 7 | from flexlate.user_config_manager import UserConfigManager 8 | from tests import config 9 | from tests.fixtures.add_mode import * 10 | from tests.fixtures.templated_repo import * 11 | from tests.fixtures.transaction import update_target_version_transaction 12 | from tests.gitutils import assert_main_commit_message_matches 13 | 14 | 15 | def test_add_local_cookiecutter_applied_template_to_repo( 16 | add_mode: AddMode, 17 | repo_with_cookiecutter_one_template_source: Repo, 18 | update_target_version_transaction: FlexlateTransaction, 19 | ): 20 | repo = repo_with_cookiecutter_one_template_source 21 | transaction = update_target_version_transaction 22 | 23 | with patch.object( 24 | appdirs, "user_config_dir", lambda name: config.GENERATED_FILES_DIR 25 | ): 26 | manager = UserConfigManager() 27 | if add_mode == AddMode.USER: 28 | # Template source was already added at project root before this, so need to move config 29 | shutil.move( 30 | str(config.GENERATED_REPO_DIR / "flexlate.json"), 31 | str(config.GENERATED_FILES_DIR), 32 | ) 33 | config_dir = config.GENERATED_FILES_DIR 34 | elif add_mode in (AddMode.PROJECT, AddMode.LOCAL): 35 | config_dir = config.GENERATED_REPO_DIR 36 | else: 37 | raise NotImplementedError(f"unsupported add mode {add_mode}") 38 | 39 | config_path = config_dir / "flexlate.json" 40 | 41 | def assert_target_version_is(version: Optional[str]): 42 | config = FlexlateConfig.load(config_path) 43 | assert len(config.template_sources) == 1 44 | ts = config.template_sources[0] 45 | assert ts.target_version == version 46 | 47 | assert_target_version_is(None) 48 | target_version = COOKIECUTTER_ONE_VERSION 49 | manager.update_template_source_target_version( 50 | COOKIECUTTER_ONE_NAME, 51 | target_version, 52 | repo, 53 | transaction, 54 | project_path=config.GENERATED_REPO_DIR, 55 | add_mode=add_mode, 56 | ) 57 | assert_target_version_is(target_version) 58 | 59 | if add_mode == AddMode.USER: 60 | # Should be no changes to git 61 | last_commit_message = "Added template source one to ." 62 | elif add_mode in (AddMode.PROJECT, AddMode.LOCAL): 63 | last_commit_message = ( 64 | f"Changed target version for template source one to {target_version}" 65 | ) 66 | else: 67 | raise NotImplementedError(f"unsupported add mode {add_mode}") 68 | 69 | assert_main_commit_message_matches(repo.commit().message, last_commit_message) 70 | -------------------------------------------------------------------------------- /tests/fs_checks.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | from flexlate.add_mode import AddMode 5 | from flexlate.config import FlexlateConfig, FlexlateProjectConfig 6 | from flexlate.constants import DEFAULT_MERGED_BRANCH_NAME, DEFAULT_TEMPLATE_BRANCH_NAME 7 | from flexlate.template.base import Template 8 | from flexlate.template.cookiecutter import CookiecutterTemplate 9 | from flexlate.template.types import TemplateType 10 | from tests import config as test_config 11 | 12 | 13 | def assert_template_source_cookiecutter_one_added_correctly( 14 | cookiecutter_one_template: CookiecutterTemplate, 15 | num_sources: int = 1, 16 | source_idx: int = 0, 17 | num_applied_templates: int = 0, 18 | target_version: Optional[str] = None, 19 | ): 20 | config_path = test_config.GENERATED_REPO_DIR / "flexlate.json" 21 | config = FlexlateConfig.load(config_path) 22 | assert len(config.applied_templates) == num_applied_templates 23 | assert len(config.template_sources) == num_sources 24 | source = config.template_sources[source_idx] 25 | assert source.name == cookiecutter_one_template.name 26 | assert source.path == str(cookiecutter_one_template.path) 27 | assert source.version == cookiecutter_one_template.version 28 | assert source.type == TemplateType.COOKIECUTTER 29 | assert source.target_version == target_version 30 | assert source.render_relative_root_in_output == Path("{{ cookiecutter.a }}") 31 | assert source.render_relative_root_in_template == Path("{{ cookiecutter.a }}") 32 | 33 | 34 | def assert_cookiecutter_one_applied_template_added_correctly( 35 | template: Template, 36 | config_dir: Optional[Path] = None, 37 | template_root: Path = Path(".."), 38 | add_mode=AddMode.LOCAL, 39 | ): 40 | config_dir = config_dir or test_config.GENERATED_REPO_DIR 41 | config_path = config_dir / "flexlate.json" 42 | config = FlexlateConfig.load(config_path) 43 | assert len(config.applied_templates) == 1 44 | at = config.applied_templates[0] 45 | assert at.name == template.name 46 | assert at.version == template.version 47 | assert at.data == {"a": "b", "c": ""} 48 | assert at.root == template_root 49 | assert at.add_mode == add_mode 50 | 51 | 52 | def assert_project_config_is_correct(add_mode: AddMode = AddMode.LOCAL): 53 | projects_config_path = test_config.GENERATED_REPO_DIR / "flexlate-project.json" 54 | projects_config = FlexlateProjectConfig.load(projects_config_path) 55 | assert len(projects_config.projects) == 1 56 | project_config = projects_config.projects[0] 57 | assert project_config.path == Path(".") 58 | assert project_config.default_add_mode == add_mode 59 | assert project_config.merged_branch_name == DEFAULT_MERGED_BRANCH_NAME 60 | assert project_config.template_branch_name == DEFAULT_TEMPLATE_BRANCH_NAME 61 | assert project_config.remote == "origin" 62 | -------------------------------------------------------------------------------- /flexlate/checker.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Dict, List, Optional, Sequence 3 | 4 | from pydantic import BaseModel 5 | from rich.console import Console, ConsoleOptions, RenderResult 6 | from rich.table import Table 7 | 8 | from flexlate.config_manager import ConfigManager 9 | from flexlate.finder.multi import MultiFinder 10 | from flexlate.styles import ACTION_REQUIRED_STYLE, SUCCESS_STYLE, styled 11 | 12 | 13 | class CheckResult(BaseModel): 14 | source_name: str 15 | existing_version: str 16 | latest_version: str 17 | 18 | @property 19 | def has_update(self) -> bool: 20 | return self.existing_version != self.latest_version 21 | 22 | 23 | class CheckResults(BaseModel): 24 | results: List[CheckResult] 25 | 26 | @property 27 | def updates(self) -> List[CheckResult]: 28 | return [result for result in self.results if result.has_update] 29 | 30 | @property 31 | def update_version_dict(self) -> Dict[str, str]: 32 | return {result.source_name: result.latest_version for result in self.updates} 33 | 34 | @property 35 | def has_updates(self) -> bool: 36 | return len(self.updates) != 0 37 | 38 | 39 | class CheckResultsRenderable(BaseModel): 40 | results: List[CheckResult] 41 | 42 | def __rich_console__( 43 | self, console: Console, options: ConsoleOptions 44 | ) -> RenderResult: 45 | if len(self.results) == 0: 46 | yield styled("All templates up to date", SUCCESS_STYLE) 47 | return 48 | 49 | yield styled( 50 | "Some templates are not up to date. Run fxt update to update", 51 | ACTION_REQUIRED_STYLE, 52 | ) 53 | 54 | table = Table("Template Name", "Current Version", "Latest Version") 55 | for res in self.results: 56 | table.add_row(res.source_name, res.existing_version, res.latest_version) 57 | yield table 58 | 59 | 60 | class Checker: 61 | def find_new_versions_for_template_sources( 62 | self, 63 | names: Optional[Sequence[str]] = None, 64 | project_root: Path = Path("."), 65 | config_manager: ConfigManager = ConfigManager(), 66 | finder: MultiFinder = MultiFinder(), 67 | ) -> CheckResults: 68 | sources = config_manager.get_template_sources(names, project_root=project_root) 69 | results: List[CheckResult] = [] 70 | for source in sources: 71 | kwargs: Dict[str, Any] = {} 72 | if source.target_version: 73 | kwargs.update(version=source.target_version) 74 | new_template = finder.find(str(source.update_location), **kwargs) 75 | results.append( 76 | CheckResult( 77 | source_name=source.name, 78 | existing_version=source.version, 79 | latest_version=new_template.version, 80 | ) 81 | ) 82 | return CheckResults(results=results) 83 | -------------------------------------------------------------------------------- /flexlate/user_config_manager.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | from git import Repo 5 | 6 | from flexlate.add_mode import AddMode 7 | from flexlate.branch_update import modify_files_via_branches_and_temp_repo 8 | from flexlate.config_manager import ConfigManager 9 | from flexlate.constants import DEFAULT_MERGED_BRANCH_NAME, DEFAULT_TEMPLATE_BRANCH_NAME 10 | from flexlate.transactions.transaction import ( 11 | FlexlateTransaction, 12 | create_transaction_commit_message, 13 | ) 14 | 15 | 16 | class UserConfigManager: 17 | """ 18 | A higher-level version of the config manager that also works with flexlate branches 19 | 20 | ConfigManager is the lower-level version that does not care about branches 21 | """ 22 | 23 | def update_template_source_target_version( 24 | self, 25 | name: str, 26 | target_version: Optional[str], 27 | repo: Repo, 28 | transaction: FlexlateTransaction, 29 | project_path: Path = Path("."), 30 | add_mode: Optional[AddMode] = None, 31 | merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 32 | base_merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 33 | template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 34 | base_template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 35 | remote: str = "origin", 36 | config_manager: ConfigManager = ConfigManager(), 37 | ): 38 | 39 | if add_mode == AddMode.USER: 40 | # No need to use git if adding for user 41 | config_manager.update_template_source_version( 42 | name, target_version=target_version, project_root=project_path 43 | ) 44 | return 45 | 46 | commit_message = create_transaction_commit_message( 47 | _update_target_version_commit_message(name, target_version), 48 | transaction, 49 | ) 50 | 51 | # Local or project config, add in git 52 | modify_files_via_branches_and_temp_repo( 53 | lambda temp_path: config_manager.update_template_source_version( 54 | name, 55 | target_version=target_version, 56 | project_root=temp_path, 57 | ), 58 | repo, 59 | commit_message, 60 | project_path, 61 | merged_branch_name=merged_branch_name, 62 | base_merged_branch_name=base_merged_branch_name, 63 | template_branch_name=template_branch_name, 64 | base_template_branch_name=base_template_branch_name, 65 | remote=remote, 66 | ) 67 | 68 | 69 | def _update_target_version_commit_message( 70 | name: str, target_version: Optional[str] 71 | ) -> str: 72 | display_target_version: str = str(target_version) 73 | if target_version is None: 74 | display_target_version = "null" 75 | return ( 76 | f"Changed target version for template source {name} to {display_target_version}" 77 | ) 78 | -------------------------------------------------------------------------------- /flexlate/template/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from flexlate.template.hashing import md5_dir 6 | from flexlate.template.types import TemplateType 7 | from flexlate.template_config.base import TemplateConfig 8 | 9 | 10 | class Template(abc.ABC): 11 | # Override this in subclasses 12 | _type: TemplateType = TemplateType.BASE 13 | 14 | def __init__( 15 | self, 16 | config: TemplateConfig, 17 | path: Path, 18 | name: Optional[str] = None, 19 | version: Optional[str] = None, 20 | target_version: Optional[str] = None, 21 | git_url: Optional[str] = None, 22 | template_source_path: Optional[str] = None, 23 | render_relative_root_in_output: Path = Path("."), 24 | render_relative_root_in_template: Path = Path("."), 25 | ): 26 | self.config = config 27 | self.path = path 28 | self.git_url = git_url 29 | self.target_version = target_version 30 | self.name = name or self.default_name 31 | self.version = version or self.folder_hash 32 | self.template_source_path = template_source_path or path 33 | self.render_relative_root_in_output = render_relative_root_in_output 34 | self.render_relative_root_in_template = render_relative_root_in_template 35 | 36 | @property 37 | def default_name(self) -> str: 38 | return self.path.name 39 | 40 | @property 41 | def folder_hash(self) -> str: 42 | return md5_dir(self.path) 43 | 44 | def __eq__(self, other): 45 | try: 46 | return all( 47 | [ 48 | self.config == other.config, 49 | self.path == other.path, 50 | self.git_url == other.git_url, 51 | self.target_version == other.target_version, 52 | self.name == other.name, 53 | self.version == other.version, 54 | self.template_source_path == other.template_source_path, 55 | self.render_relative_root_in_output 56 | == other.render_relative_root_in_output, 57 | self.render_relative_root_in_template 58 | == other.render_relative_root_in_template, 59 | ] 60 | ) 61 | except AttributeError: 62 | return False 63 | 64 | def update_from_template(self, template: "Template"): 65 | self.config = template.config 66 | self.path = template.path 67 | self.git_url = template.git_url 68 | self.target_version = template.target_version 69 | self.name = template.name 70 | self.version = template.version 71 | self.template_source_path = template.template_source_path 72 | self.render_relative_root_in_output = template.render_relative_root_in_output 73 | self.render_relative_root_in_template = ( 74 | template.render_relative_root_in_template 75 | ) 76 | self._type = template._type 77 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/arbitrary-changes.md: -------------------------------------------------------------------------------- 1 | # Making Other Changes to Flexlate Templates and Configuration 2 | 3 | Flexlate provides commands that automatically manage configuration such 4 | as [`fxt add source`](../commands.md#fxt-add-source) or 5 | [`fxt config target`](../commands.md#fxt-config-target). But not 6 | every change you might want to make to the config has a command. 7 | 8 | ## Use `fxt sync` to Bring Arbitrary Changes to Flexlate Branches 9 | 10 | Enter [`fxt sync`](../commands.md#fxt-sync) that will take the current 11 | state of your config and render it to the Flexlate branches before merging 12 | back into your working branch. 13 | 14 | ```{note} 15 | Be sure to commit your changes before running 16 | [`fxt sync`](../commands.md#fxt-sync). In general, Flexlate always 17 | wants you to commit your changes before running operations. 18 | ``` 19 | 20 | ## Possible Uses for `fxt sync` 21 | 22 | Why might you want to do this? Let's go through a few examples. 23 | 24 | ### Moving an Applied Template 25 | 26 | For example, say you applied a template but later decide you want 27 | to move it. 28 | 29 | ```{run-fxt-terminal} 30 | --- 31 | setup: "fxt add source https://github.com/nickderobertis/copier-simple-example" 32 | input: "my answer\n10" 33 | --- 34 | fxt add output copier-simple-example some/path 35 | mv some/path other 36 | git add -A 37 | git commit -m 'Move copier-simple-example from some/path to other' 38 | fxt sync 39 | ``` 40 | 41 | ### Moving a Template Source 42 | 43 | There are several reasons why you might want to move a template source: 44 | 45 | - You are using a local template and you want to move it. 46 | - You are using a template from a remote repository and you want to 47 | switch it to a fork. 48 | - You are using a template from a remote repository and it was renamed 49 | 50 | Just manually update the `flexlate.json` file containing your template 51 | source, commit the changes, then run 52 | [`fxt sync`](../commands.md#fxt-sync). 53 | 54 | ### Updating a Single Applied Template 55 | 56 | Flexlate has built-in functionality to update all the applied templates 57 | within a project for a given template source. It does not have a built-in 58 | command to update a single applied template, but you can do it by manually 59 | updating the version in the `flexlate.json` file and running 60 | [`fxt sync`](../commands.md#fxt-sync). 61 | 62 | ### Migrating Add Modes 63 | 64 | Say you have taken the default add mode `local` setting and you are applying 65 | a lot of templates but you don't like having `flexlate.json` files scattered 66 | throughout your project. You want to switch to `project` add mode, but 67 | Flexlate cannot do this automatically. You can do this manually by 68 | taking the contents of those `flexlate.json` files and moving them to 69 | one `flexlate.json` file in your project root. You will need to update the 70 | paths of the applied templates as well as the add mode accordingly. 71 | Then change the default add mode in `flexlate-project.json` to `project` 72 | so that all new applied templates will use `project` add mode automatically. 73 | Once you have it all done, 74 | commit your changes and run [`fxt sync`](../commands.md#fxt-sync). -------------------------------------------------------------------------------- /tests/gitutils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional, Union 3 | 4 | from git import Repo 5 | 6 | from tests import config 7 | 8 | 9 | def create_empty_repo(out_dir: Optional[Path] = None) -> Repo: 10 | out_dir = out_dir or config.GENERATED_REPO_DIR 11 | if not out_dir.exists(): 12 | out_dir.mkdir(parents=True) 13 | return Repo.init(out_dir) 14 | 15 | 16 | def add_dummy_file_to_repo(repo: Repo): 17 | folder = repo.working_dir 18 | out_folder = Path(folder) / "some-dir" 19 | out_folder.mkdir() 20 | out_path = out_folder / "placeholder.txt" 21 | out_path.write_text("some text") 22 | 23 | 24 | def add_dummy_file2_to_repo(repo: Repo): 25 | folder = repo.working_dir 26 | out_folder = Path(folder) / "some-dir" 27 | out_path = out_folder / "placeholder2.txt" 28 | out_path.write_text("some text2") 29 | 30 | 31 | def add_gitignore_and_ignored_file_to_repo(repo: Repo): 32 | folder = repo.working_dir 33 | out_folder = Path(folder) / "ignored" 34 | out_folder.mkdir() 35 | out_path = out_folder / "ignored.txt" 36 | out_path.write_text("this should be ignored in git") 37 | gitignore_path = Path(folder) / ".gitignore" 38 | gitignore_path.write_text("ignored\n") 39 | 40 | 41 | def assert_main_commit_message_matches(message1: str, message2: str): 42 | main_1 = _get_main_message_from_commit_message(message1) 43 | main_2 = _get_main_message_from_commit_message(message2) 44 | assert main_1 == main_2 45 | 46 | 47 | def rename_branch(repo: Repo, branch_name: str, new_branch_name: str): 48 | repo.git.branch("-m", branch_name, new_branch_name) 49 | 50 | 51 | def checkout_new_branch(repo: Repo, branch_name: str): 52 | repo.git.checkout("-b", branch_name) 53 | 54 | 55 | def checkout_existing_branch(repo: Repo, branch_name: str): 56 | repo.git.checkout(branch_name) 57 | 58 | 59 | def add_remote(repo: Repo, path: Union[str, Path], remote_name: str = "origin"): 60 | if isinstance(path, Path): 61 | remote_path = str(path.resolve()) 62 | else: 63 | remote_path = path 64 | repo.git.remote("add", remote_name, remote_path) 65 | 66 | 67 | def add_local_remote( 68 | repo: Repo, 69 | remote_path: Optional[Path] = None, 70 | ) -> Repo: 71 | remote_path = remote_path or config.GENERATED_FILES_DIR / "remote" 72 | if not remote_path.exists(): 73 | remote_path.mkdir() 74 | remote_repo = Repo.init(remote_path) 75 | add_remote(repo, remote_path) 76 | return remote_repo 77 | 78 | 79 | def accept_theirs_in_merge_conflict(repo: Repo): 80 | repo.git.checkout("--theirs", ".") 81 | 82 | 83 | def reset_n_commits_without_checkout( 84 | repo: Repo, branch_name: str, n_commits: int = 1, ignore_merges: bool = True 85 | ): 86 | if not ignore_merges: 87 | reset_to = f"HEAD~{n_commits}" 88 | else: 89 | reset_to = _get_last_non_merge_commit_parent_on_branch(repo, branch_name) 90 | repo.git.branch("--force", branch_name, reset_to) 91 | 92 | 93 | def _get_last_non_merge_commit_parent_on_branch(repo: Repo, branch_name: str) -> str: 94 | return repo.git.rev_list("--no-merges", "-n", "1", f"{branch_name}~1") 95 | 96 | 97 | def _get_main_message_from_commit_message(message: str) -> str: 98 | return message.split("\n")[0] 99 | -------------------------------------------------------------------------------- /tests/test_bootstrapper.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from git import Repo 4 | 5 | from flexlate import branch_update 6 | from flexlate.bootstrapper import Bootstrapper 7 | from flexlate.config import FlexlateProjectConfig 8 | from tests import config 9 | from tests.fixtures.templated_repo import * 10 | from tests.fixtures.transaction import bootstrap_transaction 11 | from tests.fs_checks import ( 12 | assert_cookiecutter_one_applied_template_added_correctly, 13 | assert_project_config_is_correct, 14 | assert_template_source_cookiecutter_one_added_correctly, 15 | ) 16 | 17 | 18 | def test_bootstrap_cookiecutter_one( 19 | repo_with_cookiecutter_one_applied_but_no_flexlate: Repo, 20 | cookiecutter_one_template: CookiecutterTemplate, 21 | bootstrap_transaction: FlexlateTransaction, 22 | ): 23 | repo = repo_with_cookiecutter_one_applied_but_no_flexlate 24 | template = cookiecutter_one_template 25 | 26 | bootstrapper = Bootstrapper() 27 | bootstrapper.bootstrap_flexlate_init_from_existing_template( 28 | repo, template, bootstrap_transaction, no_input=True, data=dict(a="b", c="") 29 | ) 30 | 31 | # Check that all Flexlate config files are correct 32 | assert_template_source_cookiecutter_one_added_correctly(cookiecutter_one_template) 33 | assert_cookiecutter_one_applied_template_added_correctly( 34 | template, config.GENERATED_REPO_DIR / "b" 35 | ) 36 | assert_project_config_is_correct() 37 | 38 | _assert_flexlate_merge_branch_exists_and_is_up_to_date(repo) 39 | 40 | 41 | def test_bootstrap_cookiecutter_one_with_conflicts( 42 | repo_with_cookiecutter_one_applied_but_no_flexlate: Repo, 43 | cookiecutter_one_template: CookiecutterTemplate, 44 | bootstrap_transaction: FlexlateTransaction, 45 | ): 46 | repo = repo_with_cookiecutter_one_applied_but_no_flexlate 47 | template = cookiecutter_one_template 48 | 49 | # Modify templated output to cause conflict 50 | content_path = config.GENERATED_REPO_DIR / "b" / "text.txt" 51 | content_path.write_text("merge conflict") 52 | stage_and_commit_all( 53 | repo, "Add a change that should cause a merge conflict on bootstrap" 54 | ) 55 | 56 | bootstrapper = Bootstrapper() 57 | 58 | def _resolve_conflicts_then_type_yes(prompt: str) -> bool: 59 | stage_and_commit_all(repo, "Resolve conflicts") 60 | return True 61 | 62 | with patch.object(branch_update, "confirm_user", _resolve_conflicts_then_type_yes): 63 | bootstrapper.bootstrap_flexlate_init_from_existing_template( 64 | repo, template, bootstrap_transaction, no_input=True, data=dict(a="b", c="") 65 | ) 66 | 67 | # Check that all Flexlate config files are correct 68 | assert_template_source_cookiecutter_one_added_correctly(cookiecutter_one_template) 69 | assert_cookiecutter_one_applied_template_added_correctly( 70 | template, config.GENERATED_REPO_DIR / "b" 71 | ) 72 | assert_project_config_is_correct() 73 | 74 | _assert_flexlate_merge_branch_exists_and_is_up_to_date(repo) 75 | 76 | 77 | def _assert_flexlate_merge_branch_exists_and_is_up_to_date(repo: Repo): 78 | master = repo.active_branch 79 | merged_branch = repo.branches[DEFAULT_MERGED_BRANCH_NAME] # type: ignore 80 | assert merged_branch.commit.hexsha == master.commit.hexsha 81 | -------------------------------------------------------------------------------- /flexlate/path_ops.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from contextlib import contextmanager 4 | from pathlib import Path 5 | from typing import Callable, Optional, Sequence 6 | 7 | 8 | def make_func_that_creates_cwd_and_out_root_before_running( 9 | out_root: Path, func: Callable[[Path], None] 10 | ): 11 | """ 12 | When switching branches, the CWD or target out_root may no longer exist. 13 | Pass a function to this function to create a function that 14 | creates those directories as needed before running the logic 15 | """ 16 | cwd = Path(os.getcwd()) 17 | absolute_out_root = out_root.absolute() 18 | 19 | def make_dirs_add_run_func(path: Path): 20 | # Need to ensure that both cwd and out root exist on the template branch 21 | for p in [cwd, absolute_out_root]: 22 | if not p.exists(): 23 | p.mkdir(parents=True) 24 | # If cwd was deleted when switching branches, need to navigate back there 25 | # or os.getcwd will throw a FileNotExistsError (which also breaks path.absolute()) 26 | os.chdir(cwd) 27 | 28 | func(path) 29 | 30 | return make_dirs_add_run_func 31 | 32 | 33 | def make_all_dirs(paths: Sequence[Path]): 34 | for path in paths: 35 | absolute_path = path.resolve() 36 | if not absolute_path.exists(): 37 | absolute_path.mkdir(parents=True) 38 | 39 | 40 | def copy_flexlate_configs(src: Path, dst: Path, root: Path): 41 | for path in src.absolute().iterdir(): 42 | if path.name in ("flexlate.json", "flexlate-project.json"): 43 | shutil.copy(path, dst) 44 | elif path.name == ".git": 45 | continue 46 | elif path.is_dir(): 47 | dst_dir = dst / path.name 48 | if not dst_dir.exists(): 49 | dst_dir.mkdir() 50 | copy_flexlate_configs(path, dst_dir, root) 51 | 52 | 53 | def location_relative_to_new_parent( 54 | path: Path, 55 | orig_parent: Path, 56 | new_parent: Path, 57 | path_is_relative_to: Optional[Path] = None, 58 | ) -> Path: 59 | if path_is_relative_to is None and not path.is_absolute(): 60 | raise ValueError( 61 | f"must pass path_is_relative_to when passing relative path {path}" 62 | ) 63 | abs_path: Path = path 64 | if not path.is_absolute() and path_is_relative_to is not None: 65 | abs_path = (path_is_relative_to.absolute() / path).resolve() 66 | try: 67 | result = new_parent / abs_path.relative_to(orig_parent) 68 | return result 69 | except ValueError as e: 70 | # python >= 3.9: is not in the subpath of 71 | # python <= 3.8: does not start with 72 | if "is not in the subpath of" in str(e) or "does not start with" in str(e): 73 | # Path is not in project, must be user path, return as is 74 | return path 75 | else: 76 | raise e 77 | 78 | 79 | @contextmanager 80 | def change_directory_to(path: Path): 81 | current_path = os.getcwd() 82 | os.chdir(path) 83 | yield 84 | os.chdir(current_path) 85 | 86 | 87 | def make_absolute_path_from_possibly_relative_to_another_path( 88 | path: Path, possibly_relative_to: Path 89 | ) -> Path: 90 | if path.is_absolute(): 91 | return path 92 | else: 93 | return (possibly_relative_to / path).resolve() 94 | -------------------------------------------------------------------------------- /flexlate/transactions/undoer.py: -------------------------------------------------------------------------------- 1 | from git import Repo 2 | 3 | from flexlate.branch_update import undo_transaction_in_flexlate_branches 4 | from flexlate.constants import DEFAULT_MERGED_BRANCH_NAME, DEFAULT_TEMPLATE_BRANCH_NAME 5 | from flexlate.ext_git import assert_repo_is_in_clean_state 6 | from flexlate.styles import INFO_STYLE, SUCCESS_STYLE, console, print_styled, styled 7 | from flexlate.transactions.transaction import ( 8 | FlexlateTransaction, 9 | assert_has_at_least_n_transactions, 10 | assert_last_commit_was_in_a_flexlate_transaction, 11 | find_last_transaction_from_commit, 12 | reset_last_transaction, 13 | ) 14 | 15 | 16 | class Undoer: 17 | def undo_transaction( 18 | self, 19 | repo: Repo, 20 | merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 21 | base_merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 22 | template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 23 | base_template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 24 | ): 25 | last_transaction = find_last_transaction_from_commit( 26 | repo.commit(), merged_branch_name, template_branch_name 27 | ) 28 | 29 | # Reset the flexlate branches 30 | undo_transaction_in_flexlate_branches( 31 | repo, 32 | last_transaction, 33 | merged_branch_name=merged_branch_name, 34 | base_merged_branch_name=base_merged_branch_name, 35 | template_branch_name=template_branch_name, 36 | base_template_branch_name=base_template_branch_name, 37 | ) 38 | 39 | # Reset the user's branch 40 | reset_last_transaction( 41 | repo, last_transaction, merged_branch_name, template_branch_name 42 | ) 43 | 44 | def undo_transactions( 45 | self, 46 | repo: Repo, 47 | num_transactions: int = 1, 48 | merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 49 | base_merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 50 | template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 51 | base_template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 52 | ): 53 | assert_repo_is_in_clean_state(repo) 54 | assert_last_commit_was_in_a_flexlate_transaction( 55 | repo, merged_branch_name, template_branch_name 56 | ) 57 | if repo.working_dir is None: 58 | raise ValueError("repo working dir should not be none") 59 | # Fail fast if there are too few transactions 60 | assert_has_at_least_n_transactions( 61 | repo, num_transactions, merged_branch_name, template_branch_name 62 | ) 63 | 64 | with console.status( 65 | styled(f"Undoing {num_transactions} flexlate transactions", INFO_STYLE) 66 | ): 67 | for i in range(num_transactions): 68 | print_styled(f"Undoing transaction {i + 1}", INFO_STYLE) 69 | self.undo_transaction( 70 | repo, 71 | merged_branch_name=merged_branch_name, 72 | base_merged_branch_name=base_merged_branch_name, 73 | template_branch_name=template_branch_name, 74 | base_template_branch_name=base_template_branch_name, 75 | ) 76 | print_styled( 77 | f"Successfully reversed transaction {i + 1}", SUCCESS_STYLE 78 | ) 79 | -------------------------------------------------------------------------------- /tests/dirutils.py: -------------------------------------------------------------------------------- 1 | import filecmp 2 | import os 3 | import shutil 4 | import tempfile 5 | import time 6 | from pathlib import Path 7 | from typing import Sequence, Union 8 | 9 | from tests import config 10 | 11 | 12 | def wipe_generated_folder(): 13 | using_temp_dir = config.USING_TEMP_DIR_AS_GENERATED_DIR 14 | if config.GENERATED_FILES_DIR.exists(): 15 | remove_folder(config.GENERATED_FILES_DIR, raise_on_error=not using_temp_dir) 16 | if not using_temp_dir: 17 | config.GENERATED_FILES_DIR.mkdir() 18 | 19 | 20 | def remove_folder( 21 | folder: Union[str, Path], retries: int = 10, raise_on_error: bool = True 22 | ): 23 | if Path(folder).exists(): 24 | try: 25 | shutil.rmtree(folder, ignore_errors=not raise_on_error) 26 | except PermissionError as e: 27 | time.sleep(0.1) 28 | if retries > 0: 29 | remove_folder(folder, retries - 1) 30 | else: 31 | if raise_on_error: 32 | raise e 33 | else: 34 | print(f"Failed to remove folder {folder}") 35 | 36 | 37 | def display_contents_of_all_files_in_folder( 38 | folder: Path, nested: bool = True, ignores: Sequence[str] = (".git",) 39 | ): 40 | outer_divider = "================================================" 41 | divider = "--------------------------------------------------" 42 | for path in folder.absolute().iterdir(): 43 | if path.name in ignores: 44 | continue 45 | if path.is_file(): 46 | print(f"{outer_divider}\n{path}\n{divider}") 47 | try: 48 | print(path.read_text()) 49 | except UnicodeDecodeError as e: 50 | print(f"FAILED TO READ TEXT WITH ERROR: {e}") 51 | print(f"{divider}\n{outer_divider}") 52 | elif path.is_dir(): 53 | print(f"- {path} (directory)") 54 | if nested: 55 | display_contents_of_all_files_in_folder(path, nested) 56 | 57 | 58 | def assert_dir_trees_are_equal(dir1: Union[str, Path], dir2: Union[str, Path]): 59 | """ 60 | Compare two directories recursively. Files in each directory are 61 | assumed to be equal if their names and contents are equal. 62 | 63 | See: https://stackoverflow.com/a/6681395/6276321 64 | 65 | @param dir1: First directory path 66 | @param dir2: Second directory path 67 | 68 | @return: True if the directory trees are the same and 69 | there were no errors while accessing the directories or files, 70 | False otherwise. 71 | """ 72 | 73 | dirs_cmp = filecmp.dircmp(dir1, dir2) 74 | if ( 75 | len(dirs_cmp.left_only) > 0 76 | or len(dirs_cmp.right_only) > 0 77 | or len(dirs_cmp.funny_files) > 0 78 | ): 79 | pass 80 | # TODO: figure out why this fails in CI but not local 81 | 82 | # raise AssertionError( 83 | # f"lefy only: {dirs_cmp.left_only}. right only: {dirs_cmp.right_only}. funny files: {dirs_cmp.funny_files}" 84 | # ) 85 | (_, mismatch, errors) = filecmp.cmpfiles( 86 | dir1, dir2, dirs_cmp.common_files, shallow=False 87 | ) 88 | if len(mismatch) > 0 or len(errors) > 0: 89 | raise AssertionError(f"mismatch: {mismatch}. errors: {errors}") 90 | for common_dir in dirs_cmp.common_dirs: 91 | new_dir1 = os.path.join(dir1, common_dir) 92 | new_dir2 = os.path.join(dir2, common_dir) 93 | assert_dir_trees_are_equal(new_dir1, new_dir2) 94 | 95 | 96 | def create_temp_path_without_cleanup() -> Path: 97 | temp_dir = tempfile.mkdtemp() 98 | path = Path(temp_dir).resolve() 99 | return path 100 | -------------------------------------------------------------------------------- /flexlate/exc.py: -------------------------------------------------------------------------------- 1 | class FlexlateException(Exception): 2 | pass 3 | 4 | 5 | class RendererNotFoundException(FlexlateException): 6 | pass 7 | 8 | 9 | class FlexlateTemplateException(FlexlateException): 10 | pass 11 | 12 | 13 | class InvalidTemplateClassException(FlexlateTemplateException): 14 | pass 15 | 16 | 17 | class InvalidTemplateTypeException(FlexlateTemplateException): 18 | pass 19 | 20 | 21 | class InvalidTemplatePathException(FlexlateTemplateException): 22 | pass 23 | 24 | 25 | class TemplateLookupException(FlexlateTemplateException): 26 | pass 27 | 28 | 29 | class InvalidTemplateDataException(FlexlateTemplateException): 30 | pass 31 | 32 | 33 | class TemplateNotRegisteredException(FlexlateTemplateException): 34 | pass 35 | 36 | 37 | class CannotFindTemplateSourceException(FlexlateTemplateException): 38 | pass 39 | 40 | 41 | class CannotFindAppliedTemplateException(FlexlateTemplateException): 42 | pass 43 | 44 | 45 | class CannotFindClonedTemplateException(FlexlateTemplateException): 46 | pass 47 | 48 | 49 | class TemplateSourceWithNameAlreadyExistsException(FlexlateTemplateException): 50 | pass 51 | 52 | 53 | class FlexlateGitException(FlexlateException): 54 | pass 55 | 56 | 57 | class GitRepoDirtyException(FlexlateGitException): 58 | pass 59 | 60 | 61 | class GitRepoHasNoCommitsException(FlexlateGitException): 62 | pass 63 | 64 | 65 | class TriedToCommitButNoChangesException(FlexlateGitException): 66 | pass 67 | 68 | 69 | class FlexlateConfigException(FlexlateException): 70 | pass 71 | 72 | 73 | class CannotLoadConfigException(FlexlateConfigException): 74 | pass 75 | 76 | 77 | class CannotRemoveConfigItemException(FlexlateConfigException): 78 | pass 79 | 80 | 81 | class CannotRemoveTemplateSourceException(CannotRemoveConfigItemException): 82 | pass 83 | 84 | 85 | class CannotRemoveAppliedTemplateException(CannotRemoveConfigItemException): 86 | pass 87 | 88 | 89 | class FlexlateConfigFileNotExistsException(CannotLoadConfigException): 90 | pass 91 | 92 | 93 | class FlexlateProjectConfigFileNotExistsException(CannotLoadConfigException): 94 | pass 95 | 96 | 97 | class FlexlateTransactionException(FlexlateException): 98 | pass 99 | 100 | 101 | class CannotParseCommitMessageFlexlateTransaction(FlexlateTransactionException): 102 | pass 103 | 104 | 105 | class LastCommitWasNotByFlexlateException(FlexlateTransactionException): 106 | pass 107 | 108 | 109 | class TransactionMismatchBetweenBranchesException(FlexlateTransactionException): 110 | pass 111 | 112 | 113 | class TooFewTransactionsException(FlexlateTransactionException): 114 | pass 115 | 116 | 117 | class InvalidNumberOfTransactionsException(FlexlateTransactionException): 118 | pass 119 | 120 | 121 | class ExpectedMergeCommitException(FlexlateTransactionException): 122 | pass 123 | 124 | 125 | class CannotFindCorrectMergeParentException(FlexlateTransactionException): 126 | pass 127 | 128 | 129 | class UserChangesWouldHaveBeenDeletedException(FlexlateTransactionException): 130 | pass 131 | 132 | 133 | class MergeCommitIsNotMergingAFlexlateTransactionException( 134 | FlexlateTransactionException 135 | ): 136 | pass 137 | 138 | 139 | class CannotFindMergeForTransactionException(FlexlateTransactionException): 140 | pass 141 | 142 | 143 | class FlexlateSyncException(FlexlateException): 144 | pass 145 | 146 | 147 | class UnnecessarySyncException(FlexlateSyncException): 148 | pass 149 | 150 | 151 | class FlexlateUpdateException(FlexlateException): 152 | pass 153 | 154 | 155 | class MergeConflictsAndAbortException(FlexlateUpdateException): 156 | pass 157 | -------------------------------------------------------------------------------- /nbexamples/ipynb_to_gallery.py: -------------------------------------------------------------------------------- 1 | """Convert jupyter notebook to sphinx gallery notebook styled examples. 2 | Usage: python ipynb_to_gallery.py 3 | Dependencies: 4 | pypandoc: install using `pip install pypandoc` 5 | """ 6 | import json 7 | import os 8 | from typing import Optional 9 | 10 | import pypandoc as pdoc 11 | 12 | 13 | def convert_ipynb_to_gallery(file_path: str, out_path: Optional[str] = None): 14 | if out_path is None: 15 | out_path = file_path.replace(".ipynb", ".py") 16 | 17 | python_file = "" 18 | 19 | nb_dict = json.load(open(file_path)) 20 | cells = nb_dict["cells"] 21 | 22 | for i, cell in enumerate(cells): 23 | if i == 0: 24 | assert cell["cell_type"] == "markdown", "First cell has to be markdown" 25 | 26 | md_source = "".join(cell["source"]) 27 | rst_source = pdoc.convert_text(md_source, "rst", "md") 28 | python_file = '"""\n' + rst_source + '\n"""' 29 | else: 30 | if cell["cell_type"] == "markdown": 31 | md_source = "".join(cell["source"]) 32 | rst_source = pdoc.convert_text(md_source, "rst", "md") 33 | commented_source = "\n".join(["# " + x for x in rst_source.split("\n")]) 34 | python_file = ( 35 | python_file + "\n\n\n" + "#" * 70 + "\n" + commented_source 36 | ) 37 | elif cell["cell_type"] == "code": 38 | source = "".join(cell["source"]) 39 | python_file = python_file + "\n" * 2 + source 40 | 41 | python_file = python_file.replace("\n%", "\n# %") 42 | with open(out_path, "w") as f: 43 | f.write(python_file) 44 | 45 | 46 | def convert_all_in_folder_to_gallery( 47 | folder: str, out_folder: Optional[str] = None, replace: bool = False 48 | ): 49 | folder = os.path.normpath(folder) 50 | 51 | if out_folder is None: 52 | out_folder = folder 53 | else: 54 | out_folder = os.path.normpath(out_folder) 55 | 56 | for path, folders, files in os.walk(folder): 57 | if ".ipynb_checkpoints" in path: 58 | # Skip checkpoints folders 59 | continue 60 | sub_path = os.path.sep.join( 61 | path.split(os.path.sep)[1:] 62 | ) # relative path within folder 63 | current_out_folder = os.path.join(out_folder, sub_path) 64 | print(f"Outputting contents of {sub_path} to {current_out_folder}") 65 | if not os.path.exists(current_out_folder): 66 | os.makedirs(current_out_folder) 67 | files = [file for file in files if file.lower().endswith("ipynb")] 68 | for file in files: 69 | file_path = os.path.join(path, file) 70 | out_file = file.lower().replace(".ipynb", ".py").replace(" ", "_") 71 | out_path = os.path.join(current_out_folder, out_file) 72 | if not replace and os.path.exists(out_path): 73 | print(f"Skipping file {file} as .py already exists") 74 | continue 75 | print(f"Converting file {file}") 76 | convert_ipynb_to_gallery(file_path, out_path) 77 | 78 | 79 | if __name__ == "__main__": 80 | import argparse 81 | 82 | parser = argparse.ArgumentParser() 83 | parser.add_argument("folder", help="Folder to convert ipynb to Sphinx Gallery py") 84 | parser.add_argument( 85 | "-o", 86 | "--out-folder", 87 | default=None, 88 | help="Output folder for Sphinx Gallery py files, default in same folder", 89 | ) 90 | parser.add_argument( 91 | "-r", 92 | "--replace", 93 | action="store_true", 94 | help="Overwrite existing Sphinx Gallery py files", 95 | ) 96 | args = parser.parse_args() 97 | convert_all_in_folder_to_gallery(args.folder, args.out_folder, args.replace) 98 | -------------------------------------------------------------------------------- /tests/fixtures/repo_path.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from pathlib import Path 3 | from typing import Callable, Final, List, Optional 4 | 5 | import pytest 6 | 7 | from tests.config import ( 8 | COOKIECUTTER_ONE_DIR, 9 | COOKIECUTTER_ONE_NAME, 10 | COOKIECUTTER_REMOTE_NAME, 11 | COOKIECUTTER_REMOTE_URL, 12 | COOKIECUTTER_REMOTE_VERSION_1, 13 | COOKIECUTTER_REMOTE_VERSION_2, 14 | COPIER_REMOTE_NAME, 15 | COPIER_REMOTE_VERSION_1, 16 | COPIER_REMOTE_VERSION_2, 17 | ) 18 | from tests.fixtures.template import ( 19 | get_footer_for_copier_remote_template, 20 | get_header_for_cookiecutter_remote_template, 21 | ) 22 | 23 | 24 | @dataclass 25 | class RepoPathFixture: 26 | path: str 27 | name: str 28 | is_repo_url: bool 29 | is_local_path: bool 30 | is_ssh_url: bool = False 31 | default_version: Optional[str] = None 32 | versions: List[Optional[str]] = field(default_factory=lambda: [None]) 33 | assert_was_cloned_correctly: Optional[Callable[[Path, Optional[str]], None]] = None 34 | 35 | 36 | def assert_cookiecutter_remote_was_cloned_correctly(path: Path, version: Optional[str]): 37 | expect_file = path / "{{ cookiecutter.name }}" / "{{ cookiecutter.name }}.txt" 38 | header = get_header_for_cookiecutter_remote_template( 39 | version or COOKIECUTTER_REMOTE_VERSION_2 40 | ) 41 | assert expect_file.read_text() == header + "{{ cookiecutter.key }}" 42 | 43 | 44 | def assert_copier_remote_was_cloned_correctly(path: Path, version: Optional[str]): 45 | expect_file = path / "output" / "{{ question1 }}.txt.jinja" 46 | footer = get_footer_for_copier_remote_template(version or COPIER_REMOTE_VERSION_2) 47 | assert expect_file.read_text() == "{{ question2 }}" + footer 48 | 49 | 50 | repo_path_fixtures: Final[List[RepoPathFixture]] = [ 51 | RepoPathFixture(str(COOKIECUTTER_ONE_DIR), COOKIECUTTER_ONE_NAME, False, True), 52 | RepoPathFixture("Aasdfssdfkj", "Aasdfssdfkj", False, False), 53 | RepoPathFixture( 54 | COOKIECUTTER_REMOTE_URL, 55 | COOKIECUTTER_REMOTE_NAME, 56 | True, 57 | False, 58 | default_version=COOKIECUTTER_REMOTE_VERSION_2, 59 | versions=[COOKIECUTTER_REMOTE_VERSION_1, None], 60 | assert_was_cloned_correctly=assert_cookiecutter_remote_was_cloned_correctly, 61 | ), 62 | RepoPathFixture( 63 | COOKIECUTTER_REMOTE_URL + ".git", 64 | COOKIECUTTER_REMOTE_NAME, 65 | True, 66 | False, 67 | default_version=COOKIECUTTER_REMOTE_VERSION_2, 68 | versions=[COOKIECUTTER_REMOTE_VERSION_1, None], 69 | assert_was_cloned_correctly=assert_cookiecutter_remote_was_cloned_correctly, 70 | ), 71 | RepoPathFixture( 72 | "git@github.com:nickderobertis/copier-simple-example.git", 73 | COPIER_REMOTE_NAME, 74 | True, 75 | False, 76 | is_ssh_url=True, 77 | default_version=COPIER_REMOTE_VERSION_2, 78 | versions=[COPIER_REMOTE_VERSION_1, None], 79 | assert_was_cloned_correctly=assert_copier_remote_was_cloned_correctly, 80 | ), 81 | RepoPathFixture( 82 | "git@github.com:nickderobertis/copier-simple-example", 83 | COPIER_REMOTE_NAME, 84 | True, 85 | False, 86 | is_ssh_url=True, 87 | default_version=COPIER_REMOTE_VERSION_2, 88 | versions=[COPIER_REMOTE_VERSION_1, None], 89 | assert_was_cloned_correctly=assert_copier_remote_was_cloned_correctly, 90 | ), 91 | ] 92 | 93 | repo_path_non_ssh_fixtures: Final[List[RepoPathFixture]] = [ 94 | fixture for fixture in repo_path_fixtures if fixture.is_ssh_url == False 95 | ] 96 | 97 | 98 | @pytest.fixture(scope="module", params=repo_path_fixtures) 99 | def repo_path_fixture(request) -> RepoPathFixture: 100 | yield request.param 101 | 102 | 103 | @pytest.fixture(scope="module", params=repo_path_non_ssh_fixtures) 104 | def repo_path_non_ssh_fixture(request) -> RepoPathFixture: 105 | yield request.param 106 | -------------------------------------------------------------------------------- /flexlate/render/multi.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | from typing import Final, List, Sequence 5 | 6 | from flexlate.exc import InvalidTemplateClassException, RendererNotFoundException 7 | from flexlate.render.renderable import Renderable 8 | from flexlate.render.specific.base import SpecificTemplateRenderer 9 | from flexlate.render.specific.cookiecutter import CookiecutterRenderer 10 | from flexlate.render.specific.copier import CopierRenderer 11 | from flexlate.temp_path import create_temp_path 12 | from flexlate.template.base import Template 13 | from flexlate.template.types import TemplateType 14 | from flexlate.template_data import TemplateData 15 | 16 | renderers: Final[List[SpecificTemplateRenderer]] = [ 17 | CookiecutterRenderer(), 18 | CopierRenderer(), 19 | ] 20 | 21 | 22 | class MultiRenderer: 23 | 24 | # TODO: register method to add user-defined template types 25 | 26 | def render( 27 | self, 28 | renderables: Sequence[Renderable], 29 | project_root: Path = Path("."), 30 | no_input: bool = False, 31 | ) -> List[TemplateData]: 32 | out_data: List[TemplateData] = [] 33 | with create_temp_path() as temp_root: 34 | temp_folders: List[Path] = [] 35 | for i, renderable in enumerate(renderables): 36 | template = renderable.template 37 | renderer = _get_specific_renderer(template) 38 | temp_folder = temp_root / f"{i + 1}-{template.name}" / project_root.name 39 | temp_folders.append(temp_folder) 40 | if renderable.out_root.is_absolute(): 41 | relative_root = Path( 42 | os.path.relpath(renderable.out_root, project_root.absolute()) 43 | ) 44 | else: 45 | relative_root = renderable.out_root 46 | new_root = temp_folder / relative_root 47 | temp_renderable = renderable.copy(update=dict(out_root=new_root)) 48 | renderable_no_input = no_input or temp_renderable.skip_prompts 49 | template_data = renderer.render( 50 | temp_renderable, no_input=renderable_no_input 51 | ) 52 | out_data.append(template_data) 53 | _merge_file_trees(temp_folders, project_root) 54 | return out_data 55 | 56 | def render_string( 57 | self, 58 | string: str, 59 | renderable: Renderable, 60 | ) -> str: 61 | template = renderable.template 62 | renderer = _get_specific_renderer(template) 63 | return renderer.render_string(string, renderable) 64 | 65 | 66 | def _get_specific_renderer(template: Template) -> SpecificTemplateRenderer: 67 | if template._type == TemplateType.BASE: 68 | raise InvalidTemplateClassException( 69 | f"No renderer for template type base, did you remember to override _type when defining the template type? {template}" 70 | ) 71 | for renderer in renderers: 72 | if renderer._template_type == template._type: 73 | return renderer 74 | raise RendererNotFoundException(f"No registered renderer for template {template}") 75 | 76 | 77 | def _merge_file_trees(dirs: Sequence[Path], out_dir: Path): 78 | for directory in dirs: 79 | _copy_files_to_directory(directory, out_dir) 80 | 81 | 82 | def _copy_files_to_directory(dir: Path, out_dir: Path): 83 | for root, folders, files in os.walk(dir): 84 | in_folder = Path(root) 85 | relative_path = in_folder.relative_to(dir) 86 | out_folder = out_dir / relative_path 87 | if not out_folder.exists(): 88 | out_folder.mkdir() 89 | for file in files: 90 | in_path = in_folder / file 91 | out_path = out_folder / file 92 | if out_path.exists(): 93 | content = in_path.read_text() 94 | with open(out_path, mode="a") as f: 95 | f.write(content) 96 | else: 97 | shutil.copy(in_path, out_path) 98 | -------------------------------------------------------------------------------- /tests/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | PROJECT_DIR = Path(__file__).parent.parent 4 | 5 | TESTS_DIR = Path(__file__).parent 6 | INPUT_FILES_DIR = TESTS_DIR / "input_files" 7 | GENERATED_FILES_DIR = TESTS_DIR / "generated" 8 | GENERATED_REPO_DIR = GENERATED_FILES_DIR / "project" 9 | USING_TEMP_DIR_AS_GENERATED_DIR = False 10 | 11 | TEMPLATES_DIR = INPUT_FILES_DIR / "templates" 12 | 13 | COOKIECUTTERS_DIR = TEMPLATES_DIR / "cookiecutters" 14 | COOKIECUTTER_ONE_NAME = "one" 15 | COOKIECUTTER_ONE_DIR = COOKIECUTTERS_DIR / COOKIECUTTER_ONE_NAME 16 | COOKIECUTTER_ONE_VERSION = "1c154af24ff30bc4cab8cf9d543304d9" 17 | COOKIECUTTER_ONE_MODIFIED_VERSION = "2dc435b3d7e256fbdcc78e62faaabff4" 18 | COOKIECUTTER_TWO_NAME = "two" 19 | COOKIECUTTER_TWO_DIR = COOKIECUTTERS_DIR / COOKIECUTTER_TWO_NAME 20 | COOKIECUTTER_WITH_HOOKS_NAME = "with-hooks" 21 | COOKIECUTTER_WITH_HOOKS_DIR = COOKIECUTTERS_DIR / COOKIECUTTER_WITH_HOOKS_NAME 22 | COOKIECUTTER_WITH_HOOKS_VERSION = "f54b5faa0a6318d5a21434235eb9e1a9" 23 | COOKIECUTTER_WITH_HOOKS_MODIFIED_VERSION = "534cfcb2dce2a8d94e15e872e6ff08ba" 24 | COOKIECUTTER_REMOTE_URL = ( 25 | "https://github.com/nickderobertis/cookiecutter-simple-example" 26 | ) 27 | COOKIECUTTER_REMOTE_NAME = "cookiecutter-simple-example" 28 | COOKIECUTTER_REMOTE_VERSION_1 = "c390901c4fd599473bdb95fa4dd3d2a6eb2b34f0" 29 | COOKIECUTTER_REMOTE_VERSION_2 = "ee8da996b5d74dfcbba5727d7c950c88173dc9ca" 30 | COOKIECUTTER_CHANGES_TO_COPIER_REMOTE_URL = ( 31 | "https://github.com/nickderobertis/cookiecutter-changes-to-copier-example" 32 | ) 33 | COOKIECUTTER_CHANGES_TO_COPIER_REMOTE_NAME = "cookiecutter-changes-to-copier-example" 34 | COOKIECUTTER_CHANGES_TO_COPIER_REMOTE_VERSION_1 = ( 35 | "fcaba89a8137dbb08902cb4c02837cb5bfe75b1c" 36 | ) 37 | COOKIECUTTER_CHANGES_TO_COPIER_REMOTE_VERSION_2 = ( 38 | "8322fdc62122cfe0e8ca435cdd0d0e231f1d0a43" 39 | ) 40 | 41 | COPIERS_DIR = TEMPLATES_DIR / "copiers" 42 | COPIER_ONE_NAME = "one" 43 | COPIER_ONE_DIR = COPIERS_DIR / COPIER_ONE_NAME 44 | COPIER_ONE_VERSION = "a6e386b97e1d7de2670e4fc4fee5b655" 45 | COPIER_ONE_MODIFIED_VERSION = "c5d65a8f94813d33ef031b597358d085" 46 | COPIER_OUTPUT_SUBDIR_NAME = "output-subdir" 47 | COPIER_OUTPUT_SUBDIR_DIR = COPIERS_DIR / COPIER_OUTPUT_SUBDIR_NAME 48 | COPIER_OUTPUT_SUBDIR_VERSION = "783e158aea9852664d497b19f6ba7b8b" 49 | COPIER_OUTPUT_SUBDIR_MODIFIED_VERSION = "does not matter yet for tests" 50 | COPIER_FROM_COOKIECUTTER_ONE_NAME = "from-cookiecutter-one" 51 | COPIER_FROM_COOKIECUTTER_ONE_DIR = COPIERS_DIR / COPIER_FROM_COOKIECUTTER_ONE_NAME 52 | COPIER_FROM_COOKIECUTTER_ONE_VERSION = "5a4e3d4bf1fe026cb6e63dbca154825a" 53 | COPIER_WITH_TASKS_NAME = "with-tasks" 54 | COPIER_WITH_TASKS_DIR = COPIERS_DIR / COPIER_WITH_TASKS_NAME 55 | COPIER_WITH_TASKS_VERSION = "ba749e5c12a24679aa138dfaec88f8ee" 56 | COPIER_WITH_TASKS_MODIFIED_VERSION = "8597833b6069a1ecb114e5d84cee0d09" 57 | COPIER_REMOTE_URL = "https://github.com/nickderobertis/copier-simple-example" 58 | COPIER_REMOTE_NAME = "copier-simple-example" 59 | COPIER_REMOTE_VERSION_1 = "c7e1ba1bfb141e9c577e7c21ee4a5d3ae5dde04d" 60 | COPIER_REMOTE_VERSION_2 = "f3fa2c9526c12b3011e0a26108ce141373539bca" 61 | 62 | CONFIGS_DIR = INPUT_FILES_DIR / "configs" 63 | CONFIG_1_PATH = CONFIGS_DIR / "flexlate.json" 64 | CONFIG_SUBDIR_1 = CONFIG_1_PATH / "subdir1" 65 | CONFIG_SUBDIR_2 = CONFIGS_DIR / "subdir2" 66 | 67 | PROJECTS_DIR = INPUT_FILES_DIR / "projects" 68 | NESTED_PROJECT_DIR = PROJECTS_DIR / "nested" 69 | 70 | PROJECT_CONFIGS_DIR = INPUT_FILES_DIR / "project_configs" 71 | PROJECT_CONFIGS_ROOT_DIR = PROJECT_CONFIGS_DIR / "root" 72 | PROJECT_CONFIGS_ROOT_CONFIG_PATH = PROJECT_CONFIGS_ROOT_DIR / "flexlate-project.json" 73 | PROJECT_CONFIGS_PROJECT_1_PATH = PROJECT_CONFIGS_ROOT_DIR / "1" 74 | PROJECT_CONFIGS_PROJECT_2_PATH = PROJECT_CONFIGS_ROOT_DIR / "2" 75 | PROJECT_CONFIGS_PROJECT_1_SUBDIR = PROJECT_CONFIGS_PROJECT_1_PATH / "subdir" 76 | PROJECT_CONFIGS_PROJECT_2_SUBDIR = PROJECT_CONFIGS_PROJECT_2_PATH / "subdir" 77 | PROJECT_CONFIGS_1_CONFIG_PATH = PROJECT_CONFIGS_PROJECT_1_PATH / "flexlate-project.json" 78 | 79 | 80 | if not GENERATED_FILES_DIR.exists(): 81 | GENERATED_FILES_DIR.mkdir() 82 | -------------------------------------------------------------------------------- /flexlate/render/specific/cookiecutter.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from cookiecutter.config import get_user_config 4 | from cookiecutter.generate import generate_context, generate_files 5 | from cookiecutter.prompt import prompt_for_config 6 | 7 | from flexlate.render.renderable import Renderable 8 | from flexlate.render.specific.base import SpecificTemplateRenderer 9 | from flexlate.temp_path import create_temp_path 10 | from flexlate.template.cookiecutter import CookiecutterTemplate 11 | from flexlate.template.types import TemplateType 12 | from flexlate.template_data import TemplateData 13 | 14 | 15 | class CookiecutterRenderer(SpecificTemplateRenderer[CookiecutterTemplate]): 16 | _template_cls = CookiecutterTemplate 17 | _template_type = TemplateType.COOKIECUTTER 18 | 19 | def render( 20 | self, 21 | renderable: Renderable[CookiecutterTemplate], 22 | no_input: bool = False, 23 | ) -> TemplateData: 24 | template = renderable.template 25 | config_dict = get_user_config() 26 | context_file = template.path / "cookiecutter.json" 27 | 28 | context = generate_context( 29 | context_file=context_file, 30 | default_context=config_dict["default_context"], 31 | extra_context=renderable.data, 32 | ) 33 | context["cookiecutter"] = prompt_for_config(context, no_input) 34 | context["cookiecutter"]["_template"] = template.path 35 | 36 | generate_files( 37 | repo_dir=str(template.path), 38 | context=context, 39 | overwrite_if_exists=False, 40 | skip_if_file_exists=False, 41 | output_dir=str(renderable.out_root), 42 | ) 43 | 44 | used_data = dict(context["cookiecutter"]) 45 | used_data.pop("_template") 46 | 47 | return used_data 48 | 49 | def render_string( 50 | self, 51 | string: str, 52 | renderable: Renderable[CookiecutterTemplate], 53 | ) -> str: 54 | template = renderable.template 55 | config_dict = get_user_config() 56 | context_file = template.path / "cookiecutter.json" 57 | 58 | context = generate_context( 59 | context_file=context_file, 60 | default_context=config_dict["default_context"], 61 | extra_context=renderable.data, 62 | ) 63 | context["cookiecutter"] = prompt_for_config(context, no_input=True) 64 | 65 | with create_temp_path() as temp_template_path: 66 | temp_template_dir = str(temp_template_path) 67 | context["cookiecutter"]["_template"] = temp_template_dir 68 | ( 69 | cookiecutter_pre_folder_name, 70 | cookiecutter_post_folder_name, 71 | ) = _cookiecutter_before_and_after_render_string_temp_folder(context) 72 | temp_template_file_path = ( 73 | temp_template_path / cookiecutter_pre_folder_name / "temp.txt" 74 | ) 75 | temp_template_file_path.parent.mkdir() 76 | temp_template_file_path.write_text(string) 77 | 78 | with create_temp_path() as temp_output_dir: 79 | generate_files( 80 | repo_dir=temp_template_dir, 81 | context=context, 82 | overwrite_if_exists=False, 83 | skip_if_file_exists=False, 84 | output_dir=temp_output_dir, 85 | ) 86 | temp_output_file_path = ( 87 | temp_output_dir / cookiecutter_post_folder_name / "temp.txt" 88 | ) 89 | output = temp_output_file_path.read_text() 90 | 91 | return output 92 | 93 | 94 | def _get_first_question_and_answer_from_context(context: dict) -> Tuple[str, str]: 95 | cookiecutter_context = context["cookiecutter"] 96 | for question, answer in cookiecutter_context.items(): 97 | return question, answer 98 | raise ValueError("no questions in cookiecutter context, this is not expected") 99 | 100 | 101 | def _cookiecutter_before_and_after_render_string_temp_folder( 102 | cookiecutter_context: dict, 103 | ) -> Tuple[str, str]: 104 | question, answer = _get_first_question_and_answer_from_context(cookiecutter_context) 105 | return "{{ cookiecutter." + question + " }}", answer 106 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/updating.md: -------------------------------------------------------------------------------- 1 | # Update Templates 2 | 3 | One of the big advantages of Flexlate is the ease of bringing updates 4 | to the template back to your project. Flexlate creates [real Git merge 5 | conflicts](../core-concepts.md#real-merge-conflicts), so you can use your preferred merge tool to resolve them. 6 | Flexlate [saves the resolved conflicts in Git branches](../core-concepts.md#git-native), so you won't 7 | have to resolve the same conflicts again on the next update. 8 | 9 | ## Updating Templates 10 | 11 | The [`fxt update`](../commands.md#fxt-update) command updates the template(s) 12 | to the latest version allowed. It can also be used to update the data 13 | with or without updating the version. 14 | 15 | ```{run-git-terminal} 16 | --- 17 | setup: "fxt init-from https://github.com/nickderobertis/copier-simple-example --no-input --version c7e1ba1bfb141e9c577e7c21ee4a5d3ae5dde04d --folder-name my-project && cd my-project && fxt config target copier-simple-example" 18 | input: "[None, '\\n50']" 19 | --- 20 | cat answer1.txt 21 | fxt update 22 | cat answer1.txt 23 | ``` 24 | 25 | By default, it will prompt for all the questions, using the saved answers 26 | as defaults. You can change any data that you want during the update process. 27 | 28 | ### Updating without Prompts 29 | 30 | If you want to use the existing data and skip all prompts, you can 31 | pass the `--no-input` flag or `-n` for short: 32 | 33 | ```shell 34 | fxt update -n 35 | ``` 36 | 37 | ### Updating specific Templates 38 | 39 | 40 | You can update specific templates by passing the names of the template 41 | sources you want to update, for example: 42 | 43 | ```shell 44 | fxt update copier-simple-example 45 | ``` 46 | 47 | ### Checking for Updates 48 | 49 | The [`fxt check`](../commands.md#fxt-check) command 50 | checks for updates to the template(s). It displays 51 | them in a tabular format if there are updates available. 52 | 53 | ```{run-git-terminal} 54 | --- 55 | setup: "fxt init-from https://github.com/nickderobertis/copier-simple-example --no-input --version c7e1ba1bfb141e9c577e7c21ee4a5d3ae5dde04d --folder-name my-project && cd my-project" 56 | allow-exceptions: True 57 | --- 58 | fxt check 59 | fxt config target copier-simple-example 60 | fxt check 61 | ``` 62 | 63 | For scripting purposes, it returns code `0` if there are no updates available, 64 | and `1` if there are: 65 | 66 | ```shell 67 | if ! fxt check; then 68 | echo "Need to update template"; 69 | else 70 | echo "No updates to template needed"; 71 | fi; 72 | ``` 73 | 74 | ### Change Target Version 75 | 76 | Normally, the target version in a template source will be set to `null`, 77 | meaning `fxt update` will always update to the newest version. If a template 78 | source was added with the `--version` flag, then it will have that version 79 | set as the target version. When the target version is specified for a template 80 | source, `fxt update` will not update beyond that version. 81 | 82 | To remove the target version, use the 83 | [`fxt config target`](../commands.md#fxt-config-target) command to remove 84 | the target version. 85 | 86 | ```{run-fxt-terminal} 87 | --- 88 | input: "my answer\n10\n" 89 | --- 90 | fxt init-from https://github.com/nickderobertis/copier-simple-example --version c7e1ba1bfb141e9c577e7c21ee4a5d3ae5dde04d 91 | cd project 92 | cat flexlate.json | grep target 93 | fxt config target copier-simple-example 94 | cat flexlate.json | grep target 95 | cat 'my answer.txt' 96 | fxt update --no-input 97 | cat 'my answer.txt' 98 | ``` 99 | 100 | ## Get Automated PRs with Template Updates 101 | 102 | Flexlate has 103 | [official companion Github Actions](../core-concepts.md#ci-workflows) 104 | that can automate using Flexlate. The 105 | [Flexlate Update Action](https://github.com/nickderobertis/flexlate-update-action) 106 | can be used to help automatically get PRs for template updates. 107 | 108 | If there is a merge conflict in the changes, it will open a separate 109 | PR to resolve the conflicts, allowing you to resolve them in Github's 110 | web editor. 111 | 112 | Follow the [user guide on CI automation](ci-automation.md) 113 | to hook up this workflow and 114 | other supporting workflows. 115 | 116 | ## Next Steps 117 | 118 | In order to keep updating and not have to resolve the same merge conflicts 119 | repeatedly, we need to [save the Flexlate history](saving.md). -------------------------------------------------------------------------------- /tests/integration/test_main.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from flexlate.main import Flexlate 4 | from flexlate.template.copier import CopierTemplate 5 | from flexlate.template.types import TemplateType 6 | from tests import config as test_config 7 | from tests.fixtures.templated_repo import * 8 | 9 | fxt = Flexlate() 10 | 11 | 12 | def test_add_template_source_from_current_path( 13 | repo_with_placeholder_committed: Repo, 14 | cookiecutter_one_template: CookiecutterTemplate, 15 | ): 16 | template = cookiecutter_one_template 17 | 18 | with change_directory_to(test_config.GENERATED_REPO_DIR): 19 | fxt.init_project() 20 | with change_directory_to(COOKIECUTTER_ONE_DIR): 21 | fxt.add_template_source(".", template_root=test_config.GENERATED_REPO_DIR) 22 | 23 | config_path = test_config.GENERATED_REPO_DIR / "flexlate.json" 24 | config = FlexlateConfig.load(config_path) 25 | assert len(config.applied_templates) == 0 26 | assert len(config.template_sources) == 1 27 | source = config.template_sources[0] 28 | assert source.name == template.name 29 | assert source.path == os.path.relpath( 30 | COOKIECUTTER_ONE_DIR, test_config.GENERATED_REPO_DIR 31 | ) 32 | assert source.version == template.version 33 | assert source.type == TemplateType.COOKIECUTTER 34 | assert source.render_relative_root_in_output == Path("{{ cookiecutter.a }}") 35 | assert source.render_relative_root_in_template == Path("{{ cookiecutter.a }}") 36 | 37 | 38 | def test_init_from_current_path_cookiecutter( 39 | cookiecutter_one_template: CookiecutterTemplate, 40 | ): 41 | template = cookiecutter_one_template 42 | project_dir = test_config.GENERATED_FILES_DIR / "b" 43 | 44 | with change_directory_to(template.path): 45 | fxt.init_project_from(".", path=test_config.GENERATED_FILES_DIR, no_input=True) 46 | 47 | content_path = project_dir / "text.txt" 48 | content = content_path.read_text() 49 | assert content == "b" 50 | 51 | config_path = project_dir / "flexlate.json" 52 | config = FlexlateConfig.load(config_path) 53 | assert len(config.template_sources) == 1 54 | source = config.template_sources[0] 55 | assert source.name == template.name 56 | assert source.path == os.path.relpath(cookiecutter_one_template.path, project_dir) 57 | assert source.version == template.version 58 | assert source.type == TemplateType.COOKIECUTTER 59 | assert source.render_relative_root_in_output == Path("{{ cookiecutter.a }}") 60 | assert source.render_relative_root_in_template == Path("{{ cookiecutter.a }}") 61 | assert len(config.applied_templates) == 1 62 | at = config.applied_templates[0] 63 | assert at.name == template.name 64 | assert at.version == template.version 65 | assert at.data == {"a": "b", "c": ""} 66 | assert at.root == Path("..") 67 | assert at.add_mode == AddMode.LOCAL 68 | 69 | 70 | def test_init_from_current_path_copier( 71 | copier_one_template: CopierTemplate, 72 | ): 73 | template = copier_one_template 74 | project_dir = test_config.GENERATED_FILES_DIR / "project" 75 | 76 | with change_directory_to(template.path): 77 | fxt.init_project_from(".", path=test_config.GENERATED_FILES_DIR, no_input=True) 78 | 79 | content_path = project_dir / "a1.txt" 80 | content = content_path.read_text() 81 | assert content == "1" 82 | 83 | readme_path = project_dir / "README.md" 84 | assert readme_path.read_text() == "some existing content" 85 | 86 | config_path = project_dir / "flexlate.json" 87 | config = FlexlateConfig.load(config_path) 88 | assert len(config.template_sources) == 1 89 | source = config.template_sources[0] 90 | assert source.name == template.name 91 | assert source.path == os.path.relpath(template.path, test_config.GENERATED_REPO_DIR) 92 | assert source.version == template.version 93 | assert source.type == TemplateType.COPIER 94 | assert source.render_relative_root_in_output == Path(".") 95 | assert source.render_relative_root_in_template == Path(".") 96 | assert len(config.applied_templates) == 1 97 | at = config.applied_templates[0] 98 | assert at.name == template.name 99 | assert at.version == template.version 100 | assert at.data == {"q1": "a1", "q2": 1, "q3": None} 101 | assert at.root == Path(".") 102 | assert at.add_mode == AddMode.LOCAL 103 | -------------------------------------------------------------------------------- /tests/integration/test_update.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from flexlate import Flexlate 4 | from flexlate.template.types import TemplateType 5 | from tests import config as test_config 6 | from tests.integration.fixtures.repo import * 7 | from tests.integration.fixtures.template_source import ( 8 | COOKIECUTTER_CHANGES_TO_COPIER_LOCAL_FIXTURE, 9 | COPIER_LOCAL_FIXTURE, 10 | TemplateSourceFixture, 11 | template_source_cookiecutter_changes_to_copier, 12 | template_source_with_temp_dir_if_local_template, 13 | ) 14 | from tests.integration.template_source_checks import ( 15 | assert_root_template_source_output_is_correct, 16 | ) 17 | 18 | fxt = Flexlate() 19 | 20 | 21 | def test_update_template_from_cookiecutter_to_copier( 22 | repo_with_default_flexlate_project: Repo, 23 | template_source_cookiecutter_changes_to_copier: TemplateSourceFixture, 24 | ): 25 | template_source = template_source_cookiecutter_changes_to_copier 26 | 27 | def assert_template_type_is(template_type: TemplateType): 28 | config = FlexlateConfig.load(test_config.GENERATED_REPO_DIR / "flexlate.json") 29 | assert len(config.template_sources) == 1 30 | ts = config.template_sources[0] 31 | assert ts.type == template_type 32 | 33 | with change_directory_to(test_config.GENERATED_REPO_DIR): 34 | fxt.add_template_source( 35 | template_source.path, target_version=template_source.version_1 36 | ) 37 | fxt.apply_template_and_add( 38 | template_source.name, data=template_source.input_data, no_input=True 39 | ) 40 | 41 | # Check that files are correct 42 | assert_root_template_source_output_is_correct( 43 | template_source, 44 | after_version_update=False, 45 | after_data_update=False, 46 | target_version=template_source.version_1, 47 | ) 48 | assert_template_type_is(TemplateType.COOKIECUTTER) 49 | 50 | # Update local template, now it is a copier template 51 | template_source.migrate_version(template_source.url_or_absolute_path) 52 | 53 | # Remove targeting to version 1 54 | fxt.update_template_source_target_version(template_source.name) 55 | 56 | # Should be anle to directly update even though template type changes 57 | fxt.update(data=[template_source.update_input_data], no_input=True) 58 | 59 | # Check that files are correct 60 | assert_root_template_source_output_is_correct( 61 | template_source, 62 | after_version_update=True, 63 | after_data_update=True, 64 | ) 65 | assert_template_type_is(TemplateType.COPIER) 66 | 67 | 68 | def test_update_template_with_relative_path_from_outside_project( 69 | repo_with_default_flexlate_project: Repo, 70 | ): 71 | update_from_dir = test_config.GENERATED_FILES_DIR / "something" 72 | update_from_dir.mkdir() 73 | 74 | project_dir = test_config.GENERATED_REPO_DIR 75 | 76 | with template_source_with_temp_dir_if_local_template( 77 | COPIER_LOCAL_FIXTURE 78 | ) as template_source: 79 | # First set up project so it can be updated 80 | with change_directory_to(project_dir): 81 | fxt.add_template_source(template_source.path) 82 | fxt.apply_template_and_add( 83 | template_source.name, data=template_source.input_data, no_input=True 84 | ) 85 | 86 | # Check that files are correct 87 | assert_root_template_source_output_is_correct( 88 | template_source, 89 | after_version_update=False, 90 | after_data_update=False, 91 | ) 92 | 93 | # Update local template 94 | template_source.migrate_version(template_source.url_or_absolute_path) 95 | 96 | # Now update from outside project 97 | with change_directory_to(update_from_dir): 98 | # Should be anle to directly update even though template type changes 99 | fxt.update( 100 | data=[template_source.update_input_data], 101 | no_input=True, 102 | project_path=Path("..") / "project", 103 | ) 104 | 105 | # Check that files are correct 106 | assert_root_template_source_output_is_correct( 107 | template_source, 108 | after_version_update=True, 109 | after_data_update=True, 110 | ) 111 | -------------------------------------------------------------------------------- /docsrc/source/tutorial/saving.md: -------------------------------------------------------------------------------- 1 | # Save your Flexlate Updates 2 | 3 | Flexlate [saves the history of your merge conflict resolutions](../core-concepts.md#branches-for-flexlate-operations) 4 | so that you don't need to resolve them multiple times. Flexlate mostly manages 5 | this history for you, but you do get to choose when you want to 6 | "save" the history by merging the [Flexlate feature branches](../core-concepts.md#flexlate-feature-branches) 7 | into the 8 | [Flexlate main branches](../core-concepts.md#flexlate-main-branches). 9 | 10 | After doing a Flexlate operation such as 11 | [`fxt update`](../commands.md#fxt-update), 12 | [`fxt add output`](../commands.md#fxt-add-output), or 13 | [`fxt sync`](../commands.md#fxt-sync) Flexlate will create feature branches 14 | to correspond to your currently checked out branch. In order to save the 15 | Flexlate history, you need to merge these Flexlate feature branches into 16 | the Flexlate main branches. 17 | 18 | ## Local Workflows 19 | 20 | If directly commit to the main branch or locally merge branches into 21 | your main branch, you will want to use the 22 | [`fxt merge`](../commands.md#fxt-merge) command locally to save your history. 23 | You can then push the changes to remote with 24 | [`fxt push main`](../commands.md#fxt-push-main) 25 | 26 | ### Committing on the Main Branch 27 | 28 | If you commit on the main branch, let's call it `main`, you will have 29 | Flexlate feature branches created corresponding to your main branch. 30 | Simply call `fxt merge` whenever you finish a Flexlate operation 31 | and are satisfied with the result. 32 | 33 | ```{run-fxt-terminal} 34 | --- 35 | setup: "fxt add source https://github.com/nickderobertis/copier-simple-example" 36 | input: "my answer\n10" 37 | --- 38 | fxt add output copier-simple-example some/path 39 | fxt merge 40 | ``` 41 | 42 | ### Locally Merging Branches 43 | 44 | After you've done a template update on a feature branch and you're ready 45 | to merge the changes into main, first run `fxt merge` before doing so. 46 | 47 | ```{run-fxt-terminal} 48 | --- 49 | setup: "fxt add source https://github.com/nickderobertis/copier-simple-example" 50 | input: "[None, 'my answer\\n10']" 51 | --- 52 | git checkout -b feature-branch 53 | fxt add output copier-simple-example some/path 54 | fxt merge 55 | git checkout main 56 | git merge feature-branch 57 | ``` 58 | 59 | ### Push your Flexlate Main Branch Changes 60 | 61 | After you've merged the Flexlate feature branches into the Flexlate main 62 | branches, you'll want to push your changes to the remote (if you are using 63 | a remote) via [`fxt push main`](../commands.md#fxt-push-main). 64 | 65 | ```{run-fxt-terminal} 66 | --- 67 | setup: "fxt add source https://github.com/nickderobertis/copier-simple-example && rm -rf ../remote && mkdir ../remote && git init ../remote && git remote add origin ../remote" 68 | input: "my answer\n10" 69 | --- 70 | fxt add output copier-simple-example some/path 71 | fxt merge 72 | fxt push main 73 | ``` 74 | 75 | ## PR Workflows 76 | 77 | If you use pull requests in your project to merge changes into the main branch, 78 | then instead of merging locally, you will want to push the Flexlate 79 | feature branches with [`fxt push feature`](../commands.md#fxt-push-feature) 80 | so that they can be merged in on the remote. 81 | 82 | ### Push your Flexlate Feature Branch Changes 83 | 84 | Use the [`fxt push feature`](../commands.md#fxt-push-feature) command to push 85 | the Flexlate feature branches after you've finished work on your feature branch. 86 | You would want to run this whenever you are pushing up the feature branch itself. 87 | 88 | ```{run-fxt-terminal} 89 | --- 90 | setup: "fxt add source https://github.com/nickderobertis/copier-simple-example && rm -rf ../remote && mkdir ../remote && git init ../remote && git remote add origin ../remote" 91 | input: "[None, 'my answer\\n10']" 92 | --- 93 | git checkout -b feature-branch 94 | fxt add output copier-simple-example some/path 95 | git push origin feature-branch 96 | fxt push feature 97 | ``` 98 | 99 | ### Merge Flexlate Branches Automatically with Github Actions 100 | 101 | Flexlate has 102 | [official companion Github Actions](../core-concepts.md#ci-workflows) 103 | that can automate using Flexlate. The 104 | [Flexlate After-Merge Action](https://github.com/nickderobertis/flexlate-merge-action) 105 | can be used to automate merging the Flexlate feature branches into the 106 | Flexlate main branches. 107 | 108 | Follow the [user guide on CI automation](ci-automation.md) 109 | to hook up this workflow and 110 | other supporting workflows. -------------------------------------------------------------------------------- /flexlate/bootstrapper.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | from git import Repo 5 | 6 | from flexlate.add_mode import AddMode 7 | from flexlate.adder import Adder 8 | from flexlate.config_manager import ConfigManager 9 | from flexlate.constants import DEFAULT_MERGED_BRANCH_NAME, DEFAULT_TEMPLATE_BRANCH_NAME 10 | from flexlate.render.multi import MultiRenderer 11 | from flexlate.styles import INFO_STYLE, SUCCESS_STYLE, print_styled 12 | from flexlate.template.base import Template 13 | from flexlate.template_data import TemplateData 14 | from flexlate.transactions.transaction import FlexlateTransaction 15 | from flexlate.update.main import Updater 16 | from flexlate.user_config_manager import UserConfigManager 17 | 18 | 19 | class Bootstrapper: 20 | def bootstrap_flexlate_init_from_existing_template( 21 | self, 22 | repo: Repo, 23 | template: Template, 24 | transaction: FlexlateTransaction, 25 | target_version: Optional[str] = None, 26 | data: Optional[TemplateData] = None, 27 | default_add_mode: AddMode = AddMode.LOCAL, 28 | merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 29 | base_merged_branch_name: str = DEFAULT_MERGED_BRANCH_NAME, 30 | template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 31 | base_template_branch_name: str = DEFAULT_TEMPLATE_BRANCH_NAME, 32 | remote: str = "origin", 33 | no_input: bool = False, 34 | adder: Adder = Adder(), 35 | config_manager: ConfigManager = ConfigManager(), 36 | renderer: MultiRenderer = MultiRenderer(), 37 | updater: Updater = Updater(), 38 | user_config_manager: UserConfigManager = UserConfigManager(), 39 | ): 40 | if repo.working_dir is None: 41 | raise ValueError("repo working dir must not be None") 42 | project_root = Path(repo.working_dir) 43 | 44 | print_styled( 45 | f"Bootstrapping {project_root} into a Flexlate project based off the template from {template.template_source_path}", 46 | INFO_STYLE, 47 | ) 48 | 49 | adder.init_project_and_add_to_branches( 50 | repo, 51 | default_add_mode=default_add_mode, 52 | merged_branch_name=merged_branch_name, 53 | template_branch_name=template_branch_name, 54 | remote=remote, 55 | config_manager=config_manager, 56 | ) 57 | adder.add_template_source( 58 | repo, 59 | template, 60 | transaction, 61 | target_version=target_version, 62 | out_root=project_root, 63 | merged_branch_name=merged_branch_name, 64 | template_branch_name=template_branch_name, 65 | base_merged_branch_name=base_merged_branch_name, 66 | base_template_branch_name=base_template_branch_name, 67 | remote=remote, 68 | config_manager=config_manager, 69 | ) 70 | adder.apply_template_and_add( 71 | repo, 72 | template, 73 | transaction, 74 | data=data, 75 | out_root=project_root, 76 | add_mode=AddMode.LOCAL, 77 | merged_branch_name=merged_branch_name, 78 | template_branch_name=template_branch_name, 79 | base_merged_branch_name=base_merged_branch_name, 80 | base_template_branch_name=base_template_branch_name, 81 | no_input=no_input, 82 | remote=remote, 83 | config_manager=config_manager, 84 | updater=updater, 85 | renderer=renderer, 86 | ) 87 | if target_version is not None: 88 | # Remove peg to specific version if one was passed 89 | user_config_manager.update_template_source_target_version( 90 | template.name, 91 | None, 92 | repo, 93 | transaction, 94 | project_path=project_root, 95 | add_mode=AddMode.LOCAL, 96 | merged_branch_name=merged_branch_name, 97 | template_branch_name=template_branch_name, 98 | base_merged_branch_name=base_merged_branch_name, 99 | base_template_branch_name=base_template_branch_name, 100 | remote=remote, 101 | config_manager=config_manager, 102 | ) 103 | 104 | print_styled( 105 | f"Successfully bootstrapped {project_root} into a Flexlate project based off the template from {template.template_source_path}", 106 | SUCCESS_STYLE, 107 | ) 108 | -------------------------------------------------------------------------------- /conf.py: -------------------------------------------------------------------------------- 1 | # This is the main settings file for package setup and PyPi deployment. 2 | # Sphinx configuration is in the docsrc folder 3 | 4 | # Main package name 5 | PACKAGE_NAME = "flexlate" 6 | 7 | # Directory name of package 8 | PACKAGE_DIRECTORY = "flexlate" 9 | 10 | # Name of Repo 11 | REPO_NAME = "flexlate" 12 | 13 | # Github username of the user which owns the repo 14 | REPO_USERNAME = "nickderobertis" 15 | 16 | # List of maintainers of package, by default the same user which owns the repo 17 | # Pull requests raised by these maintainers without the "no auto merge" label will be automatically merged 18 | REPO_MAINTAINERS = [ 19 | REPO_USERNAME, 20 | ] 21 | 22 | # Short description of the package 23 | PACKAGE_SHORT_DESCRIPTION = "A composable, maintainable system for managing templates" 24 | 25 | # Long description of the package for PyPI 26 | # Set to 'auto' to use README.md as the PyPI description 27 | # Any other string will be used directly as the PyPI description 28 | PACKAGE_DESCRIPTION = "auto" 29 | 30 | # Author 31 | PACKAGE_AUTHOR = "Nick DeRobertis" 32 | 33 | # Author email 34 | PACKAGE_AUTHOR_EMAIL = "whoopnip@gmail.com" 35 | 36 | # Name of license for package 37 | PACKAGE_LICENSE = "MIT" 38 | 39 | # Classifications for the package, see common settings below 40 | PACKAGE_CLASSIFIERS = [ 41 | # How mature is this project? Common values are 42 | # 3 - Alpha 43 | # 4 - Beta 44 | # 5 - Production/Stable 45 | "Development Status :: 3 - Alpha", 46 | # Indicate who your project is intended for 47 | "Intended Audience :: Developers", 48 | # Specify the Python versions you support here. In particular, ensure 49 | # that you indicate whether you support Python 2, Python 3 or both. 50 | "Programming Language :: Python :: 3.6", 51 | "Programming Language :: Python :: 3.7", 52 | ] 53 | 54 | # Add any third party packages you use in requirements here 55 | PACKAGE_INSTALL_REQUIRES = [ 56 | # Include the names of the packages and any required versions in as strings 57 | # e.g. 58 | # 'package', 59 | # 'otherpackage>=1,<2' 60 | "py-app-conf", 61 | "cookiecutter", 62 | "gitpython", 63 | # Should be able to remove click version fix after typer puts patch for 64 | # ImportError: cannot import name 'get_terminal_size' from 'click.termui' 65 | "click<8.1", 66 | "typer", 67 | "rich", 68 | "markupsafe<2.1", 69 | # Need to land support for Copier 6.0.0, until then peg to 5 or below 70 | "copier==5.*", 71 | ] 72 | 73 | # Add any third party packages you use in requirements for optional features of your package here 74 | # Keys should be name of the optional feature and values are lists of required packages 75 | # E.g. {'feature1': ['pandas', 'numpy'], 'feature2': ['matplotlib']} 76 | OPTIONAL_PACKAGE_INSTALL_REQUIRES = {} 77 | 78 | # Packages added to Binder environment so that examples can be executed in Binder 79 | # By default, takes this package (PACKAGE_NAME) 80 | # everything the package requires (PACKAGE_INSTALL_REQUIRES) and everything 81 | # that the package optionally requires (OPTIONAL_PACKAGE_INSTALL_REQUIRES) and adds them all to one list 82 | # If a custom list is passed, it must include all the requirements for the Binder environment 83 | BINDER_ENVIRONMENT_REQUIRES = list( 84 | set( 85 | PACKAGE_INSTALL_REQUIRES 86 | + [PACKAGE_NAME] 87 | + [ 88 | package 89 | for package_list in OPTIONAL_PACKAGE_INSTALL_REQUIRES.values() 90 | for package in package_list 91 | ] 92 | ) 93 | ) 94 | 95 | 96 | # Sphinx executes all the import statements as it generates the documentation. To avoid having to install all 97 | # the necessary packages, third-party packages can be passed to mock imports to just skip the import. 98 | # By default, everything in PACKAGE_INSTALL_REQUIRES will be passed as mock imports, along with anything here. 99 | # This variable is useful if a package includes multiple packages which need to be ignored. 100 | DOCS_OTHER_MOCK_IMPORTS = [ 101 | # Include the names of the packages as they would be imported, e.g. 102 | # 'package', 103 | ] 104 | 105 | # Add any Python scripts which should be exposed to the command line in the format: 106 | # CONSOLE_SCRIPTS = ['funniest-joke=funniest.command_line:main'] 107 | CONSOLE_SCRIPTS = ["fxt=flexlate.cli:cli"], 108 | 109 | # Add any arbitrary scripts to be exposed to the command line in the format: 110 | # SCRIPTS = ['bin/funniest-joke'] 111 | SCRIPTS = [] 112 | 113 | # Optional Google Analytics tracking ID for documentation 114 | # Go to https://analytics.google.com/ and set it up for your documentation URL 115 | # Set to None or empty string to not use this 116 | GOOGLE_ANALYTICS_TRACKING_ID = "" 117 | 118 | PACKAGE_URLS = { 119 | "Code": f"https://github.com/{REPO_USERNAME}/{REPO_NAME}", 120 | "Documentation": f"https://{REPO_USERNAME}.github.io/{REPO_NAME}", 121 | } 122 | 123 | # Url of logo 124 | PACKAGE_LOGO_URL = "" 125 | 126 | if __name__ == "__main__": 127 | # Store config as environment variables 128 | env_vars = dict(locals()) 129 | # Imports after getting locals so that locals are only environment variables 130 | import shlex 131 | 132 | for name, value in env_vars.items(): 133 | quoted_value = shlex.quote(str(value)) 134 | print(f"export {name}={quoted_value};") 135 | --------------------------------------------------------------------------------