Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions infrahub_sdk/spec/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from ..exceptions import ObjectValidationError, ValidationError
from ..schema import GenericSchemaAPI, RelationshipKind, RelationshipSchema
from ..utils import is_valid_uuid
from ..yaml import InfrahubFile, InfrahubFileKind
from .models import InfrahubObjectParameters
from .processors.factory import DataProcessorFactory
Expand All @@ -33,6 +34,36 @@ def validate_list_of_objects(value: list[Any]) -> bool:
return all(isinstance(item, dict) for item in value)


def normalize_hfid_reference(value: str | list[str]) -> str | list[str]:
"""Normalize a reference value to HFID format.

Only call this function when the peer schema has human_friendly_id defined.

Args:
value: Either a string (ID or single-component HFID) or a list of strings (multi-component HFID).

Returns:
- If value is already a list: returns it unchanged as list[str]
- If value is a valid UUID string: returns it unchanged as str (will be treated as an ID)
- If value is a non-UUID string: wraps it in a list as list[str] (single-component HFID)
"""
if isinstance(value, list):
return value
if is_valid_uuid(value):
return value
return [value]


def normalize_hfid_references(values: list[str | list[str]]) -> list[str | list[str]]:
"""Normalize a list of reference values to HFID format.

Only call this function when the peer schema has human_friendly_id defined.

Each string that is not a valid UUID will be wrapped in a list to treat it as a single-component HFID.
"""
return [normalize_hfid_reference(v) for v in values]


class RelationshipDataFormat(str, Enum):
UNKNOWN = "unknown"

Expand All @@ -51,6 +82,12 @@ class RelationshipInfo(BaseModel):
peer_rel: RelationshipSchema | None = None
reason_relationship_not_valid: str | None = None
format: RelationshipDataFormat = RelationshipDataFormat.UNKNOWN
peer_human_friendly_id: list[str] | None = None

@property
def peer_has_hfid(self) -> bool:
"""Indicate if the peer schema has a human-friendly ID defined."""
return bool(self.peer_human_friendly_id)

