diff --git a/.gitignore b/.gitignore index d34781dd8..d57e60169 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ dist/* # Generated files generated/ +# Committed, generated schema models (write/read variants) must be version-controlled. +!infrahub_sdk/schema/generated/ +!infrahub_sdk/schema/generated/*.py sandbox/ # hatch-vcs version file (written at build time; must not be tracked) diff --git a/.vale/styles/Infrahub/sentence-case.yml b/.vale/styles/Infrahub/sentence-case.yml index c27cf7a1e..5ea115fc9 100644 --- a/.vale/styles/Infrahub/sentence-case.yml +++ b/.vale/styles/Infrahub/sentence-case.yml @@ -45,6 +45,7 @@ exceptions: - IP - IP Fabric - IPAM + - IPAddress - IPHost - IPNetwork - JavaScript diff --git a/CHANGELOG.md b/CHANGELOG.md index 2704ac369..7e6a7ac06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,44 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang +## [1.23.0](https://github.com/opsmill/infrahub-sdk-python/tree/v1.23.0) - 2026-08-19 + +### Removed + +- Removed `client.branch.diff_data()` from both the async and sync clients. The method relied on a `GET /api/diff/data` REST endpoint that does not exist in Infrahub, so every call returned a 404. Use `client.get_diff_tree()` to retrieve the full diff of a branch against its base branch, or `client.get_diff_summary()` for the list of changed nodes; both use the `DiffTree` GraphQL query. ([#325](https://github.com/opsmill/infrahub-sdk-python/issues/325)) + +### Added + +- Added the Infrahub deployment ID to the `infrahubctl info` command output and a `get_server_information()` method (returning the server version and deployment ID) on the async and sync clients. ([#1017](https://github.com/opsmill/infrahub-sdk-python/issues/1017)) +- Added transparent retry of HTTP 429 (rate-limited) responses on both `InfrahubClient` and `InfrahubClientSync`. Retries use jittered exponential backoff and honour a server-provided `Retry-After` header (delta-seconds or HTTP-date). The behaviour is tunable through four new `Config` fields (`rate_limit_retry_enabled`, `rate_limit_max_retries`, `rate_limit_backoff_base`, `rate_limit_backoff_max`), and a new `RateLimitError` exception is raised when retries are exhausted. ([#1124](https://github.com/opsmill/infrahub-sdk-python/issues/1124)) +- Added support for tagging SDK requests with a priority via a new `X-Priority` header. A `Priority` enum (`high`, `medium`, `low`) is available from `infrahub_sdk.constants`; set `Config.priority` (env var `INFRAHUB_PRIORITY`) for a client-wide default emitted on every request, or pass `priority=` to individual operations to override it per request. When unset, no header is sent. Works identically on `InfrahubClient` and `InfrahubClientSync`. ([#1151](https://github.com/opsmill/infrahub-sdk-python/issues/1151)) +- Add `infrahubctl schema format` command, an opinionated offline formatter that normalises the key ordering of schema files. Optional flags can also strip redundant default values (`--strip-defaults`), sort attributes/relationships by `order_weight` (`--sort-by-order-weight`), and backfill a missing `order_weight` (`--backfill-order-weight`). +- Added `retry()` and `cancel()` methods to the task manager. The `Task` model now exposes `available_actions` along with `can_retry` / `can_cancel` helpers. +- Added an opt-in `include_diagnostics` flag to the task manager's `all()`, `filter()`, and `get()` methods. When enabled, tasks expose an `error` field, and `webhook-send` tasks are returned as `WebhookDeliveryTask` instances carrying `http_request` / `http_response` delivery details. +- Added support for the new `IPAddress` attribute kind. Values are exposed as bare `ipaddress.IPv4Address`/`IPv6Address` objects (no prefix) and serialized to a bare-address string when writing, alongside the existing `IPHost` and `IPNetwork` kinds. +- The JSON schema generated for `.infrahub.yml` now warns when a definition has not said what it depends on, so YAML language servers flag it while the file is being edited. A Python transform or generator definition with no `watch` block is flagged, and so is a `watch` value that is not a mapping, including the bare `watch:` that parses as null and records nothing. Both warnings are advisory only: the models still accept every one of those forms. An empty `watch: {}` or `files: []` stays clean, since either one records that the author checked and nothing beyond what Infrahub detects needs watching. The generated schema also picks up the `watch` block on generator definitions, which it was previously rejecting as an unknown property. +- The request priority (`X-Priority` header) can now be carried on the client's `RequestContext` via a new `priority` field, alongside the existing client-wide `Config.priority` default and per-call `priority=` override. Resolution precedence is per-call `priority=` > `request_context.priority` > `Config.priority` > no header. The priority is emitted as a header only and is never included in the mutation body. Works identically on `InfrahubClient` and `InfrahubClientSync`. +- Import `pyarrow` lazily in the line-delimited JSON importer so that `infrahubctl` commands other than `object load` no longer require the `ctl` extra (and its heavy `pyarrow` dependency) to be installed. + +### Changed + +- Paginated queries generated by `all()`, `filters()`, `get()` and resource pool allocation lookups now pass `offset` and `limit` as GraphQL variables instead of inlining them in the query text. The query document stays identical across pages, allowing the Infrahub server to reuse its cached query analysis, and the query is now rendered once per call instead of once per page. `generate_query_data` also accepts variable placeholder strings (for example `"$offset"`) for its `offset` and `limit` arguments. +- Raised the default `Config.rate_limit_max_retries` from 5 to 10, so a request shed with HTTP 429 keeps retrying (honouring `Retry-After`) for longer before raising `RateLimitError`. This lets background work ride out a longer burst of server-side backpressure. Callers that prefer to give up sooner can lower the value. +- The hand-maintained schema models in `infrahub_sdk.schema` are now backed by the generated write/read contract (`infrahub_sdk.schema.generated`). Public names, import paths, and behavior methods are unchanged, but a few defaults and constraints now match the server contract: + + - `AttributeKind.STRING` has been removed. It was deprecated and `kind="String"` was already rejected server-side; use `AttributeKind.TEXT` instead. + - Write and read models drop unknown fields silently (`extra="ignore"`). A submitted field that is not part of the write contract — read-level, internal, or a typo — is dropped rather than rejected, and a read model tolerates additional fields returned by a newer server. + - Write-model defaults now match the server contract: relationship `min_count`/`max_count` default to `0` (was `None`), node `branch` defaults to `"aware"`, `generate_profile` defaults to `True`, and `generate_template` defaults to `False`. This changes the round-trip output of programmatically-built schemas. + + Constructing `AttributeSchema(name=..., kind=AttributeKind.TEXT, ...)`, `NodeSchema`, `GenericSchema`, `RelationshipSchema`, `SchemaRoot`, and the read-side `*API` models continues to work unchanged. + +## [1.22.3](https://github.com/opsmill/infrahub-sdk-python/tree/v1.22.3) - 2026-08-19 + +### Fixed + +- Register the `infrahub_integration` pytest marker under its real name. It was registered as `infrahub_integraton`, so integration tests raised a `PytestUnknownMarkWarning` on every run and failed to collect under `--strict-markers`. ([#1231](https://github.com/opsmill/infrahub-sdk-python/issues/1231)) +- Fixed the `load` and `check` command descriptions in the `infrahubctl schema` help output and generated docs, which were cut off mid-sentence. + ## [1.22.2](https://github.com/opsmill/infrahub-sdk-python/tree/v1.22.2) - 2026-07-27 ### Added diff --git a/changelog/+schema-cli-short-help.fixed.md b/changelog/+schema-cli-short-help.fixed.md deleted file mode 100644 index 830263dcd..000000000 --- a/changelog/+schema-cli-short-help.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed the `load` and `check` command descriptions in the `infrahubctl schema` help output and generated docs, which were cut off mid-sentence. diff --git a/changelog/1231.fixed.md b/changelog/1231.fixed.md deleted file mode 100644 index c845fa9f4..000000000 --- a/changelog/1231.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Register the `infrahub_integration` pytest marker under its real name. It was registered as `infrahub_integraton`, so integration tests raised a `PytestUnknownMarkWarning` on every run and failed to collect under `--strict-markers`. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/alignment-check.md b/dev/specs/ihs-259-sdk-x-priority-header/alignment-check.md new file mode 100644 index 000000000..2c3a7e621 --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/alignment-check.md @@ -0,0 +1,32 @@ +# Spec / Ask Alignment Check: SDK `X-Priority` Request Header + +**Date**: 2026-07-10 +**Feature dir**: `specs/ihs-259-sdk-x-priority-header/` + +## 1. Source + +- **Source PRD**: Jira **IHS-259** — "feat: SDK X-Priority request header" (`https://opsmill.atlassian.net/browse/IHS-259`), fetched via the Atlassian integration. The Jira issue description *is* a full PRD (Problem Statement, Solution Overview, 7 User Stories, FR-001…008, Key Entities, Edge Cases, SC-001…005, Implementation/Testing Decisions, Out of Scope, Assumptions). +- Compared against: `spec.md` (current, post-critique). + +## 2. Verdict + +✅ **ALIGNED** + +The spec faithfully carries every PRD requirement, user story, acceptance criterion, edge case, and out-of-scope boundary. The only differences are expansions of detail and testability clarifications that preserve — and in two cases make verifiable — the PRD's stated intent. No requirement is missing, changed in meaning, dropped, softened, or contradicted. + +## 3. Findings + +| Severity | Category | PRD reference | Spec reference | Description | +|----------|----------|---------------|----------------|-------------| +| ℹ️ Info (no drift) | mapping | PRD User Stories 1–7 | spec US1–US5 | 7 PRD stories consolidated into 5. All intent preserved: PRD US1→US1, US2 (enum) folded into FR-001 + contracts, US3 (override)→US2, US4 (rides every transport)→US1/FR-003, US5 (zero change)→US3, US6 (invalid rejected)→US4, US7 (async=sync)→US5. Consolidation, not loss. | +| ℹ️ Info (expansion) | added | PRD Assumptions ("header is exactly `X-Priority`") | spec FR-009 | Spec adds FR-009 stating the header name is exactly `X-Priority` with lowercase value. This promotes a PRD assumption to a testable requirement — expansion of detail, within PRD scope. | +| ℹ️ Info (expansion) | added | PRD Edge Cases ("batch mode and raw blob transfers inherit the client default") | spec SC-006 | Spec adds SC-006 verifying batch/blob inherit the configured default. Makes an implicit PRD scope claim testable; does not add new scope (no per-request override for these, matching the PRD). Raised by the critique (P5/X1). | +| ⚠️ Minor (clarified, not softened) | changed-wording | PRD SC-002 ("emits no `X-Priority` header — asserted byte-for-byte against current behaviour") | spec SC-002 | Reworded to "no `X-Priority` emitted; no other SDK-set outgoing header changes (assert `X-Priority` absent; not a literal byte-for-byte comparison of transport-injected headers)". The requirement (no header, no behaviour change) is unchanged; only the assertion method is clarified because httpx injects its own headers, making a literal byte comparison neither stable nor meaningful. Raised by the critique (E6). | + +All FR-001…008 map 1:1 to spec FR-001…008. All SC-001, SC-003, SC-004, SC-005 map 1:1. All PRD edge cases and all five Out-of-Scope items (429/#1124, server-side/INFP-636, classification guidance, per-batch/blob knobs, anti-escalation) are present in the spec. + +## 4. Action + +**Proceed.** No remediation required. The spec is aligned with IHS-259; the two ⚠️/expansion items are testability clarifications that strengthen the spec without departing from the PRD. `tasks.md` (Phase 4) is ready for review and implementation. + +- Remediation passes used: **0**. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/checklists/requirements.md b/dev/specs/ihs-259-sdk-x-priority-header/checklists/requirements.md new file mode 100644 index 000000000..12b3ea77b --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: SDK `X-Priority` Request Header + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-10 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- The spec unavoidably names the concrete wire contract (`X-Priority` header, `Priority` enum, `Config` field, method kwarg) because these ARE the requirement contract handed down from the PRD (IHS-259) and the server-side effort (INFP-636), not free implementation choices. Enum/config/kwarg names are treated as the externally observable API surface, not internal implementation detail. +- No [NEEDS CLARIFICATION] markers were needed: the source PRD is detailed and unambiguous, with resolution rules, transport coverage, and testing decisions all specified. +- All items pass. Spec is ready for `/speckit-plan`. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/contracts/priority-api.md b/dev/specs/ihs-259-sdk-x-priority-header/contracts/priority-api.md new file mode 100644 index 000000000..9d4b8206b --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/contracts/priority-api.md @@ -0,0 +1,74 @@ +# API Contract: Priority public surface + +**Feature**: IHS-259 | **Scope**: SDK public Python API (async + sync). This is a public-API-signature change (governance-approved in IHS-259). + +## New public symbol: `Priority` + +```python +from infrahub_sdk.constants import Priority + +class Priority(str, enum.Enum): + HIGH = "high" + NORMAL = "normal" + LOW = "low" +``` + +- `str`-valued closed enum. `Priority("LOW") is Priority.LOW` (case-insensitive via `_missing_`). +- Unknown values raise `ValueError` (→ `pydantic.ValidationError` at config load). +- Imported from `infrahub_sdk.constants` (kept out of the top-level `infrahub_sdk` namespace so importing the enum does not pull in `Config` and the client classes). + +## Extended: `Config.priority` + +```python +class ConfigBase(BaseSettings): + ... + priority: Priority | None = Field( + default=None, + description="Default request priority emitted as the X-Priority header on every request. " + "One of high|normal|low (case-insensitive). When unset, no header is sent.", + ) +``` + +- Env var: `INFRAHUB_PRIORITY`. +- Accepts a `Priority` or a case-insensitive string; unknown → validation error at load. +- Default `None` → no client-wide default. + +## Extended method signatures (new `priority` keyword — both `InfrahubClient` and `InfrahubClientSync`) + +Each covered method gains `priority: Priority | None = None` (default `None` preserves current behaviour). The argument is keyword-friendly and additive — existing positional/keyword calls are unaffected. + +```python +# Client +def get(self, kind, ..., priority: Priority | None = None) -> ... +def all(self, kind, ..., priority: Priority | None = None) -> ... # forwards to filters + count +def filters(self, kind, ..., priority: Priority | None = None) -> ... +def count(self, kind, ..., priority: Priority | None = None) -> int +def execute_graphql(self, query, ..., priority: Priority | None = None) -> dict +def _execute_graphql_with_file(self, ..., priority: Priority | None = None) -> ... # file variant +def create_diff(self, ..., priority: Priority | None = None) -> ... +def get_diff_summary(self, ..., priority: Priority | None = None) -> ... +def get_diff_tree(self, ..., priority: Priority | None = None) -> ... + +# Node (InfrahubNode / InfrahubNodeSync) +def save(self, ..., priority: Priority | None = None) -> None +def create(self, ..., priority: Priority | None = None) -> None +def update(self, ..., priority: Priority | None = None) -> None +def delete(self, ..., priority: Priority | None = None) -> None +``` + +> **Note — `client.create` is intentionally NOT extended.** `InfrahubClient.create()` / `InfrahubClientSync.create()` only build an unsaved `InfrahubNode` in memory and issue no HTTP request, so a `priority=` kwarg there would be a no-op. Per-request priority for creating a node is carried by the node-level `save()` / `create()` (which issue the mutation). Within a node create/update, a resource-pool relationship's follow-up peer fetch inherits the same `priority`. + +### Behavioural contract per call + +- `priority=None` (default): use the client-wide default (which may itself be `None` → no header). No client state is mutated. +- `priority=Priority.X`: this request carries `X-Priority: x`, overriding the client default for this call only. The next un-annotated call reverts to the client default. +- Resolution: `resolved = per_request if per_request is not None else client_default`. + +## Explicitly NOT extended (v1) + +- `_get`, `_post`, `_get_streaming` (raw blob transfers) — inherit the client default only; no `priority` kwarg. +- Batch mode — inherits the client default only; no per-call override. + +## Backwards-compatibility guarantee + +- Adding a keyword-only-friendly parameter with a `None` default and a new optional config field is additive. Any existing caller that sets nothing sees no change in outgoing requests (FR-004 / SC-002). diff --git a/dev/specs/ihs-259-sdk-x-priority-header/contracts/x-priority-header.md b/dev/specs/ihs-259-sdk-x-priority-header/contracts/x-priority-header.md new file mode 100644 index 000000000..f9acfc0c1 --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/contracts/x-priority-header.md @@ -0,0 +1,30 @@ +# Wire Contract: `X-Priority` HTTP header + +**Feature**: IHS-259 | **Consumer**: Infrahub API server (INFP-636) + +## Header + +| Property | Value | +|----------|-------| +| Name | `X-Priority` (exact, case-insensitive on the server per HTTP header rules) | +| Values | `high`, `normal`, `low` (lowercase emitted by the SDK) | +| Cardinality | 0 or 1 per request | + +## Emission rules (SDK side) + +1. The SDK emits the header on a request **iff** the resolved priority for that request is non-`None`. +2. When emitted, the value is exactly the lowercase token of the resolved `Priority` member. +3. The header is emitted uniformly across every transport when a client-wide default is configured: GraphQL query/mutation, multipart file upload, and raw blob `_get`/`_post`. +4. When no priority is configured and none is passed per request, the header is **absent** — the outgoing request is byte-for-byte identical to the pre-feature SDK. + +## Server semantics (assumed, per INFP-636 — not implemented here) + +- The server treats the value case-insensitively. +- An **absent** header and an **unknown** value are both treated as `normal`. +- Consequently, "omit the header" and "send `normal`" are server-equivalent, which is what makes omitting-when-unconfigured a safe, non-breaking rollout. + +## Non-goals (this contract) + +- No `Retry-After` / 429 semantics (GitHub #1124). +- No server-side admission control, routing, or throttling behaviour (INFP-636). +- The SDK does not read or react to any response header related to priority. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/critiques/critique-20260710-164718.md b/dev/specs/ihs-259-sdk-x-priority-header/critiques/critique-20260710-164718.md new file mode 100644 index 000000000..f33b9afbb --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/critiques/critique-20260710-164718.md @@ -0,0 +1,135 @@ +# Critique Report: SDK `X-Priority` Request Header + +**Date**: 2026-07-10 +**Feature**: [spec.md](../spec.md) +**Plan**: [plan.md](../plan.md) +**Verdict**: ⚠️ PROCEED WITH UPDATES + +--- + +## Executive Summary + +This is a small, well-scoped, low-risk feature backed by a detailed PRD (IHS-259) and a clear parent effort (INFP-636). The spec and plan are strong: the problem is real and clearly stated, backwards compatibility is treated as a first-class P1 requirement, async/sync parity is enforced, and the technical approach is grounded in existing prior art (`X-Infrahub-Tracker`) rather than inventing a new mechanism. There are **no Must-Address blockers**. Three low-risk clarifications are worth applying before task generation: (1) SC-002's "byte-for-byte identical" is not literally testable given transport-injected headers and should be reframed as "`X-Priority` absent, no other SDK-set header changed"; (2) batch-mode default inheritance is claimed in scope but has no verifying success criterion or test; (3) the `all` pagination loop must forward `priority` on every page request — worth stating explicitly so it isn't missed. All three are applied as edits below. + +--- + +## Product Lens Findings 🎯 + +### Problem Validation + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P1 | ✅ (strength) | Problem statement is clear, evidence is architectural (shared worker pool / no interactive-vs-background signal), and it is correctly framed as the SDK's slice of INFP-636 with server work explicitly out of scope. | None — keep as is. | + +### User Value Assessment + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P2 | 💡 | The PRD's 7 user stories were consolidated into 5 spec stories. This is reasonable grouping (enum-typing folded into US1/US2), not drift, but the "typed enum instead of raw strings" value (PRD story 2) is now implicit. | Confirmed acceptable; enum value is captured in FR-001 and the API contract. No change required. | + +### Alternative Approaches + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P3 | 🤔 | A `Literal["high","normal","low"]` would be simpler than an enum. | Rejected by design — FR-001/PRD mandate a `Priority` enum for typo-safety and intent expression. No action. | + +### Edge Cases & UX + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P4 | 💡 | The "caller manually pre-populates an `X-Priority` header" edge case is only reachable via the low-level `_get`/`_post` `headers=` kwarg — the covered public methods don't expose a raw headers argument. As written it reads as broader than it is. | Minor wording clarification (applied): scope this edge case to the low-level transport methods. | + +### Success Measurement + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P5 | 💡 | Batch mode is listed under scope ("inherits the client default") but no Success Criterion asserts it, so "does the default actually ride batch requests?" is unverified. | Add a validation note/task asserting batch requests carry the configured default (applied to plan/spec + tasks). | + +--- + +## Engineering Lens Findings 🔬 + +### Architecture Soundness + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E1 | ✅ (strength) | Injecting the default into base `self.headers` and applying the override only in the two `execute_graphql*` funnels is the right factoring — it guarantees transport coverage without touching ~10 call sites and mirrors `X-INFRAHUB-KEY`. | None. | + +### Failure Mode Analysis + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E2 | 💡 | `all()` paginates by calling `execute_graphql` once per page (via `filters`). If `priority` is forwarded to only the first page, later pages would silently drop it. | State explicitly that the pagination loop forwards `priority` to every page call; cover with a multi-page test (applied). | +| E3 | 💡 | The multipart path (`_execute_graphql_with_file`) pops `content-type`; the override is applied to the copied dict, so `X-Priority` survives — but this ordering is load-bearing and easy to regress. | Add an explicit multipart override test and a note that `X-Priority` must be set after the content-type pop (applied to research/tasks). | + +### Security & Privacy + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E4 | 🤔 | The header is client-asserted and trivially spoofable/escalatable by a caller. | Correctly out of scope — anti-escalation is a server/usage concern (INFP-636), already noted in spec Out of Scope. No action. | + +### Performance & Scalability + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E5 | ✅ | One dict insertion per request; no new round-trips or allocation of concern. | None. | + +### Testing Strategy + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E6 | 💡 | SC-002 says the unconfigured case is "asserted **byte-for-byte** against current behaviour." httpx injects its own headers (host, user-agent, content-length, etc.), so a literal byte-for-byte comparison of the full request is neither stable nor what the test should assert. | Reframe SC-002 as "no `X-Priority` header is emitted and no other SDK-set header changes," asserted via `"x-priority" not in request.headers` (applied to spec). | + +### Operational Readiness + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E7 | 🤔 | No observability of which priority a request carried. | Out of scope for an SDK header emitter; the server observes the header. No action. | + +### Dependencies & Integration + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E8 | ✅ | No new dependencies; reuses pydantic + httpx. `Config.clone()` carries the new field automatically. | None. | + +--- + +## Cross-Lens Insights + +| ID | Lens | Severity | Category | Finding | Suggestion | +|----|------|----------|----------|---------|------------| +| X1 | Both | 💡 | Scope × Verification | Batch mode + blob transports are claimed to inherit the client default (product scope) but the engineering verification for batch is absent (E2/P5 converge). | Add explicit tests that a configured default rides batch and blob requests; keeps the "every transport" promise honest. Applied to spec SC + tasks. | + +--- + +## Findings Summary Table + +| ID | Lens | Severity | Category | Finding | Suggestion | +|----|------|----------|----------|---------|------------| +| P1 | Product | ✅ | Problem Validation | Clear, evidence-backed problem | None | +| P2 | Product | 💡 | User Value | 7→5 story consolidation; enum value implicit | Confirmed OK; captured in FR-001 | +| P3 | Product | 🤔 | Alternatives | Literal vs enum | Enum mandated; no action | +| P4 | Product | 💡 | Edge/UX | Manual-header edge case broader than reachable | Scope wording to low-level methods (applied) | +| P5 | Product | 💡 | Success Measurement | Batch inheritance unverified | Add batch test/criterion (applied) | +| E1 | Engineering | ✅ | Architecture | Base-header + funnel factoring is sound | None | +| E2 | Engineering | 💡 | Failure Modes | Pagination must forward priority per page | State + test (applied) | +| E3 | Engineering | 💡 | Failure Modes | Multipart content-type pop ordering is load-bearing | Order note + test (applied) | +| E4 | Engineering | 🤔 | Security | Header spoofable | Out of scope; no action | +| E5 | Engineering | ✅ | Performance | Negligible overhead | None | +| E6 | Engineering | 💡 | Testing | "byte-for-byte" not literally testable | Reframe SC-002 (applied) | +| E7 | Engineering | 🤔 | Ops | No priority observability | Out of scope; no action | +| E8 | Engineering | ✅ | Dependencies | No new deps; clone carries field | None | +| X1 | Both | 💡 | Scope × Risk | Batch/blob inheritance unverified | Add tests (applied) | + +--- + +## Verdict & Remediation + +**⚠️ PROCEED WITH UPDATES** — no Must-Address blockers. Six 💡 recommendations, all clear and low-risk, are applied autonomously (per the prep workflow): + +1. **SC-002 reworded** (E6) — from "byte-for-byte" to "no `X-Priority` emitted; no other SDK-set header changes." +2. **New SC-006** (P5/X1) — a configured default rides batch and blob transfers. +3. **Edge case P4** — scoped the manual-header case to low-level `_get`/`_post`. +4. **Plan/research notes** (E2, E3) — pagination forwards `priority` per page; multipart sets `X-Priority` after the content-type pop. + +These are reflected in `spec.md` and `plan.md`/`research.md`. Tasks generation (Phase 4) will include the batch/blob/multipart/pagination tests. Ready for `/speckit-tasks`. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/data-model.md b/dev/specs/ihs-259-sdk-x-priority-header/data-model.md new file mode 100644 index 000000000..47784bff7 --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/data-model.md @@ -0,0 +1,102 @@ +# Data Model: SDK `X-Priority` Request Header + +**Feature**: IHS-259 | **Date**: 2026-07-10 + +This feature introduces no persisted data. The "entities" are in-memory types and the wire header. Below: the new/changed types, their fields, validation rules, and the state/resolution logic. + +## Entity: `Priority` (new) + +A closed enumeration representing request priority. Owned by the SDK; no lifecycle beyond its value. + +| Member | Wire value | Meaning | +|----------|-----------|----------------------------------------------------------------| +| `HIGH` | `high` | Prefer this request; server should protect it under load. | +| `NORMAL` | `normal` | Default server treatment; equivalent to absent header. | +| `LOW` | `low` | Sheddable first; intended for background/bulk workloads. | + +- **Base type**: `str, enum.Enum` — each member *is* its lowercase wire token, so it drops directly into a header dict. +- **Location**: `infrahub_sdk/constants.py` (beside `InfrahubClientMode`). +- **Validation / coercion rules**: + - Exact string match: `Priority("low") == Priority.LOW`. + - Case-insensitive match via `_missing_`: `Priority("LOW")`, `Priority("Low")` → `Priority.LOW` (FR-002). + - Unknown value (`Priority("lowe")`) → `ValueError` (surfaced as `pydantic.ValidationError` at config load) (FR-007). +- **No ordering semantics**: the enum is a closed label set, not a comparable ranking. The SDK does not compare priorities; it only serialises the resolved value. + +## Entity: `Config.priority` (extended field) + +Extends `ConfigBase` (`infrahub_sdk/config.py`). + +| Attribute | Value | +|-----------|-------| +| Field name | `priority` | +| Type | `Priority \| None` | +| Default | `None` (no client-wide default → header omitted) | +| Env var | `INFRAHUB_PRIORITY` (via `env_prefix="INFRAHUB_"`) | +| Accepts | a `Priority` value, or a case-insensitive string (`"high"/"normal"/"low"`, any case) | +| Validation | pydantic + `Priority` enum; unknown value → `ValidationError` at load time | +| Carried by `clone()` | Yes (automatic — `clone()` iterates `Config.model_fields`) | + +- **Meaning of values**: + - `None` → no client-wide default; no header unless a per-request override is given. + - `Priority.X` → every request from this client carries `X-Priority: x` unless overridden per request. + +## Entity: Base request headers `self.headers` (extended) + +The client's base header dict, built once in `BaseClient.__init__` (`infrahub_sdk/client.py`). + +- **Before**: `{"content-type": "application/json"[, "X-INFRAHUB-KEY": ]}`. +- **After**: additionally `"X-Priority": ` **iff** `config.priority is not None`. +- **Invariant (FR-004/SC-002)**: when `config.priority is None`, the dict is byte-for-byte what it is today — no `X-Priority` key. +- Every transport copies this dict per request, so the default rides all of them. + +## Entity: `X-Priority` wire header (new, external contract) + +| Attribute | Value | +|-----------|-------| +| Header name | `X-Priority` (exact) | +| Value | one of `high` / `normal` / `low` (lowercase) | +| Presence | present only when the resolved priority is non-`None` | +| Server semantics (per INFP-636) | case-insensitive; absent or unknown treated as `normal` | + +See [contracts/x-priority-header.md](./contracts/x-priority-header.md). + +## Resolution logic (the core rule) + +Per request, the emitted header is determined by: + +```text +resolved = per_request if per_request is not None else client_default +if resolved is None: omit the X-Priority header +else: send X-Priority: resolved.value +``` + +Realised in code as: the client default is already in the copied `self.headers`; then `if per_request is not None: headers["X-Priority"] = per_request.value`. + +### Resolution truth table + +| Client default | Per-request arg | Emitted header | +|----------------|-----------------|-----------------------| +| `None` | `None` | *(none)* | +| `None` | `HIGH` | `X-Priority: high` | +| `None` | `NORMAL` | `X-Priority: normal` | +| `LOW` | `None` | `X-Priority: low` | +| `LOW` | `HIGH` | `X-Priority: high` | +| `LOW` | `NORMAL` | `X-Priority: normal` (explicit step-up wins) | +| `NORMAL` | `None` | `X-Priority: normal` | +| `HIGH` | `LOW` | `X-Priority: low` | + +- There is no per-request way to force "send no header" once a default is set; passing `NORMAL` explicitly is the accepted equivalent (spec Edge Cases). +- A per-request value never mutates client state — the next un-annotated call reverts to the client default (SC-003). + +## Coverage of the per-request override + +| Surface | Client default rides it? | Per-request `priority=` override? | +|---------|--------------------------|-----------------------------------| +| `execute_graphql` + file variant | Yes | **Yes** | +| `get`, `all`, `filters`, `count` | Yes | **Yes** | +| diff methods (`create_diff`, `get_diff_summary`, `get_diff_tree`) | Yes | **Yes** | +| node `save` / `create` / `update` / `delete` | Yes | **Yes** (forwarded) | +| resource-pool peer fetch within a node create/update | Yes | **Yes** (inherits the operation's `priority`) | +| `client.create` (builds an unsaved node, issues no request) | n/a | No (nothing to send) | +| raw blob `_get` / `_post` / `_get_streaming` | Yes | No (v1) | +| batch mode | Yes | No (v1) | diff --git a/dev/specs/ihs-259-sdk-x-priority-header/opsmill-implement-report.md b/dev/specs/ihs-259-sdk-x-priority-header/opsmill-implement-report.md new file mode 100644 index 000000000..6e82882d1 --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/opsmill-implement-report.md @@ -0,0 +1,106 @@ +# Implementation Report: SDK `X-Priority` Request Header (IHS-259) + +**Status**: ✅ DONE + +## 1. Header + +- **Feature**: SDK `X-Priority` request header (client-wide default + per-request override). +- **Spec dir**: `specs/ihs-259-sdk-x-priority-header/` +- **Base commit**: `6b82d0f` (prep artifacts; tail started here) +- **Head commit**: `2cfe193` +- **Branch**: `dga/feat-x-priority-aa2nd` +- **Tasks**: 35/35 complete (all `[X]`). +- **Wall-clock**: ~1h (implement loop + review + fixes). + +## 2. Chunk-by-chunk ledger + +| # | Chunk (phase) | Tasks | ✅ / ⚠️ / ❌ | Commit(s) | Notes | +|---|---------------|-------|--------------|-----------|-------| +| 0 | Phase 1 Setup (T001) | 1 | 1 / 0 / 0 | `f4422e5` | Run by orchestrator as preflight; baseline 107 passed. | +| 1 | Phase 2 Foundational (T002–T004) | 3 | 3 / 0 / 0 | `c95dc7a` | `Priority` enum + `_missing_`, export, `Config.priority`. Import smoke-check passed. | +| 2 | Phase 3 US1 (T005–T010) | 6 | 6 / 0 / 0 | `feef536` | Base-header injection; default rides GraphQL/multipart/blob/batch. 14 tests. | +| 3 | Phase 4 US3 (T011–T012) | 2 | 2 / 0 / 0 | `39b566e` | No-header-when-unconfigured; no production change needed. | +| 4 | Phase 5 US2 (T013–T024) | 12 | 10 / 2 / 0 | `af1e6c4` | Per-request override on funnels + high-level + node. **T016/T018 ⚠️ partial**: `client.create` intentionally excluded (issues no request; covered at `node.save`). **Load-bearing merge flip** applied (see §6). 40 tests. | +| 5 | Phase 6 US4 (T025–T027) | 3 | 3 / 0 / 0 | `3feedaa` | Config validation (case-insensitive accept, reject unknown, default None). Justified `# ty: ignore` on deliberate string-coercion tests. | +| 6 | Phase 7 US5 (T028–T029) | 2 | 2 / 0 / 0 | `afbb902` | Parity audit (all wire tests already dual); resolution truth-table parity test (16 cases). | +| 7 | Phase 8 Polish (T030–T035) | 6 | 6 / 0 / 0 | `76b8834` | Docstrings, `docs-generate`+`docs-validate` (green), changelog `1151.added.md`, full-suite run. | + +**Review-driven commits** (Phase 6): `8ab2964` (HIGH fix), `2cfe193` (Medium test-gap closure). + +## 3. Tasks not completed + +None. All 35 tasks are `[X]`. + +- **Nuance (not incomplete)**: T016/T018 deliberately excluded `client.create` from the `priority=` kwarg. Reason (from the subagent, confirmed by the code review): `client.create` only constructs an unsaved `InfrahubNode` and issues no HTTP request — the create request is made by `node.save()`/`node.create()`, which DO carry `priority` (T019/T020, tested). Adding an unused kwarg to `client.create` would be a misleading no-op and a lint error. FR-005's "create" surface is therefore satisfied at the request-issuing layer. + +## 4. Local-pass evidence + +All tests added/modified by this run, observed passing locally (unit; project has no locally-runnable E2E — the "E2E scenario" in the PRD is realized as unit wire-assertions). Aggregated from chunk subagents; final consolidated run: `tests/unit/sdk/ → 1145 passed`. + +| Test id | Type | Run command | Passed at (ISO 8601) | Env | Verbatim pass line | +|---------|------|-------------|----------------------|-----|--------------------| +| `test_config.py::test_invalid_priority_rejected` | unit | `uv run pytest tests/unit/sdk/test_config.py -q` | 2026-07-11T14:47:13Z | n/a | `Pytest: 25 passed` | +| `test_config.py::test_priority_case_insensitive_acceptance` (12 params) | unit | `uv run pytest tests/unit/sdk/test_config.py -q` | 2026-07-11T14:47:13Z | n/a | `Pytest: 25 passed` | +| `test_config.py::test_priority_from_env_var` (3 params) | unit | `uv run pytest tests/unit/sdk/test_config.py -q` | 2026-07-11T14:47:13Z | n/a | `Pytest: 25 passed` | +| `test_config.py::test_priority_default_is_none` | unit | `uv run pytest tests/unit/sdk/test_config.py -q` | 2026-07-11T14:47:13Z | n/a | `Pytest: 25 passed` | +| `test_priority.py::test_priority_header_on_graphql_query` (×2 clients) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_priority_header_on_graphql_mutation` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_priority_header_on_blob_download` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_priority_header_on_blob_upload` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_priority_header_on_multipart_upload` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_priority_header_on_batched_requests` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_priority_normal_is_always_emitted` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_no_priority_header_on_graphql_when_unconfigured` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_no_priority_header_on_blob_download_when_unconfigured` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_no_priority_header_on_blob_upload_when_unconfigured` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_no_priority_header_on_multipart_upload_when_unconfigured` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_unconfigured_headers_unchanged_versus_baseline` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_no_default_client_then_no_leak` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_beats_default_then_reverts` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_normal_beats_low_default` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_get` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_all_carries_on_every_page` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_save_create_path` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_save_update_path` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_node_delete` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_diff_method` (create_diff, ×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_get_diff_summary` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_get_diff_tree` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_override_on_multipart_upload` (×2) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_priority.py::test_resolution_truth_table_parity` (16 params) | unit | `uv run pytest tests/unit/sdk/test_priority.py -q` | 2026-07-11T15:12:59Z | n/a | `Pytest: 64 passed` | +| `test_relogin_headers.py::test_relogin_retry_uses_refreshed_auth_header` (×2) | unit | `uv run pytest tests/unit/sdk/test_relogin_headers.py -p no:cacheprovider` | 2026-07-11T15:08:18Z | n/a | `4 passed in 0.02s` | +| `test_relogin_headers.py::test_merge_request_headers_reasserts_live_auth` (×2) | unit | `uv run pytest tests/unit/sdk/test_relogin_headers.py -p no:cacheprovider` | 2026-07-11T15:08:18Z | n/a | `4 passed in 0.02s` | + +**Consolidated final runs**: `tests/unit/sdk/ → 1145 passed` (2026-07-11); full `tests/unit/ → 1588 passed, 8 failed, 1 xfailed` — the 8 failures are **pre-existing** (`ctl/` menu/repo/schema/task app CLI-rendering + `pytest_plugin` fixture), fail identically on base `6b82d0f`, and none reference priority. + +## 5. Review findings + +Dual-lens review (correctness/types/errors, tests, comments/simplify) across `6b82d0f..HEAD`. + +| Severity | File | Summary | Disposition | +|----------|------|---------|-------------| +| 🔴 High | `client.py` (8 transport helpers) | Merge-order flip let a stale per-request header snapshot overwrite the freshly-refreshed `Authorization` on the relogin retry → password-auth token-refresh broken. | **Fixed inline** (`8ab2964`) — added `BaseClient._merge_request_headers` re-asserting live auth after the per-request merge; regression test proves it (removing fix → 4 failures). | +| 🟡 Medium | `test_priority.py` | Node `delete()` per-request override untested on the wire. | **Fixed inline** (`2cfe193`). | +| 🟡 Medium | `test_priority.py` | `save()` update-path override untested (only create branch). | **Fixed inline** (`2cfe193`). | +| 🟢 Low | `test_priority.py` | Only `create_diff` of the 3 diff methods tested. | **Fixed inline** (`2cfe193`) — added summary + tree. | +| 🟢 Low | `client.py` | `get`/`create_diff`/`get_diff_summary` have no docstring, so `priority` is undocumented there (pre-existing lack of docstrings; feature widens the gap). | **Deferred** — cosmetic; matches existing docstring density. | +| 🟢 Low | `node.py` | Node docstrings omit the "when None → client default" sentence the client docstrings include. | **Deferred** — accurate, just terser. | +| 🟢 Low (advisory) | `client.py` | `if priority is not None: headers[...] = ...` duplicated across 4 funnels; a helper could remove it + the `# noqa: PLR0912`. | **Deferred** — advisory; async/sync split limits gains. | + +Positive observations from review: `Priority._missing_` correct (case-insensitive, no recursion, non-str safe); resolution applied exactly once per funnel; async/sync parity complete; type hints clean; no silent failures; test assertions are genuinely on-the-wire (a reverted feature would fail the mocks), parity is real (sync path exercised), multi-page `all` truly asserts every page. + +## 6. Autonomous decisions + +1. **Ran T001 in the orchestrator** (env sync + baseline) rather than dispatching a subagent for a one-line check; ticked it with a fixup commit. +2. **`client.create` excluded from `priority=`** (T016/T018 ⚠️): it issues no request; the create request path (`node.save`/`create`) carries priority and is tested. Review confirmed this is correct and no request-issuing FR-005 surface was missed. +3. **Merge-order flip** (chunk 4): the subagent discovered the transport helpers let base `self.headers` overwrite per-request headers (defeating the override) and flipped precedence. This was load-bearing and correct for the feature — but the review then caught that it broke the relogin auth-refresh path; the final fix (`_merge_request_headers`) preserves BOTH invariants (per-request wins for non-auth keys; live auth always wins). Full suite confirms no regression. +4. **Closed two Medium + one Low test gap inline** (`2cfe193`) although the workflow only mandates inline fixes for High+. Rationale: these are the core override surfaces; an untested forwarding call site could silently regress. Cheap, low-risk, high-value. No production bug was found while doing so. +5. **Deferred the Low docstring/simplify findings** — cosmetic, no correctness impact; recorded above for a follow-up. +6. **`docs-generate` drift**: regeneration also touched 8 unrelated `.mdx` files already stale vs. the generator; all regenerated output was committed so `docs-validate` stays green (the only way to keep it passing). + +## 7. Suggested next steps + +1. **Open a PR** for `dga/feat-x-priority-aa2nd` → `stable` (this is a public-API change per IHS-259 governance; the PR description should call that out). +2. (Optional) Address the deferred Low findings: add one-line docstrings with the `priority` Args entry to `get`/`create_diff`/`get_diff_summary`, and consider the `_apply_priority` helper to drop the two `# noqa: PLR0912`. +3. The 8 pre-existing `ctl`/`pytest_plugin` unit failures are unrelated to this feature but exist on `stable`'s merge-base — worth a separate ticket if not already tracked. +4. Run `speckit-opsmill-extract` if you want ADRs/guidelines mined from this spec dir. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/plan.md b/dev/specs/ihs-259-sdk-x-priority-header/plan.md new file mode 100644 index 000000000..23fae45f2 --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/plan.md @@ -0,0 +1,115 @@ +# Implementation Plan: SDK `X-Priority` Request Header + +**Branch**: `dga/feat-x-priority-aa2nd` | **Date**: 2026-07-10 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/ihs-259-sdk-x-priority-header/spec.md` + +## Summary + +Add a first-class request-priority concept to the SDK, emitted as an `X-Priority: high|normal|low` HTTP header. Two configuration surfaces: a client-wide default via `Config.priority` (rides every transport by being injected into the client's base `self.headers`) and a per-request `priority=` keyword argument on the covered public methods (resolved as `per_request if per_request is not None else client_default`). When nothing is configured, no header is emitted — byte-for-byte identical to today. Both `InfrahubClient` (async) and `InfrahubClientSync` (sync) behave identically. No server-side logic, no 429 handling. + +**Technical approach** (grounded in the existing `X-Infrahub-Tracker` prior art): + +1. New `Priority(str, enum.Enum)` in `infrahub_sdk/constants.py` with members `HIGH="high"`, `NORMAL="normal"`, `LOW="low"` and a case-insensitive `_missing_` classmethod. +2. New `Config.priority: Priority | None = None` field (auto-binds to `INFRAHUB_PRIORITY` via the existing `env_prefix`). Pydantic + the enum give validation and case-insensitive string coercion for free; unknown values raise at config load. +3. Inject the configured default once into `BaseClient.__init__`'s base `self.headers` (right next to the `X-INFRAHUB-KEY` line). Because every transport already merges `self.headers`, the default automatically rides GraphQL, multipart upload, and raw blob `_get`/`_post`. +4. Add `priority: Priority | None = None` to `execute_graphql` and `_execute_graphql_with_file` (async + sync). These are the single points where the per-request header is applied: `if priority is not None: headers["X-Priority"] = priority.value` on the already-copied header dict — which realises the resolution rule exactly (a `None` per-request keeps whatever the base default was; an explicit value, including `NORMAL`, overrides it). +5. Thread the `priority` kwarg through the higher-level callers so they forward it to the two execute methods: client `get`, `all` (via `filters`), `create`, `create_diff`/`get_diff_summary`/`get_diff_tree`; node `save`/`create`/`update`/`delete`. Raw blob `_get`/`_post` and batch mode inherit the client default only (no per-call override in v1). + +**Two load-bearing details surfaced by the critique** (see [critiques/](./critiques/)): + +- **Pagination**: `all()` calls `execute_graphql` once per page via `filters`. The `priority` kwarg must be forwarded on **every** page request, not just the first — covered by a multi-page test. +- **Multipart ordering**: `_execute_graphql_with_file` pops `content-type` from the copied header dict for multipart. `X-Priority` must be applied **after** that pop (and the default already in `self.headers` survives it, since only `content-type` is removed). Covered by an explicit multipart override test. +- **Batch/blob inheritance**: verified by test (SC-006) — a configured default must ride batch-mode and raw blob requests even though neither exposes a per-request override. + +## Technical Context + +**Language/Version**: Python 3.10–3.13 + +**Primary Dependencies**: pydantic >=2.0, pydantic-settings, httpx, graphql-core + +**Storage**: N/A (stateless HTTP header) + +**Testing**: pytest, pytest-httpx (`HTTPXMock` with `match_headers=`), pytest async auto-mode + +**Target Platform**: Library consumed by network-automation code, `infrahubctl`, and the Infrahub Ansible collection + +**Project Type**: Single-project Python library (async + sync dual client) + +**Performance Goals**: No measurable overhead — a single dict insertion per request; no new network round-trips + +**Constraints**: Zero behaviour change when unconfigured (byte-for-byte identical headers); async/sync parity; no new dependencies; no changes to generated `protocols.py` + +**Scale/Scope**: ~1 new enum, 1 new config field, header injection in `BaseClient.__init__`, and a `priority` kwarg on ~10 public methods across two clients plus the node module + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +The project constitution (`.specify/memory/constitution.md`) is an unpopulated template with no ratified principles, so there are no formal constitutional gates to evaluate. In its place, the binding constraints are the repository's `AGENTS.md` boundaries: + +- **Async/sync dual pattern (Always)** — satisfied: every change is applied to both clients; FR-008 / User Story 5 make parity a first-class, tested requirement. +- **Type hints on all signatures (Always)** — satisfied: the new enum and every touched signature are fully typed (`Priority | None`). +- **Do not modify generated `protocols.py` (Never)** — satisfied: no generated code is touched. +- **No new dependencies (Ask first)** — satisfied: none added. +- **Changing public API signatures (Ask first)** — this feature *intentionally* changes public signatures (new enum, new config field, new `priority` kwarg). This is the explicit, PRD-approved purpose of the ticket (governance gate checked in IHS-259); documented as an accepted assumption in the spec. +- **Docs regeneration (Always, after config/docstring changes)** — handled: `uv run invoke docs-generate` is a task in Phase 4. + +**Result**: PASS (no unjustified violations). Re-checked post-design: unchanged. + +## Project Structure + +### Documentation (this feature) + +```text +specs/ihs-259-sdk-x-priority-header/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output (public API + wire contract) +│ ├── priority-api.md +│ └── x-priority-header.md +├── checklists/ +│ └── requirements.md # From /speckit-specify +└── tasks.md # Phase 2 output (/speckit-tasks) +``` + +### Source Code (repository root) + +```text +infrahub_sdk/ +├── constants.py # ADD: Priority(str, enum.Enum) + case-insensitive _missing_ +├── config.py # ADD: Config.priority field (ConfigBase), imports Priority +├── client.py # EDIT: BaseClient.__init__ base-header injection; +│ # priority kwarg on execute_graphql + _execute_graphql_with_file +│ # (async + sync); thread through get/all/filters/create/diff methods +└── node/ + └── node.py # EDIT: priority kwarg on save/create/update/delete (async + sync), + # forwarded to client execute methods + +tests/unit/sdk/ +├── test_config.py # ADD: Config.priority validation (enum/string/case/reject-unknown) +├── test_priority.py # ADD (new): Priority enum + resolution behaviour +├── test_client.py # ADD: header-on-the-wire assertions (default, override, omit) both clients +├── test_object_store.py # ADD: blob transports carry the default +└── conftest.py # reuse BothClients fixture pattern + +docs/ # regenerated via `uv run invoke docs-generate` +``` + +**Structure Decision**: Single-project library layout (the existing SDK structure). All production changes are confined to `constants.py`, `config.py`, `client.py`, and `node/node.py`; no new modules are required beyond the enum, which lives with the other client enums in `constants.py`. + +## Design Decisions (detail) + +See [research.md](./research.md) for the full decision log. Highlights: + +- **Config field is named `priority`, not `x_priority`** — the PRD specifies `Config.priority`; the `X-` prefix belongs to the wire header, not the config surface. Auto-binds to `INFRAHUB_PRIORITY`. +- **Default injected into base `self.headers`, not at each call site** — this is the mechanism that guarantees FR-003 (every transport) without touching blob/batch code paths, and mirrors how `X-INFRAHUB-KEY` already rides every request. +- **Per-request application lives in `execute_graphql` / `_execute_graphql_with_file` only** — every high-level method funnels through these two, so the resolution rule is implemented once per client (twice total) instead of at ~10 call sites. Higher-level methods only *forward* the kwarg. +- **Resolution is realised by override-if-present on the copied header dict** — `copy.copy(self.headers)` already carries the default; `if priority is not None: headers["X-Priority"] = priority.value` yields exactly `per_request if per_request is not None else client_default`, including the explicit-`NORMAL`-beats-`low`-default edge case. +- **Case-insensitivity via `Priority._missing_`** — handles `LOW`/`Low`/`low` from env/file config and raises for unknown values, satisfying FR-002 and FR-007 with no bespoke validator. + +## Complexity Tracking + +No constitutional violations to justify — this section is intentionally empty. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/quickstart.md b/dev/specs/ihs-259-sdk-x-priority-header/quickstart.md new file mode 100644 index 000000000..241b370dc --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/quickstart.md @@ -0,0 +1,93 @@ +# Quickstart / Validation Guide: SDK `X-Priority` Request Header + +**Feature**: IHS-259 | **Date**: 2026-07-10 + +How to exercise and validate the feature end-to-end. See [contracts/priority-api.md](./contracts/priority-api.md) for the API surface and [data-model.md](./data-model.md) for the resolution truth table. + +## Prerequisites + +```bash +uv sync --all-groups --all-extras +``` + +## Usage examples (what the feature enables) + +### Client-wide default (P1) + +```python +from infrahub_sdk import InfrahubClient, Config +from infrahub_sdk.constants import Priority + +# A client dedicated to background work tags every request low. +client = InfrahubClient(config=Config(address="http://localhost:8000", priority=Priority.LOW)) + +# Every request below carries `X-Priority: low` with no call-site changes: +await client.all(kind="BuiltinTag") # GraphQL +await client.execute_graphql(query=MY_QUERY) # GraphQL +# ...multipart uploads and blob get/post from this client also carry it. +``` + +Via environment / file config (case-insensitive): + +```bash +export INFRAHUB_PRIORITY=LOW # accepted; normalised to Priority.LOW +``` + +### Per-request override (P2) + +```python +client = InfrahubClient(config=Config(address="http://localhost:8000", priority=Priority.LOW)) + +# This one user-triggered call steps up to high; the rest stay low. +node = await client.get(kind="BuiltinTag", name__value="blue", priority=Priority.HIGH) + +# Explicit NORMAL beats a LOW default for this call only: +await client.execute_graphql(query=MY_QUERY, priority=Priority.NORMAL) # -> X-Priority: normal +``` + +### Zero behaviour change when unconfigured (P1) + +```python +client = InfrahubClient(config=Config(address="http://localhost:8000")) # no priority +await client.all(kind="BuiltinTag") # NO X-Priority header — identical to pre-feature SDK +``` + +### Invalid value rejected at config load (P2) + +```python +Config(address="http://localhost:8000", priority="lowe") # raises pydantic.ValidationError +``` + +## Validation scenarios (map to Success Criteria) + +Run the unit suite: + +```bash +uv run pytest tests/unit/sdk/test_priority.py tests/unit/sdk/test_config.py \ + tests/unit/sdk/test_client.py tests/unit/sdk/test_object_store.py -q +``` + +| Scenario | How it is asserted | Criterion | +|----------|--------------------|-----------| +| Default rides GraphQL, multipart, blob | `httpx_mock.add_response(match_headers={"X-Priority": "low"})` for each transport; request only matches if header present | SC-001 | +| Unconfigured client emits no header | capture request via `httpx_mock.get_requests()`, assert `"x-priority" not in request.headers` | SC-002 | +| Per-request override, then revert | override call matches `{"X-Priority": "high"}`; next un-annotated call matches the default (or no header) | SC-003 | +| Explicit `NORMAL` beats `LOW` default | override call matches `{"X-Priority": "normal"}` | SC-003 (edge) | +| Invalid value rejected | `pytest.raises(pydantic.ValidationError, match=...)` on `Config(priority="lowe")` | SC-004 | +| Case-insensitive config accepted | `Config(priority="LOW").priority is Priority.LOW` | FR-002 | +| Async/sync parity | parametrize every wire test over `["standard", "sync"]` via the `BothClients` fixture | SC-005 | + +## Full quality gate (run before commit) + +```bash +uv run invoke format lint-code +uv run invoke docs-generate # required: new Config field + docstrings +uv run invoke docs-validate +uv run pytest tests/unit/ +``` + +## Expected outcomes + +- All new unit tests pass for both `standard` and `sync` clients. +- `docs-validate` passes (generated docs include the new `Config.priority` field). +- No change to any test that exercises an unconfigured client (backwards compatibility intact). diff --git a/dev/specs/ihs-259-sdk-x-priority-header/research.md b/dev/specs/ihs-259-sdk-x-priority-header/research.md new file mode 100644 index 000000000..e4b1ea6ee --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/research.md @@ -0,0 +1,83 @@ +# Research: SDK `X-Priority` Request Header + +**Feature**: IHS-259 | **Date**: 2026-07-10 + +This feature has no external unknowns — the wire contract is fixed by INFP-636 and the PRD. "Research" here is the codebase investigation that fixes the *how*. Each decision below is grounded in existing code (`file:line` references from the current tree). + +## Decision 1 — Where the `Priority` enum lives and its shape + +- **Decision**: `class Priority(str, enum.Enum)` in `infrahub_sdk/constants.py`, members `HIGH = "high"`, `NORMAL = "normal"`, `LOW = "low"`, plus a case-insensitive `_missing_` classmethod. +- **Rationale**: `constants.py` already hosts `InfrahubClientMode(str, enum.Enum)` (`constants.py:4`) and is already imported by both `config.py` (`config.py:11`) and `client.py`. A `str`-valued enum means `Priority.LOW.value == "high"`-style access gives the exact wire token, and the member *is* a `str` so it slots straight into a headers dict. `_missing_` lets `Priority("LOW")` resolve case-insensitively, which pydantic uses when coercing env/file strings. +- **Alternatives considered**: + - `infrahub_sdk/enums.py` (`OrderDirection`, `enums.py:4`) — viable, but `constants.py` is the home for *client/config-consumed* enums (`InfrahubClientMode`), which is exactly this case. + - A `Literal["high","normal","low"]` instead of an enum — rejected: FR-001 explicitly requires a `Priority` enum so callers write `Priority.LOW` and cannot typo the contract. + +```python +# infrahub_sdk/constants.py +class Priority(str, enum.Enum): + HIGH = "high" + NORMAL = "normal" + LOW = "low" + + @classmethod + def _missing_(cls, value: object) -> "Priority | None": + if isinstance(value, str): + for member in cls: + if member.value == value.lower(): + return member + return None +``` + +## Decision 2 — Config field name, type, and validation + +- **Decision**: add `priority: Priority | None = Field(default=None, description=...)` to `ConfigBase` in `config.py`. No custom validator needed. +- **Rationale**: `ConfigBase(BaseSettings)` uses `model_config = SettingsConfigDict(env_prefix="INFRAHUB_", ...)` (`config.py:39`), so the field auto-binds to `INFRAHUB_PRIORITY`. Pydantic v2 coerces an incoming string to the enum, invoking `Priority._missing_` for case-insensitive matches (FR-002) and raising `ValidationError` for unknown values (FR-007). Default `None` means "no header" (FR-004). Existing enum fields (`mode`, `config.py:57`; `transport`, `config.py:87`) confirm the pattern; the closest optional-typed field is `api_token: str | None` (`config.py:41`). +- **Config field is `priority`, not `x_priority`**: the PRD names `Config.priority`; the `X-` prefix is a wire concern only. +- **`clone()` (`config.py:278`)** iterates `Config.model_fields` for any field not explicitly listed, so `priority` is carried into clones automatically — no change required there. +- **Alternatives considered**: a `@field_validator("priority", mode="before")` to lowercase strings — redundant once `_missing_` handles case; rejected to keep validation in one place (the enum). + +## Decision 3 — How the client-wide default rides every transport + +- **Decision**: inject the default into the base header dict in `BaseClient.__init__`, adjacent to the existing auth header: + +```python +# infrahub_sdk/client.py (BaseClient.__init__, ~line 218, after X-INFRAHUB-KEY) +if self.config.priority is not None: + self.headers["X-Priority"] = self.config.priority.value +``` + +- **Rationale**: every transport path copies and re-merges `self.headers` before sending — `execute_graphql` (`client.py:1244`), `_execute_graphql_with_file` (`client.py:1329`), `_post` (`client.py:1447`), `_get` (`client.py:1470`), `_get_streaming` (`client.py:1497`), `_post_multipart` (`client.py:1374`), and the object-store paths (`object_store.py:46,69,98,141,164,193`). Putting the default in `self.headers` once means it automatically covers GraphQL, multipart upload, and raw blob `_get`/`_post` (FR-003, SC-001) — including batch and blob paths that get no per-call override — with no per-call-site edits. This is exactly how `X-INFRAHUB-KEY` (`client.py:217-218`) already behaves. +- **Alternatives considered**: adding the header at each of the ~10 call sites — rejected as error-prone and easy to miss a transport (the very failure FR-003 guards against). + +## Decision 4 — Where and how the per-request override is applied + +- **Decision**: add `priority: Priority | None = None` to `execute_graphql` and `_execute_graphql_with_file` (async at `client.py:1201`/`1290`, sync at `client.py:2181`/`2270`). Immediately after the existing `headers = copy.copy(self.headers or {})` + tracker block, add: + +```python +if priority is not None: + headers["X-Priority"] = priority.value +``` + +- **Rationale**: these two methods are the single funnel for all GraphQL traffic. `copy.copy(self.headers)` already carries the client default, so `if priority is not None: headers["X-Priority"] = priority.value` computes exactly `resolved = per_request if per_request is not None else client_default` (FR-006): `None` keeps the default (or absence); an explicit value overrides it, including an explicit `NORMAL` stepping *up* from a `low` default (spec edge case, SC-003). Implementing it here means the rule exists twice (async + sync), not at every public method. +- **Alternatives considered**: a shared `_apply_priority(headers, priority)` helper — optional nicety; the two-line inline form mirrors the surrounding tracker code and is clearer in context. Left to implementer discretion; both satisfy the contract. + +## Decision 5 — Threading the kwarg through higher-level methods + +- **Decision**: higher-level methods gain `priority: Priority | None = None` and forward it to the execute methods; they do **not** re-implement resolution. + - Client: `get` (`client.py:442`), `all`→`filters` (`client.py:905`/`1131`), `create` (`client.py:400`), `create_diff` (`client.py:1695`), `get_diff_summary` (`client.py:1724`), `get_diff_tree` (`client.py:1763`) — and every sync twin. + - Node: `save` (`node.py:1241`), `create` (`node.py:1602`), `update` (`node.py:1681`), `delete` (`node.py:1214`) — plus sync twins — forward `priority` into `execute_graphql` / `_execute_graphql_with_file`. +- **Rationale**: `get`/`all`/`create` already funnel through `execute_graphql` carrying a `tracker`; adding a parallel `priority` passthrough matches the established shape. Node mutation methods already call the two execute methods (`node.py:1657-1678`), so they only need to forward the new kwarg. +- **Out of scope (v1)**: raw `_get`/`_post`/`_get_streaming` and batch mode expose no per-call override — they inherit the default from `self.headers` (spec Out of Scope + Edge Cases). No `priority` kwarg is added to those. +- **Pagination caveat (from critique E2)**: `all()` renders and calls `execute_graphql` per page (`client.py:1131` / sync `client.py:2907`). Forward `priority` inside the pagination loop so every page request carries it, not only page 1. Cover with a multi-page test. +- **Multipart ordering (from critique E3)**: in `_execute_graphql_with_file`, the existing code pops `content-type` from the copied headers for multipart. Apply the `X-Priority` override **after** the copy/pop so it is not lost; the base default in `self.headers` is unaffected because only `content-type` is removed. + +## Decision 6 — Testing strategy + +- **Decision**: mirror the `X-Infrahub-Tracker` test style — `pytest-httpx`'s `HTTPXMock.add_response(match_headers={"X-Priority": "low"})`, which only matches when the outgoing request carries that header; and negative assertions that no `X-Priority` is present when unconfigured. Parametrize over `["standard", "sync"]` via the existing `BothClients` fixture (`tests/unit/sdk/conftest.py:33-45`) for parity (FR-008/SC-005). +- **Rationale**: `match_headers` is the repo-standard way to assert on outgoing headers (`test_object_store.py:22-29`, `test_client.py:366+`, `test_diff_summary.py:92+`). The `BothClients` fixture is the repo-standard parity harness. +- **Byte-for-byte no-header check (SC-002)**: assert absence via a request captured by the mock (`httpx_mock.get_requests()` → `"x-priority" not in request.headers`), following the direct-header-read style at `test_rate_limit_retry.py:677`. +- **Config validation (SC-004)**: unit tests constructing `Config(priority=...)` with enum, valid strings in mixed case, and an unknown string expecting `pydantic.ValidationError` (assert with `pytest.raises(..., match=...)`). + +## Open questions + +None. The wire contract, resolution rule, transport coverage, and testing approach are all fixed by the PRD and confirmed against the current code. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/spec.md b/dev/specs/ihs-259-sdk-x-priority-header/spec.md new file mode 100644 index 000000000..108bd61fb --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/spec.md @@ -0,0 +1,156 @@ +# Feature Specification: SDK `X-Priority` Request Header + +**Feature Branch**: `dga/feat-x-priority-aa2nd` + +**Created**: 2026-07-10 + +**Status**: Draft + +**Input**: Jira IHS-259 — "feat: SDK X-Priority request header" (PRD). Related: INFP-636 (server-side prioritization, parent), GitHub #1151 (SDK feature issue), GitHub #1124 (429 retry/backoff, complementary). + +## Overview + +Infrahub's background systems (generators, artifacts, diffs, syncs, computed attributes) call back into the same API servers through this SDK, competing on equal footing with human/frontend traffic for a shared worker pool and database connections. Today the API layer cannot distinguish interactive from background traffic, so under heavy background load it cannot preferentially protect the frontend. + +This feature gives the SDK a first-class notion of request **priority** (`high | normal | low`) that it emits as an `X-Priority` HTTP header. The originator of each request — the SDK caller — declares how important the request is, in a form the server can act on. Priority is set two ways: a **client-wide default** (a client dedicated to background work tags everything `low` with no call-site changes) and a **per-request override** on individual operations. When nothing is configured, the SDK sends no header and behaves exactly as today; the server treats absent/unknown values as `normal`, so rollout is safe and incremental. + +This is the SDK's contribution to the server-side prioritization effort in INFP-636. It does **not** implement server-side admission control or 429 handling. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Client-wide default priority (Priority: P1) + +An operator running background workloads constructs one client with a default priority. Every request that client issues then carries `X-Priority: ` with no changes at any call site, so the server can shed that traffic before frontend traffic. + +**Why this priority**: This is the core value of the feature — it lets an entire background workload be tagged uniformly by configuration alone, which is the dominant real-world use case (generators, syncs, `infrahubctl`, the Ansible collection). It is the minimum viable slice: shipping only this already lets operators protect frontend traffic. + +**Independent Test**: Construct a client with `priority=low`, issue one request of each transport type (GraphQL query/mutation, multipart file upload, raw blob `_get`/`_post`), and assert every outgoing request carries `X-Priority: low`. Fully testable without the per-request override existing. + +**Acceptance Scenarios**: + +1. **Given** a client built with a default priority of `low`, **When** it issues a GraphQL query or mutation, **Then** that request carries `X-Priority: low`. +2. **Given** a client built with a default priority of `low`, **When** it issues a multipart file upload, **Then** that request carries `X-Priority: low`. +3. **Given** a client built with a default priority of `low`, **When** it issues a raw blob transfer (`_get`/`_post`), **Then** that request carries `X-Priority: low`. +4. **Given** a client built with a default priority of `normal`, **When** it issues any request, **Then** that request carries `X-Priority: normal` (an explicitly configured default is always emitted). + +--- + +### User Story 2 - Per-request override (Priority: P2) + +A caller overrides priority for a single operation on an otherwise-default client — for example, tagging one user-triggered operation `high` on a client that is otherwise dedicated to background work — without affecting any other call. + +**Why this priority**: Adds targeted control on top of the client-wide default. Valuable but secondary: the default alone delivers the primary outcome, and the override is only meaningful once defaults exist. + +**Independent Test**: On a client with no configured default, invoke a covered method with `priority=Priority.HIGH`, assert that one request carries `X-Priority: high`, then invoke the same method with no priority argument and assert no `X-Priority` header is present. + +**Acceptance Scenarios**: + +1. **Given** a client with no configured default, **When** the caller invokes a covered method with `priority=Priority.HIGH`, **Then** that one request carries `X-Priority: high`. +2. **Given** a client with no configured default, **When** the caller invokes a covered method with no priority argument, **Then** that request carries no `X-Priority` header, and a prior override does not leak into it. +3. **Given** a client with a default priority of `low`, **When** the caller invokes a covered method with `priority=Priority.HIGH`, **Then** that one request carries `X-Priority: high` and the next un-annotated call reverts to `X-Priority: low`. + +--- + +### User Story 3 - Zero behaviour change when unconfigured (Priority: P1) + +An existing SDK user who sets no priority sees no change to outgoing requests after upgrading the SDK. No `X-Priority` header is added anywhere. + +**Why this priority**: Backwards compatibility is a hard safety requirement for a foundational library. It is P1 because a regression here silently changes every existing user's traffic. It is independently testable and gates safe rollout. + +**Independent Test**: With a client constructed exactly as before this feature (no priority configured, no per-request argument), assert that outgoing requests across all transports contain no `X-Priority` header — identical to pre-feature behaviour. + +**Acceptance Scenarios**: + +1. **Given** a client with no priority configured, **When** it issues any request across any transport, **Then** no `X-Priority` header is present. +2. **Given** a client with no priority configured and a call that passes no priority argument, **When** the request is issued, **Then** the outgoing request headers are identical to pre-feature behaviour. + +--- + +### User Story 4 - Invalid configured priority rejected loudly (Priority: P2) + +An SDK developer or operator who configures an invalid priority value (a typo such as `lowe`, or any value outside the closed set) gets a loud failure at configuration-load time, not a silent malformed header on the wire. + +**Why this priority**: Fail-fast on misconfiguration prevents silently shedding or over-prioritising traffic. Secondary to the happy paths but important for operational trust. + +**Independent Test**: Attempt to construct configuration with an unknown priority value and assert it raises a configuration/validation error before any request is issued. + +**Acceptance Scenarios**: + +1. **Given** configuration with a priority value outside the closed set (e.g. `lowe`), **When** the configuration is loaded, **Then** loading fails with a validation error and no client is created. +2. **Given** configuration with a valid priority value in any letter case (e.g. `LOW`, `Low`, `low`), **When** the configuration is loaded, **Then** it is accepted and normalised to the corresponding priority. + +--- + +### User Story 5 - Async and sync parity (Priority: P1) + +A developer using the synchronous client (`InfrahubClientSync`) gets behaviour identical to the asynchronous client (`InfrahubClient`) for every aspect of this feature — configuration, defaults, per-request override, resolution, and the unconfigured no-header case. + +**Why this priority**: The dual async/sync pattern is mandatory in this SDK (per AGENTS.md). Divergence would make the choice of client style silently change semantics. P1 because it is a correctness invariant across the whole feature rather than an add-on. + +**Independent Test**: Run the identical assertion suite (defaults, override, omit-vs-emit, resolution) against both clients and assert identical outcomes. + +**Acceptance Scenarios**: + +1. **Given** the same priority configuration, **When** the assertion suite runs against `InfrahubClient` and against `InfrahubClientSync`, **Then** both produce identical outgoing `X-Priority` behaviour. + +--- + +### Edge Cases + +- **Explicit step-up on a low-default client**: a per-request `NORMAL` on a client whose default is `low` sends `X-Priority: normal` for that call — explicit intent wins, even when stepping *up* from the default. +- **No "send no header" per-request escape once a default is set**: once a client default is configured there is no per-request way to suppress the header; the accepted equivalent is passing `NORMAL` explicitly. +- **Batch mode and raw blob transfers**: these inherit the client default but expose **no** per-call override in v1. +- **Invalid configured value**: errors at configuration load, never at request time. +- **Caller manually pre-populates an `X-Priority` header**: only reachable via the low-level `_get`/`_post` transport methods, which accept a raw `headers=` argument (the covered public methods do not). The resolution rule is the single source of truth (documented behaviour); manually injecting the header at that low level is not a supported side channel and its interaction with resolution is not guaranteed. +- **Explicit per-request `NORMAL` vs. no argument**: an explicit `NORMAL` always emits `X-Priority: normal`; a `None`/absent per-request value falls through to the client default (which may itself be absent, in which case no header is sent). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST expose a closed set of priority values `high | normal | low` as a `Priority` enum, so callers express intent as a typed value rather than a raw header string. +- **FR-002**: Users MUST be able to configure a client-wide default priority via configuration, accepting either a `Priority` enum value or a case-insensitive string (through environment or file configuration). +- **FR-003**: When a default priority is configured, System MUST attach the `X-Priority` header to **every** outgoing request across all transports — GraphQL queries/mutations, multipart uploads, and raw blob `_get`/`_post`. +- **FR-004**: When no priority is configured and none is supplied per request, System MUST omit the `X-Priority` header entirely, producing outgoing requests byte-for-byte identical to current (pre-feature) behaviour. +- **FR-005**: Users MUST be able to override priority per request via a `priority` argument (accepting a `Priority` value or `None`, default `None`) on the covered public methods: `get`, `all`, `create`, `save`, the diff methods, `execute_graphql`, and its file variant. +- **FR-006**: Priority resolution MUST be `resolved = per_request if per_request is not None else client_default`. A resolved value of `None` MUST omit the header; a resolved explicit value MUST be sent, including an explicit `NORMAL`, which MUST send `X-Priority: normal`. +- **FR-007**: System MUST reject an invalid or unknown configured priority value at configuration-load time (a validation/type error) rather than coercing it or silently sending it. +- **FR-008**: The asynchronous client (`InfrahubClient`) and the synchronous client (`InfrahubClientSync`) MUST behave identically for every aspect of this feature. +- **FR-009**: The emitted header MUST be named exactly `X-Priority`, carrying the lowercase value string (`high`, `normal`, `low`) that corresponds to the resolved priority. + +### Key Entities *(include if feature involves data)* + +- **Priority** *(new)*: a closed enumeration with members `HIGH`, `NORMAL`, `LOW`. It is the single in-code representation of request priority, owned by the SDK, with no lifecycle beyond its value. Each member maps to a lowercase wire value (`high`/`normal`/`low`). +- **Configuration** *(extended)*: gains a `priority` field defaulting to `None` (meaning "no default; omit the header"). Accepts a `Priority` value or a case-insensitive string, validated at load time. +- **Base request headers** *(extended)*: the client's base header set is extended so a configured default is injected once and rides every transport, rather than being added at each call site. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A client with a configured default emits `X-Priority: ` on 100% of requests, verified across GraphQL, multipart upload, and blob `_get`/`_post`. +- **SC-002**: A client with no priority configured emits no `X-Priority` header, and no other SDK-set outgoing header changes versus current behaviour (asserted by `X-Priority` being absent from the captured request; not a literal byte-for-byte comparison of transport-injected headers). +- **SC-003**: A per-request override sets the header on exactly that request and leaves the client default intact for the next call. +- **SC-004**: An invalid priority value is rejected at configuration load rather than sent or coerced. +- **SC-005**: The async and sync clients pass the identical assertion suite with identical outcomes. +- **SC-006**: A client with a configured default emits `X-Priority: ` on requests issued via batch mode and via raw blob transfers, confirming those transports inherit the client default (even though they expose no per-request override in v1). + +## Assumptions + +- **Server contract (per INFP-636)**: the header is exactly `X-Priority`; values are case-insensitive on the server; absent or unknown values are treated as `normal` server-side. This makes an absent header and a `normal` value semantically equivalent to the server, which is why omitting the header when unconfigured is safe. +- **Dual async/sync pattern is mandatory**: every change here is applied to both `InfrahubClient` and `InfrahubClientSync`, per AGENTS.md. +- **Public API signature change is accepted**: this feature adds a new enum, a new configuration field, and a new `priority` keyword argument across the covered public method surface — an intentional public-API-signature change (flagged per AGENTS.md "ask first: changing public API signatures"). +- **Docs regeneration is required**: the new configuration field and docstrings require `uv run invoke docs-generate`. +- **Prior art guides implementation**: existing header handling in the client (notably `X-Infrahub-Tracker`) and current `tests/unit` client request tests are the reference for how headers are injected and asserted. + +## Out of Scope + +- 429 / `Retry-After` retry and backoff handling → tracked in GitHub #1124 (complementary, separate). +- Server-side admission control, dedicated-capacity routing, or database-throttle priority awareness → server side, tracked under INFP-636. +- Traffic classification guidance or auto-tagging of call sites (deciding *which* priority a given workload should use). +- Per-batch or per-blob override knobs (batch and blob transfers inherit the client default only, in v1). +- Anti-escalation enforcement (a usage guideline, not enforced in code). + +## Dependencies + +- **INFP-636** — server-side API Request Prioritization (parent effort that defines and consumes the `X-Priority` contract). This SDK feature is only useful once the server acts on the header, but is safe to ship independently because absent/unknown is treated as `normal`. diff --git a/dev/specs/ihs-259-sdk-x-priority-header/tasks.md b/dev/specs/ihs-259-sdk-x-priority-header/tasks.md new file mode 100644 index 000000000..c0ad370ca --- /dev/null +++ b/dev/specs/ihs-259-sdk-x-priority-header/tasks.md @@ -0,0 +1,200 @@ +--- +description: "Task list for SDK X-Priority request header (IHS-259)" +--- + +# Tasks: SDK `X-Priority` Request Header + +**Input**: Design documents from `specs/ihs-259-sdk-x-priority-header/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md + +**Tests**: Included — the PRD (IHS-259 "Testing Decisions") and Success Criteria SC-001…SC-006 explicitly require unit + contract tests. Tests are first-class here. + +**Organization**: Tasks are grouped by user story (from spec.md), in priority order. The MVP is User Story 1. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependency on an incomplete task) +- **[Story]**: US1…US5 (setup / foundational / polish tasks have no story label) + +## Path Conventions + +Single-project Python library. Production code under `infrahub_sdk/`; tests under `tests/unit/sdk/`. Paths below are repository-relative (the `specs/` symlink resolves to `dev/specs/`). + +## Prior art to mirror + +- Header injection & per-request merge: `X-INFRAHUB-KEY` (`infrahub_sdk/client.py:217-218`) and `X-Infrahub-Tracker` (`client.py:1244-1246`, `1329-1333`, `2225-2226`, `2312-2313`). +- Enum pattern: `InfrahubClientMode(str, enum.Enum)` (`infrahub_sdk/constants.py:4`). +- Config enum field: `mode` / `transport` (`config.py:57`, `config.py:87`). +- Header-on-the-wire tests: `match_headers={...}` (`tests/unit/sdk/test_object_store.py:22-29`, `test_client.py:366+`); `BothClients` parity fixture (`tests/unit/sdk/conftest.py:33-45`). + +--- + +## Phase 1: Setup + +**Purpose**: Confirm the working environment before touching code. + +- [X] T001 Ensure dev dependencies are installed and the baseline is green: run `uv sync --all-groups --all-extras` then `uv run pytest tests/unit/sdk/test_config.py tests/unit/sdk/test_client.py -q` to confirm a clean starting point. (Done: 107 passed on baseline.) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The `Priority` enum and `Config.priority` field are prerequisites for every user story. MUST complete before Phase 3+. + +- [X] T002 Add `class Priority(str, enum.Enum)` with members `HIGH = "high"`, `NORMAL = "normal"`, `LOW = "low"` and a case-insensitive `_missing_` classmethod (returns the member matching `value.lower()`, else `None`) to `infrahub_sdk/constants.py` (mirror `InfrahubClientMode`). Add module docstring/type hints per repo style. +- [X] T003 [P] Export `Priority` from the SDK public namespace: add it to `infrahub_sdk/__init__.py` imports and `__all__` (alongside other public enums), per contracts/priority-api.md. +- [X] T004 Add `priority: Priority | None = Field(default=None, description="Default request priority emitted as the X-Priority header on every request; one of high|normal|low (case-insensitive). When unset, no header is sent.")` to `ConfigBase` in `infrahub_sdk/config.py`; import `Priority` from `.constants`. Confirm the field auto-binds to `INFRAHUB_PRIORITY` (no custom source needed) and is carried by `Config.clone()` (it iterates `model_fields`, so no change to `clone()` required — verify only). + +**Checkpoint**: `Priority` importable from `infrahub_sdk`; `Config(priority=...)` accepts enum/string; the SDK still imports and existing tests still pass. + +--- + +## Phase 3: User Story 1 - Client-wide default priority (Priority: P1) 🎯 MVP + +**Goal**: A client built with a default priority emits `X-Priority: ` on every request across all transports, with no call-site changes. + +**Independent test**: Construct a client with `priority=Priority.LOW`; issue one GraphQL, one multipart upload, and one blob `_get`/`_post`; assert each outgoing request carries `X-Priority: low`. + +### Implementation + +- [X] T005 [US1] In `BaseClient.__init__` (`infrahub_sdk/client.py`, next to the `X-INFRAHUB-KEY` block ~line 217-218), inject the default once: `if self.config.priority is not None: self.headers["X-Priority"] = self.config.priority.value`. This single edit covers async and sync (both subclass `BaseClient`) and, because every transport re-merges `self.headers`, rides GraphQL, multipart, and raw blob transports automatically. + +### Tests + +- [X] T006 [P] [US1] Add `tests/unit/sdk/test_priority.py`: assert a `priority=Priority.LOW` client emits `X-Priority: low` on a GraphQL query and a mutation, for both clients (`match_headers={"X-Priority": "low"}`, parametrized over the `BothClients` fixture). (SC-001, FR-003) +- [X] T007 [P] [US1] In `tests/unit/sdk/test_object_store.py` (or `test_priority.py`), assert a `priority=Priority.LOW` client emits `X-Priority: low` on a blob download (`_get_streaming`) and upload (`_post`/object-store), both clients. (SC-001, SC-006 blob) +- [X] T008 [P] [US1] Add a multipart-upload test: a `priority=Priority.LOW` client emits `X-Priority: low` on `_execute_graphql_with_file`, both clients (confirm the header survives the `content-type` pop). (SC-001, FR-003) +- [X] T009 [P] [US1] Add a batch-mode test: a `priority=Priority.LOW` client issues a batched operation and every batched request carries `X-Priority: low`. (SC-006 batch) +- [X] T010 [P] [US1] Add a test that a `priority=Priority.NORMAL` client emits `X-Priority: normal` on requests (an explicitly configured default is always emitted, not omitted), both clients. (US1 acceptance #4, FR-006) + +**Checkpoint**: US1 fully testable and green independently — this is a shippable MVP. + +--- + +## Phase 4: User Story 3 - Zero behaviour change when unconfigured (Priority: P1) + +**Goal**: A client with no priority configured (and no per-request arg) emits no `X-Priority` header anywhere — identical to the pre-feature SDK. + +**Independent test**: Construct a client with no `priority`; issue requests across transports; assert no `X-Priority` header is present. + +**Note**: The conditional in T005 already implements the omit path; this phase locks it with tests. Depends on T005. + +### Tests + +- [X] T011 [P] [US3] In `tests/unit/sdk/test_priority.py`, assert an unconfigured client emits **no** `X-Priority` header across GraphQL, multipart, and blob transports — capture the request via `httpx_mock.get_requests()` and assert `"x-priority" not in request.headers`, both clients. (SC-002, FR-004) +- [X] T012 [P] [US3] Assert that with no priority configured, no per-request arg, the SDK-set outgoing headers are unchanged versus baseline (only `X-Priority` absence matters; do not assert on transport-injected headers like host/user-agent). (SC-002) + +**Checkpoint**: Backwards compatibility proven for both clients. + +--- + +## Phase 5: User Story 2 - Per-request override (Priority: P2) + +**Goal**: A `priority=` argument on the covered public methods overrides the client default for exactly one request, resolving as `per_request if per_request is not None else client_default`. + +**Independent test**: On a client with no default, call a covered method with `priority=Priority.HIGH` (asserts `X-Priority: high`), then call it again with no arg (asserts no header). + +### Implementation + +- [X] T013 [US2] Add `priority: Priority | None = None` to async `execute_graphql` (`client.py:1201`) and apply the override after the existing `headers = copy.copy(self.headers or {})` + tracker block: `if priority is not None: headers["X-Priority"] = priority.value`. (FR-005, FR-006) +- [X] T014 [US2] Add `priority: Priority | None = None` to async `_execute_graphql_with_file` (`client.py:1290`); apply the override **after** the copy and the `content-type` pop so it is not lost. (FR-005, FR-006, critique E3) +- [X] T015 [US2] Mirror T013–T014 on the sync client: `execute_graphql` (`client.py:2181`) and `_execute_graphql_with_file` (`client.py:2270`). (FR-008) +- [X] T016 [US2] Thread `priority` through the client high-level methods so they forward it to the execute funnels: async `get` (`client.py:442`), `all`→`filters` (`client.py:905`/`1131`), `create` (`client.py:400`); ensure the pagination loop in `filters` forwards `priority` on **every** page request. (FR-005, critique E2) +- [X] T017 [US2] Thread `priority` through the diff methods: `create_diff` (`client.py:1695`), `get_diff_summary` (`client.py:1724`), `get_diff_tree` (`client.py:1763`), forwarding to `execute_graphql`. (FR-005) +- [X] T018 [US2] Mirror T016–T017 on the sync client (`get` `client.py:2975`, `all` `client.py:2639`/`2907`, `create` `client.py:2137`, `create_diff` `client.py:3266`, `get_diff_summary`/`get_diff_tree`). (FR-008) +- [X] T019 [US2] Add `priority: Priority | None = None` to async node methods and forward to the client execute calls: `save` (`node/node.py:1241`), `create` (`node/node.py:1602`), `update` (`node/node.py:1681`), `delete` (`node/node.py:1214`). (FR-005) +- [X] T020 [US2] Mirror T019 on the sync node (`InfrahubNodeSync`: `delete` `node/node.py:2402`, `save` `node/node.py:2429`, plus `create`/`update`). (FR-008) + +### Tests + +- [X] T021 [P] [US2] Test: no-default client + `priority=Priority.HIGH` on `execute_graphql` emits `X-Priority: high`; a following un-annotated call emits no header (no leak). Both clients. (SC-003, US2 acceptance #1/#2) +- [X] T022 [P] [US2] Test: `priority=Priority.LOW` default client + per-request `priority=Priority.HIGH` emits `X-Priority: high` for that call, and the next un-annotated call reverts to `X-Priority: low`. Both clients. (SC-003, US2 acceptance #3) +- [X] T023 [P] [US2] Test: `priority=Priority.LOW` default client + per-request `priority=Priority.NORMAL` emits `X-Priority: normal` (explicit step-up wins). Both clients. (spec Edge Cases, SC-003) +- [X] T024 [P] [US2] Test the override on the covered surfaces: `get`, `all` (multi-page — assert every page request carries the override), `create`, `save`, a diff method, and `_execute_graphql_with_file`. Both clients. (FR-005, critique E2/E3) + +**Checkpoint**: Override works and resolves correctly on every covered surface, both clients. + +--- + +## Phase 6: User Story 4 - Invalid configured priority rejected (Priority: P2) + +**Goal**: An invalid/unknown configured priority fails at config load; valid strings in any case are accepted. + +**Independent test**: `Config(priority="lowe")` raises; `Config(priority="LOW").priority is Priority.LOW`. + +**Note**: Validation comes for free from the enum + `_missing_` (T002/T004); this phase locks it with tests. + +### Tests + +- [X] T025 [P] [US4] In `tests/unit/sdk/test_config.py`, assert `Config(address="http://localhost:8000", priority="lowe")` raises `pydantic.ValidationError` (use `pytest.raises(..., match=...)`); assert no request is issued. (SC-004, FR-007) +- [X] T026 [P] [US4] Assert case-insensitive acceptance: `Config(priority="LOW")`, `Config(priority="Low")`, `Config(priority="low")`, and `Config(priority=Priority.LOW)` all yield `Priority.LOW`; likewise for HIGH/NORMAL. Include the env-var path `INFRAHUB_PRIORITY=LOW` via `monkeypatch`. (SC-004, FR-002) +- [X] T027 [P] [US4] Assert `Config()` default → `priority is None` (no default, header omitted). (FR-004) + +**Checkpoint**: Misconfiguration fails loudly; valid config in any case is accepted. + +--- + +## Phase 7: User Story 5 - Async / sync parity (Priority: P1) + +**Goal**: Every aspect behaves identically on `InfrahubClient` and `InfrahubClientSync`. + +**Independent test**: The same assertion suite runs against both clients with identical outcomes. + +- [X] T028 [US5] Audit T006–T012 and T021–T024 to confirm every wire/resolution test is parametrized over the `BothClients` fixture (`["standard","sync"]`); add parametrization to any that isn't. (SC-005, FR-008) +- [X] T029 [P] [US5] Add a focused parity test asserting the resolution truth table (data-model.md) produces identical emitted headers for both clients across the default × override combinations. (SC-005) + +**Checkpoint**: Parity is explicit and enforced, not incidental. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Docs, quality gates, and release hygiene. + +- [X] T030 Add docstrings to the new `Priority` enum, the `Config.priority` field, and the `priority` kwarg on the covered public methods (drives generated docs). +- [X] T031 Run `uv run invoke docs-generate`, then `uv run invoke docs-validate`; commit the regenerated docs (new `Config.priority` field). (Governance gate: docs regeneration) +- [X] T032 [P] Add a changelog fragment under `changelog/` (mirror the existing fragment style, e.g. an `.added.md` for the new `Priority`/`Config.priority`/`priority=` surface referencing IHS-259 / #1151). +- [X] T033 Run `uv run invoke format lint-code` (ruff, ty, mypy) and fix any findings; confirm type hints on all new/changed signatures. +- [X] T034 Run the full `uv run pytest tests/unit/` suite and confirm green (including all new priority tests for both clients). +- [X] T035 Validate against quickstart.md: run the mapped validation scenarios and confirm SC-001…SC-006 are all covered. + +--- + +## Dependencies & Execution Order + +- **Phase 1 (Setup)** → **Phase 2 (Foundational: T002–T004)** must complete first; they block everything. +- **Phase 3 (US1)** depends on T004 (config field) + T005 (base injection). This is the MVP. +- **Phase 4 (US3)** depends on T005 (shares the conditional-injection code). Can be developed right after US1. +- **Phase 5 (US2)** depends on Foundational (enum) and is independent of US1's base injection for its core (works on a no-default client), but its "override beats default" tests (T022–T023) depend on T005. +- **Phase 6 (US4)** depends only on Foundational (T002/T004). +- **Phase 7 (US5)** depends on the tests from US1–US4 existing (it audits/extends them). +- **Phase 8 (Polish)** last. + +### Story independence + +- US1, US3, US4 are each independently testable after Foundational. +- US2 is independently testable on a no-default client after Foundational; full override-vs-default coverage wants T005. +- US5 is a cross-cutting invariant realized by parametrizing the other stories' tests. + +### Parallelization + +- **Foundational**: T003 [P] can run alongside T002/T004 once `Priority` exists. +- **Within US1**: T006–T010 are all [P] (independent test files/cases) once T005 lands. +- **Within US2**: implementation T013–T020 touch overlapping regions of `client.py`/`node.py` (mostly sequential per file); tests T021–T024 are [P]. +- **Within US4**: T025–T027 are [P]. +- **Polish**: T032 [P]; T031/T033/T034 are sequential gates. + +## Implementation Strategy + +1. **MVP first**: Phases 1–3 (Setup → Foundational → US1). Ship a client-wide default that rides every transport. +2. **Lock safety**: Phase 4 (US3) — prove zero behaviour change when unconfigured. +3. **Add control**: Phase 5 (US2) — per-request override. +4. **Harden**: Phases 6–7 (US4 validation, US5 parity). +5. **Finish**: Phase 8 — docs, changelog, quality gates. + +## Task summary + +- **Total tasks**: 35 +- **By story**: Foundational 3 (T002–T004) + Setup 1 + US1 6 (T005–T010) + US3 2 (T011–T012) + US2 12 (T013–T024) + US4 3 (T025–T027) + US5 2 (T028–T029) + Polish 6 (T030–T035) +- **MVP scope**: T001–T010 (Setup + Foundational + US1) diff --git a/docs/docs/python-sdk/guides/branches.mdx b/docs/docs/python-sdk/guides/branches.mdx index 64016048e..ffe2049d7 100644 --- a/docs/docs/python-sdk/guides/branches.mdx +++ b/docs/docs/python-sdk/guides/branches.mdx @@ -108,15 +108,40 @@ The Python SDK provides multiple methods to manage the branches in an Infrahub i -## Generating a diff for a branch +## Getting the diff for a branch + +Use `get_diff_tree` to retrieve the full diff of a branch compared to its base branch, including summary counts and the list of changed nodes. It returns `None` if no diff exists for the branch. + + + + + ```python + from infrahub_sdk import InfrahubClient + client = InfrahubClient() + diff = await client.get_diff_tree(branch="new-branch") + ``` + + + + + ```python + from infrahub_sdk import InfrahubClientSync + client = InfrahubClientSync() + diff = client.get_diff_tree(branch="new-branch") + ``` + + + + +If you only need the list of changed nodes, `get_diff_summary` returns them without the diff metadata. ```python from infrahub_sdk import InfrahubClient - client = await InfrahubClient() - diff = await client.branch.diff_data(branch_name="new-branch") + client = InfrahubClient() + node_diffs = await client.get_diff_summary(branch="new-branch") ``` @@ -125,7 +150,7 @@ The Python SDK provides multiple methods to manage the branches in an Infrahub i ```python from infrahub_sdk import InfrahubClientSync client = InfrahubClientSync() - diff = client.branch.diff_data(branch_name="new-branch") + node_diffs = client.get_diff_summary(branch="new-branch") ``` diff --git a/docs/docs/python-sdk/reference/config.mdx b/docs/docs/python-sdk/reference/config.mdx index c07c2317e..b8750fe0a 100644 --- a/docs/docs/python-sdk/reference/config.mdx +++ b/docs/docs/python-sdk/reference/config.mdx @@ -133,6 +133,14 @@ The following settings can be defined in the `Config` class **Environment variable**: `INFRAHUB_PAGINATION_SIZE`
+## priority + + +**Description**: Default request priority emitted as the X-Priority header on every request; one of high|medium|low (case-insensitive). When unset, no header is sent.
+**Type**: `object`
+**Environment variable**: `INFRAHUB_PRIORITY`
+ + ## retry_delay @@ -165,7 +173,7 @@ The following settings can be defined in the `Config` class **Description**: Maximum number of retries after the initial attempt when receiving HTTP 429.
**Type**: `integer`
-**Default value**: 5
+**Default value**: 10
**Environment variable**: `INFRAHUB_RATE_LIMIT_MAX_RETRIES`
diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx index 0d9445a17..1f783d46c 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx @@ -16,7 +16,7 @@ GraphQL Client to interact with Infrahub. #### `get` ```python -get(self, kind: type[SchemaType], raise_when_missing: Literal[False], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> SchemaType | None +get(self, kind: type[SchemaType], raise_when_missing: Literal[False], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> SchemaType | None ```
@@ -25,37 +25,37 @@ get(self, kind: type[SchemaType], raise_when_missing: Literal[False], at: Timest #### `get` ```python -get(self, kind: type[SchemaType], raise_when_missing: Literal[True], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> SchemaType +get(self, kind: type[SchemaType], raise_when_missing: Literal[True], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> SchemaType ``` #### `get` ```python -get(self, kind: type[SchemaType], raise_when_missing: bool = ..., at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> SchemaType +get(self, kind: type[SchemaType], raise_when_missing: bool = ..., at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> SchemaType ``` #### `get` ```python -get(self, kind: str, raise_when_missing: Literal[False], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> InfrahubNode | None +get(self, kind: str, raise_when_missing: Literal[False], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> InfrahubNode | None ``` #### `get` ```python -get(self, kind: str, raise_when_missing: Literal[True], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> InfrahubNode +get(self, kind: str, raise_when_missing: Literal[True], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> InfrahubNode ``` #### `get` ```python -get(self, kind: str, raise_when_missing: bool = ..., at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> InfrahubNode +get(self, kind: str, raise_when_missing: bool = ..., at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> InfrahubNode ``` #### `get` ```python -get(self, kind: str | type[SchemaType], raise_when_missing: bool = True, at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, id: str | None = None, hfid: list[str] | None = None, include: list[str] | None = None, exclude: list[str] | None = None, populate_store: bool = True, fragment: bool = False, prefetch_relationships: bool = False, property: bool = False, include_metadata: bool = False, query_name: str | None = None, **kwargs: Any) -> InfrahubNode | SchemaType | None +get(self, kind: str | type[SchemaType], raise_when_missing: bool = True, at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, id: str | None = None, hfid: list[str] | None = None, include: list[str] | None = None, exclude: list[str] | None = None, populate_store: bool = True, fragment: bool = False, prefetch_relationships: bool = False, property: bool = False, include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, **kwargs: Any) -> InfrahubNode | SchemaType | None ```
@@ -124,7 +124,7 @@ Return user permissions. #### `count` ```python -count(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, partial_match: bool = False, query_name: str | None = None, **kwargs: Any) -> int +count(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, partial_match: bool = False, query_name: str | None = None, priority: Priority | None = None, **kwargs: Any) -> int ``` Return the number of nodes of a given kind. @@ -231,7 +231,7 @@ Requires Infrahub 1.10 or later. #### `all` ```python -all(self, kind: type[SchemaType], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ...) -> list[SchemaType] +all(self, kind: type[SchemaType], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ...) -> list[SchemaType] ```
@@ -240,13 +240,13 @@ all(self, kind: type[SchemaType], at: Timestamp | None = ..., branch: str | None #### `all` ```python -all(self, kind: str, at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ...) -> list[InfrahubNode] +all(self, kind: str, at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ...) -> list[InfrahubNode] ``` #### `all` ```python -all(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, populate_store: bool = True, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, property: bool = False, parallel: bool = False, order: Order | None = None, include_metadata: bool = False, query_name: str | None = None) -> list[InfrahubNode] | list[SchemaType] +all(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, populate_store: bool = True, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, property: bool = False, parallel: bool = False, order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None) -> list[InfrahubNode] | list[SchemaType] ``` Retrieve all nodes of a given kind. @@ -268,6 +268,8 @@ Retrieve all nodes of a given kind. - `order`: Ordering related options. Setting `disable=True` enhances performances. - `include_metadata`: If True, includes node_metadata and relationship_metadata in the query. - `query_name`: If provided is used as the GraphQL operation name else All_<kind> is used. +- `priority`: Per-request priority emitted as the X-Priority header, overriding the +client default for these requests only. When None, the client default (if any) is used. **Returns:** @@ -278,7 +280,7 @@ Retrieve all nodes of a given kind. #### `filters` ```python -filters(self, kind: type[SchemaType], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., partial_match: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> list[SchemaType] +filters(self, kind: type[SchemaType], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., partial_match: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> list[SchemaType] ```
@@ -287,13 +289,13 @@ filters(self, kind: type[SchemaType], at: Timestamp | None = ..., branch: str | #### `filters` ```python -filters(self, kind: str, at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., partial_match: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> list[InfrahubNode] +filters(self, kind: str, at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., partial_match: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> list[InfrahubNode] ``` #### `filters` ```python -filters(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, populate_store: bool = True, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, parallel: bool = False, order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, **kwargs: Any) -> list[InfrahubNode] | list[SchemaType] +filters(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, populate_store: bool = True, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, parallel: bool = False, order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, **kwargs: Any) -> list[InfrahubNode] | list[SchemaType] ``` Retrieve nodes of a given kind based on provided filters. @@ -316,6 +318,8 @@ Retrieve nodes of a given kind based on provided filters. - `order`: Ordering related options. Setting `disable=True` enhances performances. - `include_metadata`: If True, includes node_metadata and relationship_metadata in the query. - `query_name`: If provided is used as the GraphQL operation name else Filters_<kind> is used. +- `priority`: Per-request priority emitted as the X-Priority header, overriding the +client default for these requests only. When None, the client default (if any) is used. - `**kwargs`: Additional filter criteria for the query. **Returns:** @@ -335,7 +339,7 @@ Return a cloned version of the client using the same configuration. #### `execute_graphql` ```python -execute_graphql(self, query: str, variables: dict | None = None, branch_name: str | None = None, at: str | Timestamp | None = None, timeout: int | None = None, tracker: str | None = None, operation_name: str | None = None) -> dict +execute_graphql(self, query: str, variables: dict | None = None, branch_name: str | None = None, at: str | Timestamp | None = None, timeout: int | None = None, tracker: str | None = None, operation_name: str | None = None, priority: Priority | None = None) -> dict ``` Execute a GraphQL query (or mutation). @@ -351,6 +355,8 @@ If retry_on_failure is True, the query will retry until the server becomes reach - `timeout`: Timeout in second for the query. Defaults to None. - `operation_name`: GraphQL operation name, sent as `operationName` in the request payload so tracing/observability tools can identify the operation. Defaults to None. +- `priority`: Per-request priority emitted as the X-Priority header. Overrides the +client-wide default for this request only. When None, the client default (if any) is used. **Returns:** @@ -385,19 +391,19 @@ query_gql_query(self, name: str, variables: dict | None = None, update_group: bo #### `create_diff` ```python -create_diff(self, branch: str, name: str, from_time: datetime, to_time: datetime, wait_until_completion: bool = True) -> bool | str +create_diff(self, branch: str, name: str, from_time: datetime, to_time: datetime, wait_until_completion: bool = True, priority: Priority | None = None) -> bool | str ``` #### `get_diff_summary` ```python -get_diff_summary(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None) -> list[NodeDiff] +get_diff_summary(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None) -> list[NodeDiff] ``` #### `get_diff_tree` ```python -get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None) -> DiffTreeData | None +get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None) -> DiffTreeData | None ``` Get complete diff tree with metadata and nodes. @@ -534,7 +540,7 @@ mapping. See https://docs.infrahub.app/guides/object-conversion for more informa #### `get` ```python -get(self, kind: type[SchemaTypeSync], raise_when_missing: Literal[False], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> SchemaTypeSync | None +get(self, kind: type[SchemaTypeSync], raise_when_missing: Literal[False], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> SchemaTypeSync | None ```
@@ -543,37 +549,37 @@ get(self, kind: type[SchemaTypeSync], raise_when_missing: Literal[False], at: Ti #### `get` ```python -get(self, kind: type[SchemaTypeSync], raise_when_missing: Literal[True], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> SchemaTypeSync +get(self, kind: type[SchemaTypeSync], raise_when_missing: Literal[True], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> SchemaTypeSync ``` #### `get` ```python -get(self, kind: type[SchemaTypeSync], raise_when_missing: bool = ..., at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> SchemaTypeSync +get(self, kind: type[SchemaTypeSync], raise_when_missing: bool = ..., at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> SchemaTypeSync ``` #### `get` ```python -get(self, kind: str, raise_when_missing: Literal[False], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> InfrahubNodeSync | None +get(self, kind: str, raise_when_missing: Literal[False], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> InfrahubNodeSync | None ``` #### `get` ```python -get(self, kind: str, raise_when_missing: Literal[True], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> InfrahubNodeSync +get(self, kind: str, raise_when_missing: Literal[True], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> InfrahubNodeSync ``` #### `get` ```python -get(self, kind: str, raise_when_missing: bool = ..., at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> InfrahubNodeSync +get(self, kind: str, raise_when_missing: bool = ..., at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., id: str | None = ..., hfid: list[str] | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., populate_store: bool = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> InfrahubNodeSync ``` #### `get` ```python -get(self, kind: str | type[SchemaTypeSync], raise_when_missing: bool = True, at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, id: str | None = None, hfid: list[str] | None = None, include: list[str] | None = None, exclude: list[str] | None = None, populate_store: bool = True, fragment: bool = False, prefetch_relationships: bool = False, property: bool = False, include_metadata: bool = False, query_name: str | None = None, **kwargs: Any) -> InfrahubNodeSync | SchemaTypeSync | None +get(self, kind: str | type[SchemaTypeSync], raise_when_missing: bool = True, at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, id: str | None = None, hfid: list[str] | None = None, include: list[str] | None = None, exclude: list[str] | None = None, populate_store: bool = True, fragment: bool = False, prefetch_relationships: bool = False, property: bool = False, include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, **kwargs: Any) -> InfrahubNodeSync | SchemaTypeSync | None ```
@@ -650,7 +656,7 @@ Return a cloned version of the client using the same configuration. #### `execute_graphql` ```python -execute_graphql(self, query: str, variables: dict | None = None, branch_name: str | None = None, at: str | Timestamp | None = None, timeout: int | None = None, tracker: str | None = None, operation_name: str | None = None) -> dict +execute_graphql(self, query: str, variables: dict | None = None, branch_name: str | None = None, at: str | Timestamp | None = None, timeout: int | None = None, tracker: str | None = None, operation_name: str | None = None, priority: Priority | None = None) -> dict ``` Execute a GraphQL query (or mutation). @@ -666,6 +672,8 @@ If retry_on_failure is True, the query will retry until the server becomes reach - `timeout`: Timeout in second for the query. Defaults to None. - `operation_name`: GraphQL operation name, sent as `operationName` in the request payload so tracing/observability tools can identify the operation. Defaults to None. +- `priority`: Per-request priority emitted as the X-Priority header. Overrides the +client-wide default for this request only. When None, the client default (if any) is used. **Returns:** @@ -682,7 +690,7 @@ so tracing/observability tools can identify the operation. Defaults to None. #### `count` ```python -count(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, partial_match: bool = False, query_name: str | None = None, **kwargs: Any) -> int +count(self, kind: str | type[SchemaType], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, partial_match: bool = False, query_name: str | None = None, priority: Priority | None = None, **kwargs: Any) -> int ``` Return the number of nodes of a given kind. @@ -789,7 +797,7 @@ Requires Infrahub 1.10 or later. #### `all` ```python -all(self, kind: type[SchemaTypeSync], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ...) -> list[SchemaTypeSync] +all(self, kind: type[SchemaTypeSync], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ...) -> list[SchemaTypeSync] ```
@@ -798,13 +806,13 @@ all(self, kind: type[SchemaTypeSync], at: Timestamp | None = ..., branch: str | #### `all` ```python -all(self, kind: str, at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ...) -> list[InfrahubNodeSync] +all(self, kind: str, at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ...) -> list[InfrahubNodeSync] ``` #### `all` ```python -all(self, kind: str | type[SchemaTypeSync], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, populate_store: bool = True, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, property: bool = False, parallel: bool = False, order: Order | None = None, include_metadata: bool = False, query_name: str | None = None) -> list[InfrahubNodeSync] | list[SchemaTypeSync] +all(self, kind: str | type[SchemaTypeSync], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, populate_store: bool = True, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, property: bool = False, parallel: bool = False, order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None) -> list[InfrahubNodeSync] | list[SchemaTypeSync] ``` Retrieve all nodes of a given kind. @@ -826,6 +834,8 @@ Retrieve all nodes of a given kind. - `order`: Ordering related options. Setting `disable=True` enhances performances. - `include_metadata`: If True, includes node_metadata and relationship_metadata in the query. - `query_name`: If provided is used as the GraphQL operation name else All_<kind> is used. +- `priority`: Per-request priority emitted as the X-Priority header, overriding the +client default for these requests only. When None, the client default (if any) is used. **Returns:** @@ -836,7 +846,7 @@ Retrieve all nodes of a given kind. #### `filters` ```python -filters(self, kind: type[SchemaTypeSync], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., partial_match: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> list[SchemaTypeSync] +filters(self, kind: type[SchemaTypeSync], at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., partial_match: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> list[SchemaTypeSync] ```
@@ -845,13 +855,13 @@ filters(self, kind: type[SchemaTypeSync], at: Timestamp | None = ..., branch: st #### `filters` ```python -filters(self, kind: str, at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., partial_match: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., **kwargs: Any) -> list[InfrahubNodeSync] +filters(self, kind: str, at: Timestamp | None = ..., branch: str | None = ..., timeout: int | None = ..., populate_store: bool = ..., offset: int | None = ..., limit: int | None = ..., include: list[str] | None = ..., exclude: list[str] | None = ..., fragment: bool = ..., prefetch_relationships: bool = ..., partial_match: bool = ..., property: bool = ..., parallel: bool = ..., order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., **kwargs: Any) -> list[InfrahubNodeSync] ``` #### `filters` ```python -filters(self, kind: str | type[SchemaTypeSync], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, populate_store: bool = True, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, parallel: bool = False, order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, **kwargs: Any) -> list[InfrahubNodeSync] | list[SchemaTypeSync] +filters(self, kind: str | type[SchemaTypeSync], at: Timestamp | None = None, branch: str | None = None, timeout: int | None = None, populate_store: bool = True, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, parallel: bool = False, order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, **kwargs: Any) -> list[InfrahubNodeSync] | list[SchemaTypeSync] ``` Retrieve nodes of a given kind based on provided filters. @@ -874,6 +884,8 @@ Retrieve nodes of a given kind based on provided filters. - `order`: Ordering related options. Setting `disable=True` enhances performances. - `include_metadata`: If True, includes node_metadata and relationship_metadata in the query. - `query_name`: If provided is used as the GraphQL operation name else Filters_<kind> is used. +- `priority`: Per-request priority emitted as the X-Priority header, overriding the +client default for these requests only. When None, the client default (if any) is used. - `**kwargs`: Additional filter criteria for the query. **Returns:** @@ -908,19 +920,19 @@ query_gql_query(self, name: str, variables: dict | None = None, update_group: bo #### `create_diff` ```python -create_diff(self, branch: str, name: str, from_time: datetime, to_time: datetime, wait_until_completion: bool = True) -> bool | str +create_diff(self, branch: str, name: str, from_time: datetime, to_time: datetime, wait_until_completion: bool = True, priority: Priority | None = None) -> bool | str ``` #### `get_diff_summary` ```python -get_diff_summary(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None) -> list[NodeDiff] +get_diff_summary(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None) -> list[NodeDiff] ``` #### `get_diff_tree` ```python -get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None) -> DiffTreeData | None +get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None) -> DiffTreeData | None ``` Get complete diff tree with metadata and nodes. @@ -1002,7 +1014,7 @@ Allocate a new IP prefix by using the provided resource pool. - `resource_pool`: Node corresponding to the pool to allocate resources from. - `identifier`: Value to perform idempotent allocation, the same resource will be returned for a given identifier. -- `size`: Length of the prefix to allocate. +- `prefix_length`: Length of the prefix to allocate. - `member_type`: Member type of the prefix to allocate. - `prefix_type`: Kind of the prefix to allocate. - `data`: A key/value map to use to set attributes values on the allocated prefix. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx index 99671d1ed..ef2823562 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx @@ -257,7 +257,7 @@ from ``source.name`` when ``name`` is omitted. #### `delete` ```python -delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None +delete(self, timeout: int | None = None, request_context: RequestContext | None = None, priority: Priority | None = None) -> None ``` Delete this node on the backend. @@ -268,11 +268,13 @@ Delete this node on the backend. GraphQL API. Specified in seconds. - `request_context`: Request-level context passed through to the mutation. When omitted, the client's request context is used. +- `priority`: Per-request priority emitted as the X-Priority header, +overriding the client default for this request only. #### `save` ```python -save(self, allow_upsert: bool = False, update_group_context: bool | None = None, timeout: int | None = None, request_context: RequestContext | None = None) -> None +save(self, allow_upsert: bool = False, update_group_context: bool | None = None, timeout: int | None = None, request_context: RequestContext | None = None, priority: Priority | None = None) -> None ``` Persist this node to the backend, creating or updating it as appropriate. @@ -293,11 +295,13 @@ to ``True``. GraphQL API. Specified in seconds. - `request_context`: Request-level context passed through to the mutation. When omitted, the client's request context is used. +- `priority`: Per-request priority emitted as the X-Priority header, +overriding the client default for this request only. #### `generate_query_data` ```python -generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] +generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | str | None = None, limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] ``` Generate the full GraphQL query payload for this node kind. @@ -310,8 +314,10 @@ relevant attributes are returned alongside the generic fields. **Args:** - `filters`: Filters to apply to the query. -- `offset`: Pagination offset. -- `limit`: Pagination limit. +- `offset`: Pagination offset, either a literal value or a +GraphQL variable placeholder such as ``"$offset"``. +- `limit`: Pagination limit, either a literal value or a +GraphQL variable placeholder such as ``"$limit"``. - `include`: Attributes or relationships to include. - `exclude`: Attributes or relationships to exclude. - `fragment`: When ``True`` and the schema is a generic, emit @@ -388,7 +394,7 @@ the listed peers, leaving every other field untouched. #### `create` ```python -create(self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None) -> None +create(self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None, priority: Priority | None = None) -> None ``` Create this node on the backend. @@ -408,6 +414,8 @@ erroring on a duplicate. Defaults to ``False``. GraphQL API. Specified in seconds. - `request_context`: Request-level context passed through to the mutation. When omitted, the client's request context is used. +- `priority`: Per-request priority emitted as the X-Priority header, +overriding the client default for this request only. **Raises:** @@ -416,7 +424,7 @@ to the mutation. When omitted, the client's request context is used. #### `update` ```python -update(self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None) -> None +update(self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None, priority: Priority | None = None) -> None ``` Update this node on the backend. @@ -436,6 +444,8 @@ unmodified. Defaults to ``False``. GraphQL API. Specified in seconds. - `request_context`: Request-level context passed through to the mutation. When omitted, the client's request context is used. +- `priority`: Per-request priority emitted as the X-Priority header, +overriding the client default for this request only. #### `get_pool_allocated_resources` @@ -776,7 +786,7 @@ from ``source.name`` when ``name`` is omitted. #### `delete` ```python -delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None +delete(self, timeout: int | None = None, request_context: RequestContext | None = None, priority: Priority | None = None) -> None ``` Delete this node on the backend. @@ -787,11 +797,13 @@ Delete this node on the backend. GraphQL API. Specified in seconds. - `request_context`: Request-level context passed through to the mutation. When omitted, the client's request context is used. +- `priority`: Per-request priority emitted as the X-Priority header, +overriding the client default for this request only. #### `save` ```python -save(self, allow_upsert: bool = False, update_group_context: bool | None = None, timeout: int | None = None, request_context: RequestContext | None = None) -> None +save(self, allow_upsert: bool = False, update_group_context: bool | None = None, timeout: int | None = None, request_context: RequestContext | None = None, priority: Priority | None = None) -> None ``` Persist this node to the backend, creating or updating it as appropriate. @@ -812,11 +824,13 @@ to ``True``. GraphQL API. Specified in seconds. - `request_context`: Request-level context passed through to the mutation. When omitted, the client's request context is used. +- `priority`: Per-request priority emitted as the X-Priority header, +overriding the client default for this request only. #### `generate_query_data` ```python -generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] +generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | str | None = None, limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] ``` Generate the full GraphQL query payload for this node kind. @@ -829,8 +843,10 @@ relevant attributes are returned alongside the generic fields. **Args:** - `filters`: Filters to apply to the query. -- `offset`: Pagination offset. -- `limit`: Pagination limit. +- `offset`: Pagination offset, either a literal value or a +GraphQL variable placeholder such as ``"$offset"``. +- `limit`: Pagination limit, either a literal value or a +GraphQL variable placeholder such as ``"$limit"``. - `include`: Attributes or relationships to include. - `exclude`: Attributes or relationships to exclude. - `fragment`: When ``True`` and the schema is a generic, emit @@ -907,7 +923,7 @@ the listed peers, leaving every other field untouched. #### `create` ```python -create(self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None) -> None +create(self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None, priority: Priority | None = None) -> None ``` Create this node on the backend. @@ -927,6 +943,8 @@ erroring on a duplicate. Defaults to ``False``. GraphQL API. Specified in seconds. - `request_context`: Request-level context passed through to the mutation. When omitted, the client's request context is used. +- `priority`: Per-request priority emitted as the X-Priority header, +overriding the client default for this request only. **Raises:** @@ -935,7 +953,7 @@ to the mutation. When omitted, the client's request context is used. #### `update` ```python -update(self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None) -> None +update(self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None, priority: Priority | None = None) -> None ``` Update this node on the backend. @@ -955,6 +973,8 @@ unmodified. Defaults to ``False``. GraphQL API. Specified in seconds. - `request_context`: Request-level context passed through to the mutation. When omitted, the client's request context is used. +- `priority`: Per-request priority emitted as the X-Priority header, +overriding the client default for this request only. #### `get_pool_allocated_resources` @@ -1336,7 +1356,7 @@ Return the raw GraphQL payload used to build this node. #### `generate_query_data_init` ```python -generate_query_data_init(self, filters: dict[str, Any] | None = None, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, partial_match: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] +generate_query_data_init(self, filters: dict[str, Any] | None = None, offset: int | str | None = None, limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, partial_match: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] ``` Build the top-level ``count``/``edges`` skeleton of a GraphQL query for this kind. @@ -1348,8 +1368,10 @@ The returned dict is the outer structure consumed by **Args:** - `filters`: Filters to apply to the query. -- `offset`: Pagination offset. -- `limit`: Pagination limit. +- `offset`: Pagination offset, either a literal value or a +GraphQL variable placeholder such as ``"$offset"``. +- `limit`: Pagination limit, either a literal value or a +GraphQL variable placeholder such as ``"$limit"``. - `include`: Attributes or relationships to include. - `exclude`: Attributes or relationships to exclude. - `partial_match`: When ``True``, allow partial matches on filter diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx index 80e6fdd92..a79c1ad9a 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/related_node.mdx @@ -173,7 +173,7 @@ around :meth:`get`. #### `fetch` ```python -fetch(self, timeout: int | None = None) -> None +fetch(self, timeout: int | None = None, priority: Priority | None = None) -> None ``` Fetch the full peer node from the backend and cache it on this object. @@ -185,6 +185,7 @@ available via :attr:`peer` or :meth:`get`. - `timeout`: Overrides the default timeout used when querying the GraphQL API. Specified in seconds. +- `priority`: Override the client-wide request priority for this fetch. When None, the client default is used. **Raises:** @@ -245,7 +246,7 @@ store, and :attr:`peer` is a convenience accessor around :meth:`get`. #### `fetch` ```python -fetch(self, timeout: int | None = None) -> None +fetch(self, timeout: int | None = None, priority: Priority | None = None) -> None ``` Fetch the full peer node from the backend and cache it on this object. @@ -257,6 +258,7 @@ available via :attr:`peer` or :meth:`get`. - `timeout`: Overrides the default timeout used when querying the GraphQL API. Specified in seconds. +- `priority`: Override the client-wide request priority for this fetch. When None, the client default is used. **Raises:** diff --git a/infrahub_sdk/branch.py b/infrahub_sdk/branch.py index d62ef23df..b310522e4 100644 --- a/infrahub_sdk/branch.py +++ b/infrahub_sdk/branch.py @@ -1,14 +1,12 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Any, Literal, overload -from urllib.parse import urlencode +from typing import TYPE_CHECKING, Literal, overload from pydantic import BaseModel from .exceptions import BranchNotFoundError from .graphql import Mutation, Query -from .utils import decode_json if TYPE_CHECKING: from .client import InfrahubClient, InfrahubClientSync @@ -20,6 +18,7 @@ class BranchStatus(str, Enum): NEED_UPGRADE_REBASE = "NEED_UPGRADE_REBASE" DELETING = "DELETING" MERGING = "MERGING" + MERGE_FAILED = "MERGE_FAILED" MERGED = "MERGED" @@ -60,30 +59,7 @@ class BranchData(BaseModel): QUERY_ONE_BRANCH_DATA = {"Branch": {**BRANCH_DATA, **BRANCH_DATA_FILTER}} -class InfraHubBranchManagerBase: - @classmethod - def generate_diff_data_url( - cls, - client: InfrahubClient | InfrahubClientSync, - branch_name: str, - branch_only: bool = True, - time_from: str | None = None, - time_to: str | None = None, - ) -> str: - """Generate the URL for the diff_data function.""" - url = f"{client.address}/api/diff/data" - url_params = {} - url_params["branch"] = branch_name - url_params["branch_only"] = str(branch_only).lower() - if time_from: - url_params["time_from"] = time_from - if time_to: - url_params["time_to"] = time_to - - return url + urlencode(url_params) - - -class InfrahubBranchManager(InfraHubBranchManagerBase): +class InfrahubBranchManager: def __init__(self, client: InfrahubClient) -> None: self.client = client @@ -205,25 +181,8 @@ async def get(self, branch_name: str) -> BranchData: raise BranchNotFoundError(identifier=branch_name) return BranchData(**data["Branch"][0]) - async def diff_data( - self, - branch_name: str, - branch_only: bool = True, - time_from: str | None = None, - time_to: str | None = None, - ) -> dict[Any, Any]: - url = self.generate_diff_data_url( - client=self.client, - branch_name=branch_name, - branch_only=branch_only, - time_from=time_from, - time_to=time_to, - ) - response = await self.client._get(url=url, headers=self.client.headers) - return decode_json(response=response) - -class InfrahubBranchManagerSync(InfraHubBranchManagerBase): +class InfrahubBranchManagerSync: def __init__(self, client: InfrahubClientSync) -> None: self.client = client @@ -299,23 +258,6 @@ def delete(self, branch_name: str) -> bool: response = self.client.execute_graphql(query=query.render(), tracker="mutation-branch-delete") return response["BranchDelete"]["ok"] - def diff_data( - self, - branch_name: str, - branch_only: bool = True, - time_from: str | None = None, - time_to: str | None = None, - ) -> dict[Any, Any]: - url = self.generate_diff_data_url( - client=self.client, - branch_name=branch_name, - branch_only=branch_only, - time_from=time_from, - time_to=time_to, - ) - response = self.client._get(url=url, headers=self.client.headers) - return decode_json(response=response) - def merge(self, branch_name: str) -> bool: input_data = { "data": { diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 2f1b8b0ae..d600ea2fc 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -20,7 +20,7 @@ from .batch import InfrahubBatch, InfrahubBatchSync from .branch import MUTATION_QUERY_TASK, BranchData, InfrahubBranchManager, InfrahubBranchManagerSync from .config import Config -from .constants import InfrahubClientMode +from .constants import InfrahubClientMode, Priority from .convert_object_type import CONVERT_OBJECT_MUTATION, ConversionFieldInput from .data import RepositoryBranchInfo, RepositoryData, ServerInfo from .diff import DiffTreeData, NodeDiff, diff_tree_node_to_node_diff, get_diff_summary_query, get_diff_tree_query @@ -217,6 +217,9 @@ def __init__( if self.config.api_token: self.headers["X-INFRAHUB-KEY"] = self.config.api_token + if self.config.priority is not None: + self.headers["X-Priority"] = self.config.priority.value + self.max_concurrent_execution = self.config.max_concurrent_execution self.update_group_context = self.config.update_group_context @@ -239,6 +242,37 @@ def _echo(self, url: str, query: str, variables: dict | None = None) -> None: if variables: print(f"VARIABLES:\n{ujson.dumps(variables, indent=4)}\n") + def _request_headers(self, tracker: str | None = None, priority: Priority | None = None) -> dict: + """Build the per-request header delta to layer over the client's base headers. + + Returns only the request-specific entries (tracker, ``X-Priority``); the base headers + (auth, ``content-type``, and any client-wide default priority) are merged in per request + by the transport helpers. Keeping this a delta means the freshest ``self.headers`` — e.g. + an auth token refreshed during a relogin retry — always applies, while a caller can still + override any header, including auth, for a single request. + """ + headers: dict = {} + if self.insert_tracker and tracker: + headers["X-Infrahub-Tracker"] = tracker + effective_priority = priority + if effective_priority is None and self._request_context is not None: + effective_priority = self._request_context.priority + if effective_priority is not None: + headers["X-Priority"] = effective_priority.value + return headers + + def _merge_request_headers(self, headers: dict | None) -> dict: + """Merge a per-request header delta over the client's current base headers. + + Per-request entries take precedence over the client-wide base headers, so a caller may + override any header (including auth) for a single request, and a token refreshed mid-flight + during the automatic relogin retry is picked up from the freshly-copied ``self.headers``. + """ + merged = copy.copy(self.headers or {}) + if headers: + merged.update(headers) + return merged + @property def request_context(self) -> RequestContext | None: return self._request_context @@ -465,6 +499,7 @@ async def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> SchemaType | None: ... @@ -486,6 +521,7 @@ async def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> SchemaType: ... @@ -507,6 +543,7 @@ async def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> SchemaType: ... @@ -528,6 +565,7 @@ async def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> InfrahubNode | None: ... @@ -549,6 +587,7 @@ async def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> InfrahubNode: ... @@ -570,6 +609,7 @@ async def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> InfrahubNode: ... @@ -590,6 +630,7 @@ async def get( property: bool = False, include_metadata: bool = False, query_name: str | None = None, + priority: Priority | None = None, **kwargs: Any, ) -> InfrahubNode | SchemaType | None: branch = branch or self.default_branch @@ -627,6 +668,7 @@ async def get( property=property, include_metadata=include_metadata, query_name=query_name, + priority=priority, **filters, ) @@ -688,6 +730,7 @@ async def count( timeout: int | None = None, partial_match: bool = False, query_name: str | None = None, + priority: Priority | None = None, **kwargs: Any, ) -> int: """Return the number of nodes of a given kind.""" @@ -714,6 +757,7 @@ async def count( at=at, timeout=timeout, operation_name=query_name, + priority=priority, ) return int(response.get(schema.kind, {}).get("count", 0)) @@ -929,6 +973,7 @@ async def all( order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., ) -> list[SchemaType]: ... @overload @@ -950,6 +995,7 @@ async def all( order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., ) -> list[InfrahubNode]: ... async def all( @@ -970,6 +1016,7 @@ async def all( order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, + priority: Priority | None = None, ) -> list[InfrahubNode] | list[SchemaType]: """Retrieve all nodes of a given kind. @@ -989,6 +1036,8 @@ async def all( order (Order, optional): Ordering related options. Setting `disable=True` enhances performances. include_metadata (bool, optional): If True, includes node_metadata and relationship_metadata in the query. query_name (str, optional): If provided is used as the GraphQL operation name else All_ is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, overriding the + client default for these requests only. When None, the client default (if any) is used. Returns: list[InfrahubNode]: List of Nodes @@ -1013,6 +1062,7 @@ async def all( order=order, include_metadata=include_metadata, query_name=query_name, + priority=priority, ) @overload @@ -1035,6 +1085,7 @@ async def filters( order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> list[SchemaType]: ... @@ -1058,6 +1109,7 @@ async def filters( order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> list[InfrahubNode]: ... @@ -1080,6 +1132,7 @@ async def filters( # noqa: C901 order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, + priority: Priority | None = None, **kwargs: Any, ) -> list[InfrahubNode] | list[SchemaType]: """Retrieve nodes of a given kind based on provided filters. @@ -1101,6 +1154,8 @@ async def filters( # noqa: C901 order (Order, optional): Ordering related options. Setting `disable=True` enhances performances. include_metadata (bool, optional): If True, includes node_metadata and relationship_metadata in the query. query_name (str, optional): If provided is used as the GraphQL operation name else Filters_ is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, overriding the + client default for these requests only. When None, the client default (if any) is used. **kwargs (Any): Additional filter criteria for the query. Returns: @@ -1117,29 +1172,38 @@ async def filters( # noqa: C901 filters = kwargs pagination_size = self.pagination_size + # Pagination is passed as GraphQL variables so the rendered query text stays + # identical across pages and can hit the server-side query cache. + query_data = await InfrahubNode(client=self, schema=schema, branch=branch).generate_query_data( + offset="$offset", + limit="$limit", + filters=filters, + include=include, + exclude=exclude, + fragment=fragment, + prefetch_relationships=prefetch_relationships, + partial_match=partial_match, + property=property, + order=order, + include_metadata=include_metadata, + ) + query = Query(query=query_data, name=query_name, variables={"offset": int, "limit": int}) + query_str = query.render() + async def process_page(page_offset: int, page_number: int) -> tuple[dict, ProcessRelationsNode]: """Process a single page of results.""" - query_data = await InfrahubNode(client=self, schema=schema, branch=branch).generate_query_data( - offset=page_offset if offset is None else offset, - limit=limit or pagination_size, - filters=filters, - include=include, - exclude=exclude, - fragment=fragment, - prefetch_relationships=prefetch_relationships, - partial_match=partial_match, - property=property, - order=order, - include_metadata=include_metadata, - ) - query = Query(query=query_data, name=query_name) response = await self.execute_graphql( - query=query.render(), + query=query_str, + variables={ + "offset": page_offset if offset is None else offset, + "limit": limit or pagination_size, + }, branch_name=branch, at=at, tracker=f"query-{str(schema.kind).lower()}-page{page_number}", timeout=timeout, operation_name=query.name, + priority=priority, ) process_result: ProcessRelationsNode = await self._process_nodes_and_relationships( @@ -1157,7 +1221,9 @@ async def process_batch() -> tuple[list[InfrahubNode], list[InfrahubNode]]: nodes = [] related_nodes = [] batch_process = await self.create_batch() - count = await self.count(kind=schema.kind, branch=branch, partial_match=partial_match, **filters) + count = await self.count( + kind=schema.kind, branch=branch, partial_match=partial_match, priority=priority, **filters + ) total_pages = (count + pagination_size - 1) // pagination_size for page_number in range(1, total_pages + 1): @@ -1216,6 +1282,7 @@ async def execute_graphql( timeout: int | None = None, tracker: str | None = None, operation_name: str | None = None, + priority: Priority | None = None, ) -> dict: """Execute a GraphQL query (or mutation). @@ -1229,6 +1296,8 @@ async def execute_graphql( timeout (int, optional): Timeout in second for the query. Defaults to None. operation_name (str, optional): GraphQL operation name, sent as `operationName` in the request payload so tracing/observability tools can identify the operation. Defaults to None. + priority (Priority, optional): Per-request priority emitted as the X-Priority header. Overrides the + client-wide default for this request only. When None, the client default (if any) is used. Returns: dict: The GraphQL data payload (response["data"]). @@ -1250,9 +1319,7 @@ async def execute_graphql( if operation_name: payload["operationName"] = operation_name - headers = copy.copy(self.headers or {}) - if self.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self._request_headers(tracker=tracker, priority=priority) self._echo(url=url, query=query, variables=variables) @@ -1306,6 +1373,7 @@ async def _execute_graphql_with_file( timeout: int | None = None, tracker: str | None = None, operation_name: str | None = None, + priority: Priority | None = None, ) -> dict: """Execute a GraphQL mutation with a file upload using multipart/form-data. @@ -1320,6 +1388,8 @@ async def _execute_graphql_with_file( branch_name: Name of the branch on which the mutation will be executed. timeout: Timeout in seconds for the query. tracker: Optional tracker for request tracing. + priority: Per-request priority emitted as the X-Priority header, overriding the client + default for this request only. When None, the client default (if any) is used. Returns: dict: The GraphQL data payload (response["data"]). @@ -1335,11 +1405,9 @@ async def _execute_graphql_with_file( variables = variables or {} variables["file"] = None - headers = copy.copy(self.headers or {}) - # Remove content-type header - httpx will set it for multipart - headers.pop("content-type", None) - if self.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + # content-type is popped from the base headers by _post_multipart (httpx sets the + # multipart boundary itself); only the request-specific delta is built here. + headers = self._request_headers(tracker=tracker, priority=priority) self._echo(url=url, query=query, variables=variables) @@ -1380,11 +1448,9 @@ async def _post_multipart( """ await self.login() - headers = headers or {} - base_headers = copy.copy(self.headers or {}) - # Remove content-type from base headers - httpx will set it for multipart - base_headers.pop("content-type", None) - headers.update(base_headers) + headers = self._merge_request_headers(headers) + # Remove content-type - httpx sets it (with the multipart boundary) itself + headers.pop("content-type", None) # Build the multipart form data according to GraphQL Multipart Request Spec files = MultipartBuilder.build_payload( @@ -1453,9 +1519,7 @@ async def _post( """ await self.login() - headers = headers or {} - base_headers = copy.copy(self.headers or {}) - headers.update(base_headers) + headers = self._merge_request_headers(headers) return await self._request( url=url, @@ -1476,9 +1540,7 @@ async def _get(self, url: str, headers: dict | None = None, timeout: int | None """ await self.login() - headers = headers or {} - base_headers = copy.copy(self.headers or {}) - headers.update(base_headers) + headers = self._merge_request_headers(headers) return await self._request( url=url, @@ -1496,6 +1558,9 @@ async def _get_streaming( Returns an async context manager that yields the streaming response. Use this for downloading large files without loading into memory. + Yields: + httpx.Response: The streaming HTTP response. + Raises: ServerNotReachableError: If we are not able to connect to the server. ServerNotResponsiveError: If the server didn't respond before the timeout expired. @@ -1503,9 +1568,7 @@ async def _get_streaming( """ await self.login() - headers = headers or {} - base_headers = copy.copy(self.headers or {}) - headers.update(base_headers) + headers = self._merge_request_headers(headers) request_timeout = timeout or self.default_timeout async with httpx.AsyncClient(**self._build_proxy_config(), verify=self.config.tls_context) as client: @@ -1658,10 +1721,7 @@ async def query_gql_query( url_params = copy.deepcopy(params or {}) url_params["branch"] = branch_name or self.default_branch - headers = copy.copy(self.headers or {}) - - if self.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self._request_headers(tracker=tracker) if at: url_params["at"] = at @@ -1708,6 +1768,7 @@ async def create_diff( from_time: datetime, to_time: datetime, wait_until_completion: bool = True, + priority: Priority | None = None, ) -> bool | str: if from_time > to_time: raise ValueError("from_time must be <= to_time") @@ -1723,7 +1784,7 @@ async def create_diff( mutation_query = MUTATION_QUERY_TASK if not wait_until_completion else {"ok": None} query = Mutation(mutation="DiffUpdate", input_data=input_data, query=mutation_query) - response = await self.execute_graphql(query=query.render(), tracker="mutation-diff-update") + response = await self.execute_graphql(query=query.render(), tracker="mutation-diff-update", priority=priority) if not wait_until_completion and "task" in response["DiffUpdate"]: return response["DiffUpdate"]["task"]["id"] @@ -1738,6 +1799,7 @@ async def get_diff_summary( to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, + priority: Priority | None = None, ) -> list[NodeDiff]: query = get_diff_summary_query() input_data = {"branch_name": branch} @@ -1756,6 +1818,7 @@ async def get_diff_summary( tracker=tracker, variables=input_data, operation_name="GetDiffTree", + priority=priority, ) node_diffs: list[NodeDiff] = [] @@ -1777,6 +1840,7 @@ async def get_diff_tree( to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, + priority: Priority | None = None, ) -> DiffTreeData | None: """Get complete diff tree with metadata and nodes. @@ -1804,6 +1868,7 @@ async def get_diff_tree( tracker=tracker, variables=input_data, operation_name=query.name, + priority=priority, ) diff_tree = response["DiffTree"] @@ -2205,6 +2270,7 @@ def execute_graphql( timeout: int | None = None, tracker: str | None = None, operation_name: str | None = None, + priority: Priority | None = None, ) -> dict: """Execute a GraphQL query (or mutation). @@ -2218,6 +2284,8 @@ def execute_graphql( timeout (int, optional): Timeout in second for the query. Defaults to None. operation_name (str, optional): GraphQL operation name, sent as `operationName` in the request payload so tracing/observability tools can identify the operation. Defaults to None. + priority (Priority, optional): Per-request priority emitted as the X-Priority header. Overrides the + client-wide default for this request only. When None, the client default (if any) is used. Returns: dict: The GraphQL data payload (`response["data"]`). @@ -2239,9 +2307,7 @@ def execute_graphql( if operation_name: payload["operationName"] = operation_name - headers = copy.copy(self.headers or {}) - if self.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self._request_headers(tracker=tracker, priority=priority) self._echo(url=url, query=query, variables=variables) @@ -2295,6 +2361,7 @@ def _execute_graphql_with_file( timeout: int | None = None, tracker: str | None = None, operation_name: str | None = None, + priority: Priority | None = None, ) -> dict: """Execute a GraphQL mutation with a file upload using multipart/form-data. @@ -2309,6 +2376,8 @@ def _execute_graphql_with_file( branch_name: Name of the branch on which the mutation will be executed. timeout: Timeout in seconds for the query. tracker: Optional tracker for request tracing. + priority: Per-request priority emitted as the X-Priority header, overriding the client + default for this request only. When None, the client default (if any) is used. Returns: dict: The GraphQL data payload (response["data"]). @@ -2324,11 +2393,9 @@ def _execute_graphql_with_file( variables = variables or {} variables["file"] = None - headers = copy.copy(self.headers or {}) - # Remove content-type header - httpx will set it for multipart - headers.pop("content-type", None) - if self.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + # content-type is popped from the base headers by _post_multipart (httpx sets the + # multipart boundary itself); only the request-specific delta is built here. + headers = self._request_headers(tracker=tracker, priority=priority) self._echo(url=url, query=query, variables=variables) @@ -2369,11 +2436,9 @@ def _post_multipart( """ self.login() - headers = headers or {} - base_headers = copy.copy(self.headers or {}) - # Remove content-type from base headers - httpx will set it for multipart - base_headers.pop("content-type", None) - headers.update(base_headers) + headers = self._merge_request_headers(headers) + # Remove content-type - httpx sets it (with the multipart boundary) itself + headers.pop("content-type", None) # Build the multipart form data according to GraphQL Multipart Request Spec files = MultipartBuilder.build_payload( @@ -2431,6 +2496,7 @@ def count( timeout: int | None = None, partial_match: bool = False, query_name: str | None = None, + priority: Priority | None = None, **kwargs: Any, ) -> int: """Return the number of nodes of a given kind.""" @@ -2457,6 +2523,7 @@ def count( at=at, timeout=timeout, operation_name=query_name, + priority=priority, ) return int(response.get(schema.kind, {}).get("count", 0)) @@ -2672,6 +2739,7 @@ def all( order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., ) -> list[SchemaTypeSync]: ... @overload @@ -2693,6 +2761,7 @@ def all( order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., ) -> list[InfrahubNodeSync]: ... def all( @@ -2713,6 +2782,7 @@ def all( order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, + priority: Priority | None = None, ) -> list[InfrahubNodeSync] | list[SchemaTypeSync]: """Retrieve all nodes of a given kind. @@ -2732,6 +2802,8 @@ def all( order (Order, optional): Ordering related options. Setting `disable=True` enhances performances. include_metadata (bool, optional): If True, includes node_metadata and relationship_metadata in the query. query_name (str, optional): If provided is used as the GraphQL operation name else All_ is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, overriding the + client default for these requests only. When None, the client default (if any) is used. Returns: list[InfrahubNodeSync]: List of Nodes @@ -2756,6 +2828,7 @@ def all( order=order, include_metadata=include_metadata, query_name=query_name, + priority=priority, ) def _process_nodes_and_relationships( @@ -2819,6 +2892,7 @@ def filters( order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> list[SchemaTypeSync]: ... @@ -2842,6 +2916,7 @@ def filters( order: Order | None = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> list[InfrahubNodeSync]: ... @@ -2864,6 +2939,7 @@ def filters( # noqa: C901 order: Order | None = None, include_metadata: bool = False, query_name: str | None = None, + priority: Priority | None = None, **kwargs: Any, ) -> list[InfrahubNodeSync] | list[SchemaTypeSync]: """Retrieve nodes of a given kind based on provided filters. @@ -2885,6 +2961,8 @@ def filters( # noqa: C901 order (Order, optional): Ordering related options. Setting `disable=True` enhances performances. include_metadata (bool, optional): If True, includes node_metadata and relationship_metadata in the query. query_name (str, optional): If provided is used as the GraphQL operation name else Filters_ is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, overriding the + client default for these requests only. When None, the client default (if any) is used. **kwargs (Any): Additional filter criteria for the query. Returns: @@ -2901,29 +2979,38 @@ def filters( # noqa: C901 filters = kwargs pagination_size = self.pagination_size + # Pagination is passed as GraphQL variables so the rendered query text stays + # identical across pages and can hit the server-side query cache. + query_data = InfrahubNodeSync(client=self, schema=schema, branch=branch).generate_query_data( + offset="$offset", + limit="$limit", + filters=filters, + include=include, + exclude=exclude, + fragment=fragment, + prefetch_relationships=prefetch_relationships, + partial_match=partial_match, + property=property, + order=order, + include_metadata=include_metadata, + ) + query = Query(query=query_data, name=query_name, variables={"offset": int, "limit": int}) + query_str = query.render() + def process_page(page_offset: int, page_number: int) -> tuple[dict, ProcessRelationsNodeSync]: """Process a single page of results.""" - query_data = InfrahubNodeSync(client=self, schema=schema, branch=branch).generate_query_data( - offset=page_offset if offset is None else offset, - limit=limit or pagination_size, - filters=filters, - include=include, - exclude=exclude, - fragment=fragment, - prefetch_relationships=prefetch_relationships, - partial_match=partial_match, - property=property, - order=order, - include_metadata=include_metadata, - ) - query = Query(query=query_data, name=query_name) response = self.execute_graphql( - query=query.render(), + query=query_str, + variables={ + "offset": page_offset if offset is None else offset, + "limit": limit or pagination_size, + }, branch_name=branch, at=at, timeout=timeout, tracker=f"query-{str(schema.kind).lower()}-page{page_number}", operation_name=query.name, + priority=priority, ) process_result: ProcessRelationsNodeSync = self._process_nodes_and_relationships( @@ -2942,7 +3029,9 @@ def process_batch() -> tuple[list[InfrahubNodeSync], list[InfrahubNodeSync]]: related_nodes = [] batch_process = self.create_batch() - count = self.count(kind=schema.kind, branch=branch, partial_match=partial_match, **filters) + count = self.count( + kind=schema.kind, branch=branch, partial_match=partial_match, priority=priority, **filters + ) total_pages = (count + pagination_size - 1) // pagination_size for page_number in range(1, total_pages + 1): @@ -3007,6 +3096,7 @@ def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> SchemaTypeSync | None: ... @@ -3028,6 +3118,7 @@ def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> SchemaTypeSync: ... @@ -3049,6 +3140,7 @@ def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> SchemaTypeSync: ... @@ -3070,6 +3162,7 @@ def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> InfrahubNodeSync | None: ... @@ -3091,6 +3184,7 @@ def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> InfrahubNodeSync: ... @@ -3112,6 +3206,7 @@ def get( property: bool = ..., include_metadata: bool = ..., query_name: str | None = ..., + priority: Priority | None = ..., **kwargs: Any, ) -> InfrahubNodeSync: ... @@ -3132,6 +3227,7 @@ def get( property: bool = False, include_metadata: bool = False, query_name: str | None = None, + priority: Priority | None = None, **kwargs: Any, ) -> InfrahubNodeSync | SchemaTypeSync | None: branch = branch or self.default_branch @@ -3169,6 +3265,7 @@ def get( property=property, include_metadata=include_metadata, query_name=query_name, + priority=priority, **filters, ) @@ -3217,10 +3314,7 @@ def query_gql_query( url_params = copy.deepcopy(params or {}) url_params["branch"] = branch_name or self.default_branch - headers = copy.copy(self.headers or {}) - - if self.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self._request_headers(tracker=tracker) if at: url_params["at"] = at @@ -3266,6 +3360,7 @@ def create_diff( from_time: datetime, to_time: datetime, wait_until_completion: bool = True, + priority: Priority | None = None, ) -> bool | str: if from_time > to_time: raise ValueError("from_time must be <= to_time") @@ -3281,7 +3376,7 @@ def create_diff( mutation_query = MUTATION_QUERY_TASK if not wait_until_completion else {"ok": None} query = Mutation(mutation="DiffUpdate", input_data=input_data, query=mutation_query) - response = self.execute_graphql(query=query.render(), tracker="mutation-diff-update") + response = self.execute_graphql(query=query.render(), tracker="mutation-diff-update", priority=priority) if not wait_until_completion and "task" in response["DiffUpdate"]: return response["DiffUpdate"]["task"]["id"] @@ -3296,6 +3391,7 @@ def get_diff_summary( to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, + priority: Priority | None = None, ) -> list[NodeDiff]: query = get_diff_summary_query() input_data = {"branch_name": branch} @@ -3314,6 +3410,7 @@ def get_diff_summary( tracker=tracker, variables=input_data, operation_name="GetDiffTree", + priority=priority, ) node_diffs: list[NodeDiff] = [] @@ -3335,6 +3432,7 @@ def get_diff_tree( to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, + priority: Priority | None = None, ) -> DiffTreeData | None: """Get complete diff tree with metadata and nodes. @@ -3362,6 +3460,7 @@ def get_diff_tree( tracker=tracker, variables=input_data, operation_name=query.name, + priority=priority, ) diff_tree = response["DiffTree"] @@ -3520,7 +3619,7 @@ def allocate_next_ip_prefix( Args: resource_pool (InfrahubNodeSync): Node corresponding to the pool to allocate resources from. identifier (str, optional): Value to perform idempotent allocation, the same resource will be returned for a given identifier. - size (int, optional): Length of the prefix to allocate. + prefix_length (int, optional): Length of the prefix to allocate. member_type (str, optional): Member type of the prefix to allocate. prefix_type (str, optional): Kind of the prefix to allocate. data (dict, optional): A key/value map to use to set attributes values on the allocated prefix. @@ -3583,9 +3682,7 @@ def _get(self, url: str, headers: dict | None = None, timeout: int | None = None """ self.login() - headers = headers or {} - base_headers = copy.copy(self.headers or {}) - headers.update(base_headers) + headers = self._merge_request_headers(headers) return self._request( url=url, @@ -3603,6 +3700,9 @@ def _get_streaming( Returns a context manager that yields the streaming response. Use this for downloading large files without loading into memory. + Yields: + httpx.Response: The streaming HTTP response. + Raises: ServerNotReachableError: If we are not able to connect to the server. ServerNotResponsiveError: If the server didn't respond before the timeout expired. @@ -3610,9 +3710,7 @@ def _get_streaming( """ self.login() - headers = headers or {} - base_headers = copy.copy(self.headers or {}) - headers.update(base_headers) + headers = self._merge_request_headers(headers) request_timeout = timeout or self.default_timeout with httpx.Client(**self._build_proxy_config(), verify=self.config.tls_context) as client: @@ -3665,9 +3763,7 @@ def _post( """ self.login() - headers = headers or {} - base_headers = copy.copy(self.headers or {}) - headers.update(base_headers) + headers = self._merge_request_headers(headers) return self._request( url=url, diff --git a/infrahub_sdk/config.py b/infrahub_sdk/config.py index 05c9f9778..f81fc911e 100644 --- a/infrahub_sdk/config.py +++ b/infrahub_sdk/config.py @@ -8,7 +8,7 @@ from pydantic_settings import BaseSettings, InitSettingsSource, PydanticBaseSettingsSource, SettingsConfigDict from typing_extensions import Self -from .constants import InfrahubClientMode +from .constants import InfrahubClientMode, Priority from .playback import JSONPlayback from .recorder import JSONRecorder, NoRecorder, Recorder, RecorderType from .types import AsyncRequester, InfrahubLoggers, RequesterTransport, SyncRequester @@ -56,6 +56,13 @@ class ConfigBase(BaseSettings): max_concurrent_execution: int = Field(default=5, description="Max concurrent execution in batch mode") mode: InfrahubClientMode = Field(default=InfrahubClientMode.DEFAULT, description="Default mode for the client") pagination_size: int = Field(default=50, description="Page size for queries to the server") + priority: Priority | None = Field( + default=None, + description=( + "Default request priority emitted as the X-Priority header on every request; " + "one of high|medium|low (case-insensitive). When unset, no header is sent." + ), + ) retry_delay: int = Field(default=5, description="Number of seconds to wait until attempting a retry.") retry_on_failure: bool = Field(default=False, description="Retry operation in case of failure") rate_limit_retry_enabled: bool = Field( @@ -63,7 +70,7 @@ class ConfigBase(BaseSettings): description="Retry requests that receive HTTP 429 using backoff. Set False to disable.", ) rate_limit_max_retries: int = Field( - default=5, + default=10, ge=0, description="Maximum number of retries after the initial attempt when receiving HTTP 429.", ) diff --git a/infrahub_sdk/constants.py b/infrahub_sdk/constants.py index 04dd6b955..785445d9a 100644 --- a/infrahub_sdk/constants.py +++ b/infrahub_sdk/constants.py @@ -1,3 +1,5 @@ +"""Enumerations shared across the Infrahub SDK.""" + import enum @@ -5,3 +7,23 @@ class InfrahubClientMode(str, enum.Enum): DEFAULT = "default" TRACKING = "tracking" # IDEMPOTENT = "idempotent" + + +class Priority(str, enum.Enum): + """Request priority emitted as the ``X-Priority`` header. + + String-valued closed enum matched case-insensitively (e.g. "LOW", "Low" and "low" all + resolve to :attr:`Priority.LOW`). + """ + + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + @classmethod + def _missing_(cls, value: object) -> "Priority | None": + if isinstance(value, str): + for member in cls: + if member.value == value.lower(): + return member + return None diff --git a/infrahub_sdk/context.py b/infrahub_sdk/context.py index 201a9ef98..38d4daeb0 100644 --- a/infrahub_sdk/context.py +++ b/infrahub_sdk/context.py @@ -2,6 +2,8 @@ from pydantic import BaseModel, Field +from .constants import Priority # noqa: TC001 (pydantic needs this at runtime to build the model schema) + class ContextAccount(BaseModel): id: str = Field(..., description="The ID of the account") @@ -11,3 +13,6 @@ class RequestContext(BaseModel): """The context can be used to override settings such as the account within mutations.""" account: ContextAccount | None = Field(default=None, description="Account tied to the context") + priority: Priority | None = Field( + default=None, description="Request priority emitted as the X-Priority header (not part of the mutation body)" + ) diff --git a/infrahub_sdk/ctl/config.py b/infrahub_sdk/ctl/config.py index 03ca58ca1..4ada5d555 100644 --- a/infrahub_sdk/ctl/config.py +++ b/infrahub_sdk/ctl/config.py @@ -81,8 +81,8 @@ def load_and_exit(self, config_file: str | Path = "infrahubctl.toml", config_dat In such cases, a message is printed to the screen indicating the settings which don't pass validation. Args: - config_file_name (str, optional): [description]. Defaults to "pyprojectctl.toml". - config_data (dict, optional): [description]. Defaults to None. + config_file (str | Path, optional): Path to the configuration file. Defaults to "infrahubctl.toml". + config_data (dict, optional): In-memory configuration overriding the file contents. Defaults to None. """ try: diff --git a/infrahub_sdk/ctl/schema.py b/infrahub_sdk/ctl/schema.py index 54ae82dbb..46b7f2072 100644 --- a/infrahub_sdk/ctl/schema.py +++ b/infrahub_sdk/ctl/schema.py @@ -6,19 +6,19 @@ from datetime import datetime, timezone from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal import typer import yaml -from pydantic import ValidationError from rich.console import Console +from rich.markup import escape from rich.table import Table from ..async_typer import AsyncTyper from ..ctl.client import initialize_client from ..ctl.utils import catch_exception, init_logging from ..queries import SCHEMA_HASH_SYNC_STATUS -from ..schema import NodeSchemaAPI, SchemaWarning +from ..schema import NodeSchemaAPI, SchemaWarning, validate_schema from ..yaml import SchemaFile from .parameters import CONFIG_PARAM from .schema_format import ( @@ -30,9 +30,6 @@ ) from .utils import load_yamlfile_from_disk_and_exit -if TYPE_CHECKING: - from .. import InfrahubClient - SchemaContainer = Literal["nodes", "generics", "relationships"] app = AsyncTyper() @@ -53,17 +50,21 @@ def callback() -> None: """Manage the schema in a remote Infrahub instance.""" -def validate_schema_content_and_exit(client: InfrahubClient, schemas: list[SchemaFile]) -> None: +def validate_schema_content_and_exit(schemas: list[SchemaFile]) -> None: + """Report every offline contract violation and exit when at least one schema is invalid. + + Read-only fields are reported by the server on the load/check response, so only errors are + rendered here to avoid warning about the same field twice. + """ has_error: bool = False for schema_file in schemas: - try: - 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 - for error in exc.errors(): - loc_str = [str(item) for item in error["loc"]] - console.print(f" '{'/'.join(loc_str)}' | {error['msg']} ({error['type']})") + result = validate_schema(schema=schema_file.payload) + if result.valid: + continue + has_error = True + console.print(f"[red]Schema not valid, found '{len(result.errors)}' error(s) in {schema_file.location}") + for error in result.errors: + console.print(f" {escape(error.message)}") if has_error: raise typer.Exit(1) @@ -208,7 +209,7 @@ async def load( schemas_data = load_yamlfile_from_disk_and_exit(paths=schemas, file_type=SchemaFile, console=console) schema_definition = "schema" if len(schemas_data) == 1 else "schemas" client = initialize_client() - validate_schema_content_and_exit(client=client, schemas=schemas_data) + validate_schema_content_and_exit(schemas=schemas_data) start_time = time.time() response = await client.schema.load(schemas=[item.payload for item in schemas_data], branch=branch) @@ -258,7 +259,7 @@ async def check( schemas_data = load_yamlfile_from_disk_and_exit(paths=schemas, file_type=SchemaFile, console=console) client = initialize_client() - validate_schema_content_and_exit(client=client, schemas=schemas_data) + validate_schema_content_and_exit(schemas=schemas_data) success, response = await client.schema.check(schemas=[item.payload for item in schemas_data], branch=branch) @@ -280,9 +281,9 @@ async def check( def _display_schema_warnings(console: Console, warnings: list[SchemaWarning]) -> None: for warning in warnings: - console.print( - f"[yellow] {warning.type.value}: {warning.message} [{', '.join([kind.display for kind in warning.kinds])}]" - ) + # A warning about a top-level key has no kind to attribute it to. + kinds = f" [{', '.join(kind.display for kind in warning.kinds)}]" if warning.kinds else "" + console.print(f"[yellow] {warning.type.value}: {escape(warning.message)}{escape(kinds)}") def _default_export_directory() -> Path: diff --git a/infrahub_sdk/ctl/validate.py b/infrahub_sdk/ctl/validate.py index cf69e2fa5..8b03b332e 100644 --- a/infrahub_sdk/ctl/validate.py +++ b/infrahub_sdk/ctl/validate.py @@ -5,14 +5,15 @@ import typer import ujson -from pydantic import ValidationError from rich.console import Console +from rich.markup import escape from ..async_typer import AsyncTyper -from ..ctl.client import initialize_client, initialize_client_sync +from ..ctl.client import initialize_client_sync from ..ctl.exceptions import QueryNotFoundError from ..ctl.utils import catch_exception, find_graphql_query, parse_cli_vars from ..exceptions import GraphQLError +from ..schema import validate_schema as validate_schema_offline from ..utils import write_to_file from ..yaml import SchemaFile from .parameters import CONFIG_PARAM @@ -36,16 +37,16 @@ async def validate_schema(schema: Path, _: str = CONFIG_PARAM) -> None: console.print(f"[red]Unable to find {schema}") raise typer.Exit(1) - client = initialize_client() + result = validate_schema_offline(schema=schema_data[0].payload) - try: - client.schema.validate(schema_data[0].payload) - except ValidationError as exc: - console.print(f"[red]Schema not valid, found {len(exc.errors())} error(s)") - for error in exc.errors(): - loc_str = [str(item) for item in error["loc"]] - console.print(f" '{'/'.join(loc_str)}' | {error['msg']} ({error['type']})") - raise typer.Exit(1) from None + for warning in result.warnings: + console.print(f"[yellow]{escape(warning.message)}") + + if not result.valid: + console.print(f"[red]Schema not valid, found {len(result.errors)} error(s)") + for error in result.errors: + console.print(f" {escape(error.message)}") + raise typer.Exit(1) console.print("[green]Schema is valid !!") diff --git a/infrahub_sdk/node/__init__.py b/infrahub_sdk/node/__init__.py index 6bbde8540..eb55bfe36 100644 --- a/infrahub_sdk/node/__init__.py +++ b/infrahub_sdk/node/__init__.py @@ -6,6 +6,7 @@ ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE, ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, HFID_STR_SEPARATOR, + IP_ADDRESS_TYPES, IP_TYPES, MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE, PROPERTIES_FLAG, @@ -30,6 +31,7 @@ "ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE", "ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE", "HFID_STR_SEPARATOR", + "IP_ADDRESS_TYPES", "IP_TYPES", "MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE", "PROPERTIES_FLAG", diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index d70d7ee93..4ada6b9de 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -5,7 +5,14 @@ from typing import TYPE_CHECKING, Any, NamedTuple, get_args from ..uuidt import UUIDT -from .constants import ATTRIBUTE_METADATA_OBJECT, IP_TYPES, PROPERTIES_FLAG, PROPERTIES_OBJECT, SAFE_VALUE +from .constants import ( + ATTRIBUTE_METADATA_OBJECT, + IP_ADDRESS_TYPES, + IP_TYPES, + PROPERTIES_FLAG, + PROPERTIES_OBJECT, + SAFE_VALUE, +) from .property import NodeProperty if TYPE_CHECKING: @@ -67,8 +74,8 @@ class Attribute: def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> None: """Build an ``Attribute`` from raw GraphQL data. - IP-typed attributes (``IPHost``, ``IPNetwork``) are parsed via the standard - ``ipaddress`` module so the in-memory value is a network/interface object. + IP-typed attributes (``IPHost``, ``IPNetwork``, ``IPAddress``) are parsed via the standard + ``ipaddress`` module so the in-memory value is an interface, network or address object. Args: name (str): The name of the attribute. @@ -105,6 +112,7 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N value_mapper: dict[str, Callable] = { "IPHost": ipaddress.ip_interface, "IPNetwork": ipaddress.ip_network, + "IPAddress": ipaddress.ip_address, } mapper = value_mapper.get(schema.kind, lambda value: value) self._value = mapper(data.get("value")) @@ -160,7 +168,13 @@ def _initialize_graphql_payload(self) -> _GraphQLPayloadAttribute: ) # Safe strings, IP types, and everything else - value = self.value.with_prefixlen if isinstance(self.value, get_args(IP_TYPES)) else self.value + if isinstance(self.value, get_args(IP_TYPES)): + value = self.value.with_prefixlen + elif isinstance(self.value, get_args(IP_ADDRESS_TYPES)): + # bare addresses have no prefix; serialize their canonical string form + value = str(self.value) + else: + value = self.value return _GraphQLPayloadAttribute(payload={"value": value}, variables={}, needs_metadata=True) def _generate_input_data(self) -> _GraphQLPayloadAttribute: diff --git a/infrahub_sdk/node/constants.py b/infrahub_sdk/node/constants.py index 6a56584ed..192bbf6de 100644 --- a/infrahub_sdk/node/constants.py +++ b/infrahub_sdk/node/constants.py @@ -18,6 +18,9 @@ IP_TYPES = ipaddress.IPv4Interface | ipaddress.IPv6Interface | ipaddress.IPv4Network | ipaddress.IPv6Network +# Bare IP addresses (no prefix); serialized with str() rather than with_prefixlen +IP_ADDRESS_TYPES = ipaddress.IPv4Address | ipaddress.IPv6Address + ARTIFACT_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE = ( "calling artifact_fetch is only supported for nodes that are Artifact Definition target" ) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index b23f6f865..df1ec65b5 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, BinaryIO, overload -from ..constants import InfrahubClientMode +from ..constants import InfrahubClientMode, Priority from ..exceptions import ( FeatureNotSupportedError, NodeNotFoundError, @@ -282,14 +282,15 @@ def __setattr__(self, name: str, value: Any) -> None: super().__setattr__(name, value) def _get_request_context(self, request_context: RequestContext | None = None) -> dict[str, Any] | None: + # priority rides the X-Priority header, not the mutation body — the server context input has no such field if request_context: - return request_context.model_dump(exclude_none=True) + return request_context.model_dump(exclude_none=True, exclude={"priority"}) or None client: InfrahubClient | InfrahubClientSync | None = getattr(self, "_client", None) if not client or not client.request_context: return None - return client.request_context.model_dump(exclude_none=True) + return client.request_context.model_dump(exclude_none=True, exclude={"priority"}) or None def _init_relationships(self, data: dict | None = None) -> None: pass @@ -651,8 +652,8 @@ def _validate_file_object_support(self, message: str) -> None: def generate_query_data_init( self, filters: dict[str, Any] | None = None, - offset: int | None = None, - limit: int | None = None, + offset: int | str | None = None, + limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, partial_match: bool = False, @@ -667,8 +668,10 @@ def generate_query_data_init( Args: filters (dict[str, Any], optional): Filters to apply to the query. - offset (int, optional): Pagination offset. - limit (int, optional): Pagination limit. + offset (int | str, optional): Pagination offset, either a literal value or a + GraphQL variable placeholder such as ``"$offset"``. + limit (int | str, optional): Pagination limit, either a literal value or a + GraphQL variable placeholder such as ``"$limit"``. include (list[str], optional): Attributes or relationships to include. exclude (list[str], optional): Attributes or relationships to exclude. partial_match (bool, optional): When ``True``, allow partial matches on filter @@ -1211,7 +1214,12 @@ async def upload_if_changed( return UploadResult(was_uploaded=True, checksum=local_digest) - async def delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None: + async def delete( + self, + timeout: int | None = None, + request_context: RequestContext | None = None, + priority: Priority | None = None, + ) -> None: """Delete this node on the backend. Args: @@ -1219,6 +1227,8 @@ async def delete(self, timeout: int | None = None, request_context: RequestConte GraphQL API. Specified in seconds. request_context (RequestContext, optional): Request-level context passed through to the mutation. When omitted, the client's request context is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, + overriding the client default for this request only. """ input_data = {"data": {"id": self.id}} @@ -1236,6 +1246,7 @@ async def delete(self, timeout: int | None = None, request_context: RequestConte branch_name=self._branch, timeout=timeout, tracker=f"mutation-{str(self._schema.kind).lower()}-delete", + priority=priority, ) async def save( @@ -1244,6 +1255,7 @@ async def save( update_group_context: bool | None = None, timeout: int | None = None, request_context: RequestContext | None = None, + priority: Priority | None = None, ) -> None: """Persist this node to the backend, creating or updating it as appropriate. @@ -1262,12 +1274,16 @@ async def save( GraphQL API. Specified in seconds. request_context (RequestContext, optional): Request-level context passed through to the mutation. When omitted, the client's request context is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, + overriding the client default for this request only. """ if self._existing is False or allow_upsert is True: - await self.create(allow_upsert=allow_upsert, timeout=timeout, request_context=request_context) + await self.create( + allow_upsert=allow_upsert, timeout=timeout, request_context=request_context, priority=priority + ) else: - await self.update(timeout=timeout, request_context=request_context) + await self.update(timeout=timeout, request_context=request_context, priority=priority) if update_group_context is None and self._client.mode == InfrahubClientMode.TRACKING: update_group_context = True @@ -1340,8 +1356,8 @@ async def _process_hierarchical_fields( async def generate_query_data( self, filters: dict[str, Any] | None = None, - offset: int | None = None, - limit: int | None = None, + offset: int | str | None = None, + limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, @@ -1360,8 +1376,10 @@ async def generate_query_data( Args: filters (dict[str, Any], optional): Filters to apply to the query. - offset (int, optional): Pagination offset. - limit (int, optional): Pagination limit. + offset (int | str, optional): Pagination offset, either a literal value or a + GraphQL variable placeholder such as ``"$offset"``. + limit (int | str, optional): Pagination limit, either a literal value or a + GraphQL variable placeholder such as ``"$limit"``. include (list[str], optional): Attributes or relationships to include. exclude (list[str], optional): Attributes or relationships to exclude. fragment (bool, optional): When ``True`` and the schema is a generic, emit @@ -1572,7 +1590,11 @@ def _generate_mutation_query(self) -> dict[str, Any]: return query_result async def _process_mutation_result( - self, mutation_name: str, response: dict[str, Any], timeout: int | None = None + self, + mutation_name: str, + response: dict[str, Any], + timeout: int | None = None, + priority: Priority | None = None, ) -> None: object_response: dict[str, Any] = response[mutation_name]["object"] self.id = object_response["id"] @@ -1596,11 +1618,15 @@ async def _process_mutation_result( related_node = RelatedNode( client=self._client, branch=self._branch, schema=rel.schema, data=allocated_resource ) - await related_node.fetch(timeout=timeout) + await related_node.fetch(timeout=timeout, priority=priority) setattr(self, rel_name, related_node) async def create( - self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None + self, + allow_upsert: bool = False, + timeout: int | None = None, + request_context: RequestContext | None = None, + priority: Priority | None = None, ) -> None: """Create this node on the backend. @@ -1618,6 +1644,8 @@ async def create( GraphQL API. Specified in seconds. request_context (RequestContext, optional): Request-level context passed through to the mutation. When omitted, the client's request context is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, + overriding the client default for this request only. Raises: ValueError: If this is a file-object node and no file content has been set. @@ -1662,6 +1690,7 @@ async def create( branch_name=self._branch, tracker=tracker, timeout=timeout, + priority=priority, ) finally: if prepared.should_close and prepared.file_object: @@ -1675,11 +1704,18 @@ async def create( tracker=tracker, variables=input_data["variables"], timeout=timeout, + priority=priority, ) - await self._process_mutation_result(mutation_name=mutation_name, response=response, timeout=timeout) + await self._process_mutation_result( + mutation_name=mutation_name, response=response, timeout=timeout, priority=priority + ) async def update( - self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None + self, + do_full_update: bool = False, + timeout: int | None = None, + request_context: RequestContext | None = None, + priority: Priority | None = None, ) -> None: """Update this node on the backend. @@ -1697,6 +1733,8 @@ async def update( GraphQL API. Specified in seconds. request_context (RequestContext, optional): Request-level context passed through to the mutation. When omitted, the client's request context is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, + overriding the client default for this request only. """ input_data = self._generate_input_data(exclude_unmodified=not do_full_update, request_context=request_context) @@ -1722,6 +1760,7 @@ async def update( branch_name=self._branch, tracker=tracker, timeout=timeout, + priority=priority, ) finally: if prepared.should_close and prepared.file_object: @@ -1735,8 +1774,11 @@ async def update( timeout=timeout, tracker=tracker, variables=input_data["variables"], + priority=priority, ) - await self._process_mutation_result(mutation_name=mutation_name, response=response, timeout=timeout) + await self._process_mutation_result( + mutation_name=mutation_name, response=response, timeout=timeout, priority=priority + ) async def _process_relationships( self, @@ -1813,30 +1855,37 @@ async def get_pool_allocated_resources(self, resource: InfrahubNode) -> list[Inf graphql_query_name = "InfrahubResourcePoolAllocated" node_ids_per_kind: dict[str, list[str]] = {} + query = Query( + query={ + graphql_query_name: { + "@filters": { + "pool_id": "$pool_id", + "resource_id": "$resource_id", + "offset": "$offset", + "limit": "$limit", + }, + "count": None, + "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, + } + }, + name="GetAllocatedResourceForPool", + variables={"pool_id": str, "resource_id": str, "offset": int, "limit": int}, + ) + query_str = query.render() + has_remaining_items = True page_number = 1 while has_remaining_items: page_offset = (page_number - 1) * self._client.pagination_size - query = Query( - query={ - graphql_query_name: { - "@filters": { - "pool_id": "$pool_id", - "resource_id": "$resource_id", - "offset": page_offset, - "limit": self._client.pagination_size, - }, - "count": None, - "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, - } - }, - name="GetAllocatedResourceForPool", - variables={"pool_id": str, "resource_id": str}, - ) response = await self._client.execute_graphql( - query=query.render(), - variables={"pool_id": self.id, "resource_id": resource.id}, + query=query_str, + variables={ + "pool_id": self.id, + "resource_id": resource.id, + "offset": page_offset, + "limit": self._client.pagination_size, + }, branch_name=self._branch, tracker=f"get-allocated-resources-page{page_number}", ) @@ -2399,7 +2448,12 @@ def upload_if_changed( return UploadResult(was_uploaded=True, checksum=local_digest) - def delete(self, timeout: int | None = None, request_context: RequestContext | None = None) -> None: + def delete( + self, + timeout: int | None = None, + request_context: RequestContext | None = None, + priority: Priority | None = None, + ) -> None: """Delete this node on the backend. Args: @@ -2407,6 +2461,8 @@ def delete(self, timeout: int | None = None, request_context: RequestContext | N GraphQL API. Specified in seconds. request_context (RequestContext, optional): Request-level context passed through to the mutation. When omitted, the client's request context is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, + overriding the client default for this request only. """ input_data = {"data": {"id": self.id}} @@ -2424,6 +2480,7 @@ def delete(self, timeout: int | None = None, request_context: RequestContext | N branch_name=self._branch, tracker=f"mutation-{str(self._schema.kind).lower()}-delete", timeout=timeout, + priority=priority, ) def save( @@ -2432,6 +2489,7 @@ def save( update_group_context: bool | None = None, timeout: int | None = None, request_context: RequestContext | None = None, + priority: Priority | None = None, ) -> None: """Persist this node to the backend, creating or updating it as appropriate. @@ -2450,12 +2508,14 @@ def save( GraphQL API. Specified in seconds. request_context (RequestContext, optional): Request-level context passed through to the mutation. When omitted, the client's request context is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, + overriding the client default for this request only. """ if self._existing is False or allow_upsert is True: - self.create(allow_upsert=allow_upsert, timeout=timeout, request_context=request_context) + self.create(allow_upsert=allow_upsert, timeout=timeout, request_context=request_context, priority=priority) else: - self.update(timeout=timeout, request_context=request_context) + self.update(timeout=timeout, request_context=request_context, priority=priority) if update_group_context is None and self._client.mode == InfrahubClientMode.TRACKING: update_group_context = True @@ -2524,8 +2584,8 @@ def _process_hierarchical_fields( def generate_query_data( self, filters: dict[str, Any] | None = None, - offset: int | None = None, - limit: int | None = None, + offset: int | str | None = None, + limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, @@ -2544,8 +2604,10 @@ def generate_query_data( Args: filters (dict[str, Any], optional): Filters to apply to the query. - offset (int, optional): Pagination offset. - limit (int, optional): Pagination limit. + offset (int | str, optional): Pagination offset, either a literal value or a + GraphQL variable placeholder such as ``"$offset"``. + limit (int | str, optional): Pagination limit, either a literal value or a + GraphQL variable placeholder such as ``"$limit"``. include (list[str], optional): Attributes or relationships to include. exclude (list[str], optional): Attributes or relationships to exclude. fragment (bool, optional): When ``True`` and the schema is a generic, emit @@ -2759,7 +2821,11 @@ def _generate_mutation_query(self) -> dict[str, Any]: return query_result def _process_mutation_result( - self, mutation_name: str, response: dict[str, Any], timeout: int | None = None + self, + mutation_name: str, + response: dict[str, Any], + timeout: int | None = None, + priority: Priority | None = None, ) -> None: object_response: dict[str, Any] = response[mutation_name]["object"] self.id = object_response["id"] @@ -2783,11 +2849,15 @@ def _process_mutation_result( related_node = RelatedNodeSync( client=self._client, branch=self._branch, schema=rel.schema, data=allocated_resource ) - related_node.fetch(timeout=timeout) + related_node.fetch(timeout=timeout, priority=priority) setattr(self, rel_name, related_node) def create( - self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None + self, + allow_upsert: bool = False, + timeout: int | None = None, + request_context: RequestContext | None = None, + priority: Priority | None = None, ) -> None: """Create this node on the backend. @@ -2805,6 +2875,8 @@ def create( GraphQL API. Specified in seconds. request_context (RequestContext, optional): Request-level context passed through to the mutation. When omitted, the client's request context is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, + overriding the client default for this request only. Raises: ValueError: If this is a file-object node and no file content has been set. @@ -2849,6 +2921,7 @@ def create( branch_name=self._branch, tracker=tracker, timeout=timeout, + priority=priority, ) finally: if prepared.should_close and prepared.file_object: @@ -2862,11 +2935,18 @@ def create( tracker=tracker, variables=input_data["variables"], timeout=timeout, + priority=priority, ) - self._process_mutation_result(mutation_name=mutation_name, response=response, timeout=timeout) + self._process_mutation_result( + mutation_name=mutation_name, response=response, timeout=timeout, priority=priority + ) def update( - self, do_full_update: bool = False, timeout: int | None = None, request_context: RequestContext | None = None + self, + do_full_update: bool = False, + timeout: int | None = None, + request_context: RequestContext | None = None, + priority: Priority | None = None, ) -> None: """Update this node on the backend. @@ -2884,6 +2964,8 @@ def update( GraphQL API. Specified in seconds. request_context (RequestContext, optional): Request-level context passed through to the mutation. When omitted, the client's request context is used. + priority (Priority, optional): Per-request priority emitted as the X-Priority header, + overriding the client default for this request only. """ input_data = self._generate_input_data(exclude_unmodified=not do_full_update, request_context=request_context) @@ -2909,6 +2991,7 @@ def update( branch_name=self._branch, tracker=tracker, timeout=timeout, + priority=priority, ) finally: if prepared.should_close and prepared.file_object: @@ -2922,8 +3005,11 @@ def update( tracker=tracker, variables=input_data["variables"], timeout=timeout, + priority=priority, ) - self._process_mutation_result(mutation_name=mutation_name, response=response, timeout=timeout) + self._process_mutation_result( + mutation_name=mutation_name, response=response, timeout=timeout, priority=priority + ) def _process_relationships( self, @@ -3000,30 +3086,37 @@ def get_pool_allocated_resources(self, resource: InfrahubNodeSync) -> list[Infra graphql_query_name = "InfrahubResourcePoolAllocated" node_ids_per_kind: dict[str, list[str]] = {} + query = Query( + query={ + graphql_query_name: { + "@filters": { + "pool_id": "$pool_id", + "resource_id": "$resource_id", + "offset": "$offset", + "limit": "$limit", + }, + "count": None, + "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, + } + }, + name="GetAllocatedResourceForPool", + variables={"pool_id": str, "resource_id": str, "offset": int, "limit": int}, + ) + query_str = query.render() + has_remaining_items = True page_number = 1 while has_remaining_items: page_offset = (page_number - 1) * self._client.pagination_size - query = Query( - query={ - graphql_query_name: { - "@filters": { - "pool_id": "$pool_id", - "resource_id": "$resource_id", - "offset": page_offset, - "limit": self._client.pagination_size, - }, - "count": None, - "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, - } - }, - name="GetAllocatedResourceForPool", - variables={"pool_id": str, "resource_id": str}, - ) response = self._client.execute_graphql( - query=query.render(), - variables={"pool_id": self.id, "resource_id": resource.id}, + query=query_str, + variables={ + "pool_id": self.id, + "resource_id": resource.id, + "offset": page_offset, + "limit": self._client.pagination_size, + }, branch_name=self._branch, tracker=f"get-allocated-resources-page{page_number}", ) diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 229aad61f..f36523f13 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync + from ..constants import Priority from ..schema import RelationshipSchemaAPI from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync @@ -347,7 +348,7 @@ def __init__( self._client = client super().__init__(branch=branch, schema=schema, data=data, name=name) - async def fetch(self, timeout: int | None = None) -> None: + async def fetch(self, timeout: int | None = None, priority: Priority | None = None) -> None: """Fetch the full peer node from the backend and cache it on this object. After ``fetch()`` completes, attribute and relationship access on the peer is @@ -356,6 +357,7 @@ async def fetch(self, timeout: int | None = None) -> None: Args: timeout (int, optional): Overrides the default timeout used when querying the GraphQL API. Specified in seconds. + priority: Override the client-wide request priority for this fetch. When None, the client default is used. Raises: Error: If neither ``id`` nor ``typename`` is set on this related node. @@ -365,7 +367,7 @@ async def fetch(self, timeout: int | None = None) -> None: raise Error("Unable to fetch the peer, id and/or typename are not defined") self._peer = await self._client.get( - kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout + kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout, priority=priority ) @property @@ -443,7 +445,7 @@ def __init__( self._client = client super().__init__(branch=branch, schema=schema, data=data, name=name) - def fetch(self, timeout: int | None = None) -> None: + def fetch(self, timeout: int | None = None, priority: Priority | None = None) -> None: """Fetch the full peer node from the backend and cache it on this object. After ``fetch()`` completes, attribute and relationship access on the peer is @@ -452,6 +454,7 @@ def fetch(self, timeout: int | None = None) -> None: Args: timeout (int, optional): Overrides the default timeout used when querying the GraphQL API. Specified in seconds. + priority: Override the client-wide request priority for this fetch. When None, the client default is used. Raises: Error: If neither ``id`` nor ``typename`` is set on this related node. @@ -461,7 +464,7 @@ def fetch(self, timeout: int | None = None) -> None: raise Error("Unable to fetch the peer, id and/or typename are not defined") self._peer = self._client.get( - kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout + kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout, priority=priority ) @property diff --git a/infrahub_sdk/object_store.py b/infrahub_sdk/object_store.py index 628c06e9a..c770d016a 100644 --- a/infrahub_sdk/object_store.py +++ b/infrahub_sdk/object_store.py @@ -1,6 +1,5 @@ from __future__ import annotations -import copy from typing import TYPE_CHECKING import httpx @@ -42,9 +41,7 @@ def __init__(self, client: InfrahubClient) -> None: async def get(self, identifier: str, tracker: str | None = None) -> str: url = f"{self.client.address}/api/storage/object/{identifier}" - headers = copy.copy(self.client.headers or {}) - if self.client.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self.client._request_headers(tracker=tracker) try: resp = await self.client._get(url=url, headers=headers) @@ -65,9 +62,7 @@ async def get(self, identifier: str, tracker: str | None = None) -> str: async def upload(self, content: str, tracker: str | None = None) -> dict[str, str]: url = f"{self.client.address}/api/storage/upload/content" - headers = copy.copy(self.client.headers or {}) - if self.client.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self.client._request_headers(tracker=tracker) try: resp = await self.client._post(url=url, payload={"content": content}, headers=headers) @@ -94,9 +89,7 @@ async def _get_file(self, url: str, identifier: str, tracker: str | None = None) HTTPStatusError: For other non-2xx HTTP responses. """ - headers = copy.copy(self.client.headers or {}) - if self.client.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self.client._request_headers(tracker=tracker) try: resp = await self.client._get(url=url, headers=headers) @@ -137,9 +130,7 @@ def __init__(self, client: InfrahubClientSync) -> None: def get(self, identifier: str, tracker: str | None = None) -> str: url = f"{self.client.address}/api/storage/object/{identifier}" - headers = copy.copy(self.client.headers or {}) - if self.client.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self.client._request_headers(tracker=tracker) try: resp = self.client._get(url=url, headers=headers) @@ -160,9 +151,7 @@ def get(self, identifier: str, tracker: str | None = None) -> str: def upload(self, content: str, tracker: str | None = None) -> dict[str, str]: url = f"{self.client.address}/api/storage/upload/content" - headers = copy.copy(self.client.headers or {}) - if self.client.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self.client._request_headers(tracker=tracker) try: resp = self.client._post(url=url, payload={"content": content}, headers=headers) @@ -189,9 +178,7 @@ def _get_file(self, url: str, identifier: str, tracker: str | None = None) -> st HTTPStatusError: For other non-2xx HTTP responses. """ - headers = copy.copy(self.client.headers or {}) - if self.client.insert_tracker and tracker: - headers["X-Infrahub-Tracker"] = tracker + headers = self.client._request_headers(tracker=tracker) try: resp = self.client._get(url=url, headers=headers) diff --git a/infrahub_sdk/protocols.py b/infrahub_sdk/protocols.py index c03d689c2..a5a029c22 100644 --- a/infrahub_sdk/protocols.py +++ b/infrahub_sdk/protocols.py @@ -151,6 +151,10 @@ class CoreGroup(CoreNode): children: RelationshipManager +class CoreIPPool(CoreNode): + pass + + class CoreKeyValue(CoreNode): name: String key: String @@ -212,9 +216,13 @@ class CoreTransformation(CoreNode): label: StringOptional description: StringOptional timeout: Integer + fingerprint: StringOptional + dependencies: ListAttributeOptional + dependencies_complete: BooleanOptional query: RelatedNode repository: RelatedNode tags: RelationshipManager + artifact_definitions: RelationshipManager class CoreTriggerRule(CoreNode): @@ -269,6 +277,7 @@ class CoreAccount(LineageOwner, LineageSource, CoreGenericAccount): class CoreAccountGroup(LineageOwner, LineageSource, CoreGroup): + origin: StringOptional roles: RelationshipManager @@ -303,8 +312,11 @@ class CoreArtifactDefinition(CoreTaskTarget): description: StringOptional parameters: JSONAttribute content_type: Enum + fingerprint: StringOptional targets: RelatedNode transformation: RelatedNode + artifacts: RelationshipManager + validators: RelationshipManager class CoreArtifactThread(CoreThread): @@ -336,6 +348,7 @@ class CoreCheckDefinition(CoreTaskTarget): query: RelatedNode targets: RelatedNode tags: RelationshipManager + validators: RelationshipManager class CoreCustomWebhook(CoreWebhook, CoreTaskTarget): @@ -390,9 +403,14 @@ class CoreGeneratorDefinition(CoreTaskTarget): convert_query_response: BooleanOptional execute_in_proposed_change: BooleanOptional execute_after_merge: BooleanOptional + fingerprint: StringOptional + dependencies: ListAttributeOptional + dependencies_complete: BooleanOptional query: RelatedNode repository: RelatedNode targets: RelatedNode + instances: RelationshipManager + validators: RelationshipManager class CoreGeneratorGroup(CoreGroup): @@ -419,6 +437,7 @@ class CoreGraphQLQuery(CoreNode): name: String description: StringOptional query: String + fingerprint: StringOptional variables: JSONAttributeOptional operations: ListAttributeOptional models: ListAttributeOptional @@ -426,6 +445,7 @@ class CoreGraphQLQuery(CoreNode): height: IntegerOptional repository: RelatedNode tags: RelationshipManager + query_groups: RelationshipManager class CoreGraphQLQueryGroup(CoreGroup): @@ -443,14 +463,14 @@ class CoreGroupTriggerRule(CoreTriggerRule): group: RelatedNode -class CoreIPAddressPool(CoreResourcePool, LineageSource): +class CoreIPAddressPool(CoreResourcePool, LineageSource, CoreIPPool): default_address_type: String default_prefix_length: IntegerOptional resources: RelationshipManager ip_namespace: RelatedNode -class CoreIPPrefixPool(CoreResourcePool, LineageSource): +class CoreIPPrefixPool(CoreResourcePool, LineageSource, CoreIPPool): default_prefix_length: IntegerOptional default_member_type: Enum default_prefix_type: StringOptional @@ -736,6 +756,10 @@ class CoreGroupSync(CoreNodeSync): children: RelationshipManagerSync +class CoreIPPoolSync(CoreNodeSync): + pass + + class CoreKeyValueSync(CoreNodeSync): name: String key: String @@ -797,9 +821,13 @@ class CoreTransformationSync(CoreNodeSync): label: StringOptional description: StringOptional timeout: Integer + fingerprint: StringOptional + dependencies: ListAttributeOptional + dependencies_complete: BooleanOptional query: RelatedNodeSync repository: RelatedNodeSync tags: RelationshipManagerSync + artifact_definitions: RelationshipManagerSync class CoreTriggerRuleSync(CoreNodeSync): @@ -854,6 +882,7 @@ class CoreAccountSync(LineageOwnerSync, LineageSourceSync, CoreGenericAccountSyn class CoreAccountGroupSync(LineageOwnerSync, LineageSourceSync, CoreGroupSync): + origin: StringOptional roles: RelationshipManagerSync @@ -888,8 +917,11 @@ class CoreArtifactDefinitionSync(CoreTaskTargetSync): description: StringOptional parameters: JSONAttribute content_type: Enum + fingerprint: StringOptional targets: RelatedNodeSync transformation: RelatedNodeSync + artifacts: RelationshipManagerSync + validators: RelationshipManagerSync class CoreArtifactThreadSync(CoreThreadSync): @@ -921,6 +953,7 @@ class CoreCheckDefinitionSync(CoreTaskTargetSync): query: RelatedNodeSync targets: RelatedNodeSync tags: RelationshipManagerSync + validators: RelationshipManagerSync class CoreCustomWebhookSync(CoreWebhookSync, CoreTaskTargetSync): @@ -975,9 +1008,14 @@ class CoreGeneratorDefinitionSync(CoreTaskTargetSync): convert_query_response: BooleanOptional execute_in_proposed_change: BooleanOptional execute_after_merge: BooleanOptional + fingerprint: StringOptional + dependencies: ListAttributeOptional + dependencies_complete: BooleanOptional query: RelatedNodeSync repository: RelatedNodeSync targets: RelatedNodeSync + instances: RelationshipManagerSync + validators: RelationshipManagerSync class CoreGeneratorGroupSync(CoreGroupSync): @@ -1004,6 +1042,7 @@ class CoreGraphQLQuerySync(CoreNodeSync): name: String description: StringOptional query: String + fingerprint: StringOptional variables: JSONAttributeOptional operations: ListAttributeOptional models: ListAttributeOptional @@ -1011,6 +1050,7 @@ class CoreGraphQLQuerySync(CoreNodeSync): height: IntegerOptional repository: RelatedNodeSync tags: RelationshipManagerSync + query_groups: RelationshipManagerSync class CoreGraphQLQueryGroupSync(CoreGroupSync): @@ -1028,14 +1068,14 @@ class CoreGroupTriggerRuleSync(CoreTriggerRuleSync): group: RelatedNodeSync -class CoreIPAddressPoolSync(CoreResourcePoolSync, LineageSourceSync): +class CoreIPAddressPoolSync(CoreResourcePoolSync, LineageSourceSync, CoreIPPoolSync): default_address_type: String default_prefix_length: IntegerOptional resources: RelationshipManagerSync ip_namespace: RelatedNodeSync -class CoreIPPrefixPoolSync(CoreResourcePoolSync, LineageSourceSync): +class CoreIPPrefixPoolSync(CoreResourcePoolSync, LineageSourceSync, CoreIPPoolSync): default_prefix_length: IntegerOptional default_member_type: Enum default_prefix_type: StringOptional diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index 57d4f23fd..7040d1af4 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -7,7 +7,7 @@ from .context import RequestContext from .node.metadata import NodeMetadata - from .schema import MainSchemaTypes + from .schema import MainSchemaTypesAPI @runtime_checkable @@ -140,6 +140,14 @@ class IPNetworkOptional(Attribute): value: ipaddress.IPv4Network | ipaddress.IPv6Network | None +class IPAddress(Attribute): + value: ipaddress.IPv4Address | ipaddress.IPv6Address + + +class IPAddressOptional(Attribute): + value: ipaddress.IPv4Address | ipaddress.IPv6Address | None + + class Boolean(Attribute): value: bool @@ -173,7 +181,7 @@ class AnyAttributeOptional(Attribute): class CoreNodeBase: - _schema: MainSchemaTypes + _schema: MainSchemaTypesAPI _internal_id: str id: str # NOTE this is incorrect, should be str | None display_label: str | None diff --git a/infrahub_sdk/protocols_generator/constants.py b/infrahub_sdk/protocols_generator/constants.py index 63c3dbb68..282217948 100644 --- a/infrahub_sdk/protocols_generator/constants.py +++ b/infrahub_sdk/protocols_generator/constants.py @@ -17,6 +17,7 @@ "Bandwidth": "Integer", "IPHost": "IPHost", "IPNetwork": "IPNetwork", + "IPAddress": "IPAddress", "Boolean": "Boolean", "Checkbox": "Boolean", "List": "ListAttribute", diff --git a/infrahub_sdk/protocols_generator/template.j2 b/infrahub_sdk/protocols_generator/template.j2 index 43ef24076..a1c9fca53 100644 --- a/infrahub_sdk/protocols_generator/template.j2 +++ b/infrahub_sdk/protocols_generator/template.j2 @@ -31,6 +31,8 @@ if TYPE_CHECKING: IPHostOptional, IPNetwork, IPNetworkOptional, + IPAddress, + IPAddressOptional, JSONAttribute, JSONAttributeOptional, ListAttribute, diff --git a/infrahub_sdk/pytest_plugin/loader.py b/infrahub_sdk/pytest_plugin/loader.py index 7ed0b889b..1d8d34b1d 100644 --- a/infrahub_sdk/pytest_plugin/loader.py +++ b/infrahub_sdk/pytest_plugin/loader.py @@ -66,7 +66,11 @@ def get_resource_config(self, group: InfrahubTestGroup) -> Any | None: return resource_config def collect_group(self, group: InfrahubTestGroup) -> Iterable[pytest.Item]: - """Collect all items for a group.""" + """Collect all items for a group. + + Yields: + pytest.Item: Each collected test item for the group. + """ marker = MARKER_MAPPING[group.resource] resource_config = self.get_resource_config(group) diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 5343f24cd..37658da37 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -24,6 +24,8 @@ from ..protocols_base import CoreNodeBase from ..queries import SCHEMA_HASH_SYNC_STATUS from .export import RESTRICTED_NAMESPACES, NamespaceExport, SchemaExport, schema_to_export_dict +from .generated.read import InfrahubSchemaRead +from .generated.write import InfrahubSchemaWrite from .main import ( AttributeSchema, AttributeSchemaAPI, @@ -42,6 +44,12 @@ SchemaRootAPI, TemplateSchemaAPI, ) +from .validate import ( + SchemaValidationErrorDetail, + SchemaValidationResult, + SchemaValidationWarningDetail, + validate_schema, +) if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync, SchemaType, SchemaTypeSync @@ -56,6 +64,8 @@ "BranchSupportType", "GenericSchema", "GenericSchemaAPI", + "InfrahubSchemaRead", + "InfrahubSchemaWrite", "NamespaceExport", "NodeSchema", "NodeSchemaAPI", @@ -67,8 +77,12 @@ "SchemaExport", "SchemaRoot", "SchemaRootAPI", + "SchemaValidationErrorDetail", + "SchemaValidationResult", + "SchemaValidationWarningDetail", "TemplateSchemaAPI", "schema_to_export_dict", + "validate_schema", ] @@ -165,8 +179,19 @@ def _build_export_schemas( ns_map[ns].nodes.append(schema_dict) return SchemaExport(namespaces=ns_map) - def validate(self, data: dict[str, Any]) -> None: - SchemaRoot(**data) + def validate(self, data: dict[str, Any]) -> SchemaValidationResult: + """Validate a schema payload against the generated write contract. + + Returns: + The verdict, carrying a warning for every read-only field the payload sets. + + Raises: + ValueError: When the payload is invalid, joining every field-level message. + + """ + # Delegating to the offline validator keeps this verdict identical to the one + # /api/schema/load reaches, since the server runs the same models. + return validate_schema(schema=data, raise_on_error=True) def validate_data_against_schema(self, schema: MainSchemaTypesAPI, data: dict) -> None: for key in data: @@ -362,7 +387,9 @@ async def load( branch = branch or self.client.default_branch url = f"{self.client.address}/api/schema/load?branch={branch}" response = await self.client._post( - url=url, timeout=max(120, self.client.default_timeout), payload={"schemas": schemas} + url=url, + timeout=max(120, self.client.default_timeout), + payload={"schemas": schemas}, ) if wait_until_converged: @@ -394,7 +421,9 @@ async def check(self, schemas: list[dict], branch: str | None = None) -> tuple[b branch = branch or self.client.default_branch url = f"{self.client.address}/api/schema/check?branch={branch}" response = await self.client._post( - url=url, timeout=max(120, self.client.default_timeout), payload={"schemas": schemas} + url=url, + timeout=max(120, self.client.default_timeout), + payload={"schemas": schemas}, ) if response.status_code == httpx.codes.ACCEPTED: @@ -892,7 +921,9 @@ def load( branch = branch or self.client.default_branch url = f"{self.client.address}/api/schema/load?branch={branch}" response = self.client._post( - url=url, timeout=max(120, self.client.default_timeout), payload={"schemas": schemas} + url=url, + timeout=max(120, self.client.default_timeout), + payload={"schemas": schemas}, ) if wait_until_converged: @@ -924,7 +955,9 @@ def check(self, schemas: list[dict], branch: str | None = None) -> tuple[bool, d branch = branch or self.client.default_branch url = f"{self.client.address}/api/schema/check?branch={branch}" response = self.client._post( - url=url, timeout=max(120, self.client.default_timeout), payload={"schemas": schemas} + url=url, + timeout=max(120, self.client.default_timeout), + payload={"schemas": schemas}, ) if response.status_code == httpx.codes.ACCEPTED: diff --git a/infrahub_sdk/schema/export.py b/infrahub_sdk/schema/export.py index d5a09c775..378f0386d 100644 --- a/infrahub_sdk/schema/export.py +++ b/infrahub_sdk/schema/export.py @@ -49,6 +49,7 @@ def to_dict(self) -> dict[str, dict[str, list[dict[str, Any]]]]: _ATTR_EXPORT_DEFAULTS: dict[str, Any] = { "read_only": False, "optional": False, + "ordered": True, } # Relationship field values that match schema loading defaults — omitted for cleaner output diff --git a/infrahub_sdk/schema/generated/__init__.py b/infrahub_sdk/schema/generated/__init__.py new file mode 100644 index 000000000..3e09a11b0 --- /dev/null +++ b/infrahub_sdk/schema/generated/__init__.py @@ -0,0 +1,4 @@ +# Generated by "invoke backend.generate", do not edit directly +from . import contract, enums, read, write + +__all__ = ["contract", "enums", "read", "write"] diff --git a/infrahub_sdk/schema/generated/contract.py b/infrahub_sdk/schema/generated/contract.py new file mode 100644 index 000000000..c2c5754f3 --- /dev/null +++ b/infrahub_sdk/schema/generated/contract.py @@ -0,0 +1,35 @@ +# Generated by "invoke backend.generate", do not edit directly +"""Read-only fields of the write contract, keyed by generated write class name. + +A field listed here is one the contract knows at that location but the user may not set: +a field the read API returns, the bookkeeping a schema dumped from the internal models +carries, or a field belonging to a sibling variant of a discriminated union. Submitting one +is reported as a warning and the value is dropped, where an extra field that is not listed +is an error. Each entry already includes what the class inherits, so a lookup is by class +name alone. +""" + +READ_ONLY_FIELDS: dict[str, frozenset[str]] = { + "AttributeParametersWrite": frozenset({"id", "state"}), + "AttributeSchemaBaseWrite": frozenset({"inherited"}), + "BaseNodeSchemaWrite": frozenset({"hash", "kind"}), + "ComputedAttributeJinja2Write": frozenset({"id", "state", "transform"}), + "ComputedAttributeTransformPythonWrite": frozenset({"id", "jinja2_template", "state"}), + "ComputedAttributeUserWrite": frozenset({"id", "jinja2_template", "state", "transform"}), + "DropdownChoiceWrite": frozenset({"id", "state"}), + "GenericAttributeWrite": frozenset({"inherited"}), + "GenericSchemaWrite": frozenset({"hash", "kind", "used_by"}), + "InfrahubSchemaWrite": frozenset({"main", "namespaces", "profiles", "templates"}), + "ListAttributeParametersWrite": frozenset({"id", "state"}), + "ListAttributeWrite": frozenset({"inherited"}), + "NodeExtensionWrite": frozenset({"id", "state"}), + "NodeSchemaWrite": frozenset({"hash", "hierarchy", "kind"}), + "NumberAttributeParametersWrite": frozenset({"id", "state"}), + "NumberAttributeWrite": frozenset({"inherited"}), + "NumberPoolAttributeWrite": frozenset({"inherited"}), + "NumberPoolParametersWrite": frozenset({"id", "state"}), + "RelationshipSchemaWrite": frozenset({"hierarchical", "inherited"}), + "SchemaExtensionWrite": frozenset({"id", "state"}), + "TextAttributeParametersWrite": frozenset({"id", "state"}), + "TextAttributeWrite": frozenset({"inherited"}), +} diff --git a/infrahub_sdk/schema/generated/enums.py b/infrahub_sdk/schema/generated/enums.py new file mode 100644 index 000000000..69c6ea2da --- /dev/null +++ b/infrahub_sdk/schema/generated/enums.py @@ -0,0 +1,85 @@ +# Generated by "invoke backend.generate", do not edit directly + +from __future__ import annotations + +from enum import Enum + + +class BranchSupportType(str, Enum): + AWARE = "aware" + AGNOSTIC = "agnostic" + LOCAL = "local" + + +class RelationshipKind(str, Enum): + GENERIC = "Generic" + ATTRIBUTE = "Attribute" + COMPONENT = "Component" + PARENT = "Parent" + GROUP = "Group" + HIERARCHY = "Hierarchy" + PROFILE = "Profile" + TEMPLATE = "Template" + + +class RelationshipCardinality(str, Enum): + ONE = "one" + MANY = "many" + + +class RelationshipDirection(str, Enum): + BIDIR = "bidirectional" + OUTBOUND = "outbound" + INBOUND = "inbound" + + +class RelationshipDeleteBehavior(str, Enum): + NO_ACTION = "no-action" + CASCADE = "cascade" + + +class AllowOverrideType(str, Enum): + NONE = "none" + ANY = "any" + + +class SchemaState(str, Enum): + PRESENT = "present" + ABSENT = "absent" + + +class SchemaAttributeDisplay(str, Enum): + DEFAULT = "default" + EXTRA = "extra" + + +class ComputedAttributeKind(str, Enum): + USER = "User" + JINJA2 = "Jinja2" + TRANSFORM_PYTHON = "TransformPython" + + +class AttributeKind(str, Enum): + ID = "ID" + DROPDOWN = "Dropdown" + TEXT = "Text" + TEXTAREA = "TextArea" + DATETIME = "DateTime" + EMAIL = "Email" + PASSWORD = "Password" + HASHEDPASSWORD = "HashedPassword" + URL = "URL" + FILE = "File" + MAC_ADDRESS = "MacAddress" + COLOR = "Color" + NUMBER = "Number" + NUMBERPOOL = "NumberPool" + BANDWIDTH = "Bandwidth" + IPHOST = "IPHost" + IPNETWORK = "IPNetwork" + IPADDRESS = "IPAddress" + BOOLEAN = "Boolean" + CHECKBOX = "Checkbox" + LIST = "List" + JSON = "JSON" + ANY = "Any" diff --git a/infrahub_sdk/schema/generated/read.py b/infrahub_sdk/schema/generated/read.py new file mode 100644 index 000000000..ecc931430 --- /dev/null +++ b/infrahub_sdk/schema/generated/read.py @@ -0,0 +1,603 @@ +# Generated by "invoke backend.generate", do not edit directly + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, computed_field + +from .enums import ( + AllowOverrideType, + AttributeKind, + BranchSupportType, + ComputedAttributeKind, + RelationshipCardinality, + RelationshipDeleteBehavior, + RelationshipDirection, + RelationshipKind, + SchemaAttributeDisplay, + SchemaState, +) + + +class AttributeParametersRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + + +class ListAttributeParametersRead(AttributeParametersRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + regex: str | None = Field( + default=None, + description="Regular expression that each list item value must match if defined", + ) + + +class TextAttributeParametersRead(AttributeParametersRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + regex: str | None = Field( + default=None, + description="Regular expression that attribute value must match if defined", + ) + min_length: int | None = Field( + default=None, + description="Set a minimum number of characters allowed.", + ) + max_length: int | None = Field( + default=None, + description="Set a maximum number of characters allowed.", + ) + + +class NumberAttributeParametersRead(AttributeParametersRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + min_value: int | None = Field( + default=None, + description="Set a minimum value allowed.", + ) + max_value: int | None = Field( + default=None, + description="Set a maximum value allowed.", + ) + excluded_values: str | None = Field( + default=None, + description="List of values or range of values not allowed for the attribute, format is: '100,150-200,280,300-400'", + pattern=r"^(\d+(?:-\d+)?)(?:,\d+(?:-\d+)?)*$", + ) + + +class NumberPoolParametersRead(AttributeParametersRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + end_range: int = Field( + default=9223372036854775807, + description="End range for numbers for the associated NumberPool", + ) + start_range: int = Field( + default=1, + description="Start range for numbers for the associated NumberPool", + ) + number_pool_id: str | None = Field( + default=None, + description="The ID of the numberpool associated with this attribute. Only set after the number pool has been provisioned.", + ) + + +class DropdownChoiceRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + name: str = Field( + ..., + description="Name of the choice, must be unique within the dropdown.", + ) + description: str | None = Field( + default=None, + description="Description of the choice.", + ) + color: str | None = Field( + default=None, + description="Color of the choice, must be a valid HTML color code.", + pattern=r"#[0-9a-fA-F]{6}\b", + ) + label: str | None = Field( + default=None, + description="Human friendly representation of the choice.", + ) + + +class ComputedAttributeUserRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[ComputedAttributeKind.USER] = Field( + ..., + description="Defines how the value of the attribute is computed.", + ) + + +class ComputedAttributeJinja2Read(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[ComputedAttributeKind.JINJA2] = Field( + ..., + description="Defines how the value of the attribute is computed.", + ) + jinja2_template: str = Field( + ..., + description="Jinja2 template used to compute the value, required when kind is Jinja2.", + ) + + +class ComputedAttributeTransformPythonRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[ComputedAttributeKind.TRANSFORM_PYTHON] = Field( + ..., + description="Defines how the value of the attribute is computed.", + ) + transform: str = Field( + ..., + description="Python transform name or ID, required when kind is TransformPython.", + ) + + +class AttributeSchemaBaseRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + id: str | None = Field( + default=None, + description="The ID of the attribute", + ) + name: str = Field( + ..., + description="Attribute name, must be unique within a model and must be all lowercase.", + pattern=r"^[a-z0-9\_]+$", + min_length=3, + max_length=64, + ) + kind: AttributeKind = Field( + ..., + description="Defines the type of the attribute.", + ) + enum: list | None = Field( + default=None, + description="Define a list of valid values for the attribute.", + ) + computed_attribute: ComputedAttributeRead | None = Field( + default=None, + description="Defines how the value of this attribute will be populated.", + ) + choices: list[DropdownChoiceRead] | None = Field( + default=None, + description="Define a list of valid choices for a dropdown attribute.", + ) + regex: str | None = Field( + default=None, + description="Regex uses to limit the characters allowed in for the attributes. (deprecated: please use parameters.regex instead)", + ) + max_length: int | None = Field( + default=None, + description="Set a maximum number of characters allowed for a given attribute. (deprecated: please use parameters.max_length instead)", + ) + min_length: int | None = Field( + default=None, + description="Set a minimum number of characters allowed for a given attribute. (deprecated: please use parameters.min_length instead)", + ) + label: str | None = Field( + default=None, + description="Human friendly representation of the name. Will be autogenerated if not provided", + max_length=64, + ) + description: str | None = Field( + default=None, + description="Short description of the attribute.", + max_length=128, + ) + read_only: bool = Field( + default=False, + description="Set the attribute as Read-Only, users won't be able to change its value. Mainly relevant for internal object.", + ) + unique: bool = Field( + default=False, + description="Indicate if the value of this attribute must be unique in the database for a given model.", + ) + optional: bool = Field( + default=False, + description="Indicate if this attribute is mandatory or optional.", + ) + branch: BranchSupportType | None = Field( + default=None, + description="Type of branch support for the attribute, if not defined it will be inherited from the node.", + ) + order_weight: int | None = Field( + default=None, + description="Number used to order the attribute in the frontend (table and view). Lowest value will be ordered first.", + ) + ordered: bool = Field( + default=True, + description="Whether element order is significant. When False, reordering a List or JSON-array attribute is not a merge/rebase conflict.", + ) + default_value: Any | None = Field( + default=None, + description="Default value of the attribute.", + ) + inherited: bool = Field( + default=False, + description="Internal value to indicate if the attribute was inherited from a Generic node.", + ) + state: SchemaState = Field( + default=SchemaState.PRESENT, + description="Expected state of the attribute after loading the schema", + ) + allow_override: AllowOverrideType = Field( + default=AllowOverrideType.ANY, + description="Type of allowed override for the attribute.", + ) + deprecation: str | None = Field( + default=None, + description="Mark attribute as deprecated and provide a user-friendly message to display", + max_length=128, + ) + display: SchemaAttributeDisplay = Field( + default=SchemaAttributeDisplay.DEFAULT, + description="Controls where the attribute is displayed. 'default' shows in the main view, 'extra' shows in an expanded/secondary section.", + ) + + +class TextAttributeRead(AttributeSchemaBaseRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.TEXT, AttributeKind.TEXTAREA] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: TextAttributeParametersRead | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +class NumberAttributeRead(AttributeSchemaBaseRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.NUMBER] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: NumberAttributeParametersRead | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +class ListAttributeRead(AttributeSchemaBaseRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.LIST] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: ListAttributeParametersRead | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +class NumberPoolAttributeRead(AttributeSchemaBaseRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.NUMBERPOOL] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: NumberPoolParametersRead | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +class GenericAttributeRead(AttributeSchemaBaseRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[ + AttributeKind.ID, + AttributeKind.DROPDOWN, + AttributeKind.DATETIME, + AttributeKind.EMAIL, + AttributeKind.PASSWORD, + AttributeKind.HASHEDPASSWORD, + AttributeKind.URL, + AttributeKind.FILE, + AttributeKind.MAC_ADDRESS, + AttributeKind.COLOR, + AttributeKind.BANDWIDTH, + AttributeKind.IPHOST, + AttributeKind.IPNETWORK, + AttributeKind.IPADDRESS, + AttributeKind.BOOLEAN, + AttributeKind.CHECKBOX, + AttributeKind.JSON, + AttributeKind.ANY, + ] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: AttributeParametersRead | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +ComputedAttributeRead = Annotated[ + ComputedAttributeUserRead | ComputedAttributeJinja2Read | ComputedAttributeTransformPythonRead, + Field(discriminator="kind"), +] + +AttributeSchemaRead = Annotated[ + TextAttributeRead | NumberAttributeRead | ListAttributeRead | NumberPoolAttributeRead | GenericAttributeRead, + Field(discriminator="kind"), +] + + +class RelationshipSchemaRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + id: str | None = Field( + default=None, + description="The ID of the relationship schema", + ) + name: str = Field( + ..., + description="Relationship name, must be unique within a model and must be all lowercase.", + pattern=r"^[a-z0-9\_]+$", + min_length=3, + max_length=64, + ) + peer: str = Field( + ..., + description="Type (kind) of objects supported on the other end of the relationship.", + pattern=r"^[A-Z][a-zA-Z0-9]+$", + ) + kind: RelationshipKind = Field( + default=RelationshipKind.GENERIC, + description="Defines the type of the relationship.", + ) + label: str | None = Field( + default=None, + description="Human friendly representation of the name. Will be autogenerated if not provided", + max_length=64, + ) + description: str | None = Field( + default=None, + description="Short description of the relationship.", + max_length=128, + ) + identifier: str | None = Field( + default=None, + description="Unique identifier of the relationship within a model, identifiers must match to traverse a relationship on both direction.", + pattern=r"^[a-z0-9\_]+$", + max_length=128, + ) + cardinality: RelationshipCardinality = Field( + default=RelationshipCardinality.MANY, + description="Defines how many objects are expected on the other side of the relationship.", + ) + min_count: int = Field( + default=0, + description="Defines the minimum objects allowed on the other side of the relationship.", + ) + max_count: int = Field( + default=0, + description="Defines the maximum objects allowed on the other side of the relationship.", + ) + common_parent: str | None = Field( + default=None, + description="Name of a parent relationship on the peer schema that must share the same related object with the object's parent.", + ) + common_relatives: list[str] | None = Field( + default=None, + description="List of relationship names on the peer schema for which all objects must share the same set of peers.", + ) + order_weight: int | None = Field( + default=None, + description="Number used to order the relationship in the frontend (table and view). Lowest value will be ordered first.", + ) + optional: bool = Field( + default=True, + description="Indicate if this relationship is mandatory or optional.", + ) + branch: BranchSupportType | None = Field( + default=None, + description="Type of branch support for the relationship. If not defined, it will be determined based on both peers.", + ) + inherited: bool = Field( + default=False, + description="Internal value to indicate if the relationship was inherited from a Generic node.", + ) + direction: RelationshipDirection = Field( + default=RelationshipDirection.BIDIR, + description="Defines the direction of the relationship, Unidirectional relationship are required when the same model is on both side.", + ) + hierarchical: str | None = Field( + default=None, + description="Internal attribute to track the type of hierarchy this relationship is part of, must match a valid Generic Kind", + ) + state: SchemaState = Field( + default=SchemaState.PRESENT, + description="Expected state of the relationship after loading the schema", + ) + on_delete: RelationshipDeleteBehavior | None = Field( + default=None, + description="Default is no-action. If cascade, related node(s) are deleted when this node is deleted.", + ) + allow_override: AllowOverrideType = Field( + default=AllowOverrideType.ANY, + description="Type of allowed override for the relationship.", + ) + read_only: bool = Field( + default=False, + description="Set the relationship as read-only, users won't be able to change its value.", + ) + deprecation: str | None = Field( + default=None, + description="Mark relationship as deprecated and provide a user-friendly message to display", + max_length=128, + ) + display: SchemaAttributeDisplay = Field( + default=SchemaAttributeDisplay.DEFAULT, + description="Controls where the relationship is displayed. 'default' shows in the main view, 'extra' shows in an expanded/secondary section.", + ) + + +class BaseNodeSchemaRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + id: str | None = Field( + default=None, + description="The ID of the node", + ) + name: str = Field( + ..., + description="Node name, must be unique within a namespace and must start with an uppercase letter.", + pattern=r"^[A-Z][a-zA-Z0-9]+$", + min_length=2, + max_length=32, + ) + namespace: str = Field( + ..., + description="Node Namespace, Namespaces are used to organize models into logical groups and to prevent name collisions.", + pattern=r"^[A-Z][a-z0-9]+$", + min_length=3, + max_length=64, + ) + description: str | None = Field( + default=None, + description="Short description of the model, will be visible in the frontend.", + max_length=128, + ) + label: str | None = Field( + default=None, + description="Human friendly representation of the name/kind", + max_length=64, + ) + branch: BranchSupportType = Field( + default=BranchSupportType.AWARE, + description="Type of branch support for the model.", + ) + default_filter: str | None = Field( + default=None, + description="Default filter used to search for a node in addition to its ID. (deprecated: please use human_friendly_id instead)", + pattern=r"^[a-z0-9\_]*$", + ) + human_friendly_id: list[str] | None = Field( + default=None, + description="Human friendly and unique identifier for the object.", + ) + display_label: str | None = Field( + default=None, + description="Attribute or Jinja2 template to use to generate the display label", + ) + display_labels: list[str] | None = Field( + default=None, + description="List of attributes to use to generate the display label (deprecated)", + ) + include_in_menu: bool | None = Field( + default=None, + description="Defines if objects of this kind should be included in the menu.", + ) + menu_placement: str | None = Field( + default=None, + description="Defines where in the menu this object should be placed.", + ) + icon: str | None = Field( + default=None, + description="Defines the icon to use in the menu. Must be a valid value from the MDI library https://icon-sets.iconify.design/mdi/", + ) + order_by: list[str] | None = Field( + default=None, + description="List of entries to order results by. Supports attributes, relationship attributes, and node_metadata with __asc/__desc.", + ) + uniqueness_constraints: list[list[str]] | None = Field( + default=None, + description="List of multi-element uniqueness constraints that can combine relationships and attributes", + ) + documentation: str | None = Field( + default=None, + description="Link to a documentation associated with this object, can be internal or external.", + ) + state: SchemaState = Field( + default=SchemaState.PRESENT, + description="Expected state of the node/generic after loading the schema", + ) + attributes: list[AttributeSchemaRead] = Field( + default_factory=list, + description="Node attributes", + ) + relationships: list[RelationshipSchemaRead] = Field( + default_factory=list, + description="Node Relationships", + ) + hash: str | None = Field( + default=None, + description="Hash of the node computed by the server.", + ) + + @computed_field + @property + def kind(self) -> str: + return f"{self.namespace}{self.name}" + + +class NodeSchemaRead(BaseNodeSchemaRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + inherit_from: list[str] = Field( + default_factory=list, + description="List of Generic Kind that this node is inheriting from", + ) + generate_profile: bool = Field( + default=True, + description="Indicate if a profile schema should be generated for this schema", + ) + generate_template: bool = Field( + default=False, + description="Indicate if an object template schema should be generated for this schema", + ) + hierarchy: str | None = Field( + default=None, + description="Internal value to track the name of the Hierarchy, must match the name of a Generic supporting hierarchical mode", + ) + parent: str | None = Field( + default=None, + description="Expected Kind for the parent node in a Hierarchy, default to the main generic defined if not defined.", + ) + children: str | None = Field( + default=None, + description="Expected Kind for the children nodes in a Hierarchy, default to the main generic defined if not defined.", + ) + + +class GenericSchemaRead(BaseNodeSchemaRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + hierarchical: bool = Field( + default=False, + description="Defines if the Generic support the hierarchical mode.", + ) + generate_profile: bool = Field( + default=True, + description="Indicate if a profile schema should be generated for this schema", + ) + used_by: list[str] = Field( + default_factory=list, + description="List of Nodes that are referencing this Generic", + ) + restricted_namespaces: list[str] | None = Field( + default=None, + description="Nodes inheriting from this Generic schema must belong to one of the listed namespaces", + ) + + +class ProfileSchemaRead(BaseNodeSchemaRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + inherit_from: list[str] = Field( + default_factory=list, + description="List of Generic Kind that this profile is inheriting from", + ) + + +class TemplateSchemaRead(BaseNodeSchemaRead): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + inherit_from: list[str] = Field( + default_factory=list, + description="List of Generic Kind that this template is inheriting from", + ) + + +class InfrahubSchemaRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + nodes: list[NodeSchemaRead] = Field(default_factory=list) + generics: list[GenericSchemaRead] = Field(default_factory=list) diff --git a/infrahub_sdk/schema/generated/write.py b/infrahub_sdk/schema/generated/write.py new file mode 100644 index 000000000..a834ea442 --- /dev/null +++ b/infrahub_sdk/schema/generated/write.py @@ -0,0 +1,588 @@ +# Generated by "invoke backend.generate", do not edit directly + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from .enums import ( + AllowOverrideType, + AttributeKind, + BranchSupportType, + ComputedAttributeKind, + RelationshipCardinality, + RelationshipDeleteBehavior, + RelationshipDirection, + RelationshipKind, + SchemaAttributeDisplay, + SchemaState, +) + + +class AttributeParametersWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + + +class ListAttributeParametersWrite(AttributeParametersWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + regex: str | None = Field( + default=None, + description="Regular expression that each list item value must match if defined", + ) + + +class TextAttributeParametersWrite(AttributeParametersWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + regex: str | None = Field( + default=None, + description="Regular expression that attribute value must match if defined", + ) + min_length: int | None = Field( + default=None, + description="Set a minimum number of characters allowed.", + ) + max_length: int | None = Field( + default=None, + description="Set a maximum number of characters allowed.", + ) + + +class NumberAttributeParametersWrite(AttributeParametersWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + min_value: int | None = Field( + default=None, + description="Set a minimum value allowed.", + ) + max_value: int | None = Field( + default=None, + description="Set a maximum value allowed.", + ) + excluded_values: str | None = Field( + default=None, + description="List of values or range of values not allowed for the attribute, format is: '100,150-200,280,300-400'", + pattern=r"^(\d+(?:-\d+)?)(?:,\d+(?:-\d+)?)*$", + ) + + +class NumberPoolParametersWrite(AttributeParametersWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + end_range: int = Field( + default=9223372036854775807, + description="End range for numbers for the associated NumberPool", + ) + start_range: int = Field( + default=1, + description="Start range for numbers for the associated NumberPool", + ) + number_pool_id: str | None = Field( + default=None, + description="The ID of the numberpool associated with this attribute. Only set after the number pool has been provisioned.", + ) + + +class DropdownChoiceWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + name: str = Field( + ..., + description="Name of the choice, must be unique within the dropdown.", + ) + description: str | None = Field( + default=None, + description="Description of the choice.", + ) + color: str | None = Field( + default=None, + description="Color of the choice, must be a valid HTML color code.", + pattern=r"#[0-9a-fA-F]{6}\b", + ) + label: str | None = Field( + default=None, + description="Human friendly representation of the choice.", + ) + + +class ComputedAttributeUserWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[ComputedAttributeKind.USER] = Field( + ..., + description="Defines how the value of the attribute is computed.", + ) + + +class ComputedAttributeJinja2Write(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[ComputedAttributeKind.JINJA2] = Field( + ..., + description="Defines how the value of the attribute is computed.", + ) + jinja2_template: str = Field( + ..., + description="Jinja2 template used to compute the value, required when kind is Jinja2.", + ) + + +class ComputedAttributeTransformPythonWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[ComputedAttributeKind.TRANSFORM_PYTHON] = Field( + ..., + description="Defines how the value of the attribute is computed.", + ) + transform: str = Field( + ..., + description="Python transform name or ID, required when kind is TransformPython.", + ) + + +class AttributeSchemaBaseWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + id: str | None = Field( + default=None, + description="The ID of the attribute", + ) + name: str = Field( + ..., + description="Attribute name, must be unique within a model and must be all lowercase.", + pattern=r"^[a-z0-9\_]+$", + min_length=3, + max_length=64, + ) + kind: AttributeKind = Field( + ..., + description="Defines the type of the attribute.", + ) + enum: list | None = Field( + default=None, + description="Define a list of valid values for the attribute.", + ) + computed_attribute: ComputedAttributeWrite | None = Field( + default=None, + description="Defines how the value of this attribute will be populated.", + ) + choices: list[DropdownChoiceWrite] | None = Field( + default=None, + description="Define a list of valid choices for a dropdown attribute.", + ) + regex: str | None = Field( + default=None, + description="Regex uses to limit the characters allowed in for the attributes. (deprecated: please use parameters.regex instead)", + ) + max_length: int | None = Field( + default=None, + description="Set a maximum number of characters allowed for a given attribute. (deprecated: please use parameters.max_length instead)", + ) + min_length: int | None = Field( + default=None, + description="Set a minimum number of characters allowed for a given attribute. (deprecated: please use parameters.min_length instead)", + ) + label: str | None = Field( + default=None, + description="Human friendly representation of the name. Will be autogenerated if not provided", + max_length=64, + ) + description: str | None = Field( + default=None, + description="Short description of the attribute.", + max_length=128, + ) + read_only: bool = Field( + default=False, + description="Set the attribute as Read-Only, users won't be able to change its value. Mainly relevant for internal object.", + ) + unique: bool = Field( + default=False, + description="Indicate if the value of this attribute must be unique in the database for a given model.", + ) + optional: bool = Field( + default=False, + description="Indicate if this attribute is mandatory or optional.", + ) + branch: BranchSupportType | None = Field( + default=None, + description="Type of branch support for the attribute, if not defined it will be inherited from the node.", + ) + order_weight: int | None = Field( + default=None, + description="Number used to order the attribute in the frontend (table and view). Lowest value will be ordered first.", + ) + ordered: bool = Field( + default=True, + description="Whether element order is significant. When False, reordering a List or JSON-array attribute is not a merge/rebase conflict.", + ) + default_value: Any | None = Field( + default=None, + description="Default value of the attribute.", + ) + state: SchemaState = Field( + default=SchemaState.PRESENT, + description="Expected state of the attribute after loading the schema", + ) + allow_override: AllowOverrideType = Field( + default=AllowOverrideType.ANY, + description="Type of allowed override for the attribute.", + ) + deprecation: str | None = Field( + default=None, + description="Mark attribute as deprecated and provide a user-friendly message to display", + max_length=128, + ) + display: SchemaAttributeDisplay = Field( + default=SchemaAttributeDisplay.DEFAULT, + description="Controls where the attribute is displayed. 'default' shows in the main view, 'extra' shows in an expanded/secondary section.", + ) + + +class TextAttributeWrite(AttributeSchemaBaseWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.TEXT, AttributeKind.TEXTAREA] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: TextAttributeParametersWrite | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +class NumberAttributeWrite(AttributeSchemaBaseWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.NUMBER] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: NumberAttributeParametersWrite | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +class ListAttributeWrite(AttributeSchemaBaseWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.LIST] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: ListAttributeParametersWrite | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +class NumberPoolAttributeWrite(AttributeSchemaBaseWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[AttributeKind.NUMBERPOOL] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: NumberPoolParametersWrite | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +class GenericAttributeWrite(AttributeSchemaBaseWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: Literal[ + AttributeKind.ID, + AttributeKind.DROPDOWN, + AttributeKind.DATETIME, + AttributeKind.EMAIL, + AttributeKind.PASSWORD, + AttributeKind.HASHEDPASSWORD, + AttributeKind.URL, + AttributeKind.FILE, + AttributeKind.MAC_ADDRESS, + AttributeKind.COLOR, + AttributeKind.BANDWIDTH, + AttributeKind.IPHOST, + AttributeKind.IPNETWORK, + AttributeKind.IPADDRESS, + AttributeKind.BOOLEAN, + AttributeKind.CHECKBOX, + AttributeKind.JSON, + AttributeKind.ANY, + ] = Field( + ..., + description="Defines the type of the attribute.", + ) + parameters: AttributeParametersWrite | None = Field( + default=None, + description="Extra parameters specific to this kind of attribute", + ) + + +ComputedAttributeWrite = Annotated[ + ComputedAttributeUserWrite | ComputedAttributeJinja2Write | ComputedAttributeTransformPythonWrite, + Field(discriminator="kind"), +] + +AttributeSchemaWrite = Annotated[ + TextAttributeWrite | NumberAttributeWrite | ListAttributeWrite | NumberPoolAttributeWrite | GenericAttributeWrite, + Field(discriminator="kind"), +] + + +class RelationshipSchemaWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + id: str | None = Field( + default=None, + description="The ID of the relationship schema", + ) + name: str = Field( + ..., + description="Relationship name, must be unique within a model and must be all lowercase.", + pattern=r"^[a-z0-9\_]+$", + min_length=3, + max_length=64, + ) + peer: str = Field( + ..., + description="Type (kind) of objects supported on the other end of the relationship.", + pattern=r"^[A-Z][a-zA-Z0-9]+$", + ) + kind: RelationshipKind = Field( + default=RelationshipKind.GENERIC, + description="Defines the type of the relationship.", + ) + label: str | None = Field( + default=None, + description="Human friendly representation of the name. Will be autogenerated if not provided", + max_length=64, + ) + description: str | None = Field( + default=None, + description="Short description of the relationship.", + max_length=128, + ) + identifier: str | None = Field( + default=None, + description="Unique identifier of the relationship within a model, identifiers must match to traverse a relationship on both direction.", + pattern=r"^[a-z0-9\_]+$", + max_length=128, + ) + cardinality: RelationshipCardinality = Field( + default=RelationshipCardinality.MANY, + description="Defines how many objects are expected on the other side of the relationship.", + ) + min_count: int = Field( + default=0, + description="Defines the minimum objects allowed on the other side of the relationship.", + ) + max_count: int = Field( + default=0, + description="Defines the maximum objects allowed on the other side of the relationship.", + ) + common_parent: str | None = Field( + default=None, + description="Name of a parent relationship on the peer schema that must share the same related object with the object's parent.", + ) + common_relatives: list[str] | None = Field( + default=None, + description="List of relationship names on the peer schema for which all objects must share the same set of peers.", + ) + order_weight: int | None = Field( + default=None, + description="Number used to order the relationship in the frontend (table and view). Lowest value will be ordered first.", + ) + optional: bool = Field( + default=True, + description="Indicate if this relationship is mandatory or optional.", + ) + branch: BranchSupportType | None = Field( + default=None, + description="Type of branch support for the relationship. If not defined, it will be determined based on both peers.", + ) + direction: RelationshipDirection = Field( + default=RelationshipDirection.BIDIR, + description="Defines the direction of the relationship, Unidirectional relationship are required when the same model is on both side.", + ) + state: SchemaState = Field( + default=SchemaState.PRESENT, + description="Expected state of the relationship after loading the schema", + ) + on_delete: RelationshipDeleteBehavior | None = Field( + default=None, + description="Default is no-action. If cascade, related node(s) are deleted when this node is deleted.", + ) + allow_override: AllowOverrideType = Field( + default=AllowOverrideType.ANY, + description="Type of allowed override for the relationship.", + ) + read_only: bool = Field( + default=False, + description="Set the relationship as read-only, users won't be able to change its value.", + ) + deprecation: str | None = Field( + default=None, + description="Mark relationship as deprecated and provide a user-friendly message to display", + max_length=128, + ) + display: SchemaAttributeDisplay = Field( + default=SchemaAttributeDisplay.DEFAULT, + description="Controls where the relationship is displayed. 'default' shows in the main view, 'extra' shows in an expanded/secondary section.", + ) + + +class BaseNodeSchemaWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + id: str | None = Field( + default=None, + description="The ID of the node", + ) + name: str = Field( + ..., + description="Node name, must be unique within a namespace and must start with an uppercase letter.", + pattern=r"^[A-Z][a-zA-Z0-9]+$", + min_length=2, + max_length=32, + ) + namespace: str = Field( + ..., + description="Node Namespace, Namespaces are used to organize models into logical groups and to prevent name collisions.", + pattern=r"^[A-Z][a-z0-9]+$", + min_length=3, + max_length=64, + ) + description: str | None = Field( + default=None, + description="Short description of the model, will be visible in the frontend.", + max_length=128, + ) + label: str | None = Field( + default=None, + description="Human friendly representation of the name/kind", + max_length=64, + ) + branch: BranchSupportType = Field( + default=BranchSupportType.AWARE, + description="Type of branch support for the model.", + ) + default_filter: str | None = Field( + default=None, + description="Default filter used to search for a node in addition to its ID. (deprecated: please use human_friendly_id instead)", + pattern=r"^[a-z0-9\_]*$", + ) + human_friendly_id: list[str] | None = Field( + default=None, + description="Human friendly and unique identifier for the object.", + ) + display_label: str | None = Field( + default=None, + description="Attribute or Jinja2 template to use to generate the display label", + ) + display_labels: list[str] | None = Field( + default=None, + description="List of attributes to use to generate the display label (deprecated)", + ) + include_in_menu: bool | None = Field( + default=None, + description="Defines if objects of this kind should be included in the menu.", + ) + menu_placement: str | None = Field( + default=None, + description="Defines where in the menu this object should be placed.", + ) + icon: str | None = Field( + default=None, + description="Defines the icon to use in the menu. Must be a valid value from the MDI library https://icon-sets.iconify.design/mdi/", + ) + order_by: list[str] | None = Field( + default=None, + description="List of entries to order results by. Supports attributes, relationship attributes, and node_metadata with __asc/__desc.", + ) + uniqueness_constraints: list[list[str]] | None = Field( + default=None, + description="List of multi-element uniqueness constraints that can combine relationships and attributes", + ) + documentation: str | None = Field( + default=None, + description="Link to a documentation associated with this object, can be internal or external.", + ) + state: SchemaState = Field( + default=SchemaState.PRESENT, + description="Expected state of the node/generic after loading the schema", + ) + attributes: list[AttributeSchemaWrite] = Field( + default_factory=list, + description="Node attributes", + ) + relationships: list[RelationshipSchemaWrite] = Field( + default_factory=list, + description="Node Relationships", + ) + + @property + def kind(self) -> str: + return f"{self.namespace}{self.name}" + + +class NodeSchemaWrite(BaseNodeSchemaWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + inherit_from: list[str] = Field( + default_factory=list, + description="List of Generic Kind that this node is inheriting from", + ) + generate_profile: bool = Field( + default=True, + description="Indicate if a profile schema should be generated for this schema", + ) + generate_template: bool = Field( + default=False, + description="Indicate if an object template schema should be generated for this schema", + ) + parent: str | None = Field( + default=None, + description="Expected Kind for the parent node in a Hierarchy, default to the main generic defined if not defined.", + ) + children: str | None = Field( + default=None, + description="Expected Kind for the children nodes in a Hierarchy, default to the main generic defined if not defined.", + ) + + +class GenericSchemaWrite(BaseNodeSchemaWrite): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + hierarchical: bool = Field( + default=False, + description="Defines if the Generic support the hierarchical mode.", + ) + generate_profile: bool = Field( + default=True, + description="Indicate if a profile schema should be generated for this schema", + ) + restricted_namespaces: list[str] | None = Field( + default=None, + description="Nodes inheriting from this Generic schema must belong to one of the listed namespaces", + ) + + +class NodeExtensionWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + kind: str = Field( + ..., + description="Kind of the existing node to extend.", + ) + attributes: list[AttributeSchemaWrite] = Field( + default_factory=list, + description="Attributes to add to the existing node.", + ) + relationships: list[RelationshipSchemaWrite] = Field( + default_factory=list, + description="Relationships to add to the existing node.", + ) + + +class SchemaExtensionWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + nodes: list[NodeExtensionWrite] = Field( + default_factory=list, + description="Nodes to extend with additional attributes and relationships.", + ) + + +class InfrahubSchemaWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + version: str + nodes: list[NodeSchemaWrite] = Field(default_factory=list) + generics: list[GenericSchemaWrite] = Field(default_factory=list) + extensions: SchemaExtensionWrite | None = None diff --git a/infrahub_sdk/schema/main.py b/infrahub_sdk/schema/main.py index 83bc69a86..04352d965 100644 --- a/infrahub_sdk/schema/main.py +++ b/infrahub_sdk/schema/main.py @@ -1,155 +1,155 @@ from __future__ import annotations -import warnings from collections.abc import MutableMapping -from enum import Enum from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field from typing_extensions import Self +from .generated.enums import ( + AllowOverrideType, + AttributeKind, + BranchSupportType, + ComputedAttributeKind, + RelationshipCardinality, + RelationshipDeleteBehavior, + RelationshipDirection, + RelationshipKind, + SchemaAttributeDisplay, + SchemaState, +) +from .generated.read import ( + AttributeSchemaBaseRead, + BaseNodeSchemaRead, + ComputedAttributeRead, # noqa: F401 (re-exported here to resolve the inherited forward reference) + GenericSchemaRead, + NodeSchemaRead, + ProfileSchemaRead, + RelationshipSchemaRead, + TemplateSchemaRead, +) +from .generated.write import ( + AttributeSchemaBaseWrite, + ComputedAttributeWrite, # noqa: F401 (re-exported here to resolve the inherited forward reference) + GenericSchemaWrite, + NodeSchemaWrite, + RelationshipSchemaWrite, + SchemaExtensionWrite, +) + if TYPE_CHECKING: from ..node import InfrahubNode, InfrahubNodeSync InfrahubNodeTypes = InfrahubNode | InfrahubNodeSync +# The enum classes and the generated write/read data models now live in the generated modules. +# ``main.py`` keeps the public names stable by re-exporting the enums and by subclassing the +# generated data models with the hand-written behavior below. The historical import paths +# (``from infrahub_sdk.schema.main import AttributeKind, NodeSchema, ...``) keep working. +__all__ = [ + "AllowOverrideType", + "AttributeKind", + "AttributeSchema", + "AttributeSchemaAPI", + "BranchSchema", + "BranchSupportType", + "ComputedAttributeKind", + "GenericSchema", + "GenericSchemaAPI", + "NodeSchema", + "NodeSchemaAPI", + "ProfileSchemaAPI", + "RelationshipCardinality", + "RelationshipDeleteBehavior", + "RelationshipDirection", + "RelationshipKind", + "RelationshipSchema", + "RelationshipSchemaAPI", + "SchemaAttributeDisplay", + "SchemaRoot", + "SchemaRootAPI", + "SchemaState", + "TemplateSchemaAPI", +] + + +# --------------------------------------------------------------------------- +# Write models (user-facing construction entry points) +# --------------------------------------------------------------------------- + + +class AttributeSchema(AttributeSchemaBaseWrite): + """Thin, constructible attribute model kept for backward compatibility. + + ``AttributeSchemaWrite`` (from the generated module) is a non-constructible discriminated union. + This class keeps ``AttributeSchema(name=..., kind=AttributeKind.TEXT, ...)`` working by exposing + the shared write base plus a permissive ``parameters``/``choices``. Unknown keys are dropped + silently (inherited ``extra="ignore"``), matching the rest of the write contract. + """ -class RelationshipCardinality(str, Enum): - ONE = "one" - MANY = "many" - - -class BranchSupportType(str, Enum): - AWARE = "aware" - AGNOSTIC = "agnostic" - LOCAL = "local" - - -class RelationshipKind(str, Enum): - GENERIC = "Generic" - ATTRIBUTE = "Attribute" - COMPONENT = "Component" - PARENT = "Parent" - GROUP = "Group" - HIERARCHY = "Hierarchy" - PROFILE = "Profile" - TEMPLATE = "Template" - - -class RelationshipDirection(str, Enum): - BIDIR = "bidirectional" - OUTBOUND = "outbound" - INBOUND = "inbound" - - -class AttributeKind(str, Enum): - ID = "ID" - TEXT = "Text" - STRING = "String" # deprecated - TEXTAREA = "TextArea" - DATETIME = "DateTime" - NUMBER = "Number" - NUMBERPOOL = "NumberPool" - DROPDOWN = "Dropdown" - EMAIL = "Email" - PASSWORD = "Password" # noqa: S105 - HASHEDPASSWORD = "HashedPassword" - URL = "URL" - FILE = "File" - MAC_ADDRESS = "MacAddress" - COLOR = "Color" - BANDWIDTH = "Bandwidth" - IPHOST = "IPHost" - IPNETWORK = "IPNetwork" - BOOLEAN = "Boolean" - CHECKBOX = "Checkbox" - LIST = "List" - JSON = "JSON" - ANY = "Any" - - def __getattr__(self, name: str) -> Any: - if name == "STRING": - warnings.warn( - f"{name} is deprecated and will be removed in future versions.", - DeprecationWarning, - stacklevel=2, - ) - return super().__getattribute__(name) - - -class SchemaState(str, Enum): - PRESENT = "present" - ABSENT = "absent" - - -class AllowOverrideType(str, Enum): - NONE = "none" - ANY = "any" - - -class RelationshipDeleteBehavior(str, Enum): - NO_ACTION = "no-action" - CASCADE = "cascade" - - -class AttributeSchema(BaseModel): - model_config = ConfigDict(use_enum_values=True) - - id: str | None = None - state: SchemaState = SchemaState.PRESENT - name: str - kind: AttributeKind - label: str | None = None - description: str | None = None - default_value: Any | None = None - unique: bool = False - branch: BranchSupportType | None = None - optional: bool = False choices: list[dict[str, Any]] | None = None - enum: list[str | int] | None = None - max_length: int | None = None - min_length: int | None = None - regex: str | None = None - order_weight: int | None = None + parameters: dict[str, Any] | None = None -class AttributeSchemaAPI(AttributeSchema): - model_config = ConfigDict(use_enum_values=True) +class RelationshipSchema(RelationshipSchemaWrite): + """Constructible relationship write model (kept as a distinct public name).""" + - inherited: bool = False - read_only: bool = False - allow_override: AllowOverrideType = AllowOverrideType.ANY +class NodeSchema(NodeSchemaWrite): + # The generated write model types these as discriminated unions, which cannot be instantiated + # directly. Overriding them with the public constructible models keeps + # ``NodeSchema(attributes=[AttributeSchema(...)])`` working for existing callers. + attributes: list[AttributeSchema] = Field(default_factory=list) + relationships: list[RelationshipSchema] = Field(default_factory=list) + + def convert_api(self) -> NodeSchemaAPI: + return NodeSchemaAPI(**self.model_dump()) -class RelationshipSchema(BaseModel): +class GenericSchema(GenericSchemaWrite): + attributes: list[AttributeSchema] = Field(default_factory=list) + relationships: list[RelationshipSchema] = Field(default_factory=list) + + def convert_api(self) -> GenericSchemaAPI: + return GenericSchemaAPI(**self.model_dump()) + + +class SchemaRoot(BaseModel): model_config = ConfigDict(use_enum_values=True) - id: str | None = None - state: SchemaState = SchemaState.PRESENT - name: str - peer: str - kind: RelationshipKind = RelationshipKind.GENERIC - label: str | None = None - description: str | None = None - identifier: str | None = None - min_count: int | None = None - max_count: int | None = None - direction: RelationshipDirection = RelationshipDirection.BIDIR - on_delete: RelationshipDeleteBehavior | None = None - cardinality: str = "many" - branch: BranchSupportType | None = None - optional: bool = True - order_weight: int | None = None - - -class RelationshipSchemaAPI(RelationshipSchema): + version: str + generics: list[GenericSchema] = Field(default_factory=list) + nodes: list[NodeSchema] = Field(default_factory=list) + # ``extensions`` mirrors the generated write contract (``nodes``/``generics``/``relationships`` + # under one block). It replaces the former flat ``node_extensions``, which the load endpoint no + # longer accepts. + extensions: SchemaExtensionWrite | None = None + + def to_schema_dict(self) -> dict[str, Any]: + return self.model_dump(exclude_unset=True, exclude_defaults=True) + + +# --------------------------------------------------------------------------- +# Read models (``*API``) -- concrete subclasses so ``isinstance`` keeps working +# --------------------------------------------------------------------------- + + +class AttributeSchemaAPI(AttributeSchemaBaseRead): + """Thin, constructible read-side attribute model kept for backward compatibility. + + ``AttributeSchemaRead`` (from the generated module) is a non-constructible discriminated union. + This class keeps ``AttributeSchemaAPI(name=..., kind=..., ...)`` working and is used as the item + type on the read schema models. It exposes the shared read base plus a permissive + ``parameters``/``choices``. No code performs ``isinstance`` on it. + """ + model_config = ConfigDict(use_enum_values=True) - inherited: bool = False - read_only: bool = False - hierarchical: str | None = None - allow_override: AllowOverrideType = AllowOverrideType.ANY + choices: list[dict[str, Any]] | None = None + parameters: dict[str, Any] | None = None + +class RelationshipSchemaAPI(RelationshipSchemaRead): @property def cardinality_is_one(self) -> bool: return self.cardinality == RelationshipCardinality.ONE @@ -159,12 +159,17 @@ def cardinality_is_many(self) -> bool: return self.cardinality == RelationshipCardinality.MANY -class BaseSchemaAttrRel(BaseModel): - attributes: list[AttributeSchema] = Field(default_factory=list) - relationships: list[RelationshipSchema] = Field(default_factory=list) +class _SchemaNodeBase(BaseNodeSchemaRead): + """Behavior shared by the node/generic/profile/template read models. + Subclasses ``BaseNodeSchemaRead``, so ``name``, ``namespace``, ``kind`` and the attribute/ + relationship collections are real inherited fields and the helpers below type-check against + them. The node-like ``*SchemaAPI`` classes inherit this alongside their specific read model + (diamond on ``BaseNodeSchemaRead``); listing this base first keeps the narrowed item types. + """ -class BaseSchemaAttrRelAPI(BaseModel): + # Narrow the attribute/relationship item types to the API variants so the returned items expose + # the behavior helpers (``cardinality_is_*``, ``inherited`` filtering, ...). attributes: list[AttributeSchemaAPI] = Field(default_factory=list) relationships: list[RelationshipSchemaAPI] = Field(default_factory=list) @@ -263,30 +268,6 @@ def local_relationships(self) -> list[RelationshipSchemaAPI]: def unique_attributes(self) -> list[AttributeSchemaAPI]: return [item for item in self.attributes if item.unique] - -class BaseSchema(BaseModel): - model_config = ConfigDict(use_enum_values=True) - - id: str | None = None - state: SchemaState = SchemaState.PRESENT - name: str - label: str | None = None - namespace: str - description: str | None = None - include_in_menu: bool | None = None - menu_placement: str | None = None - display_label: str | None = None - display_labels: list[str] | None = None - human_friendly_id: list[str] | None = None - icon: str | None = None - uniqueness_constraints: list[list[str]] | None = None - documentation: str | None = None - order_by: list[str] | None = None - - @property - def kind(self) -> str: - return self.namespace + self.name - @property def supports_artifact_definition(self) -> bool: """Returns True if this schema represents CoreArtifactDefinition. Only meaningful for NodeSchemaAPI.""" @@ -324,41 +305,7 @@ def hierarchical_relationship_schemas(self) -> list[RelationshipSchemaAPI]: return [] -class GenericSchema(BaseSchema, BaseSchemaAttrRel): - def convert_api(self) -> GenericSchemaAPI: - return GenericSchemaAPI(**self.model_dump()) - - -class GenericSchemaAPI(BaseSchema, BaseSchemaAttrRelAPI): - """A Generic can be either an Interface or a Union depending if there are some Attributes or Relationships defined.""" - - hash: str | None = None - hierarchical: bool | None = None - used_by: list[str] = Field(default_factory=list) - restricted_namespaces: list[str] | None = None - - -class BaseNodeSchema(BaseSchema): - model_config = ConfigDict(use_enum_values=True) - - inherit_from: list[str] = Field(default_factory=list) - branch: BranchSupportType | None = None - default_filter: str | None = None - generate_profile: bool | None = None - generate_template: bool | None = None - parent: str | None = None - children: str | None = None - - -class NodeSchema(BaseNodeSchema, BaseSchemaAttrRel): - def convert_api(self) -> NodeSchemaAPI: - return NodeSchemaAPI(**self.model_dump()) - - -class NodeSchemaAPI(BaseNodeSchema, BaseSchemaAttrRelAPI): - hash: str | None = None - hierarchy: str | None = None - +class NodeSchemaAPI(_SchemaNodeBase, NodeSchemaRead): @property def supports_artifacts(self) -> bool: return "CoreArtifactTarget" in self.inherit_from @@ -377,52 +324,46 @@ def hierarchical_relationship_schemas(self) -> list[RelationshipSchemaAPI]: return [] return [ RelationshipSchemaAPI( - name="parent", peer=self.hierarchy, kind=RelationshipKind.HIERARCHY, cardinality="one", optional=True + name="parent", + peer=self.hierarchy, + kind=RelationshipKind.HIERARCHY, + cardinality=RelationshipCardinality.ONE, + optional=True, ), RelationshipSchemaAPI( - name="children", peer=self.hierarchy, kind=RelationshipKind.HIERARCHY, cardinality="many", optional=True + name="children", + peer=self.hierarchy, + kind=RelationshipKind.HIERARCHY, + cardinality=RelationshipCardinality.MANY, + optional=True, ), RelationshipSchemaAPI( - name="ancestors", peer=self.hierarchy, cardinality="many", read_only=True, optional=True + name="ancestors", + peer=self.hierarchy, + cardinality=RelationshipCardinality.MANY, + read_only=True, + optional=True, ), RelationshipSchemaAPI( - name="descendants", peer=self.hierarchy, cardinality="many", read_only=True, optional=True + name="descendants", + peer=self.hierarchy, + cardinality=RelationshipCardinality.MANY, + read_only=True, + optional=True, ), ] -class ProfileSchemaAPI(BaseSchema, BaseSchemaAttrRelAPI): - inherit_from: list[str] = Field(default_factory=list) - - -class TemplateSchemaAPI(BaseSchema, BaseSchemaAttrRelAPI): - inherit_from: list[str] = Field(default_factory=list) - - -class NodeExtensionSchema(BaseModel): - model_config = ConfigDict(use_enum_values=True) - - name: str | None = None - kind: str - description: str | None = None - label: str | None = None - inherit_from: list[str] = Field(default_factory=list) - branch: BranchSupportType | None = None - default_filter: str | None = None - attributes: list[AttributeSchema] = Field(default_factory=list) - relationships: list[RelationshipSchema] = Field(default_factory=list) +class GenericSchemaAPI(_SchemaNodeBase, GenericSchemaRead): + """A Generic can be either an Interface or a Union depending if there are some Attributes or Relationships defined.""" -class SchemaRoot(BaseModel): - model_config = ConfigDict(use_enum_values=True) +class ProfileSchemaAPI(_SchemaNodeBase, ProfileSchemaRead): + pass - version: str - generics: list[GenericSchema] = Field(default_factory=list) - nodes: list[NodeSchema] = Field(default_factory=list) - node_extensions: list[NodeExtensionSchema] = Field(default_factory=list) - def to_schema_dict(self) -> dict[str, Any]: - return self.model_dump(exclude_unset=True, exclude_defaults=True) +class TemplateSchemaAPI(_SchemaNodeBase, TemplateSchemaRead): + pass class SchemaRootAPI(BaseModel): diff --git a/infrahub_sdk/schema/repository.py b/infrahub_sdk/schema/repository.py index aca13a78b..db3bece9f 100644 --- a/infrahub_sdk/schema/repository.py +++ b/infrahub_sdk/schema/repository.py @@ -37,6 +37,31 @@ class InfrahubRepositoryArtifactDefinitionConfig(InfrahubRepositoryConfigElement transformation: str = Field(..., description="The transformation to use.") +MISSING_WATCH_MESSAGE = ( + "Missing 'watch' block. Infrahub cannot detect every file this depends on, so list templates, " + "helper modules and data files under 'watch.files' to have the results regenerated when they " + "change. Use an empty 'files: []' to record that nothing extra needs watching." +) + +MALFORMED_WATCH_MESSAGE = ( + "The 'watch' block must be a mapping. Put the watched paths under 'files', or use 'files: []' to " + "confirm that nothing extra needs watching." +) + +# Advisory only: nothing here is enforced by the models. A YAML language server reports a missing +# required property as a warning, which is how editors flag an absent 'watch' block while someone +# edits .infrahub.yml. 'allOf' is used rather than a top-level 'required' because keys in +# json_schema_extra replace the ones pydantic generates, which would drop the real required fields. +REQUIRE_WATCH_JSON_SCHEMA: dict[str, Any] = {"allOf": [{"required": ["watch"], "errorMessage": MISSING_WATCH_MESSAGE}]} + +# Narrowing the field to an object rejects a bare 'watch:' or 'watch: null', which pydantic accepts +# through the null half of the generated anyOf. Those forms leave 'watch' as None, indistinguishable +# from never having declared it, so the acknowledgement the block exists to record is lost. An empty +# 'watch: {}' is deliberately fine: 'files' defaults to an empty list, so it carries that +# acknowledgement without the author having to spell the key out. +WATCH_FIELD_JSON_SCHEMA: dict[str, Any] = {"type": "object", "errorMessage": MALFORMED_WATCH_MESSAGE} + + class InfrahubWatchConfig(BaseModel): """Extra files and directories a transform depends on. @@ -44,6 +69,9 @@ class InfrahubWatchConfig(BaseModel): depends on files that cannot be detected automatically, such as templates pulled in dynamically or helper modules imported at runtime. When any watched file changes, the transform's artifacts are regenerated. + + An empty 'files' list is a valid answer: it records that the author checked and nothing beyond + what Infrahub detects needs watching. """ model_config = ConfigDict(extra="forbid") @@ -61,6 +89,7 @@ class InfrahubJinja2TransformConfig(InfrahubRepositoryConfigElement): description: str | None = Field(default=None, description="Description for this transform") watch: InfrahubWatchConfig | None = Field( default=None, + json_schema_extra=WATCH_FIELD_JSON_SCHEMA, description="Extra files and directories this transform depends on, in addition to the ones Infrahub detects automatically.", ) @@ -102,7 +131,7 @@ def load_class(self, import_root: str | None = None, relative_path: str | None = class InfrahubGeneratorDefinitionConfig(InfrahubRepositoryConfigElement): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", json_schema_extra=REQUIRE_WATCH_JSON_SCHEMA) name: str = Field(..., description="The name of the Generator Definition") file_path: Path = Field(..., description="The file within the repository with the generator code.") @@ -131,6 +160,11 @@ class InfrahubGeneratorDefinitionConfig(InfrahubRepositoryConfigElement): default=True, description="When true (default), the Generator runs after a branch merge. Set to false for Generators that only run via event triggers.", ) + watch: InfrahubWatchConfig | None = Field( + default=None, + json_schema_extra=WATCH_FIELD_JSON_SCHEMA, + description="Extra files and directories this generator depends on, in addition to the ones Infrahub detects automatically.", + ) def load_class(self, import_root: str | None = None, relative_path: str | None = None) -> type[InfrahubGenerator]: module = import_module(module_path=self.file_path, import_root=import_root, relative_path=relative_path) @@ -147,7 +181,7 @@ def load_class(self, import_root: str | None = None, relative_path: str | None = class InfrahubPythonTransformConfig(InfrahubRepositoryConfigElement): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", json_schema_extra=REQUIRE_WATCH_JSON_SCHEMA) name: str = Field(..., description="The name of the Transform") file_path: Path = Field(..., description="The file within the repository with the transform code.") @@ -159,6 +193,7 @@ class InfrahubPythonTransformConfig(InfrahubRepositoryConfigElement): description: str | None = Field(default=None, description="Description for this transform") watch: InfrahubWatchConfig | None = Field( default=None, + json_schema_extra=WATCH_FIELD_JSON_SCHEMA, description="Extra files and directories this transform depends on, in addition to the ones Infrahub detects automatically.", ) diff --git a/infrahub_sdk/schema/validate.py b/infrahub_sdk/schema/validate.py new file mode 100644 index 000000000..8cae66cb1 --- /dev/null +++ b/infrahub_sdk/schema/validate.py @@ -0,0 +1,260 @@ +"""Offline validation of a schema payload against the generated write models. + +This module depends only on pydantic and the generated write models, so a caller +can validate a schema payload with just the SDK installed (no server, no backend). +The write models omit fields the user may not set (read-level, internal) and set +``extra="ignore"``, so those values never reach the server. Whether an omitted field is +reported depends on what it is: a read-only field -- one the read API returns -- is reported +as a warning so a payload read back from Infrahub still loads, while any other extra field is +an error, because the only ways to get one are a typo and a field that no longer exists. +Constrained fields set outside their allowed set are also rejected naming the field and the +invalid value, as are missing required fields and unknown enum members. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field +from pydantic import ValidationError as PydanticValidationError + +from .generated.contract import READ_ONLY_FIELDS +from .generated.write import InfrahubSchemaWrite + +# Payload containers whose items carry the identity used to report a finding. The warning shape +# consumers render is named (kind, field) rather than positional, so the walk tracks the owning +# kind and element alongside the dotted path. +_KIND_CONTAINERS = frozenset({"nodes", "generics"}) +_ELEMENT_CONTAINERS = frozenset({"attributes", "relationships"}) + + +class SchemaValidationErrorDetail(BaseModel): + """A single field-level validation problem in a schema payload.""" + + field: str = Field(..., description="Dotted path to the offending field, e.g. 'nodes[0].attributes[1].kind'") + message: str = Field(..., description="Human-readable, field-level error message") + + +class SchemaValidationWarningDetail(BaseModel): + """A read-only field set in a schema payload: accepted, but the submitted value is dropped.""" + + field: str = Field(..., description="Dotted path to the offending field, e.g. 'nodes[0].attributes[1].inherited'") + name: str = Field( + ..., + description="Field path relative to the owning kind or element, e.g. 'inherited' or 'parameters.id'", + ) + kind: str | None = Field(default=None, description="Kind of the schema node carrying the field, when resolvable") + element: str | None = Field( + default=None, description="Name of the attribute or relationship carrying the field, when applicable" + ) + message: str = Field(..., description="Human-readable, field-level warning message") + + +class SchemaValidationResult(BaseModel): + """The verdict of validating a schema payload against the write contract.""" + + valid: bool = Field(..., description="True when the payload satisfies the write contract") + errors: list[SchemaValidationErrorDetail] = Field( + default_factory=list, description="One entry per field-level problem; empty when valid" + ) + warnings: list[SchemaValidationWarningDetail] = Field( + default_factory=list, description="One entry per read-only field set in the payload" + ) + + @property + def messages(self) -> list[str]: + return [error.message for error in self.errors] + + @property + def warning_messages(self) -> list[str]: + return [warning.message for warning in self.warnings] + + def raise_for_status(self) -> None: + """Raise when the payload is invalid, joining every field-level message. + + Raises: + ValueError: When the result is invalid; the message joins every field-level error. + + """ + if not self.valid: + raise ValueError("; ".join(self.messages)) + + +def _format_error_location(loc: tuple[Any, ...], prefix: str = "") -> str: + """Render a dotted field path from a pydantic error location, optionally under a base prefix. + + Integer elements index into the preceding segment (``attributes`` + ``1`` becomes + ``attributes[1]``); everything else is appended as a new dotted segment. A ``prefix`` is used + when the location is relative to an item validated on its own (e.g. an extension attribute). + """ + parts = [prefix] if prefix else [] + for element in loc: + if isinstance(element, int): + if parts: + parts[-1] = f"{parts[-1]}[{element}]" + else: + parts.append(f"[{element}]") + else: + parts.append(str(element)) + return ".".join(parts) + + +def _collect_validation_errors( + exc: PydanticValidationError, errors: list[SchemaValidationErrorDetail], prefix: str = "" +) -> None: + """Append a field-level detail for every problem in a pydantic validation error.""" + for error in exc.errors(): + location = _format_error_location(loc=error["loc"], prefix=prefix) + message = f"{location}: {error['msg']}" + if error["type"] != "missing" and "input" in error: + message += f" (received: {error['input']!r})" + errors.append(SchemaValidationErrorDetail(field=location, message=message)) + + +def _descend_context( + field: str, item: dict[str, Any], kind: str | None, element: str | None, qualifier: tuple[str, ...] +) -> tuple[str | None, str | None, tuple[str, ...]]: + """Resolve the owning kind, element and field qualifier for a value nested under a field. + + Entering a kind or element container re-anchors the identity a finding is reported against, so + the qualifier resets there. Anywhere else the field name joins the qualifier, which is what + distinguishes a nested ``parameters.id`` from an ``id`` set directly on the attribute. + """ + if field in _KIND_CONTAINERS: + namespace, name = item.get("namespace"), item.get("name") + # An extension addresses an existing node by kind; a new node is namespace + name. + resolved = f"{namespace}{name}" if namespace and name else item.get("kind") + return (resolved if isinstance(resolved, str) else None), None, () + if field in _ELEMENT_CONTAINERS: + name = item.get("name") + return kind, (name if isinstance(name, str) else None), () + return kind, element, (*qualifier, field) + + +def _collect_extra_fields( + payload: Any, + instance: BaseModel, + errors: list[SchemaValidationErrorDetail], + warnings: list[SchemaValidationWarningDetail], + path: str = "", + field: str | None = None, + kind: str | None = None, + element: str | None = None, + qualifier: tuple[str, ...] = (), +) -> None: + """Report every payload key the write contract does not declare, walking the validated model. + + The submitted payload is walked alongside the model validated from it, because neither on its + own carries what a finding needs. ``extra="ignore"`` means the validated instance no longer + knows which keys were dropped, so the raw payload has to supply them; and only the instance + resolves which model governs a given location -- notably which member of a discriminated union + an attribute matched -- so only it can say which keys that location accepts. Pairing them also + yields the owning kind and element, which a finding is reported against by name rather than by + position. + + ``field`` is the name this payload was reached through, and is None only at the root. + """ + if not isinstance(payload, dict): + # A caller may nest an already-built model rather than plain data; a model cannot carry an + # undeclared key, so there is nothing to compare and nothing below worth walking. + return + + if field is not None: + kind, element, qualifier = _descend_context( + field=field, item=payload, kind=kind, element=element, qualifier=qualifier + ) + + fields = type(instance).model_fields + read_only = READ_ONLY_FIELDS.get(type(instance).__name__, frozenset()) + + for key in sorted(set(payload) - set(fields)): + location = f"{path}.{key}" if path else key + if key in read_only: + warnings.append( + SchemaValidationWarningDetail( + field=location, + name=".".join((*qualifier, key)), + kind=kind, + element=element, + message=f"{location}: Read-only field, the submitted value is ignored (received: {payload[key]!r})", + ) + ) + else: + errors.append( + SchemaValidationErrorDetail( + field=location, + message=f"{location}: Unknown field, it is not part of the schema (received: {payload[key]!r})", + ) + ) + + for name in fields: + if name not in payload: + continue + raw, value = payload[name], getattr(instance, name) + child_path = f"{path}.{name}" if path else name + # Validation succeeded, so a list field is index-aligned with the list it was built from. + # A list of plain values carries no nested model and is skipped. + if isinstance(value, list): + for index, (raw_item, item) in enumerate(zip(raw, value, strict=True)): + if isinstance(item, BaseModel): + _collect_extra_fields( + payload=raw_item, + instance=item, + errors=errors, + warnings=warnings, + path=f"{child_path}[{index}]", + field=name, + kind=kind, + element=element, + qualifier=qualifier, + ) + elif isinstance(value, BaseModel): + _collect_extra_fields( + payload=raw, + instance=value, + errors=errors, + warnings=warnings, + path=child_path, + field=name, + kind=kind, + element=element, + qualifier=qualifier, + ) + + +def validate_schema(schema: dict[str, Any], *, raise_on_error: bool = False) -> SchemaValidationResult: + """Validate a single schema-root payload against the generated write contract. + + Args: + schema: A schema-root mapping, e.g. ``{"version": "1.0", "nodes": [...], "generics": [...]}``. + raise_on_error: When True, raise ``ValueError`` instead of returning an invalid result. + + Returns: + A :class:`SchemaValidationResult` with a field-level message for every constrained field set + outside its allowed set, every missing required field, and every extra field the contract + does not declare, plus a warning for every read-only field the payload sets. The whole root + -- nodes, generics and the attributes/relationships nested under ``extensions.nodes`` -- is + validated against the write document model in one pass. + + Extra fields are reported only once the payload validates against the write models, since + the validated instance is what resolves the contract applying at each location. A payload + rejected for another reason therefore reports that reason first. + + Raises: + ValueError: When ``raise_on_error`` is True and the payload is invalid. + + """ + errors: list[SchemaValidationErrorDetail] = [] + warnings: list[SchemaValidationWarningDetail] = [] + + try: + validated = InfrahubSchemaWrite.model_validate(schema) + except PydanticValidationError as exc: + _collect_validation_errors(exc=exc, errors=errors) + else: + _collect_extra_fields(payload=schema, instance=validated, errors=errors, warnings=warnings) + + result = SchemaValidationResult(valid=not errors, errors=errors, warnings=warnings) + if raise_on_error: + result.raise_for_status() + return result diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index d634442a7..432d37d12 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from ..exceptions import ObjectValidationError, ValidationError -from ..schema import GenericSchemaAPI, RelationshipCardinality, RelationshipKind, RelationshipSchema +from ..schema import GenericSchemaAPI, RelationshipCardinality, RelationshipKind, RelationshipSchemaAPI from ..utils import is_valid_uuid from ..yaml import InfrahubFile, InfrahubFileKind from .models import InfrahubObjectParameters @@ -16,7 +16,7 @@ if TYPE_CHECKING: from ..client import InfrahubClient from ..node import InfrahubNode - from ..schema import MainSchemaTypesAPI, RelationshipSchema + from ..schema import MainSchemaTypesAPI def validate_list_of_scalars(value: list[Any]) -> bool: @@ -79,9 +79,9 @@ class RelationshipDataFormat(str, Enum): class RelationshipInfo(BaseModel): name: str - rel_schema: RelationshipSchema + rel_schema: RelationshipSchemaAPI peer_kind: str - peer_rel: RelationshipSchema | None = None + peer_rel: RelationshipSchemaAPI | None = None reason_relationship_not_valid: str | None = None format: RelationshipDataFormat = RelationshipDataFormat.UNKNOWN peer_human_friendly_id: list[str] | None = None @@ -128,7 +128,7 @@ def get_context(self, value: Any) -> dict: def find_matching_relationship( self, peer_schema: MainSchemaTypesAPI, force: bool = False - ) -> RelationshipSchema | None: + ) -> RelationshipSchemaAPI | None: """Find the matching relationship on the other side of the relationship.""" if self.peer_rel and not force: return self.peer_rel diff --git a/infrahub_sdk/task/__init__.py b/infrahub_sdk/task/__init__.py index 601803158..7ad13ccc5 100644 --- a/infrahub_sdk/task/__init__.py +++ b/infrahub_sdk/task/__init__.py @@ -1,11 +1,29 @@ from __future__ import annotations -from .models import Task, TaskFilter, TaskLog, TaskRelatedNode, TaskState +from .models import ( + HttpRequest, + HttpResponse, + Task, + TaskAction, + TaskActionName, + TaskError, + TaskFilter, + TaskLog, + TaskRelatedNode, + TaskState, + WebhookDeliveryTask, +) __all__ = [ + "HttpRequest", + "HttpResponse", "Task", + "TaskAction", + "TaskActionName", + "TaskError", "TaskFilter", "TaskLog", "TaskRelatedNode", "TaskState", + "WebhookDeliveryTask", ] diff --git a/infrahub_sdk/task/manager.py b/infrahub_sdk/task/manager.py index 913c6b753..59f17f869 100644 --- a/infrahub_sdk/task/manager.py +++ b/infrahub_sdk/task/manager.py @@ -4,7 +4,7 @@ import time from typing import TYPE_CHECKING, Any -from ..graphql import Query +from ..graphql import Mutation, Query from .constants import FINAL_STATES from .exceptions import TaskNotCompletedError, TaskNotFoundError, TooManyTasksError from .models import Task, TaskFilter @@ -12,6 +12,8 @@ if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync +MUTATION_TASK_QUERY = {"ok": None, "task": {"id": None}} + class InfraHubTaskManagerBase: @classmethod @@ -20,6 +22,8 @@ def _generate_query( filters: TaskFilter | None = None, include_logs: bool = False, include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, offset: int | None = None, limit: int | None = None, count: bool = False, @@ -68,6 +72,22 @@ def _generate_query( if include_related_nodes: query["InfrahubTask"]["edges"]["node"]["related_nodes"] = {"id": None, "kind": None} + if include_actions: + query["InfrahubTask"]["edges"]["node"]["available_actions"] = { + "action": None, + "available": None, + "unavailability_reason": None, + } + + if include_diagnostics: + node = query["InfrahubTask"]["edges"]["node"] + node["error"] = {"status_class": None, "message": None, "remediation": None} + # node is a GraphQL interface; the string key renders as an inline fragment. + node["... on WebhookDeliveryTask"] = { + "http_request": {"url": None, "headers": None}, + "http_response": {"status_code": None, "body": None, "latency_ms": None}, + } + return Query(query=query) @classmethod @@ -113,6 +133,8 @@ async def all( parallel: bool = False, include_logs: bool = False, include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, ) -> list[Task]: """Get all tasks. @@ -123,6 +145,8 @@ async def all( parallel: Whether to query the tasks in parallel. Defaults to False. include_logs: Whether to include the logs in the tasks. Defaults to False. include_related_nodes: Whether to include the related nodes in the tasks. Defaults to False. + include_actions: Whether to include the available actions in the tasks. Defaults to False. + include_diagnostics: Whether to include the error and webhook delivery diagnostics in the tasks. Defaults to False. Returns: A list of tasks. @@ -135,6 +159,8 @@ async def all( parallel=parallel, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, ) async def filter( @@ -146,6 +172,8 @@ async def filter( parallel: bool = False, include_logs: bool = False, include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, ) -> list[Task]: """Filter tasks. @@ -157,6 +185,8 @@ async def filter( parallel: Whether to query the tasks in parallel. Defaults to False. include_logs: Whether to include the logs in the tasks. Defaults to False. include_related_nodes: Whether to include the related nodes in the tasks. Defaults to False. + include_actions: Whether to include the available actions in the tasks. Defaults to False. + include_diagnostics: Whether to include the error and webhook delivery diagnostics in the tasks. Defaults to False. Returns: A list of tasks. @@ -167,7 +197,19 @@ async def filter( if limit: tasks, _ = await self.process_page( - self.client, self._generate_query(filters=filter, offset=offset, limit=limit, count=False), 1, timeout + self.client, + self._generate_query( + filters=filter, + offset=offset, + limit=limit, + include_logs=include_logs, + include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, + count=False, + ), + 1, + timeout, ) return tasks @@ -177,6 +219,8 @@ async def filter( timeout=timeout, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, ) return await self.process_non_batch( @@ -186,13 +230,24 @@ async def filter( timeout=timeout, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, ) - async def get(self, id: str, include_logs: bool = False, include_related_nodes: bool = False) -> Task: + async def get( + self, + id: str, + include_logs: bool = False, + include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, + ) -> Task: tasks = await self.filter( filter=TaskFilter(ids=[id]), include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, parallel=False, ) if not tasks: @@ -225,6 +280,34 @@ async def wait_for_completion(self, id: str, interval: int = 1, timeout: int = 6 await asyncio.sleep(interval) raise TaskNotCompletedError(id=id, message=f"Task {id} did not complete in {timeout} seconds") + async def retry(self, id: str) -> str: + """Retry a settled task by replaying it as a new, independent task. + + Args: + id: The id of the task to retry. + + Returns: + The id of the new task created by the retry. + + """ + query = Mutation(mutation="InfrahubTaskRetry", input_data={"data": {"id": id}}, query=MUTATION_TASK_QUERY) + response = await self.client.execute_graphql(query=query.render(), tracker="mutation-task-retry") + return response["InfrahubTaskRetry"]["task"]["id"] + + async def cancel(self, id: str) -> bool: + """Cancel an in-flight task, stopping any remaining retries. + + Args: + id: The id of the task to cancel. + + Returns: + Whether the task was successfully cancelled. + + """ + query = Mutation(mutation="InfrahubTaskCancel", input_data={"data": {"id": id}}, query=MUTATION_TASK_QUERY) + response = await self.client.execute_graphql(query=query.render(), tracker="mutation-task-cancel") + return response["InfrahubTaskCancel"]["ok"] + @staticmethod async def process_page( client: InfrahubClient, query: Query, page_number: int, timeout: int | None = None @@ -255,6 +338,8 @@ async def process_batch( timeout: int | None = None, include_logs: bool = False, include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, ) -> list[Task]: """Process queries in parallel mode.""" pagination_size = self.client.pagination_size @@ -271,6 +356,8 @@ async def process_batch( limit=pagination_size, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, count=False, ) batch_process.add( @@ -290,6 +377,8 @@ async def process_non_batch( timeout: int | None = None, include_logs: bool = False, include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, ) -> list[Task]: """Process queries without parallel mode. @@ -309,6 +398,8 @@ async def process_non_batch( limit=self.client.pagination_size, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, count=True, ) new_tasks, count = await self.process_page( @@ -353,6 +444,8 @@ def all( parallel: bool = False, include_logs: bool = False, include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, ) -> list[Task]: """Get all tasks. @@ -363,6 +456,8 @@ def all( parallel: Whether to query the tasks in parallel. Defaults to False. include_logs: Whether to include the logs in the tasks. Defaults to False. include_related_nodes: Whether to include the related nodes in the tasks. Defaults to False. + include_actions: Whether to include the available actions in the tasks. Defaults to False. + include_diagnostics: Whether to include the error and webhook delivery diagnostics in the tasks. Defaults to False. Returns: A list of tasks. @@ -375,6 +470,8 @@ def all( parallel=parallel, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, ) def filter( @@ -386,6 +483,8 @@ def filter( parallel: bool = False, include_logs: bool = False, include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, ) -> list[Task]: """Filter tasks. @@ -397,6 +496,8 @@ def filter( parallel: Whether to query the tasks in parallel. Defaults to False. include_logs: Whether to include the logs in the tasks. Defaults to False. include_related_nodes: Whether to include the related nodes in the tasks. Defaults to False. + include_actions: Whether to include the available actions in the tasks. Defaults to False. + include_diagnostics: Whether to include the error and webhook delivery diagnostics in the tasks. Defaults to False. Returns: A list of tasks. @@ -407,7 +508,19 @@ def filter( if limit: tasks, _ = self.process_page( - self.client, self._generate_query(filters=filter, offset=offset, limit=limit, count=False), 1, timeout + self.client, + self._generate_query( + filters=filter, + offset=offset, + limit=limit, + include_logs=include_logs, + include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, + count=False, + ), + 1, + timeout, ) return tasks @@ -417,6 +530,8 @@ def filter( timeout=timeout, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, ) return self.process_non_batch( @@ -426,13 +541,24 @@ def filter( timeout=timeout, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, ) - def get(self, id: str, include_logs: bool = False, include_related_nodes: bool = False) -> Task: + def get( + self, + id: str, + include_logs: bool = False, + include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, + ) -> Task: tasks = self.filter( filter=TaskFilter(ids=[id]), include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, parallel=False, ) if not tasks: @@ -465,6 +591,34 @@ def wait_for_completion(self, id: str, interval: int = 1, timeout: int = 60) -> time.sleep(interval) raise TaskNotCompletedError(id=id, message=f"Task {id} did not complete in {timeout} seconds") + def retry(self, id: str) -> str: + """Retry a settled task by replaying it as a new, independent task. + + Args: + id: The id of the task to retry. + + Returns: + The id of the new task created by the retry. + + """ + query = Mutation(mutation="InfrahubTaskRetry", input_data={"data": {"id": id}}, query=MUTATION_TASK_QUERY) + response = self.client.execute_graphql(query=query.render(), tracker="mutation-task-retry") + return response["InfrahubTaskRetry"]["task"]["id"] + + def cancel(self, id: str) -> bool: + """Cancel an in-flight task, stopping any remaining retries. + + Args: + id: The id of the task to cancel. + + Returns: + Whether the task was successfully cancelled. + + """ + query = Mutation(mutation="InfrahubTaskCancel", input_data={"data": {"id": id}}, query=MUTATION_TASK_QUERY) + response = self.client.execute_graphql(query=query.render(), tracker="mutation-task-cancel") + return response["InfrahubTaskCancel"]["ok"] + @staticmethod def process_page( client: InfrahubClientSync, query: Query, page_number: int, timeout: int | None = None @@ -495,6 +649,8 @@ def process_batch( timeout: int | None = None, include_logs: bool = False, include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, ) -> list[Task]: """Process queries in parallel mode.""" pagination_size = self.client.pagination_size @@ -511,6 +667,8 @@ def process_batch( limit=pagination_size, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, count=False, ) batch_process.add( @@ -530,6 +688,8 @@ def process_non_batch( timeout: int | None = None, include_logs: bool = False, include_related_nodes: bool = False, + include_actions: bool = False, + include_diagnostics: bool = False, ) -> list[Task]: """Process queries without parallel mode. @@ -549,6 +709,8 @@ def process_non_batch( limit=self.client.pagination_size, include_logs=include_logs, include_related_nodes=include_related_nodes, + include_actions=include_actions, + include_diagnostics=include_diagnostics, count=True, ) new_tasks, count = self.process_page( diff --git a/infrahub_sdk/task/models.py b/infrahub_sdk/task/models.py index 2525bda21..9ab907b13 100644 --- a/infrahub_sdk/task/models.py +++ b/infrahub_sdk/task/models.py @@ -2,9 +2,12 @@ from datetime import datetime from enum import Enum +from typing import Any from pydantic import BaseModel, Field +WEBHOOK_SEND_WORKFLOW = "webhook-send" + class TaskState(str, Enum): SCHEDULED = "SCHEDULED" @@ -18,17 +21,47 @@ class TaskState(str, Enum): CANCELLING = "CANCELLING" +class TaskActionName(str, Enum): + RETRY = "RETRY" + CANCEL = "CANCEL" + + class TaskLog(BaseModel): message: str severity: str timestamp: datetime +class TaskAction(BaseModel): + action: TaskActionName + available: bool + unavailability_reason: str | None = None + + class TaskRelatedNode(BaseModel): id: str kind: str +class TaskError(BaseModel): + """Classified failure reason with a remediation hint; set only when a task failed with one.""" + + status_class: str + message: str + remediation: str | None = None + + +class HttpRequest(BaseModel): + url: str + headers: dict[str, Any] | None = None # secret values are masked by the server + + +class HttpResponse(BaseModel): + status_code: int | None = None + body: str | None = None + latency_ms: float | None = None + + class Task(BaseModel): id: str title: str @@ -43,11 +76,25 @@ class Task(BaseModel): tags: list[str] | None = None related_nodes: list[TaskRelatedNode] = Field(default_factory=list) logs: list[TaskLog] = Field(default_factory=list) + available_actions: list[TaskAction] = Field(default_factory=list) + error: TaskError | None = None + + @property + def can_retry(self) -> bool: + """Whether this task can currently be retried.""" + return any(action.action is TaskActionName.RETRY and action.available for action in self.available_actions) + + @property + def can_cancel(self) -> bool: + """Whether this task can currently be cancelled.""" + return any(action.action is TaskActionName.CANCEL and action.available for action in self.available_actions) @classmethod def from_graphql(cls, data: dict) -> Task: + data = dict(data) related_nodes: list[TaskRelatedNode] = [] logs: list[TaskLog] = [] + available_actions: list[TaskAction] = [] if "related_nodes" in data: if data.get("related_nodes"): @@ -59,7 +106,32 @@ def from_graphql(cls, data: dict) -> Task: logs = [TaskLog(**item["node"]) for item in data["logs"]["edges"]] del data["logs"] - return cls(**data, related_nodes=related_nodes, logs=logs) + if "available_actions" in data: + if data.get("available_actions"): + available_actions = [TaskAction(**item) for item in data["available_actions"]] + del data["available_actions"] + + # The workflow name selects the concrete type, mirroring the server's interface; + # pydantic coerces the remaining error / http_* dicts in `data` into their models. + target_cls = TASK_TYPES.get(data.get("workflow") or "", cls) + return target_cls( + **data, + related_nodes=related_nodes, + logs=logs, + available_actions=available_actions, + ) + + +class WebhookDeliveryTask(Task): + """Concrete task type for the ``webhook-send`` workflow, carrying delivery diagnostics.""" + + http_request: HttpRequest | None = None + http_response: HttpResponse | None = None + + +TASK_TYPES: dict[str, type[Task]] = { + WEBHOOK_SEND_WORKFLOW: WebhookDeliveryTask, +} class TaskFilter(BaseModel): diff --git a/infrahub_sdk/testing/schemas/animal.py b/infrahub_sdk/testing/schemas/animal.py index bbc9992b2..03925fdc3 100644 --- a/infrahub_sdk/testing/schemas/animal.py +++ b/infrahub_sdk/testing/schemas/animal.py @@ -7,6 +7,7 @@ AttributeKind, GenericSchema, NodeSchema, + RelationshipCardinality, RelationshipDirection, RelationshipKind, SchemaRoot, @@ -44,7 +45,7 @@ def schema_animal(self) -> GenericSchema: kind=RelationshipKind.GENERIC, optional=False, peer=TESTING_PERSON, - cardinality="one", + cardinality=RelationshipCardinality.ONE, identifier="person__animal", direction=RelationshipDirection.OUTBOUND, ), @@ -53,7 +54,7 @@ def schema_animal(self) -> GenericSchema: kind=RelationshipKind.GENERIC, optional=True, peer=TESTING_PERSON, - cardinality="one", + cardinality=RelationshipCardinality.ONE, identifier="person__animal_friend", direction=RelationshipDirection.OUTBOUND, ), @@ -109,7 +110,7 @@ def schema_person(self) -> NodeSchema: name="animals", peer=TESTING_ANIMAL, identifier="person__animal", - cardinality="many", + cardinality=RelationshipCardinality.MANY, direction=RelationshipDirection.INBOUND, max_count=10, ), @@ -117,21 +118,21 @@ def schema_person(self) -> NodeSchema: name="favorite_animal", peer=TESTING_ANIMAL, identifier="favorite_animal", - cardinality="one", + cardinality=RelationshipCardinality.ONE, direction=RelationshipDirection.INBOUND, ), Rel( name="best_friends", peer=TESTING_ANIMAL, identifier="person__animal_friend", - cardinality="many", + cardinality=RelationshipCardinality.MANY, direction=RelationshipDirection.INBOUND, ), Rel( name="tags", optional=True, peer=BUILTIN_TAG, - cardinality="many", + cardinality=RelationshipCardinality.MANY, ), ], ) diff --git a/infrahub_sdk/testing/schemas/car_person.py b/infrahub_sdk/testing/schemas/car_person.py index 3a1c3dc3c..99efe4088 100644 --- a/infrahub_sdk/testing/schemas/car_person.py +++ b/infrahub_sdk/testing/schemas/car_person.py @@ -5,7 +5,7 @@ import pytest -from infrahub_sdk.schema.main import AttributeKind, NodeSchema, RelationshipKind, SchemaRoot +from infrahub_sdk.schema.main import AttributeKind, NodeSchema, RelationshipCardinality, RelationshipKind, SchemaRoot from infrahub_sdk.schema.main import AttributeSchema as Attr from infrahub_sdk.schema.main import RelationshipSchema as Rel @@ -57,7 +57,13 @@ def schema_person_base(self) -> NodeSchema: Attr(name="age", kind=AttributeKind.NUMBER, optional=True), ], relationships=[ - Rel(name="cars", kind=RelationshipKind.GENERIC, optional=True, peer=TESTING_CAR, cardinality="many") + Rel( + name="cars", + kind=RelationshipKind.GENERIC, + optional=True, + peer=TESTING_CAR, + cardinality=RelationshipCardinality.MANY, + ) ], ) @@ -81,21 +87,21 @@ def schema_car_base(self) -> NodeSchema: kind=RelationshipKind.ATTRIBUTE, optional=False, peer=TESTING_PERSON, - cardinality="one", + cardinality=RelationshipCardinality.ONE, ), Rel( name="manufacturer", kind=RelationshipKind.ATTRIBUTE, optional=False, peer=TESTING_MANUFACTURER, - cardinality="one", + cardinality=RelationshipCardinality.ONE, identifier="car__manufacturer", ), Rel( name="tags", optional=True, peer=BUILTIN_TAG, - cardinality="many", + cardinality=RelationshipCardinality.MANY, ), ], ) @@ -118,7 +124,7 @@ def schema_manufacturer_base(self) -> NodeSchema: kind=RelationshipKind.GENERIC, optional=True, peer=TESTING_CAR, - cardinality="many", + cardinality=RelationshipCardinality.MANY, identifier="car__manufacturer", ), Rel( @@ -126,7 +132,7 @@ def schema_manufacturer_base(self) -> NodeSchema: kind=RelationshipKind.GENERIC, optional=True, peer=TESTING_PERSON, - cardinality="many", + cardinality=RelationshipCardinality.MANY, identifier="person__manufacturer", ), ], diff --git a/infrahub_sdk/transfer/importer/json.py b/infrahub_sdk/transfer/importer/json.py index 32928df97..8b0c1e730 100644 --- a/infrahub_sdk/transfer/importer/json.py +++ b/infrahub_sdk/transfer/importer/json.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -import pyarrow.json as pa_json import ujson from rich.progress import Progress @@ -53,6 +52,17 @@ def wrapped_task_output(self, start: str, end: str = "[green]done") -> Generator self.console.print(f"{end}") async def import_data(self, import_directory: Path, branch: str) -> None: + # pyarrow is a heavy, optional dependency used only to read the line-delimited JSON + # export. Import it lazily so the rest of infrahubctl works without it being installed; + # only `infrahubctl object load` reaches this code path. + try: + import pyarrow.json as pa_json # noqa: PLC0415 + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "Loading objects requires pyarrow, install the 'ctl' extra of the infrahub-sdk " + "package, `pip install 'infrahub-sdk[ctl]'` or run `uv sync --extra ctl`." + ) from exc + node_file = import_directory / "nodes.json" relationship_file = import_directory / "relationships.json" for f in (node_file, relationship_file): diff --git a/pyproject.toml b/pyproject.toml index 81b05994e..b8941b9a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ all = [ # Core optional dependencies tests = [ "infrahub-testcontainers>=1.7.3", + "jsonschema>=4.25.1", "pytest>=9.0,<9.1", "pytest-asyncio>=1.3,<1.4", "pytest-clarity>=1.0.1", @@ -277,6 +278,20 @@ disable_error_code = ["call-overload"] module = "infrahub_sdk.utils" disable_error_code = ["arg-type", "attr-defined", "return-value", "union-attr"] +[[tool.mypy.overrides]] +# ``main.py`` intentionally narrows the ``attributes``/``relationships``/``choices`` fields inherited +# from the generated write/read models to the user-facing constructible variants (``AttributeSchema``, +# ``RelationshipSchema``, and the ``*API`` read models). pydantic invariance makes mypy flag these +# deliberate overrides; the narrowed types are a superset-safe refinement validated at runtime. +module = "infrahub_sdk.schema.main" +disable_error_code = ["assignment"] + +[[tool.mypy.overrides]] +# The generated read models expose ``kind`` via ``@computed_field`` stacked on ``@property``. mypy +# does not support decorators on top of ``@property`` and flags it, but pydantic requires this order. +module = "infrahub_sdk.schema.generated.read" +disable_error_code = ["misc"] + [tool.ruff] line-length = 120 @@ -306,8 +321,6 @@ ignore = [ "CPY", # flake8-copyright "T201", # use of `print` "COM812", # missing-trailing-comma - "D203", # incorrect-blank-line-before-class (incompatible with D211) - "D213", # multi-line-summary-second-line (incompatible with D212) ################################################################################################## # Rules below needs to be Investigated # @@ -352,13 +365,9 @@ ignore = [ "D104", # Missing docstring in public package "D105", # Missing docstring in magic method "D107", # Missing docstring in `__init__` - "D301", # Use `r"""` if any backslashes in a docstring - "D401", # First line of docstring should be in imperative mood - "D404", # First word of the docstring should not be "This" + "D301", # Use `r"""` if any backslashes in a docstring — conflicts with Typer's `\b` no-wrap marker in CLI command docstrings, which must stay a real escape (not a raw string) "D417", # Missing argument description in the docstring - "DOC102", # Docstring contains extraneous parameter(s) "DOC201", # `return` is not documented in docstring - "DOC402", # `yield` is not documented in docstring "DOC502", # Raised exception is not explicitly raised (false positives for transitive raises through helpers) ] @@ -384,6 +393,10 @@ ignorelist = [ [tool.ruff.lint.isort] known-first-party = ["infrahub_sdk", "infrahub_ctl"] +[tool.ruff.lint.pydocstyle] +# Also lets the DOC (pydoclint) rules parse Args/Returns/Yields sections correctly. +convention = "google" + [tool.ruff.lint.pycodestyle] max-line-length = 150 @@ -393,6 +406,15 @@ max-complexity = 14 [tool.ruff.lint.per-file-ignores] +"infrahub_sdk/schema/generated/*.py" = [ + # Generated pydantic models use `from __future__ import annotations`, so the enum imports + # they reference are required at runtime for pydantic to resolve the annotations; moving + # them into a type-checking block would break model construction. + "TC001", # Move application import into a type-checking block + # The AttributeKind enum carries a member whose value is the literal string "Password". + "S105", # Possible hardcoded password assigned to a variable +] + "infrahub_sdk/**/*.py" = [ ################################################################################################## # Review and change the below later # diff --git a/tests/fixtures/models/valid_schemas/contract.yml b/tests/fixtures/models/valid_schemas/contract.yml index 398f2bd7f..0b9ca690b 100644 --- a/tests/fixtures/models/valid_schemas/contract.yml +++ b/tests/fixtures/models/valid_schemas/contract.yml @@ -18,7 +18,7 @@ nodes: kind: Text optional: true relationships: - - name: Organization + - name: organization peer: TestOrganization optional: false cardinality: one diff --git a/tests/fixtures/schema_01.json b/tests/fixtures/schema_01.json index c2fab38ad..8504edcae 100644 --- a/tests/fixtures/schema_01.json +++ b/tests/fixtures/schema_01.json @@ -8,7 +8,7 @@ "attributes": [ { "name": "query", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -19,7 +19,7 @@ }, { "name": "description", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -30,7 +30,7 @@ }, { "name": "name", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -65,7 +65,7 @@ "attributes": [ { "name": "username", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -76,7 +76,7 @@ }, { "name": "type", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": "LOCAL", @@ -87,7 +87,7 @@ }, { "name": "name", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -98,7 +98,7 @@ }, { "name": "description", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -109,7 +109,7 @@ }, { "name": "commit", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -120,7 +120,7 @@ }, { "name": "location", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -131,7 +131,7 @@ }, { "name": "password", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -142,7 +142,7 @@ }, { "name": "default_branch", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": "main", @@ -194,7 +194,7 @@ "attributes": [ { "name": "name", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -205,7 +205,7 @@ }, { "name": "description", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -254,17 +254,17 @@ "attributes": [ { "name": "name", - "kind": "String", + "kind": "Text", "unique": true }, { "name": "description", - "kind": "String", + "kind": "Text", "optional": true }, { "name": "type", - "kind": "String" + "kind": "Text" } ], "relationships": [ diff --git a/tests/fixtures/schema_02.json b/tests/fixtures/schema_02.json index 1417053f1..bcdde5373 100644 --- a/tests/fixtures/schema_02.json +++ b/tests/fixtures/schema_02.json @@ -683,7 +683,7 @@ "attributes": [ { "name": "query", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -694,7 +694,7 @@ }, { "name": "description", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -705,7 +705,7 @@ }, { "name": "name", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -740,7 +740,7 @@ "attributes": [ { "name": "username", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -751,7 +751,7 @@ }, { "name": "type", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": "LOCAL", @@ -762,7 +762,7 @@ }, { "name": "name", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -773,7 +773,7 @@ }, { "name": "description", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -784,7 +784,7 @@ }, { "name": "commit", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -795,7 +795,7 @@ }, { "name": "location", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -806,7 +806,7 @@ }, { "name": "password", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -817,7 +817,7 @@ }, { "name": "default_branch", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": "main", @@ -884,7 +884,7 @@ "attributes": [ { "name": "name", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -895,7 +895,7 @@ }, { "name": "description", - "kind": "String", + "kind": "Text", "label": null, "description": null, "default_value": null, @@ -923,17 +923,17 @@ "attributes": [ { "name": "name", - "kind": "String", + "kind": "Text", "unique": true }, { "name": "description", - "kind": "String", + "kind": "Text", "optional": true }, { "name": "type", - "kind": "String" + "kind": "Text" } ], "relationships": [ diff --git a/tests/unit/ctl/test_schema_app.py b/tests/unit/ctl/test_schema_app.py index 8be46056a..bbd498b54 100644 --- a/tests/unit/ctl/test_schema_app.py +++ b/tests/unit/ctl/test_schema_app.py @@ -81,53 +81,24 @@ def test_schema_load_multiple(httpx_mock: HTTPXMock) -> None: assert content_json == {"schemas": [fixture_file1_content, fixture_file2_content]} -def test_schema_load_notvalid_namespace(httpx_mock: HTTPXMock) -> None: +def test_schema_load_notvalid_namespace() -> None: + """An invalid namespace is now rejected client-side by the write contract. + + The SDK write models mirror the server's field constraints, so ``infrahubctl load`` + catches an invalid namespace during local validation and exits before sending the + payload to the server. + """ fixture_file = get_fixtures_dir() / "models" / "non_valid_namespace.json" - httpx_mock.add_response( - method="POST", - url="http://mock/api/schema/load?branch=main", - status_code=422, - json={ - "detail": [ - { - "type": "string_pattern_mismatch", - "loc": ["body", "schemas", 0, "nodes", 0, "namespace"], - "msg": "String should match pattern '^[A-Z][a-z0-9]+$'", - "input": "OuT", - "ctx": {"pattern": "^[A-Z][a-z0-9]+$"}, - "url": "https://errors.pydantic.dev/2.7/v/string_pattern_mismatch", - }, - { - "type": "value_error", - "loc": ["body", "schemas", 0, "nodes", 0, "attributes", 0, "kind"], - "msg": "Value error, Only valid Attribute Kind are : ['ID', 'Dropdown'] ", - "input": "NotValid", - "ctx": {"error": {}}, - "url": "https://errors.pydantic.dev/2.7/v/value_error", - }, - ] - }, - ) result = runner.invoke(app=app, args=["load", str(fixture_file)]) assert result.exit_code == 1 clean_output = remove_ansi_color(result.stdout.replace("\n", "")) - expected_result = ( - "Unable to load the schema: Node: OuTDevice | " - "namespace (OuT) | String should match pattern '^[A-Z][a-z0-9]+$' (string_pattern_mismatch) " - " Node: OuTDevice | Attribute: name (NotValid) | Value error, Only valid Attribute Kind " - "are : ['ID', 'Dropdown'] (value_error)" - ) - assert expected_result == clean_output - - content = httpx_mock.get_requests()[0].content.decode("utf8") - content_json = yaml.safe_load(content) - fixture_file_content = yaml.safe_load( - fixture_file.read_text(encoding="utf-8"), - ) - assert content_json == {"schemas": [fixture_file_content]} + assert "Schema not valid" in clean_output + assert "nodes[0].namespace" in clean_output + assert "String should match pattern" in clean_output + assert "received: 'OuT'" in clean_output def test_load_valid_generic_schema(httpx_mock: HTTPXMock) -> None: diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index c4286af89..896a45fb1 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -154,9 +154,9 @@ async def location_schema() -> NodeSchemaAPI: "namespace": "Builtin", "default_filter": "name__value", "attributes": [ - {"name": "name", "kind": "String", "unique": True}, - {"name": "description", "kind": "String", "optional": True}, - {"name": "type", "kind": "String"}, + {"name": "name", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, + {"name": "type", "kind": "Text"}, ], "relationships": [ { @@ -190,9 +190,9 @@ async def location_schema_with_dropdown() -> NodeSchemaAPI: "namespace": "Builtin", "default_filter": "name__value", "attributes": [ - {"name": "name", "kind": "String", "unique": True}, - {"name": "description", "kind": "String", "optional": True}, - {"name": "type", "kind": "String"}, + {"name": "name", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, + {"name": "type", "kind": "Text"}, { "name": "status", "kind": "Dropdown", @@ -234,9 +234,9 @@ async def schema_with_hfid() -> dict[str, NodeSchemaAPI]: "default_filter": "name__value", "human_friendly_id": ["name__value"], "attributes": [ - {"name": "name", "kind": "String", "unique": True}, - {"name": "description", "kind": "String", "optional": True}, - {"name": "type", "kind": "String"}, + {"name": "name", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, + {"name": "type", "kind": "Text"}, ], "relationships": [ { @@ -266,8 +266,8 @@ async def schema_with_hfid() -> dict[str, NodeSchemaAPI]: "default_filter": "facility_id__value", "human_friendly_id": ["facility_id__value", "location__name__value"], "attributes": [ - {"name": "facility_id", "kind": "String", "unique": True}, - {"name": "description", "kind": "String", "optional": True}, + {"name": "facility_id", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, ], "relationships": [ {"name": "location", "peer": "BuiltinLocation", "cardinality": "one"}, @@ -297,8 +297,8 @@ async def std_group_schema() -> NodeSchemaAPI: "namespace": "Core", "default_filter": "name__value", "attributes": [ - {"name": "name", "kind": "String", "unique": True}, - {"name": "description", "kind": "String", "optional": True}, + {"name": "name", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, ], } return NodeSchema(**data).convert_api() @@ -831,9 +831,9 @@ async def rfile_schema() -> NodeSchemaAPI: "display_labels": ["label__value"], "branch": BranchSupportType.AWARE.value, "attributes": [ - {"name": "name", "kind": "String", "unique": True}, - {"name": "description", "kind": "String", "optional": True}, - {"name": "template_path", "kind": "String"}, + {"name": "name", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, + {"name": "template_path", "kind": "Text"}, ], "relationships": [ { @@ -886,6 +886,21 @@ async def ipaddress_schema() -> NodeSchemaAPI: return NodeSchema(**data).convert_api() +@pytest.fixture +async def bare_ipaddress_schema() -> NodeSchemaAPI: + data = { + "name": "DnsRecord", + "namespace": "Infra", + "default_filter": "address__value", + "display_labels": ["address_value"], + "order_by": ["address_value"], + "attributes": [ + {"name": "address", "kind": "IPAddress"}, + ], + } + return NodeSchema(**data).convert_api() + + @pytest.fixture async def ipnetwork_schema() -> NodeSchemaAPI: data = { @@ -1027,10 +1042,10 @@ async def address_schema() -> NodeSchemaAPI: "display_labels": ["network_value"], "order_by": ["network_value"], "attributes": [ - {"name": "street_number", "kind": "String", "optional": True}, - {"name": "street_name", "kind": "String", "optional": True}, - {"name": "postal_code", "kind": "String", "optional": True}, - {"name": "computed_address", "kind": "String", "optional": True, "read_only": True}, + {"name": "street_number", "kind": "Text", "optional": True}, + {"name": "street_name", "kind": "Text", "optional": True}, + {"name": "postal_code", "kind": "Text", "optional": True}, + {"name": "computed_address", "kind": "Text", "optional": True, "read_only": True}, ], "relationships": [], } diff --git a/tests/unit/sdk/test_client.py b/tests/unit/sdk/test_client.py index da34abdad..c20227093 100644 --- a/tests/unit/sdk/test_client.py +++ b/tests/unit/sdk/test_client.py @@ -293,6 +293,31 @@ async def test_method_all_multiple_pages( assert len(repos) == 5 +@pytest.mark.parametrize("client_type", client_types) +async def test_method_all_pagination_uses_graphql_variables( + httpx_mock: HTTPXMock, + clients: BothClients, + mock_query_repository_page1_2: HTTPXMock, + mock_query_repository_page2_2: HTTPXMock, + client_type: str, +) -> None: + if client_type == "standard": + repos = await clients.standard.all(kind="CoreRepository", populate_store=False) + else: + repos = clients.sync.all(kind="CoreRepository", populate_store=False) + + assert len(repos) == 5 + + payloads = [json.loads(request.content) for request in httpx_mock.get_requests() if request.method == "POST"] + assert len(payloads) == 2 + page1, page2 = payloads + assert page1["query"] == page2["query"] + assert "offset: $offset" in page1["query"] + assert "limit: $limit" in page1["query"] + assert page1["variables"] == {"offset": 0, "limit": 3} + assert page2["variables"] == {"offset": 3, "limit": 3} + + @pytest.mark.parametrize(("client_type", "use_parallel"), batch_client_types) async def test_method_all_batching( clients: BothClients, @@ -768,7 +793,7 @@ async def test_query_name_all( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query MyAllQuery {" in payload["query"] + assert "query MyAllQuery ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "MyAllQuery" @@ -784,7 +809,7 @@ async def test_query_name_filters( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query MyFiltersQuery {" in payload["query"] + assert "query MyFiltersQuery ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "MyFiltersQuery" @@ -802,7 +827,7 @@ async def test_query_name_get( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query MyGetQuery {" in payload["query"] + assert "query MyGetQuery ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "MyGetQuery" @@ -834,7 +859,7 @@ async def test_query_name_for_all_ommitted( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query All_CoreRepository {" in payload["query"] + assert "query All_CoreRepository ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "All_CoreRepository" @@ -850,7 +875,7 @@ async def test_query_name_for_filters_ommitted( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query Filters_CoreRepository {" in payload["query"] + assert "query Filters_CoreRepository ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "Filters_CoreRepository" @@ -866,7 +891,7 @@ async def test_query_name_for_get_ommitted( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query Get_CoreRepository {" in payload["query"] + assert "query Get_CoreRepository ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "Get_CoreRepository" diff --git a/tests/unit/sdk/test_config.py b/tests/unit/sdk/test_config.py index 609e63e3f..527411276 100644 --- a/tests/unit/sdk/test_config.py +++ b/tests/unit/sdk/test_config.py @@ -1,7 +1,11 @@ +from dataclasses import dataclass +from typing import Any + import pytest from pydantic import ValidationError from infrahub_sdk.config import Config +from infrahub_sdk.constants import Priority def test_combine_authentications() -> None: @@ -93,3 +97,73 @@ def test_password_auth_overrides_env_token_when_password_env_var_and_username_ex assert config.password == "testpass" assert config.api_token is None assert config.password_authentication is True + + +def test_invalid_priority_rejected() -> None: + """An unknown configured priority fails at config load; no request is ever issued. + + Construction raises before any client/request exists, so 'no request is issued' + is inherent — the ValidationError is raised while building Config. + """ + # Passing an invalid string is the behaviour under test; pydantic rejects it at load. The field + # statically types Priority | None but coerces strings at runtime, so the dynamic input is passed + # via a dict[str, Any] rather than suppressed with a type-checker ignore. + kwargs: dict[str, Any] = {"address": "http://localhost:8000", "priority": "lowe"} + with pytest.raises(ValidationError, match=r"Input should be 'high', 'medium' or 'low'"): + Config(**kwargs) + + +@dataclass +class PriorityCase: + name: str + value: str | Priority + expected: Priority + + +PRIORITY_CASES = [ + PriorityCase(name="high-upper", value="HIGH", expected=Priority.HIGH), + PriorityCase(name="high-title", value="High", expected=Priority.HIGH), + PriorityCase(name="high-lower", value="high", expected=Priority.HIGH), + PriorityCase(name="high-enum", value=Priority.HIGH, expected=Priority.HIGH), + PriorityCase(name="medium-upper", value="MEDIUM", expected=Priority.MEDIUM), + PriorityCase(name="medium-title", value="Medium", expected=Priority.MEDIUM), + PriorityCase(name="medium-lower", value="medium", expected=Priority.MEDIUM), + PriorityCase(name="medium-enum", value=Priority.MEDIUM, expected=Priority.MEDIUM), + PriorityCase(name="low-upper", value="LOW", expected=Priority.LOW), + PriorityCase(name="low-title", value="Low", expected=Priority.LOW), + PriorityCase(name="low-lower", value="low", expected=Priority.LOW), + PriorityCase(name="low-enum", value=Priority.LOW, expected=Priority.LOW), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in PRIORITY_CASES]) +def test_priority_case_insensitive_acceptance(case: PriorityCase) -> None: + """Valid priority strings are accepted case-insensitively (and enum members pass through).""" + # Case-insensitive string coercion is the behaviour under test; the field statically types Priority, + # so the mixed str/Priority input is passed via a dict[str, Any] rather than suppressed with an ignore. + kwargs: dict[str, Any] = {"address": "http://localhost:8000", "priority": case.value} + config = Config(**kwargs) + assert config.priority is case.expected + + +@pytest.mark.parametrize( + ("env_value", "expected"), + [ + pytest.param("LOW", Priority.LOW, id="low"), + pytest.param("HIGH", Priority.HIGH, id="high"), + pytest.param("MEDIUM", Priority.MEDIUM, id="medium"), + ], +) +def test_priority_from_env_var(monkeypatch: pytest.MonkeyPatch, env_value: str, expected: Priority) -> None: + """The INFRAHUB_PRIORITY env var resolves case-insensitively to a Priority member.""" + monkeypatch.setenv("INFRAHUB_PRIORITY", env_value) + + config = Config(address="http://localhost:8000") + + assert config.priority is expected + + +def test_priority_default_is_none() -> None: + """With no priority configured, the field defaults to None (no header emitted).""" + config = Config(address="http://localhost:8000") + assert config.priority is None diff --git a/tests/unit/sdk/test_hierarchical_nodes.py b/tests/unit/sdk/test_hierarchical_nodes.py index 3165effe0..aa139ba61 100644 --- a/tests/unit/sdk/test_hierarchical_nodes.py +++ b/tests/unit/sdk/test_hierarchical_nodes.py @@ -23,8 +23,8 @@ async def hierarchical_schema() -> NodeSchemaAPI: "namespace": "Infra", "default_filter": "name__value", "attributes": [ - {"name": "name", "kind": "String", "unique": True}, - {"name": "description", "kind": "String", "optional": True}, + {"name": "name", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, ], "relationships": [ { diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index 0a30cb7e3..d8c735637 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -1787,6 +1787,23 @@ async def test_create_input_data_with_IPHost_attribute( } +@pytest.mark.parametrize("client_type", client_types) +async def test_create_input_data_with_IPAddress_attribute( + client: InfrahubClient, bare_ipaddress_schema: NodeSchemaAPI, client_type: str +) -> None: + data = {"address": {"value": ipaddress.ip_address("1.1.1.1"), "is_protected": True}} + + if client_type == "standard": + dns_record = InfrahubNode(client=client, schema=bare_ipaddress_schema, data=data) + else: + dns_record = InfrahubNodeSync(client=client, schema=bare_ipaddress_schema, data=data) + + # a bare address is serialized without any prefix + assert dns_record._generate_input_data()["data"] == { + "data": {"address": {"value": "1.1.1.1", "is_protected": True}} + } + + @pytest.mark.parametrize("client_type", client_types) async def test_create_input_data_with_IPNetwork_attribute( client: InfrahubClient, ipnetwork_schema: NodeSchemaAPI, client_type: str @@ -2180,6 +2197,26 @@ async def test_node_IPHost_deserialization( assert ip_address.address.value == ipaddress.ip_interface("1.1.1.1/24") +@pytest.mark.parametrize("client_type", client_types) +async def test_node_IPAddress_deserialization( + client: InfrahubClient, bare_ipaddress_schema: NodeSchemaAPI, client_type: str +) -> None: + data = { + "id": "aaaaaaaaaaaaaa", + "address": { + "value": "1.1.1.1", + "is_protected": True, + }, + } + if client_type == "standard": + dns_record = InfrahubNode(client=client, schema=bare_ipaddress_schema, data=data) + else: + dns_record = InfrahubNodeSync(client=client, schema=bare_ipaddress_schema, data=data) + + # a bare address deserializes to an ip_address object (no prefix) + assert dns_record.address.value == ipaddress.ip_address("1.1.1.1") + + @pytest.mark.parametrize("client_type", client_types) async def test_node_IPNetwork_deserialization( client: InfrahubClient, ipnetwork_schema: NodeSchemaAPI, client_type: str diff --git a/tests/unit/sdk/test_priority.py b/tests/unit/sdk/test_priority.py new file mode 100644 index 000000000..2d03f632a --- /dev/null +++ b/tests/unit/sdk/test_priority.py @@ -0,0 +1,990 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync +from infrahub_sdk.constants import Priority +from infrahub_sdk.context import ContextAccount, RequestContext +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync +from tests.unit.sdk.conftest import BothClients + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk.schema import NodeSchemaAPI + +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + +client_types = ["standard", "sync"] + + +def _build_clients(priority: Priority) -> BothClients: + return BothClients( + standard=InfrahubClient( + config=Config(address="http://mock", insert_tracker=True, pagination_size=3, priority=priority) + ), + sync=InfrahubClientSync( + config=Config(address="http://mock", insert_tracker=True, pagination_size=3, priority=priority) + ), + ) + + +@pytest.fixture +def low_clients() -> BothClients: + return _build_clients(Priority.LOW) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_priority_header_on_graphql_mutation( + client_type: str, low_clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """A client with a default priority emits X-Priority on a GraphQL mutation.""" + httpx_mock.add_response( + method="POST", + json={"data": {"BuiltinTagCreate": {"ok": True, "object": {"id": "tag-1"}}}}, + match_headers={"X-Priority": "low"}, + ) + + mutation = 'mutation { BuiltinTagCreate(data: {name: {value: "blue"}}) { ok } }' + client = getattr(low_clients, client_type) + if client_type == "standard": + await client.execute_graphql(query=mutation) + else: + client.execute_graphql(query=mutation) + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "low" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_priority_header_on_blob_download( + client_type: str, low_clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """A client with a default priority emits X-Priority on an object-store blob download.""" + httpx_mock.add_response( + method="GET", + text="any content", + match_headers={"X-Priority": "low"}, + ) + + client = getattr(low_clients, client_type) + if client_type == "standard": + content = await client.object_store.get(identifier="aaaaaaaaa") + else: + content = client.object_store.get(identifier="aaaaaaaaa") + + assert content == "any content" + requests = [r for r in httpx_mock.get_requests() if r.method == "GET"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "low" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_priority_header_on_blob_upload( + client_type: str, low_clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """A client with a default priority emits X-Priority on an object-store blob upload.""" + httpx_mock.add_response( + method="POST", + json={"identifier": "xxxxxxxxxx", "checksum": "yyyyyyyyyyyyyy"}, + match_headers={"X-Priority": "low"}, + ) + + client = getattr(low_clients, client_type) + if client_type == "standard": + response = await client.object_store.upload(content="any content") + else: + response = client.object_store.upload(content="any content") + + assert response == {"checksum": "yyyyyyyyyyyyyy", "identifier": "xxxxxxxxxx"} + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "low" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_priority_header_on_multipart_upload( + client_type: str, + low_clients: BothClients, + file_object_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + """A client with a default priority emits X-Priority on a multipart file upload. + + Confirms the header survives the ``content-type`` pop performed for multipart requests. + """ + httpx_mock.add_response( + method="POST", + json={ + "data": { + "NetworkCircuitContractCreate": { + "ok": True, + "object": { + "id": "new-file-node-123", + "display_label": "contract.pdf", + "file_name": {"value": "contract.pdf"}, + "checksum": {"value": "abc123checksum"}, + "file_size": {"value": 17}, + "file_type": {"value": "application/pdf"}, + "storage_id": {"value": "storage-xyz-789"}, + "contract_start": {"value": "2024-01-01T00:00:00Z"}, + "contract_end": {"value": "2024-12-31T23:59:59Z"}, + }, + } + } + }, + match_headers={"X-Priority": "low"}, + ) + + client = getattr(low_clients, client_type) + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + + node._get_attribute("contract_start").value = "2024-01-01T00:00:00Z" + node._get_attribute("contract_end").value = "2024-12-31T23:59:59Z" + node.upload_from_bytes(content=b"Test file content", name="contract.pdf") + + if isinstance(node, InfrahubNode): + await node.save() + else: + node.save() + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "low" + assert requests[0].headers.get("content-type").startswith("multipart/form-data;") + + +@pytest.mark.parametrize("client_type", client_types) +async def test_priority_header_on_batched_requests( + client_type: str, low_clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """Every request issued through a batch carries the client-wide X-Priority header.""" + httpx_mock.add_response( + method="POST", + json={"data": {"InfrahubInfo": {"version": "1.0"}}}, + match_headers={"X-Priority": "low"}, + is_reusable=True, + ) + + query = "query { InfrahubInfo { version }}" + tasks_number = 3 + client = getattr(low_clients, client_type) + + if client_type == "standard": + batch = await client.create_batch() + for _ in range(tasks_number): + batch.add(task=client.execute_graphql, query=query) + async for _, _result in batch.execute(): + pass + else: + batch = client.create_batch() + for _ in range(tasks_number): + batch.add(task=client.execute_graphql, query=query) + for _, _result in batch.execute(): + pass + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == tasks_number + assert all(r.headers["x-priority"] == "low" for r in requests) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_no_priority_header_on_blob_download_when_unconfigured( + client_type: str, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """An unconfigured client emits no X-Priority header on an object-store blob download.""" + httpx_mock.add_response( + method="GET", + text="any content", + ) + + client = getattr(clients, client_type) + if client_type == "standard": + content = await client.object_store.get(identifier="aaaaaaaaa") + else: + content = client.object_store.get(identifier="aaaaaaaaa") + + assert content == "any content" + requests = [r for r in httpx_mock.get_requests() if r.method == "GET"] + assert len(requests) == 1 + assert "x-priority" not in requests[0].headers + + +@pytest.mark.parametrize("client_type", client_types) +async def test_no_priority_header_on_blob_upload_when_unconfigured( + client_type: str, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """An unconfigured client emits no X-Priority header on an object-store blob upload.""" + httpx_mock.add_response( + method="POST", + json={"identifier": "xxxxxxxxxx", "checksum": "yyyyyyyyyyyyyy"}, + ) + + client = getattr(clients, client_type) + if client_type == "standard": + response = await client.object_store.upload(content="any content") + else: + response = client.object_store.upload(content="any content") + + assert response == {"checksum": "yyyyyyyyyyyyyy", "identifier": "xxxxxxxxxx"} + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert "x-priority" not in requests[0].headers + + +@pytest.mark.parametrize("client_type", client_types) +async def test_no_priority_header_on_multipart_upload_when_unconfigured( + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + """An unconfigured client emits no X-Priority header on a multipart file upload.""" + httpx_mock.add_response( + method="POST", + json={ + "data": { + "NetworkCircuitContractCreate": { + "ok": True, + "object": { + "id": "new-file-node-123", + "display_label": "contract.pdf", + "file_name": {"value": "contract.pdf"}, + "checksum": {"value": "abc123checksum"}, + "file_size": {"value": 17}, + "file_type": {"value": "application/pdf"}, + "storage_id": {"value": "storage-xyz-789"}, + "contract_start": {"value": "2024-01-01T00:00:00Z"}, + "contract_end": {"value": "2024-12-31T23:59:59Z"}, + }, + } + } + }, + ) + + client = getattr(clients, client_type) + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + + node._get_attribute("contract_start").value = "2024-01-01T00:00:00Z" + node._get_attribute("contract_end").value = "2024-12-31T23:59:59Z" + node.upload_from_bytes(content=b"Test file content", name="contract.pdf") + + if isinstance(node, InfrahubNode): + await node.save() + else: + node.save() + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert "x-priority" not in requests[0].headers + assert requests[0].headers.get("content-type").startswith("multipart/form-data;") + + +@pytest.mark.parametrize("client_type", client_types) +async def test_unconfigured_headers_unchanged_versus_baseline( + client_type: str, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """With no priority and no per-request arg, the SDK-set outgoing headers are unchanged. + + Only the absence of X-Priority matters; the request still carries the baseline SDK + headers it always had (``content-type`` and, since ``insert_tracker`` is set, the + ``X-Infrahub-Tracker`` header). Transport-injected headers (host, user-agent, etc.) + are intentionally not asserted. + """ + httpx_mock.add_response( + method="POST", + json={"data": {"InfrahubInfo": {"version": "1.0"}}}, + ) + + query = "query { InfrahubInfo { version }}" + tracker = "test-priority-baseline" + client = getattr(clients, client_type) + if client_type == "standard": + await client.execute_graphql(query=query, tracker=tracker) + else: + client.execute_graphql(query=query, tracker=tracker) + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + request = requests[0] + assert "x-priority" not in request.headers + assert request.headers["content-type"].startswith("application/json") + assert request.headers["x-infrahub-tracker"] == tracker + + +# --------------------------------------------------------------------------- +# Per-request override +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_no_default_client_then_no_leak( + client_type: str, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """On a no-default client, priority=HIGH emits the header; the next un-annotated call emits none.""" + httpx_mock.add_response( + method="POST", + json={"data": {"InfrahubInfo": {"version": "1.0"}}}, + is_reusable=True, + ) + + query = "query { InfrahubInfo { version }}" + client = getattr(clients, client_type) + if client_type == "standard": + await client.execute_graphql(query=query, priority=Priority.HIGH) + await client.execute_graphql(query=query) + else: + client.execute_graphql(query=query, priority=Priority.HIGH) + client.execute_graphql(query=query) + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 2 + assert requests[0].headers["x-priority"] == "high" + assert "x-priority" not in requests[1].headers + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_beats_default_then_reverts( + client_type: str, low_clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """A per-request HIGH overrides a LOW default for one call; the next call reverts to LOW.""" + httpx_mock.add_response( + method="POST", + json={"data": {"InfrahubInfo": {"version": "1.0"}}}, + is_reusable=True, + ) + + query = "query { InfrahubInfo { version }}" + client = getattr(low_clients, client_type) + if client_type == "standard": + await client.execute_graphql(query=query, priority=Priority.HIGH) + await client.execute_graphql(query=query) + else: + client.execute_graphql(query=query, priority=Priority.HIGH) + client.execute_graphql(query=query) + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 2 + assert requests[0].headers["x-priority"] == "high" + assert requests[1].headers["x-priority"] == "low" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_get( + client_type: str, clients: BothClients, mock_schema_query_01: HTTPXMock, httpx_mock: HTTPXMock +) -> None: + """A per-request priority on get() reaches the underlying GraphQL request.""" + response = { + "data": { + "CoreRepository": { + "edges": [ + { + "node": { + "__typename": "CoreRepository", + "id": "bfae43e8-5ebb-456c-a946-bf64e930710a", + "name": {"value": "infrahub-demo-core"}, + "location": {"value": "git@github.com:opsmill/infrahub-demo-core.git"}, + "commit": {"value": "bbbbbbbbbbbbbbbbbbbb"}, + } + } + ] + } + } + } + httpx_mock.add_response( + method="POST", + json=response, + match_headers={"X-Priority": "high"}, + is_reusable=True, + ) + + node_id = "bfae43e8-5ebb-456c-a946-bf64e930710a" + client = getattr(clients, client_type) + if client_type == "standard": + await client.get(kind="CoreRepository", id=node_id, priority=Priority.HIGH) + else: + client.get(kind="CoreRepository", id=node_id, priority=Priority.HIGH) + + query_requests = [ + r + for r in httpx_mock.get_requests() + if r.method == "POST" and r.headers.get("x-infrahub-tracker") == "query-corerepository-page1" + ] + assert len(query_requests) == 1 + assert query_requests[0].headers["x-priority"] == "high" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_all_carries_on_every_page( + client_type: str, + clients: BothClients, + mock_query_repository_page1_2: HTTPXMock, + mock_query_repository_page2_2: HTTPXMock, + httpx_mock: HTTPXMock, +) -> None: + """The override is forwarded on every page request of a paginated all().""" + client = getattr(clients, client_type) + if client_type == "standard": + repos = await client.all(kind="CoreRepository", priority=Priority.HIGH) + else: + repos = client.all(kind="CoreRepository", priority=Priority.HIGH) + assert len(repos) == 5 + + page_requests = [ + r + for r in httpx_mock.get_requests() + if r.method == "POST" and (r.headers.get("x-infrahub-tracker") or "").startswith("query-corerepository-page") + ] + assert len(page_requests) == 2 + assert all(r.headers["x-priority"] == "high" for r in page_requests) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_save_create_path( + client_type: str, clients: BothClients, location_schema: NodeSchemaAPI, httpx_mock: HTTPXMock +) -> None: + """A per-request priority on node.save() reaches the create mutation.""" + httpx_mock.add_response( + method="POST", + json={ + "data": {"BuiltinLocationCreate": {"ok": True, "object": {"id": "17aec828-9814-ce00-3f20-1a053670f1c8"}}} + }, + is_reusable=True, + ) + + client = getattr(clients, client_type) + data = {"name": {"value": "JFK1"}, "type": {"value": "SITE"}} + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data=data) + await node.save(priority=Priority.HIGH) + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data=data) + node.save(priority=Priority.HIGH) + + create_requests = [ + r + for r in httpx_mock.get_requests() + if r.method == "POST" and r.headers.get("x-infrahub-tracker") == "mutation-builtinlocation-create" + ] + assert len(create_requests) == 1 + assert create_requests[0].headers["x-priority"] == "high" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_diff_method(client_type: str, clients: BothClients, httpx_mock: HTTPXMock) -> None: + """A per-request priority on a diff method reaches the GraphQL request.""" + httpx_mock.add_response( + method="POST", + json={"data": {"DiffUpdate": {"ok": True}}}, + match_headers={"X-Priority": "high"}, + ) + + from_time = datetime(2024, 1, 1, tzinfo=timezone.utc) + to_time = datetime(2024, 1, 2, tzinfo=timezone.utc) + client = getattr(clients, client_type) + if client_type == "standard": + await client.create_diff( + branch="main", name="test-diff", from_time=from_time, to_time=to_time, priority=Priority.HIGH + ) + else: + client.create_diff( + branch="main", name="test-diff", from_time=from_time, to_time=to_time, priority=Priority.HIGH + ) + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "high" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_get_diff_summary(client_type: str, clients: BothClients, httpx_mock: HTTPXMock) -> None: + """A per-request priority on get_diff_summary() reaches the GraphQL request.""" + httpx_mock.add_response( + method="POST", + json={"data": {"DiffTree": {"nodes": []}}}, + match_headers={"X-Priority": "high"}, + ) + + client = getattr(clients, client_type) + if client_type == "standard": + await client.get_diff_summary(branch="main", priority=Priority.HIGH) + else: + client.get_diff_summary(branch="main", priority=Priority.HIGH) + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "high" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_get_diff_tree(client_type: str, clients: BothClients, httpx_mock: HTTPXMock) -> None: + """A per-request priority on get_diff_tree() reaches the GraphQL request.""" + httpx_mock.add_response( + method="POST", + json={"data": {"DiffTree": None}}, + match_headers={"X-Priority": "high"}, + ) + + client = getattr(clients, client_type) + if client_type == "standard": + await client.get_diff_tree(branch="main", priority=Priority.HIGH) + else: + client.get_diff_tree(branch="main", priority=Priority.HIGH) + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "high" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_save_update_path( + client_type: str, clients: BothClients, location_schema: NodeSchemaAPI, httpx_mock: HTTPXMock +) -> None: + """A per-request priority on node.save() reaches the update mutation for an existing node.""" + httpx_mock.add_response( + method="POST", + json={ + "data": {"BuiltinLocationUpdate": {"ok": True, "object": {"id": "17aec828-9814-ce00-3f20-1a053670f1c8"}}} + }, + is_reusable=True, + ) + + data = {"id": "17aec828-9814-ce00-3f20-1a053670f1c8", "name": {"value": "JFK1"}, "type": {"value": "SITE"}} + client = getattr(clients, client_type) + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data=data) + node._get_attribute("name").value = "JFK2" + await node.save(priority=Priority.HIGH) + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data=data) + node._get_attribute("name").value = "JFK2" + node.save(priority=Priority.HIGH) + + update_requests = [ + r + for r in httpx_mock.get_requests() + if r.method == "POST" and r.headers.get("x-infrahub-tracker") == "mutation-builtinlocation-update" + ] + assert len(update_requests) == 1 + assert update_requests[0].headers["x-priority"] == "high" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_node_delete( + client_type: str, clients: BothClients, location_schema: NodeSchemaAPI, httpx_mock: HTTPXMock +) -> None: + """A per-request priority on node.delete() reaches the delete mutation.""" + httpx_mock.add_response( + method="POST", + json={"data": {"BuiltinLocationDelete": {"ok": True}}}, + is_reusable=True, + ) + + data = {"id": "17aec828-9814-ce00-3f20-1a053670f1c8", "name": {"value": "JFK1"}, "type": {"value": "SITE"}} + client = getattr(clients, client_type) + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data=data) + await node.delete(priority=Priority.HIGH) + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data=data) + node.delete(priority=Priority.HIGH) + + delete_requests = [ + r + for r in httpx_mock.get_requests() + if r.method == "POST" and r.headers.get("x-infrahub-tracker") == "mutation-builtinlocation-delete" + ] + assert len(delete_requests) == 1 + assert delete_requests[0].headers["x-priority"] == "high" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_multipart_upload( + client_type: str, + clients: BothClients, + file_object_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + """A per-request priority survives the multipart content-type pop on a file upload.""" + httpx_mock.add_response( + method="POST", + json={ + "data": { + "NetworkCircuitContractCreate": { + "ok": True, + "object": { + "id": "new-file-node-123", + "display_label": "contract.pdf", + "file_name": {"value": "contract.pdf"}, + "checksum": {"value": "abc123checksum"}, + "file_size": {"value": 17}, + "file_type": {"value": "application/pdf"}, + "storage_id": {"value": "storage-xyz-789"}, + "contract_start": {"value": "2024-01-01T00:00:00Z"}, + "contract_end": {"value": "2024-12-31T23:59:59Z"}, + }, + } + } + }, + match_headers={"X-Priority": "high"}, + ) + + client = getattr(clients, client_type) + if client_type == "standard": + node = InfrahubNode(client=client, schema=file_object_schema, branch="main") + else: + node = InfrahubNodeSync(client=client, schema=file_object_schema, branch="main") + + node._get_attribute("contract_start").value = "2024-01-01T00:00:00Z" + node._get_attribute("contract_end").value = "2024-12-31T23:59:59Z" + node.upload_from_bytes(content=b"Test file content", name="contract.pdf") + + if isinstance(node, InfrahubNode): + await node.save(priority=Priority.HIGH) + else: + node.save(priority=Priority.HIGH) + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "high" + assert requests[0].headers.get("content-type").startswith("multipart/form-data;") + + +# --------------------------------------------------------------------------- +# Async / sync parity +# --------------------------------------------------------------------------- + + +@dataclass +class ResolutionCase: + """One row of the priority resolution truth table. + + ``expected`` is the emitted ``X-Priority`` header value, or ``None`` when no + header should be present. + """ + + name: str + client_default: Priority | None + per_request: Priority | None + expected: str | None + + +# The full priority resolution truth table. Each row must resolve +# identically on both the async and sync clients. +RESOLUTION_TRUTH_TABLE = [ + ResolutionCase(name="no-default-no-override", client_default=None, per_request=None, expected=None), + ResolutionCase(name="no-default-override-high", client_default=None, per_request=Priority.HIGH, expected="high"), + ResolutionCase( + name="no-default-override-medium", client_default=None, per_request=Priority.MEDIUM, expected="medium" + ), + ResolutionCase(name="low-default-no-override", client_default=Priority.LOW, per_request=None, expected="low"), + ResolutionCase( + name="low-default-override-high", client_default=Priority.LOW, per_request=Priority.HIGH, expected="high" + ), + ResolutionCase( + name="low-default-override-medium", client_default=Priority.LOW, per_request=Priority.MEDIUM, expected="medium" + ), + ResolutionCase( + name="medium-default-no-override", client_default=Priority.MEDIUM, per_request=None, expected="medium" + ), + ResolutionCase( + name="high-default-override-low", client_default=Priority.HIGH, per_request=Priority.LOW, expected="low" + ), +] + + +def _client_with_default(client_type: str, default: Priority | None) -> InfrahubClient | InfrahubClientSync: + config = Config(address="http://mock", insert_tracker=True, pagination_size=3, priority=default) + if client_type == "standard": + return InfrahubClient(config=config) + return InfrahubClientSync(config=config) + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in RESOLUTION_TRUTH_TABLE]) +async def test_resolution_truth_table_parity(case: ResolutionCase, client_type: str, httpx_mock: HTTPXMock) -> None: + """Each (client_default x per_request) combination emits the same header on both clients. + + Runs every row of the priority resolution truth table against both the async and sync + clients, asserting identical emitted headers. + """ + httpx_mock.add_response( + method="POST", + json={"data": {"InfrahubInfo": {"version": "1.0"}}}, + ) + + query = "query { InfrahubInfo { version }}" + client = _client_with_default(client_type, case.client_default) + kwargs = {} if case.per_request is None else {"priority": case.per_request} + + if client_type == "standard": + await client.execute_graphql(query=query, **kwargs) # type: ignore[misc] + else: + client.execute_graphql(query=query, **kwargs) # type: ignore[union-attr] + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + if case.expected is None: + assert "x-priority" not in requests[0].headers + else: + assert requests[0].headers["x-priority"] == case.expected + + +@pytest.mark.parametrize("client_type", client_types) +async def test_count_carries_priority( + client_type: str, clients: BothClients, mock_schema_query_01: HTTPXMock, httpx_mock: HTTPXMock +) -> None: + """A per-request priority on count() reaches the count query.""" + httpx_mock.add_response( + method="POST", + json={"data": {"CoreRepository": {"count": 5}}}, + match_headers={"X-Priority": "high"}, + is_reusable=True, + ) + + client = getattr(clients, client_type) + if client_type == "standard": + result = await client.count(kind="CoreRepository", priority=Priority.HIGH) + else: + result = client.count(kind="CoreRepository", priority=Priority.HIGH) + assert result == 5 + + +@pytest.mark.parametrize("client_type", client_types) +async def test_override_on_all_parallel_count_query( + client_type: str, + clients: BothClients, + mock_query_repository_page1_2: HTTPXMock, + mock_query_repository_page2_2: HTTPXMock, + httpx_mock: HTTPXMock, +) -> None: + """In parallel mode, the preliminary count query carries the override too, not just the pages.""" + # Registered after the tracker-matched page fixtures so the untracked count query falls through here. + httpx_mock.add_response( + method="POST", + json={"data": {"CoreRepository": {"count": 5}}}, + is_reusable=True, + ) + + client = getattr(clients, client_type) + if client_type == "standard": + await client.all(kind="CoreRepository", parallel=True, priority=Priority.HIGH) + else: + client.all(kind="CoreRepository", parallel=True, priority=Priority.HIGH) + + count_requests = [ + r for r in httpx_mock.get_requests() if r.method == "POST" and b"Count_CoreRepository" in r.read() + ] + assert count_requests + assert all(r.headers["x-priority"] == "high" for r in count_requests) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_related_node_fetch_forwards_priority( + client_type: str, + clients: BothClients, + mock_schema_query_01: HTTPXMock, + location_schema: NodeSchemaAPI, + location_data01: dict, + tag_schema: NodeSchemaAPI, + tag_blue_data: dict, + httpx_mock: HTTPXMock, +) -> None: + """A per-request priority passed to RelatedNode.fetch() reaches the peer query. + + This is the path a node create/update with a resource-pool relationship uses for its + follow-up peer fetch, so the whole operation carries a single consistent priority. + """ + httpx_mock.add_response( + method="POST", + json={"data": {"BuiltinTag": {"count": 1, "edges": [tag_blue_data]}}}, + match_headers={"X-Priority": "high"}, + is_reusable=True, + ) + + client = getattr(clients, client_type) + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data=location_data01) + await node.primary_tag.fetch(priority=Priority.HIGH) # type: ignore[attr-defined] + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data=location_data01) + node.primary_tag.fetch(priority=Priority.HIGH) # type: ignore[attr-defined] + + tag_requests = [ + r + for r in httpx_mock.get_requests() + if r.method == "POST" and (r.headers.get("x-infrahub-tracker") or "").startswith("query-builtintag") + ] + assert tag_requests + assert all(r.headers["x-priority"] == "high" for r in tag_requests) + + +# --------------------------------------------------------------------------- +# request_context.priority — priority carried on the client's RequestContext +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("client_type", client_types) +async def test_request_context_priority_on_graphql( + client_type: str, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """A priority set on the client's request_context emits X-Priority on a GraphQL request.""" + httpx_mock.add_response( + method="POST", + json={"data": {"InfrahubInfo": {"version": "1.0"}}}, + match_headers={"X-Priority": "low"}, + ) + + client = getattr(clients, client_type) + client.request_context = RequestContext(priority=Priority.LOW) + query = "query { InfrahubInfo { version }}" + if client_type == "standard": + await client.execute_graphql(query=query) + else: + client.execute_graphql(query=query) + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "low" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_request_context_priority_on_object_store_blob( + client_type: str, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """Blob requests (which take no per-call priority kwarg) inherit request_context.priority.""" + httpx_mock.add_response( + method="GET", + text="any content", + match_headers={"X-Priority": "low"}, + ) + + client = getattr(clients, client_type) + client.request_context = RequestContext(priority=Priority.LOW) + if client_type == "standard": + await client.object_store.get(identifier="aaaaaaaaa") + else: + client.object_store.get(identifier="aaaaaaaaa") + + requests = [r for r in httpx_mock.get_requests() if r.method == "GET"] + assert len(requests) == 1 + assert requests[0].headers["x-priority"] == "low" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_request_context_priority_not_in_mutation_body( + client_type: str, clients: BothClients, location_schema: NodeSchemaAPI, httpx_mock: HTTPXMock +) -> None: + """Priority rides the header only — the account still reaches the mutation body, priority never does.""" + httpx_mock.add_response( + method="POST", + json={ + "data": {"BuiltinLocationCreate": {"ok": True, "object": {"id": "17aec828-9814-ce00-3f20-1a053670f1c8"}}} + }, + is_reusable=True, + ) + + client = getattr(clients, client_type) + client.request_context = RequestContext(account=ContextAccount(id="acc-1"), priority=Priority.LOW) + data = {"name": {"value": "JFK1"}, "type": {"value": "SITE"}} + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data=data) + await node.save() + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data=data) + node.save() + + create_requests = [ + r + for r in httpx_mock.get_requests() + if r.method == "POST" and r.headers.get("x-infrahub-tracker") == "mutation-builtinlocation-create" + ] + assert len(create_requests) == 1 + assert create_requests[0].headers["x-priority"] == "low" + + # the account context is rendered into the mutation, but priority must never leak into the body + query = json.loads(create_requests[0].content)["query"] + assert "acc-1" in query + assert "priority" not in query + + +@dataclass +class RequestContextResolutionCase: + """One row of the request_context priority resolution truth table.""" + + name: str + client_default: Priority | None + request_context_priority: Priority | None + per_request: Priority | None + expected: str | None + + +# Precedence: per-call kwarg > request_context.priority > client default (Config.priority) > none. +REQUEST_CONTEXT_TRUTH_TABLE = [ + RequestContextResolutionCase( + name="rc-only", client_default=None, request_context_priority=Priority.LOW, per_request=None, expected="low" + ), + RequestContextResolutionCase( + name="rc-none-no-default", client_default=None, request_context_priority=None, per_request=None, expected=None + ), + RequestContextResolutionCase( + name="rc-beats-default", + client_default=Priority.MEDIUM, + request_context_priority=Priority.LOW, + per_request=None, + expected="low", + ), + RequestContextResolutionCase( + name="kwarg-beats-rc", + client_default=None, + request_context_priority=Priority.LOW, + per_request=Priority.HIGH, + expected="high", + ), + RequestContextResolutionCase( + name="default-when-rc-none", + client_default=Priority.LOW, + request_context_priority=None, + per_request=None, + expected="low", + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in REQUEST_CONTEXT_TRUTH_TABLE]) +async def test_request_context_precedence_parity( + case: RequestContextResolutionCase, client_type: str, httpx_mock: HTTPXMock +) -> None: + """Kwarg > request_context.priority > Config.priority default > none, identically on both clients.""" + httpx_mock.add_response(method="POST", json={"data": {"InfrahubInfo": {"version": "1.0"}}}) + + client = _client_with_default(client_type, case.client_default) + if case.request_context_priority is not None: + client.request_context = RequestContext(priority=case.request_context_priority) + query = "query { InfrahubInfo { version }}" + kwargs = {} if case.per_request is None else {"priority": case.per_request} + + if client_type == "standard": + await client.execute_graphql(query=query, **kwargs) # type: ignore[misc] + else: + client.execute_graphql(query=query, **kwargs) # type: ignore[union-attr] + + requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] + assert len(requests) == 1 + if case.expected is None: + assert "x-priority" not in requests[0].headers + else: + assert requests[0].headers["x-priority"] == case.expected diff --git a/tests/unit/sdk/test_relogin_headers.py b/tests/unit/sdk/test_relogin_headers.py new file mode 100644 index 000000000..76dd913c3 --- /dev/null +++ b/tests/unit/sdk/test_relogin_headers.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync +from infrahub_sdk.constants import Priority + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + +client_types = ["standard", "sync"] + + +def _build_password_client(client_type: str) -> InfrahubClient | InfrahubClientSync: + """Build a password-authenticated client primed with a stale bearer token.""" + config = Config(address="http://mock", username="admin", password="password", insert_tracker=True) + client: InfrahubClient | InfrahubClientSync = ( + InfrahubClient(config=config) if client_type == "standard" else InfrahubClientSync(config=config) + ) + + # Prime the client as if it had already logged in with a now-stale token. + client.access_token = "OLD" + client.refresh_token = "refresh-token" + client.headers["Authorization"] = "Bearer OLD" + return client + + +@pytest.mark.parametrize("client_type", client_types) +async def test_relogin_retry_uses_refreshed_auth_header(client_type: str, httpx_mock: HTTPXMock) -> None: + """The relogin retry carries the freshly-refreshed token, while a per-request priority override rides both attempts. + + The transport helpers merge the per-request delta (here, the priority override) over the + current base headers, so the retry picks up the token refreshed mid-flight instead of a + stale snapshot, and the priority override is preserved across the retry. + """ + # First GraphQL POST returns 401 with an expired-signature error. + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + status_code=401, + json={"errors": [{"message": "Expired Signature"}]}, + ) + # The relogin refresh call issues a NEW access token. + httpx_mock.add_response( + method="POST", + url="http://mock/api/auth/refresh", + json={"access_token": "NEW"}, + ) + # The retried GraphQL POST succeeds; it must carry the refreshed token. + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json={"data": {"InfrahubInfo": {"version": "1.0"}}}, + ) + + client = _build_password_client(client_type) + query = "query { InfrahubInfo { version }}" + if isinstance(client, InfrahubClient): + await client.execute_graphql(query=query, branch_name="main", priority=Priority.HIGH) + else: + client.execute_graphql(query=query, branch_name="main", priority=Priority.HIGH) + + graphql_requests = [r for r in httpx_mock.get_requests() if str(r.url) == "http://mock/graphql/main"] + assert len(graphql_requests) == 2 + # The first attempt used the stale token; the retry must use the refreshed one. + assert graphql_requests[0].headers["Authorization"] == "Bearer OLD" + assert graphql_requests[1].headers["Authorization"] == "Bearer NEW" + # The per-request priority override rides both the initial attempt and the retry. + assert all(r.headers["x-priority"] == "high" for r in graphql_requests) + + +@pytest.mark.parametrize("client_type", client_types) +def test_merge_request_headers_layers_delta_over_live_base(client_type: str) -> None: + """The merge helper layers the per-request delta over the current base headers. + + A delta with no auth key leaves the live base auth intact (so a refreshed token wins), while + an explicit per-request override — of a normal header or of auth itself — takes precedence. + """ + client = _build_password_client(client_type) + client.headers["X-Priority"] = "medium" + + # A delta carrying only a per-request priority override (no auth). + merged = client._merge_request_headers({"X-Priority": "high"}) + assert merged["X-Priority"] == "high" # per-request override wins over the base default + assert merged["Authorization"] == "Bearer OLD" # base auth is preserved from the live headers + + # After a mid-flight token refresh, a delta with no auth picks up the fresh token. + client.headers["Authorization"] = "Bearer NEW" + assert client._merge_request_headers({"X-Priority": "high"})["Authorization"] == "Bearer NEW" + + # An explicit per-request auth override is respected (the caller owns relogin in that case). + assert client._merge_request_headers({"Authorization": "Bearer CALLER"})["Authorization"] == "Bearer CALLER" diff --git a/tests/unit/sdk/test_repository.py b/tests/unit/sdk/test_repository.py index eca1c9a60..a8364b1c9 100644 --- a/tests/unit/sdk/test_repository.py +++ b/tests/unit/sdk/test_repository.py @@ -13,7 +13,11 @@ @pytest.fixture def temp_dir() -> Generator[str]: - """Fixture to create a temporary directory for testing.""" + """Fixture to create a temporary directory for testing. + + Yields: + str: Path to the temporary directory. + """ with tempfile.TemporaryDirectory() as tmp_dir: yield tmp_dir diff --git a/tests/unit/sdk/test_schema_export.py b/tests/unit/sdk/test_schema_export.py index ed2814e49..e579168c6 100644 --- a/tests/unit/sdk/test_schema_export.py +++ b/tests/unit/sdk/test_schema_export.py @@ -13,6 +13,7 @@ SchemaExport, TemplateSchemaAPI, ) +from infrahub_sdk.schema.export import schema_to_export_dict if TYPE_CHECKING: from pytest_httpx import HTTPXMock @@ -44,8 +45,8 @@ "inherit_from": [], "branch": "aware", "default_filter": None, - "generate_profile": None, - "generate_template": None, + "generate_profile": True, + "generate_template": False, "parent": None, "children": None, "attributes": [], @@ -191,6 +192,25 @@ def test_to_dict(self) -> None: assert len(as_dict["Infra"]["generics"]) == 1 +def test_export_preserves_non_default_ordered_flag() -> None: + """`ordered: false` survives the fetch -> export round-trip; the default `true` is omitted.""" + node = NodeSchemaAPI( + **{ + **_BASE_NODE, + "namespace": "Infra", + "name": "Device", + "attributes": [ + {"name": "tags_unordered", "kind": "List", "ordered": False}, + {"name": "tags_ordered", "kind": "List"}, + ], + } + ) + exported = schema_to_export_dict(node) + attrs = {attr["name"]: attr for attr in exported["attributes"]} + assert attrs["tags_unordered"]["ordered"] is False + assert "ordered" not in attrs["tags_ordered"] + + # --------------------------------------------------------------------------- # Integration tests for export() method on client.schema # --------------------------------------------------------------------------- diff --git a/tests/unit/sdk/test_schema_repository.py b/tests/unit/sdk/test_schema_repository.py index 7c85d7cca..7c1866799 100644 --- a/tests/unit/sdk/test_schema_repository.py +++ b/tests/unit/sdk/test_schema_repository.py @@ -1,11 +1,17 @@ import tempfile +from dataclasses import dataclass, field from pathlib import Path +from typing import Any import pytest +from jsonschema import Draft202012Validator from pydantic import ValidationError from infrahub_sdk.exceptions import FragmentFileNotFoundError, RepositoryFileNotFoundError, ResourceNotDefinedError from infrahub_sdk.schema.repository import ( + MALFORMED_WATCH_MESSAGE, + MISSING_WATCH_MESSAGE, + InfrahubGeneratorDefinitionConfig, InfrahubJinja2TransformConfig, InfrahubPythonTransformConfig, InfrahubRepositoryConfig, @@ -392,3 +398,252 @@ def test_jinja2_transform_payload_includes_declared_watch() -> None: } ) assert config.payload["watch"] == {"files": ["templates/partials/"]} + + +def test_generator_watch_omitted_defaults_to_none() -> None: + """A generator definition without a watch block parses, with watch defaulting to None. + + The generator config sets extra="forbid", so this also proves watch is a recognised optional + field rather than a rejected extra key. + """ + config = InfrahubGeneratorDefinitionConfig.model_validate( + {"name": "my_generator", "file_path": "generators/g.py", "query": "q", "targets": "grp"} + ) + assert config.watch is None + + +def test_generator_watch_parses_object_form() -> None: + """The object form `watch: {files: [...]}` parses into an InfrahubWatchConfig with the files preserved.""" + config = InfrahubGeneratorDefinitionConfig.model_validate( + { + "name": "my_generator", + "file_path": "generators/g.py", + "query": "q", + "targets": "grp", + "watch": {"files": ["a", "dir/"]}, + } + ) + assert config.watch is not None + assert config.watch.files == ["a", "dir/"] + + +def test_generator_watch_list_form_rejected() -> None: + """A bare list `watch: [a, b]` is rejected: the realistic YAML mistake of a list instead of an object. + + Matching the message confirms the rejection is the watch model-type error, not some unrelated failure. + """ + with pytest.raises(ValidationError, match="valid dictionary or instance of InfrahubWatchConfig"): + InfrahubGeneratorDefinitionConfig.model_validate( + { + "name": "my_generator", + "file_path": "generators/g.py", + "query": "q", + "targets": "grp", + "watch": ["a", "b"], + } + ) + + +# --- Advisory 'watch' warning carried by the generated JSON schema --- +# +# The JSON schema generated from InfrahubRepositoryConfig is published to the infrahub-jsonschema +# repository, where YAML language servers use it to validate .infrahub.yml while it is edited. Two +# advisory rules exist purely so editors can nudge: +# +# - Python transforms and generators declare 'watch' as required, flagging an absent block. +# - A 'watch' value must be a mapping, flagging the bare 'watch:' and 'watch: null' forms that +# parse to None and so record nothing, along with any other non-mapping value. +# +# Neither rule is enforced by the models, so these tests pin the split: the JSON schema nudges, the +# runtime stays permissive. Any mapping stays clean, including an empty 'watch: {}' and an explicit +# 'files: []': both record that the author checked and nothing extra needs watching. + +PYTHON_TRANSFORM = {"name": "device_config", "file_path": "transforms/device.py"} +GENERATOR = {"name": "build_interfaces", "file_path": "generators/iface.py", "query": "q", "targets": "grp"} +JINJA2_TRANSFORM = {"name": "device_config", "query": "q", "template_path": "templates/device.j2"} + + +def _validation_errors(document: dict[str, Any]) -> list[Any]: + return list(Draft202012Validator(InfrahubRepositoryConfig.model_json_schema()).iter_errors(document)) + + +def missing_watch_paths(document: dict[str, Any]) -> list[str]: + """Definitions in ``document`` the schema flags for having no 'watch' block at all. + + Filtering on ``validator_value`` isolates the advisory requirement from the genuine required + fields, so an unrelated omission elsewhere in the document cannot be mistaken for a watch warning. + """ + return [ + "/".join(str(part) for part in error.absolute_path) + for error in _validation_errors(document) + if error.validator == "required" and error.validator_value == ["watch"] + ] + + +def flagged_watch_paths(document: dict[str, Any]) -> list[str]: + """'watch' values in ``document`` the schema flags for not being a mapping. + + Anchored on the location rather than the keyword, so it catches every non-mapping value however + it fails. Duplicates are collapsed because one such value can fail several keywords at once. + """ + paths = [ + "/".join(str(part) for part in error.absolute_path) + for error in _validation_errors(document) + if error.absolute_path and error.absolute_path[-1] == "watch" + ] + return list(dict.fromkeys(paths)) + + +@dataclass +class WatchWarningCase: + name: str + document: dict[str, Any] + missing: list[str] = field(default_factory=list) + flagged: list[str] = field(default_factory=list) + + +WATCH_WARNING_CASES = [ + WatchWarningCase( + name="python-transform-without-watch", + document={"python_transforms": [PYTHON_TRANSFORM]}, + missing=["python_transforms/0"], + ), + WatchWarningCase( + name="generator-without-watch", + document={"generator_definitions": [GENERATOR]}, + missing=["generator_definitions/0"], + ), + WatchWarningCase( + name="each-entry-flagged-independently", + document={ + "python_transforms": [PYTHON_TRANSFORM | {"watch": {"files": ["lib/"]}}, PYTHON_TRANSFORM], + "generator_definitions": [GENERATOR], + }, + missing=["python_transforms/1", "generator_definitions/0"], + ), + WatchWarningCase( + name="bare-watch-key-is-not-an-answer", + document={"python_transforms": [PYTHON_TRANSFORM | {"watch": None}]}, + flagged=["python_transforms/0/watch"], + ), + WatchWarningCase( + name="empty-watch-block-is-a-deliberate-choice", + document={"python_transforms": [PYTHON_TRANSFORM | {"watch": {}}]}, + ), + WatchWarningCase( + name="empty-generator-watch-block-is-a-deliberate-choice", + document={"generator_definitions": [GENERATOR | {"watch": {}}]}, + ), + WatchWarningCase( + name="watch-as-a-bare-list", + document={"python_transforms": [PYTHON_TRANSFORM | {"watch": ["lib/"]}]}, + flagged=["python_transforms/0/watch"], + ), + WatchWarningCase( + name="watch-as-a-string", + document={"python_transforms": [PYTHON_TRANSFORM | {"watch": "lib/"}]}, + flagged=["python_transforms/0/watch"], + ), + WatchWarningCase( + name="python-transform-with-watch", + document={"python_transforms": [PYTHON_TRANSFORM | {"watch": {"files": ["lib/helpers.py"]}}]}, + ), + WatchWarningCase( + name="generator-with-watch", + document={"generator_definitions": [GENERATOR | {"watch": {"files": ["lib/"]}}]}, + ), + WatchWarningCase( + name="empty-files-list-is-a-deliberate-choice", + document={"python_transforms": [PYTHON_TRANSFORM | {"watch": {"files": []}}]}, + ), + WatchWarningCase( + name="jinja2-transform-without-watch-is-out-of-scope", + document={"jinja2_transforms": [JINJA2_TRANSFORM]}, + ), + WatchWarningCase( + name="check-definition-has-no-watch-to-warn-about", + document={"check_definitions": [{"name": "my_check", "file_path": "check.py"}]}, + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in WATCH_WARNING_CASES]) +def test_watch_warnings_flagged_by_json_schema(case: WatchWarningCase) -> None: + assert missing_watch_paths(case.document) == case.missing + assert flagged_watch_paths(case.document) == case.flagged + + +def test_watch_warnings_carry_their_guidance_messages() -> None: + """Both rules ship 'errorMessage', which is what a YAML language server displays. + + Without them editors fall back to a bare `Missing property "watch"` or `Incorrect type`, neither + of which tells anyone why they should care. + """ + defs = InfrahubRepositoryConfig.model_json_schema()["$defs"] + for name in ("InfrahubPythonTransformConfig", "InfrahubGeneratorDefinitionConfig"): + assert defs[name]["allOf"] == [{"required": ["watch"], "errorMessage": MISSING_WATCH_MESSAGE}] + for name in ("InfrahubPythonTransformConfig", "InfrahubGeneratorDefinitionConfig", "InfrahubJinja2TransformConfig"): + watch = defs[name]["properties"]["watch"] + assert watch["type"] == "object" + assert watch["errorMessage"] == MALFORMED_WATCH_MESSAGE + + +def test_empty_watch_block_records_the_acknowledgement_a_bare_key_does_not() -> None: + """The runtime difference is the whole reason only one of the two forms warns. + + 'files' defaults to an empty list, so 'watch: {}' still produces a watch config and carries the + author's "nothing extra needs watching" either way. A bare 'watch:' parses to None, which is + indistinguishable from never having declared it, so nothing is recorded and the schema flags it. + Collapsing that difference would make one of the two warnings wrong. + """ + empty_block = InfrahubPythonTransformConfig.model_validate(PYTHON_TRANSFORM | {"watch": {}}) + bare_key = InfrahubPythonTransformConfig.model_validate(PYTHON_TRANSFORM | {"watch": None}) + + assert empty_block.watch is not None + assert empty_block.watch.files == [] + assert bare_key.watch is None + + +def test_genuine_required_fields_survive_alongside_the_watch_warning() -> None: + """The advisory requirement must not displace the fields pydantic marks as required. + + Declaring it through a top-level 'required' in json_schema_extra would overwrite the generated + list, silently making genuinely mandatory fields optional in the published schema. + """ + defs = InfrahubRepositoryConfig.model_json_schema()["$defs"] + assert defs["InfrahubPythonTransformConfig"]["required"] == ["name", "file_path"] + assert defs["InfrahubGeneratorDefinitionConfig"]["required"] == ["name", "file_path", "query", "targets"] + + +def test_watch_field_keeps_its_nullable_reference() -> None: + """Narrowing the field to an object must not replace the reference pydantic generates. + + The 'type' keyword sits alongside the anyOf so a well-formed block still validates against + InfrahubWatchConfig, which is what drives editor completion inside the block. + """ + watch = InfrahubRepositoryConfig.model_json_schema()["$defs"]["InfrahubPythonTransformConfig"]["properties"][ + "watch" + ] + assert watch["anyOf"] == [{"$ref": "#/$defs/InfrahubWatchConfig"}, {"type": "null"}] + + +def test_repository_json_schema_is_a_valid_draft_2020_12_schema() -> None: + Draft202012Validator.check_schema(InfrahubRepositoryConfig.model_json_schema()) + + +@pytest.mark.parametrize( + "watch", + [pytest.param(None, id="watch-omitted"), pytest.param({"watch": None}, id="watch-explicitly-null")], +) +def test_warned_about_watch_forms_stay_valid_at_runtime(watch: dict[str, Any] | None) -> None: + """The warnings are editor-only. + + The schema is deliberately stricter than the models here, so parsing a config the editor warns + about must keep working. + """ + extra = watch or {} + config = InfrahubRepositoryConfig.model_validate( + {"python_transforms": [PYTHON_TRANSFORM | extra], "generator_definitions": [GENERATOR | extra]} + ) + assert config.python_transforms[0].watch is None + assert config.generator_definitions[0].watch is None diff --git a/tests/unit/sdk/test_task.py b/tests/unit/sdk/test_task.py index dd029003f..c3e26c698 100644 --- a/tests/unit/sdk/test_task.py +++ b/tests/unit/sdk/test_task.py @@ -1,13 +1,15 @@ from __future__ import annotations +import json from datetime import datetime, timezone from typing import TYPE_CHECKING import pytest +from infrahub_sdk.graphql import Mutation from infrahub_sdk.task.exceptions import TaskNotFoundError, TooManyTasksError -from infrahub_sdk.task.manager import InfraHubTaskManagerBase -from infrahub_sdk.task.models import Task, TaskFilter, TaskState +from infrahub_sdk.task.manager import MUTATION_TASK_QUERY, InfraHubTaskManagerBase +from infrahub_sdk.task.models import Task, TaskFilter, TaskState, WebhookDeliveryTask if TYPE_CHECKING: from pytest_httpx import HTTPXMock @@ -39,6 +41,95 @@ async def test_method_all_full(clients: BothClients, mock_query_tasks_01: HTTPXM assert isinstance(tasks[0], Task) +@pytest.mark.parametrize("client_type", client_types) +async def test_method_retry(clients: BothClients, httpx_mock: HTTPXMock, client_type: str) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"InfrahubTaskRetry": {"ok": True, "task": {"id": "b71f5542-7b54-562f-9053-8fd6ec0b0481"}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-task-retry"}, + ) + + if client_type == "standard": + new_id = await clients.standard.task.retry(id="a60f4431-6a43-451e-8f42-9ec5db9a9370") + else: + new_id = clients.sync.task.retry(id="a60f4431-6a43-451e-8f42-9ec5db9a9370") + + assert new_id == "b71f5542-7b54-562f-9053-8fd6ec0b0481" + sent_query = json.loads(httpx_mock.get_requests()[-1].content)["query"] + assert "InfrahubTaskRetry(" in sent_query + assert 'id: "a60f4431-6a43-451e-8f42-9ec5db9a9370"' in sent_query + + +@pytest.mark.parametrize("client_type", client_types) +async def test_method_cancel(clients: BothClients, httpx_mock: HTTPXMock, client_type: str) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"InfrahubTaskCancel": {"ok": True, "task": {"id": "a60f4431-6a43-451e-8f42-9ec5db9a9370"}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-task-cancel"}, + ) + + if client_type == "standard": + cancelled = await clients.standard.task.cancel(id="a60f4431-6a43-451e-8f42-9ec5db9a9370") + else: + cancelled = clients.sync.task.cancel(id="a60f4431-6a43-451e-8f42-9ec5db9a9370") + + assert cancelled is True + sent_query = json.loads(httpx_mock.get_requests()[-1].content)["query"] + assert "InfrahubTaskCancel(" in sent_query + assert 'id: "a60f4431-6a43-451e-8f42-9ec5db9a9370"' in sent_query + + +@pytest.mark.parametrize("client_type", client_types) +async def test_filter_limit_forwards_include_actions( + clients: BothClients, mock_query_tasks_03: HTTPXMock, client_type: str +) -> None: + if client_type == "standard": + await clients.standard.task.filter(limit=5, include_actions=True) + else: + clients.sync.task.filter(limit=5, include_actions=True) + + sent_query = json.loads(mock_query_tasks_03.get_requests()[-1].content)["query"] + assert "available_actions" in sent_query + + +@pytest.mark.parametrize("client_type", client_types) +async def test_filter_limit_forwards_include_diagnostics( + clients: BothClients, mock_query_tasks_03: HTTPXMock, client_type: str +) -> None: + if client_type == "standard": + await clients.standard.task.filter(limit=5, include_diagnostics=True) + else: + clients.sync.task.filter(limit=5, include_diagnostics=True) + + sent_query = json.loads(mock_query_tasks_03.get_requests()[-1].content)["query"] + assert "... on WebhookDeliveryTask" in sent_query + + +async def test_action_mutation_render() -> None: + query = Mutation( + mutation="InfrahubTaskRetry", + input_data={"data": {"id": "a60f4431-6a43-451e-8f42-9ec5db9a9370"}}, + query=MUTATION_TASK_QUERY, + ) + assert ( + query.render() + == """ +mutation { + InfrahubTaskRetry( + data: { + id: "a60f4431-6a43-451e-8f42-9ec5db9a9370" + } + ){ + ok + task { + id + } + } +} +""" + ) + + async def test_generate_count_query() -> None: query = InfraHubTaskManagerBase._generate_count_query() assert query @@ -121,8 +212,10 @@ async def test_method_get_full(clients: BothClients, mock_query_tasks_05: HTTPXM assert len(task.logs) == 4 assert len(task.related_nodes) == 2 assert task.model_dump() == { + "available_actions": [], "branch": "main", "created_at": datetime(2025, 1, 18, 22, 12, 20, 228112, tzinfo=timezone.utc), + "error": None, "id": "32116fcd-9071-43a7-9f14-777901020b5b", "logs": [ { @@ -161,3 +254,90 @@ async def test_method_get_full(clients: BothClients, mock_query_tasks_05: HTTPXM "updated_at": datetime(2025, 1, 18, 22, 12, 22, 44921, tzinfo=timezone.utc), "workflow": "import-python-files", } + + +def _base_task_data() -> dict: + return { + "id": "a60f4431-6a43-451e-8f42-9ec5db9a9370", + "title": "Webhook delivery", + "state": "COMPLETED", + "created_at": "2025-01-18T22:12:20.228112+00:00", + "updated_at": "2025-01-18T22:12:22.044921+00:00", + } + + +async def test_available_actions_parsed() -> None: + task = Task.from_graphql( + { + **_base_task_data(), + "available_actions": [ + {"action": "RETRY", "available": True, "unavailability_reason": None}, + {"action": "CANCEL", "available": False, "unavailability_reason": "the task has already settled"}, + ], + } + ) + + assert task.can_retry is True + assert task.can_cancel is False + assert task.available_actions[1].unavailability_reason == "the task has already settled" + + +async def test_available_actions_absent_defaults_empty() -> None: + task = Task.from_graphql(_base_task_data()) + + assert task.available_actions == [] + assert task.can_retry is False + assert task.can_cancel is False + + +async def test_error_parsed() -> None: + task = Task.from_graphql( + { + **_base_task_data(), + "error": { + "status_class": "client_error", + "message": "endpoint returned 404", + "remediation": "check the webhook URL", + }, + } + ) + + assert task.error is not None + assert task.error.status_class == "client_error" + assert task.error.message == "endpoint returned 404" + assert task.error.remediation == "check the webhook URL" + + +async def test_webhook_send_dispatches_to_webhook_task() -> None: + task = Task.from_graphql( + { + **_base_task_data(), + "workflow": "webhook-send", + "http_request": {"url": "https://example.com/hook", "headers": {"X-Signature": "***"}}, + "http_response": {"status_code": 200, "body": "ok", "latency_ms": 42.5}, + } + ) + + assert isinstance(task, WebhookDeliveryTask) + assert task.http_request is not None + assert task.http_request.url == "https://example.com/hook" + assert task.http_response is not None + assert task.http_response.status_code == 200 + assert task.http_response.latency_ms == pytest.approx(42.5) + + +async def test_generate_query_excludes_diagnostics_by_default() -> None: + query = InfraHubTaskManagerBase._generate_query().render() + + assert "error {" not in query + assert "... on WebhookDeliveryTask {" not in query + + +async def test_generate_query_includes_diagnostics_when_requested() -> None: + query = InfraHubTaskManagerBase._generate_query(include_diagnostics=True).render() + + assert "error {" in query + assert "remediation" in query + assert "... on WebhookDeliveryTask {" in query + assert "http_request {" in query + assert "http_response {" in query diff --git a/tests/unit/test_schema_generated_models.py b/tests/unit/test_schema_generated_models.py new file mode 100644 index 000000000..7e7eca521 --- /dev/null +++ b/tests/unit/test_schema_generated_models.py @@ -0,0 +1,240 @@ +"""Drift guard for the generated user-facing write/read schema models. + +The SDK repo cannot regenerate these models on its own (they are rendered from the +backend's schema definitions), so this checks what the SDK can verify standalone: +the generated files are present, carry the do-not-edit header, expose the expected +model families, and satisfy the write/read structural invariants (write drops extra +fields silently; read is a superset of write). The full regeneration drift is enforced by the +monorepo's generated-file CI, which regenerates and fails on any diff. + +The attribute family is a discriminated union on ``kind``: a shared ``AttributeSchemaBase`` +carries every field except ``parameters``, and each variant narrows ``kind`` and adds its own +``parameters`` model. The public ``AttributeSchema{Write,Read}`` name is the union alias, so +class-level checks introspect the base and the variants rather than the alias. +""" + +from __future__ import annotations + +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk.schema import InfrahubSchemaRead, InfrahubSchemaWrite +from infrahub_sdk.schema.generated import enums as enums_module +from infrahub_sdk.schema.generated import read as read_module +from infrahub_sdk.schema.generated import write as write_module + +if TYPE_CHECKING: + from pydantic import BaseModel + +_GENERATED_DIR = Path(write_module.__file__).parent +# Plain (non-union) families pair their write-variant class name with their read-variant class name. +_FAMILY_PAIRS = [ + ("RelationshipSchemaWrite", "RelationshipSchemaRead"), + ("BaseNodeSchemaWrite", "BaseNodeSchemaRead"), + ("NodeSchemaWrite", "NodeSchemaRead"), + ("GenericSchemaWrite", "GenericSchemaRead"), +] +_WRITE_FAMILIES = [write for write, _ in _FAMILY_PAIRS] + +# The attribute union's shared base plus its kind-discriminated variants (bare names; a variant's +# class is ``Write`` / ``Read``). +_ATTRIBUTE_BASE = "AttributeSchemaBase" +_ATTRIBUTE_VARIANTS = [ + "TextAttribute", + "NumberAttribute", + "ListAttribute", + "NumberPoolAttribute", + "GenericAttribute", +] + + +def _attribute_write_classes() -> list[type[BaseModel]]: + return [getattr(write_module, _ATTRIBUTE_BASE + "Write")] + [ + getattr(write_module, variant + "Write") for variant in _ATTRIBUTE_VARIANTS + ] + + +def _attribute_read_classes() -> list[type[BaseModel]]: + return [getattr(read_module, _ATTRIBUTE_BASE + "Read")] + [ + getattr(read_module, variant + "Read") for variant in _ATTRIBUTE_VARIANTS + ] + + +def _aggregate_fields(models: list[type[BaseModel]]) -> set[str]: + fields: set[str] = set() + for model in models: + fields |= set(model.model_fields) + return fields + + +@pytest.mark.parametrize("filename", ["write.py", "read.py"]) +def test_generated_files_present_with_do_not_edit_header(filename: str) -> None: + path = _GENERATED_DIR / filename + assert path.is_file(), f"Generated schema model file is missing: {path}" + assert "do not edit" in path.read_text().splitlines()[0].lower() + + +@pytest.mark.parametrize(("write_family", "read_family"), _FAMILY_PAIRS) +def test_expected_model_families_present_in_each_variant(write_family: str, read_family: str) -> None: + assert hasattr(write_module, write_family), f"write variant is missing {write_family}" + assert hasattr(read_module, read_family), f"read variant is missing {read_family}" + + +def test_attribute_union_families_present_in_each_variant() -> None: + # The public union alias and every variant (plus the shared base) must exist in both variants. + for name in ["AttributeSchema", _ATTRIBUTE_BASE, *_ATTRIBUTE_VARIANTS]: + assert hasattr(write_module, name + "Write"), f"write variant is missing {name}Write" + assert hasattr(read_module, name + "Read"), f"read variant is missing {name}Read" + + +@pytest.mark.parametrize("family", _WRITE_FAMILIES) +def test_write_variant_ignores_extra_fields(family: str) -> None: + model: type[BaseModel] = getattr(write_module, family) + assert model.model_config.get("extra") == "ignore", ( + f"write variant {family} must set extra='ignore' so non-settable fields are dropped, not rejected" + ) + + +def test_attribute_write_base_and_variants_ignore_extra_fields() -> None: + for model in _attribute_write_classes(): + assert model.model_config.get("extra") == "ignore", ( + f"write attribute model {model.__name__} must set extra='ignore'" + ) + + +@pytest.mark.parametrize(("write_family", "read_family"), _FAMILY_PAIRS) +def test_read_variant_is_superset_of_write_variant(write_family: str, read_family: str) -> None: + write_model: type[BaseModel] = getattr(write_module, write_family) + read_model: type[BaseModel] = getattr(read_module, read_family) + write_fields = set(write_model.model_fields) + read_fields = set(read_model.model_fields) + missing = write_fields - read_fields + assert not missing, f"read variant {read_family} must expose every write field; missing: {sorted(missing)}" + + +def test_attribute_read_is_superset_of_attribute_write() -> None: + # Aggregated across the base and every variant, read must expose every write field. + write_fields = _aggregate_fields(_attribute_write_classes()) + read_fields = _aggregate_fields(_attribute_read_classes()) + missing = write_fields - read_fields + assert not missing, f"read attribute variants must expose every write field; missing: {sorted(missing)}" + + +def test_write_variant_carries_read_level_fields_absent() -> None: + # `inherited` is a read-level attribute field carried on the shared base; it must be absent on + # the write base and present on the read base. + write_base = getattr(write_module, _ATTRIBUTE_BASE + "Write") + read_base = getattr(read_module, _ATTRIBUTE_BASE + "Read") + assert "inherited" not in write_base.model_fields + assert "inherited" in read_base.model_fields + + +def test_attribute_parameters_only_on_variants_not_base() -> None: + # `parameters` is what the union discriminates, so it lives on the variants, not the base. + write_base = getattr(write_module, _ATTRIBUTE_BASE + "Write") + assert "parameters" not in write_base.model_fields + for variant in _ATTRIBUTE_VARIANTS: + model = getattr(write_module, variant + "Write") + assert "parameters" in model.model_fields, f"{model.__name__} must carry a parameters field" + + +def test_document_root_models_import_with_nodes_and_generics() -> None: + for root in (InfrahubSchemaWrite, InfrahubSchemaRead): + assert "nodes" in root.model_fields, f"{root.__name__} must expose a 'nodes' field" + assert "generics" in root.model_fields, f"{root.__name__} must expose a 'generics' field" + # The write root drops unknown top-level keys silently (tolerated, not rejected). + assert InfrahubSchemaWrite.model_config.get("extra") == "ignore" + + +def test_write_root_exposes_extensions_field() -> None: + # Extensions are part of the write contract (write-only); the read root does not carry them. + assert "extensions" in InfrahubSchemaWrite.model_fields + assert "extensions" not in InfrahubSchemaRead.model_fields + + +@pytest.mark.parametrize("name", ["NodeExtensionWrite", "SchemaExtensionWrite"]) +def test_extension_models_present_on_write_variant_only(name: str) -> None: + assert hasattr(write_module, name), f"write variant is missing {name}" + assert not hasattr(read_module, name.replace("Write", "Read")), ( + f"extension models are write-only; read variant must not define {name.replace('Write', 'Read')}" + ) + + +@pytest.mark.parametrize("name", ["NodeExtensionWrite", "SchemaExtensionWrite"]) +def test_extension_models_ignore_extra_fields(name: str) -> None: + model: type[BaseModel] = getattr(write_module, name) + assert model.model_config.get("extra") == "ignore", f"extension model {name} must set extra='ignore'" + + +# Constrained fields are typed with dedicated (str, Enum) classes emitted into enums.py rather +# than inline Literals. Each generated enum's ordered values must match this contract. +_EXPECTED_ENUM_VALUES = { + "BranchSupportType": ["aware", "agnostic", "local"], + "RelationshipKind": ["Generic", "Attribute", "Component", "Parent", "Group", "Hierarchy", "Profile", "Template"], + "RelationshipCardinality": ["one", "many"], + "RelationshipDirection": ["bidirectional", "outbound", "inbound"], + "RelationshipDeleteBehavior": ["no-action", "cascade"], + "AllowOverrideType": ["none", "any"], + "SchemaState": ["present", "absent"], + "SchemaAttributeDisplay": ["default", "extra"], + "ComputedAttributeKind": ["User", "Jinja2", "TransformPython"], +} + + +@pytest.mark.parametrize("enum_name", sorted(_EXPECTED_ENUM_VALUES)) +def test_generated_enums_are_str_enum_classes_with_expected_values(enum_name: str) -> None: + enum_cls = getattr(enums_module, enum_name) + assert issubclass(enum_cls, Enum), f"{enum_name} must be an Enum class" + assert issubclass(enum_cls, str), f"{enum_name} must be a str-backed enum" + assert [member.value for member in enum_cls] == _EXPECTED_ENUM_VALUES[enum_name] + + +def test_attribute_kind_enum_present_without_deprecated_string_member() -> None: + assert issubclass(enums_module.AttributeKind, Enum) + assert issubclass(enums_module.AttributeKind, str) + values = [member.value for member in enums_module.AttributeKind] + # The deprecated "String" kind is dropped from the generated enum. + assert "String" not in values + assert "Text" in values + + +def test_constrained_fields_are_typed_with_generated_enums() -> None: + # The constrained fields reference the dedicated enum classes, not inline Literals. + assert ( + write_module.RelationshipSchemaWrite.model_fields["cardinality"].annotation + is enums_module.RelationshipCardinality + ) + assert write_module.RelationshipSchemaWrite.model_fields["kind"].annotation is enums_module.RelationshipKind + assert write_module.AttributeSchemaBaseWrite.model_fields["kind"].annotation is enums_module.AttributeKind + assert ( + read_module.RelationshipSchemaRead.model_fields["cardinality"].annotation + is enums_module.RelationshipCardinality + ) + + +def test_use_enum_values_keeps_runtime_field_values_as_plain_strings() -> None: + # use_enum_values means a constructed model stores the plain string, so equality against + # both the raw string and the enum member holds and serialization is unchanged. + # Passing the raw string is intentional here: the field is typed as the enum, but the point of + # this test is that pydantic coerces a plain string at runtime, so the static complaint is expected. + relationship = write_module.RelationshipSchemaWrite(name="interfaces", peer="InfraInterface", cardinality="one") # ty: ignore[invalid-argument-type] + assert relationship.cardinality == "one" + assert relationship.cardinality == enums_module.RelationshipCardinality.ONE + assert isinstance(relationship.cardinality, str) + # Discriminates the mode: without use_enum_values the value would be a RelationshipCardinality + # member (also a str, so the assertions above cannot tell the modes apart). A plain string is + # not an instance of the enum, so this fails if use_enum_values is ever dropped on regeneration. + assert not isinstance(relationship.cardinality, enums_module.RelationshipCardinality) + + +@pytest.mark.parametrize("name", ["ProfileSchemaRead", "TemplateSchemaRead"]) +def test_profile_template_read_models_present_on_read_variant_only(name: str) -> None: + # Profiles and templates are read-only projections; only the read variant defines them. + model: type[BaseModel] = getattr(read_module, name) + assert "inherit_from" in model.model_fields, f"{name} must expose an 'inherit_from' field" + assert not hasattr(write_module, name.replace("Read", "Write")), ( + f"profile/template models are read-only; write variant must not define {name.replace('Read', 'Write')}" + ) diff --git a/tests/unit/test_schema_offline_validation.py b/tests/unit/test_schema_offline_validation.py new file mode 100644 index 000000000..d9353672c --- /dev/null +++ b/tests/unit/test_schema_offline_validation.py @@ -0,0 +1,556 @@ +"""Offline schema validation: with only the SDK installed (pydantic, no server). + +Validates a schema payload against the generated write models and asserts the +field-level verdict, without importing the backend/server package. Values the user may not +set never reach the server, but they are reported: a read-only field -- one the read API +returns -- as a warning, and any other extra field as an error. Enum, constraint and +required-field violations are reported naming the field and the invalid value. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from infrahub_sdk.schema import InfrahubSchemaRead, InfrahubSchemaWrite, validate_schema +from infrahub_sdk.schema.validate import SchemaValidationResult + + +def _valid_schema() -> dict: + return { + "version": "1.0", + "nodes": [ + { + "name": "Device", + "namespace": "Infra", + "attributes": [ + {"name": "hostname", "kind": "Text"}, + {"name": "count", "kind": "Number", "optional": True}, + ], + "relationships": [ + {"name": "interfaces", "peer": "InfraInterface", "cardinality": "many", "optional": True}, + ], + }, + ], + "generics": [ + {"name": "Endpoint", "namespace": "Infra", "attributes": [{"name": "role", "kind": "Text"}]}, + ], + } + + +def _fields_named(result: SchemaValidationResult) -> set[str]: + return {error.field for error in result.errors} + + +def _extension_node_schema(node: dict) -> dict: + return {"version": "1.0", "extensions": {"nodes": [node]}} + + +def _schema_with_computed_attribute(computed_attribute: dict) -> dict: + schema = _valid_schema() + schema["nodes"][0]["attributes"][0]["computed_attribute"] = computed_attribute + return schema + + +def _schema_with_choices(choices: list[dict]) -> dict: + schema = _valid_schema() + schema["nodes"][0]["attributes"][0]["choices"] = choices + return schema + + +def _schema_with_parameters(parameters: dict) -> dict: + schema = _valid_schema() + schema["nodes"][0]["attributes"][0]["parameters"] = parameters + return schema + + +def _schema_with_kind_and_parameters(kind: str, parameters: dict) -> dict: + schema = _valid_schema() + schema["nodes"][0]["attributes"][0]["kind"] = kind + schema["nodes"][0]["attributes"][0]["parameters"] = parameters + return schema + + +def _relationship_out_of_enum(field: str, value: str) -> dict: + schema = _valid_schema() + schema["nodes"][0]["relationships"][0][field] = value + return schema + + +def _attribute_out_of_enum_kind(kind: str) -> dict: + schema = _valid_schema() + schema["nodes"][0]["attributes"][0]["kind"] = kind + return schema + + +def _schema_with_attribute_fields(**fields: object) -> dict: + schema = _valid_schema() + schema["nodes"][0]["attributes"][0].update(fields) + return schema + + +def _schema_with_relationship_fields(**fields: object) -> dict: + schema = _valid_schema() + schema["nodes"][0]["relationships"][0].update(fields) + return schema + + +def _schema_with_node_fields(**fields: object) -> dict: + schema = _valid_schema() + schema["nodes"][0].update(fields) + return schema + + +def _schema_with_generic_fields(**fields: object) -> dict: + schema = _valid_schema() + schema["generics"][0].update(fields) + return schema + + +def _schema_with_root_fields(**fields: object) -> dict: + schema = _valid_schema() + schema.update(fields) + return schema + + +def test_schema_root_models_are_importable_with_nodes_and_generics() -> None: + for root in (InfrahubSchemaWrite, InfrahubSchemaRead): + assert "nodes" in root.model_fields + assert "generics" in root.model_fields + + +def test_valid_payload_passes() -> None: + result = validate_schema(schema=_valid_schema()) + assert isinstance(result, SchemaValidationResult) + assert result.valid is True + assert result.errors == [] + # raise_for_status must be a no-op for a valid payload + result.raise_for_status() + + +def test_valid_payload_with_extensions_block_passes() -> None: + schema = _valid_schema() + schema["extensions"] = { + "nodes": [ + { + "kind": "InfraDevice", + "attributes": [{"name": "extra", "kind": "Text"}], + "relationships": [{"name": "peers", "peer": "InfraDevice", "cardinality": "many", "optional": True}], + } + ] + } + + result = validate_schema(schema=schema) + + assert result.valid is True, result.messages + + +def test_enum_backed_relationship_cardinality_valid_value_passes() -> None: + # A plain string for RelationshipCardinality is valid. + schema = _valid_schema() + schema["nodes"][0]["relationships"][0]["cardinality"] = "one" + + result = validate_schema(schema=schema) + + assert result.valid is True, result.messages + + +# --------------------------------------------------------------------------- +# Read-only fields are accepted with a warning +# --------------------------------------------------------------------------- + + +@dataclass +class ReadOnlyCase: + name: str + schema: dict + # Exact dotted paths expected among the reported warnings. + expected_fields: set[str] + + +READ_ONLY_CASES = [ + ReadOnlyCase( + name="attribute-inherited", + schema=_schema_with_attribute_fields(inherited=True), + expected_fields={"nodes[0].attributes[0].inherited"}, + ), + ReadOnlyCase( + name="relationship-inherited-and-hierarchical", + schema=_schema_with_relationship_fields(inherited=True, hierarchical="SomeGeneric"), + expected_fields={ + "nodes[0].relationships[0].inherited", + "nodes[0].relationships[0].hierarchical", + }, + ), + ReadOnlyCase( + name="generic-used-by", + schema=_schema_with_generic_fields(used_by=["InfraThing"]), + expected_fields={"generics[0].used_by"}, + ), + ReadOnlyCase( + name="node-hierarchy", + schema=_schema_with_node_fields(hierarchy="SomeGeneric"), + expected_fields={"nodes[0].hierarchy"}, + ), + ReadOnlyCase( + name="node-derived-kind-and-hash", + schema=_schema_with_node_fields(kind="InfraDevice", hash="abc123"), + expected_fields={"nodes[0].kind", "nodes[0].hash"}, + ), + ReadOnlyCase( + name="extension-attribute-inherited", + schema=_extension_node_schema( + {"kind": "InfraDevice", "attributes": [{"name": "extra", "kind": "Text", "inherited": True}]} + ), + expected_fields={"extensions.nodes[0].attributes[0].inherited"}, + ), + ReadOnlyCase( + name="root-keys-of-a-read-api-response", + schema=_schema_with_root_fields(main="abc123", profiles=[], templates=[], namespaces=[]), + expected_fields={"main", "profiles", "templates", "namespaces"}, + ), + # Every internal schema model carries `id` and `state`, so they appear on the nested value + # models of a schema dumped from those models even though they are not settable there. + ReadOnlyCase( + name="parameters-bookkeeping-fields", + schema=_schema_with_parameters({"min_length": 1, "id": None, "state": "present"}), + expected_fields={ + "nodes[0].attributes[0].parameters.id", + "nodes[0].attributes[0].parameters.state", + }, + ), + ReadOnlyCase( + name="choice-bookkeeping-fields", + schema=_schema_with_choices([{"name": "active", "id": None, "state": "present"}]), + expected_fields={ + "nodes[0].attributes[0].choices[0].id", + "nodes[0].attributes[0].choices[0].state", + }, + ), + # `transform` belongs to the TransformPython variant of the computed-attribute union, so it is + # known at this location but not settable on a Jinja2 one. + ReadOnlyCase( + name="computed-attribute-sibling-variant-field", + schema=_schema_with_computed_attribute({"kind": "Jinja2", "jinja2_template": "x", "transform": "t"}), + expected_fields={"nodes[0].attributes[0].computed_attribute.transform"}, + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in READ_ONLY_CASES]) +def test_read_only_field_is_accepted_with_a_warning(case: ReadOnlyCase) -> None: + # A payload read back from Infrahub carries read-only fields, so it must still load; the user + # is told the value is ignored rather than having it dropped silently. + result = validate_schema(schema=case.schema) + + assert result.valid is True, result.messages + assert {warning.field for warning in result.warnings} == case.expected_fields + + +def test_read_only_warning_names_the_owning_kind_and_element() -> None: + # Consumers render a warning as kind + field rather than as a path, so the owning schema kind + # and the attribute/relationship carrying the field travel with the finding. + schema = _schema_with_attribute_fields(inherited=True) + + result = validate_schema(schema=schema) + + assert len(result.warnings) == 1 + warning = result.warnings[0] + assert warning.name == "inherited" + assert warning.kind == "InfraDevice" + assert warning.element == "hostname" + + +def test_nested_read_only_field_is_named_relative_to_its_owner() -> None: + # `id` is settable on an attribute but not on its nested parameters, so reporting the bare name + # would claim the wrong field is read-only -- and would collide with an `id` reported elsewhere. + schema = _schema_with_parameters({"id": None, "state": "present"}) + schema["extensions"] = {"nodes": [{"kind": "InfraDevice"}], "id": None} + + result = validate_schema(schema=schema) + + assert result.valid is True, result.messages + assert {warning.name for warning in result.warnings} == { + "parameters.id", + "parameters.state", + "extensions.id", + } + + +def test_read_only_fields_are_dropped_on_round_trip() -> None: + # A warning must not mean the value is kept: read-only fields are absent from the validated + # model, so they never reach the server. + schema = _valid_schema() + schema["nodes"][0]["hierarchy"] = "SomeGeneric" + schema["nodes"][0]["attributes"][0]["inherited"] = True + + assert validate_schema(schema=schema).valid is True + + dumped = InfrahubSchemaWrite.model_validate(schema).model_dump() + node = dumped["nodes"][0] + assert "hierarchy" not in node + assert "inherited" not in node["attributes"][0] + + +# --------------------------------------------------------------------------- +# Any other extra field is rejected +# --------------------------------------------------------------------------- + + +@dataclass +class UnknownFieldCase: + name: str + schema: dict + # Exact dotted paths expected among the reported error fields. + expected_fields: set[str] + + +UNKNOWN_FIELD_CASES = [ + UnknownFieldCase( + name="node-unknown-field", + schema=_schema_with_node_fields(not_a_field="boom"), + expected_fields={"nodes[0].not_a_field"}, + ), + UnknownFieldCase( + name="unknown-top-level-key", + schema=_schema_with_root_fields(not_a_root_field="boom"), + expected_fields={"not_a_root_field"}, + ), + UnknownFieldCase( + name="extension-attribute-unknown-field", + schema=_extension_node_schema( + {"kind": "InfraDevice", "attributes": [{"name": "extra", "kind": "Text", "not_a_field": "boom"}]} + ), + expected_fields={"extensions.nodes[0].attributes[0].not_a_field"}, + ), + UnknownFieldCase( + name="computed-attribute-unknown-field", + schema=_schema_with_computed_attribute({"kind": "Jinja2", "jinja2_template": "x", "not_a_real_field": "x"}), + expected_fields={"nodes[0].attributes[0].computed_attribute.not_a_real_field"}, + ), + UnknownFieldCase( + name="choice-unknown-field", + schema=_schema_with_choices([{"name": "active", "not_a_real_field": "x"}]), + expected_fields={"nodes[0].attributes[0].choices[0].not_a_real_field"}, + ), + UnknownFieldCase( + name="parameters-unknown-field", + schema=_schema_with_parameters({"not_a_real_param": 1}), + expected_fields={"nodes[0].attributes[0].parameters.not_a_real_param"}, + ), + # Parameters only valid for a different attribute kind do nothing on this one, so naming them + # is the only way the author learns the setting had no effect. + UnknownFieldCase( + name="number-attribute-number-pool-parameters", + schema=_schema_with_kind_and_parameters("Number", {"start_range": 1, "end_range": 9}), + expected_fields={ + "nodes[0].attributes[0].parameters.start_range", + "nodes[0].attributes[0].parameters.end_range", + }, + ), + UnknownFieldCase( + name="text-attribute-number-parameters", + schema=_schema_with_kind_and_parameters("Text", {"min_value": 1}), + expected_fields={"nodes[0].attributes[0].parameters.min_value"}, + ), + UnknownFieldCase( + name="generic-attribute-any-parameters", + schema=_schema_with_kind_and_parameters("Dropdown", {"regex": "x"}), + expected_fields={"nodes[0].attributes[0].parameters.regex"}, + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in UNKNOWN_FIELD_CASES]) +def test_unknown_field_is_rejected_naming_the_field(case: UnknownFieldCase) -> None: + result = validate_schema(schema=case.schema) + + assert result.valid is False + assert _fields_named(result) == case.expected_fields + assert result.warnings == [] + + +def test_unknown_fields_are_reported_at_every_nesting_level_at_once() -> None: + # One pass must name every offending key rather than stopping at the first, so a payload is + # corrected in a single round. + schema = _valid_schema() + schema["not_a_root_field"] = "boom" + schema["nodes"][0]["not_a_node_field"] = "boom" + schema["nodes"][0]["attributes"][0]["not_an_attribute_field"] = "boom" + schema["nodes"][0]["relationships"][0]["not_a_relationship_field"] = "boom" + + result = validate_schema(schema=schema) + + assert result.valid is False + assert _fields_named(result) == { + "not_a_root_field", + "nodes[0].not_a_node_field", + "nodes[0].attributes[0].not_an_attribute_field", + "nodes[0].relationships[0].not_a_relationship_field", + } + + +def test_unknown_fields_are_not_reported_while_the_payload_is_otherwise_invalid() -> None: + # The validated model is what resolves the contract at each location, so a payload that fails + # validation reports that failure first and the extra fields once it is corrected. + schema = _schema_with_node_fields(not_a_field="boom") + schema["nodes"][0]["attributes"][0]["kind"] = "NotARealKind" + + result = validate_schema(schema=schema) + + assert result.valid is False + assert _fields_named(result) == {"nodes[0].attributes[0]"} + + +# --------------------------------------------------------------------------- +# Value violations are still rejected naming the field and the invalid value +# --------------------------------------------------------------------------- + + +@dataclass +class OutOfEnumCase: + name: str + schema: dict + # Exact dotted path expected among the reported error fields. For a discriminated union the + # unknown discriminator is reported against the container (attribute), not a leaf field. + expected_field: str + invalid_value: str + + +OUT_OF_ENUM_CASES = [ + OutOfEnumCase( + name="attribute-kind", + schema=_attribute_out_of_enum_kind("NotARealKind"), + expected_field="nodes[0].attributes[0]", + invalid_value="NotARealKind", + ), + OutOfEnumCase( + name="relationship-kind", + schema=_relationship_out_of_enum("kind", "NotARealKind"), + expected_field="nodes[0].relationships[0].kind", + invalid_value="NotARealKind", + ), + OutOfEnumCase( + name="relationship-cardinality", + schema=_relationship_out_of_enum("cardinality", "both"), + expected_field="nodes[0].relationships[0].cardinality", + invalid_value="both", + ), + OutOfEnumCase( + name="computed-attribute-kind", + schema=_schema_with_computed_attribute({"kind": "NotARealKind"}), + expected_field="nodes[0].attributes[0].Text.computed_attribute", + invalid_value="NotARealKind", + ), + OutOfEnumCase( + name="extension-attribute-kind", + schema=_extension_node_schema( + {"kind": "InfraDevice", "attributes": [{"name": "extra", "kind": "NotARealKind"}]} + ), + expected_field="extensions.nodes[0].attributes[0]", + invalid_value="NotARealKind", + ), + OutOfEnumCase( + name="extension-relationship-cardinality", + schema=_extension_node_schema( + {"kind": "InfraDevice", "relationships": [{"name": "peers", "peer": "InfraDevice", "cardinality": "both"}]} + ), + expected_field="extensions.nodes[0].relationships[0].cardinality", + invalid_value="both", + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in OUT_OF_ENUM_CASES]) +def test_out_of_enum_value_is_rejected_naming_field_and_value(case: OutOfEnumCase) -> None: + result = validate_schema(schema=case.schema) + + assert result.valid is False + assert case.expected_field in _fields_named(result), result.messages + assert any(case.invalid_value in message for message in result.messages), result.messages + + +def test_enum_backed_relationship_cardinality_out_of_enum_value_is_rejected() -> None: + schema = _valid_schema() + schema["nodes"][0]["relationships"][0]["cardinality"] = "both" + + result = validate_schema(schema=schema) + + assert result.valid is False + assert any("cardinality" in message for message in result.messages), result.messages + + +def test_missing_version_is_rejected() -> None: + # The load endpoint requires ``version``, so a payload without it must be reported invalid + # offline too instead of passing here and being rejected on submission. + schema = _valid_schema() + del schema["version"] + + result = validate_schema(schema=schema) + + assert result.valid is False + assert _fields_named(result) == {"version"} + + +def test_raise_on_error_raises_value_error_naming_field() -> None: + # Exercises the raise_on_error path rather than the result verdict: an out-of-enum value must + # raise a ValueError naming the offending field. + schema = _valid_schema() + schema["nodes"][0]["attributes"][0]["kind"] = "NotARealKind" + + with pytest.raises(ValueError, match=r"kind"): + validate_schema(schema=schema, raise_on_error=True) + + +def test_valid_computed_attribute_block_passes() -> None: + result = validate_schema( + schema=_schema_with_computed_attribute({"kind": "Jinja2", "jinja2_template": "{{ name }}"}) + ) + + assert result.valid is True, result.messages + + +def test_computed_attribute_jinja2_without_template_is_rejected() -> None: + result = validate_schema(schema=_schema_with_computed_attribute({"kind": "Jinja2"})) + + assert result.valid is False + assert any("jinja2_template" in message for message in result.messages), result.messages + + +def test_computed_attribute_transform_python_without_transform_is_rejected() -> None: + result = validate_schema(schema=_schema_with_computed_attribute({"kind": "TransformPython"})) + + assert result.valid is False + assert any("transform" in message for message in result.messages), result.messages + + +def test_valid_choice_passes() -> None: + result = validate_schema(schema=_schema_with_choices([{"name": "active", "color": "#aabbcc", "label": "Active"}])) + + assert result.valid is True, result.messages + + +def test_choice_bad_color_is_rejected() -> None: + result = validate_schema(schema=_schema_with_choices([{"name": "active", "color": "not-a-color"}])) + + assert result.valid is False + assert any("color" in message for message in result.messages), result.messages + + +def test_valid_text_parameters_pass() -> None: + result = validate_schema(schema=_schema_with_parameters({"min_length": 1})) + + assert result.valid is True, result.messages + + +def test_number_attribute_accepts_number_parameters() -> None: + result = validate_schema(schema=_schema_with_kind_and_parameters("Number", {"min_value": 1})) + + assert result.valid is True, result.messages + + +def test_number_pool_attribute_accepts_number_pool_parameters() -> None: + result = validate_schema(schema=_schema_with_kind_and_parameters("NumberPool", {"start_range": 1, "end_range": 9})) + + assert result.valid is True, result.messages diff --git a/uv.lock b/uv.lock index 4d4ac3167..201b6daa8 100644 --- a/uv.lock +++ b/uv.lock @@ -725,6 +725,7 @@ dev = [ { name = "invoke" }, { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jsonschema" }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, @@ -753,6 +754,7 @@ lint = [ ] tests = [ { name = "infrahub-testcontainers" }, + { name = "jsonschema" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-clarity" }, @@ -809,6 +811,7 @@ dev = [ { name = "infrahub-testcontainers", specifier = ">=1.7.3" }, { name = "invoke", specifier = ">=2.2.1" }, { name = "ipython" }, + { name = "jsonschema", specifier = ">=4.25.1" }, { name = "mypy", specifier = "==1.11.2" }, { name = "prek", specifier = ">=0.3.0" }, { name = "pytest", specifier = ">=9.0,<9.1" }, @@ -837,6 +840,7 @@ lint = [ ] tests = [ { name = "infrahub-testcontainers", specifier = ">=1.7.3" }, + { name = "jsonschema", specifier = ">=4.25.1" }, { name = "pytest", specifier = ">=9.0,<9.1" }, { name = "pytest-asyncio", specifier = ">=1.3,<1.4" }, { name = "pytest-clarity", specifier = ">=1.0.1" }, @@ -852,7 +856,7 @@ types = [ [[package]] name = "infrahub-testcontainers" -version = "1.10.6" +version = "1.10.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -862,9 +866,9 @@ dependencies = [ { name = "pytest" }, { name = "testcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/c2/4ef3e3248c8a59dce88f2efd6a2a3fa7f8efc17d6f380ae861ad6d5e674b/infrahub_testcontainers-1.10.6.tar.gz", hash = "sha256:56cd3a9855743b05402275bfe21a863ce22134de849863cd157d3da0df1d453f", size = 17943, upload-time = "2026-07-28T10:57:46.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/74/1d0debf7befd0b331c86c6c27b29ca83f40512e7725d1d42eb5974e655fc/infrahub_testcontainers-1.10.8.tar.gz", hash = "sha256:d88af9f57dbb895146b15b5fc7c9d9b521e769bceb74f98fcec071c7636e0634", size = 18782, upload-time = "2026-08-14T18:56:18.537Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/8f/8cf61d25a3a8b8e8f643d12c059cbe6c941297adee2cc7a211935806a03b/infrahub_testcontainers-1.10.6-py3-none-any.whl", hash = "sha256:949e5c30b2049710ad0e22a5a5423743ce69366ac015c98656a4abf850805400", size = 24215, upload-time = "2026-07-28T10:57:45.609Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/8b6028c3c2e4f4ae781a21374927c4ee9769b4cb9b5779ce6172db11c3af/infrahub_testcontainers-1.10.8-py3-none-any.whl", hash = "sha256:c5b21daf527e8e51c58b91fb203c916fad9848c4073249a93b3aa21e15f6a9d2", size = 25073, upload-time = "2026-08-14T18:56:17.522Z" }, ] [[package]]