diff --git a/changelog/466.added.md b/changelog/466.added.md new file mode 100644 index 00000000..49d639ef --- /dev/null +++ b/changelog/466.added.md @@ -0,0 +1 @@ +Added `infrahubctl repository init` command to allow the initialization of an Infrahub repository using [infrahub-template](https://github.com/opsmill/infrahub-template). \ No newline at end of file diff --git a/docs/docs/infrahubctl/infrahubctl-repository.mdx b/docs/docs/infrahubctl/infrahubctl-repository.mdx index 94375125..eed5af5a 100644 --- a/docs/docs/infrahubctl/infrahubctl-repository.mdx +++ b/docs/docs/infrahubctl/infrahubctl-repository.mdx @@ -19,6 +19,7 @@ $ infrahubctl repository [OPTIONS] COMMAND [ARGS]... **Commands**: * `add`: Add a new repository. +* `init`: Initialize a new Infrahub repository. * `list` ## `infrahubctl repository add` @@ -47,6 +48,29 @@ $ infrahubctl repository add [OPTIONS] NAME LOCATION * `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] * `--help`: Show this message and exit. +## `infrahubctl repository init` + +Initialize a new Infrahub repository. + +**Usage**: + +```console +$ infrahubctl repository init [OPTIONS] DIRECTORY +``` + +**Arguments**: + +* `DIRECTORY`: Directory path for the new project. [required] + +**Options**: + +* `--template TEXT`: Template to use for the new repository. Can be a local path or a git repository URL. [default: https://github.com/opsmill/infrahub-template.git] +* `--data PATH`: Path to YAML file containing answers to CLI prompt. +* `--vcs-ref TEXT`: VCS reference to use for the template. Defaults to HEAD. [default: HEAD] +* `--trust / --no-trust`: Trust the template repository. If set, the template will be cloned without verification. [default: no-trust] +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. + ## `infrahubctl repository list` **Usage**: diff --git a/infrahub_sdk/ctl/repository.py b/infrahub_sdk/ctl/repository.py index 98e394bf..d23f8484 100644 --- a/infrahub_sdk/ctl/repository.py +++ b/infrahub_sdk/ctl/repository.py @@ -1,10 +1,12 @@ from __future__ import annotations +import asyncio from pathlib import Path from typing import Optional import typer import yaml +from copier import run_copy from pydantic import ValidationError from rich.console import Console from rich.table import Table @@ -165,3 +167,52 @@ async def list( ) console.print(table) + + +@app.command() +async def init( + directory: Path = typer.Argument(help="Directory path for the new project."), + template: str = typer.Option( + default="https://github.com/opsmill/infrahub-template.git", + help="Template to use for the new repository. Can be a local path or a git repository URL.", + ), + data: Optional[Path] = typer.Option(default=None, help="Path to YAML file containing answers to CLI prompt."), + vcs_ref: Optional[str] = typer.Option( + default="HEAD", + help="VCS reference to use for the template. Defaults to HEAD.", + ), + trust: Optional[bool] = typer.Option( + default=False, + help="Trust the template repository. If set, the template will be cloned without verification.", + ), + _: str = CONFIG_PARAM, +) -> None: + """Initialize a new Infrahub repository.""" + + config_data = None + if data: + try: + with Path.open(data, encoding="utf-8") as file: + config_data = yaml.safe_load(file) + typer.echo(f"Loaded config: {config_data}") + except Exception as exc: + typer.echo(f"Error loading YAML file: {exc}", err=True) + raise typer.Exit(code=1) + + # Allow template to be a local path or a URL + template_source = template or "" + if template and Path(template).exists(): + template_source = str(Path(template).resolve()) + + try: + await asyncio.to_thread( + run_copy, + template_source, + str(directory), + data=config_data, + vcs_ref=vcs_ref, + unsafe=trust, + ) + except Exception as e: + typer.echo(f"Error running copier: {e}", err=True) + raise typer.Exit(code=1) diff --git a/poetry.lock b/poetry.lock index a1b4c065..694d1253 100644 --- a/poetry.lock +++ b/poetry.lock @@ -220,7 +220,37 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "(extra == \"ctl\" or extra == \"all\") and platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "extra == \"ctl\" or extra == \"all\" or sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} + +[[package]] +name = "copier" +version = "9.8.0" +description = "A library for rendering project templates." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"ctl\" or extra == \"all\"" +files = [ + {file = "copier-9.8.0-py3-none-any.whl", hash = "sha256:ca0bee47f198b66cec926c4f1a3aa77f11ee0102624369c10e42ca9058c0a891"}, + {file = "copier-9.8.0.tar.gz", hash = "sha256:343ac1eb65e678aa355690d7f19869ef07cabf837f511a87ed452443c085ec58"}, +] + +[package.dependencies] +colorama = ">=0.4.6" +dunamai = ">=1.7.0" +eval-type-backport = {version = ">=0.1.3,<0.3.0", markers = "python_version < \"3.10\""} +funcy = ">=1.17" +jinja2 = ">=3.1.5" +jinja2-ansible-filters = ">=1.3.1" +packaging = ">=23.0" +pathspec = ">=0.9.0" +platformdirs = ">=4.3.6" +plumbum = ">=1.6.9" +pydantic = ">=2.4.2" +pygments = ">=2.7.1" +pyyaml = ">=5.3.1" +questionary = ">=1.8.1" +typing-extensions = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.11\""} [[package]] name = "coverage" @@ -446,6 +476,22 @@ https = ["urllib3 (>=1.24.1)"] paramiko = ["paramiko"] pgp = ["gpg"] +[[package]] +name = "dunamai" +version = "1.25.0" +description = "Dynamic version generation" +optional = true +python-versions = ">=3.5" +groups = ["main"] +markers = "extra == \"ctl\" or extra == \"all\"" +files = [ + {file = "dunamai-1.25.0-py3-none-any.whl", hash = "sha256:7f9dc687dd3256e613b6cc978d9daabfd2bb5deb8adc541fc135ee423ffa98ab"}, + {file = "dunamai-1.25.0.tar.gz", hash = "sha256:a7f8360ea286d3dbaf0b6a1473f9253280ac93d619836ad4514facb70c0719d1"}, +] + +[package.dependencies] +packaging = ">=20.9" + [[package]] name = "eval-type-backport" version = "0.2.2" @@ -525,6 +571,19 @@ docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2. testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.1.1)", "pytest (>=8.3.2)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.3)"] typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] +[[package]] +name = "funcy" +version = "2.0" +description = "A fancy and practical functional tools" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"ctl\" or extra == \"all\"" +files = [ + {file = "funcy-2.0-py2.py3-none-any.whl", hash = "sha256:53df23c8bb1651b12f095df764bfb057935d49537a56de211b098f4c79614bb0"}, + {file = "funcy-2.0.tar.gz", hash = "sha256:3963315d59d41c6f30c04bc910e10ab50a3ac4a225868bfa96feed133df075cb"}, +] + [[package]] name = "graphql-core" version = "3.2.4" @@ -796,6 +855,26 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jinja2-ansible-filters" +version = "1.3.2" +description = "A port of Ansible's jinja2 filters without requiring ansible core." +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"ctl\" or extra == \"all\"" +files = [ + {file = "jinja2-ansible-filters-1.3.2.tar.gz", hash = "sha256:07c10cf44d7073f4f01102ca12d9a2dc31b41d47e4c61ed92ef6a6d2669b356b"}, + {file = "jinja2_ansible_filters-1.3.2-py3-none-any.whl", hash = "sha256:e1082f5564917649c76fed239117820610516ec10f87735d0338688800a55b34"}, +] + +[package.dependencies] +Jinja2 = "*" +PyYAML = "*" + +[package.extras] +test = ["pytest", "pytest-cov"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1089,11 +1168,12 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] +markers = {main = "extra == \"ctl\" or extra == \"all\""} [[package]] name = "pexpect" @@ -1113,20 +1193,21 @@ ptyprocess = ">=0.5" [[package]] name = "platformdirs" -version = "4.3.3" +version = "4.3.8" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false -python-versions = ">=3.8" -groups = ["dev"] +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "platformdirs-4.3.3-py3-none-any.whl", hash = "sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5"}, - {file = "platformdirs-4.3.3.tar.gz", hash = "sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0"}, + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, ] +markers = {main = "extra == \"ctl\" or extra == \"all\""} [package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] [[package]] name = "pluggy" @@ -1144,6 +1225,28 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "plumbum" +version = "1.9.0" +description = "Plumbum: shell combinators library" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"ctl\" or extra == \"all\"" +files = [ + {file = "plumbum-1.9.0-py3-none-any.whl", hash = "sha256:9fd0d3b0e8d86e4b581af36edf3f3bbe9d1ae15b45b8caab28de1bcb27aaa7f5"}, + {file = "plumbum-1.9.0.tar.gz", hash = "sha256:e640062b72642c3873bd5bdc3effed75ba4d3c70ef6b6a7b907357a84d909219"}, +] + +[package.dependencies] +pywin32 = {version = "*", markers = "platform_system == \"Windows\" and platform_python_implementation != \"PyPy\""} + +[package.extras] +dev = ["coverage[toml]", "paramiko", "psutil", "pytest (>=6.0)", "pytest-cov", "pytest-mock", "pytest-timeout"] +docs = ["sphinx (>=4.0.0)", "sphinx-rtd-theme (>=1.0.0)"] +ssh = ["paramiko"] +test = ["coverage[toml]", "paramiko", "psutil", "pytest (>=6.0)", "pytest-cov", "pytest-mock", "pytest-timeout"] + [[package]] name = "pprintpp" version = "0.4.0" @@ -1181,11 +1284,12 @@ version = "3.0.47" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.7.0" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, ] +markers = {main = "extra == \"ctl\" or extra == \"all\""} [package.dependencies] wcwidth = "*" @@ -1608,8 +1712,7 @@ version = "308" description = "Python for Window Extensions" optional = false python-versions = "*" -groups = ["dev"] -markers = "python_version >= \"3.10\" and sys_platform == \"win32\"" +groups = ["main", "dev"] files = [ {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"}, {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"}, @@ -1630,6 +1733,7 @@ files = [ {file = "pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341"}, {file = "pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920"}, ] +markers = {main = "platform_system == \"Windows\" and platform_python_implementation != \"PyPy\" and (extra == \"ctl\" or extra == \"all\")", dev = "python_version >= \"3.10\" and sys_platform == \"win32\""} [[package]] name = "pyyaml" @@ -1695,6 +1799,22 @@ files = [ ] markers = {main = "extra == \"ctl\" or extra == \"tests\" or extra == \"all\""} +[[package]] +name = "questionary" +version = "2.1.0" +description = "Python library to build pretty command line user prompts ⭐️" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"ctl\" or extra == \"all\"" +files = [ + {file = "questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec"}, + {file = "questionary-2.1.0.tar.gz", hash = "sha256:6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587"}, +] + +[package.dependencies] +prompt_toolkit = ">=2.0,<4.0" + [[package]] name = "requests" version = "2.32.3" @@ -2180,11 +2300,12 @@ version = "0.2.13" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = "*" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, ] +markers = {main = "extra == \"ctl\" or extra == \"all\""} [[package]] name = "whenever" @@ -2387,11 +2508,11 @@ test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.funct type = ["pytest-mypy"] [extras] -all = ["Jinja2", "click", "numpy", "numpy", "pyarrow", "pytest", "pyyaml", "rich", "toml", "typer"] -ctl = ["Jinja2", "click", "numpy", "numpy", "pyarrow", "pyyaml", "rich", "toml", "typer"] +all = ["Jinja2", "click", "copier", "numpy", "numpy", "pyarrow", "pytest", "pyyaml", "rich", "toml", "typer"] +ctl = ["Jinja2", "click", "copier", "numpy", "numpy", "pyarrow", "pyyaml", "rich", "toml", "typer"] tests = ["Jinja2", "pytest", "pyyaml", "rich"] [metadata] lock-version = "2.1" python-versions = "^3.9, <3.14" -content-hash = "978a8ed3c6f4f4e46d39b8c33affb767a91275ee2bee532a48a7abc3d224deb8" +content-hash = "110653882a7abfb7d9597d6ffa77d455ab93eca8aec08f6b505a1dd12baaec6c" diff --git a/pyproject.toml b/pyproject.toml index f6b4f7e3..3a90196f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dulwich = "^0.21.4" whenever = ">=0.7.2,<0.8.0" netutils = "^1.0.0" click = { version = "8.1.*", optional = true } +copier = { version = "^9.8.0", optional = true } [tool.poetry.group.dev.dependencies] pytest = "*" @@ -69,7 +70,7 @@ infrahub-testcontainers = { version = "^1.2.5", python = ">=3.10" } astroid = "~3.1" [tool.poetry.extras] -ctl = ["Jinja2", "numpy", "pyarrow", "pyyaml", "rich", "toml", "typer", "click"] +ctl = ["Jinja2", "numpy", "pyarrow", "pyyaml", "rich", "toml", "typer", "click", "copier"] tests = ["Jinja2", "pytest", "pyyaml", "rich"] all = [ "Jinja2", @@ -81,6 +82,7 @@ all = [ "toml", "typer", "click", + "copier", ] [tool.poetry.scripts] diff --git a/tests/unit/ctl/test_repository_app.py b/tests/unit/ctl/test_repository_app.py index ba0a7721..f8636824 100644 --- a/tests/unit/ctl/test_repository_app.py +++ b/tests/unit/ctl/test_repository_app.py @@ -1,8 +1,11 @@ """Integration tests for infrahubctl commands.""" +import tempfile +from pathlib import Path from unittest import mock import pytest +import yaml from typer.testing import CliRunner from infrahub_sdk.client import InfrahubClient @@ -322,3 +325,73 @@ def test_repo_list(self, mock_repositories_list) -> None: result = runner.invoke(app, ["repository", "list"]) assert result.exit_code == 0 assert strip_color(result.stdout) == read_fixture("output.txt", "integration/test_infrahubctl/repository_list") + + def test_repo_init(self) -> None: + """Test the repository init command.""" + with ( + tempfile.TemporaryDirectory() as temp_dst, + tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False, encoding="utf-8") as temp_yaml, + ): + dst = Path(temp_dst) + yaml_path = Path(temp_yaml.name) + commit = "v0.0.1" + + answers = { + "generators": True, + "menus": True, + "project_name": "test", + "queries": True, + "scripts": True, + "tests": True, + "transforms": True, + "package_mode": False, + "_commit": commit, + } + + yaml.safe_dump(answers, temp_yaml) + temp_yaml.close() + runner.invoke(app, ["repository", "init", str(dst), "--data", str(yaml_path), "--vcs-ref", commit]) + coppied_answers = yaml.safe_load((dst / ".copier-answers.yml").read_text()) + coppied_answers.pop("_src_path") + + assert coppied_answers == answers + assert (dst / "generators").is_dir() + assert (dst / "queries").is_dir() + assert (dst / "scripts").is_dir() + assert (dst / "pyproject.toml").is_file() + + def test_repo_init_local_template(self) -> None: + """Test the repository init command with a local template.""" + with ( + tempfile.TemporaryDirectory() as temp_src, + tempfile.TemporaryDirectory() as temp_dst, + tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False, encoding="utf-8") as temp_yaml, + ): + src = Path(temp_src) + dst = Path(temp_dst) + + # Create a simple copier template + (src / "copier.yml").write_text("project_name:\n type: str") + template_dir = src / "{{project_name}}" + template_dir.mkdir() + (template_dir / "file.txt.jinja").write_text("Hello {{ project_name }}") + + # Create answers file + yaml_path = Path(temp_yaml.name) + answers = {"project_name": "local-test"} + yaml.safe_dump(answers, temp_yaml) + temp_yaml.close() + + # Run the command + result = runner.invoke( + app, ["repository", "init", str(dst), "--template", str(src), "--data", str(yaml_path)] + ) + + assert result.exit_code == 0, result.stdout + + # Check the output + project_dir = dst / "local-test" + assert project_dir.is_dir() + output_file = project_dir / "file.txt" + assert output_file.is_file() + assert output_file.read_text() == "Hello local-test"