├── tests ├── data │ └── warning_list.txt ├── __init__.py ├── check_warnings.py ├── conftest.py ├── test_copie_parent_child.py └── test_copie.py ├── codecov.yml ├── pytest_copie ├── py.typed ├── __init__.py └── plugin.py ├── stubs └── pytest_copie │ ├── __init__.pyi │ └── plugin.pyi ├── demo_template ├── template │ └── README.rst.jinja ├── copier.yaml ├── README.rst └── tests │ ├── test_custom_template_fixture.py │ └── test_template.py ├── docs ├── contribute.rst ├── _static │ └── custom.css ├── install.rst ├── index.rst ├── conf.py └── usage.rst ├── .readthedocs.yaml ├── .devcontainer └── devcontainer.json ├── .copier-answers.yml ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── PULL_REQUEST_TEMPLATE │ │ └── pr_template.md │ └── feature_request.md └── workflows │ ├── release.yaml │ ├── pypackage_check.yaml │ └── unit.yaml ├── LICENSE ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── noxfile.py ├── .gitignore ├── pyproject.toml ├── README.rst ├── CONTRIBUTING.rst └── CODE_OF_CONDUCT.rst /tests/data/warning_list.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """make test folder a package for coverage.""" 2 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # disable the treemap comment and report in PRs 2 | comment: false 3 | -------------------------------------------------------------------------------- /pytest_copie/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. The mypy package uses inline types. -------------------------------------------------------------------------------- /stubs/pytest_copie/__init__.pyi: -------------------------------------------------------------------------------- 1 | __version__: str 2 | __author__: str 3 | __email__: str 4 | -------------------------------------------------------------------------------- /demo_template/template/README.rst.jinja: -------------------------------------------------------------------------------- 1 | {{ name }} 2 | =============== 3 | 4 | {{ short_description }} 5 | -------------------------------------------------------------------------------- /docs/contribute.rst: -------------------------------------------------------------------------------- 1 | Contribute 2 | ========== 3 | 4 | .. include:: ../CONTRIBUTING.rst 5 | :start-line: 3 6 | -------------------------------------------------------------------------------- /demo_template/copier.yaml: -------------------------------------------------------------------------------- 1 | name: 2 | type: str 3 | default: foobar 4 | short_description: 5 | type: str 6 | default: Test Project 7 | _subdirectory: template 8 | -------------------------------------------------------------------------------- /demo_template/README.rst: -------------------------------------------------------------------------------- 1 | Demo template 2 | ============= 3 | 4 | A demo copier template, including tests with pytest-copie. 5 | For more details, see `docs/usage.rst`. 6 | -------------------------------------------------------------------------------- /pytest_copie/__init__.py: -------------------------------------------------------------------------------- 1 | """The init file of the package.""" 2 | 3 | __version__ = "0.3.1" 4 | __author__ = "Pierrick Rambaud" 5 | __email__ = "pierrick.rambaud49@gmail.com" 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | 3 | version: 2 4 | 5 | build: 6 | os: ubuntu-22.04 7 | tools: 8 | python: "3.10" 9 | 10 | sphinx: 11 | configuration: docs/conf.py 12 | 13 | python: 14 | install: 15 | - method: pip 16 | path: . 17 | extra_requirements: 18 | - doc 19 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", 4 | "features": { 5 | "ghcr.io/devcontainers-extra/features/nox:2": {}, 6 | "ghcr.io/devcontainers-extra/features/pre-commit:2": {} 7 | }, 8 | "postCreateCommand": "python -m pip install commitizen uv && pre-commit install" 9 | } 10 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier 2 | _commit: 0.1.16 3 | _src_path: gh:12rambau/pypackage 4 | author_email: pierrick.rambaud49@gmail.com 5 | author_first_name: Pierrick 6 | author_last_name: Rambaud 7 | author_orcid: 0000-0001-8764-5749 8 | creation_year: "2023" 9 | github_repo_name: pytest-copie 10 | github_user: 12rambau 11 | project_name: pytest-copie 12 | project_slug: pytest_copie 13 | short_description: The pytest plugin for your copier templates 📒 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | # Check for updates to GitHub Actions every week 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE/pr_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull request template 3 | about: Create a pull request 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | ## reference the related issue 10 | 11 | PR should answer problem stated in the issue tracker. please open one before starting a PR 12 | 13 | ## description of the changes 14 | 15 | Describe the changes you propose 16 | 17 | ## mention 18 | 19 | @mentions of the person or team responsible for reviewing proposed changes 20 | 21 | ## comments 22 | 23 | any other comments we should pay attention to 24 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* add dollar sign in console code-block */ 2 | div.highlight-console pre span.go::before { 3 | content: "$"; 4 | margin-right: 10px; 5 | margin-left: 5px; 6 | } 7 | 8 | /* hide read the docs flyout */ 9 | .injected .rst-versions.rst-badge { 10 | display: none; 11 | } 12 | 13 | /* make autosummary tables use all the width available */ 14 | table.autosummary { 15 | width: 100% !important; 16 | } 17 | 18 | table.autosummary tr:not(:last-child) { 19 | white-space: nowrap !important; 20 | } 21 | 22 | table.autosummary td:last-child { 23 | width: 99% !important; 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | env: 8 | PIP_ROOT_USER_ACTION: ignore 9 | 10 | jobs: 11 | tests: 12 | uses: ./.github/workflows/unit.yaml 13 | 14 | deploy: 15 | needs: [tests] 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v5 19 | - uses: actions/setup-python@v6 20 | with: 21 | python-version: "3.12" 22 | - name: Install dependencies 23 | run: pip install twine build nox[uv] 24 | - name: Build and publish 25 | env: 26 | TWINE_USERNAME: __token__ 27 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 28 | run: python -m build && twine upload dist/* 29 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | **pytest-copie** is available on `PyPI `__ and can be installed with `pip `__: 5 | 6 | .. code-block:: console 7 | 8 | pip install pytest-copie 9 | 10 | Alternatively it is also available on `conda-forge `__ and can be installed with `conda `__: 11 | 12 | .. code-block:: console 13 | 14 | conda install pytest-copie 15 | 16 | .. warning:: 17 | 18 | `pytest` is loading all the existing plugin from the running environment which is a super powerful feature of the framework. As this is a port of `pytest-cookies`, 19 | the 2 plugin will conflict with one another if installed in together as reported in https://github.com/12rambau/pytest-copie/issues/85. We highly recommend to run your tests 20 | in dedicated environements siloted from one another. 21 | -------------------------------------------------------------------------------- /stubs/pytest_copie/plugin.pyi: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import Generator, Optional, Union 4 | 5 | @dataclass 6 | class Result: 7 | exception: Union[Exception, SystemExit, None] = ... 8 | exit_code: Union[str, int, None] = ... 9 | project_dir: Optional[Path] = ... 10 | answers: dict = ... 11 | def __repr__(self) -> str: ... 12 | def __init__(self, exception, exit_code, project_dir, answers) -> None: ... 13 | 14 | @dataclass 15 | class Copie: 16 | default_template_dir: Path 17 | test_dir: Path 18 | config_file: Path 19 | counter: int = ... 20 | def copy(self, extra_answers: dict = ..., template_dir: Optional[Path] = ...) -> Result: ... 21 | def __init__(self, default_template_dir, test_dir, config_file, counter) -> None: ... 22 | 23 | def _copier_config_file(tmp_path_factory) -> Path: ... 24 | def copie(request, tmp_path: Path, _copier_config_file: Path) -> Generator: ... 25 | def pytest_addoption(parser) -> None: ... 26 | def pytest_configure(config) -> None: ... 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Pierrick Rambaud 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 | -------------------------------------------------------------------------------- /demo_template/tests/test_custom_template_fixture.py: -------------------------------------------------------------------------------- 1 | """Demo example of how to use pytest-copie with a custom template fixture.""" 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | import yaml 7 | 8 | 9 | @pytest.fixture 10 | def custom_template(tmp_path) -> Path: 11 | """Generate a custom copier template to use as a pytest fixture.""" 12 | # Create custom copier template directory and copier.yaml file 13 | (template := tmp_path / "copier-template").mkdir() 14 | questions = {"custom_name": {"type": "str", "default": "my_default_name"}} 15 | (template / "copier.yaml").write_text(yaml.dump(questions)) 16 | # Create custom subdirectory 17 | (repo_dir := template / "custom_template").mkdir() 18 | (template / "copier.yaml").write_text(yaml.dump({"_subdirectory": "custom_template"})) 19 | # Create custom template text files 20 | (repo_dir / "README.rst.jinja").write_text("{{custom_name}}\n") 21 | 22 | return template 23 | 24 | 25 | def test_copie_custom_project(copie, custom_template): 26 | """Test custom copier template fixture using pytest-copie.""" 27 | result = copie.copy(template_dir=custom_template, extra_answers={"custom_name": "tutu"}) 28 | 29 | assert result.project_dir.is_dir() 30 | with open(result.project_dir / "README.rst") as f: 31 | assert f.readline() == "tutu\n" 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: [pre-commit, commit-msg] 2 | 3 | repos: 4 | - repo: "https://github.com/commitizen-tools/commitizen" 5 | rev: "v2.18.0" 6 | hooks: 7 | - id: commitizen 8 | stages: [commit-msg] 9 | 10 | - repo: "https://github.com/kynan/nbstripout" 11 | rev: "0.5.0" 12 | hooks: 13 | - id: nbstripout 14 | stages: [pre-commit] 15 | 16 | - repo: "https://github.com/pycontribs/mirrors-prettier" 17 | rev: "v3.4.2" 18 | hooks: 19 | - id: prettier 20 | stages: [pre-commit] 21 | exclude: tests\/test_.+\. 22 | 23 | - repo: https://github.com/charliermarsh/ruff-pre-commit 24 | rev: "v0.7.0" 25 | hooks: 26 | - id: ruff 27 | stages: [pre-commit] 28 | - id: ruff-format 29 | stages: [pre-commit] 30 | 31 | - repo: https://github.com/sphinx-contrib/sphinx-lint 32 | rev: "v1.0.0" 33 | hooks: 34 | - id: sphinx-lint 35 | stages: [pre-commit] 36 | 37 | - repo: https://github.com/codespell-project/codespell 38 | rev: v2.2.4 39 | hooks: 40 | - id: codespell 41 | stages: [pre-commit] 42 | additional_dependencies: 43 | - tomli 44 | 45 | # Prevent committing inline conflict markers 46 | - repo: https://github.com/pre-commit/pre-commit-hooks 47 | rev: v6.0.0 48 | hooks: 49 | - id: check-merge-conflict 50 | stages: [pre-commit] 51 | args: [--assume-in-merge] 52 | -------------------------------------------------------------------------------- /demo_template/tests/test_template.py: -------------------------------------------------------------------------------- 1 | """Demo example of how to test a copier template with pytest-copie.""" 2 | 3 | from pathlib import Path 4 | 5 | 6 | def test_template(copie): 7 | """Test the demo copier template using pytest-copie.""" 8 | # This demo copier template is a subdirectory of the main repository 9 | # so we have to specify the location for pytest. 10 | # Users who have `copier.yaml` at the top level of their repository 11 | # can delete the template_dir keyword argument here. 12 | demo_template_dir = Path(__file__).parent.parent 13 | 14 | result = copie.copy(template_dir=demo_template_dir) 15 | 16 | assert result.exit_code == 0 17 | assert result.exception is None 18 | assert result.project_dir.is_dir() 19 | with open(result.project_dir / "README.rst") as f: 20 | assert f.readline() == "foobar\n" 21 | 22 | 23 | def test_template_with_extra_answers(copie): 24 | """Test the demo copier template with extra answers using pytest-copie.""" 25 | # This demo copier template is a subdirectory of the main repository 26 | # so we have to specify the location for pytest. 27 | # Users who have `copier.yaml` at the top level of their repository 28 | # can delete the template_dir keyword argument here. 29 | demo_template_dir = Path(__file__).parent.parent 30 | 31 | result = copie.copy(extra_answers={"name": "helloworld"}, template_dir=demo_template_dir) 32 | 33 | assert result.exit_code == 0 34 | assert result.exception is None 35 | assert result.project_dir.is_dir() 36 | with open(result.project_dir / "README.rst") as f: 37 | assert f.readline() == "helloworld\n" 38 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | pytest-copie 2 | ============ 3 | 4 | .. toctree:: 5 | :hidden: 6 | 7 | install 8 | usage 9 | contribute 10 | 11 | Overview 12 | -------- 13 | 14 | pytest-copie is a `pytest `__ plugin that comes with a :py:func:`copie ` fixture which is a wrapper on top of the `copier `__ API for generating projects. It helps you verify that your template is working as expected and takes care of cleaning up after running the tests. |:ledger:| 15 | 16 | It is an adaptation of the `pytest-cookies `__ plugin for `copier `__ templates. 17 | 18 | It's here to help templates designers to check that everything works as expected on the generated files including (but not limited to): 19 | 20 | - linting operations 21 | - testing operations 22 | - packaging operations 23 | - documentation operations 24 | - ... 25 | 26 | .. note:: 27 | 28 | As this lib is designed to perform test on **copier** template, the test suit is expected to be outside of the source directory copied by **copier**. It can thus only be used in templates using ``subdirectories``. Using it in a raw template will raise a ``ValueError``. 29 | 30 | .. warning:: 31 | 32 | This plugin is called ``pytest-copie`` as the french word for a "copy" object. It should not be confused with ``pytest-copier`` another plugin using different approach that is still in the development phase. You can find their repository `here `__. A conversation about a potential merge of the two projects is ongoing `here `__. 33 | -------------------------------------------------------------------------------- /tests/check_warnings.py: -------------------------------------------------------------------------------- 1 | """Check the warnings from doc builds.""" 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | 7 | def check_warnings(file: Path) -> int: 8 | """Check the list of warnings produced by the CI tests. 9 | 10 | Raises errors if there are unexpected ones and/or if some are missing. 11 | 12 | Args: 13 | file: the path to the generated warning.txt file from 14 | the CI build 15 | 16 | Returns: 17 | 0 if the warnings are all there 18 | 1 if some warning are not registered or unexpected 19 | """ 20 | # print some log 21 | print("\n=== Sphinx Warnings test ===\n") 22 | 23 | # find the file where all the known warnings are stored 24 | warning_file = Path(__file__).parent / "data" / "warning_list.txt" 25 | 26 | test_warnings = file.read_text().strip().split("\n") 27 | ref_warnings = warning_file.read_text().strip().split("\n") 28 | 29 | print( 30 | f'Checking build warnings in file: "{file}" and comparing to expected ' 31 | f'warnings defined in "{warning_file}"\n\n' 32 | ) 33 | 34 | # find all the missing warnings 35 | missing_warnings = [] 36 | for wa in ref_warnings: 37 | index = [i for i, twa in enumerate(test_warnings) if wa in twa] 38 | if len(index) == 0: 39 | missing_warnings += [wa] 40 | print(f"Warning was not raised: {wa}") 41 | else: 42 | test_warnings.pop(index[0]) 43 | 44 | # the remaining one are unexpected 45 | for twa in test_warnings: 46 | print(f"Unexpected warning: {twa}") 47 | 48 | # delete the tmp warnings file 49 | file.unlink() 50 | 51 | return len(missing_warnings) != 0 or len(test_warnings) != 0 52 | 53 | 54 | if __name__ == "__main__": 55 | # cast the file to path and resolve to an absolute one 56 | file = Path.cwd() / "warnings.txt" 57 | 58 | # execute the test 59 | sys.exit(check_warnings(file)) 60 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Thanks goes to these wonderful people (`emoji key `_): 2 | 3 | .. raw:: html 4 | 5 | 6 | 7 | 8 | 22 | 32 | 40 | 41 | 42 |
9 | 10 | 12rambau
11 | Pierrick Rambaud 12 |
13 |
14 | 💻 15 | 🤔 16 | 💬 17 | 🐛 18 | 🚧 19 | 👀 20 | 💡 21 |
23 | 24 | GenevieveBuckley
25 | Genevieve Buckley 26 |
27 |
28 | 💻 29 | 🐛 30 | 💡 31 |
33 | 34 | anjos
35 | André Anjos 36 |
37 |
38 | 💻 39 |
43 | 44 | This project follows the `all-contributors `_ specification. 45 | 46 | Contributions of any kind are welcome! 47 | -------------------------------------------------------------------------------- /.github/workflows/pypackage_check.yaml: -------------------------------------------------------------------------------- 1 | name: template update check 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | PIP_ROOT_USER_ACTION: ignore 8 | 9 | jobs: 10 | check_version: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | - uses: actions/setup-python@v6 15 | with: 16 | python-version: "3.10" 17 | - name: install dependencies 18 | run: pip install requests 19 | - name: get latest pypackage release 20 | id: get_latest_release 21 | run: | 22 | RELEASE=$(curl -s https://api.github.com/repos/12rambau/pypackage/releases | jq -r '.[0].tag_name') 23 | echo "latest=$RELEASE" >> $GITHUB_OUTPUT 24 | echo "latest release: $RELEASE" 25 | - name: get current pypackage version 26 | id: get_current_version 27 | run: | 28 | RELEASE=$(yq -r "._commit" .copier-answers.yml) 29 | echo "current=$RELEASE" >> $GITHUB_OUTPUT 30 | echo "current release: $RELEASE" 31 | - name: open issue 32 | if: steps.get_current_version.outputs.current != steps.get_latest_release.outputs.latest 33 | uses: rishabhgupta/git-action-issue@v2 34 | with: 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | title: "Update template to ${{ steps.get_latest_release.outputs.latest }}" 37 | body: | 38 | The package is based on the ${{ steps.get_current_version.outputs.current }} version of [@12rambau/pypackage](https://github.com/12rambau/pypackage). 39 | 40 | The latest version of the template is ${{ steps.get_latest_release.outputs.latest }}. 41 | 42 | Please consider updating the template to the latest version to include all the latest developments. 43 | 44 | Run the following code in your project directory to update the template: 45 | 46 | ``` 47 | copier update --trust --defaults --vcs-ref ${{ steps.get_latest_release.outputs.latest }} 48 | ``` 49 | 50 | > **Note** 51 | > You may need to reinstall ``copier`` and ``jinja2-time`` if they are not available in your environment. 52 | 53 | After solving the merging issues you can push back the changes to your main branch. 54 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """All the process that can be run using nox. 2 | 3 | The nox run are build in isolated environment that will be stored in .nox. to force the venv update, remove the .nox/xxx folder. 4 | """ 5 | 6 | import nox 7 | 8 | nox.options.sessions = ["lint", "test", "docs", "mypy"] 9 | 10 | 11 | @nox.session(reuse_venv=True, venv_backend="uv") 12 | def lint(session): 13 | """Apply the pre-commits.""" 14 | session.install("pre-commit") 15 | session.run("pre-commit", "run", "--all-files", *session.posargs) 16 | 17 | 18 | @nox.session(reuse_venv=True, venv_backend="uv") 19 | def test(session): 20 | """Run the selected tests and report coverage in html.""" 21 | session.install(".[test]") 22 | test_files = session.posargs or ["tests", "demo_template"] 23 | session.run("coverage", "run", "-m", "pytest", "--color=yes", *test_files) 24 | session.run("coverage", "html") 25 | 26 | 27 | @nox.session(reuse_venv=True, name="ci-test", venv_backend="uv") 28 | def ci_test(session): 29 | """Run all the test and report coverage in xml.""" 30 | session.install(".[test]") 31 | session.run("pytest", "--cov", "--cov-report=xml") 32 | 33 | 34 | @nox.session(reuse_venv=True, name="dead-fixtures", venv_backend="uv") 35 | def dead_fixtures(session): 36 | """Check for dead fixtures within the tests.""" 37 | session.install(".[test]") 38 | session.run("pytest", "--dead-fixtures") 39 | 40 | 41 | @nox.session(reuse_venv=True, venv_backend="uv") 42 | def docs(session): 43 | """Build the documentation.""" 44 | build = session.posargs.pop() if session.posargs else "html" 45 | session.install(".[doc]") 46 | dst, warn = f"docs/_build/{build}", "warnings.txt" 47 | session.run("sphinx-build", "-v", "-b", build, "docs", dst, "-w", warn) 48 | session.run("python", "tests/check_warnings.py") 49 | 50 | 51 | @nox.session(name="mypy", reuse_venv=True) 52 | def mypy(session): 53 | """Run a mypy check of the lib.""" 54 | session.install("mypy") 55 | test_files = session.posargs or ["pytest_copie"] 56 | session.run("mypy", *test_files) 57 | 58 | 59 | @nox.session(reuse_venv=True, venv_backend="uv") 60 | def stubgen(session): 61 | """Generate stub files for the lib but requires human attention before merge.""" 62 | session.install("mypy") 63 | package = session.posargs or ["pytest_copie"] 64 | session.run("stubgen", "-p", package[0], "-o", "stubs", "--include-private") 65 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | .ruff_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | docs/api/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | # system IDE 134 | .vscode/ 135 | 136 | # image tmp file 137 | *Zone.Identifier 138 | 139 | # debugging notebooks 140 | test.ipynb 141 | 142 | warnings.txt 143 | -------------------------------------------------------------------------------- /.github/workflows/unit.yaml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | workflow_call: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | env: 11 | FORCE_COLOR: 1 12 | PIP_ROOT_USER_ACTION: ignore 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v5 19 | - uses: actions/setup-python@v6 20 | with: 21 | python-version: "3.12" 22 | - uses: pre-commit/action@v3.0.1 23 | 24 | mypy: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v5 28 | - uses: actions/setup-python@v6 29 | with: 30 | python-version: "3.12" 31 | - name: Install nox 32 | run: pip install nox[uv] 33 | - name: run mypy checks 34 | run: nox -s mypy 35 | 36 | docs: 37 | needs: [lint, mypy] 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v5 41 | - uses: actions/setup-python@v6 42 | with: 43 | python-version: "3.12" 44 | - name: Install nox 45 | run: pip install nox[uv] 46 | - name: build static docs 47 | run: nox -s docs 48 | 49 | build: 50 | needs: [lint, mypy] 51 | strategy: 52 | fail-fast: false 53 | matrix: 54 | os: [ubuntu-latest] 55 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 56 | include: 57 | - os: macos-latest # macos test 58 | python-version: "3.13" 59 | - os: windows-latest # windows test 60 | python-version: "3.13" 61 | runs-on: ${{ matrix.os }} 62 | steps: 63 | - uses: actions/checkout@v5 64 | - name: Set up Python ${{ matrix.python-version }} 65 | uses: actions/setup-python@v6 66 | with: 67 | python-version: ${{ matrix.python-version }} 68 | - name: Install nox 69 | run: pip install nox[uv] 70 | - name: test with pytest 71 | run: nox -s ci-test 72 | - name: assess dead fixtures 73 | if: ${{ matrix.python-version == '3.12' }} 74 | shell: bash 75 | run: nox -s dead-fixtures 76 | - uses: actions/upload-artifact@v4 77 | if: ${{ matrix.python-version == '3.12' }} 78 | with: 79 | name: coverage 80 | path: coverage.xml 81 | 82 | coverage: 83 | needs: [build] 84 | runs-on: ubuntu-latest 85 | steps: 86 | - uses: actions/checkout@v5 87 | - uses: actions/download-artifact@v5 88 | with: 89 | name: coverage 90 | - name: codecov 91 | uses: codecov/codecov-action@v5 92 | with: 93 | token: ${{ secrets.CODECOV_TOKEN }} 94 | verbose: true 95 | fail_ci_if_error: true 96 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "pytest-copie" 7 | version = "0.3.1" 8 | description = "The pytest plugin for your copier templates 📒" 9 | keywords = [ 10 | "Python", 11 | "pytest", 12 | "pytest-plugin", 13 | "copier" 14 | ] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | ] 24 | requires-python = ">=3.9" 25 | dependencies = [ 26 | "deprecated>=1.2.14", 27 | "copier", 28 | "pytest", 29 | "plumbum", 30 | ] 31 | 32 | [[project.authors]] 33 | name = "Pierrick Rambaud" 34 | email = "pierrick.rambaud49@gmail.com" 35 | 36 | [project.license] 37 | text = "MIT" 38 | 39 | [project.readme] 40 | file = "README.rst" 41 | content-type = "text/x-rst" 42 | 43 | [project.urls] 44 | Homepage = "https://github.com/12rambau/pytest-copie" 45 | 46 | [project.entry-points."pytest11"] 47 | copie = "pytest_copie.plugin" 48 | 49 | [project.optional-dependencies] 50 | test = [ 51 | "pytest", 52 | "pytest-cov", 53 | "pytest-deadfixtures" 54 | ] 55 | doc = [ 56 | "sphinx>=6.2.1", 57 | "sphinx-immaterial", 58 | "sphinx-copybutton", 59 | "sphinx-autoapi", 60 | "sphinxemoji", 61 | ] 62 | 63 | [tool.hatch.build.targets.wheel] 64 | only-include = ["pytest_copie"] 65 | 66 | [tool.hatch.envs.default] 67 | dependencies = [ 68 | "pre-commit", 69 | "commitizen", 70 | "nox[uv]" 71 | ] 72 | post-install-commands = ["pre-commit install"] 73 | 74 | [tool.commitizen] 75 | tag_format = "v$major.$minor.$patch$prerelease" 76 | update_changelog_on_bump = false 77 | version = "0.3.1" 78 | version_files = [ 79 | "pyproject.toml:version", 80 | "pytest_copie/__init__.py:__version__", 81 | "docs/conf.py:release", 82 | ] 83 | 84 | [tool.pytest.ini_options] 85 | testpaths = ["tests", "demo_template"] 86 | 87 | [tool.ruff] 88 | line-length = 100 89 | fix = true 90 | 91 | [tool.ruff.lint] 92 | select = ["E", "F", "W", "I", "D", "RUF"] 93 | ignore = [ 94 | "E501", # line too long | Black take care of it 95 | "D212", # Multi-line docstring | We use D213 96 | "D101", # Missing docstring in public class | We use D106 97 | ] 98 | 99 | [tool.ruff.lint.flake8-quotes] 100 | docstring-quotes = "double" 101 | 102 | [tool.ruff.lint.pydocstyle] 103 | convention = "google" 104 | 105 | [tool.coverage.run] 106 | source = ["pytest_copie"] 107 | 108 | [tool.mypy] 109 | scripts_are_modules = true 110 | ignore_missing_imports = true 111 | install_types = true 112 | non_interactive = true 113 | warn_redundant_casts = true 114 | 115 | [tool.codespell] 116 | ignore-words-list = "copie" 117 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration file for the Sphinx documentation builder. 2 | 3 | This file only contains a selection of the most common options. For a full 4 | list see the documentation: 5 | https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | """ 7 | 8 | # -- Path setup ---------------------------------------------------------------- 9 | from datetime import datetime 10 | 11 | # -- Project information ------------------------------------------------------- 12 | project = "pytest-copie" 13 | author = "Pierrick Rambaud" 14 | copyright = f"2023-{datetime.now().year}, {author}" 15 | release = "0.3.1" 16 | 17 | # -- General configuration ----------------------------------------------------- 18 | extensions = [ 19 | "sphinx_copybutton", 20 | "sphinx.ext.napoleon", 21 | "sphinx.ext.viewcode", 22 | "sphinx_immaterial", 23 | "autoapi.extension", 24 | "sphinxemoji.sphinxemoji", 25 | ] 26 | exclude_patterns = ["**.ipynb_checkpoints"] 27 | templates_path = ["_template"] 28 | 29 | # -- Options for HTML output --------------------------------------------------- 30 | html_theme = "sphinx_immaterial" 31 | html_title = "pytest-copie" 32 | html_static_path = ["_static"] 33 | html_theme_options = { 34 | "toc_title_is_page_title": True, 35 | "icon": { 36 | "repo": "fontawesome/brands/github", 37 | "edit": "material/file-edit-outline", 38 | }, 39 | "social": [ 40 | { 41 | "icon": "fontawesome/brands/github", 42 | "link": "https://github.com/12rambau/pytest-copie", 43 | "name": "Source on github.com", 44 | }, 45 | { 46 | "icon": "fontawesome/brands/python", 47 | "link": "https://pypi.org/project/pytest-copie/", 48 | }, 49 | ], 50 | "site_url": "https://pytest-copie.readthedocs.io/", 51 | "repo_url": "https://github.com/12rambau/pytest-copie/", 52 | "edit_uri": "blob/main/docs", 53 | "palette": [ 54 | { 55 | "media": "(prefers-color-scheme: light)", 56 | "scheme": "default", 57 | "primary": "deep-orange", 58 | "accent": "amber", 59 | "toggle": { 60 | "icon": "material/weather-sunny", 61 | "name": "Switch to dark mode", 62 | }, 63 | }, 64 | { 65 | "media": "(prefers-color-scheme: dark)", 66 | "scheme": "slate", 67 | "primary": "amber", 68 | "accent": "orange", 69 | "toggle": { 70 | "icon": "material/weather-night", 71 | "name": "Switch to light mode", 72 | }, 73 | }, 74 | ], 75 | "globaltoc_collapse": False, 76 | } 77 | html_css_files = ["custom.css"] 78 | 79 | # -- Options for autosummary/autodoc output ------------------------------------ 80 | autodoc_typehints = "description" 81 | autoapi_dirs = ["../pytest_copie"] 82 | autoapi_python_class_content = "init" 83 | autoapi_member_order = "groupwise" 84 | autoapi_options = [ 85 | "members", 86 | "undoc-members", 87 | "show-inheritance", 88 | "show-module-summary", 89 | ] 90 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest session configuration.""" 2 | 3 | from pathlib import Path 4 | from typing import Callable 5 | 6 | import pytest 7 | import yaml 8 | 9 | # plugin integrated to pytest to test pytest itself (in our case the copie plugin) 10 | pytest_plugins = "pytester" 11 | 12 | 13 | @pytest.fixture 14 | def copier_template(tmpdir) -> Path: 15 | """Create a default template for the copier project generation.""" 16 | # set up the configuration parameters 17 | template_config = { 18 | "repo_name": {"type": "str", "default": "foobar"}, 19 | "test_templated": {"type": "str", "default": "{{ repo_name }}"}, 20 | "test_value": "value", 21 | "short_description": { 22 | "type": "str", 23 | "default": "Test Project", 24 | }, 25 | "_subdirectory": "project", 26 | } 27 | 28 | # content of a fake readme file 29 | template_readme = [ 30 | r"{{ repo_name }}", 31 | "{% for _ in repo_name %}={% endfor %}", 32 | r"{{ short_description }}", 33 | r"This is a templated variable: {{ test_templated }}", 34 | r"This is a non-default variable: {{ test_value }}", 35 | ] 36 | 37 | # create all the folders and files 38 | (template_dir := Path(tmpdir) / "copie-template").mkdir() 39 | (template_dir / "copier.yaml").write_text(yaml.dump(template_config), "utf-8") 40 | (repo_dir := template_dir / r"project").mkdir() 41 | (repo_dir / "{{ _copier_conf.answers_file }}.jinja").write_text( 42 | "{{ _copier_answers|to_nice_yaml -}}" 43 | ) 44 | (repo_dir / "{{repo_name}}.txt.jinja").write_text("templated filename", "utf-8") 45 | (repo_dir / "README.rst.jinja").write_text("\n".join(template_readme), "utf-8") 46 | 47 | return template_dir 48 | 49 | 50 | @pytest.fixture 51 | def incomplete_copier_template(tmpdir) -> Path: 52 | """Create a template for the copier project generation without any subdirectories.""" 53 | # set up the configuration parameters 54 | template_config = {"test": "test"} 55 | 56 | # create all the folders and files 57 | (template_dir := Path(tmpdir) / "copie-template").mkdir() 58 | (template_dir / "copier.yaml").write_text(yaml.dump(template_config), "utf-8") 59 | (repo_dir := template_dir / r"project").mkdir() 60 | (repo_dir / "{{repo_name}}.txt.jinja").write_text("templated filename", "utf-8") 61 | 62 | return template_dir 63 | 64 | 65 | @pytest.fixture(scope="session") 66 | def template_default_content() -> str: 67 | """The expected computed REAMDME.rst file.""" 68 | return r"\n".join( 69 | [ 70 | "foobar", 71 | "======", 72 | "Test Project", 73 | "This is a templated variable: foobar", 74 | "This is a non-default variable: value", 75 | ] 76 | ) 77 | 78 | 79 | @pytest.fixture(scope="session") 80 | def test_check() -> Callable: 81 | """Return a method to test valid copiage.""" 82 | 83 | def _test_check(result, test_name): 84 | result.stdout.re_match_lines([f".*::{test_name} (?:✓|PASSED).*"]) 85 | 86 | return _test_check 87 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | pytest-copie 3 | ============ 4 | 5 | .. |license| image:: https://img.shields.io/badge/License-MIT-yellow.svg?logo=opensourceinitiative&logoColor=white 6 | :target: LICENSE 7 | :alt: License: MIT 8 | 9 | .. |commit| image:: https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?logo=git&logoColor=white 10 | :target: https://conventionalcommits.org 11 | :alt: conventional commit 12 | 13 | .. |ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 14 | :target: https://github.com/astral-sh/ruff 15 | :alt: ruff badge 16 | 17 | .. |prettier| image:: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?logo=prettier&logoColor=white 18 | :target: https://github.com/prettier/prettier 19 | :alt: prettier badge 20 | 21 | .. |pre-commmit| image:: https://img.shields.io/badge/pre--commit-active-yellow?logo=pre-commit&logoColor=white 22 | :target: https://pre-commit.com/ 23 | :alt: pre-commit 24 | 25 | .. |pypi| image:: https://img.shields.io/pypi/v/pytest-copie?color=blue&logo=pypi&logoColor=white 26 | :target: https://pypi.org/project/pytest-copie/ 27 | :alt: PyPI version 28 | 29 | .. |conda| image:: https://img.shields.io/conda/v/conda-forge/pytest-copie?logo=condaforge&logoColor=white&color=blue 30 | :target: https://anaconda.org/conda-forge/pytest-copie 31 | :alt: Conda 32 | 33 | .. |build| image:: https://img.shields.io/github/actions/workflow/status/12rambau/pytest-copie/unit.yaml?logo=github&logoColor=white 34 | :target: https://github.com/12rambau/pytest-copie/actions/workflows/unit.yaml 35 | :alt: build 36 | 37 | .. |coverage| image:: https://img.shields.io/codecov/c/github/12rambau/pytest-copie?logo=codecov&logoColor=white 38 | :target: https://codecov.io/gh/12rambau/pytest-copie 39 | :alt: Test Coverage 40 | 41 | .. |docs| image:: https://img.shields.io/readthedocs/pytest-copie?logo=readthedocs&logoColor=white 42 | :target: https://pytest-copie.readthedocs.io/en/latest/ 43 | :alt: Documentation Status 44 | 45 | |license| |commit| |ruff| |prettier| |pre-commmit| |pypi| |build| |coverage| |docs| |conda| 46 | 47 | Overview 48 | -------- 49 | 50 | pytest-copie is a `pytest `__ plugin that comes with a ``copie`` fixture which is a wrapper on top the `copier `__ API for generating projects. It helps you verify that your template is working as expected and takes care of cleaning up after running the tests. :ledger: 51 | 52 | It is an adaptation of the `pytest-cookies `__ plugin for `copier `__ templates. 53 | 54 | It’s here to help templates designers to check that everything works as expected on the generated files including (but not limited to): 55 | 56 | - linting operations 57 | - testing operations 58 | - packaging operations 59 | - documentation operations 60 | - … 61 | 62 | Installation 63 | ------------ 64 | 65 | **pytest-copie** is available on `PyPI `__ and can be installed with `pip `__: 66 | 67 | .. code-block:: console 68 | 69 | pip install pytest-copie 70 | 71 | Usage 72 | ----- 73 | 74 | The ``copie`` fixture will allow you to ``copy`` a template and run tests against it. It will also clean up the generated project after the tests have been run. 75 | 76 | .. code-block:: python 77 | 78 | def test_template(copie): 79 | res = copie.copy(extra_answers={"repo_name": "helloworld"}) 80 | 81 | assert res.exit_code == 0 82 | assert res.exception is None 83 | assert result.project_dir.is_dir() 84 | 85 | Context and template location can be fully customized, see our `documentation `__ for more details. 86 | 87 | Credits 88 | ------- 89 | 90 | This package was created with `Copier `__ and the `@12rambau/pypackage `__ 0.1.16 project template. 91 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contribute 2 | ========== 3 | 4 | Thank you for your help improving **pytest-copie**! 5 | 6 | **pytest-copie** uses `nox `__ to automate several development-related tasks. 7 | Currently, the project uses four automation processes (called sessions) in ``noxfile.py``: 8 | 9 | - ``mypy``: to perform a mypy check on the lib; 10 | - ``test``: to run the test with pytest; 11 | - ``docs``: to build the documentation in the ``build`` folder; 12 | - ``lint``: to run the pre-commits in an isolated environment 13 | 14 | Every nox session is run in its own virtual environment, and the dependencies are installed automatically. 15 | 16 | To run a specific nox automation process, use the following command: 17 | 18 | .. code-block:: console 19 | 20 | nox -s 21 | 22 | For example: ``nox -s test`` or ``nox -s docs``. 23 | 24 | Workflow for contributing changes 25 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 26 | 27 | We follow a typical GitHub workflow of: 28 | 29 | - Create a personal fork of this repo 30 | - Create a branch 31 | - Open a pull request 32 | - Fix findings of various linters and checks 33 | - Work through code review 34 | 35 | See the following sections for more details. 36 | 37 | Clone the repository 38 | ^^^^^^^^^^^^^^^^^^^^ 39 | 40 | First off, you'll need your own copy of **pytest-copie** codebase. You can clone it for local development like so: 41 | 42 | Fork the repository so you have your own copy on GitHub. See the `GitHub forking guide for more information `__. 43 | 44 | Then, clone the repository locally so that you have a local copy to work on: 45 | 46 | .. code-block:: console 47 | 48 | git clone https://github.com//pytest-copie 49 | cd pytest-copie 50 | 51 | Then install the development version of the extension: 52 | 53 | .. code-block:: console 54 | 55 | pip install -e .[dev] 56 | 57 | This will install the **pytest-copie** library, together with two additional tools: 58 | - `pre-commit `__ for automatically enforcing code standards and quality checks before commits. 59 | - `nox `__, for automating common development tasks. 60 | 61 | Lastly, activate the pre-commit hooks by running: 62 | 63 | .. code-block:: console 64 | 65 | pre-commit install 66 | 67 | This will install the necessary dependencies to run pre-commit every time you make a commit with Git. 68 | 69 | Contribute to the codebase 70 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 71 | 72 | Any larger updates to the codebase should include tests and documentation. The tests are located in the ``tests`` folder, and the documentation is located in the ``docs`` folder. 73 | 74 | To run the tests locally, use the following command: 75 | 76 | .. code-block:: console 77 | 78 | nox -s test 79 | 80 | See :ref:`below ` for more information on how to update the documentation. 81 | 82 | .. _contributing-docs: 83 | 84 | Contribute to the docs 85 | ^^^^^^^^^^^^^^^^^^^^^^ 86 | 87 | The documentation is built using `Sphinx `__ and deployed to `Read the Docs `__. 88 | 89 | To build the documentation locally, use the following command: 90 | 91 | .. code-block:: console 92 | 93 | nox -s docs 94 | 95 | For each pull request, the documentation is built and deployed to make it easier to review the changes in the PR. To access the docs build from a PR, click on the "Read the Docs" preview in the CI/CD jobs. 96 | 97 | Release new version 98 | ^^^^^^^^^^^^^^^^^^^ 99 | 100 | To release a new version, start by pushing a new bump from the local directory: 101 | 102 | .. code-block:: 103 | 104 | cz bump 105 | 106 | The commitizen-tool will detect the semantic version name based on the existing commits messages. 107 | 108 | Then push to Github. In Github design a new release using the same tag name nad the ``release.yaml`` job will send it to pipy. 109 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | Contributor Covenant Code of Conduct 2 | ==================================== 3 | 4 | Our Pledge 5 | ---------- 6 | 7 | We as members, contributors, and leaders pledge to make participation in our 8 | community a harassment-free experience for everyone, regardless of age, body 9 | size, visible or invisible disability, ethnicity, sex characteristics, gender 10 | identity and expression, level of experience, education, socio-economic status, 11 | nationality, personal appearance, race, religion, or sexual identity 12 | and orientation. 13 | 14 | We pledge to act and interact in ways that contribute to an open, welcoming, 15 | diverse, inclusive, and healthy community. 16 | 17 | Our Standards 18 | ------------- 19 | 20 | Examples of behavior that contributes to a positive environment for our 21 | community include: 22 | 23 | * Demonstrating empathy and kindness toward other people 24 | * Being respectful of differing opinions, viewpoints, and experiences 25 | * Giving and gracefully accepting constructive feedback 26 | * Accepting responsibility and apologizing to those affected by our mistakes, 27 | and learning from the experience 28 | * Focusing on what is best not just for us as individuals, but for the 29 | overall community 30 | 31 | Examples of unacceptable behavior include: 32 | 33 | * The use of sexualized language or imagery, and sexual attention or 34 | advances of any kind 35 | * Trolling, insulting or derogatory comments, and personal or political attacks 36 | * Public or private harassment 37 | * Publishing others' private information, such as a physical or email 38 | address, without their explicit permission 39 | * Other conduct which could reasonably be considered inappropriate in a 40 | professional setting 41 | 42 | Enforcement Responsibilities 43 | ---------------------------- 44 | 45 | Community leaders are responsible for clarifying and enforcing our standards of 46 | acceptable behavior and will take appropriate and fair corrective action in 47 | response to any behavior that they deem inappropriate, threatening, offensive, 48 | or harmful. 49 | 50 | Community leaders have the right and responsibility to remove, edit, or reject 51 | comments, commits, code, wiki edits, issues, and other contributions that are 52 | not aligned to this Code of Conduct, and will communicate reasons for moderation 53 | decisions when appropriate. 54 | 55 | Scope 56 | ----- 57 | 58 | This Code of Conduct applies within all community spaces, and also applies when 59 | an individual is officially representing the community in public spaces. 60 | Examples of representing our community include using an official e-mail address, 61 | posting via an official social media account, or acting as an appointed 62 | representative at an online or offline event. 63 | 64 | Enforcement 65 | ----------- 66 | 67 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 68 | reported to the FAO team responsible for enforcement at 69 | pierrick.rambaud49@gmail.com. 70 | All complaints will be reviewed and investigated promptly and fairly. 71 | 72 | All community leaders are obligated to respect the privacy and security of the 73 | reporter of any incident. 74 | 75 | Enforcement Guidelines 76 | ---------------------- 77 | 78 | Community leaders will follow these Community Impact Guidelines in determining 79 | the consequences for any action they deem in violation of this Code of Conduct: 80 | 81 | Correction 82 | ^^^^^^^^^^ 83 | 84 | **Community Impact**: Use of inappropriate language or other behavior deemed 85 | unprofessional or unwelcome in the community. 86 | 87 | **Consequence**: A private, written warning from community leaders, providing 88 | clarity around the nature of the violation and an explanation of why the 89 | behavior was inappropriate. A public apology may be requested. 90 | 91 | Warning 92 | ^^^^^^^ 93 | 94 | **Community Impact**: A violation through a single incident or series 95 | of actions. 96 | 97 | **Consequence**: A warning with consequences for continued behavior. No 98 | interaction with the people involved, including unsolicited interaction with 99 | those enforcing the Code of Conduct, for a specified period of time. This 100 | includes avoiding interactions in community spaces as well as external channels 101 | like social media. Violating these terms may lead to a temporary or 102 | permanent ban. 103 | 104 | Temporary Ban 105 | ^^^^^^^^^^^^^ 106 | 107 | **Community Impact**: A serious violation of community standards, including 108 | sustained inappropriate behavior. 109 | 110 | **Consequence**: A temporary ban from any sort of interaction or public 111 | communication with the community for a specified period of time. No public or 112 | private interaction with the people involved, including unsolicited interaction 113 | with those enforcing the Code of Conduct, is allowed during this period. 114 | Violating these terms may lead to a permanent ban. 115 | 116 | Permanent Ban 117 | ^^^^^^^^^^^^^ 118 | 119 | **Community Impact**: Demonstrating a pattern of violation of community 120 | standards, including sustained inappropriate behavior, harassment of an 121 | individual, or aggression toward or disparagement of classes of individuals. 122 | 123 | **Consequence**: A permanent ban from any sort of public interaction within 124 | the community. 125 | 126 | Attribution 127 | ----------- 128 | 129 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 130 | version 2.0, available at 131 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 132 | 133 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 134 | enforcement ladder](https://github.com/mozilla/diversity). 135 | 136 | [homepage]: https://www.contributor-covenant.org 137 | 138 | For answers to common questions about this code of conduct, see the FAQ at 139 | https://www.contributor-covenant.org/faq. Translations are available at 140 | https://www.contributor-covenant.org/translations. 141 | -------------------------------------------------------------------------------- /tests/test_copie_parent_child.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit-tests for the “parent / child Copier template” feature. 3 | 4 | The tests create two miniature Copier templates: 5 | 6 | * parent_template/ 7 | copier.yml 8 | parent_project/ 9 | external_data.txt.jinja -> "parent-data" 10 | 11 | * child_template/ 12 | copier.yml 13 | child_project/ 14 | child.txt.jinja -> "child-generated" 15 | 16 | The new `copie` fixture must │ 17 | - render the parent first, │ 18 | - copy the resulting files into the child's output-dir, and │ 19 | - render the child successfully inside the parent └── tested here 20 | """ 21 | 22 | from __future__ import annotations 23 | 24 | import textwrap 25 | from pathlib import Path 26 | 27 | from _pytest.pytester import Pytester 28 | 29 | 30 | def _create_parent_template(base: Path) -> Path: 31 | """Return a ready-to-use *parent* Copier template.""" 32 | tpl = base / "parent_template" 33 | proj = tpl / "template" 34 | proj.mkdir(parents=True) 35 | 36 | # minimal copier.yml - must use a subdirectory 37 | (tpl / "copier.yml").write_text( 38 | textwrap.dedent( 39 | """\ 40 | _subdirectory: template 41 | _answers_file: .parent-answers.yml 42 | project_name: parent project 43 | """ 44 | ) 45 | ) 46 | 47 | # File created by parent 48 | (proj / "parent_file.txt.jinja").write_text( 49 | textwrap.dedent( 50 | """\ 51 | parent-data 52 | {{ project_name }} 53 | """ 54 | ) 55 | ) 56 | 57 | # Parent answers file 58 | (proj / "{{ _copier_conf.answers_file }}.jinja").write_text( 59 | textwrap.dedent( 60 | """\ 61 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 62 | {{ _copier_answers|to_nice_yaml -}} 63 | """ 64 | ) 65 | ) 66 | 67 | return tpl 68 | 69 | 70 | def _create_child_template(base: Path) -> Path: 71 | """Return a ready-to-use *child* Copier template.""" 72 | tpl = base / "child_template" 73 | proj = tpl / "template" 74 | proj.mkdir(parents=True) 75 | 76 | # minimal copier.yml - must use a subdirectory 77 | (tpl / "copier.yml").write_text( 78 | textwrap.dedent( 79 | """\ 80 | _subdirectory: template 81 | _answers_file: .child-answers.yml 82 | child_name: foo bar 83 | _external_data: 84 | parent_tpl: .parent-answers.yml 85 | project_name: "{{ _external_data.parent_tpl.project_name }}" 86 | """ 87 | ) 88 | ) 89 | 90 | # File created by child 91 | (proj / "child.txt.jinja").write_text( 92 | textwrap.dedent( 93 | """\ 94 | child-generated 95 | {{ project_name }} 96 | {{ child_name }} 97 | """ 98 | ) 99 | ) 100 | 101 | # Child answers file 102 | (proj / "{{ _copier_conf.answers_file }}.jinja").write_text( 103 | textwrap.dedent( 104 | """\ 105 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 106 | {{ _copier_answers|to_nice_yaml -}} 107 | """ 108 | ) 109 | ) 110 | 111 | return tpl 112 | 113 | 114 | # --------------------------------------------------------------------------- # 115 | # Happy-path test # 116 | # --------------------------------------------------------------------------- # 117 | def test_parent_child_roundtrip(testdir: Pytester) -> None: 118 | """The child template is rendered with the files coming from its parent.""" 119 | # ------------------------------------------------------------------ setup 120 | tmp = Path(testdir.tmpdir) 121 | parent_tpl = _create_parent_template(tmp) 122 | child_tpl = _create_child_template(tmp) 123 | 124 | # escape back-slashes for the inline test-code (Windows friendly) 125 | parent_tpl_s = str(parent_tpl).replace("\\", "\\\\") 126 | child_tpl_s = str(child_tpl).replace("\\", "\\\\") 127 | 128 | # ----------------------------------------------------------------- inline 129 | testdir.makepyfile( 130 | f""" 131 | from pathlib import Path 132 | 133 | def test_parent_child(copie): 134 | parent_template = Path(r"{parent_tpl_s}") 135 | child_template = Path(r"{child_tpl_s}") 136 | 137 | # -------- parent ------------------------------------------------ 138 | parent_result = copie.copy(template_dir=parent_template) 139 | assert parent_result.exit_code == 0 140 | assert (parent_result.project_dir / "parent_file.txt").is_file() 141 | 142 | # -------- child ------------------------------------------------- 143 | child_copie = copie(parent_result=parent_result, 144 | child_tpl=child_template) 145 | child_result = child_copie.copy() 146 | assert child_result.exit_code == 0 147 | 148 | # The parent's file must have been copied *in to* the child project 149 | parent_file = child_result.project_dir / "parent_file.txt" 150 | assert parent_file.is_file() 151 | assert parent_file.read_text() == "parent-data\\nparent project\\n" 152 | 153 | # The child's file must have been rendered successfully 154 | child_file = child_result.project_dir / "child.txt" 155 | assert child_file.is_file() 156 | assert child_file.read_text() == "child-generated\\nparent project\\nfoo bar\\n" 157 | """ 158 | ) 159 | 160 | res = testdir.runpytest("-v") 161 | res.assert_outcomes(passed=1) 162 | 163 | 164 | # --------------------------------------------------------------------------- # 165 | # Validation / error-handling test # 166 | # --------------------------------------------------------------------------- # 167 | def test_invalid_parent_result_rejected(testdir: Pytester) -> None: 168 | """A parent ``Result`` with a non-zero exit-code must raise a ``ValueError``.""" 169 | tmp = Path(testdir.tmpdir) 170 | dummy_child_tpl = _create_child_template(tmp) 171 | 172 | child_tpl_s = str(dummy_child_tpl).replace("\\", "\\\\") 173 | 174 | testdir.makepyfile( 175 | f""" 176 | from pathlib import Path 177 | import pytest 178 | from pytest_copie.plugin import Result 179 | 180 | def test_invalid_parent(copie, tmp_path): 181 | bad_parent = Result(exit_code=1, project_dir=tmp_path) # ← invalid! 182 | child = copie(parent_result=bad_parent, 183 | child_tpl=Path(r"{child_tpl_s}")) 184 | with pytest.raises(ValueError, match="successful exit code"): 185 | child.copy() 186 | """ 187 | ) 188 | 189 | res = testdir.runpytest("-v") 190 | res.assert_outcomes(passed=1) 191 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | The :py:func:`copie ` fixture will allow you to :py:meth:`copy ` or :py:meth:`copy ` a template and run tests against it. It will also clean up the generated project after the tests have been run. 5 | 6 | For these examples, let's assume the current folder is a git repository containing a copier template. It should include a ``copier.yml`` file and a ``template`` folder containing jinja templates. 7 | 8 | .. tip:: 9 | 10 | If needed you can also switch to the :py:func:`copie_session` fixture to get the same functionalities but session scoped. 11 | 12 | .. note:: 13 | 14 | The name of the template folder can be anything as long as it matches the ``_subdirectory`` key in the ``copier.yml`` file. 15 | 16 | .. code-block:: 17 | 18 | demo_template/ 19 | ├── .git/ 20 | ├── template 21 | │ ├── {{ _copier_conf.answers_file }}.jinja # required only to test update() 22 | │ └── README.rst.jinja 23 | ├── tests/ 24 | │ └── test_template.py 25 | └── copier.yaml 26 | 27 | the ``copier.yaml`` file has the following content: 28 | 29 | .. code-block:: yaml 30 | 31 | repo_name: 32 | type: str 33 | default: foobar 34 | short_description: 35 | type: str 36 | default: Test Project 37 | _subdirectory: template 38 | 39 | The answers file contain the following: 40 | 41 | .. code-block:: yaml 42 | 43 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 44 | {{ _copier_answers|to_nice_yaml -}} 45 | 46 | And the readme template is: 47 | 48 | .. code-block:: rst 49 | 50 | {{ repo_name }} 51 | =============== 52 | 53 | {{ short_description }} 54 | 55 | default project 56 | --------------- 57 | 58 | Use the following code in your test file to generate the project with all the default values: 59 | 60 | .. code-block:: python 61 | 62 | def test_template(copie): 63 | result = copie.copy() 64 | 65 | assert result.exit_code == 0 66 | assert result.exception is None 67 | assert result.project_dir.is_dir() 68 | with open(result.project_dir / "README.rst") as f: 69 | assert f.readline() == "foobar\n" 70 | 71 | It will generate a new repository based on your template, eg: 72 | 73 | .. code-block:: 74 | 75 | foobar/ 76 | └── .copier-answers.yml 77 | └── README.rst 78 | 79 | the :py:class:`Return ` object can then be used to access the process outputs: 80 | 81 | - :py:attr:`result.project_dir ` 82 | - :py:attr:`result.exception ` 83 | - :py:attr:`result.exit_code ` 84 | - :py:attr:`result.answers ` 85 | 86 | To test the generation for a particular git tag or commit use the ``vcs_ref`` argument, 87 | when calling ``copie.copy()``: 88 | 89 | .. code-block:: python 90 | 91 | def test_template(copie): 92 | result = copie.copy(vcs_ref="v1") # tests template generation from v1 93 | 94 | Naturally, if not specified, ``vcs_ref`` defaults to ``HEAD``. 95 | 96 | To test for an update, you should first generate a copy based on a historical commit or 97 | tag from the template, initialize a git repository with those contents (required by 98 | Copier itself), and then test the current changes on the top of the desired reference: 99 | 100 | .. code-block:: python 101 | 102 | import plumbum 103 | 104 | def test_template(copie): 105 | result = copie.copy(vcs_ref="v1") 106 | assert result.exit_code == 0 107 | with open(result.project_dir / "README.rst") as f: 108 | assert f.readline() == "foobar\n" 109 | 110 | with plumbum.local.cwd(result.project_dir): 111 | git = copie.git() 112 | git("init") 113 | git("add", ".") 114 | git("commit", "-m", "Initial commit") 115 | 116 | updated_result = copie.update(result) # updates to "HEAD" by default 117 | assert updated_result.exception is None 118 | with open(result.project_dir / "README.rst") as f: 119 | assert f.readline() == "foobar\nlatest modifications\n" 120 | 121 | You may use this mechanism to test migrations from/to any tagged versions of your 122 | current template, for as long as you can assign a proper ``vcs_ref`` to it. To test an 123 | update to a specific ``vcs_ref``, use the form ``copie.update(vcs_ref="v2")`` instead of 124 | the default ``"HEAD"`` tag. 125 | 126 | The temp folder will be cleaned up after the test is run. 127 | 128 | Custom answers 129 | -------------- 130 | 131 | Use the ``extra_answers`` parameter to pass custom answers to the ``copier.yaml`` questions. 132 | The parameter is a dictionary with the question name as key and the answer as value. 133 | 134 | .. code-block:: python 135 | 136 | def test_template_with_extra_answers(copie): 137 | result = copie.copy(extra_answers={"repo_name": "helloworld"}) 138 | 139 | assert result.exit_code == 0 140 | assert result.exception is None 141 | assert result.project_dir.is_dir() 142 | with open(result.project_dir / "README.rst") as f: 143 | assert f.readline() == "helloworld\n" 144 | 145 | Custom template 146 | --------------- 147 | 148 | By default :py:meth:`copy() ` looks for a copier template in the current directory. 149 | This can be overridden on the command line by passing a ``--template`` parameter to pytest: 150 | 151 | .. code-block:: console 152 | 153 | pytest --template TEMPLATE 154 | 155 | You can also customize the template directory from a test by passing in the optional ``template`` parameter: 156 | 157 | .. code-block:: python 158 | 159 | @pytest.fixture 160 | def custom_template(tmp_path) -> Path: 161 | # Create custom copier template directory 162 | (template := tmp_path / "copier-template").mkdir() 163 | questions = {"custom_name": {"type": "str", "default": "my_default_name"}} 164 | # Create custom subdirectory 165 | (repo_dir := template / "custom_template").mkdir() 166 | questions.update({"_subdirectory": "custom_template"}) 167 | # Write the data to copier.yaml file 168 | (template /"copier.yaml").write_text(yaml.dump(questions, sort_keys=False)) 169 | # Create custom template text files 170 | (repo_dir / "README.rst.jinja").write_text("{{custom_name}}\n") 171 | 172 | return template 173 | 174 | 175 | def test_copie_custom_project(copie, custom_template): 176 | 177 | result = copie.copy( 178 | template_dir=custom_template, extra_answers={"custom_name": "tutu"} 179 | ) 180 | 181 | assert result.project_dir.is_dir() 182 | with open(result.project_dir / "README.rst") as f: 183 | assert f.readline() == "tutu\n" 184 | 185 | .. important:: 186 | 187 | The ``template`` parameter will override any ``--template`` parameter passed on the command line. 188 | 189 | Subprojects 190 | ----------- 191 | 192 | Copier allows you to create :subprojects:`https://copier.readthedocs.io/en/stable/configuring/#applying-multiple-templates-to-the-same-subproject`, which are projects that are copied into the main project directory, and may consume answers from previously copied projects. 193 | 194 | Consider that you have two templates to run on the same project that will be run in the following order: 195 | 196 | - You use one framework that has a public template to generate a project. It's available at ``path/to/parent_template``. 197 | - You are developing a second template (with a `copier.yml` template in the CWD, ``path/to/child_template```) that will generate a subproject in the main project directory, and will consume answers from the first template. 198 | 199 | .. code-block:: python 200 | 201 | def test_parent_child(copie): 202 | parent_template = Path("path/to/parent_template") 203 | child_template = Path("path/to/child_template") 204 | 205 | parent_result = copie.copy(template_dir=parent_template) 206 | 207 | child_copie = copie( 208 | parent_result=parent_result, 209 | child_tpl=child_template 210 | ) 211 | child_result = child_copie.copy() 212 | 213 | 214 | Keep output 215 | ----------- 216 | 217 | By default :py:meth:`copie ` fixture removes copied projects at the end of the test. 218 | However, you can pass the ``keep-copied-projects`` flag if you'd like to keep them in the temp directory. 219 | 220 | .. note:: 221 | 222 | It won't clutter as pytest only keeps the three newest temporary directories 223 | 224 | .. code-block:: console 225 | 226 | pytest --keep-copied-projects 227 | -------------------------------------------------------------------------------- /tests/test_copie.py: -------------------------------------------------------------------------------- 1 | """Test the pytest_copie package.""" 2 | 3 | import textwrap 4 | from pathlib import Path 5 | 6 | import plumbum 7 | 8 | from pytest_copie.plugin import _git as git 9 | 10 | 11 | def test_copie_fixture(testdir, test_check): 12 | """Make sure that pytest accepts the "copie" fixture.""" 13 | # create a tmp pytest module 14 | testdir.makepyfile( 15 | """ 16 | def test_valid_fixture(copie): 17 | assert hasattr(copie, "copy") 18 | assert callable(copie.copy) 19 | """ 20 | ) 21 | 22 | # run pytest with the following cmd args 23 | result = testdir.runpytest("-v") 24 | test_check(result, "test_valid_fixture") 25 | assert result.ret == 0 26 | 27 | 28 | def test_copie_copy(testdir, copier_template, test_check, template_default_content): 29 | """Programmatically create a **Copier** template and use `copy` to create a project from it.""" 30 | testdir.makepyfile( 31 | """ 32 | def test_copie_project(copie): 33 | result = copie.copy() 34 | 35 | assert result.exit_code == 0 36 | assert result.exception is None 37 | 38 | assert result.project_dir.stem.startswith("copie") 39 | assert result.project_dir.is_dir() 40 | assert str(result) == f"" 41 | readme_file = result.project_dir / "README.rst" 42 | assert readme_file.is_file() 43 | assert readme_file.read_text() == "%s" 44 | """ 45 | % template_default_content 46 | ) 47 | 48 | result = testdir.runpytest("-v", f"--template={copier_template}") 49 | test_check(result, "test_copie_project") 50 | assert result.ret == 0 51 | 52 | 53 | def test_copie_copy_tag(testdir, copier_template, test_check, template_default_content): 54 | """Programmatically create a **Copier** template and use `copy` with a specific repo tag to create a project from it.""" 55 | git_tag = "v1" 56 | 57 | testdir.makepyfile( 58 | """ 59 | def test_copie_project(copie): 60 | result = copie.copy(vcs_ref="%s") 61 | 62 | assert result.exit_code == 0 63 | assert result.exception is None 64 | 65 | assert result.project_dir.stem.startswith("copie") 66 | assert result.project_dir.is_dir() 67 | assert str(result) == f"" 68 | readme_file = result.project_dir / "README.rst" 69 | assert readme_file.is_file() 70 | assert readme_file.read_text() == "%s" 71 | """ 72 | % (git_tag, template_default_content) 73 | ) 74 | 75 | with plumbum.local.cwd(copier_template): 76 | git("init") 77 | git("add", ".") 78 | git("commit", "-m", "Initial commit") 79 | git("tag", git_tag) 80 | 81 | # slightly modify the README file on the repository so to make the repo dirty 82 | with (copier_template / "project" / "README.rst.jinja").open("a") as f: 83 | f.write("\nNew content") 84 | 85 | result = testdir.runpytest("-v", f"--template={copier_template}") 86 | test_check(result, "test_copie_project") 87 | assert result.ret == 0 88 | 89 | 90 | def test_copie_update(testdir, copier_template, test_check, template_default_content): 91 | """Programmatically create a **Copier** template, and a project from a given tag, then and use `update` to update the project.""" 92 | git_tag = "v1" 93 | appended_content = r"\nNew content" 94 | 95 | testdir.makepyfile( 96 | """ 97 | import plumbum 98 | 99 | def test_copie_project(copie): 100 | result = copie.copy(vcs_ref="%s") 101 | 102 | assert result.exit_code == 0 103 | assert result.exception is None 104 | 105 | assert result.project_dir.stem.startswith("copie") 106 | assert result.project_dir.is_dir() 107 | assert str(result) == f"" 108 | readme_file = result.project_dir / "README.rst" 109 | assert readme_file.is_file() 110 | assert readme_file.read_text() == "%s" 111 | copier_answers_file = result.project_dir / ".copier-answers.yml" 112 | assert copier_answers_file.is_file() 113 | 114 | with plumbum.local.cwd(result.project_dir): 115 | git = copie.git() 116 | git("init") 117 | git("add", ".") 118 | git("commit", "-m", "Initial commit") 119 | 120 | updated_result = copie.update(result) 121 | 122 | assert updated_result.exit_code == 0 123 | assert updated_result.exception is None 124 | 125 | assert str(updated_result) == f"" 126 | updated_readme_file = updated_result.project_dir / "README.rst" 127 | assert updated_readme_file.is_file() 128 | print(updated_readme_file.read_text()) 129 | assert updated_readme_file.read_text() == "%s" 130 | """ 131 | % (git_tag, template_default_content, template_default_content + appended_content) 132 | ) 133 | 134 | with plumbum.local.cwd(copier_template): 135 | git("init") 136 | git("add", ".") 137 | git("commit", "-m", "Initial commit") 138 | git("tag", git_tag) 139 | 140 | # slightly modify the README file on the repository so to make the repo dirty 141 | with (copier_template / "project" / "README.rst.jinja").open("a") as f: 142 | f.write(bytes(appended_content, "utf-8").decode("unicode_escape")) 143 | 144 | result = testdir.runpytest("-v", f"--template={copier_template}") 145 | test_check(result, "test_copie_project") 146 | assert result.ret == 0 147 | 148 | 149 | def test_copie_copy_without_subdirectory(testdir, incomplete_copier_template, test_check): 150 | """Programmatically create a **Copier** template and use `copy` to create a project from it.""" 151 | testdir.makepyfile( 152 | """ 153 | def test_copie_project(copie): 154 | 155 | result = copie.copy() 156 | 157 | assert result.exit_code == -1 158 | assert isinstance(result.exception, ValueError) 159 | """ 160 | ) 161 | 162 | result = testdir.runpytest("-v", f"--template={incomplete_copier_template}") 163 | test_check(result, "test_copie_project") 164 | assert result.ret == 0 165 | 166 | 167 | def test_copie_copy_with_extra(testdir, copier_template, test_check): 168 | """Programmatically create a **Copier** template and use `copy` to create a project from it.""" 169 | testdir.makepyfile( 170 | """ 171 | def test_copie_project(copie): 172 | result = copie.copy(extra_answers={"repo_name": "helloworld"}) 173 | templated_file = result.project_dir / "helloworld.txt" 174 | assert templated_file.is_file() 175 | 176 | """ 177 | ) 178 | 179 | result = testdir.runpytest("-v", f"--template={copier_template}") 180 | test_check(result, "test_copie_project") 181 | assert result.ret == 0 182 | 183 | 184 | def test_copie_with_template_kwarg(testdir, copier_template, test_check): 185 | """Check that copie accepts a template kwarg.""" 186 | testdir.makepyfile( 187 | """ 188 | from pathlib import Path 189 | def test_copie_project(copie): 190 | result = copie.copy( 191 | extra_answers={"repo_name": "helloworld"}, template_dir=Path(r"%s"), 192 | ) 193 | assert result.exit_code == 0 194 | assert result.exception is None 195 | assert result.project_dir.stem.startswith("copie") 196 | assert result.project_dir.is_dir() 197 | 198 | assert str(result) == f"" 199 | """ 200 | % copier_template 201 | ) 202 | 203 | # run pytest without the template cli arg 204 | result = testdir.runpytest("-v") 205 | test_check(result, "test_copie_project") 206 | assert result.ret == 0 207 | 208 | 209 | def test_copie_fixture_removes_directories(testdir, copier_template, test_check): 210 | """Check the copie fixture removes the test directories from one test to another.""" 211 | testdir.makepyfile( 212 | """ 213 | def test_create_dir(copie): 214 | result = copie.copy() 215 | globals().update(test_dir = result.project_dir.parent) 216 | assert result.exception is None 217 | 218 | def test_previous_dir_is_removed(copie): 219 | assert test_dir.is_dir() is False 220 | """ 221 | ) 222 | 223 | result = testdir.runpytest("-v", f"--template={copier_template}") 224 | test_check(result, "test_create_dir") 225 | test_check(result, "test_previous_dir_is_removed") 226 | assert result.ret == 0 227 | 228 | 229 | def test_copie_fixture_keeps_directories(testdir, copier_template, test_check): 230 | """Check the copie fixture keeps the test directories from one test to another.""" 231 | testdir.makepyfile( 232 | """ 233 | def test_create_dir(copie): 234 | result = copie.copy() 235 | globals().update(test_dir = result.project_dir.parent) 236 | assert result.exception is None 237 | 238 | def test_previous_dir_is_kept(copie): 239 | assert test_dir.is_dir() is True 240 | """ 241 | ) 242 | 243 | result = testdir.runpytest("-v", f"--template={copier_template}", "--keep-copied-projects") 244 | test_check(result, "test_create_dir") 245 | test_check(result, "test_previous_dir_is_kept") 246 | assert result.ret == 0 247 | 248 | 249 | def test_copie_result_context(testdir, copier_template, test_check): 250 | """Check that the result holds the rendered answers.""" 251 | testdir.makepyfile( 252 | """ 253 | def test_copie_project(copie): 254 | my_answers = {'repo_name': 'foobar', "short_description": "copie is awesome"} 255 | default_answers = {"test_templated": "foobar", "test_value": "value"} 256 | result = copie.copy(extra_answers=my_answers) 257 | assert result.project_dir.stem.startswith("copie") 258 | assert result.answers == {**my_answers, **default_answers} 259 | """ 260 | ) 261 | 262 | result = testdir.runpytest("-v", f"--template={copier_template}") 263 | test_check(result, "test_copie_project") 264 | assert result.ret == 0 265 | 266 | 267 | def test_cookies_group(testdir): 268 | """Check that pytest registered the --cookies-group option.""" 269 | result = testdir.runpytest("--help") 270 | result.stdout.fnmatch_lines(["copie:", "*--template=TEMPLATE*"]) 271 | 272 | 273 | def test_config(testdir, test_check): 274 | """Check that pytest accepts the `copie` fixture.""" 275 | # create a temporary pytest test module 276 | testdir.makepyfile( 277 | """ 278 | import yaml 279 | 280 | def test_user_dir(tmp_path_factory, _copier_config_file): 281 | basetemp = tmp_path_factory.getbasetemp() 282 | assert _copier_config_file.stem == "config" 283 | user_dir = _copier_config_file.parent 284 | assert user_dir.stem.startswith("user_dir") 285 | assert user_dir.parent == basetemp 286 | 287 | 288 | def test_valid_copier_config(_copier_config_file): 289 | with open(_copier_config_file) as f: 290 | config = yaml.safe_load(f) 291 | user_dir = _copier_config_file.parent 292 | expected = { 293 | "copier_dir": str(user_dir / "copier"), 294 | "replay_dir": str(user_dir / "copier_replay"), 295 | } 296 | assert config == expected 297 | """ 298 | ) 299 | 300 | result = testdir.runpytest("-v") 301 | test_check(result, "test_user_dir") 302 | test_check(result, "test_valid_copier_config") 303 | assert result.ret == 0 304 | 305 | 306 | def test_copy_include_file(testdir): 307 | """Validates that pytest-copie can handle the '!include' directive.""" 308 | (template_dir := Path(testdir.tmpdir) / "copie-template").mkdir() 309 | 310 | (template_dir / "copier.yml").write_text( 311 | textwrap.dedent( 312 | """\ 313 | test1: test1 314 | 315 | --- 316 | !include other.yml 317 | --- 318 | 319 | test2: test2 320 | 321 | _subdirectory: project 322 | """, 323 | ), 324 | ) 325 | 326 | (template_dir / "other.yml").write_text( 327 | textwrap.dedent( 328 | """\ 329 | test3: test3 330 | """, 331 | ), 332 | ) 333 | 334 | (repo_dir := template_dir / "project").mkdir() 335 | 336 | (repo_dir / "README.rst.jinja").write_text( 337 | textwrap.dedent( 338 | """\ 339 | test1: {{ test1 }} 340 | test2: {{ test2 }} 341 | test3: {{ test3 }} 342 | """, 343 | ), 344 | ) 345 | 346 | testdir.makepyfile( 347 | """ 348 | def test_copie_project(copie): 349 | result = copie.copy() 350 | 351 | assert result.exit_code == 0 352 | assert result.exception is None 353 | 354 | readme_file = result.project_dir / "README.rst" 355 | assert readme_file.read_text() == "test1: test1\\ntest2: test2\\ntest3: test3\\n" 356 | """ 357 | ) 358 | 359 | result = testdir.runpytest("-v", f"--template={template_dir}") 360 | assert result.ret == 0 361 | 362 | 363 | def test_copy_include_file_error_invalid_file(testdir): 364 | """Validates that pytest-copie raises an exception when the included file does not exist.""" 365 | (template_dir := Path(testdir.tmpdir) / "copie-template").mkdir() 366 | 367 | (template_dir / "copier.yml").write_text( 368 | textwrap.dedent( 369 | """\ 370 | test1: test1 371 | 372 | --- 373 | !include file_does_not_exist.yml 374 | --- 375 | 376 | test2: test2 377 | 378 | _subdirectory: project 379 | """, 380 | ), 381 | ) 382 | 383 | invalid_filename = template_dir / "file_does_not_exist.yml" 384 | invalid_filename_str = str(invalid_filename).replace("\\", "\\\\") 385 | 386 | testdir.makepyfile( 387 | f""" 388 | def test_copie_project(copie): 389 | result = copie.copy() 390 | 391 | assert result.exit_code == -1 392 | assert result.exception is not None 393 | assert str(result.exception) == "The filename '{invalid_filename_str}' does not exist." 394 | """, 395 | ) 396 | 397 | result = testdir.runpytest("-v", f"--template={template_dir}") 398 | assert result.ret == 0 399 | -------------------------------------------------------------------------------- /pytest_copie/plugin.py: -------------------------------------------------------------------------------- 1 | """A pytest plugin to build copier project from a template.""" 2 | 3 | from dataclasses import dataclass, field 4 | from pathlib import Path 5 | from shutil import copy2, copytree, rmtree 6 | from typing import Callable, Generator, List, Optional, Union 7 | 8 | import plumbum 9 | import plumbum.machines 10 | import pytest 11 | import yaml 12 | from _pytest.tmpdir import TempPathFactory 13 | from copier import run_copy, run_update 14 | 15 | 16 | @dataclass 17 | class Result: 18 | """Holds the captured result of the copier project generation.""" 19 | 20 | exception: Union[Exception, SystemExit, None] = None 21 | "The exception raised during the copier project generation." 22 | 23 | exit_code: Union[str, int, None] = 0 24 | "The exit code of the copier project generation." 25 | 26 | project_dir: Optional[Path] = None 27 | "The path to the generated project." 28 | 29 | answers: dict = field(default_factory=dict) 30 | "The answers used to generate the project." 31 | 32 | def __repr__(self) -> str: 33 | """Return a string representation of the result.""" 34 | return f"" 35 | 36 | 37 | _GIT_AUTHOR = "Pytest Copie" 38 | _GIT_EMAIL = "pytest@example.com" 39 | 40 | _git: plumbum.machines.LocalCommand = plumbum.cmd.git.with_env( 41 | GIT_AUTHOR_NAME=_GIT_AUTHOR, 42 | GIT_AUTHOR_EMAIL=_GIT_EMAIL, 43 | GIT_COMMITTER_NAME=_GIT_AUTHOR, 44 | GIT_COMMITTER_EMAIL=_GIT_EMAIL, 45 | ) 46 | """A handle to allow execution of git commands during tests.""" 47 | 48 | 49 | @dataclass 50 | class Copie: 51 | """Class to provide convenient access to the copier API.""" 52 | 53 | default_template_dir: Path 54 | "The path to the default template to use to create the project." 55 | 56 | test_dir: Path 57 | "The directory where the project will be created." 58 | 59 | config_file: Path 60 | "The path to the copier config file." 61 | 62 | parent_result: Optional[Result] = None 63 | "Template generated by the parent template to be used in combination with default_template." 64 | 65 | counter: int = 0 66 | "A counter to keep track of the number of projects created." 67 | 68 | def git(self) -> plumbum.machines.LocalCommand: 69 | """A handle to allow execution of git commands during tests.""" 70 | return _git 71 | 72 | def copy( 73 | self, extra_answers: dict = {}, template_dir: Optional[Path] = None, vcs_ref: str = "HEAD" 74 | ) -> Result: 75 | """Create a copier Project from the template and return the associated :py:class:`Result ` object. 76 | 77 | Args: 78 | extra_answers: extra answers to pass to the Copie object and overwrite the default ones 79 | template_dir: the path to the template to use to create the project instead of the default ".". 80 | vcs_ref: the commit hash, tag or branch to use from the template repo, for the copy 81 | 82 | Returns: 83 | the result of the copier project generation 84 | """ 85 | # Check for valid parent_result if provided 86 | if self.parent_result is not None: 87 | if self.parent_result.project_dir is None: 88 | raise ValueError("parent_result.project_dir must be set.") 89 | if not isinstance(self.parent_result.project_dir, Path): 90 | raise ValueError("parent_result.project_dir must be a Path object.") 91 | if not self.parent_result.project_dir.exists(): 92 | raise ValueError("parent_result.project_dir must exist.") 93 | if not self.parent_result.exit_code == 0: 94 | raise ValueError( 95 | "parent_result must have a successful exit code (0) to be used as a parent." 96 | ) 97 | 98 | # set the template dir and the associated copier.yaml file 99 | template_dir = template_dir or self.default_template_dir 100 | files = template_dir.glob("copier.*") 101 | try: 102 | copier_yaml = next(f for f in files if f.suffix in [".yaml", ".yml"]) 103 | except StopIteration: 104 | raise FileNotFoundError("No copier.yaml configuration file found.") 105 | 106 | # create a new output_dir in the test dir based on the counter value 107 | (output_dir := self.test_dir / f"copie{self.counter:03d}").mkdir() 108 | self.counter += 1 109 | 110 | # Copy contents from parent_result.project_dir into output_dir 111 | if self.parent_result: 112 | if self.parent_result.project_dir is None: 113 | raise ValueError("parent_result.project_dir must be set.") 114 | else: 115 | for item in self.parent_result.project_dir.iterdir(): 116 | dest = output_dir / item.name 117 | copy_method = copytree if item.is_dir() else copy2 118 | copy_method(item, dest) 119 | 120 | try: 121 | # make sure the copiercopier project is using subdirectories 122 | _add_yaml_include_constructor(template_dir) 123 | 124 | all_params = yaml.safe_load_all(copier_yaml.read_text()) 125 | if not any("_subdirectory" in params for params in all_params): 126 | raise ValueError( 127 | "The plugin can only work for templates using subdirectories, " 128 | '"_subdirectory" key is missing from copier.yaml' 129 | ) 130 | 131 | worker = run_copy( 132 | src_path=str(template_dir), 133 | dst_path=str(output_dir), 134 | unsafe=True, 135 | defaults=True, 136 | user_defaults=extra_answers, 137 | vcs_ref=vcs_ref or "HEAD", 138 | ) 139 | 140 | # refresh project_dir with the generated one 141 | # the project path will be the first child of the ouptut_dir 142 | project_dir = Path(worker.dst_path) 143 | 144 | # refresh answers with the generated ones and remove private stuff 145 | answers = worker._answers_to_remember() 146 | answers = {q: a for q, a in answers.items() if not q.startswith("_")} 147 | 148 | return Result(project_dir=project_dir, answers=answers) 149 | 150 | except SystemExit as e: 151 | return Result(exception=e, exit_code=e.code) 152 | except Exception as e: 153 | return Result(exception=e, exit_code=-1) 154 | 155 | def update( 156 | self, result: Result, extra_answers: Optional[dict] = None, vcs_ref: str = "HEAD" 157 | ) -> Result: 158 | """Update a copier Project from the template and return the associated :py:class:`Result ` object, returns a new :py:class:`Result `. 159 | 160 | Args: 161 | result: results obtained when the project was first created 162 | extra_answers: extra answers to pass to the Copie object and overwrite the default ones 163 | vcs_ref: the commit/tag to use for the update 164 | 165 | Returns: 166 | the result of the copier project update 167 | """ 168 | assert ( 169 | result.project_dir is not None 170 | ) and result.project_dir.exists(), "To update, `result.project_dir` must exist" 171 | 172 | try: 173 | worker = run_update( 174 | dst_path=str(result.project_dir), 175 | unsafe=True, 176 | defaults=True, 177 | overwrite=True, 178 | user_defaults=extra_answers if extra_answers is not None else {}, 179 | vcs_ref=vcs_ref, 180 | ) 181 | 182 | # refresh answers with the generated ones and remove private stuff 183 | answers = worker._answers_to_remember() 184 | answers = {q: a for q, a in answers.items() if not q.startswith("_")} 185 | 186 | return Result(project_dir=result.project_dir, answers=answers) 187 | 188 | except SystemExit as e: 189 | return Result(exception=e, exit_code=e.code) 190 | except Exception as e: 191 | return Result(exception=e, exit_code=-1) 192 | 193 | 194 | @pytest.fixture(scope="session") 195 | def _copier_config_file(tmp_path_factory) -> Path: 196 | """Return a temporary copier config file.""" 197 | # create a user from the tmp_path_factory fixture 198 | user_dir = tmp_path_factory.mktemp("user_dir") 199 | 200 | # create the different folders and files 201 | (copier_dir := user_dir / "copier").mkdir() 202 | (replay_dir := user_dir / "copier_replay").mkdir() 203 | 204 | # set up the configuration parameters in a config file 205 | config = {"copier_dir": str(copier_dir), "replay_dir": str(replay_dir)} 206 | (config_file := user_dir / "config").write_text(yaml.dump(config)) 207 | 208 | return config_file 209 | 210 | 211 | @pytest.fixture 212 | def copie( 213 | request: Union[pytest.FixtureRequest, None], 214 | tmp_path: Path, 215 | _copier_config_file: Path, 216 | parent_tpl: Optional[Path] = None, 217 | ) -> Generator: 218 | """Yield an instance of the :py:class:`Copie ` helper class. 219 | 220 | The class can then be used to generate a project from a template. 221 | 222 | Args: 223 | request: the pytest request object (None when used outside of pytest) 224 | tmp_path: the temporary directory 225 | _copier_config_file: the temporary copier config file 226 | parent_tpl: the path to the parent template directory, 227 | must be provided when used outside of pytest. 228 | 229 | Returns: 230 | the object instance, ready to copy ! 231 | """ 232 | if request is None and parent_tpl is None: 233 | raise ValueError( 234 | "When not used in pytest, the 'parent_template_dir' argument must be provided." 235 | ) 236 | # If in pytest, use the template directory from the pytest command parameter 237 | if parent_tpl is None: 238 | if request is None: 239 | raise ValueError( 240 | "The 'parent_template_dir' argument must be provided when not in pytest." 241 | ) 242 | if getattr(request.config.option, "template", None) is None: 243 | raise ValueError("The 'template' pytest option must be set to use the 'copie' fixture.") 244 | parent_tpl = Path(request.config.option.template) 245 | 246 | # list to keep track of each applied template 247 | created_dirs: List[Path] = [] 248 | 249 | # set up a test directory in the tmp folder for the 1st template to apply 250 | parent_dir = tmp_path / "copie" 251 | parent_dir.mkdir() 252 | created_dirs.append(parent_dir) 253 | 254 | # Create the primary Copie instance 255 | # which will be used to apply the first template 256 | primary = Copie( 257 | default_template_dir=parent_tpl, 258 | test_dir=parent_dir, 259 | config_file=_copier_config_file, 260 | ) 261 | 262 | def _spawn_child( 263 | *, 264 | parent_result: Optional[Result] = None, 265 | child_tpl: Path, 266 | ) -> "Copie": 267 | """ 268 | Create a child Copie instance to apply a new template. 269 | 270 | Args: 271 | parent_result: the result of the parent Copie instance, if any 272 | child_tpl: the path to the child template directory 273 | 274 | Returns: 275 | A new instance of the Copie class, ready to copy a new template. 276 | """ 277 | child_dir = tmp_path / f"copie_{len(created_dirs):03d}" 278 | child_dir.mkdir() 279 | created_dirs.append(child_dir) 280 | 281 | return Copie( 282 | default_template_dir=child_tpl, 283 | test_dir=child_dir, 284 | config_file=_copier_config_file, 285 | parent_result=parent_result, 286 | ) 287 | 288 | class CopieHandle: 289 | """Acts like `Copie` *and* like a factory for more `Copie`s.""" 290 | 291 | def __init__(self, primary: Copie, factory: Callable[..., Copie]): 292 | self._primary = primary 293 | self._factory = factory 294 | 295 | # delegate every unknown attribute to the primary instance 296 | def __getattr__(self, item): 297 | return getattr(self._primary, item) 298 | 299 | # being *callable* makes the handle a factory 300 | def __call__(self, *args, **kwargs): 301 | return self._factory(*args, **kwargs) 302 | 303 | # create the handle to the primary Copie instance 304 | handle = CopieHandle(primary, _spawn_child) 305 | yield handle 306 | 307 | # Common cleanup after tests 308 | if request is not None and not request.config.option.keep_copied_projects: 309 | for d in reversed(created_dirs): 310 | rmtree(d, ignore_errors=True) 311 | 312 | 313 | @pytest.fixture(scope="session") 314 | def copie_session( 315 | request, 316 | tmp_path_factory: TempPathFactory, 317 | _copier_config_file: Path, 318 | ) -> Generator: 319 | """Yield an instance of the :py:class:`Copie ` helper class. 320 | 321 | The class can then be used to generate a project from a template. 322 | 323 | Args: 324 | request: the pytest request object 325 | tmp_path_factory: the temporary directory 326 | _copier_config_file: the temporary copier config file 327 | 328 | Returns: 329 | the object instance, ready to copy ! 330 | """ 331 | # extract the template directory from the pytest command parameter 332 | template_dir = Path(request.config.option.template) 333 | 334 | # set up a test directory in the tmp folder 335 | test_dir = tmp_path_factory.mktemp("copie") 336 | 337 | yield Copie(template_dir, test_dir, _copier_config_file) 338 | 339 | # don't delete the files at the end of the test if requested 340 | if not request.config.option.keep_copied_projects: 341 | rmtree(test_dir, ignore_errors=True) 342 | 343 | 344 | def pytest_addoption(parser): 345 | """Add option to the pytest command.""" 346 | group = parser.getgroup("copie") 347 | 348 | group.addoption( 349 | "--template", 350 | action="store", 351 | default=".", 352 | dest="template", 353 | help="specify the template to be rendered", 354 | type=str, 355 | ) 356 | 357 | group.addoption( 358 | "--keep-copied-projects", 359 | action="store_true", 360 | default=False, 361 | dest="keep_copied_projects", 362 | help="Keep projects directories generated with 'copie.copie()'.", 363 | ) 364 | 365 | 366 | def pytest_configure(config): 367 | """Force the template path to be absolute to protect ourselves from fixtures that changes path.""" 368 | config.option.template = str(Path(config.option.template).resolve()) 369 | 370 | 371 | def _add_yaml_include_constructor( 372 | template_dir: Path, 373 | ): 374 | """Adds an include constructor to yaml.SafeLoader.""" 375 | 376 | def include_constructor( 377 | loader: yaml.SafeLoader, 378 | node: yaml.Node, 379 | ): 380 | fullpath = template_dir / node.value 381 | 382 | if not fullpath.is_file(): 383 | raise FileNotFoundError(f"The filename '{fullpath}' does not exist.") 384 | 385 | with fullpath.open("rb") as f: 386 | return yaml.safe_load(f) 387 | 388 | yaml.add_constructor("!include", include_constructor, yaml.SafeLoader) 389 | --------------------------------------------------------------------------------