Skip to content

Commit 42687da

Browse files
authored
Merge pull request #968 from opsmill/stable
Merge stable into develop
2 parents e78b9da + dec3101 commit 42687da

70 files changed

Lines changed: 4768 additions & 1053 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,29 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang
1111

1212
<!-- towncrier release notes start -->
1313

14+
## [1.20.0](https://github.com/opsmill/infrahub-sdk-python/tree/v1.20.0) - 2026-04-24
15+
16+
### Removed
17+
18+
- Removed the deprecated `raise_for_error` argument from `execute_graphql`, `query_gql_query`, `get_diff_summary`, `allocate_next_ip_address`, and `allocate_next_ip_prefix` client methods. HTTP errors are now always raised via `resp.raise_for_status()`.
19+
20+
### Added
21+
22+
- Add `infrahubctl schema export` command to export schemas from Infrahub. ([#151](https://github.com/opsmill/infrahub-sdk-python/issues/151))
23+
- Add `artifact_content`, `file_object_content`, `from_json`, and `from_yaml` Jinja2 filters for artifact content composition in templates.
24+
25+
### Changed
26+
27+
- Replace `FilterDefinition.trusted: bool` with flag-based `ExecutionContext` model (`CORE`, `WORKER`, `LOCAL`) for context-aware template validation. `validate()` now accepts an optional `context` parameter. Backward compatible.
28+
29+
### Fixed
30+
31+
- Allow direct assignment of authentication method to the configuration to override settings from environment variables. ([#654](https://github.com/opsmill/infrahub-sdk-python/issues/654))
32+
- Corrected protocol typing for IPHost.value IPAddress -> IPInterface ([#891](https://github.com/opsmill/infrahub-sdk-python/issues/891))
33+
- Generate protocols so that optional attributes with a default value are rendered as required (not nullable). ([#894](https://github.com/opsmill/infrahub-sdk-python/issues/894))
34+
- Fixed `ObjectStore.get()` and `ObjectStore.upload()` silently swallowing non-2xx HTTP errors instead of raising them. ([#958](https://github.com/opsmill/infrahub-sdk-python/issues/958))
35+
- Skip mandatory field validation during object loading when `object_template` is specified.
36+
1437
## [1.19.0](https://github.com/opsmill/infrahub-sdk-python/tree/v1.19.0) - 2026-03-16
1538

1639
### Added

changelog/+ifc2404.fixed.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

changelog/151.added.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

changelog/891.fixed.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

changelog/894.fixed.md

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# SDK Spec: GraphQL Fragment Inlining
2+
3+
**Jira**: INFP-496
4+
**Created**: 2026-03-13
5+
**Status**: Implemented
6+
**Parent spec**: [infrahub/dev/specs/infp-496-graphql-fragment-inlining/spec.md](../../../../dev/specs/infp-496-graphql-fragment-inlining/spec.md)
7+
8+
## Scope
9+
10+
This spec covers the SDK-side responsibilities for the GraphQL Fragment Inlining feature (FR-015). The Infrahub server and backend integration are documented in the parent spec.
11+
12+
Per the architecture decision in the parent spec:
13+
14+
> Fragment parsing, resolution, and rendering is **SDK responsibility**, not server responsibility.
15+
16+
The SDK must provide:
17+
18+
1. **Config model extension**`graphql_fragments` in `.infrahub.yml`
19+
2. **Fragment renderer** — parse, resolve (transitively), and render queries
20+
3. **CLI integration**`infrahubctl` local workflows apply fragment rendering automatically
21+
22+
---
23+
24+
## Component Responsibilities
25+
26+
| Responsibility | SDK Module |
27+
| --- | --- |
28+
| `InfrahubRepositoryFragmentConfig` model | `infrahub_sdk/schema/repository.py` |
29+
| `graphql_fragments` field on `InfrahubRepositoryConfig` | `infrahub_sdk/schema/repository.py` |
30+
| Read fragment file content from disk | `InfrahubRepositoryFragmentConfig.load_fragments()` |
31+
| Parse `.gql` fragment files into AST | `infrahub_sdk/graphql/query_renderer.py` |
32+
| Build fragment name index (across all declared files) | `infrahub_sdk/graphql/query_renderer.py` |
33+
| Detect duplicate fragment names across files | `infrahub_sdk/graphql/query_renderer.py` |
34+
| Resolve transitive fragment dependencies | `infrahub_sdk/graphql/query_renderer.py` |
35+
| Detect circular fragment dependencies | `infrahub_sdk/graphql/query_renderer.py` |
36+
| Render self-contained query document | `infrahub_sdk/graphql/query_renderer.py` |
37+
| High-level `render_query()` entry point (load + render) | `infrahub_sdk/graphql/query_renderer.py` |
38+
| Typed error exceptions | `infrahub_sdk/exceptions.py` |
39+
| Apply rendering in `infrahubctl` execution | `infrahub_sdk/ctl/utils.py` |
40+
| Apply rendering in `infrahubctl` transform | `infrahub_sdk/ctl/cli_commands.py` |
41+
42+
---
43+
44+
## API Contract: Fragment Renderer
45+
46+
### CLI-facing entry point
47+
48+
```python
49+
# infrahub_sdk/graphql/query_renderer.py
50+
51+
def render_query(name: str, config: InfrahubRepositoryConfig, relative_path: str = ".") -> str:
52+
"""Return a self-contained GraphQL document for the named query, with fragment definitions inlined.
53+
54+
Loads the query file and all declared fragment files from config, then delegates to
55+
render_query_with_fragments.
56+
57+
Raises:
58+
ResourceNotDefinedError: Query name not found in config.
59+
FragmentFileNotFoundError: A declared fragment file path does not exist.
60+
DuplicateFragmentError: Same fragment name declared in multiple files.
61+
FragmentNotFoundError: Query references a fragment not found in any declared file.
62+
CircularFragmentError: Circular dependency detected among fragments.
63+
"""
64+
```
65+
66+
### Low-level entry point
67+
68+
```python
69+
# infrahub_sdk/graphql/query_renderer.py
70+
71+
def render_query_with_fragments(query_str: str, fragment_files: list[str]) -> str:
72+
"""Return a self-contained GraphQL document with required fragment definitions inlined.
73+
74+
If the query contains no fragment spreads, query_str is returned unchanged.
75+
76+
Raises:
77+
QuerySyntaxError: Query string or a fragment file contains invalid GraphQL syntax.
78+
DuplicateFragmentError: Same fragment name declared in multiple files.
79+
FragmentNotFoundError: Query references a fragment not found in any declared file.
80+
CircularFragmentError: Circular dependency detected among fragments.
81+
"""
82+
```
83+
84+
### Public helpers in `query_renderer.py`
85+
86+
```python
87+
def build_fragment_index(fragment_files: list[str]) -> dict[str, FragmentDefinitionNode]:
88+
"""Parse all fragment file contents and return a mapping from fragment name to its AST node."""
89+
90+
def collect_required_fragments(
91+
query_doc: DocumentNode,
92+
fragment_index: dict[str, FragmentDefinitionNode],
93+
) -> list[str]:
94+
"""Walk query_doc and collect all fragment names required (transitively).
95+
96+
Returns a topologically ordered list of unique fragment names.
97+
"""
98+
```
99+
100+
### Error types (additions to `infrahub_sdk/exceptions.py`)
101+
102+
```python
103+
class GraphQLQueryError(Error):
104+
"""Base class for all errors raised during GraphQL query rendering."""
105+
106+
107+
class QuerySyntaxError(GraphQLQueryError):
108+
def __init__(self, syntax_error: str) -> None: ...
109+
# message: f"GraphQL syntax error: {syntax_error}"
110+
111+
112+
class FragmentNotFoundError(GraphQLQueryError):
113+
def __init__(self, fragment_name: str, query_file: str | None = None, message: str | None = None) -> None: ...
114+
# message: f"Fragment '{fragment_name}' not found." (or mentions query_file if provided)
115+
116+
117+
class DuplicateFragmentError(GraphQLQueryError):
118+
def __init__(self, fragment_name: str, message: str | None = None) -> None: ...
119+
# message: f"Fragment '{fragment_name}' is defined more than once across declared fragment files."
120+
121+
122+
class CircularFragmentError(GraphQLQueryError):
123+
def __init__(self, cycle: list[str], message: str | None = None) -> None: ...
124+
# message: f"Circular fragment dependency detected: {' -> '.join(cycle)}."
125+
126+
127+
class FragmentFileNotFoundError(GraphQLQueryError):
128+
def __init__(self, file_path: str, message: str | None = None) -> None: ...
129+
# message: f"Fragment file '{file_path}' declared in graphql_fragments does not exist."
130+
```
131+
132+
`GraphQLQueryError` is also handled in `handle_exception()` in `ctl/utils.py` so CLI commands print
133+
a clean error message and exit instead of raising an unhandled exception.
134+
135+
---
136+
137+
## Config Model Extension
138+
139+
```python
140+
# infrahub_sdk/schema/repository.py
141+
142+
class InfrahubRepositoryFragmentConfig(InfrahubRepositoryConfigElement):
143+
model_config = ConfigDict(extra="forbid")
144+
name: str = Field(..., description="Logical name for this fragment file or directory")
145+
file_path: Path = Field(..., description="Path to a .gql file or directory of .gql files, relative to repo root")
146+
147+
def load_fragments(self, relative_path: str = ".") -> list[str]:
148+
"""Read and return raw content of all fragment files at file_path.
149+
150+
If file_path is a .gql file, returns a single-element list.
151+
If file_path is a directory, returns one entry per .gql file found (sorted).
152+
Raises FragmentFileNotFoundError if file_path does not exist.
153+
"""
154+
resolved = Path(f"{relative_path}/{self.file_path}")
155+
if not resolved.exists():
156+
raise FragmentFileNotFoundError(file_path=str(self.file_path))
157+
if resolved.is_dir():
158+
return [f.read_text(encoding="UTF-8") for f in sorted(resolved.glob("*.gql"))]
159+
return [resolved.read_text(encoding="UTF-8")]
160+
161+
162+
class InfrahubRepositoryConfig(BaseModel):
163+
# ... existing fields ...
164+
graphql_fragments: list[InfrahubRepositoryFragmentConfig] = Field(
165+
default_factory=list, description="GraphQL fragment files"
166+
)
167+
```
168+
169+
---
170+
171+
## infrahubctl Integration
172+
173+
Both CLI call sites use `render_query()` from `query_renderer.py`, which handles loading fragment
174+
files from config and delegating to `render_query_with_fragments`.
175+
176+
### `execute_graphql_query()` in `ctl/utils.py`
177+
178+
```python
179+
# Before
180+
query_str = query_object.load_query()
181+
182+
# After
183+
query_str = render_query(name=query, config=repository_config)
184+
```
185+
186+
### `transform()` in `ctl/cli_commands.py`
187+
188+
```python
189+
# Before
190+
query_str = repository_config.get_query(name=transform.query).load_query()
191+
192+
# After
193+
query_str = render_query(name=transform.query, config=repository_config)
194+
```
195+
196+
---
197+
198+
## Testing Requirements
199+
200+
### Unit tests — `tests/unit/sdk/graphql/test_fragment_renderer.py` (imports from `query_renderer`)
201+
202+
- Render query with single direct fragment spread → correct output
203+
- Render query with fragment spreads across two files → correct output
204+
- Render query with transitive dependency (A → B across files) → correct output
205+
- Render query with no fragment spreads → returned unchanged
206+
- Same fragment spread used twice → fragment definition appears once in output
207+
- Only required fragments included, not all from the file
208+
- `FragmentNotFoundError` raised for unresolved spread
209+
- `DuplicateFragmentError` raised for duplicate name across multiple content strings
210+
- `DuplicateFragmentError` raised for duplicate name within the same content string
211+
- `CircularFragmentError` raised for A→B→A cycle
212+
- `QuerySyntaxError` raised for invalid GraphQL syntax in query or fragment file
213+
214+
### Unit tests — `tests/unit/sdk/graphql/test_query_renderer.py`
215+
216+
- `render_query()` loads query + fragments from config and returns rendered document
217+
- `render_query()` with no `graphql_fragments` in config returns query unchanged
218+
219+
### Unit tests — `tests/unit/sdk/test_repository.py`
220+
221+
- `InfrahubRepositoryConfig` parses `graphql_fragments` YAML correctly
222+
- `InfrahubRepositoryFragmentConfig.load_fragments()` with a file path returns a single-element list with the file content
223+
- `InfrahubRepositoryFragmentConfig.load_fragments()` with a directory path returns one entry per `.gql` file found
224+
- `load_fragments()` raises `FragmentFileNotFoundError` for a path that does not exist
225+
226+
### Integration / functional tests — caller-side
227+
228+
- See main repo plan for backend integration tests and E2E fixtures
229+
230+
---
231+
232+
## Constraints
233+
234+
- Fragment rendering uses only `graphql-core` (already a dependency). No new dependencies.
235+
- All new public functions carry full type hints.
236+
- Both async and sync `InfrahubClient` paths are unaffected — rendering is a pure string transformation with no I/O.
237+
- Generated files (`protocols.py`) are not touched.

0 commit comments

Comments
 (0)