diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17b58efd..cde9aa72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,7 +126,6 @@ jobs: - "3.10" - "3.11" - "3.12" - - "3.13" if: | always() && !cancelled() && !contains(needs.*.result, 'failure') && @@ -144,7 +143,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: "Setup environment" run: | - pipx install poetry==1.8.5 + pipx install poetry==1.8.5 --python python${{ matrix.python-version }} poetry config virtualenvs.create true --local pip install invoke toml codecov - name: "Install Package" @@ -174,7 +173,7 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # ------------------------------------------ Integration Tests ------------------------------------------ - integration-tests: + integration-tests-latest-infrahub: if: | always() && !cancelled() && !contains(needs.*.result, 'failure') && @@ -202,10 +201,91 @@ jobs: pip install invoke toml codecov - name: "Install Package" run: "poetry install --all-extras" + - name: "Set environment variables for python_testcontainers" + run: | + echo INFRAHUB_TESTING_IMAGE_VER=latest >> $GITHUB_ENV - name: "Integration Tests" - run: "poetry run pytest --cov infrahub_sdk tests/integration/" + run: | + poetry run pytest --cov infrahub_sdk tests/integration/ - name: "Upload coverage to Codecov" run: | codecov --flags integration-tests env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + # NOTE: Disabling this test for now because it's expected that we can't start the latest version of infrahub + # with the current shipping version of infrahub-testcontainers + # integration-tests-local-infrahub: + # if: | + # always() && !cancelled() && + # !contains(needs.*.result, 'failure') && + # !contains(needs.*.result, 'cancelled') && + # needs.files-changed.outputs.python == 'true' && + # (github.base_ref == 'stable' || github.base_ref == 'develop') + # needs: ["files-changed", "yaml-lint", "python-lint"] + # runs-on: + # group: "huge-runners" + # timeout-minutes: 30 + # steps: + # - name: "Check out repository code" + # uses: "actions/checkout@v4" + + # - 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" + # with: + # repository: "opsmill/infrahub" + # path: "infrahub-server" + # ref: ${{ github.base_ref }} + # submodules: true + + # - name: Set up Python + # uses: actions/setup-python@v5 + # with: + # python-version: "3.12" + + # - name: "Setup git credentials prior dev.build" + # run: | + # cd infrahub-server + # git config --global user.name 'Infrahub' + # git config --global user.email 'infrahub@opsmill.com' + # git config --global --add safe.directory '*' + # git config --global credential.usehttppath true + # git config --global credential.helper /usr/local/bin/infrahub-git-credential + + # - name: "Set environment variables prior dev.build" + # run: | + # echo "INFRAHUB_BUILD_NAME=infrahub-${{ runner.name }}" >> $GITHUB_ENV + # RUNNER_NAME=$(echo "${{ runner.name }}" | grep -o 'ghrunner[0-9]\+' | sed 's/ghrunner\([0-9]\+\)/ghrunner_\1/') + # echo "PYTEST_DEBUG_TEMPROOT=/var/lib/github/${RUNNER_NAME}/_temp" >> $GITHUB_ENV + # echo "INFRAHUB_IMAGE_VER=local-${{ runner.name }}-${{ github.sha }}" >> $GITHUB_ENV + # echo "INFRAHUB_TESTING_IMAGE_VER=local-${{ runner.name }}-${{ github.sha }}" >> $GITHUB_ENV + # echo "INFRAHUB_TESTING_DOCKER_IMAGE=opsmill/infrahub" >> $GITHUB_ENV + + # - name: "Build container" + # run: | + # cd infrahub-server + # inv dev.build + + # - name: "Setup environment" + # run: | + # pipx install poetry==1.8.5 + # poetry config virtualenvs.create true --local + # pip install invoke toml codecov + + # - name: "Install Package" + # run: "poetry install --all-extras" + + # - name: "Integration Tests" + # run: | + # echo "Running tests for version: $INFRAHUB_TESTING_IMAGE_VER" + # poetry run pytest --cov infrahub_sdk tests/integration/ + + # - name: "Upload coverage to Codecov" + # run: | + # codecov --flags integration-tests + # env: + # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/changelog/+add-logger-to-generator-class.md b/changelog/+add-logger-to-generator-class.md new file mode 100644 index 00000000..ab8be4d5 --- /dev/null +++ b/changelog/+add-logger-to-generator-class.md @@ -0,0 +1 @@ +Added logger to `InfrahubGenerator` class to allow users use built-in logging (`self.logger`) to show logging within Infrahub CI pipeline. diff --git a/changelog/+artifact_methods.changed.md b/changelog/+artifact_methods.changed.md new file mode 100644 index 00000000..6aa2b60b --- /dev/null +++ b/changelog/+artifact_methods.changed.md @@ -0,0 +1 @@ +Changes InfrahubNode `artifact_fetch` and `artifact_generate` methods to use the name of the artifact instead of the name of the artifact definition diff --git a/changelog/+move-read-file.housekeeping.md b/changelog/+move-read-file.housekeeping.md new file mode 100644 index 00000000..e9d818c5 --- /dev/null +++ b/changelog/+move-read-file.housekeeping.md @@ -0,0 +1 @@ +Move the function `read_file` from the ctl module to the SDK. \ No newline at end of file diff --git a/changelog/104.fixed.md b/changelog/104.fixed.md new file mode 100644 index 00000000..e6e64a72 --- /dev/null +++ b/changelog/104.fixed.md @@ -0,0 +1 @@ +- `protocols` CTL command properly gets default branch setting from environment variable \ No newline at end of file diff --git a/changelog/251.fixed.md b/changelog/251.fixed.md new file mode 100644 index 00000000..84e39163 --- /dev/null +++ b/changelog/251.fixed.md @@ -0,0 +1 @@ +Fix typing for Python 3.9 and remove support for Python 3.13 \ No newline at end of file diff --git a/changelog/264.fixed.md b/changelog/264.fixed.md new file mode 100644 index 00000000..da596b06 --- /dev/null +++ b/changelog/264.fixed.md @@ -0,0 +1 @@ +Remove default value "main" for branch parameter from all Infrahub CTL commands. \ No newline at end of file diff --git a/infrahub_sdk/ctl/_file.py b/infrahub_sdk/ctl/_file.py deleted file mode 100644 index 96629d72..00000000 --- a/infrahub_sdk/ctl/_file.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path - -from .exceptions import FileNotValidError - - -def read_file(file_name: Path) -> str: - if not file_name.is_file(): - raise FileNotValidError(name=str(file_name), message=f"{file_name} is not a valid file") - try: - with Path.open(file_name, encoding="utf-8") as fobj: - return fobj.read() - except UnicodeDecodeError as exc: - raise FileNotValidError(name=str(file_name), message=f"Unable to read {file_name} with utf-8 encoding") from exc diff --git a/infrahub_sdk/ctl/check.py b/infrahub_sdk/ctl/check.py index 3d5319dd..0626d884 100644 --- a/infrahub_sdk/ctl/check.py +++ b/infrahub_sdk/ctl/check.py @@ -5,7 +5,7 @@ from asyncio import run as aiorun from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import typer from rich.console import Console @@ -50,8 +50,8 @@ def run( format_json: bool, list_available: bool, variables: dict[str, str], - name: str | None = None, - branch: str | None = None, + name: Optional[str] = None, + branch: Optional[str] = None, ) -> None: """Locate and execute all checks under the defined path.""" diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index 3cb23a55..7a10f136 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -7,7 +7,7 @@ import platform import sys from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Optional import jinja2 import typer @@ -74,13 +74,13 @@ @catch_exception(console=console) def check( check_name: str = typer.Argument(default="", help="Name of the Python check"), - branch: str | None = None, + branch: Optional[str] = None, path: str = typer.Option(".", help="Root directory"), debug: bool = False, format_json: bool = False, _: str = CONFIG_PARAM, list_available: bool = typer.Option(False, "--list", help="Show available Python checks"), - variables: list[str] | None = typer.Argument( + variables: Optional[list[str]] = typer.Argument( None, help="Variables to pass along with the query. Format key=value key=value." ), ) -> None: @@ -102,12 +102,12 @@ def check( @catch_exception(console=console) async def generator( generator_name: str = typer.Argument(default="", help="Name of the Generator"), - branch: str | None = None, + branch: Optional[str] = None, path: str = typer.Option(".", help="Root directory"), debug: bool = False, _: str = CONFIG_PARAM, list_available: bool = typer.Option(False, "--list", help="Show available Generators"), - variables: list[str] | None = typer.Argument( + variables: Optional[list[str]] = typer.Argument( None, help="Variables to pass along with the query. Format key=value key=value." ), ) -> None: @@ -129,14 +129,14 @@ async def run( method: str = "run", debug: bool = False, _: str = CONFIG_PARAM, - branch: str = typer.Option("main", help="Branch on which to run the script."), - concurrent: int | None = typer.Option( + branch: str = typer.Option(None, help="Branch on which to run the script."), + concurrent: Optional[int] = typer.Option( None, help="Maximum number of requests to execute at the same time.", envvar="INFRAHUB_MAX_CONCURRENT_EXECUTION", ), timeout: int = typer.Option(60, help="Timeout in sec", envvar="INFRAHUB_TIMEOUT"), - variables: list[str] | None = typer.Argument( + variables: Optional[list[str]] = typer.Argument( None, help="Variables to pass along with the query. Format key=value key=value." ), ) -> None: @@ -259,7 +259,7 @@ def _run_transform( @catch_exception(console=console) def render( transform_name: str = typer.Argument(default="", help="Name of the Python transformation", show_default=False), - variables: list[str] | None = typer.Argument( + variables: Optional[list[str]] = typer.Argument( None, help="Variables to pass along with the query. Format key=value key=value." ), branch: str = typer.Option(None, help="Branch on which to render the transform."), @@ -309,7 +309,7 @@ def render( @catch_exception(console=console) def transform( transform_name: str = typer.Argument(default="", help="Name of the Python transformation", show_default=False), - variables: list[str] | None = typer.Argument( + variables: Optional[list[str]] = typer.Argument( None, help="Variables to pass along with the query. Format key=value key=value." ), branch: str = typer.Option(None, help="Branch on which to run the transformation"), @@ -383,6 +383,7 @@ def protocols( else: client = initialize_client_sync() + branch = branch or client.default_branch schema.update(client.schema.fetch(branch=branch)) code_generator = CodeGenerator(schema=schema) diff --git a/infrahub_sdk/ctl/exceptions.py b/infrahub_sdk/ctl/exceptions.py index 28436d5b..fc764f3b 100644 --- a/infrahub_sdk/ctl/exceptions.py +++ b/infrahub_sdk/ctl/exceptions.py @@ -6,9 +6,3 @@ class QueryNotFoundError(Error): def __init__(self, name: str, message: str = ""): self.message = message or f"The requested query '{name}' was not found." super().__init__(self.message) - - -class FileNotValidError(Error): - def __init__(self, name: str, message: str = ""): - self.message = message or f"Cannot parse '{name}' content." - super().__init__(self.message) diff --git a/infrahub_sdk/ctl/exporter.py b/infrahub_sdk/ctl/exporter.py index 50be3282..ae5e5d18 100644 --- a/infrahub_sdk/ctl/exporter.py +++ b/infrahub_sdk/ctl/exporter.py @@ -22,7 +22,7 @@ def dump( directory: Path = typer.Option(directory_name_with_timestamp, help="Directory path to store export"), quiet: bool = typer.Option(False, help="No console output"), _: str = CONFIG_PARAM, - branch: str = typer.Option("main", help="Branch from which to export"), + branch: str = typer.Option(None, help="Branch from which to export"), concurrent: int = typer.Option( 4, help="Maximum number of requests to execute at the same time.", diff --git a/infrahub_sdk/ctl/generator.py b/infrahub_sdk/ctl/generator.py index 689f17e3..22501568 100644 --- a/infrahub_sdk/ctl/generator.py +++ b/infrahub_sdk/ctl/generator.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import typer from rich.console import Console @@ -9,7 +9,7 @@ from ..ctl import config from ..ctl.client import initialize_client from ..ctl.repository import get_repository_config -from ..ctl.utils import execute_graphql_query, parse_cli_vars +from ..ctl.utils import execute_graphql_query, init_logging, parse_cli_vars from ..exceptions import ModuleImportError from ..node import InfrahubNode @@ -20,11 +20,12 @@ async def run( generator_name: str, path: str, # noqa: ARG001 - debug: bool, # noqa: ARG001 + debug: bool, list_available: bool, branch: str | None = None, - variables: list[str] | None = None, + variables: Optional[list[str]] = None, ) -> None: + init_logging(debug=debug) repository_config = get_repository_config(Path(config.INFRAHUB_REPO_CONFIG_FILE)) if list_available or not generator_name: @@ -34,7 +35,6 @@ async def run( generator_config = repository_config.get_generator_definition(name=generator_name) console = Console() - relative_path = str(generator_config.file_path.parent) if generator_config.file_path.parent != Path() else None try: diff --git a/infrahub_sdk/ctl/importer.py b/infrahub_sdk/ctl/importer.py index be087361..e9181c8b 100644 --- a/infrahub_sdk/ctl/importer.py +++ b/infrahub_sdk/ctl/importer.py @@ -2,6 +2,7 @@ from asyncio import run as aiorun from pathlib import Path +from typing import Optional import typer from rich.console import Console @@ -25,8 +26,8 @@ def load( ), quiet: bool = typer.Option(False, help="No console output"), _: str = CONFIG_PARAM, - branch: str = typer.Option("main", help="Branch from which to export"), - concurrent: int | None = typer.Option( + branch: str = typer.Option(None, help="Branch from which to export"), + concurrent: Optional[int] = typer.Option( None, help="Maximum number of requests to execute at the same time.", envvar="INFRAHUB_MAX_CONCURRENT_EXECUTION", diff --git a/infrahub_sdk/ctl/menu.py b/infrahub_sdk/ctl/menu.py index fe9798fa..560564ed 100644 --- a/infrahub_sdk/ctl/menu.py +++ b/infrahub_sdk/ctl/menu.py @@ -27,7 +27,7 @@ def callback() -> None: async def load( menus: list[Path], debug: bool = False, - branch: str = typer.Option("main", help="Branch on which to load the menu."), + branch: str = typer.Option(None, help="Branch on which to load the menu."), _: str = CONFIG_PARAM, ) -> None: """Load one or multiple menu files into Infrahub.""" diff --git a/infrahub_sdk/ctl/object.py b/infrahub_sdk/ctl/object.py index 5f8e71cf..b589fcc2 100644 --- a/infrahub_sdk/ctl/object.py +++ b/infrahub_sdk/ctl/object.py @@ -27,7 +27,7 @@ def callback() -> None: async def load( paths: list[Path], debug: bool = False, - branch: str = typer.Option("main", help="Branch on which to load the objects."), + branch: str = typer.Option(None, help="Branch on which to load the objects."), _: str = CONFIG_PARAM, ) -> None: """Load one or multiple objects files into Infrahub.""" diff --git a/infrahub_sdk/ctl/repository.py b/infrahub_sdk/ctl/repository.py index e57ee6bf..1992a537 100644 --- a/infrahub_sdk/ctl/repository.py +++ b/infrahub_sdk/ctl/repository.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import Optional import typer import yaml @@ -8,15 +9,14 @@ from rich.console import Console from rich.table import Table -from infrahub_sdk.ctl.client import initialize_client - from ..async_typer import AsyncTyper -from ..ctl.exceptions import FileNotValidError -from ..ctl.utils import init_logging +from ..exceptions import FileNotValidError from ..graphql import Mutation, Query from ..schema.repository import InfrahubRepositoryConfig -from ._file import read_file +from ..utils import read_file +from .client import initialize_client from .parameters import CONFIG_PARAM +from .utils import init_logging app = AsyncTyper() console = Console() @@ -69,12 +69,11 @@ async def add( name: str, location: str, description: str = "", - username: str | None = None, + username: Optional[str] = None, password: str = "", - commit: str = "", + ref: str = "", read_only: bool = False, debug: bool = False, - branch: str = typer.Option("main", help="Branch on which to add the repository."), _: str = CONFIG_PARAM, ) -> None: """Add a new repository.""" @@ -86,15 +85,24 @@ async def add( "name": {"value": name}, "location": {"value": location}, "description": {"value": description}, - "commit": {"value": commit}, }, } + if read_only: + input_data["data"]["ref"] = {"value": ref} + else: + input_data["data"]["default_branch"] = {"value": ref} client = initialize_client() - credential = await client.create(kind="CorePasswordCredential", name=name, username=username, password=password) - await credential.save(allow_upsert=True) - input_data["data"]["credential"] = {"id": credential.id} + if username or password: + credential = await client.create( + kind="CorePasswordCredential", + name=name, + username=username, + password=password, + ) + await credential.save(allow_upsert=True) + input_data["data"]["credential"] = {"id": credential.id} query = Mutation( mutation="CoreReadOnlyRepositoryCreate" if read_only else "CoreRepositoryCreate", @@ -102,18 +110,18 @@ async def add( query={"ok": None}, ) - await client.execute_graphql(query=query.render(), branch_name=branch, tracker="mutation-repository-create") + await client.execute_graphql(query=query.render(), tracker="mutation-repository-create") @app.command() async def list( - branch: str | None = None, + branch: Optional[str] = typer.Option(None, help="Branch on which to list repositories."), debug: bool = False, _: str = CONFIG_PARAM, ) -> None: init_logging(debug=debug) - client = initialize_client(branch=branch) + client = initialize_client() repo_status_query = { "CoreGenericRepository": { diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 3c9557c5..6e9ff994 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -108,7 +108,7 @@ def get_node(schemas_data: list[dict], schema_index: int, node_index: int) -> di async def load( schemas: list[Path], debug: bool = False, - branch: str = typer.Option("main", help="Branch on which to load the schema."), + branch: str = typer.Option(None, help="Branch on which to load the schema."), wait: int = typer.Option(0, help="Time in seconds to wait until the schema has converged across all workers"), _: str = CONFIG_PARAM, ) -> None: @@ -159,7 +159,7 @@ async def load( async def check( schemas: list[Path], debug: bool = False, - branch: str = typer.Option("main", help="Branch on which to check the schema."), + branch: str = typer.Option(None, help="Branch on which to check the schema."), _: str = CONFIG_PARAM, ) -> None: """Check if schema files are valid and what would be the impact of loading them with Infrahub.""" diff --git a/infrahub_sdk/ctl/utils.py b/infrahub_sdk/ctl/utils.py index 5c0b069e..4c627119 100644 --- a/infrahub_sdk/ctl/utils.py +++ b/infrahub_sdk/ctl/utils.py @@ -6,7 +6,7 @@ from collections.abc import Coroutine from functools import wraps from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, NoReturn, TypeVar +from typing import TYPE_CHECKING, Any, Callable, NoReturn, Optional, TypeVar import pendulum import typer @@ -17,10 +17,10 @@ from rich.logging import RichHandler from rich.markup import escape -from ..ctl.exceptions import FileNotValidError, QueryNotFoundError from ..exceptions import ( AuthenticationError, Error, + FileNotValidError, GraphQLError, NodeNotFoundError, ResourceNotDefinedError, @@ -30,6 +30,7 @@ ) from ..yaml import YamlFile from .client import initialize_client_sync +from .exceptions import QueryNotFoundError if TYPE_CHECKING: from ..schema.repository import InfrahubRepositoryConfig @@ -144,7 +145,7 @@ def print_graphql_errors(console: Console, errors: list) -> None: console.print(f"[red]{escape(str(error))}") -def parse_cli_vars(variables: list[str] | None) -> dict[str, str]: +def parse_cli_vars(variables: Optional[list[str]]) -> dict[str, str]: if not variables: return {} diff --git a/infrahub_sdk/ctl/validate.py b/infrahub_sdk/ctl/validate.py index d93260b2..99318239 100644 --- a/infrahub_sdk/ctl/validate.py +++ b/infrahub_sdk/ctl/validate.py @@ -2,6 +2,7 @@ import sys from pathlib import Path +from typing import Optional import typer import ujson @@ -57,7 +58,7 @@ async def validate_schema(schema: Path, _: str = CONFIG_PARAM) -> None: @catch_exception(console=console) def validate_graphql( query: str, - variables: list[str] | None = typer.Argument( + variables: Optional[list[str]] = typer.Argument( None, help="Variables to pass along with the query. Format key=value key=value." ), debug: bool = typer.Option(False, help="Display more troubleshooting information."), diff --git a/infrahub_sdk/exceptions.py b/infrahub_sdk/exceptions.py index f8eff9cc..b3120441 100644 --- a/infrahub_sdk/exceptions.py +++ b/infrahub_sdk/exceptions.py @@ -131,3 +131,9 @@ class UninitializedError(Error): class InvalidResponseError(Error): """Raised when an object requires an initialization step before use""" + + +class FileNotValidError(Error): + def __init__(self, name: str, message: str = ""): + self.message = message or f"Cannot parse '{name}' content." + super().__init__(self.message) diff --git a/infrahub_sdk/generator.py b/infrahub_sdk/generator.py index e6448b3e..3ba6c767 100644 --- a/infrahub_sdk/generator.py +++ b/infrahub_sdk/generator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os from abc import abstractmethod from typing import TYPE_CHECKING @@ -27,6 +28,7 @@ def __init__( generator_instance: str = "", params: dict | None = None, convert_query_response: bool = False, + logger: logging.Logger | None = None, ) -> None: self.query = query self.branch = branch @@ -41,6 +43,7 @@ def __init__( self._related_nodes: list[InfrahubNode] = [] self.infrahub_node = infrahub_node self.convert_query_response = convert_query_response + self.logger = logger if logger else logging.getLogger("infrahub.tasks") @property def store(self) -> NodeStore: diff --git a/infrahub_sdk/node.py b/infrahub_sdk/node.py index 174cc06a..5be89a01 100644 --- a/infrahub_sdk/node.py +++ b/infrahub_sdk/node.py @@ -1118,14 +1118,14 @@ async def generate(self, nodes: list[str] | None = None) -> None: async def artifact_generate(self, name: str) -> None: self._validate_artifact_support(ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE) - artifact = await self._client.get(kind="CoreArtifact", definition__name__value=name, object__ids=[self.id]) + artifact = await self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id]) await artifact.definition.fetch() # type: ignore[attr-defined] await artifact.definition.peer.generate([artifact.id]) # type: ignore[attr-defined] async def artifact_fetch(self, name: str) -> str | dict[str, Any]: self._validate_artifact_support(ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE) - artifact = await self._client.get(kind="CoreArtifact", definition__name__value=name, object__ids=[self.id]) + artifact = await self._client.get(kind="CoreArtifact", name__value=name, object__ids=[self.id]) content = await self._client.object_store.get(identifier=artifact.storage_id.value) # type: ignore[attr-defined] return content diff --git a/infrahub_sdk/testing/docker.py b/infrahub_sdk/testing/docker.py index 4beccb16..e5865678 100644 --- a/infrahub_sdk/testing/docker.py +++ b/infrahub_sdk/testing/docker.py @@ -1,10 +1,40 @@ +from __future__ import annotations + +import os + import pytest from infrahub_testcontainers.helpers import TestInfrahubDocker +from packaging.version import InvalidVersion, Version from .. import Config, InfrahubClient, InfrahubClientSync +INFRAHUB_VERSION = os.getenv("INFRAHUB_TESTING_IMAGE_VER", "latest") + + +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. + """ + try: + version = Version(INFRAHUB_VERSION) + except InvalidVersion: + # We would typically end up here for development purpose while running a CI test against + # unreleased versions of infrahub, like `stable` or `develop` branch. + # For now, we consider this means we are testing against the most recent version of infrahub, + # so we skip if the test should not be ran against a maximum version. + return max_infrahub_version is None + + if min_infrahub_version is not None and version < Version(min_infrahub_version): + return True + + return max_infrahub_version is not None and version > Version(max_infrahub_version) + class TestInfrahubDockerClient(TestInfrahubDocker): + @pytest.fixture(scope="class") + def infrahub_version(self) -> str: + return INFRAHUB_VERSION + @pytest.fixture(scope="class") def client(self, infrahub_port: int) -> InfrahubClient: return InfrahubClient( diff --git a/infrahub_sdk/utils.py b/infrahub_sdk/utils.py index aa065e27..2627a062 100644 --- a/infrahub_sdk/utils.py +++ b/infrahub_sdk/utils.py @@ -17,7 +17,7 @@ from infrahub_sdk.repository import GitRepoManager -from .exceptions import JsonDecodeError +from .exceptions import FileNotValidError, JsonDecodeError if TYPE_CHECKING: from graphql import GraphQLResolveInfo @@ -342,6 +342,16 @@ def write_to_file(path: Path, value: Any) -> bool: return written is not None +def read_file(file_name: Path) -> str: + if not file_name.is_file(): + raise FileNotValidError(name=str(file_name), message=f"{file_name} is not a valid file") + try: + with Path.open(file_name, encoding="utf-8") as fobj: + return fobj.read() + except UnicodeDecodeError as exc: + raise FileNotValidError(name=str(file_name), message=f"Unable to read {file_name} with utf-8 encoding") from exc + + def get_user_permissions(data: list[dict]) -> dict: groups = {} for group in data: diff --git a/infrahub_sdk/yaml.py b/infrahub_sdk/yaml.py index 5cfd5bce..69d973cf 100644 --- a/infrahub_sdk/yaml.py +++ b/infrahub_sdk/yaml.py @@ -8,9 +8,8 @@ from pydantic import BaseModel, Field from typing_extensions import Self -from .ctl._file import read_file -from .ctl.exceptions import FileNotValidError -from .utils import find_files +from .exceptions import FileNotValidError +from .utils import find_files, read_file class InfrahubFileApiVersion(str, Enum): diff --git a/poetry.lock b/poetry.lock index fc23269b..3b60de6a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1301,10 +1301,7 @@ files = [ [package.dependencies] annotated-types = ">=0.6.0" pydantic-core = "2.23.3" -typing-extensions = [ - {version = ">=4.6.1", markers = "python_version < \"3.13\""}, - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, -] +typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""} [package.extras] email = ["email-validator (>=2.0.0)"] @@ -2237,5 +2234,5 @@ tests = ["Jinja2", "pytest", "pyyaml", "rich"] [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "8df86b45a0478a859bbe784b5d9027b2f033da597a52815f6006fd81fd424fdf" +python-versions = "^3.9, < 3.13" +content-hash = "7cf3b9fd5e6ad627c30cb1660ef9c45d5b6a264150d064bc47cc7ae7a2be4030" diff --git a/pyproject.toml b/pyproject.toml index 86314d7b..9fbab36f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,3 @@ -[project] -name = "infrahub-sdk" -version = "1.5.0" -requires-python = ">=3.9" - [tool.poetry] name = "infrahub-sdk" version = "1.7.1" @@ -21,11 +16,10 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", ] [tool.poetry.dependencies] -python = "^3.9" +python = "^3.9, < 3.13" pydantic = ">=2.0.0,!=2.0.1,!=2.1.0,<3.0.0" pydantic-settings = ">=2.0" graphql-core = ">=3.1,<3.3" @@ -253,6 +247,10 @@ max-complexity = 17 "ANN401", # Dynamically typed expressions (typing.Any) are disallowed ] +"infrahub_sdk/ctl/**/*.py" = [ + "UP007", # Use `X | Y` for type annotations | Required for Typer until we can drop the support for Python 3.9 +] + "infrahub_sdk/client.py" = [ ################################################################################################## # Review and change the below later # diff --git a/tests/integration/test_infrahub_client.py b/tests/integration/test_infrahub_client.py index 3c607518..817205ec 100644 --- a/tests/integration/test_infrahub_client.py +++ b/tests/integration/test_infrahub_client.py @@ -16,10 +16,6 @@ class TestInfrahubNode(TestInfrahubDockerClient, SchemaAnimal): - @pytest.fixture(scope="class") - def infrahub_version(self) -> str: - return "1.1.0" - @pytest.fixture(scope="class") async def base_dataset( self, diff --git a/tests/integration/test_node.py b/tests/integration/test_node.py index b5ee1566..ea218284 100644 --- a/tests/integration/test_node.py +++ b/tests/integration/test_node.py @@ -9,10 +9,6 @@ class TestInfrahubNode(TestInfrahubDockerClient, SchemaCarPerson): - @pytest.fixture(scope="class") - def infrahub_version(self) -> str: - return "1.1.0" - @pytest.fixture(scope="class") async def initial_schema(self, default_branch: str, client: InfrahubClient, schema_base: SchemaRoot) -> None: await client.schema.wait_until_converged(branch=default_branch) diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index 359f1e68..76b14f58 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -import pytest - from infrahub_sdk.testing.docker import TestInfrahubDockerClient from infrahub_sdk.testing.repository import GitRepo from infrahub_sdk.utils import get_fixtures_dir @@ -13,10 +11,6 @@ class TestInfrahubRepository(TestInfrahubDockerClient): - @pytest.fixture(scope="class") - def infrahub_version(self) -> str: - return "1.1.0" - async def test_add_repository(self, client: InfrahubClient, remote_repos_dir): src_directory = get_fixtures_dir() / "integration/mock_repo" repo = GitRepo(name="mock_repo", src_directory=src_directory, dst_directory=remote_repos_dir) diff --git a/tests/unit/ctl/test_cli.py b/tests/unit/ctl/test_cli.py index d73a7105..30d63e92 100644 --- a/tests/unit/ctl/test_cli.py +++ b/tests/unit/ctl/test_cli.py @@ -1,16 +1,10 @@ -import sys - -import pytest from typer.testing import CliRunner from infrahub_sdk.ctl.cli import app runner = CliRunner() -requires_python_310 = pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10 or higher") - -@requires_python_310 def test_main_app(): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 @@ -29,14 +23,12 @@ def test_validate_all_groups_have_names(): assert group.name -@requires_python_310 def test_version_command(): result = runner.invoke(app, ["version"]) assert result.exit_code == 0 assert "Python SDK: v" in result.stdout -@requires_python_310 def test_info_command_success(mock_query_infrahub_version, mock_query_infrahub_user): result = runner.invoke(app, ["info"]) assert result.exit_code == 0 @@ -44,14 +36,12 @@ def test_info_command_success(mock_query_infrahub_version, mock_query_infrahub_u assert expected in result.stdout, f"'{expected}' not found in info command output" -@requires_python_310 def test_info_command_failure(): result = runner.invoke(app, ["info"]) assert result.exit_code == 0 assert "Connection Error" in result.stdout -@requires_python_310 def test_info_detail_command_success(mock_query_infrahub_version, mock_query_infrahub_user): result = runner.invoke(app, ["info", "--detail"]) assert result.exit_code == 0 @@ -65,7 +55,6 @@ def test_info_detail_command_success(mock_query_infrahub_version, mock_query_inf assert expected in result.stdout, f"'{expected}' not found in detailed info command output" -@requires_python_310 def test_info_detail_command_failure(): result = runner.invoke(app, ["info", "--detail"]) assert result.exit_code == 0 diff --git a/tests/unit/ctl/test_repository_app.py b/tests/unit/ctl/test_repository_app.py index a7cd70f0..ba0a7721 100644 --- a/tests/unit/ctl/test_repository_app.py +++ b/tests/unit/ctl/test_repository_app.py @@ -1,6 +1,5 @@ """Integration tests for infrahubctl commands.""" -import sys from unittest import mock import pytest @@ -13,8 +12,6 @@ runner = CliRunner() -requires_python_310 = pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10 or higher") - @pytest.fixture def mock_client() -> mock.Mock: @@ -29,7 +26,53 @@ def mock_client() -> mock.Mock: class TestInfrahubctlRepository: """Groups the 'infrahubctl repository' test cases.""" - @requires_python_310 + @mock.patch("infrahub_sdk.ctl.repository.initialize_client") + def test_repo_no_username_or_password(self, mock_init_client, mock_client) -> None: + """Case allow no username to be passed in and set it as None rather than blank string that fails.""" + mock_cred = mock.AsyncMock() + mock_cred.id = "1234" + mock_client.create.return_value = mock_cred + + mock_init_client.return_value = mock_client + output = runner.invoke( + app, + [ + "repository", + "add", + "Gitlab", + "https://gitlab.com/opsmill/example-repo.git", + ], + ) + assert output.exit_code == 0 + mock_client.create.assert_not_called() + mock_cred.save.assert_not_called() + mock_client.execute_graphql.assert_called_once() + mock_client.execute_graphql.assert_called_with( + query=""" +mutation { + CoreRepositoryCreate( + data: { + name: { + value: "Gitlab" + } + location: { + value: "https://gitlab.com/opsmill/example-repo.git" + } + description: { + value: "" + } + default_branch: { + value: "" + } + } + ){ + ok + } +} +""", + tracker="mutation-repository-create", + ) + @mock.patch("infrahub_sdk.ctl.repository.initialize_client") def test_repo_no_username(self, mock_init_client, mock_client) -> None: """Case allow no username to be passed in and set it as None rather than blank string that fails.""" @@ -74,7 +117,7 @@ def test_repo_no_username(self, mock_init_client, mock_client) -> None: description: { value: "" } - commit: { + default_branch: { value: "" } credential: { @@ -86,11 +129,9 @@ def test_repo_no_username(self, mock_init_client, mock_client) -> None: } } """, - branch_name="main", tracker="mutation-repository-create", ) - @requires_python_310 @mock.patch("infrahub_sdk.ctl.repository.initialize_client") def test_repo_username(self, mock_init_client, mock_client) -> None: """Case allow no username to be passed in and set it as None rather than blank string that fails.""" @@ -137,7 +178,7 @@ def test_repo_username(self, mock_init_client, mock_client) -> None: description: { value: "" } - commit: { + default_branch: { value: "" } credential: { @@ -149,11 +190,9 @@ def test_repo_username(self, mock_init_client, mock_client) -> None: } } """, - branch_name="main", tracker="mutation-repository-create", ) - @requires_python_310 @mock.patch("infrahub_sdk.ctl.repository.initialize_client") def test_repo_readonly_true(self, mock_init_client, mock_client) -> None: """Case allow no username to be passed in and set it as None rather than blank string that fails.""" @@ -168,7 +207,7 @@ def test_repo_readonly_true(self, mock_init_client, mock_client) -> None: "repository", "add", "Gitlab", - "https://gitlab.com/FragmentedPacket/nautobot-plugin-ansible-filters.git", + "https://gitlab.com/opsmill/example-repo.git", "--password", "mySup3rSecureP@ssw0rd", "--read-only", @@ -194,12 +233,12 @@ def test_repo_readonly_true(self, mock_init_client, mock_client) -> None: value: "Gitlab" } location: { - value: "https://gitlab.com/FragmentedPacket/nautobot-plugin-ansible-filters.git" + value: "https://gitlab.com/opsmill/example-repo.git" } description: { value: "" } - commit: { + ref: { value: "" } credential: { @@ -211,11 +250,9 @@ def test_repo_readonly_true(self, mock_init_client, mock_client) -> None: } } """, - branch_name="main", tracker="mutation-repository-create", ) - @requires_python_310 @mock.patch("infrahub_sdk.ctl.repository.initialize_client") def test_repo_description_commit_branch(self, mock_init_client, mock_client) -> None: """Case allow no username to be passed in and set it as None rather than blank string that fails.""" @@ -230,17 +267,15 @@ def test_repo_description_commit_branch(self, mock_init_client, mock_client) -> "repository", "add", "Gitlab", - "https://gitlab.com/FragmentedPacket/nautobot-plugin-ansible-filters.git", + "https://gitlab.com/opsmill/example-repo.git", "--password", "mySup3rSecureP@ssw0rd", "--username", "opsmill", "--description", "This is a test description", - "--commit", - "myHashCommit", - "--branch", - "develop", + "--ref", + "my-custom-branch", ], ) assert output.exit_code == 0 @@ -263,13 +298,13 @@ def test_repo_description_commit_branch(self, mock_init_client, mock_client) -> value: "Gitlab" } location: { - value: "https://gitlab.com/FragmentedPacket/nautobot-plugin-ansible-filters.git" + value: "https://gitlab.com/opsmill/example-repo.git" } description: { value: "This is a test description" } - commit: { - value: "myHashCommit" + default_branch: { + value: "my-custom-branch" } credential: { id: "1234" @@ -280,11 +315,10 @@ def test_repo_description_commit_branch(self, mock_init_client, mock_client) -> } } """, - branch_name="develop", tracker="mutation-repository-create", ) def test_repo_list(self, mock_repositories_list) -> None: - result = runner.invoke(app, ["repository", "list", "--branch", "main"]) + 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") diff --git a/tests/unit/ctl/test_validate_app.py b/tests/unit/ctl/test_validate_app.py index 3264b087..43d41ddb 100644 --- a/tests/unit/ctl/test_validate_app.py +++ b/tests/unit/ctl/test_validate_app.py @@ -1,5 +1,3 @@ -import sys - import pytest from typer.testing import CliRunner @@ -9,10 +7,7 @@ runner = CliRunner() -requires_python_310 = pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10 or higher") - -@requires_python_310 def test_validate_schema_valid(): fixture_file = get_fixtures_dir() / "models" / "valid_model_01.json" @@ -21,7 +16,6 @@ def test_validate_schema_valid(): assert "Schema is valid" in result.stdout -@requires_python_310 def test_validate_schema_empty(): fixture_file = get_fixtures_dir() / "models" / "empty.json" @@ -30,7 +24,6 @@ def test_validate_schema_empty(): assert "Empty YAML/JSON file" in remove_ansi_color(result.stdout) -@requires_python_310 def test_validate_schema_non_valid(): fixture_file = get_fixtures_dir() / "models" / "non_valid_model_01.json"