@property
def is_bidirectional(self) -> bool:
Expand Down Expand Up @@ -119,6 +156,7 @@ async def get_relationship_info(
info.peer_kind = value["kind"]

peer_schema = await client.schema.get(kind=info.peer_kind, branch=branch)
info.peer_human_friendly_id = peer_schema.human_friendly_id

try:
info.peer_rel = peer_schema.get_matching_relationship(
Expand Down Expand Up @@ -444,10 +482,12 @@ async def create_node(
# - if the relationship is bidirectional and is mandatory on the other side, then we need to create this object First
# - if the relationship is bidirectional and is not mandatory on the other side, then we need should create the related object First
# - if the relationship is not bidirectional, then we need to create the related object First
if rel_info.is_reference and isinstance(value, list):
clean_data[key] = value
elif rel_info.format == RelationshipDataFormat.ONE_REF and isinstance(value, str):
clean_data[key] = [value]
if rel_info.format == RelationshipDataFormat.MANY_REF and isinstance(value, list):
# Cardinality-many reference: normalize string HFIDs to list format if peer has HFID defined
clean_data[key] = normalize_hfid_references(value) if rel_info.peer_has_hfid else value
elif rel_info.format == RelationshipDataFormat.ONE_REF:
# Cardinality-one reference: normalize string to HFID list if peer has HFID, else pass as-is
clean_data[key] = normalize_hfid_reference(value) if rel_info.peer_has_hfid else value
elif not rel_info.is_reference and rel_info.is_bidirectional and rel_info.is_mandatory:
remaining_rels.append(key)
elif not rel_info.is_reference and not rel_info.is_mandatory:
Expand Down
19 changes: 0 additions & 19 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -328,25 +328,6 @@ max-complexity = 17
"ARG002", # Unused method argument
]

##################################################################################################
# ANN001 ignores - broken down for incremental cleanup #
# Remove each section as type annotations are added to that directory #
##################################################################################################

# tests/unit/sdk/ - 478 errors total
"tests/unit/sdk/test_node.py" = ["ANN001"] # 206 errors
"tests/unit/sdk/test_client.py" = ["ANN001"] # 85 errors
"tests/unit/sdk/test_schema.py" = ["ANN001"] # 36 errors
"tests/unit/sdk/test_artifact.py" = ["ANN001"] # 27 errors
"tests/unit/sdk/test_hierarchical_nodes.py" = ["ANN001"] # 26 errors
"tests/unit/sdk/test_task.py" = ["ANN001"] # 21 errors
"tests/unit/sdk/test_store.py" = ["ANN001"] # 12 errors
"tests/unit/sdk/spec/test_object.py" = ["ANN001"] # 11 errors
"tests/unit/sdk/conftest.py" = ["ANN001"] # 11 errors
"tests/unit/sdk/test_diff_summary.py" = ["ANN001"] # 9 errors
"tests/unit/sdk/test_object_store.py" = ["ANN001"] # 7 errors
"tests/unit/sdk/graphql/test_query.py" = ["ANN001"] # 7 errors

# tests/integration/
"tests/integration/test_infrahub_client.py" = ["PLR0904"]
"tests/integration/test_infrahub_client_sync.py" = ["PLR0904"]
Expand Down
5 changes: 4 additions & 1 deletion tests/fixtures/schema_01.json
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,10 @@
"label": null,
"inherit_from": [],
"branch": "aware",
"default_filter": "name__value"
"default_filter": "name__value",
"human_friendly_id": [
"name__value"
]
},
{
"name": "Location",
Expand Down
28 changes: 17 additions & 11 deletions tests/unit/sdk/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def replace_annotation(annotation: str) -> str:

@pytest.fixture
def replace_async_parameter_annotations(
replace_async_return_annotation,
replace_async_return_annotation: Callable[[str], str],
) -> Callable[[Mapping[str, Parameter]], list[tuple[str, str]]]:
"""Allows for comparison between sync and async parameter annotations."""

Expand All @@ -130,7 +130,7 @@ def replace_annotation(annotation: str) -> str:

@pytest.fixture
def replace_sync_parameter_annotations(
replace_sync_return_annotation,
replace_sync_return_annotation: Callable[[str], str],
) -> Callable[[Mapping[str, Parameter]], list[tuple[str, str]]]:
"""Allows for comparison between sync and async parameter annotations."""

Expand Down Expand Up @@ -1501,7 +1501,7 @@ async def mock_repositories_query_no_pagination(httpx_mock: HTTPXMock) -> HTTPXM

@pytest.fixture
async def mock_query_repository_all_01_no_pagination(
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01: HTTPXMock
) -> HTTPXMock:
response = {
"data": {
Expand Down Expand Up @@ -1601,7 +1601,7 @@ async def mock_repositories_query(httpx_mock: HTTPXMock) -> HTTPXMock:

@pytest.fixture
async def mock_query_repository_page1_1(
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01: HTTPXMock
) -> HTTPXMock:
response = {
"data": {
Expand Down Expand Up @@ -1641,7 +1641,9 @@ async def mock_query_repository_page1_1(


@pytest.fixture
async def mock_query_corenode_page1_1(httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_02) -> HTTPXMock:
async def mock_query_corenode_page1_1(
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_02: HTTPXMock
) -> HTTPXMock:
response = {
"data": {
"CoreNode": {
Expand Down Expand Up @@ -1676,14 +1678,16 @@ async def mock_query_corenode_page1_1(httpx_mock: HTTPXMock, client: InfrahubCli


@pytest.fixture
async def mock_query_repository_count(httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01) -> HTTPXMock:
async def mock_query_repository_count(
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01: HTTPXMock
) -> HTTPXMock:
httpx_mock.add_response(method="POST", json={"data": {"CoreRepository": {"count": 5}}}, is_reusable=True)
return httpx_mock


@pytest.fixture
async def mock_query_repository_page1_empty(
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01: HTTPXMock
) -> HTTPXMock:
response: dict = {"data": {"CoreRepository": {"edges": []}}}

Expand All @@ -1698,7 +1702,7 @@ async def mock_query_repository_page1_empty(

@pytest.fixture
async def mock_query_repository_page1_2(
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01: HTTPXMock
) -> HTTPXMock:
response = {
"data": {
Expand Down Expand Up @@ -1748,7 +1752,7 @@ async def mock_query_repository_page1_2(

@pytest.fixture
async def mock_query_repository_page2_2(
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01: HTTPXMock
) -> HTTPXMock:
response = {
"data": {
Expand Down Expand Up @@ -2512,15 +2516,17 @@ async def mock_schema_query_ipam(httpx_mock: HTTPXMock) -> HTTPXMock:

@pytest.fixture
async def mock_query_location_batch_count(
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01: HTTPXMock
) -> HTTPXMock:
response = {"data": {"BuiltinLocation": {"count": 30}}}
httpx_mock.add_response(method="POST", url="http://mock/graphql/main", json=response, is_reusable=True)
return httpx_mock


@pytest.fixture
async def mock_query_location_batch(httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01) -> HTTPXMock:
async def mock_query_location_batch(
httpx_mock: HTTPXMock, client: InfrahubClient, mock_schema_query_01: HTTPXMock
) -> HTTPXMock:
for i in range(1, 11):
filename = get_fixtures_dir() / "batch" / f"mock_query_location_page{i}.json"
response_text = filename.read_text(encoding="UTF-8")
Expand Down
15 changes: 8 additions & 7 deletions tests/unit/sdk/graphql/test_query.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from enum import Enum
from typing import Any

from infrahub_sdk.graphql.query import Mutation, Query

Expand All @@ -13,7 +14,7 @@ class MyIntEnum(int, Enum):
VALUE2 = 24


def test_query_rendering_no_vars(query_data_no_filter) -> None:
def test_query_rendering_no_vars(query_data_no_filter: dict[str, Any]) -> None:
query = Query(query=query_data_no_filter)

expected_query = """
Expand All @@ -37,7 +38,7 @@ def test_query_rendering_no_vars(query_data_no_filter) -> None:
assert query.render() == expected_query


def test_query_rendering_empty_filter(query_data_empty_filter) -> None:
def test_query_rendering_empty_filter(query_data_empty_filter: dict[str, Any]) -> None:
query = Query(query=query_data_empty_filter)

expected_query = """
Expand All @@ -61,7 +62,7 @@ def test_query_rendering_empty_filter(query_data_empty_filter) -> None:
assert query.render() == expected_query


def test_query_rendering_with_filters_and_vars(query_data_filters_01) -> None:
def test_query_rendering_with_filters_and_vars(query_data_filters_01: dict[str, Any]) -> None:
query = Query(query=query_data_filters_01, variables={"name": str, "enabled": bool})

expected_query = """
Expand All @@ -85,7 +86,7 @@ def test_query_rendering_with_filters_and_vars(query_data_filters_01) -> None:
assert query.render() == expected_query


def test_query_rendering_with_filters(query_data_filters_02) -> None:
def test_query_rendering_with_filters(query_data_filters_02: dict[str, Any]) -> None:
query = Query(query=query_data_filters_02)

expected_query = """
Expand All @@ -105,7 +106,7 @@ def test_query_rendering_with_filters(query_data_filters_02) -> None:
assert query.render() == expected_query


def test_query_rendering_with_filters_convert_enum(query_data_filters_02) -> None:
def test_query_rendering_with_filters_convert_enum(query_data_filters_02: dict[str, Any]) -> None:
query = Query(query=query_data_filters_02)

expected_query = """
Expand All @@ -125,7 +126,7 @@ def test_query_rendering_with_filters_convert_enum(query_data_filters_02) -> Non
assert query.render(convert_enum=True) == expected_query


def test_mutation_rendering_no_vars(input_data_01) -> None:
def test_mutation_rendering_no_vars(input_data_01: dict[str, Any]) -> None:
query_data = {"ok": None, "object": {"id": None}}

query = Mutation(mutation="myobject_create", query=query_data, input_data=input_data_01)
Expand Down Expand Up @@ -245,7 +246,7 @@ def test_mutation_rendering_enum() -> None:
assert query.render() == expected_query


def test_mutation_rendering_with_vars(input_data_01) -> None:
def test_mutation_rendering_with_vars(input_data_01: dict[str, Any]) -> None:
query_data = {"ok": None, "object": {"id": None}}
variables = {"name": str, "description": str, "number": int}
query = Mutation(
Expand Down
Loading