diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5501ac6..e84aff83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: github_workflows: ${{ steps.changes.outputs.github_workflows }} steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: Check for file changes uses: dorny/paths-filter@v3 id: changes @@ -63,7 +63,7 @@ jobs: timeout-minutes: 5 steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: "Setup environment" run: "pip install yamllint==1.35.1" - name: "Linting: yamllint" @@ -76,7 +76,7 @@ jobs: timeout-minutes: 5 steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: "Setup environment" run: "pip install ruff==0.11.0" - name: "Linting: ruff check" @@ -94,7 +94,7 @@ jobs: timeout-minutes: 5 steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: "Linting: markdownlint" uses: DavidAnson/markdownlint-cli2-action@v20 with: @@ -110,7 +110,7 @@ jobs: timeout-minutes: 5 steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: Check workflow files run: | bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) @@ -134,7 +134,7 @@ jobs: timeout-minutes: 5 steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" with: submodules: true - name: Install NodeJS @@ -161,7 +161,7 @@ jobs: timeout-minutes: 5 steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" with: submodules: true - name: Set up Python @@ -190,7 +190,7 @@ jobs: timeout-minutes: 5 steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" with: submodules: true @@ -227,7 +227,7 @@ jobs: timeout-minutes: 30 steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: @@ -276,7 +276,7 @@ jobs: timeout-minutes: 30 steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: Set up Python uses: actions/setup-python@v5 with: @@ -316,14 +316,14 @@ jobs: # timeout-minutes: 30 # steps: # - name: "Check out repository code" - # uses: "actions/checkout@v4" + # uses: "actions/checkout@v5" # - name: "Extract target branch name" # id: extract_branch # run: echo "TARGET_BRANCH=${{ github.base_ref }}" >> $GITHUB_ENV # - name: "Checkout infrahub repository" - # uses: "actions/checkout@v4" + # uses: "actions/checkout@v5" # with: # repository: "opsmill/infrahub" # path: "infrahub-server" diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 8e8d76fb..aa7c4002 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@v5 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 5f85465f..6fdf4b4d 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -50,7 +50,7 @@ jobs: installer-parallel: true - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" with: submodules: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e07c696..c6e99b42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: latest_tag: ${{ steps.release.outputs.latest_tag }} steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" with: submodules: true diff --git a/.github/workflows/repository-dispatch.yml b/.github/workflows/repository-dispatch.yml index e5c7ec28..a3923a0d 100644 --- a/.github/workflows/repository-dispatch.yml +++ b/.github/workflows/repository-dispatch.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Repository Dispatch uses: peter-evans/repository-dispatch@v3 diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 2a6f32d2..6ed64d59 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -16,12 +16,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout source repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: path: source-repo - name: Checkout target repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: opsmill/infrahub-docs token: ${{ secrets.PAT_TOKEN }} diff --git a/.github/workflows/update-submodule.yml b/.github/workflows/update-submodule.yml index c6971deb..314a6ac7 100644 --- a/.github/workflows/update-submodule.yml +++ b/.github/workflows/update-submodule.yml @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Trigger submodule update run: | 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/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 6e9ff994..8c18b395 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -36,7 +36,7 @@ def validate_schema_content_and_exit(client: InfrahubClient, schemas: list[Schem has_error: bool = False for schema_file in schemas: try: - client.schema.validate(data=schema_file.content) + client.schema.validate(data=schema_file.payload) except ValidationError as exc: console.print(f"[red]Schema not valid, found '{len(exc.errors())}' error(s) in {schema_file.location}") has_error = True @@ -48,7 +48,7 @@ def validate_schema_content_and_exit(client: InfrahubClient, schemas: list[Schem raise typer.Exit(1) -def display_schema_load_errors(response: dict[str, Any], schemas_data: list[dict]) -> None: +def display_schema_load_errors(response: dict[str, Any], schemas_data: list[SchemaFile]) -> None: console.print("[red]Unable to load the schema:") if "detail" not in response: handle_non_detail_errors(response=response) @@ -87,7 +87,7 @@ def handle_non_detail_errors(response: dict[str, Any]) -> None: if "error" in response: console.print(f" {response.get('error')}") elif "errors" in response: - for error in response.get("errors"): + for error in response["errors"]: console.print(f" {error.get('message')}") else: console.print(f" '{response}'") @@ -97,9 +97,9 @@ def valid_error_path(loc_path: list[Any]) -> bool: return len(loc_path) >= 6 and loc_path[0] == "body" and loc_path[1] == "schemas" -def get_node(schemas_data: list[dict], schema_index: int, node_index: int) -> dict | None: - if schema_index < len(schemas_data) and node_index < len(schemas_data[schema_index].content["nodes"]): - return schemas_data[schema_index].content["nodes"][node_index] +def get_node(schemas_data: list[SchemaFile], schema_index: int, node_index: int) -> dict | None: + if schema_index < len(schemas_data) and node_index < len(schemas_data[schema_index].payload["nodes"]): + return schemas_data[schema_index].payload["nodes"][node_index] return None @@ -122,7 +122,7 @@ async def load( validate_schema_content_and_exit(client=client, schemas=schemas_data) start_time = time.time() - response = await client.schema.load(schemas=[item.content for item in schemas_data], branch=branch) + response = await client.schema.load(schemas=[item.payload for item in schemas_data], branch=branch) loading_time = time.time() - start_time if response.errors: @@ -170,10 +170,10 @@ async def check( client = initialize_client() validate_schema_content_and_exit(client=client, schemas=schemas_data) - success, response = await client.schema.check(schemas=[item.content for item in schemas_data], branch=branch) + success, response = await client.schema.check(schemas=[item.payload for item in schemas_data], branch=branch) if not success: - display_schema_load_errors(response=response, schemas_data=schemas_data) + display_schema_load_errors(response=response or {}, schemas_data=schemas_data) else: for schema_file in schemas_data: console.print(f"[green] schema '{schema_file.location}' is Valid!") diff --git a/infrahub_sdk/pytest_plugin/items/graphql_query.py b/infrahub_sdk/pytest_plugin/items/graphql_query.py index 769880bb..defb9fb9 100644 --- a/infrahub_sdk/pytest_plugin/items/graphql_query.py +++ b/infrahub_sdk/pytest_plugin/items/graphql_query.py @@ -16,7 +16,7 @@ class InfrahubGraphQLQueryItem(InfrahubItem): def validate_resource_config(self) -> None: - # Resource name does not need to match against infrahub repo config + # Resource name does not need to match against Infrahub repository configuration return def execute_query(self) -> Any: diff --git a/infrahub_sdk/schema/repository.py b/infrahub_sdk/schema/repository.py index 69d63832..832651c9 100644 --- a/infrahub_sdk/schema/repository.py +++ b/infrahub_sdk/schema/repository.py @@ -24,7 +24,7 @@ class InfrahubRepositoryConfigElement(BaseModel): - """Class to regroup all elements of the infrahub configuration for a repository for typing purpose.""" + """Class to regroup all elements of the Infrahub configuration for a repository for typing purpose.""" class InfrahubRepositoryArtifactDefinitionConfig(InfrahubRepositoryConfigElement): diff --git a/infrahub_sdk/testing/docker.py b/infrahub_sdk/testing/docker.py index 0f7d46b2..dc76b3a3 100644 --- a/infrahub_sdk/testing/docker.py +++ b/infrahub_sdk/testing/docker.py @@ -13,7 +13,7 @@ def skip_version(min_infrahub_version: str | None = None, max_infrahub_version: str | None = None) -> bool: """ - Check if a test should be skipped depending on infrahub version. + Check if a test should be skipped depending on Infrahub version. """ if INFRAHUB_VERSION is None: return True diff --git a/infrahub_sdk/utils.py b/infrahub_sdk/utils.py index 9c7274f1..d90c1f96 100644 --- a/infrahub_sdk/utils.py +++ b/infrahub_sdk/utils.py @@ -95,7 +95,7 @@ def decode_json(response: httpx.Response) -> dict: try: return response.json() except json.decoder.JSONDecodeError as exc: - raise JsonDecodeError(content=response.text, url=response.url) from exc + raise JsonDecodeError(content=response.text, url=str(response.url)) from exc def generate_uuid() -> str: @@ -232,7 +232,7 @@ def get_branch(branch: str | None = None, directory: str | Path = ".") -> str: if branch: return branch - repo = GitRepoManager(directory) + repo = GitRepoManager(root_directory=str(directory)) return str(repo.active_branch) diff --git a/poetry.lock b/poetry.lock index a1b4c065..b3a5cbfc 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" @@ -539,37 +598,37 @@ files = [ [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] name = "httpcore" -version = "1.0.5" +version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, - {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [package.dependencies] certifi = "*" -h11 = ">=0.13,<0.15" +h11 = ">=0.16" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.26.0)"] +trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httpx" @@ -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,21 +1799,37 @@ 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" +version = "2.32.5" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -2137,14 +2257,14 @@ files = [ [[package]] name = "urllib3" -version = "2.2.3" +version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] @@ -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 2465500f..17c0ccd3 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] @@ -113,10 +115,6 @@ disallow_untyped_defs = true module = "infrahub_sdk.ctl.check" disable_error_code = ["call-overload"] -[[tool.mypy.overrides]] -module = "infrahub_sdk.ctl.schema" -disable_error_code = ["arg-type", "attr-defined", "misc", "union-attr"] - [[tool.mypy.overrides]] module = "infrahub_sdk.utils" disable_error_code = ["arg-type", "attr-defined", "return-value", "union-attr"] @@ -176,7 +174,6 @@ ignore = [ ################################################################################################## "B008", # Do not perform function call `typer.Option` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable "B904", # Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling - "C408", # Unnecessary `dict` call (rewrite as a literal) "FURB110", # Replace ternary `if` expression with `or` operator "FURB113", # Use `lines.extend((" " * self.indentation + "}", "}"))` instead of repeatedly calling `lines.append()` "FURB177", # Prefer `Path.cwd()` over `Path().resolve()` for current-directory lookups diff --git a/tests/integration/test_infrahub_client.py b/tests/integration/test_infrahub_client.py index 82701252..6dd1fdc9 100644 --- a/tests/integration/test_infrahub_client.py +++ b/tests/integration/test_infrahub_client.py @@ -65,9 +65,9 @@ async def test_get_all(self, client: InfrahubClient, base_dataset) -> None: assert isinstance(nodes[0], InfrahubNode) assert [node.name.value for node in nodes] == ["Bella", "Luna"] - # TODO enable these tests for infrahub version containing this commit + # TODO enable these tests for Infrahub version containing this commit # https://github.com/opsmill/infrahub/commit/5a4d6860196b5bfb51fb8a124f33125f4a0b6753 - # when we support testing against multiple infrahub versions. + # when we support testing against multiple Infrahub versions. # async def test_get_all_no_order(self, client: InfrahubClient, base_dataset): # nodes = await client.all(kind=TESTING_CAT, order=Order(disable=True)) # assert len(nodes) == 2 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" diff --git a/tests/unit/sdk/test_topological_sort.py b/tests/unit/sdk/test_topological_sort.py index fdae9e0d..06f6063a 100644 --- a/tests/unit/sdk/test_topological_sort.py +++ b/tests/unit/sdk/test_topological_sort.py @@ -4,7 +4,7 @@ def test_topological_sort_empty() -> None: - assert topological_sort(dict()) == [] + assert topological_sort({}) == [] def test_topological_sort_with_cycle_raises_error() -> None: