Skip to content
Closed
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Infrahub Python SDK - async/sync client for Infrahub infrastructure management.
```bash
uv sync --all-groups --all-extras # Install all deps
uv run invoke format # Format code
uv run invoke lint # All linters (code + yamllint + documentation)
uv run invoke lint # Full pipeline: ruff, yamllint, ty, mypy, markdownlint, vale
uv run invoke lint-code # All linters for Python code
uv run pytest tests/unit/ # Unit tests
uv run pytest tests/integration/ # Integration tests
Expand Down
1 change: 1 addition & 0 deletions changelog/497.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed Python SDK query generation regarding from_pool generated attribute value
92 changes: 92 additions & 0 deletions dev/commands/feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Session Feedback

Analyze this conversation and identify what documentation or context was missing, incomplete, or incorrect. The goal is to continuously improve the project's knowledge base so future conversations are more efficient.

## Step 1: Session Analysis

Reflect on the work done in this conversation. For each area, identify friction points:

1. **Exploration overhead**: What parts of the codebase did you have to discover by searching that should have been documented? (e.g., patterns, conventions, module responsibilities)
2. **Wrong assumptions**: Did you make incorrect assumptions due to missing or misleading documentation?
3. **Repeated patterns**: Did you discover recurring patterns or conventions that aren't documented anywhere?
4. **Missing context**: What background knowledge would have helped you start faster? (e.g., architecture decisions, data flow, naming conventions)
5. **Tooling gaps**: Were there commands, scripts, or workflows that you had to figure out?

## Step 2: Documentation Audit

For each friction point identified, determine the appropriate fix. Check the existing documentation to avoid duplicating what's already there:

- `AGENTS.md` — Top-level project instructions and component map
- `CLAUDE.md` — Entry point referencing AGENTS.md
- `docs/AGENTS.md` — Documentation site guide
- `infrahub_sdk/ctl/AGENTS.md` — CLI development guide
- `infrahub_sdk/pytest_plugin/AGENTS.md` — Pytest plugin guide
- `tests/AGENTS.md` — Testing guide

Read the relevant existing files to understand what's already documented before proposing changes.

## Step 3: Generate Report

Present the feedback as a structured report with the following sections. Only include sections that have content — skip empty sections.

### Format

```markdown
## Session Feedback Report

### What I Was Working On
<!-- Brief summary of the task(s) performed in this conversation -->

### Documentation Gaps
<!-- Things that should be documented but aren't -->

For each gap:

- **Topic**: What's missing
- **Where**: Which file should contain this (existing file to update, or new file to create)
- **Why**: How this would have helped during this conversation
- **Suggested content**: A draft of what should be added (be specific and actionable)

### Documentation Corrections
<!-- Things that are documented but wrong or misleading -->

For each correction:

- **File**: Path to the file
- **Issue**: What's wrong or misleading
- **Fix**: What it should say instead

### Discovered Patterns
<!-- Conventions or patterns found in the code that aren't documented -->

For each pattern:

- **Pattern**: Description of the convention
- **Evidence**: Where in the code this pattern is used (file paths)
- **Where to document**: Which AGENTS.md or guide file should capture this

### Memory Updates
<!-- Propose additions/changes to MEMORY.md for cross-session persistence -->

For each update:

- **Action**: Add / Update / Remove
- **Content**: What to write
- **Reason**: Why this is worth remembering across sessions
```

## Step 4: Apply Changes

After presenting the report, ask the user which changes they want to apply. Present the options:

1. **Apply all** — Create/update all proposed documentation files and memory
2. **Cherry-pick** — Let the user select which changes to apply
3. **None** — Just keep the report as reference, don't modify any files


For approved changes:

- Edit existing files when updating documentation
- Create new files only when no appropriate existing file exists
- Update `MEMORY.md` with approved memory changes
- Keep all changes minimal and focused — don't over-document
36 changes: 36 additions & 0 deletions dev/commands/pre-ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Run a subset of fast CI checks locally. These are lightweight validations that catch common issues before pushing. Run all steps and report a summary at the end.

## Steps

1. **Format** Python code:
```bash
uv run invoke format
```

2. **Lint** (YAML, Ruff, ty, mypy, markdownlint, vale):
```bash
uv run invoke lint
```

3. **Python unit tests**:
```bash
uv run pytest tests/unit/
```

4. **Docs unit tests** (vitest):
```bash
(cd docs && npx --no-install vitest run)
```

5. **Validate generated documentation** (regenerate and check for drift):
```bash
uv run invoke docs-validate
```

## Instructions

- Run each step in order using the Bash tool.
- If a step fails, continue with the remaining steps.
- At the end, print a summary table of all steps with pass/fail status.
- Do NOT commit or push anything.

12 changes: 12 additions & 0 deletions docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ value(self) -> Any
```python
value(self, value: Any) -> None
```

#### `is_from_pool_attribute`

```python
is_from_pool_attribute(self) -> bool
```

Check whether this attribute's value is sourced from a resource pool.

**Returns:**

- True if the attribute value is a resource pool node or was explicitly allocated from a pool.
122 changes: 89 additions & 33 deletions infrahub_sdk/node/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import ipaddress
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, get_args
from typing import TYPE_CHECKING, Any, NamedTuple, get_args

from ..protocols_base import CoreNodeBase
from ..uuidt import UUIDT
Expand All @@ -13,6 +13,33 @@
from ..schema import AttributeSchemaAPI


class _GraphQLPayloadAttribute(NamedTuple):
"""Result of resolving an attribute value for a GraphQL mutation.

Attributes:
payload: Key/value entries to include in the mutation payload
(e.g. ``{"value": ...}`` or ``{"from_pool": ...}``).
variables: GraphQL variable bindings for unsafe string values.
needs_metadata: When ``True``, the payload needs to append property flags/objects
"""

payload: dict[str, Any]
variables: dict[str, Any]
needs_metadata: bool

def to_dict(self) -> dict[str, Any]:
return {"data": self.payload, "variables": self.variables}

def add_properties(self, properties_flag: dict[str, Any], properties_object: dict[str, str | None]) -> None:
if not self.needs_metadata:
return
for prop_name, prop in properties_flag.items():
self.payload[prop_name] = prop

for prop_name, prop in properties_object.items():
self.payload[prop_name] = prop


class Attribute:
"""Represents an attribute of a Node, including its schema, value, and properties."""

Expand All @@ -25,8 +52,12 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N
"""
self.name = name
self._schema = schema
self._from_pool: dict[str, Any] | None = None

if not isinstance(data, dict) or "value" not in data:
if isinstance(data, dict) and "from_pool" in data:
self._from_pool = data.pop("from_pool")
data.setdefault("value", None)
elif not isinstance(data, dict) or "value" not in data:
data = {"value": data}

self._properties_flag = PROPERTIES_FLAG
Expand Down Expand Up @@ -76,38 +107,55 @@ def value(self, value: Any) -> None:
self._value = value
self.value_has_been_mutated = True

def _generate_input_data(self) -> dict | None:
data: dict[str, Any] = {}
variables: dict[str, Any] = {}

if self.value is None:
if self._schema.optional and self.value_has_been_mutated:
data["value"] = None
return data

if isinstance(self.value, str):
if SAFE_VALUE.match(self.value):
data["value"] = self.value
else:
var_name = f"value_{UUIDT.new().hex}"
variables[var_name] = self.value
data["value"] = f"${var_name}"
elif isinstance(self.value, get_args(IP_TYPES)):
data["value"] = self.value.with_prefixlen
elif isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool():
data["from_pool"] = {"id": self.value.id}
else:
data["value"] = self.value

for prop_name in self._properties_flag:
if getattr(self, prop_name) is not None:
data[prop_name] = getattr(self, prop_name)
def _initialize_graphql_payload(self) -> _GraphQLPayloadAttribute:
"""Resolve the attribute value into a GraphQL mutation payload object."""

for prop_name in self._properties_object:
if getattr(self, prop_name) is not None:
data[prop_name] = getattr(self, prop_name)._generate_input_data()
# Pool-based allocation (dict data or resource-pool node)
if self._from_pool is not None:
return _GraphQLPayloadAttribute(payload={"from_pool": self._from_pool}, variables={}, needs_metadata=True)
if isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool():
return _GraphQLPayloadAttribute(
payload={"from_pool": {"id": self.value.id}}, variables={}, needs_metadata=True
)

return {"data": data, "variables": variables}
# Null value
if self.value is None:
data = {"value": None} if (self._schema.optional and self.value_has_been_mutated) else {}
return _GraphQLPayloadAttribute(payload=data, variables={}, needs_metadata=False)

# Unsafe strings need a variable binding to avoid injection
if isinstance(self.value, str) and not SAFE_VALUE.match(self.value):
var_name = f"value_{UUIDT.new().hex}"
return _GraphQLPayloadAttribute(
payload={"value": f"${var_name}"},
variables={var_name: self.value},
needs_metadata=True,
)

# Safe strings, IP types, and everything else
value = self.value.with_prefixlen if isinstance(self.value, get_args(IP_TYPES)) else self.value
return _GraphQLPayloadAttribute(payload={"value": value}, variables={}, needs_metadata=True)

def _generate_input_data(self) -> _GraphQLPayloadAttribute:
"""Build the input payload for a GraphQL mutation on this attribute.

Returns a ResolvedValue object, which contains all the data required.
"""
graphql_payload = self._initialize_graphql_payload()

properties_flag: dict[str, Any] = {
property_name: getattr(self, property_name)
for property_name in self._properties_flag
if getattr(self, property_name) is not None
}
properties_object: dict[str, str | None] = {
property_name: getattr(self, property_name)._generate_input_data()
for property_name in self._properties_object
if getattr(self, property_name) is not None
}
graphql_payload.add_properties(properties_flag, properties_object)

return graphql_payload

def _generate_query_data(self, property: bool = False, include_metadata: bool = False) -> dict | None:
data: dict[str, Any] = {"value": None}
Expand All @@ -128,7 +176,15 @@ def _generate_query_data(self, property: bool = False, include_metadata: bool =
return data

def _generate_mutation_query(self) -> dict[str, Any]:
if isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool():
if self.is_from_pool_attribute():
# If it points to a pool, ask for the value of the pool allocated resource
return {self.name: {"value": None}}
return {}

def is_from_pool_attribute(self) -> bool:
"""Check whether this attribute's value is sourced from a resource pool.

Returns:
True if the attribute value is a resource pool node or was explicitly allocated from a pool.
"""
return (isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool()) or self._from_pool is not None
37 changes: 10 additions & 27 deletions infrahub_sdk/node/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ def is_resource_pool(self) -> bool:
def get_raw_graphql_data(self) -> dict | None:
return self._data

def _generate_input_data( # noqa: C901, PLR0915
def _generate_input_data( # noqa: C901
self,
exclude_unmodified: bool = False,
exclude_hfid: bool = False,
Expand All @@ -228,27 +228,18 @@ def _generate_input_data( # noqa: C901, PLR0915
dict[str, Dict]: Representation of an input data in dict format
"""

data = {}
variables = {}
data: dict[str, Any] = {}
variables: dict[str, Any] = {}

for item_name in self._attributes:
attr: Attribute = getattr(self, item_name)
if attr._schema.read_only:
continue
attr_data = attr._generate_input_data()

# NOTE, this code has been inherited when we splitted attributes and relationships
# into 2 loops, most likely it's possible to simply it
if attr_data and isinstance(attr_data, dict):
if variable_values := attr_data.get("data"):
data[item_name] = variable_values
else:
data[item_name] = attr_data
if variable_names := attr_data.get("variables"):
variables.update(variable_names)

elif attr_data and isinstance(attr_data, list):
data[item_name] = attr_data
graphql_payload = attr._generate_input_data()
if graphql_payload.payload:
data[item_name] = graphql_payload.payload
if graphql_payload.variables:
variables.update(graphql_payload.variables)

for item_name in self._relationships:
allocate_from_pool = False
Expand Down Expand Up @@ -1011,11 +1002,7 @@ async def _process_mutation_result(

for attr_name in self._attributes:
attr = getattr(self, attr_name)
if (
attr_name not in object_response
or not isinstance(attr.value, InfrahubNodeBase)
or not attr.value.is_resource_pool()
):
if attr_name not in object_response or not attr.is_from_pool_attribute():
continue

# Process allocated resource from a pool and update attribute
Expand Down Expand Up @@ -1819,11 +1806,7 @@ def _process_mutation_result(

for attr_name in self._attributes:
attr = getattr(self, attr_name)
if (
attr_name not in object_response
or not isinstance(attr.value, InfrahubNodeBase)
or not attr.value.is_resource_pool()
):
if attr_name not in object_response or not attr.is_from_pool_attribute():
continue

# Process allocated resource from a pool and update attribute
Expand Down
6 changes: 6 additions & 0 deletions tests/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ uv run pytest tests/unit/test_client.py # Single file
```text
tests/
├── unit/ # Fast, mocked, no external deps
│ ├── ctl/ # CLI command tests
│ └── sdk/ # SDK tests
│ ├── pool/ # Resource pool allocation tests
│ ├── spec/ # Object spec tests
│ ├── checks/ # InfrahubCheck tests
│ └── ... # Core SDK tests (client, node, schema, etc.)
├── integration/ # Real Infrahub via testcontainers
├── fixtures/ # Test data (JSON, YAML)
└── helpers/ # Test utilities
Expand Down
Loading