diff --git a/.agents/rules/code-comments.md b/.agents/rules/code-comments.md index f1740c405..71bf49f29 100644 --- a/.agents/rules/code-comments.md +++ b/.agents/rules/code-comments.md @@ -17,4 +17,11 @@ Do not reference specs, tasks, plans, tickets, issue numbers, user stories, or t ## Type-ignore comments -Use the standard `# type: ignore[]` form (for example `# type: ignore[arg-type]`), never `# ty: ignore`. Only `# type: ignore[...]` is honored by mypy, pyright, and pytype, so a `# ty:` comment suppresses nothing and is dead. +Use the standard `# type: ignore[]` form (for example `# type: ignore[arg-type]`), never `# ty: ignore`. +Inline comments carry mypy codes only. + +`ty` does not read a bare mypy code as a suppression; it wants either `# ty: ignore[]` or a +`ty:`-prefixed code inside the standard brackets (`# type: ignore[arg-type, ty:invalid-argument-type]`). +Do not use either form. Remaining `ty` violations belong in `[[tool.ty.overrides]]` in `pyproject.toml`, +scoped to the narrowest file or glob that covers them and annotated with the violation count so they can be +retired incrementally. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcc956777..1d014d312 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,6 +310,215 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # ------------------------------------------ Dependency Bounds ------------------------------------------ + # `unit-tests` installs what uv.lock pins, so it never exercises the ranges in pyproject.toml. + # Upper bounds are tracked separately: an upstream release could turn such a job red on its own. + + # Deterministic enough to gate on, since floors only move when pyproject.toml does. Development + # dependencies mask some floors; `dependency-lower-bounds-no-harness` below reaches those. + dependency-lower-bounds: + env: + # workaround for Rich table column width + COLUMNS: 140 + if: | + always() && !cancelled() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + needs.files-changed.outputs.python == 'true' + needs: ["prepare-environment", "files-changed", "yaml-lint", "python-lint"] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: "Check out repository code" + uses: "actions/checkout@v7" + # Oldest supported interpreter: pyarrow 14, pyyaml 6.0 and ujson 5.0 have no wheels past + # cp312, so a newer one would build them from source. Bump when requires-python changes. + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.10" + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + version: "${{ needs.prepare-environment.outputs.UV_VERSION }}" + python-version: "3.10" + # Rewrites uv.lock in the checkout, which is discarded with the runner. + - name: "Re-resolve at the declared lower bounds" + run: uv lock --resolution lowest-direct + - name: Install dependencies + run: uv sync --frozen --all-groups --all-extras + - name: "Report the resolved versions" + run: uv pip list + - name: Unit Tests + run: uv run --frozen pytest --cov infrahub_sdk tests/unit/ + + # Proves a plain install works on every supported interpreter, which the uv.lock-based jobs cannot. + install-matrix: + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + if: | + always() && !cancelled() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + needs.files-changed.outputs.python == 'true' + needs: ["prepare-environment", "files-changed", "yaml-lint", "python-lint"] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: "Check out repository code" + uses: "actions/checkout@v7" + with: + # hatch-vcs derives the version from git tags, and a shallow clone has none. + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + version: "${{ needs.prepare-environment.outputs.UV_VERSION }}" + python-version: ${{ matrix.python-version }} + - name: "Build the wheel" + run: uv build --wheel --out-dir dist/ + # Not `uv sync`: a fresh resolution of the built wheel is what a user actually gets. + - name: "Install the wheel with every extra" + run: | + uv venv --python ${{ matrix.python-version }} .venv-install + uv pip install --python .venv-install "$(echo dist/*.whl)[all]" + - name: "Report the resolved versions" + run: uv pip list --python .venv-install + # Run from elsewhere: `python -` puts the working directory on sys.path, so from the checkout + # this would import the source tree rather than the wheel. + - name: "Import every shipped module" + working-directory: ${{ runner.temp }} + run: | + ${{ github.workspace }}/.venv-install/bin/python - <<'PY' + import importlib, pkgutil, sys + import infrahub_sdk + + failures = [] + for module in pkgutil.walk_packages(infrahub_sdk.__path__, "infrahub_sdk."): + try: + importlib.import_module(module.name) + except BaseException as exc: # noqa: BLE001 + failures.append(f"{module.name}: {type(exc).__name__}: {exc}") + for failure in failures: + print(f"FAIL {failure}") + print(f"{len(failures)} module(s) failed to import") + sys.exit(1 if failures else 0) + PY + - name: "Run the CLI" + run: | + .venv-install/bin/infrahubctl --help + .venv-install/bin/infrahubctl schema --help + .venv-install/bin/infrahubctl branch --help + + # With no dependency groups, nothing pulls pytest, pydantic, anyio or typing-extensions above + # their declared floors, so this is the only job that validates those four. httpx and + # graphql-core stay out of reach: ariadne-codegen requires httpx>=0.28 and graphql-core>=3.2. + # It cannot run the unit suite without the harness, so it checks the surface a user touches. + dependency-lower-bounds-no-harness: + if: | + always() && !cancelled() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + needs.files-changed.outputs.python == 'true' + needs: ["prepare-environment", "files-changed", "yaml-lint", "python-lint"] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: "Check out repository code" + uses: "actions/checkout@v7" + with: + # hatch-vcs derives the version from git tags, and a shallow clone has none. + fetch-depth: 0 + # The oldest supported interpreter, for the same wheel-availability reason as the job above. + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.10" + - name: Install UV + uses: astral-sh/setup-uv@v7 + with: + version: "${{ needs.prepare-environment.outputs.UV_VERSION }}" + python-version: "3.10" + - name: "Build the wheel" + run: uv build --wheel --out-dir dist/ + # From pyproject.toml, not the wheel: `--resolution lowest-direct` only lowers what is named + # on the command line, so asking for the wheel leaves its dependencies at their newest. + - name: "Resolve the extras at their declared lower bounds" + run: | + uv pip compile pyproject.toml --extra ctl --extra testing \ + --resolution lowest-direct --python-version 3.10 -o floors.txt + - name: "Install the wheel against those floors, with no dependency groups" + run: | + uv venv --python 3.10 .venv-floors + uv pip install --python .venv-floors -r floors.txt + uv pip install --python .venv-floors --no-deps "$(echo dist/*.whl)" + - name: "Report the resolved versions" + run: uv pip list --python .venv-floors + # From outside the checkout, so this tests the wheel in site-packages and not the source tree. + - name: "Import every shipped module" + working-directory: ${{ runner.temp }} + run: | + ${{ github.workspace }}/.venv-floors/bin/python - <<'PY' + import importlib, pkgutil, sys + import infrahub_sdk + + # `testing.docker` needs the testcontainers extra, which is deliberately absent here. + skip = {"infrahub_sdk.testing.docker"} + failures = [] + for module in pkgutil.walk_packages(infrahub_sdk.__path__, "infrahub_sdk."): + if module.name in skip: + continue + try: + importlib.import_module(module.name) + except BaseException as exc: # noqa: BLE001 + failures.append(f"{module.name}: {type(exc).__name__}: {exc}") + for failure in failures: + print(f"FAIL {failure}") + print(f"{len(failures)} module(s) failed to import") + sys.exit(1 if failures else 0) + PY + - name: "Run the CLI" + run: | + .venv-floors/bin/infrahubctl --help + .venv-floors/bin/infrahubctl schema --help + .venv-floors/bin/infrahubctl branch --help + # Collection is where the plugin does its work; importing it would prove far less. + - name: "Collect a test through the bundled pytest plugin" + working-directory: ${{ runner.temp }} + run: | + rm -rf plugin-check && mkdir -p plugin-check/templates && cd plugin-check + cat > infrahub_config.yml <<'YAML' + --- + jinja2_transforms: + - name: bgp_config + description: "Template for BGP config base" + query: "bgp_sessions" + template_path: "templates/bgp_config.j2" + YAML + cat > test_bgp.yml <<'YAML' + --- + infrahub_tests: + - resource: "Jinja2Transform" + resource_name: "bgp_config" + tests: + - name: smoke + spec: + kind: "jinja2-transform-smoke" + YAML + printf 'router bgp {{ asn }}\n' > templates/bgp_config.j2 + # The default shell is `bash -e` without pipefail, so a pytest failure would otherwise + # be reported as a grep failure. + set -o pipefail + ${{ github.workspace }}/.venv-floors/bin/python -m pytest \ + --infrahub-repo-config=infrahub_config.yml --strict-markers --collect-only -q \ + | tee collected.txt + grep -q "infrahub_jinja2_transform__bgp_config__smoke" collected.txt + # ------------------------------------------ Integration Tests ------------------------------------------ integration-tests-latest-infrahub: if: | @@ -450,6 +659,9 @@ jobs: - validate-documentation-style - unit-tests - integration-tests-latest-infrahub + - dependency-lower-bounds + - dependency-lower-bounds-no-harness + - install-matrix runs-on: "ubuntu-latest" timeout-minutes: 5 steps: diff --git a/AGENTS.md b/AGENTS.md index c8e56e5a4..a4b43ef4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,22 @@ infrahub_sdk/ └── pytest_plugin/ # Custom pytest plugin ``` +## Branches + +| Branch | Holds | +|--------|-------| +| `stable` | The released SDK and its fixes | +| `develop` | SDK features that work with the current Infrahub version | +| `infrahub-release-x.y` | The SDK that ships with Infrahub `x.y`; created from `infrahub-develop` when Infrahub cuts `release-x.y` | +| `infrahub-develop` | SDK changes that need the next Infrahub version | + +The Infrahub repository pins `python_sdk` to the branch that matches its own: `stable` to `stable`, +`release-x.y` to `infrahub-release-x.y`, `develop` to `infrahub-develop`. + +A bot opens a pull request to sync each branch into the next one, in this order: +`stable` → `develop` → `infrahub-release-x.y` → `infrahub-develop`. The rules are in +[opsmill/infrahub-github-bot `config.yaml`](https://github.com/opsmill/infrahub-github-bot/blob/main/config.yaml). + ## Markdown style When editing `.md` or `.mdx` files (`uv run invoke lint-docs` enforces these): diff --git a/CHANGELOG.md b/CHANGELOG.md index a4363ac6e..59e3cd3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,192 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang +## [1.24.0](https://github.com/opsmill/infrahub-sdk-python/tree/v1.24.0) - 2026-11-09 + +### Security + +- Raised the `graphql-core` requirement from `>=3.1` to `>=3.2.12`, so installing the SDK can no longer resolve a release affected by CVE-2026-75507 or CVE-2026-75508. Both are denial-of-service bugs: one in the validation rule that checks for conflicting fields, which `infrahubctl graphql generate-return-types` runs, and one in the parser, which ignored comments when counting tokens. With 3.2.12 or later, a query that would make field-conflict validation run for minutes now fails validation with an error instead. + +### Removed + +- `numpy` and `mdxify` are no longer installed by the `ctl` and `all` extras. + + Neither is used by the SDK. `numpy` was only ever needed because older `pyarrow` releases required it, and `pyarrow` still installs it itself on the versions that do. `mdxify` is a documentation tool used when building these docs, not at runtime. + + If your project imports either package directly, add it to your own dependencies rather than relying on `infrahub-sdk[ctl]` to supply it. + +### Deprecated + +- Deprecated support for Python 3.10. Python 3.10 is end of life, and `infrahub-sdk` 1.24.0 is the last release that supports Python 3.10. The next release removes that support. + +### Added + +- A `CoreNumberPool` can be created with its ranges in one call, by passing `ranges` as a list of dictionaries with `start`, `end` and `allocation_weight`, any of which can be left out. Saving with `allow_upsert=True` sets the ranges of an existing pool to the declared list. Before, the SDK sent each range as an empty object, which Infrahub read as one range without a start or an end. A single range of an existing pool is still created, changed or deleted through its `CoreNumberPoolRange` node. An object file declares the same list under `ranges`, as objects holding the bounds, on a new pool and on one that already exists. Requires Infrahub 1.12 or later. +- Add the `connect_timeout` setting (`INFRAHUB_CONNECT_TIMEOUT`, default 10 seconds) so the TCP/TLS connection phase of every request is bounded separately from the request `timeout`. An unreachable address now fails fast instead of consuming the whole request budget, which lets endpoints published through DNS round robin fall over to the next address quickly. +- Add the `infrahubctl troubleshooting trace-sample-ratio` commands to read, set and clear the temporary trace sampling ratio of an Infrahub instance. +- Add the `shared` option to `GitRepo` in `infrahub_sdk.testing.repository`. With `shared=True`, every user on the machine can read and write the Git repository that `GitRepo` creates, so Infrahub containers that run as another user than the test process can fetch from it and push to it. The default, `False`, keeps the current behavior. +- Added support for Python 3.15. The `requires-python` range now allows 3.15, and the unit tests run on 3.15 in CI. +- Added the `only` argument to `get`, `all`, `filters` and relationship `fetch`, to fetch only the named attributes and relationships; reading a field that was not fetched raises `FieldNotLoadedError`, and `is_loaded` tells whether a field was fetched. +- Added the location, the received value and the reason as separate fields (`loc`, `input`, `reason`) on every schema validation error, next to the unchanged `field` and `message`, so a consumer can report each problem without parsing the message text. +- Codes in Infrahub's error catalogue now raise an exception class of their own, importable from `infrahub_sdk.exceptions`, carrying the failure's payload as directly typed attributes. Identifying a specific failure no longer means matching words in a message: + + ```python + from infrahub_sdk.exceptions import ApiError, UniquenessViolationError + + try: + await node.save() + except UniquenessViolationError as exc: + print(exc.node_kind, exc.fields) # "TestPerson", ["name"] + except ApiError as exc: + print("some other failure:", exc.code) + ``` + + The new classes are `AttributeConstraintViolationError`, `AttributeInvalidTypeError`, `AttributeRequiredError`, `BranchAlreadyMergedError`, `BranchNeedsRebaseError`, `MergeInProgressError`, `MergeRecoveryRequiredError`, `UndefinedError`, and `UniquenessViolationError`. Each is typed exactly as the catalogue declares the payload, so a required field is never optional and needs no guard. They all descend from `GraphQLError`, so no `except` clause stops catching what it catches today. `NODE_NOT_FOUND`, `BRANCH_NOT_FOUND` and `SCHEMA_NOT_FOUND` reach the classes the SDK already had for them, listed under Changed. + + `exc.http_status` reads the status the envelope declares. Where the envelope omits one, a generated class supplies the status the catalogue gives its code; the three lookup-miss classes declare none, since they are also raised with no server behind them, and stay `None` there. + + `AUTHENTICATION_REQUIRED`, `TOKEN_EXPIRED`, and `PERMISSION_DENIED` deliberately have no class of their own: each of them reaches the SDK on two transports, and which generic class it raises follows the transport the SDK observed rather than the status the code declares. Catch `ApiError` and test `exc.code` to handle one of the three whichever way it arrived. + + A server predating a code, or one whose payload does not match what the catalogue declares for it, still raises the generic class for the transport with `exc.code` readable, so an SDK of any version keeps working against a server of any version. + + `docs/python-sdk/topics/error_handling` covers the hierarchy, the attributes readable on a caught error, and the cross-version guarantees. +- Number attributes have `attach_to_pool()` and `detach_from_pool()`. `attach_to_pool()` makes a number pool track the number the attribute holds, without allocating a new one, and `detach_from_pool()` makes the pool stop tracking it while the attribute keeps its number. Both take effect when the node is saved, and require Infrahub 1.12 or later. +- Object files can now reference an existing node through a relationship whose peer is a generic, by giving the node's `kind` and `hfid`. When the generic has no HFID, a reference can also be an HFID alone, and the load uses the single node that matches it among the kinds that inherit from the generic. The load fails when no node or more than one node matches. +- Resource pool nodes have `get_pool_allocations()`, which lists the resources a pool holds and, for a number pool, whether the pool allocated each number or a user attached it. Requires Infrahub 1.12 or later. `get_pool_allocated_resources()` is unchanged. +- The SDK sends the W3C `traceparent` header with each request to Infrahub when OpenTelemetry is installed and a span is active, so the Infrahub server records its spans in the caller's trace. The new `tracing` extra installs the OpenTelemetry API. +- The schema models accept `parameters.ranges` on NumberPool attributes, a list of ranges with an optional `start`, `end` and `weight`; a range without a `start` or an `end` is open on that side. The `start_range`/`end_range` shorthand is now optional and deprecated. `ranges` is `None` when a NumberPool attribute declares no range, which Infrahub reads as one range without a start or an end, and an empty list declares no range. +- `query` and `variables` are now readable on every `ApiError`, not only on the GraphQL branch. Reading either off an `AuthenticationError` previously raised `AttributeError`: + + ```python + try: + await client.execute_graphql(query=query) + except ApiError as exc: + log.error("request failed", code=exc.code, query=exc.query) # no longer raises on a 401 + ``` + + That clause is the one the SDK recommends for the three authentication codes, since each of them can arrive either on a real 401 or 403 or inside a GraphQL `errors` array, so it is exactly where the attributes had to exist. They join `code`, `http_status`, `extensions` and `errors`, which were already declared there for the same reason. + + `None` means the failed request was not recorded on the exception rather than that there was no query: an authentication failure is observed at the transport, which has neither to hand. Nothing that reads these attributes today changes - `GraphQLError` still populates both from the request it was raised for. + +### Changed + +- `InfrahubGroupContext.delete_unused()` and its sync counterpart no longer raise. They attempt every unused member and return a `ReapResult`, importable from `infrahub_sdk.query_groups`, that lists the members the server refused to delete, the members the cleanup never reached, and the failure that stopped it, if any. A caller that invoked `delete_unused()` directly and relied on it raising must now inspect that result. `update_group()` and the tracking context manager still raise, as described below. + + Leaving a tracking context whose cleanup was refused now raises `TrackingGroupCleanupError` once every member has been attempted and the tracking group has been written, in place of the first `GraphQLError`. If the group write itself fails, its error is raised instead. `TrackingGroupCleanupError` is not a `GraphQLError`, so a caller that caught the cleanup failure with `except GraphQLError` must catch it explicitly. A cleanup stopped by a failure that is not about a member, such as a timeout, still raises that failure itself. ([#572](https://github.com/opsmill/infrahub-sdk-python/issues/572)) +- Generators, transforms and checks run without an explicit branch now target the configured `default_branch` (`INFRAHUB_DEFAULT_BRANCH`, `main` by default) instead of the active branch of the local Git checkout. If you run `infrahubctl transform`, `generator` or `check` locally and relied on them following your Git branch, set `INFRAHUB_DEFAULT_BRANCH_FROM_GIT=true` or pass `--branch` explicitly. ([#1290](https://github.com/opsmill/infrahub-sdk-python/issues/1290)) +- A failure the server's error catalogue describes now carries a message naming that code and the server's own words, in place of the query text. `GraphQLError` previously rendered as `An error occurred while executing the GraphQL Query , ` and now reads `UNIQUENESS_VIOLATION: Node of kind TestPerson already has name 'John'`; `AuthenticationError` reads `AUTHENTICATION_REQUIRED: `. On both transports the code names the first error only, which is the one that determines the exception raised; the complete list stays on `exc.errors`, and the query and variables stay on `exc.query` and `exc.variables`. + + A failure the catalogue does **not** describe keeps today's message exactly, query text and full error list included, as does one of the lookup-miss classes raised without a server behind it. That covers a server predating the catalogue, an error carrying no `extensions`, and, since a current server codes every error it reports, anything the server coded `UNDEFINED_ERROR`. `exc.code` is readable in every case, so it is not the test for which message form you are holding: `code_names_the_failure(exc.code)`, importable from `infrahub_sdk.exceptions`, is. + + In `infrahubctl`, a described failure is now reported by its code and message rather than prefixed with `Authentication failure:` or rendered as a bare error list. An undescribed one renders exactly as before, so a GraphQL validation error still shows the line and column it failed on. Code matching on any of these strings should branch on `exc.code` instead. +- A lookup miss the server reports - `NODE_NOT_FOUND`, `BRANCH_NOT_FOUND` or `SCHEMA_NOT_FOUND` - now raises `NodeNotFoundError`, `BranchNotFoundError` or `SchemaNotFoundError` built from the payload the server sent, where it previously raised a generic `GraphQLError`: + + ```python + try: + await client.delete(kind="NetworkDevice", id=device_id) + except NodeNotFoundError: + ... # already gone + ``` + + These three are the only catalogued classes the SDK also raises on its own, for a lookup that returned nothing and for the REST 404 behind a missing file. A ladder that handles one of them specifically will now see server-reported failures arrive there alongside the SDK's own, and `exc.code is not None` tells the two apart. +- Authentication failures now surface the server's error envelope. `AuthenticationError` and `GraphQLError` share a new `ApiError` base carrying `code`, `http_status`, `extensions`, and `errors`, so a caller can branch on the server's catalogue code instead of matching on message text. + + A 401 or 403 whose body the SDK cannot read as an error envelope now raises `AuthenticationError` carrying the best reason available: the REST API's bare `detail` string where the body has one, and otherwise the plain status. Previously the same responses raised `JsonDecodeError` (or, from the object store and file handler, a raw `json.JSONDecodeError`) when the body was not JSON, and `TypeError` when the body carried an `errors` array whose entries had no `message`. A body that was JSON but carried no `errors` key raised `AuthenticationError` with its generic default message, dropping the status the server sent. Code that catches those types around `object_store`, `file_handler`, or a client request should catch `AuthenticationError` instead. + + `from infrahub_sdk.exceptions import *` now yields exactly the exception classes. It previously also carried whatever the module imported for its own annotations, such as `Mapping` and `Any`. Every exception class keeps its name and its import path. +- Changed `number_pool_id` in the parameters of a NumberPool attribute to a read-only field: schema validation now warns that the value is ignored instead of accepting it. +- Generated protocols are now ordered by the class name they render rather than by the schema name without its namespace, so `BuiltinIPPrefix` no longer sorts under `IPPrefix`. Regenerating with `infrahubctl protocols` reorders the classes in your file and changes nothing else. The ordering is also total now. Kinds that the old key compared equal, two sharing a name and differing only in namespace, or two whose names differ only in case, previously fell back to whatever order the schema was supplied in, which could make the same schema render differently from one run to the next. +- Raised the `rich` requirement from `>=12` to `>=12.6`, the first release that honours `FORCE_COLOR`. Earlier releases ignore the variable, so the test suite's rendering guard could not describe them; nothing in the SDK itself changes. +- The `rich` requirement is now `>=12.6` with no upper bound, so the SDK can be installed alongside rich 14 and later. + + The previous `<14` cap would otherwise have applied to every install rather than only those using the `infrahubctl` CLI, and would have conflicted with any project already on a newer rich. The SDK's unit tests pass against rich 13, 14 and 15. +- The protocols shipped in `infrahub_sdk.protocols` now carry the peer type of every relationship, so `node.rel.peer` and `node.many_rel.peers[0].peer` resolve to the peer's protocol instead of a bare `InfrahubNode`, and a cardinality-one relationship can be assigned an id, an HFID, a peer node or `None`. They are generated by the same renderer as `infrahubctl protocols` rather than by a separate template, which also adds the relationships the API exposes implicitly (`member_of_groups`, `subscriber_of_groups`, `profiles`) and the `ProfileBuiltinIPAddress`, `ProfileBuiltinIPPrefix`, `ProfileBuiltinTag` and `ProfileIpamNamespace` kinds. Attributes that always hold a value are no longer typed as optional, and each attribute now follows its schema kind, so `CoreGlobalPermission.decision` and `CoreObjectPermission.decision` are `Integer` rather than `Enum`. Two relationships that the API never exposed, `CoreGenericAccount.tokens` and `CoreGenericAccount.external_identities`, are gone. Protocols generated for your own schema now import the four `Profile*` kinds above instead of redefining them. +- Two extras now cover testing, replacing the `tests` extra. + + `pip install 'infrahub-sdk[testing]'` installs pytest and enables the bundled `pytest-infrahub` plugin, which is what you want to test your own Transforms, Queries and Checks. `pip install 'infrahub-sdk[testcontainers]'` additionally provides `infrahub_sdk.testing.docker`, which starts a real Infrahub in containers so your tests can run against a live instance instead of mocked responses. + + They are kept apart because the container tooling is a much heavier install. `[testing]` adds four packages on top of a plain install; `[testcontainers]` adds around sixty, among them Docker, FastAPI and Prefect client libraries. Previously you would have had to take all of it to get either. + + The `tests` extra is gone. It stopped installing anything in 1.16.0 while the documentation carried on advertising it, so since then `pip install 'infrahub-sdk[tests]'` has warned about an unknown extra and installed only the base package. If you are coming from 1.15.2 or earlier, `[testing]` is the direct replacement for what `[tests]` gave you. +- `NodeNotFoundError.identifier` is now annotated `Mapping[str, list[str]] | str`. The SDK already raised it with a plain string to name a missing file, so this documents behaviour that was always there; no runtime behaviour changes and no existing caller needs updating. +- `NodeNotFoundError`, `BranchNotFoundError`, and `SchemaNotFoundError` now descend from `GraphQLError`, so that one class covers a lookup miss however it arose: reported by the server, decided by the SDK, or turned from a REST 404. + + An `except GraphQLError` clause therefore also catches lookup misses that involved no GraphQL request at all. Code that relied on those escaping such a clause should catch the specific class ahead of it, as an ordered `except` ladder already must. Each class keeps its name, its constructor, and the message it produces when no server reported the failure, and `errors`, `query`, and `variables` are now readable on every one of them rather than missing on a client-side raise. + + `NodeInvalidError` inherits the re-rooting but not the adopted code: it means a node of the wrong kind rather than a lookup miss, so `NodeInvalidError.CODE` is `None` where `NodeNotFoundError.CODE` is `NODE_NOT_FOUND`. +- `ObjectFile.validate_content()` and `MenuFile.validate_content()`, their `spec` properties, and `InfrahubFile.validate_content()` now raise `infrahub_sdk.exceptions.ValidationError` when a file has one of these problems: + + - a `kind` of the other file type, such as `kind: Menu` in an object file, which previously raised `ValueError` + - an unknown `kind` or `apiVersion`, or a missing `spec`, which previously raised a pydantic `ValidationError` + - a `spec` that does not match the object or menu format, which previously raised a pydantic `ValidationError` for menu files + + The exception's `identifier` is the file location. `messages` holds one line per problem, such as `kind | Input should be 'Menu' or 'Object', received 'Schema' (enum)`. The line names the field and includes the wrong value when that value is a single string, number or boolean. `infrahubctl object load`, `object validate`, `menu load` and `menu validate` therefore report these problems as a validation error for the file instead of stopping with a Python traceback. `infrahubctl` also no longer drops text in square brackets from these messages. + + The SDK `ValidationError` does not subclass `ValueError` or the pydantic `ValidationError`. Code that catches either of those around these methods should catch `infrahub_sdk.exceptions.ValidationError` instead. +- `add()`, `extend()` and `remove()` on the `ranges` relationship of a `CoreNumberPool` now raise `FeatureNotSupportedError`, and `infrahubctl object update --set ranges=...` reports the same refusal before it changes anything. Before, `add()` and `extend()` dropped the range without an error, and `remove()` sent the remaining ranges as references, which Infrahub refused with a GraphQL error. To set the ranges of an existing pool, pass the complete list of ranges to `client.create()` and save the pool with `allow_upsert=True`, or declare them in an object file. To add, change or delete a single range, create, update or delete its `CoreNumberPoolRange` node. +- `infrahub-sdk` now requires `pydantic>=2.0.3`, up from `>=2.0.0`. + + If you pin `pydantic` to 2.0, 2.0.1 or 2.0.2, installing the SDK now fails while resolving dependencies. Those versions never actually worked: `import infrahub_sdk` raised a `SchemaError` on them, because their regex engine rejects a pattern used by the schema models. Pin `pydantic>=2.0.3` to resolve it. + + `anyio` and `typing-extensions` are now installed as direct requirements of the SDK itself. It has always imported them but relied on other packages to pull them in, so a minimal or heavily constrained environment could end up with the SDK installed and unusable. No action is needed, installs simply become reliable. + + `packaging` is now a direct requirement of the `testcontainers` extra, which is where the `infrahub_sdk.testing.docker` helper that imports it lives. A plain `pip install infrahub-sdk` does not install it. + +### Fixed + +- Tracking groups now reconcile correctly when a run saves no nodes at all. Previously `update_group()` returned early on an empty member list, so a generator that produced nothing (a decommissioning run) or a repository whose last object file was removed left every previously tracked node behind as an orphan, still listed in the group. With `delete_unused_nodes=True`, as generators and repository imports use it, a run that tracks nothing but has an existing group now prunes it; a run that tracks nothing and has no group still creates none. With `delete_unused_nodes=False` the existing group is not read, so a run that tracks nothing still leaves it unchanged. + + The cleanup now deletes unused members on the branch the tracking context targets. It previously deleted them on the client's default branch, so on any other branch it removed the wrong node or found nothing to remove. + + Cleanup is also no longer aborted by a single refused delete. `delete_unused()` attempts every unused member and reports the refusals together as `TrackingGroupCleanupError` instead of propagating the first one and silently skipping the rest. Members that could not be deleted are kept in the tracking group so a later run retries them. A refusal that persists, such as a node outside the run that still points at the member through a mandatory relationship, is raised again on every run until whatever blocks the deletion is removed; previously the member dropped out of the group after the first failure and stayed behind unnoticed. + + A failure that is about the request rather than the member being deleted, such as a timeout, an expired token, or a branch that is gone or locked by a merge, stops the cleanup and is raised as itself rather than recorded against every remaining member in turn. The tracking group write is still attempted before that failure surfaces, listing the members the cleanup never reached along with the nodes the run created, so an interrupted cleanup no longer leaves those nodes in no group at all whenever the server can still accept the write. The member whose delete was interrupted stays in the group when the server answered, since a rejected delete removed nothing. When no answer came back, such as on a timeout, the delete may have gone through, so that member is left out of the group; if the delete in fact did not happen, it stays behind as before. When the write fails as well, usually for the same reason, the failure that stopped the cleanup is the one raised, with the write's failure as its cause. + + `infrahubctl` renders `TrackingGroupCleanupError` as a table of each member and the server's reason instead of a traceback, and the exception can be serialized so it survives a task orchestrator's failure handling. ([#572](https://github.com/opsmill/infrahub-sdk-python/issues/572)) +- Sync protocols generated by `infrahubctl protocols` now inherit the `*Sync` variant of every base class that has one, instead of only `CoreNode`, `CoreProfile` and `CoreObjectTemplate`. A class inheriting another core kind (for example `BuiltinIPAddress` or `LineageSource`) previously kept the async base in its sync output, so its `save`, `delete` and `create` methods were typed as async and a correct sync call was reported as a missing `await`. Regenerate with `infrahubctl protocols`: sync classes that inherited an async base will now expose the sync methods, which may surface new type errors in code that relied on the old async signatures. ([#1276](https://github.com/opsmill/infrahub-sdk-python/issues/1276)) +- Fixed `infrahubctl generator`, which exited with `The node doesn't have an attribute for name` instead of running when the target group contained members of a kind without a `name` attribute. The generator definition parameters are now resolved the same way Infrahub resolves them when it runs a generator, so those targets work and every declared parameter reaches the query instead of only the first one. ([#1289](https://github.com/opsmill/infrahub-sdk-python/issues/1289)) +- Fixed `infrahubctl generator`, `transform` and `check` failing with `BranchNotFoundError` when run without `--branch` while the active local Git branch did not exist in Infrahub. ([#1290](https://github.com/opsmill/infrahub-sdk-python/issues/1290)) +- Fixed a requested branch being replaced by the local Git branch when `default_branch_from_git` is enabled. This affected `client.clone(branch=...)`, generators and transforms created with `branch=...`, and the `--branch` option of `infrahubctl`. Calls that did not pass a branch went to the Git branch, so a generator run with `--branch` could read from the requested branch and create or update nodes on the Git branch. ([#1295](https://github.com/opsmill/infrahub-sdk-python/issues/1295)) +- Corrected the return type annotations of `rebase()` and `validate()` on both `InfrahubClient.branch` and `InfrahubClientSync.branch`. They were declared as `-> BranchData` but have always returned the mutation's `ok` boolean, so the annotations now read `-> bool` to match the actual behaviour and align with `merge()`. ([#1308](https://github.com/opsmill/infrahub-sdk-python/issues/1308)) +- Fixed `get_diff_summary()` reporting its request under the GraphQL `operationName` `GetDiffTree`, the same name used by `get_diff_tree()`. The summary query now sends `GetDiffSummary`, so the two methods are distinguishable in server-side query logs, traces, and observability tooling keyed on `operationName`. ([#1313](https://github.com/opsmill/infrahub-sdk-python/issues/1313)) +- Fixed `fetch()` raising `ValueError` on the `ancestors` and `descendants` of a node that was retrieved without them, on both the async and the sync client. ([#1425](https://github.com/opsmill/infrahub-sdk-python/issues/1425)) +- A `from_pool` request is sent once: after a successful save, the next update no longer sends it again. +- A client authenticating with an API token no longer replays a request after a 401 that reports an expired token. Only a client configured with a username and password can obtain a new token, so on the request paths that retry automatically the retry sent the same rejected token a second time, doubling the cost of the failure. Streaming downloads never retried and are unaffected. +- A file download answered with HTTP 404 now raises `NodeNotFoundError` whatever the response body contains. Previously a body that was not a JSON object - an HTML error page from an intermediary, an empty body, or a JSON array - escaped as a raw `json.JSONDecodeError` or `AttributeError` instead. +- An attribute created with both `value` and `from_pool` now sends both, so the number pool tracks the given number instead of allocating a new one. +- Fixed `fetch()` on a cardinality-many relationship that was not loaded, such as `children`, replacing the node in the client store with a copy that has no attribute values, on both the async and the sync client. `fetch()` on a node that has not been saved now raises `UninitializedError` instead of `ValueError: At least one filter must be provided to get()`; code that catches `ValueError` for this case must catch `UninitializedError` instead. +- Fixed an `AttributeError` escaping the client when a 401 response carried a body that was valid JSON but not an object, such as the bare array or string a proxy or gateway may return. The silent token refresh now treats any body it cannot read as an envelope as carrying no refresh signal, and the request surfaces `AuthenticationError` as it does for every other unreadable 401. +- Fixed peers given as `RelatedNode` objects, such as a peer taken from another node's relationship, being stored with the `RelatedNode` object as their ID, which made the save request invalid and made `remove()` remove nothing. This affected lists of peers for a cardinality-many relationship, `add()` and `remove()` on a cardinality-many relationship, and assignment to a cardinality-one relationship. +- Parts of the SDK no longer fail to import on a plain `pip install infrahub-sdk`. + + `infrahub_sdk.template`, `infrahub_sdk.yaml`, `infrahub_sdk.spec`, `infrahub_sdk.transfer` and `infrahub_sdk.protocols_generator` import Jinja2, PyYAML and rich, but those three were only installed by the `ctl` extra. Using any of them without that extra raised `ModuleNotFoundError`, even though none of it is CLI-specific: `template` renders your Transforms, and rich supplies the traceback types carried in Jinja error reporting. + + All three are now installed with the SDK itself, so a plain install grows from 19 to 26 packages. The `ctl` extra is correspondingly smaller and now covers only what `infrahubctl` genuinely needs. +- Protocols generated for a hierarchical kind no longer declare `parent` and `children` twice. When the schema already exposes them as relationships, which is the case for any schema read from the API, they were emitted once from the relationship list and once more from the hierarchy, and the second pair overrode the first. Regenerate with `infrahubctl protocols` to drop the duplicates. +- Two declared lower bounds were wrong and have been corrected. + + `typer` now requires `>=0.16.0`, up from `>=0.15.0`. Combined with the `click>=8.3` the SDK already required, typer 0.15 crashed on any `infrahubctl --help` with `TypeError: Parameter.make_metavar() missing 1 required positional argument`, because click 8.3 changed that signature and typer only adapted in 0.16. The old floor advertised a combination that could not work. + + `Jinja2` now requires `>=3.1.5`, up from `>=3`. On 3.1.4 and earlier, template error reporting points at the wrong template when a nested template uses an undefined variable, and omits the source path when an imported template is missing. + + If you pin either package below its new floor, installing the SDK now fails while resolving instead of breaking once you run it. +- `GraphQLError` and every exception under it now survive `pickle` and `copy.deepcopy` intact. An exception that crosses a process boundary - a task queue, a parallel test runner, a process pool - previously came back wrong in one of two ways, because Python rebuilds an exception by calling its class with the message as a lone positional argument. + + `GraphQLError` takes the server's error list first, so that call filed the message under `errors` and the message a caller read came back as the placeholder built from it: `An error occurred while executing the GraphQL Query None, UNIQUENESS_VIOLATION: ...` in place of the real one. The per-code classes take their payload fields as required keyword arguments, so the same call raised `TypeError: __init__() takes 1 positional argument but 2 were given` and the original failure was lost entirely. + + Both are fixed at the root of the GraphQL branch, so the type, the message, the payload attributes, `code`, `http_status`, `extensions`, `errors`, `query` and `variables` all come back as they went in, and an `except` clause still catches a restored exception by its own class. + + `NodeNotFoundError`, `BranchNotFoundError` and `SchemaNotFoundError` were affected more quietly: their attributes and `str()` came back correct, but `exc.args` was rebuilt as the class's own default sentence rather than the message the exception was raised with. `AuthenticationError`, whose constructor takes the message first, was the only one unaffected. +- `infrahubctl` no longer deletes bracketed text from an error message. A message such as `The requested branch was not found on the server [main]` was printed without the branch name, because rich read the brackets as a style tag. Everything the shared error handler prints is now escaped, including the traceback the fallback branch emits. Affects branch, schema, and node lookup misses, authentication failures, HTTP transport failures, and the fallback handler. +- `infrahubctl` no longer exits non-zero with no output when a GraphQL failure arrives in a shape the SDK cannot read as an error envelope. `infrahubctl run`, `infrahubctl validate graphql-query`, and every command wrapped by the shared error handler now fall back to printing the exception's message, which keeps the server's payload verbatim. + +### Housekeeping + +- Added two CI jobs covering the declared dependency ranges, which the existing test matrix never exercised because it installs exactly what `uv.lock` pins. One re-resolves at the declared lower bounds and runs the unit tests; the other builds the wheel and confirms it installs, imports and runs the CLI on every supported Python with a fresh resolution. Upper bounds are tracked separately, since any upstream release could otherwise turn an unrelated pull request red. +- Added unit tests over the packaging metadata so the declared dependencies stay honest. They check that every requirement has a lower bound, that the `all` extra keeps covering the other extras, and that the packages the SDK imports and the packages it declares match each other. +- Pinned Rich's colour and width for the test suite from `pytest_configure`, so CLI-output assertions render identically regardless of the developer's `FORCE_COLOR`/`COLUMNS` environment. +- Routed the two remaining `existing_group.members.peer_ids` accesses in `infrahub_sdk/query_groups.py` through the typed `_get_relationship_many(name="members")` accessor, removing the last two `# type: ignore[union-attr]` suppressions in that module. Internal typing hygiene with no behavioural change. + ## [1.23.2](https://github.com/opsmill/infrahub-sdk-python/tree/v1.23.2) - 2026-09-08 ### Added diff --git a/README.md b/README.md index b17362608..540a5f9ba 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,20 @@ The ctl extra provides the `infrahubctl` command, which allows you to intera pip install 'infrahub-sdk[ctl]' ``` -#### tests +#### testing -The tests extra provides all the components for the testing framework of Transforms, Queries and Checks. +The testing extra provides the components for the testing framework of Transforms, Queries and Checks. ```bash -pip install 'infrahub-sdk[tests]' +pip install 'infrahub-sdk[testing]' +``` + +#### testcontainers + +The testcontainers extra provides infrahub_sdk.testing.docker, which starts a real Infrahub in containers so tests can run against a live instance. + +```bash +pip install 'infrahub-sdk[testcontainers]' ``` #### all diff --git a/changelog/+connect-timeout.added.md b/changelog/+connect-timeout.added.md deleted file mode 100644 index 30294599f..000000000 --- a/changelog/+connect-timeout.added.md +++ /dev/null @@ -1 +0,0 @@ -Add the `connect_timeout` setting (`INFRAHUB_CONNECT_TIMEOUT`, default 10 seconds) so the TCP/TLS connection phase of every request is bounded separately from the request `timeout`. An unreachable address now fails fast instead of consuming the whole request budget, which lets endpoints published through DNS round robin fall over to the next address quickly. diff --git a/changelog/+generic-peer-hfid.added.md b/changelog/+generic-peer-hfid.added.md deleted file mode 100644 index 1984f51e3..000000000 --- a/changelog/+generic-peer-hfid.added.md +++ /dev/null @@ -1 +0,0 @@ -Object files can now reference an existing node through a relationship whose peer is a generic, by giving the node's `kind` and `hfid`. When the generic has no HFID, a reference can also be an HFID alone, and the load uses the single node that matches it among the kinds that inherit from the generic. The load fails when no node or more than one node matches. diff --git a/changelog/+graphql-core-floor-3-2-12.security.md b/changelog/+graphql-core-floor-3-2-12.security.md deleted file mode 100644 index 6b4ec5179..000000000 --- a/changelog/+graphql-core-floor-3-2-12.security.md +++ /dev/null @@ -1 +0,0 @@ -Raised the `graphql-core` requirement from `>=3.1` to `>=3.2.12`, so installing the SDK can no longer resolve a release affected by CVE-2026-75507 or CVE-2026-75508. Both are denial-of-service bugs: one in the validation rule that checks for conflicting fields, which `infrahubctl graphql generate-return-types` runs, and one in the parser, which ignored comments when counting tokens. With 3.2.12 or later, a query that would make field-conflict validation run for minutes now fails validation with an error instead. diff --git a/changelog/+python-3-15.added.md b/changelog/+python-3-15.added.md deleted file mode 100644 index 4dbdfdf21..000000000 --- a/changelog/+python-3-15.added.md +++ /dev/null @@ -1 +0,0 @@ -Added support for Python 3.15. The `requires-python` range now allows 3.15, and the unit tests run on 3.15 in CI. diff --git a/changelog/+related-node-peer-input.fixed.md b/changelog/+related-node-peer-input.fixed.md deleted file mode 100644 index ab5795998..000000000 --- a/changelog/+related-node-peer-input.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed peers given as `RelatedNode` objects, such as a peer taken from another node's relationship, being stored with the `RelatedNode` object as their ID, which made the save request invalid and made `remove()` remove nothing. This affected lists of peers for a cardinality-many relationship, `add()` and `remove()` on a cardinality-many relationship, and assignment to a cardinality-one relationship. diff --git a/changelog/+relationship-fetch-store.fixed.md b/changelog/+relationship-fetch-store.fixed.md deleted file mode 100644 index 2892576f4..000000000 --- a/changelog/+relationship-fetch-store.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed `fetch()` on a cardinality-many relationship that was not loaded, such as `children`, replacing the node in the client store with a copy that has no attribute values, on both the async and the sync client. `fetch()` on a node that has not been saved now raises `UninitializedError` instead of `ValueError: At least one filter must be provided to get()`; code that catches `ValueError` for this case must catch `UninitializedError` instead. diff --git a/changelog/+rich-floor-12-6.changed.md b/changelog/+rich-floor-12-6.changed.md deleted file mode 100644 index ce19abba6..000000000 --- a/changelog/+rich-floor-12-6.changed.md +++ /dev/null @@ -1 +0,0 @@ -Raised the `rich` requirement from `>=12` to `>=12.6`, the first release that honours `FORCE_COLOR`. Earlier releases ignore the variable, so the test suite's rendering guard could not describe them; nothing in the SDK itself changes. diff --git a/changelog/+structured-validation-errors.added.md b/changelog/+structured-validation-errors.added.md deleted file mode 100644 index 905e1b732..000000000 --- a/changelog/+structured-validation-errors.added.md +++ /dev/null @@ -1 +0,0 @@ -Added the location, the received value and the reason as separate fields (`loc`, `input`, `reason`) on every schema validation error, next to the unchanged `field` and `message`, so a consumer can report each problem without parsing the message text. diff --git a/changelog/1289.fixed.md b/changelog/1289.fixed.md deleted file mode 100644 index da458af63..000000000 --- a/changelog/1289.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed `infrahubctl generator`, which exited with `The node doesn't have an attribute for name` instead of running when the target group contained members of a kind without a `name` attribute. The generator definition parameters are now resolved the same way Infrahub resolves them when it runs a generator, so those targets work and every declared parameter reaches the query instead of only the first one. diff --git a/changelog/1290.changed.md b/changelog/1290.changed.md deleted file mode 100644 index ac73ad53b..000000000 --- a/changelog/1290.changed.md +++ /dev/null @@ -1 +0,0 @@ -Generators, transforms and checks run without an explicit branch now target the configured `default_branch` (`INFRAHUB_DEFAULT_BRANCH`, `main` by default) instead of the active branch of the local Git checkout. If you run `infrahubctl transform`, `generator` or `check` locally and relied on them following your Git branch, set `INFRAHUB_DEFAULT_BRANCH_FROM_GIT=true` or pass `--branch` explicitly. diff --git a/changelog/1290.fixed.md b/changelog/1290.fixed.md deleted file mode 100644 index 2dac166ab..000000000 --- a/changelog/1290.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed `infrahubctl generator`, `transform` and `check` failing with `BranchNotFoundError` when run without `--branch` while the active local Git branch did not exist in Infrahub. diff --git a/changelog/1295.fixed.md b/changelog/1295.fixed.md deleted file mode 100644 index 80cab2e7d..000000000 --- a/changelog/1295.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed a requested branch being replaced by the local Git branch when `default_branch_from_git` is enabled. This affected `client.clone(branch=...)`, generators and transforms created with `branch=...`, and the `--branch` option of `infrahubctl`. Calls that did not pass a branch went to the Git branch, so a generator run with `--branch` could read from the requested branch and create or update nodes on the Git branch. diff --git a/changelog/1308.fixed.md b/changelog/1308.fixed.md deleted file mode 100644 index 991912b07..000000000 --- a/changelog/1308.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Corrected the return type annotations of `rebase()` and `validate()` on both `InfrahubClient.branch` and `InfrahubClientSync.branch`. They were declared as `-> BranchData` but have always returned the mutation's `ok` boolean, so the annotations now read `-> bool` to match the actual behaviour and align with `merge()`. diff --git a/changelog/1313.fixed.md b/changelog/1313.fixed.md deleted file mode 100644 index d11c79611..000000000 --- a/changelog/1313.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed `get_diff_summary()` reporting its request under the GraphQL `operationName` `GetDiffTree`, the same name used by `get_diff_tree()`. The summary query now sends `GetDiffSummary`, so the two methods are distinguishable in server-side query logs, traces, and observability tooling keyed on `operationName`. diff --git a/changelog/1425.fixed.md b/changelog/1425.fixed.md deleted file mode 100644 index 9bdb7c3b4..000000000 --- a/changelog/1425.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed `fetch()` raising `ValueError` on the `ancestors` and `descendants` of a node that was retrieved without them, on both the async and the sync client. diff --git a/dev/specs/ifc-3034-error-catalogue/checklists/requirements.md b/dev/specs/ifc-3034-error-catalogue/checklists/requirements.md new file mode 100644 index 000000000..d2178078c --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/checklists/requirements.md @@ -0,0 +1,60 @@ +# Specification Quality Checklist: Error Catalogue in the Python SDK + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-21 +**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 + +Two checklist items were resolved by scoping rather than by rewriting, and the reasoning is recorded +here so the plan phase does not relitigate it: + +- **"No implementation details" / "written for non-technical stakeholders"** — for a library, the + exception hierarchy *is* the user-facing product, so class names, catalogue codes, and the + transport split are domain vocabulary rather than implementation leakage. The spec names those and + deliberately withholds module layout, file names, generator implementation, and test mechanics. + Recorded as an explicit assumption in the spec rather than left implicit. +- **"Success criteria are technology-agnostic"** — SC-001 through SC-008 are stated as outcomes a + consumer or reviewer can verify (a failure is handleable without reading a message; no string + matching remains; a stale artefact fails validation) rather than as internal mechanics. They do + reference exceptions and catalogue codes, which is unavoidable and correct for this feature. + +Two items were originally deferred to the plan and have since been pulled back into the spec, both +prompted by automated review of the pull request: + +- **The `identifier` contract on the unified `NodeNotFoundError`.** Deferring the whole question was + wrong: *which* attributes a consumer can read is observable API surface and belongs here, even + though the mechanism does not. FR-016 now pins the contract — every construction shape in use today + keeps working, the server-reported kind and identifier are reachable, one documented accessor works + for both cases, and any type widening is called out in release notes. Surveying the code for this + also turned up that the attribute is *already* heterogeneous: the file handler passes a plain string + where the declared type is a mapping. +- **Multi-error precedence.** FR-013 originally required only that a rule exist, which is untestable + until the rule does. It now specifies that the first error in the response governs, with the + complete list retained, and records why first-*recognised* was rejected: it would make the raised + type depend on binding freshness rather than on the response. diff --git a/dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md b/dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md new file mode 100644 index 000000000..b8101dcd4 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/contracts/exception-hierarchy.md @@ -0,0 +1,179 @@ +# Contract: The exception hierarchy + +The SDK's public interface here is the set of names a consumer can import from +`infrahub_sdk.exceptions`, catch, and read attributes off. This is what the change promises. + +`infrahub_sdk.exceptions` is the supported import path for every exception the SDK raises, generated or +hand-written. A consumer never needs to know which module inside it defines a given class, and the +modules beneath it are internal. Every name importable from `infrahub_sdk.exceptions` before this +change is still importable from it afterwards, pinned by a test against a committed snapshot rather +than asserted. + +## Catching + +| Intent | Clause | +|--------|--------| +| Any failure carrying a parsed error envelope, on either transport | `except ApiError` | +| Any GraphQL-path failure | `except GraphQLError` | +| Any failure the SDK observed as HTTP 401 or 403 | `except AuthenticationError` | +| One specific catalogued failure | `except UniquenessViolationError` (and so on per code) | +| One of the three authentication codes, whichever way it arrived | `except ApiError` then test `exc.code` | +| Anything the SDK raises | `except Error` | + +The hierarchy is a plain tree: `GraphQLError` and `AuthenticationError` are siblings under `ApiError`, +and no class has more than one parent. + +**The three authentication codes are the one asymmetry.** `AUTHENTICATION_REQUIRED`, `TOKEN_EXPIRED`, +and `PERMISSION_DENIED` have no class of their own, because they are the only codes that reach the SDK +on two different transports, and each transport already has a class an existing clause depends on: + +| Arrival | Class raised | `exc.code` | +|---------|--------------|------------| +| A real 401 or 403, when the failure escapes before query execution | `AuthenticationError`, as today | the catalogue code | +| Inside an HTTP 200 `errors` array, when a resolver raised it | `GraphQLError`, as today | the catalogue code | + +**Any of the three can arrive either way**, so the arrival path is a property of how the server +happened to fail, not of the code. Distinguish them by code, and catch `ApiError` unless you genuinely +want one arrival path only: + +Catch the code however it arrived — the form to reach for: + +```python +except ApiError as exc: + if exc.code == "TOKEN_EXPIRED": + ... +``` + +Or, to handle only the pre-execution arrivals from the table above, accepting that a resolver-raised +failure carrying the same code raises `GraphQLError` and escapes this clause: + +```python +except AuthenticationError as exc: + ... +``` + +These are alternatives, not a sequence. `AuthenticationError` descends from `ApiError`, so an +`except ApiError` clause placed first makes any later `except AuthenticationError` unreachable. + +So `except AuthenticationError` is not the clause that spans both arrival paths for *any* of the three +codes — `except ApiError` is. That is not a coverage loss: such a response raises `GraphQLError` today +too, so no existing clause stops catching anything it catches now. + +Every clause that worked before the change still catches what it caught before (FR-018). The +broadenings below are deliberate. They are not numbered, because adopting a further code adds one. + +- `except GraphQLError` now also catches node, branch, and schema lookup misses that involved no + GraphQL request at all — both the client-side ones and the REST 404 the file handler turns into a + `NodeNotFoundError` — because those classes are re-rooted under it. +- Code that catches a generic error to inspect its message will now sometimes receive a subclass whose + message names the code, or the same class carrying a described failure's message instead of the query + text. +- A failure the server reports under an adopted code, with a payload carrying the fields that code's + class needs, now raises that class rather than `GraphQLError`. A server-reported `NODE_NOT_FOUND` + reaches an `except NodeNotFoundError` clause that only saw client-side lookup misses before. The + specific class and `GraphQLError` are both caught by `except GraphQLError`, so no clause stops + catching what it catches now; a ladder that handles the specific class differently sees the + server-reported ones arrive there too, which is the point of adopting the code. + +## Reading a caught error + +Available on every `ApiError`: + +| Attribute | Contract | +|-----------|----------| +| `code` | The catalogue code string, or `None`. Never an integer. `None` means the SDK resolved no catalogue code — a pre-catalogue server, a REST failure, an error with no `extensions`, or an integer `code` on the wire. An unrecognised string code from a newer server is still readable here. | +| `http_status` | The status the governing error's `extensions` declares, or `None` when it declared none. This is metadata about the failure, not the status the transport observed — a catalogued data error arrives as HTTP 200, and the observed status is not carried on the exception at all. Once the generated classes land, a generated class's own declared status fills this in for a code whose envelope omitted it. `NodeNotFoundError`, `BranchNotFoundError` and `SchemaNotFoundError` declare none and so stay `None` there, because they are also raised with no catalogue code behind them. The envelope's value is the catalogue's except where the catalogue could not resolve a status more specific than 500, in which case the server substitutes the HTTP status it is about to return — so a generated class's declared 500 and the envelope's value can differ. | +| the payload's fields | Not on the base. Each catalogued class carries its payload's fields as directly typed attributes — `UniquenessViolationError.node_kind` is a `str`, `.fields` a `list[str]` — typed exactly as the catalogue declares them, so a required field is never optional and needs no guard. The three exceptions are `NodeNotFoundError`, `BranchNotFoundError`, and `SchemaNotFoundError`, whose attributes are optional because those classes are also raised with no catalogue code behind them — a client-side lookup miss, or the REST 404 that has a response but no code; guard on `exc.code is not None` there. The raw payload dict remains in `extensions["data"]` for anything forwarding it verbatim. | +| `extensions` | The raw `extensions` mapping of the governing error, or `None`. | +| `errors` | The complete server error list, unreordered — empty for a client-side raise. | +| `query`, `variables` | The GraphQL query and variables the failed request carried, where the exception recorded them. `None` means the request was not recorded, not that there was none: an authentication failure is observed at the transport, which has neither to hand. | + +`errors`, `query`, and `variables` are declared on `ApiError` itself, so they are readable on every +server-reported failure and on a purely client-side raise alike. A client-side `NodeNotFoundError` has +an empty `errors` and `None` for the rest, and so does an `AuthenticationError`, so neither +`except GraphQLError` nor the `except ApiError` clause that spans both transports has to guard for a +missing attribute. + +`UNDEFINED_ERROR` is readable on `code` like any other: it means the server explicitly reported a gap +in its own catalogue, and it is not the same as an error carrying no `extensions`. It is the one code +that never shapes the message, because it describes nothing; see [Messages](#messages). + +A current server codes **every** error it reports, falling back to `UNDEFINED_ERROR` where its +catalogue has no entry, so `exc.code is not None` is not the test for "the server described this". +`code_names_the_failure(exc.code)` is, and it is importable from `infrahub_sdk.exceptions`. + +## Cross-version behaviour + +Any SDK version talks to any server version. Parsing never raises. + +| Situation | Behaviour | +|-----------|-----------| +| A code the SDK has a class for | That class is raised, built from the envelope's payload through its own `from_payload`. Until the generated bindings land, that is the three adopted codes (`NODE_NOT_FOUND`, `BRANCH_NOT_FOUND`, `SCHEMA_NOT_FOUND`) and every other code raises the generic class for the branch. | +| A code the SDK has never heard of | The generic class for the branch is raised — `GraphQLError` for data failures, `AuthenticationError` for 401/403 — with `code` set to the string the server sent. | +| A known code whose payload gained a field | The unknown field is ignored; behaviour is unchanged. | +| A server predating the catalogue, or an error with no `extensions` | Today's behaviour exactly; `code` is `None`. | +| An integer `code` on `/graphql` from a pre-catalogue server | Not surfaced as a catalogue code; `code` is `None`. | +| A payload that violates the catalogue's own contract | The generic class for the branch, with the code still readable. The specific class's attributes are typed as the catalogue declares them, so there is nothing to populate a required one with. | + +Every fallback above is logged at debug level with the code involved, so an SDK meeting a newer server +is diagnosable in the field rather than only in tests. + +Regenerating bindings buys typed handling of newly catalogued codes. It never changes which exception +a byte-identical response produces for a code the SDK already knows, because the first error in the +response governs unconditionally — not the first *recognised* one. + +## Multiple errors in one response + +The first error in the response determines the class raised. The complete list is retained on the +exception, unreordered, and nothing is discarded. If the first error carries no code and a later one +does, the generic class for the branch is raised. + +## Messages + +A **server-reported** failure the catalogue describes has a message naming the code and the governing +error's message, and contains no query text. A failure the catalogue does not describe - one with no +`extensions`, an integer `code`, or the code `UNDEFINED_ERROR` - keeps a message byte-identical to +today's, query text and full error list included. The query is available as an attribute in both cases. + +`UNDEFINED_ERROR` sits on the second side of that line deliberately. Since the server codes every +error it reports, treating it as described would apply the short message to every failure the +catalogue has no entry for, replacing the query text and the later errors with a code that says only +that the server had nothing to say. It stays readable on `exc.code`. + +Both transports follow the same rule about **which** message may be named beside a code: the governing +error's, which is the first one. Joining the rest would file them under a code that is not theirs; the +complete list stays on `exc.errors`. + +The qualifier matters because three catalogued classes can also be raised with **no catalogue code +behind them**: `NodeNotFoundError`, `BranchNotFoundError`, and `SchemaNotFoundError`. That covers a +client-side lookup miss, which has no server response at all, and the REST 404 the file handler turns +into a `NodeNotFoundError`, which has a response and a message but no catalogue code, since REST carries +the legacy envelope. Both keep the message they produce today, because there is no code to name. As +everywhere else, `exc.code is not None` is the test for which case you are holding. + +Where the catalogue provides them, the server's message names the failing action and resource kind, so +that detail now appears in logs and CLI output in place of the query text that used to be there. + +## Parity + +The async and sync clients raise the same type with the same attributes for the same failure, for +every catalogued code. + +## Stability + +`infrahub_sdk.exceptions` is treated as public and is the one import path a consumer needs. That is a +stronger promise than the constitution's tiering strictly requires — only `Config`, `InfrahubClient`, +and `InfrahubClientSync` are exported at top level — and it is made deliberately, because +`infrahubctl`, the Ansible collection, and external consumers already import from it directly. + +Concretely: + +- No name is removed or renamed, and no constructor loses a signature it has today. +- Every name importable from `infrahub_sdk.exceptions` before this change remains importable from it, + which a test pins against a committed snapshot. Restructuring the module into a package must not be + observable from the outside. +- Modules beneath `infrahub_sdk.exceptions` are internal. Importing `…exceptions.catalogue` or + `…exceptions.payloads` directly is not supported, and their layout may change. +- One annotation widens: `NodeNotFoundError.identifier` becomes `Mapping[str, list[str]] | str`. It is + called out in a changelog fragment because external consumers read these attributes even though + nothing in this repository does. diff --git a/dev/specs/ifc-3034-error-catalogue/contracts/generator-contract.md b/dev/specs/ifc-3034-error-catalogue/contracts/generator-contract.md new file mode 100644 index 000000000..eae799c90 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/contracts/generator-contract.md @@ -0,0 +1,152 @@ +# Contract: Generating the SDK's error bindings + +One artefact crosses from the Infrahub repository into the SDK. This is the contract between the two +sides. The SDK holds no copy of the catalogue schema (FR-010). + +## The artefact + +- **Source**: `schema/error-catalogue.json` in the Infrahub repository. +- **Output**: `infrahub_sdk/exceptions/catalogue.py` in the SDK submodule. +- **Owner**: Infrahub. The SDK never regenerates it, exactly as it never regenerates `protocols.py` + or its schema models. +- **Committed**: yes. Generation happens in a pull request, not at install time, so catalogue drift is + visible in the diff. + +The file is generated **in full**. There is never a hand-edited region inside a generated file, nor a +generated region inside a hand-written one. + +## What the generated module contains + +It opens with a header marking it generated and not to be edited, naming the source artefact, recording +the catalogue's `infrahub_catalogue_version`, and giving the regeneration command (FR-009) — the same +marking style as the repository's other generated files. It declares `__all__`, which is what the +package façade's own tests hold its hand-written re-export list against. The façade names each class +explicitly rather than star-importing, so a newly generated class needs an entry there too; a wildcard +would also promote the payload models, the lookup maps and the dispatch helper onto the package +surface. + +The body holds three things: + +- One pydantic payload model per catalogue code, including codes with an empty payload, adopted codes, + and the 401/403 codes that get no class. These validate the envelope and supply the promoted + attributes' types. +- One exception class per catalogue code that is neither adopted by the SDK nor declares 401/403, each + promoting its payload's fields to directly typed attributes and exposing a `from_payload` + classmethod, and each with exactly one parent. +- `CODE_TO_EXCEPTION`, mapping every code that has a class to it — generated classes for most, + imported adopted classes for the rest. The 401/403 codes are deliberately absent, so the factory's + lookup misses and the transport rule raises the generic class carrying the code. + +It imports only `infrahub_sdk.exceptions.base`, which imports nothing from inside the package. That +keeps the package's import graph one-way with no cycle: `base` → `catalogue` → `factory` → the façade. + +Codes are emitted in sorted order so that reordering the catalogue's JSON does not churn the diff. + +## Derivation rules + +No hand-maintained per-code table exists on either side. Everything is derived from the catalogue +entry: + +| Output | Derived from | +|--------|--------------| +| Exception class name | The code's parts capitalised and joined, with `Error` appended only if it does not already end in `Error`. `UNDEFINED_ERROR` → `UndefinedError`. | +| Payload model name | `data_schema.title`, verbatim. | +| Whether a class is emitted | `http_status in {401, 403}` → no class, payload model only. Every other code → a class. | +| Base class | `GraphQLError`, always exactly one parent. | +| `http_status` class attribute | The catalogue's declared `http_status`. | +| Docstring | The catalogue's `description` and `stability`. | +| Promoted attribute names | The payload field names, verbatim. | +| Field and attribute types | The JSON Schema mapping in [data-model.md](../data-model.md); a required field is non-optional, a nullable one carries its declared default. | + +## Adoption + +Some catalogue codes are represented by a class the SDK already ships. Those classes declare the code +they represent with a `CODE` class attribute, typed `ClassVar[str | None]` so a subclass of an adopted +class can clear it. The generator parses `infrahub_sdk/exceptions/base.py` with `ast`, collects every +class whose body assigns a `CODE` string - **both `ast.Assign` and `ast.AnnAssign`, since the +declarations are annotated** - and for those codes emits an import and a map entry instead of a class +definition. A `CODE` assigned anything but a string constant, which is how a subclass disclaims an +inherited code, is not an adoption. The payload model is still generated. + +An adopted class supplies its own `from_payload`, since its attribute names are its existing ones +rather than the catalogue's. + +Adopting a further code later is a one-line change in the SDK's hand-written module with no generator +edit. Discovery is by parsing rather than importing, so the generator stays a pure text transform and +generation never depends on the SDK checkout being importable. The same walk collects every class name +defined in `base.py`, which is what the collision check below needs. Parsing also sidesteps a trap an +attribute walk would hit: a subclass of an adopted class would otherwise appear to claim the same code. +`NodeInvalidError` additionally clears `CODE` in its own body, so the two mechanisms agree and nothing +reading `exc.CODE` at runtime sees a wrong-kind result labelled as a lookup miss. + +## Failing loudly + +Generation aborts, rather than emitting a guess, when: + +- a catalogue entry has no integer `http_status`; +- a catalogue entry has no non-empty `data_schema.title`; +- a `data_schema` uses a construct outside the supported vocabulary, in which case the offending + fragment appears in the error; +- `codes` is empty or the root is not an object; +- a derived class name collides with a class already defined in `base.py` that has not declared that + code as adopted. + +The first four are the assertions the frontend generator already makes, for the same reason. The last +is specific to Python's import semantics: the SDK's façade re-exports `base` and then `catalogue`, so an +undeclared collision would let the generated class silently take the name and change what an existing +`except` clause catches. The SDK already defines `ValidationError`, `RateLimitError`, +`InvalidResponseError`, `FileNotValidError`, and `ResourceNotDefinedError` — every one of them the name +a plausible future code would derive — so this is a live hazard rather than a theoretical one. Failing +generation forces the choice (adopt the code, or rename) into the pull request that adds the code. + +## Validation + +No new command. `uv run invoke backend.generate` renders the artefact alongside the schema models and +the protocols — the other two things Infrahub generates into the submodule — and +`uv run invoke backend.validate-generated` verifies it with +`git -C python_sdk diff --exit-code infrahub_sdk/exceptions/catalogue.py`. The diff must run inside the +submodule, because from the superproject `git diff` only sees the submodule pointer, which is the same +reason the existing schema-model and protocol checks are written that way. + +The `backend` namespace owns this because it names the *producer*: Infrahub generates, the SDK receives. +Nothing outside it generates the SDK's bindings — in particular not +`frontend.regenerate-error-bindings`, which belongs to a sibling consumer of the same catalogue. + +In CI this needs no new step and no new path filter: `backend-validate-generated` already runs +`backend.validate-generated` and already checks out the submodule. The one edit is that job's trigger, +which gains `error_catalogue == 'true'` so a hand-edit of `schema/error-catalogue.json` alone cannot +slip past. + +Everything else is already covered, and by design rather than by luck: + +| Change | What CI sees | Filter that catches it | +|--------|--------------|------------------------| +| The committed bindings in the submodule | The `python_sdk` gitlink moving — never the inner path | `sdk_files`, commented in the repo as "Catch updates to the submodule commit", which feeds `backend_all` | +| The generator template | `backend/templates/generate_sdk_errors.j2` | `backend_files` (`backend/**`), which feeds `backend_all` | +| The catalogue JSON | `schema/error-catalogue.json` | `error_catalogue_files`, which already lists it | + +Note the first row: a path filter on `python_sdk/infrahub_sdk/exceptions/catalogue.py` would never match +anything. From the superproject a submodule is a single gitlink entry, so a change to a file inside it +appears only as a change to `python_sdk` — the same limitation that forces the `git diff` above to run +inside the submodule. + +**Across the catalogue's four derived artefacts, CI is what keeps the set in step.** The JSON, the +frontend bindings, the docs page, and the SDK bindings are each generated by their own owner; the +`error_catalogue` file filter gates all of their checks, so a catalogue change that skips any one of +them fails the pull request that made it. That is where the drift protection lives, not in any single +regenerate-everything command. + +Submodule availability is not a factor in that placement. Infrahub declares +`infrahub-sdk = { path = "python_sdk", editable = true }`, so `uv sync` fails without the submodule and +every Python job there already requires `submodules: true`. Any job that needs the submodule declares +it; the check goes where it belongs and the checkout follows. + +A catalogue change that skips regeneration therefore fails the pull request that made it (FR-026). +There is no release-time gate on either side; pull-request-time validation is the mechanism, matching +how the existing generated artefacts are treated (FR-027). + +## What regeneration does and does not buy + +Regenerating adds typed handling for newly catalogued codes. It never changes which exception a +byte-identical response produces for a code the SDK already knows, and correctness never depends on +having regenerated — an SDK with stale bindings falls back rather than failing. diff --git a/dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md b/dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md new file mode 100644 index 000000000..b17386fd5 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/critiques/critique-20260824-161725.md @@ -0,0 +1,630 @@ +# Critique Report: Error Catalogue in the Python SDK + +**Date**: 2026-08-24 +**Feature**: [spec.md](../spec.md) +**Plan**: [plan.md](../plan.md) +**Verdict**: ⚠️ PROCEED WITH UPDATES + +--- + +## Executive Summary + +The spec is unusually strong for a library change: it names the user-facing contract (classes, codes, +attributes) while withholding mechanism, it enumerates hazards found by surveying real code rather than +hypotheticals, and FR-013's rationale for first-error precedence is the kind of reasoning that prevents +a whole class of later bugs. The plan is grounded — every decision cites the file it was derived from — +and the revised exceptions-package layering is a genuine solution to the cycle rather than a mitigation +of it. + +Three findings block task generation, all of them in the same family: the plan's treatment of what +happens when a class is re-rooted or resolved. **E1** is a latent `AttributeError` — the three adopted +classes become `GraphQLError` subclasses without ever setting `errors`, `query`, or `variables`, so any +consumer (or the CLI) reading `exc.errors` off a client-side lookup miss crashes; the plan's stated +mitigation assumes an empty list where the attribute is actually absent. **E2** is a verified conflict +between FR-008 and FR-018: Infrahub's `/graphql` app returns HTTP 200 for resolver-raised errors and its +formatter maps permission failures to `PERMISSION_DENIED`, so an auth-branch code really does arrive on +the data path — and deriving its parent solely from the declared status would make `except GraphQLError` +stop catching a response it catches today. **E3** is a contract inconsistency: the plan lets a malformed +payload downgrade the *raised type*, which contradicts FR-013's own reasoning that the type must be a +pure function of the response, and the server's payload builder has a reachable fallback that emits an +empty payload for codes whose schema declares required fields. + +None of the three requires rethinking the approach; each is a local correction to the plan, and two also +want a one-line clarification in the spec. + +--- + +## Product Lens Findings 🎯 + +### Problem Validation + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P1 | 💡 | Problem validation is well-evidenced (a spike, the backend catalogue, the frontend's generated bindings, and the SDK's own `"Expired Signature"` string match). Nothing to challenge on need. The gap is *sequencing*: US5 "bindings that cannot silently drift" is labelled P2, but the per-code classes US1 (P1) delivers are produced by the generator US5 builds, so US5 is a hard prerequisite for the P1 story. | Make the cross-repo dependency explicit in `tasks.md`: the Infrahub generator and its first hand-verified run come before the SDK-side typed-raising work can be demonstrated. Priority labels describe value, not order — say so, so the task ordering is not derived from them. | + +### User Value Assessment + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P2 | 💡 | The plan's new `topics/error_handling.mdx` is to describe "the catalogue codes it covers". Restating 15 codes by hand in the SDK repo creates a second source of truth that rots the first time Infrahub adds one, and nothing validates it — a direct Principle VII risk. Infrahub already generates `docs/docs/reference/error-catalogue.mdx` from the same artefact. | Scope the SDK page to what is genuinely SDK-specific: the hierarchy, how to catch by branch versus by code, the cross-version guarantees, and the two accepted broadenings. Link to Infrahub's generated catalogue reference for the code list instead of duplicating it. | +| P3 | 💡 | FR-016 requires the `identifier` widening to be "called out in the change's release notes", and the plan asserts this in the Constitution Check without naming a mechanism. The repository uses towncrier (`[tool.towncrier]`, `directory = "changelog"`, `orphan_prefix = "+"`), so a release note is a file, not a promise. | Add a changelog fragment task: one entry for the typed errors, one for the `identifier` widening, one for the `except GraphQLError` broadening. Naming the fragment in the plan makes FR-016 verifiable instead of aspirational. | + +### Alternative Approaches + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P4 | 💡 | The plan rejects "generate all of `exceptions.py`" for good reasons, and the layered package is the right call. One alternative is not recorded: doing nothing on the generation side and hand-writing 15 classes once. Worth a sentence, because a reader will ask — 15 codes is small enough that the generator's value is drift protection (US5), not typing effort. | Add the alternative to research.md R2 with that framing, so the generator's justification reads as "drift protection" rather than "avoiding 15 classes of typing". | + +### Edge Cases & UX + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P5 | 💡 | The plan adds no observability to the fallback paths. Every cross-version case — unknown code, absent envelope, invalid payload — is silent, which is exactly the signal a maintainer wants from the field when an SDK meets a newer server. The plan mentions a debug log for payload-validation failure only. | Log at debug on every fallback, including the code that failed to resolve. Cheap, and it makes SC-004's guarantees observable in production rather than only in tests. | +| P6 | 🤔 | E2's fix changes CLI output for a resolver-raised permission failure: today it renders through `print_graphql_errors`, and after the change the earlier `AuthenticationError` branch claims it and prints "Authentication failure: …". That is arguably better, but it is a user-visible change not currently anticipated by the spec. | Confirm the new rendering is wanted, then pin it with a test. If it is not wanted, the ladder needs a third position rather than the current two. | + +### Success Measurement + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P7 | 🤔 | SC-008 promises "a developer can catch every server-reported error, on either transport, with one `except` clause", and that clause is `except ApiError`. But `ApiError` is reachable only by deep import, which the constitution's tiering places explicitly *outside* the guaranteed-stability tier. The spec's central promise is therefore backed by a surface the constitution says may change in a minor release. | Decide whether `ApiError` (and possibly `Error`) joins `infrahub_sdk/__init__.py`'s `__all__`. This permanently enlarges the guaranteed surface, so it is a maintainer call, not a plan call — but leaving it unanswered means SC-008 promises more stability than the constitution grants. | + +--- + +## Engineering Lens Findings 🔬 + +### Architecture Soundness + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E1 | 🎯 | **Re-rooting the three adopted classes under `GraphQLError` leaves `errors`, `query`, and `variables` unset.** `NodeNotFoundError.__init__` (and `BranchNotFoundError`, `SchemaNotFoundError`) never call `GraphQLError.__init__`, so on a client-side raise those attributes do not exist at all. Any consumer doing `except GraphQLError as exc: … exc.errors` gets `AttributeError`, and the CLI's `print_graphql_errors(errors=exc.errors)` raises *before* reaching the plan's "degrade when the list is empty" fix. The plan's mitigation is built on the wrong premise: the list is not empty, the attribute is absent. | Give `ApiError` safe class-level defaults for `errors` (empty tuple or `None`, not a mutable list), `query`, and `variables`, so every descendant has them regardless of which constructor ran. Keep the renderer's degradation as well, and add a test that reads `exc.errors`, `exc.query`, and `exc.variables` off a purely client-side `NodeNotFoundError`. | +| E2 | 🎯 | **Auth-branch codes really do arrive on the data path, so deriving the parent solely from the declared status breaks FR-018.** Verified in Infrahub: `backend/infrahub/graphql/app.py:298-300` returns `status_code=200` for every executed query, and `graphql/error_formatter.py` maps resolver-raised failures to `PERMISSION_DENIED` / `AUTHENTICATION_REQUIRED` / `TOKEN_EXPIRED` inside that 200 response's `errors` array. Today such a response raises `GraphQLError`. Under FR-008 as written it would raise a class descending only from `AuthenticationError`, so an existing `except GraphQLError` around `execute_graphql` silently stops catching it — an FR-018 and SC-003 violation. The spec's edge case ("auth failures come back as real 401/403 responses handled by a separate code path") holds only for failures that escape *before* execution, which `api/exception_handlers.py:52-55` states explicitly. | Give the 401/403 codes both parents: `class PermissionDeniedError(GraphQLError, AuthenticationError)`. The diamond closes cleanly on `ApiError`, the MRO gives `GraphQLError.__init__` (correct — these only ever arise on the GraphQL transport, since REST failures raise plain `AuthenticationError` per FR-015), and both `except AuthenticationError` (US3 AS1/AS2) and `except GraphQLError` (FR-018) are satisfied. Amend FR-008 from "descend from the authentication branch" to "*additionally* descend from the authentication branch". | +| E3 | 🎯 | **The payload-validation fallback downgrades the raised type, contradicting FR-013's own rationale.** The plan falls back to the generic branch class when a payload fails validation, to buy the guarantee that `exc.data` is never `None` on a specific class. But FR-013 argues the raised type must be a pure function of the response so it never depends on anything else — and this makes it depend on payload validity. It also defeats the P1 story in the case that matters: US1 AS2 asserts `.delete()` of a missing node raises `NodeNotFoundError`, which would become a bare `GraphQLError` if the payload were malformed. This is not theoretical: `error_formatter.py:59-60` defaults `payload = UndefinedErrorData()` and only overwrites it when an `isinstance` guard matches, so the server can emit `data: {}` under a code whose schema declares `node_kind` and `identifier` required. | Invert the choice: resolve the class from the code, always, and let `data` be `Model | None` with the generated annotation `Optional`. Log at debug when validation fails. The cost is a `None` check that in practice never fires; the benefit is that the type a consumer catches is exactly what FR-013 promises. Note in the contract that a `None` `data` means the server violated its own emission contract. | +| E4 | 💡 | FR-013 requires the exception to retain the complete error list, but `AuthenticationError.__init__(message)` has nowhere to put it, and FR-015 freezes that constructor. On the auth branch the list would currently be dropped. | Fold this into E1's fix: `errors` lives on `ApiError` with a safe default, and the auth factory populates it. One change satisfies both findings. | +| E5 | 💡 | The `ast`-based adoption discovery is the right call, but the documented import-based fallback has a trap: `NodeInvalidError` *inherits* `CODE = "NODE_NOT_FOUND"` from `NodeNotFoundError`, so an attribute walk would see two classes claiming the same code and let dict ordering pick the winner. | If the fallback is ever used, filter to classes whose own `__dict__` carries `CODE`. Worth one sentence in research.md R4 so the trap is documented where the fallback is offered. | +| E6 | 💡 | The layering test as described parses module-level and `TYPE_CHECKING` imports. A function-body import (`def f(): from .factory import …`) is the classic way a cycle gets reintroduced once the obvious route is closed. | Walk every `Import`/`ImportFrom` node in the module, not just top-level ones. Same effort, closes the remaining hole. | + +### Failure Mode Analysis + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E7 | 💡 | The factory sits on the failure path of every client method, which makes it the highest-blast-radius code in the change: an unexpected exception inside it replaces a legitimate server error with an SDK `TypeError`, and the original failure is lost. The plan guards payload validation but not the resolution logic around it. | Make the factory total: wrap resolution in a `try/except Exception` that falls back to constructing today's generic error, so a factory bug degrades to current behaviour instead of masking the server's error. Test it by feeding the factory a deliberately malformed envelope (`errors` as a string — which `analyzer.py:42` already produces, and `extensions` as a list). | +| E8 | 💡 | The wire `extensions.http_status` can legitimately differ from the code's declared status: `api/exception_handlers.py:26-27` overwrites a catalogue 500 with the actual FastAPI status, so an `UNDEFINED_ERROR` can arrive declaring 500 while carrying 422. The plan asserts `exc.http_status` is the catalogue value (matching US1 AS3) without noting the observable divergence. | Keep `exc.http_status` as the catalogue value, and state in the contract that the wire value remains available via `exc.extensions["http_status"]` and may differ for `UNDEFINED_ERROR`. One sentence prevents a confusing bug report. | + +### Security & Privacy + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E9 | 💡 | No new attack surface: the change parses a response the SDK already parses, adds no dependency, and reaches no new data. The one hygiene point is that a catalogued error's message now names the code and the server's message, and `PermissionDeniedData` carries `action` and `resource_kind` — which will show up in logs and CLI output where the previous message was a wall of query text. | Nothing to change. Worth one line in the docs page noting that error messages now surface the failing action and resource kind, so anyone shipping SDK logs to a third party knows what changed. | + +### Performance & Scalability + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E10 | 💡 | Cost is one dict lookup plus one pydantic validation per failed request, on a path that already decoded JSON. Not a concern. The only scaling question is generated-module size, and 15 codes is nothing. | None. The plan's "Performance Goals: None" is the correct answer, not an omission. | + +### Testing Strategy + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E11 | 💡 | SC-006 reads "async and sync clients raise the same type with the same attributes for the same failure, across all catalogued codes", but the plan's approach — exhaustive at the factory level, parametrized at the client level — does not say which layer covers "all codes". Read literally, SC-006 asks for 15 codes × 2 clients through the client layer. | State the split explicitly: factory-level tests cover all codes exhaustively; client-level parity tests cover a representative set that exercises both branches, both transports, and the file-upload variant. That satisfies SC-006's intent and keeps the suite fast, per the constitution's unit-test speed requirement. | +| E12 | 💡 | The plan does not name a test for the two accepted broadenings. They are deliberate behaviour changes, and the constitution requires each to be pinned by a test asserting the new behaviour. | Add explicit tests: `except GraphQLError` catches a client-side `NodeNotFoundError`; a catalogued failure's message differs from the generic one while the uncatalogued message is byte-identical. | + +### Operational Readiness + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E13 | 💡 | Rollback for a library is a revert, and the plan needs no deployment strategy — correct. But the cross-repo failure mode has no story: if Infrahub regenerates bindings into the submodule and the SDK's `sdk_ref` docs are generated from the `exceptions` package, the SDK's own `docs-validate` fails on the next SDK pull request, for a change made in another repository. `exceptions.py` is not documented in `sdk_ref` today, so adding it to `packages_to_document` creates this coupling from nothing. | Flip R1's consequence: put `"exceptions"` in `packages_to_ignore` and cover the hierarchy with the hand-written topic page, which is what FR-028 actually asks for. Preserves today's docs behaviour exactly and removes a cross-repo trap. | + +### Dependencies & Integration + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E14 | 💡 | No new runtime dependency, and the generator reuses Infrahub's existing Jinja2 and ruff pipeline — sound. The unverified assumption is the type-checking one: the plan asserts that annotating `data` concretely on a subclass of an `Any`-typed base attribute is accepted by *both* mypy and `ty`. mypy accepts it; `ty` is newer and its variance handling on attribute overrides is not something the plan has evidence for. | Spike it before the generator template is finalised: one hand-written class, both checkers. If `ty` objects, the fallback is a covariant read-only property per generated class, which the template can emit just as easily. Cheap insurance against discovering it across 15 generated classes. | +| E15 | 🤔 | The bindings cross a submodule boundary, so the SDK commit that adds `catalogue.py` and the Infrahub commit that generates it must land together. The plan says the first generation is hand-verified once, but not how the two pull requests are paired thereafter, nor what happens when Infrahub's CI regenerates against an SDK branch that is not yet merged. | Confirm the intended workflow with the maintainer: does the Infrahub pull request carry a submodule pointer bump to an SDK branch, and is that branch merged first? This is existing practice for `protocols.py`, so the answer likely exists — it just is not written down here. | + +--- + +## Cross-Lens Insights 🔗 + +| ID | Finding | Product Impact | Engineering Impact | Suggestion | +|----|---------|---------------|-------------------|------------| +| X1 | Auth codes on HTTP 200 (E2) | A consumer's working `except GraphQLError` silently stops catching permission failures — the exact class of breakage the feature exists to prevent | FR-008's derivation rule conflicts with FR-018's no-clause-loses-coverage guarantee | Dual base `(GraphQLError, AuthenticationError)` for 401/403 codes, and amend FR-008's wording to "additionally descend" | +| X2 | Payload validity decides the raised type (E3) | US1 AS2's promise ("`.delete()` on a missing node raises `NodeNotFoundError`") becomes conditional on the server's payload being well-formed, and the server has a reachable path that emits an empty payload | Contradicts FR-013's stated rationale that the raised type is a pure function of the response | Resolve the class from the code unconditionally; make `data` `Model \| None` and document that `None` means the server broke its own contract | +| X3 | Documenting the `exceptions` package in `sdk_ref` (E13, P2) | A hand-maintained code list and a generated API page both rot, and readers cannot tell which is authoritative | Creates a cross-repo CI coupling where an Infrahub-side regeneration fails an SDK pull request | Ignore the package for API-doc generation; one hand-written topic page for hierarchy and guarantees, linking to Infrahub's generated catalogue reference for codes | + +--- + +## Findings Summary + +| Metric | Count | +|--------|-------| +| 🎯 Must-Address | 3 | +| 💡 Recommendations | 16 | +| 🤔 Questions | 3 | +| Product findings | 7 | +| Engineering findings | 15 | +| Cross-lens findings | 3 | + +--- + +## Consolidated Findings Table + +| ID | Lens | Severity | Category | Finding | Suggestion | +|----|------|----------|----------|---------|------------| +| E1 | Engineering | 🎯 | Architecture | Re-rooted adopted classes never set `errors`/`query`/`variables`; consumers and the CLI hit `AttributeError`, not an empty list | Safe class-level defaults on `ApiError`; test attribute access on a client-side raise | +| E2 | Engineering | 🎯 | Architecture | Auth codes arrive inside HTTP 200 GraphQL responses, so status-derived parents make `except GraphQLError` lose coverage | Dual base `(GraphQLError, AuthenticationError)`; amend FR-008 to "additionally descend" | +| E3 | Engineering | 🎯 | Architecture | A malformed payload downgrades the raised type, contradicting FR-013 and US1 | Resolve class from code unconditionally; `data: Model \| None` | +| E4 | Engineering | 💡 | Architecture | FR-013's error-list retention is unmet on the auth branch | Put `errors` on `ApiError`; auth factory populates it | +| E5 | Engineering | 💡 | Architecture | Import-based adoption fallback double-counts `NodeInvalidError`'s inherited `CODE` | Filter on the class's own `__dict__` | +| E6 | Engineering | 💡 | Architecture | Layering test misses function-body imports | Walk all import nodes, not just module-level | +| E7 | Engineering | 💡 | Failure modes | A factory bug replaces the server's error with an SDK `TypeError` | Make the factory total, falling back to today's construction | +| E8 | Engineering | 💡 | Failure modes | Wire `http_status` can differ from the declared one for `UNDEFINED_ERROR` | Document that `exc.extensions["http_status"]` holds the wire value | +| E9 | Engineering | 💡 | Security | Messages now surface action and resource kind into logs | One documentation line; no code change | +| E10 | Engineering | 💡 | Performance | No bottleneck; "Performance Goals: None" is correct | None | +| E11 | Engineering | 💡 | Testing | SC-006's "all codes" is not mapped to a test layer | State the factory-exhaustive / client-representative split | +| E12 | Engineering | 💡 | Testing | The two accepted broadenings have no named test | Add tests pinning both | +| E13 | Engineering | 💡 | Operations | Documenting the `exceptions` package couples SDK `docs-validate` to Infrahub regeneration | Use `packages_to_ignore`; rely on the topic page | +| E14 | Engineering | 💡 | Dependencies | `ty`'s acceptance of the narrowed `data` annotation is unverified | Spike one class against mypy and `ty` first | +| E15 | Engineering | 🤔 | Integration | Cross-repo pull-request pairing for the submodule artefact is unwritten | Confirm the `protocols.py` workflow and record it | +| P1 | Product | 💡 | Problem validation | US5 (P2) is a hard prerequisite for US1 (P1) | Make the dependency explicit in `tasks.md`; priorities are value, not order | +| P2 | Product | 💡 | User value | A hand-written code list in SDK docs becomes a second source of truth | Describe hierarchy and guarantees; link to Infrahub's generated reference | +| P3 | Product | 💡 | User value | FR-016's release-note requirement has no mechanism | Add towncrier fragments for the typed errors and both broadenings | +| P4 | Product | 💡 | Alternatives | "Hand-write 15 classes" is not recorded as a rejected alternative | Add it, framed as drift protection versus typing effort | +| P5 | Product | 💡 | Edge cases | Fallback paths are silent in the field | Debug-log every fallback with the unresolved code | +| P6 | Product | 🤔 | Edge cases | CLI output changes for resolver-raised permission failures | Confirm the new rendering, then pin it with a test | +| P7 | Product | 🤔 | Success measurement | SC-008's promise rests on a class outside the guaranteed-stability tier | Decide whether `ApiError` joins the top-level `__all__` | +| X1 | Cross-lens | 🎯 | Scope × Risk | See E2 | See E2 | +| X2 | Cross-lens | 🎯 | Scope × Risk | See E3 | See E3 | +| X3 | Cross-lens | 💡 | Docs × CI | See E13 and P2 | Ignore the package for API docs; one hand-written topic page | + +--- + +## Recommended Actions + +### 🎯 Must-Address (Before Proceeding) + +1. **E1**: In `data-model.md` and `plan.md`, give `ApiError` safe class-level defaults for `errors`, + `query`, and `variables`, and correct the CLI mitigation in research.md R11 — the hazard is a missing + attribute, not an empty list. Add the attribute-access test to the plan's test list. +2. **E2**: Amend spec FR-008 to "401 and 403 codes *additionally* descend from the authentication + branch", and update `data-model.md`, `research.md` R5, and + `contracts/exception-hierarchy.md` to the dual base. Record the verification (Infrahub + `graphql/app.py:298-300` returns 200; `graphql/error_formatter.py` maps resolver-raised auth + failures) so the reasoning is not lost. Also correct the spec's edge case, which currently claims + auth failures only ever arrive as real 401/403. +3. **E3**: Reverse the payload-validation decision in research.md R6, `data-model.md`, and + `contracts/exception-hierarchy.md`: the code resolves the class unconditionally, `data` is + `Model | None`, and a `None` means the server violated its own emission contract. + +### 💡 Recommendations (Strongly Suggested) + +1. **E4 + E13 + E7**: fold `errors` onto `ApiError`; flip the docs package to `packages_to_ignore`; + make the factory total. +2. **E5, E6, E8, E11, E12, E14, P4, P5**: one- to three-line corrections to the artefacts named in each + row above. +3. **P1, P2, P3**: sequencing note in `tasks.md`, docs page scoped to hierarchy and guarantees with a + link out for codes, and towncrier fragments named as tasks. + +### 🤔 Questions (Need Stakeholder Input) + +1. **P7**: Should `ApiError` (and `Error`) be exported from `infrahub_sdk/__init__.py` so SC-008's + promise sits in the guaranteed-stability tier? This permanently enlarges the guaranteed surface. +2. **P6**: Is the changed CLI rendering for a resolver-raised permission failure ("Authentication + failure: …" instead of the GraphQL error list) the wanted outcome? +3. **E15**: How are the paired Infrahub and SDK pull requests sequenced for a submodule artefact? The + `protocols.py` precedent presumably answers this. + +--- + +## Resolution + +Applied on 2026-08-25: all three must-address items and all sixteen recommendations. + +| Finding | Where it landed | +|---------|-----------------| +| E1 | `ApiError` gains class-level defaults for `errors`, `query`, `variables` — data-model.md, research.md R11 (which also corrects the wrong empty-versus-missing diagnosis), plan.md constraints | +| E2 | FR-008 amended to "additionally descend"; the HTTP 200 edge case corrected in spec.md; dual base recorded in research.md R5, data-model.md, both contracts | +| E3 | Reversed in research.md R6; FR-004 gained the rule; `data` is `Model \| None` throughout the contracts and data model | +| E4 | `errors` moved onto `ApiError` (same change as E1) | +| E5 | research.md R4 and the generator contract note the inherited-`CODE` trap | +| E6 | research.md R1 and quickstart scenario 3b: the layering test walks every import node | +| E7 | research.md R7: the factory is total; quickstart scenario 2 drives malformed envelopes | +| E8 | Wire-versus-declared `http_status` documented in spec.md, research.md R5, data-model.md, the hierarchy contract | +| E9 | FR-028 and research.md R13: messages now surface action and resource kind | +| E11, E12 | research.md R12 gained the per-layer coverage table including the broadening tests | +| E13 | research.md R1 flipped to `packages_to_ignore`, with the cross-repo reason; plan.md Principle VII row | +| E14 | New research.md R15: spike the narrowed annotation against mypy and `ty` first | +| E15, P6 | New research.md R17 and a plan.md "Open questions" section | +| P1 | New research.md R16 and a plan.md "Sequencing" section | +| P2 | research.md R13: the page links to the published catalogue instead of restating codes | +| P3 | New research.md R14: towncrier fragments in `changelog/` | +| P4 | research.md R2 gained the no-generator alternative | +| P5 | research.md R7: every fallback logs at debug | +| P7 | Answered by the maintainer, and the answer reframed the question — see below | + +### Second pass + +A follow-up review of the *applied* artefacts found three more blocker-class items, two of them created +by the fixes above, all now applied: + +| Finding | Fix | +|---------|-----| +| An undeclared name collision would silently shadow a hand-written class. `VALIDATION_ERROR`, `RATE_LIMIT`, `INVALID_RESPONSE`, `FILE_NOT_VALID`, and `RESOURCE_NOT_DEFINED` all derive names the SDK already defines, and the façade's ordered star-imports would let the generated class win | FR-006 gained the rule; generation aborts on an undeclared collision (research.md R3, generator contract) | +| The dual base (E2) gives auth classes `GraphQLError.__init__`, whose first positional parameter is `errors` — so passing the joined message positionally would assign it to `errors`, reproducing `analyzer.py`'s corruption by construction | Factories construct with keywords only, asserted by a test (research.md R7); the spec's constructor-misuse edge case now names the hazard | +| Every acceptance criterion was provable against fixtures authored alongside the parser, with no test against a real server — and the constitution puts server-dependent behaviour in the integration tier, which already exists here | Two real failures driven through testcontainers on both clients (research.md R12, plan Principle V, quickstart scenario 6b) | + +Smaller items from the same pass: the tuple default for `errors` (E1) would fall into +`print_graphql_errors`' non-list branch, which also lacks a `return`; the auth factory must use +`decode_json` so a non-JSON 401 body does not raise in place of the authentication error; there are two +pre-existing malformed `GraphQLError` construction sites, not one; and the accepted broadening is +slightly wider than stated, since the file handler's REST 404 also becomes a `GraphQLError`. + +### Third pass — the payload access design + +E3 and E14 both circled a payload *object* on the exception without questioning whether it should exist. +It should not, and removing it retired both findings along with several of the complications the earlier +passes introduced. + +US1 asks for the detail "as typed attributes", never for a payload object. So the payload's fields are +promoted to directly typed attributes on the exception (`exc.node_kind`, `exc.fields`), and the pydantic +model reverts to being the validation mechanism. Consequences: + +- `ApiError.data: Any` is gone, and with it the only invented `Any` in the design. There is nothing on + the base to narrow, so no variance problem, no property pair, no generic hierarchy, and no + suppression anywhere (E14's spike survives in reduced form as R15). +- The generated module drops from two files to one. The `payloads.py` / `catalogue.py` split existed + only so `base.py` could name a generated model type for the adopted classes; with promotion it names + none, `base.py` imports nothing from inside the package, and the package is four modules rather than + five. +- The access pattern is now uniform. The adopted classes already promoted their payload fields onto + `node_type` and `identifier`, so `.data` for the other twelve codes was an inconsistency the SDK's + users would have had to learn. +- E3 is reverted, with a better argument than the one that made it: a required catalogue field is a + non-optional attribute, so an invalid payload has nothing to populate it with and must fall back to + the generic class. The FR-013 reasoning used to reject that originally was overreaching — FR-013's + concern is that the raised type must not depend on *binding freshness*, and payload validity is a + property of the response, not of the SDK's bindings. + +### Fourth pass — automated PR review + +Eight findings on the committed artefacts. Six accepted as filed, one accepted with a corrected +diagnosis, one rejected. + +| Finding | Verdict | +|---------|---------| +| R6 and R7 disagreed on whether `code` stays readable when a recognised code's payload fails validation | **Valid and consequential.** R7 had folded that case into its `code is None` list, which would have made a payload-invalid catalogued error render as an uncatalogued one — R11's CLI branch and R9's server-reported test both key on `exc.code is not None`. R7 now separates the two questions: which class is raised, and what `code` reports. | +| FR-012's fallback could be read as routing by the code's declared status, which would send an unrecognised 401/403 code arriving in a 200 body to the authentication branch and out of `except GraphQLError` | **Valid as a wording defect**, though not as a reading of the intent — "the branch it is already on" meant the transport. But the wrong reading breaks FR-018 silently, so FR-012 now says transport explicitly and states why declared status cannot work: for an unrecognised code the SDK holds no binding and so does not know the declared status at all. | +| The hierarchy contract claimed the dual base's "both clauses catch it today" for the real-401/403 path, where `except GraphQLError` does not catch it today | **Valid**, and it surfaced an unrecorded consequence: the authentication path now resolves catalogue codes, so `except GraphQLError` will begin catching a real 401/403 whose code the bindings recognise. That is a third accepted broadening, now listed in both the contract and the spec. | +| `errors` documented as `list` on `GraphQLError` while an adopted class would expose the base's empty *tuple* | **Valid**, and the right fix is the one already preferred on design grounds: the adopted classes call `super().__init__(errors=[], …)` explicitly instead of leaning on a class-level default. The tuple/list divergence disappears rather than being documented. | +| `exc.extensions["http_status"]` recommended while `extensions` may be `None` | Valid. The contract now says to guard on `extensions` first. | +| Plan said "~12" `AuthenticationError` raise sites; the code has 11 (4 + 6 + 1), as research.md already stated | Valid. Corrected to 11. | +| The Resolution section read third pass before second pass | Valid. Reordered. | +| A pure-docs change should target a different release branch | **Rejected as actionable.** The stated remedy contradicts itself ("belong on stable; target develop instead"), the guideline it cites is not present in this repository, and `develop` and `infrahub-develop` are different branches here. Left as a question for the maintainer, who owns the branch model. | + +### Fifth pass — automated PR review of the fourth pass + +Three findings, all valid, two of them defects the fourth pass introduced. Applied. + +| Finding | Verdict | +|---------|---------| +| The third broadening was stated as "a real 401/403 carrying a catalogue code", which is overbroad — the dual base only applies to codes the bindings recognise, and an unrecognised code on a real 401/403 falls back to the generic `AuthenticationError`, which is not a `GraphQLError` | Valid. Both the contract and the spec now qualify the broadening to recognised codes and say explicitly what happens to the rest. | +| The data-model rationale claimed the adopted classes' explicit `super().__init__` call is what prevents an `AttributeError` — but the fourth pass *kept* `ApiError`'s class-level defaults, so the attributes exist regardless. The explicit call is about `errors` being a *list* | Valid, and self-inflicted: two fixes were applied and the old justification was left attached to the new mechanism. The two mechanisms are now described separately — defaults guarantee existence, the explicit call guarantees the type. | +| FR-012's new rationale was internally contradictory (it said the declared status is unknowable for an unrecognised code, then used an unrecognised code as the example of declared-status routing) and misattributed the coverage guarantee to FR-008's dual inheritance, which shapes per-code classes and not the generic class a fallback raises | Valid on both counts. The example is now a recognised 401/403 code whose payload fails to validate, and the coverage is attributed to FR-012's transport rule, with a note that the dual base does not help there. The same two errors were present in research.md R7 and are fixed there too. | + +### Sixth pass — automated PR review of the fifth pass + +One finding, valid: the qualified broadening ("a real 401/403 whose code the bindings recognise") is +still only true when the payload *also* validates, since a recognised code with an invalid payload falls +back to the generic `AuthenticationError` per FR-012. + +Rather than add a third conditional to a sentence that has now been narrowed twice, both the spec and +the contract restate the broadening as the mechanism that produces it: `except GraphQLError` catches a +real 401/403 exactly when the SDK raises a per-code class for it, because only those classes carry both +parents. That covers the recognised-code and valid-payload conditions without enumerating them, so it +cannot be narrowed again by a further condition on reaching a per-code class. + +### Seventh pass — maintainer review + +One finding, on task placement: `frontend.regenerate-error-bindings` should not generate the SDK's +bindings, because the frontend namespace should generate frontend artefacts only. + +Valid, and it exposed that the original decision was worse than stated — that task *already* calls +`backend.export_error_catalogue` and `docs.generate_error_catalogue`, so it is a catalogue orchestrator +carrying a frontend label, and adding a fourth cross-namespace call would have deepened an existing +wart rather than introducing a new one. + +The first attempt at a fix over-corrected: it invented a `generate` / `validate` pair in `tasks/sdk.py`, +reasoning from `tasks/schema.py`'s per-artefact pattern. The maintainer pointed out that the pair +already exists — `backend.generate` calls `_generate_schemas` and `_generate_protocols`, both of which +write into `python_sdk/`, and `backend.validate-generated` verifies them. A second entry point for the +same category of work was unnecessary. + +What the round actually settled is the principle: the namespace names the **producer**, not the +artefact's location. `backend` generating into `python_sdk/` is the point of the task rather than a +violation of it, and the frontend is a sibling *consumer* of the same catalogue — which is precisely why +hooking regeneration there was wrong. The error bindings join `backend.generate` and +`backend.validate-generated` as a third Infrahub-generated, SDK-destined artefact. No new command, and +no new CI step. + +Tidying the pre-existing frontend orchestrator is left as separate work. + +### Eighth pass — automated PR review + +One finding, valid: the planned `error_catalogue_files` entry for the submodule artefact path could +never match. From the superproject a submodule is a single gitlink, so a change to a file inside it +appears only as a change to `python_sdk`. + +This was a self-contradiction two paragraphs wide — the same document states that limitation as the +reason the `git diff` must run *inside* the submodule, and then claims a path filter on the inner path +would work. It is the second finding of that exact shape (the first being the `AttributeError` +justification left attached to a mechanism that no longer produced it), which suggests the habit worth +breaking: derive a claim about a mechanism from the one place the mechanism is stated, rather than +restating it independently a few paragraphs later. + +Checking the repository shrank the change further than the finding proposed. Nothing needs adding to +any filter: `sdk_files` is already `"python_sdk"`, commented "Catch updates to the submodule commit" +and included in `backend_all`; the generator template is covered by `backend_files` (`backend/**`); and +`error_catalogue_files` already lists the catalogue JSON. The only CI edit left is adding +`error_catalogue == 'true'` to the job's trigger, so that a hand-edit of the catalogue JSON alone +cannot slip past. + +### Ninth pass — automated PR review of the squashed branch + +Ten findings, seven valid, three rejected. Unlike the previous three rounds these were not corrections +of the round before: most had been present since the original plan commit and survived six incremental +reviews untouched. Reviewing the full diff at once found unreviewed ground rather than re-litigating +settled ground. + +Rejected, with the underlying gap fixed anyway: + +| Finding | Verdict | +|---------|---------| +| `UNIQUENESS_VIOLATION` does not exist in the catalogue | Wrong for the branch this pairs with. It is present on `opsmill/infrahub@develop`; the commit the reviewer read is on the stable line, where it is absent. | +| The catalogue holds 14 codes, not 15 (11 non-auth, not 12) | Same cause. `develop` holds 15 (12 + 3); `stable` holds 14 (11 + 3). | +| The `"Expired Signature"` grep should expect two sites, not one | R10 already puts both the code check and the legacy fallback inside one shared helper, so one occurrence is correct — down from two today. | + +The two catalogue findings were wrong but productive: nothing in the artefacts said *which* ref the +counts and examples came from, which is why a reviewer reading another branch reached a different +answer, and a human would have hit the same thing. The survey now pins `develop` explicitly and records +that US1 scenario 1 is only demonstrable against a catalogue containing `UNIQUENESS_VIOLATION`. The +grep finding got a clarifying clause in R10 and the quickstart for the same reason. + +Valid and applied: + +| Finding | Fix | +|---------|-----| +| The plan's Constraints section still said the raised class never depends on payload validity or transport — contradicting FR-004 and FR-012 | Stale since the E3 reversal, and it would have led an implementer to route a payload-invalid 401/403 code to the authentication branch. Reworded to "a function of the response alone… never of binding freshness". | +| `exc.code is not None` cannot distinguish server-reported from client-side on the adopted classes, because nothing documented set `code` on them | The `code` table row now states that the factory sets it per-instance for adopted classes — a class attribute cannot work, since the same class must report `None` on a client-side raise. | +| FR-002's "the base GraphQL error itself" is ambiguous now that FR-001 introduces a shared base above both branches | Names the FR-001 base explicitly, and says not the per-code classes. | +| FR-003 and FR-012 said `code` is "absent"; two acceptance scenarios promise `None`. Different observable contracts | Standardised on always-exists-and-may-be-`None`, which is what the scenarios and the CLI branch assume. | +| The Messages guarantee claimed every catalogued failure names the server's message, but three catalogued classes can be raised client-side with no server response | Qualified to server-reported failures in both FR-022 and the contract, with the client-side case keeping today's message. | +| data-model.md's intro said "nothing here is typed `Any`" two paragraphs above an `extensions: dict[str, Any]` row | Narrowed to "no payload attribute", with the raw-JSON exception stated. | +| A local absolute developer path in plan.md | Replaced with the repository slug. | + +### Tenth pass — automated PR review + +Two findings, both valid, both about the accuracy of a justification rather than the design: + +| Finding | Fix | +|---------|-----| +| The trend paragraph claimed all seven ninth-pass defects "had survived since the original plan commit", while the section it summarises says "most" — and its own example, the Constraints contradiction, only became a contradiction at the E3 reversal in the third pass | Qualified to "most", and the example dated to the third pass. | +| The new Messages qualifier said the three unified classes are raised "with no server response behind them", but `file_handler.py:168` raises `NodeNotFoundError` from a REST 404 that *does* carry a response and a message — a case the broadenings list two paragraphs above | Restated around the operative property: **no catalogue code** behind them. That covers both the client-side miss and the REST 404, whose response carries the legacy envelope and so no code. FR-022 corrected the same way. | + +This review was submitted four minutes after the commit it reviews and went unread for four days, +because the previous round was reported as finished without checking whether a new review had landed +against the fix. Worth noting alongside the eighth-pass observation about full-diff passes: the review +loop needs a check for *new* findings after each push, not only before one. + +The second finding is the third instance of one failure mode — restating a mechanism a few paragraphs +from where it is stated correctly, and getting the restatement wrong. The first was the `AttributeError` +justification, the second the submodule path filter. In all three the correct statement was already in +the same file. + +### Eleventh pass — maintainer review: the diamond is gone + +The maintainer questioned the dual base on SOLID and maintainability grounds, noting that the goal is +for a consumer to catch one generic SDK error rather than enumerate types. That reframing dissolved the +requirement the diamond existed to serve. + +**What the MI was for.** US3's scenarios specified distinct types for the three authentication codes, +and FR-018 required `except GraphQLError` to keep catching a resolver-raised `PERMISSION_DENIED` in an +HTTP 200. One class per code cannot satisfy both without inheriting from both branches. But those +scenarios were written *before* the HTTP 200 behaviour was verified — the spec's own acceptance criteria +forced the MI, and they were amendable. + +**What replaced it.** The 401/403 codes get no class. The transport rule already in FR-012 raises the +generic class the transport dictates — `AuthenticationError` for a real 401/403, `GraphQLError` for a +200 `errors` array — and `exc.code` carries the identity. Every existing `except` clause keeps working, +no exception escapes anyone, the hierarchy is a tree, the generator has one shape, and FR-008 changes +from "which parents" to "which codes get classes": one rule, no special cases in the factory. + +**What the diamond had already cost**, all found in review rather than by design: the positional-argument +corruption, where method resolution handed those classes `GraphQLError.__init__` and its first parameter +is `errors`; the cooperative `super().__init__` reaching `AuthenticationError.__init__` on the GraphQL +path, where its default message escaped substitution only by accident; two generated class shapes; and a +maintainer misreading the hierarchy on first contact. All to distinguish three codes whose payloads are +empty or entirely nullable and usually unset. + +**What it costs.** Those three codes have no typed payload attributes, reachable only through +`exc.extensions["data"]` if the catalogue later gives them substantive fields — at which point the +trade can be re-weighed against a concrete benefit. US3's scenarios, SC-001, FR-005, and FR-008 were +amended; the third broadening from the ninth pass disappears, since a real 401/403 once again produces +`AuthenticationError` and nothing else. + +Also rejected on the way: registering the generated classes as virtual subclasses of +`AuthenticationError` via `ABCMeta`. CPython matches `except` clauses with real subtype checks and +ignores `__subclasshook__`. + +### Twelfth pass — maintainer review: R9 settled, and reframed + +The maintainer asked whether the SDK could simply populate future catalogue fields itself on a +client-side raise, or whether the reverse is the problem — required fields the SDK sets that the server +never sends. + +The second, and it is already true in both directions: the catalogue supplies no `branch_name` at all, +while four of the seven client-side raise sites supply no node kind and fall back to `"unknown"`. +Whether the SDK could fill a *new* field depends entirely on the field — a `branch_name` addition could +be filled by three sites and not by the four store lookups, and anything genuinely server-side by none. + +Two things came out of it: + +- **A policy, now pinned** in FR-016, R9, the data model, and the hierarchy contract: promoted + attributes on the three unified classes are optional even where the catalogue declares the field + required, because a required attribute would be a promise a class raised from two provenances cannot + keep. With an explicit tripwire — an adopted code gaining a field that is both required and + semantically server-only — for revisiting. +- **A correction to this document's own framing.** Earlier passes flagged R9 as the design's weak point + and implied it might want reopening. Working the question properly showed the opposite: a split is + more expensive than the optional attribute, because the derived name for `NODE_NOT_FOUND` *is* the + existing class name, so splitting means breaking FR-005, FR-006, or every `except NodeNotFoundError` + around a store lookup. Unification is the lesser evil and is now defended rather than merely + accepted. + +The maintainer also set the direction of travel: separate classes for the SDK's own failures, arrived at +through the constitution's deprecation path rather than inside this change. R9 records the sequence, and +the spec records it as an assumption, so the next change has somewhere to start from. + +### Thirteenth pass — automated PR review of the two design changes + +Five findings across the two commits, all valid at least in part. Unlike the prose rounds, two of these +were real contradictions introduced by removing the diamond. + +| Finding | Verdict | +|---------|---------| +| FR-015 still promised "gaining the three catalogue subclasses beneath it" while the data model said `AuthenticationError` gains none, and FR-012's rationale still cited "the dual inheritance in FR-008" after that wording was removed | **Valid, and the important one.** Two spec documents contradicting each other on whether the class has subclasses. Both corrected. | +| The catching table still promised `except AuthenticationError` for "any authentication or permission failure, either transport", which the tree design does not deliver — a resolver-raised permission failure in a 200 raises `GraphQLError` | **Valid on the table and the example**, which described the diamond's capability. Corrected, with `except ApiError` named as the clause that spans both arrival paths. **Rejected on the FR-018 part**: the finding measured coverage against the unshipped diamond, but `except AuthenticationError` does not catch such a response today either, so nothing is lost against the pre-feature baseline. | +| R5 said "twelve generated classes" where nine are generated and three are adopted | Valid. The diagram beneath it was already right. | +| The new optional-attribute row justified itself as "raised without a server response", but the REST 404 has a response and merely no catalogue code | Valid — and the same error already fixed in the Messages section during the tenth pass, reintroduced verbatim in a new location. | +| The `NodeNotFoundError` construction-site counts were wrong: nine sites, not seven, with `node_type` supplied by five rather than three | Valid. Re-derived from source: eight `raise` statements plus one deferred construction at `store.py:184`; `identifier` is a mapping at eight and a plain string at one. | + +Two patterns worth naming. The "no server response" error is now the **fourth** instance of restating a +mechanism inaccurately away from where it is stated correctly — and the first where the same mistake was +made again *after* being fixed elsewhere, which means fixing an instance does not inoculate the +document. And this is the **second** time a count cited from memory of an earlier grep was wrong (the +first being "~12 `AuthenticationError` raise sites"). Counts belong re-derived at the moment of writing, +not recalled. + +### Fourteenth pass — automated PR review + +One finding, valid. The worked examples in the hierarchy contract commented that `TOKEN_EXPIRED` and +`AUTHENTICATION_REQUIRED` arrive as a real 401/403 while `PERMISSION_DENIED` is the resolver-raised one. +R5 says otherwise, having verified it: the formatter maps *any* error escaping a resolver onto a +catalogue code, `AuthorizationError` included, so all three codes can arrive inside an HTTP 200. The +example would have sent a consumer to `except AuthenticationError` and had them miss every 200-path +arrival of the same code. + +Fixed by having the examples **reference** the arrival table rather than restate which code goes where, +and by leading with `except ApiError` as the form to reach for. US3 scenario 5 generalised from "a +permission failure" to "any of the three authentication codes" for the same reason. + +**This is the fifth instance of one failure mode, and it settles the mitigation.** Restating a mechanism +away from its authoritative statement has now produced: the `AttributeError` justification, the +submodule path filter, the "no server response" phrasing, that same phrasing again after being fixed, +and now the arrival mapping. Fixing instances has not reduced the rate. The rule these artefacts need is +structural rather than editorial: for the handful of load-bearing mechanisms — which transport produces +which class, what `exc.code` holds, which codes get classes — state it in exactly one place and point at +it from everywhere else. Prose that paraphrases a mechanism is a defect waiting to be found, however +correct it is when written. + +### Fifteenth pass — automated PR review, and both spikes executed + +One finding, valid: the two catch forms were shown in a single code block, and since +`AuthenticationError` descends from `ApiError`, anyone copying the block as one handler sequence would +have got a dead final clause. Split into alternatives with the shadowing stated. + +Both outstanding spikes then ran, and both passed: + +- **The `ast` discovery walk** (R3, R4) classified every catalogue code against the real + `exceptions.py`: nine to generate, three with no class, three colliding. It confirms the corrected + counts from the thirteenth pass independently, fires the collision check on the four hypothetical + names, and is correctly blind to an inherited `CODE`. +- **The generated class shape** (R15) is clean under both `mypy` 1.11.2 and `ty` 0.0.14 with zero + suppressions, and the types a consumer sees were asserted by typed assignment rather than inspected. + +The discovery walk also surfaced something the plan had not stated: generation *aborts* until `base.py` +declares the three `CODE` attributes, so adoption is a prerequisite for the generator running at all +rather than merely for it producing the right output. Recorded in R4 and in the plan, because the +natural instinct is to build the generator first. + +### Sixteenth pass — one review finding, plus a systematic contradiction sweep + +The review found that R4's new spike paragraph enumerated a different collision set from R3's in the +same document — four codes against five. Both lists were factually correct, which is why neither had +been caught: the defect was that two lists each read as *the* set. Reconciled to one list of six in R3, +verified against the current module, with R4 referring to it rather than repeating it. + +The maintainer then asked for a check across the rest, so the load-bearing mechanisms were swept +against their authoritative statements rather than re-read as prose. Ten were checked: how many classes +exist per code, which transport produces which class, what `exc.code` holds, the payload-validation +fallback, the package layering, generation and validation placement, the broadening count, promoted +attribute optionality, the raise-site counts, and the message rule. + +| Mechanism | Result | +|-----------|--------| +| Package layering | **Contradiction.** R1's decision still said "a package of five modules" above a four-row layer table — stale from when the payload models had their own module. | +| Classes per code | **Contradiction.** The plan's summary said "12 on the GraphQL branch, 3 on the authentication branch", which describes the diamond: there are no classes on the authentication branch now. | +| Message rule | **Contradiction.** The quickstart's expectation lacked the "server-reported" qualifier that FR-022 and the contract carry, so it read as promising a catalogued message for a client-side raise. | +| Transport → class | Consistent, including a check for the *inverted* mapping, which appears nowhere. | +| `exc.code` contract | Consistent across FR-003, FR-012, R7, and the cross-version table. | +| Payload-validation fallback | Consistent across FR-004, FR-012, R6, and the data model. | +| Generation placement | Consistent — `backend.generate` and `backend.validate-generated` everywhere, with `frontend.regenerate-error-bindings` appearing only as the rejected alternative. | +| Broadening count | Consistent: two, in all three places. | +| Promoted optionality | Consistent: required on generated classes, optional on the three adopted ones, scoped correctly in each place. | +| Raise-site counts | Consistent: nine `NodeNotFoundError` construction sites, eleven `AuthenticationError` raise sites, four `GraphQLError` raise sites. | + +Three of ten mechanisms carried a contradiction, and all three were **stale text describing a +superseded design** rather than disagreements about the current one — the residue of the diamond +removal and the module split. That is the failure mode the "state once, reference" rule addresses, and +the sweep is the way to find the instances the rule was adopted too late to prevent. Worth repeating +after any change that alters a mechanism, rather than trusting an incremental review to notice. + +### Reading of the trend + +Findings are shrinking — three in the fifth pass, one in the sixth, none above P3 since the fourth — +and are now about the accuracy of rationale prose rather than about the design, while most were +introduced by the previous round's edits. That is the signature of edit churn rather than of remaining +design risk: each round is mostly correcting the last. The design has been stable across three rounds; +what keeps moving is the explanation of it. + +Two durable lessons. + +From the sixth pass: a claim stated as an enumeration of conditions invites narrowing forever, while +the same claim stated as the mechanism that produces it cannot be narrowed. Where a later reviewer +finds another condition to add, restate rather than qualify. + +From the seventh: the automated passes converged on prose while a structural question — which namespace +owns a task — sat untouched through six rounds, and a human found it immediately. The shrinking-findings +trend above was real but it measured the wrong thing: it tracked how much the reviewer had left to say +about the text, not how much design risk remained. Declaring the documents finished on that basis was +premature. Consistency review saturates; judgement review does not, and the two are not +interchangeable. + +From the eleventh, and the sharpest of the three: the reviewer that found the largest simplification in +eleven rounds was the one asking *why* a construct existed rather than whether it was described +correctly. Ten automated passes verified the diamond's internal consistency without once questioning +whether it should exist — and it existed only because an acceptance scenario written before a key fact +was verified had specified a type where a code attribute would do. Automated review checks a design +against itself; only a human checks it against its purpose. + +From the ninth: the saturation was an artefact of *incremental* review. Squashing the branch made the +reviewer read the whole diff again, and it immediately found seven real defects, most of them present +since the original plan commit — including a Constraints section that had contradicted the spec since +the E3 reversal in the third pass. Rounds three through eight each reviewed only the delta, so nothing ever re-examined the parts +that were not being edited. The lesson for the next long-lived design branch: a full-diff pass is worth +forcing periodically, not just at the end, and low incremental finding counts say nothing about the +parts the diff does not touch. + +**P7 as answered**: no top-level export change. `infrahub_sdk.exceptions` is the supported import path +for every exception, generated or hand-written; a consumer never needs to know which module defines +what; and no name importable from it today may stop being importable from it. That is now FR-005's +second sentence, a rewritten stability assumption in the spec, the "Stability" section of the hierarchy +contract, and `tests/unit/sdk/test_exceptions_public_names.py` — a snapshot test, so the guarantee is +checked rather than asserted. Modules beneath the package are explicitly internal. + +--- + +**Severity Legend**: + +- 🎯 **Must-Address**: Blocks proceeding to implementation +- 💡 **Recommendation**: Strongly suggested improvement +- 🤔 **Question**: Needs stakeholder input to resolve diff --git a/dev/specs/ifc-3034-error-catalogue/data-model.md b/dev/specs/ifc-3034-error-catalogue/data-model.md new file mode 100644 index 000000000..ad3c587d4 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/data-model.md @@ -0,0 +1,191 @@ +# Data Model: Error Catalogue in the Python SDK + +The entities here are exception classes and pydantic models. Field lists are the observable contract; +see [contracts/exception-hierarchy.md](./contracts/exception-hierarchy.md) for what a consumer may +rely on and [contracts/generator-contract.md](./contracts/generator-contract.md) for how the generated +half is produced. + +Which module holds what is [research.md](./research.md) R1. In short: the hand-written hierarchy sits +in `base.py`, which imports nothing from inside the package; the payload models and per-code exception +classes are generated into `catalogue.py`; `factory.py` sits above both. Imports only ever point +downward. + +The payload of a catalogued error is read as **typed attributes on the exception**, not as a payload +object. No class in this design exposes a `data` attribute, and no payload attribute is typed `Any` — +see [research.md](./research.md) R6. `Any` appears only in the raw decoded JSON that `extensions` and +`errors` hold, where the value types genuinely are unknown at the type level. + +## Hierarchy + +```text +Error (existing root, unchanged) +├── ApiError NEW — "the server reported an error" +│ ├── GraphQLError re-rooted under ApiError +│ │ ├── NodeNotFoundError re-rooted, unified, adopts NODE_NOT_FOUND +│ │ │ └── NodeInvalidError inherits the re-rooting +│ │ ├── BranchNotFoundError re-rooted, unified, adopts BRANCH_NOT_FOUND +│ │ ├── SchemaNotFoundError re-rooted, unified, adopts SCHEMA_NOT_FOUND +│ │ └── +│ └── AuthenticationError re-rooted under ApiError, name and constructor unchanged +└── … every other existing exception, untouched + +# GraphQLError and AuthenticationError are siblings; neither inherits from the other, +# and no class in the package has more than one parent. +# +# The three 401/403 codes get no class of their own. They arrive on whichever generic +# class their transport produces — AuthenticationError for a real 401/403, GraphQLError +# for a resolver-raised failure inside a 200 response — and carry their identity in +# `code`: "AUTHENTICATION_REQUIRED", "TOKEN_EXPIRED", "PERMISSION_DENIED". +``` + +## ApiError + +The base for "the server reported an error", carrying the parsed envelope (FR-001). + +| Attribute | Type | Notes | +|-----------|------|-------| +| `code` | `str \| None` | A catalogue code string, or `None`. Never an integer, so the REST envelope's integer `code` cannot be mistaken for a catalogue code (FR-003). Generated classes carry it as a class attribute, since they are only ever raised from a response. The factory sets it per-instance in the two cases a class attribute cannot cover: an unrecognised code on a generic class, and **an adopted class**, whose `code` must stay `None` on a client-side raise and be set when the same class carries a server-reported failure. | +| `http_status` | `int \| None` | The status the envelope declares, not the one observed on the wire, which the exception does not carry. `None` when the governing error declared none. Generated classes will carry the catalogue's declared status as a class attribute, covering a code whose envelope omitted it. The envelope's value is the catalogue's in all but one case: where the catalogue could not resolve a status more specific than 500, the server substitutes the HTTP status it is about to return, so a generated class's declared 500 and the envelope's value can differ. | +| `extensions` | `dict[str, Any] \| None` | The raw `extensions` mapping of the governing error, so nothing the SDK does not model is lost. `Any` here is the honest type of decoded JSON, not an escape hatch: the mapping's value types genuinely are not known at the type level. | +| `errors` | `Sequence[dict[str, Any]]` (empty tuple by default) | The complete, unreordered server error list (FR-013). Lives here rather than only on `GraphQLError` because the authentication branch must retain it too and FR-015 freezes `AuthenticationError`'s constructor. The default is an immutable empty tuple and is only a floor for a directly constructed `AuthenticationError`; anything built from a response, and every adopted class, is constructed with a list. | +| `query` | `str \| None` | Class-level default `None`. | +| `variables` | `dict \| None` | Class-level default `None`. | + +`ApiError` adds no required constructor arguments. Its subclasses keep the constructors they have +today, and the factory sets these attributes after construction. + +Two mechanisms sit behind these attributes, and they answer different questions. The class-level +defaults guarantee the attributes *exist* on any `ApiError`, including an `AuthenticationError` +constructed directly through the constructor FR-015 freezes — so `except GraphQLError as exc: +exc.errors` can never raise `AttributeError`. The three adopted classes additionally call +`GraphQLError.__init__` explicitly so their envelope state is set by the constructor that owns it and +`errors` is a *list*, matching the type documented on `GraphQLError` rather than the base's tuple +default. + +## GraphQLError + +| Attribute | Type | Change | +|-----------|------|--------| +| `errors` | `list[dict[str, Any]]` | Unchanged. Complete and unreordered; the first element governs the raised class (FR-013). Every subclass reaches this constructor — the adopted three call it with `errors=[]` — so it is a list on every `GraphQLError`, never the base's tuple default. | +| `query` | `str \| None` | Unchanged, still populated for catalogued failures (FR-024). | +| `variables` | `dict \| None` | Unchanged. | +| `message` | `str` | Constructor gains an optional `message`. Omitted → today's string, byte-identical. Supplied by the factory for a catalogued failure → names the code and the server's message, with no query text (FR-022, FR-023). | + +## AuthenticationError + +Name, constructor, and default message unchanged (FR-015). It gains no subclasses and inherits the +`ApiError` attributes. It remains the class raised for every failure the SDK observed as HTTP 401 or +403, with `code` set per-instance by the factory: `None` on the REST path, which carries no catalogue +code, and the catalogue code on the GraphQL path — `"TOKEN_EXPIRED"`, `"AUTHENTICATION_REQUIRED"`, or +`"PERMISSION_DENIED"`. + +`GraphQLError` likewise carries a `code` when a resolver-raised authentication or permission failure +arrives inside an HTTP 200 response. So both generic classes can be catalogued or uncatalogued, and +`exc.code` is the only thing that distinguishes those cases. + +## Adopted classes + +Three hand-written classes declare the catalogue code they represent, which is how the generator +knows not to define them: + +| Class | `CODE` | Payload field promoted onto | Type change | +|-------|--------|----------------------------|-------------| +| `NodeNotFoundError` | `NODE_NOT_FOUND` | `node_kind` → `node_type`, `identifier` → `identifier` | `identifier` widens to `Mapping[str, list[str]] \| str` | +| `BranchNotFoundError` | `BRANCH_NOT_FOUND` | `branch_name` → `identifier` | none | +| `SchemaNotFoundError` | `SCHEMA_NOT_FOUND` | `kind` → `identifier` | none | + +Their `from_payload` is hand-written, because the target attribute names already exist and are not the +catalogue's. Every construction shape in use today keeps working: the filter mappings passed from +`infrahub_sdk/store.py` and `infrahub_sdk/client.py`, and the plain string passed from +`infrahub_sdk/file_handler.py` that the current annotation wrongly excludes. `exc.code is not None` +distinguishes a server-reported raise from a client-side one. + +Unlike the generated classes, **every attribute on these three is optional**, even where the catalogue +declares the underlying field required. Neither provenance populates the full set — the catalogue +supplies no `branch_name`, and four of the nine construction sites supply no node kind — so a +required attribute would be a promise the class cannot keep. See [research.md](./research.md) R9, which +also records the tripwire for splitting these classes and why that is a later change. + +## Generated exception classes + +Generated into `catalogue.py`, one per catalogue code that is not adopted. Each declares: + +| Member | Value | +|--------|-------| +| `CODE` | The catalogue code string. | +| `code` | Class attribute equal to `CODE`. | +| `http_status` | The catalogue-declared status. | +| `DATA_MODEL` | The payload model class, used to validate the envelope. | +| Promoted attributes | One per payload field, typed as the catalogue declares it — required fields non-optional, nullable fields carrying their declared default. Assigned in `__init__`, so they always exist. | +| `from_payload` | Classmethod taking a validated payload plus the envelope, returning the constructed exception. The factory's only construction path. | +| docstring | The catalogue's `description`, plus its stability level. | + +The declared status decides whether a code gets a class at all, not which parent it takes: a 401/403 +code gets none, and everything else descends from `GraphQLError` (FR-008). Twelve codes reach a class: +nine generated here, and the three the SDK already had, which the generated module imports rather than +redeclares. Nine generated classes +today, each with exactly one parent. + +## Generated payload models + +Generated into `catalogue.py` alongside the classes, one per catalogue code, including codes with an +empty payload and including the adopted codes. Named from the catalogue's `data_schema.title` verbatim +(FR-007), so SDK and frontend binding names agree. `model_config = ConfigDict(extra="ignore")` makes an +unknown field from a newer server a no-op (FR-004). Required fields stay required; nullable fields carry +the catalogue's declared default. + +These models are the validation mechanism and the source of the promoted attributes' types. They are +importable — useful for building a test fixture — but a consumer never reads one off an exception. + +A payload that fails validation falls back to the generic class for the branch, with the code still +readable. The server has a reachable path that emits an empty payload under a code whose schema declares +required fields, so this is a real state rather than a hypothetical one. + +Field types, mapped from the catalogue's JSON Schema vocabulary: + +| JSON Schema | Python | +|-------------|--------| +| `{"type": "string"}` | `str` | +| `{"type": "string", "format": "date-time"}` | `datetime` | +| `{"type": "integer"}` / `{"type": "number"}` | `int` / `float` | +| `{"type": "boolean"}` | `bool` | +| `{"type": "array", "items": T}` | `list[T]` | +| `{"anyOf": [T, {"type": "null"}]}` | `T \| None` | + +Anything outside that vocabulary fails generation loudly rather than emitting a guess. + +## Resolution map + +`CODE_TO_EXCEPTION: dict[str, type[ApiError]]` in `catalogue.py`, covering every catalogue +code: generated classes for most, imported adopted classes for the three. It is the only lookup the +factory performs, and a miss is the entire fallback story (FR-012). + +## The two envelopes + +Both shapes are read; only one is a catalogue envelope. + +**GraphQL** (`/graphql`) — the catalogue envelope. Data errors arrive as HTTP 200 with an `errors` +array; auth failures arrive as a real 401/403. + +```json +{ + "errors": [ + { + "message": "…", + "extensions": {"code": "UNIQUENESS_VIOLATION", "http_status": 422, + "data": {"node_kind": "TestPerson", "fields": ["name"]}} + } + ] +} +``` + +**REST** (`/api/…`) — the legacy envelope, where `extensions.code` is an *integer* mirroring the HTTP +status. No catalogue code, no `data`. Parsed for its messages only; `exc.code` stays `None`. + +```json +{"errors": [{"message": "…", "extensions": {"code": 401}}]} +``` + +## State transitions + +None. Exceptions are constructed, raised, and read. diff --git a/dev/specs/ifc-3034-error-catalogue/plan.md b/dev/specs/ifc-3034-error-catalogue/plan.md new file mode 100644 index 000000000..88a7304b7 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/plan.md @@ -0,0 +1,227 @@ +# Implementation Plan: Error Catalogue in the Python SDK + +**Branch**: `pog-error-catalogue-IFC-3034` | **Date**: 2026-08-24 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `dev/specs/ifc-3034-error-catalogue/spec.md` + +## Summary + +Make ordinary SDK operations raise the specific exception for the failure the server reported, on +both the async and sync clients, without ever raising on a payload the SDK does not recognise. + +The approach has four parts: + +1. **A parsed envelope on the base classes.** A new `ApiError` base carries `code`, `http_status`, the + raw `extensions`, and the server error list; `GraphQLError` and `AuthenticationError` both descend + from it. The envelope is read by one raise-time factory shared by every existing raise site, so the + code is readable against any server version even with no generated bindings at all. Because the + catalogue is GraphQL-only, every generated class descends from `GraphQLError` and from nothing else. + The three 401/403 codes get no class: they are the only codes that arrive on two transports, so each + is raised as the generic class its transport already produces and identified by `exc.code`. The + hierarchy stays a tree. + + The payload is **not** an attribute. Each catalogued class promotes its payload's fields to directly + typed attributes — `exc.node_kind`, `exc.fields` — typed exactly as the catalogue declares them. + That is what US1 asks for, it matches how the three adopted classes already work, and it means no + class needs a loosely typed payload attribute for subclasses to narrow. Nothing in this design is + typed `Any` beyond the raw decoded JSON in `extensions` and `errors`. +2. **Generated per-code bindings.** Infrahub renders one exception class and one pydantic payload + model per catalogue code into a single module in the SDK submodule, next to the schema models and + protocols it already generates there, and its existing generated-artefact validation gains a check + for it. The module is generated in full and imports only the hand-written base, which is what keeps + the package's import graph one-way. +3. **Reconciling the three names that already exist.** `NodeNotFoundError`, + `BranchNotFoundError`, and `SchemaNotFoundError` are adopted by the generator rather than + duplicated: the hand-written classes declare the code they represent, the generator sees that + and imports them instead of defining them. +4. **Removing the string matching.** The silent-refresh decision reads the code, falling back to + the legacy message check only for servers that predate the catalogue. + +The catalogue holds 15 codes today: 12 get a class under the GraphQL branch — nine generated, three +adopted from the SDK's existing names — and the three declaring 401 or 403 get none, carrying their +identity in `exc.code` instead. + +## Technical Context + +**Language/Version**: Python 3.10-3.13 (SDK); the generator runs under the Infrahub repository's +Python environment + +**Primary Dependencies**: pydantic >= 2.0 (payload models), httpx (transport), typer + rich (CLI); +Jinja2 and invoke on the Infrahub side for generation. No new runtime dependency. + +**Storage**: N/A + +**Testing**: pytest with `asyncio_mode = "auto"`, `pytest-httpx` for transport-level mocking; +response-envelope fixtures under `tests/fixtures/`; both client variants exercised through the +`BothClients` fixture in `tests/unit/sdk/conftest.py` + +**Target Platform**: Library consumed by `infrahubctl`, the Infrahub Ansible collection, and +external Python applications + +**Project Type**: Library plus its CLI, spanning two repositories (bindings are generated in +Infrahub, consumed here) + +**Performance Goals**: None. The factory runs once per failed request; parsing is a dict lookup plus +one pydantic validation. + +**Constraints**: + +- Parsing MUST NOT raise for any envelope shape, including an unknown code, an unknown payload + field, an absent `extensions`, or a pre-catalogue integer `code`. +- Every existing exception name, constructor signature, and `except` clause keeps working. +- The SDK holds no copy of the catalogue schema; the generated bindings are the only artefact that + crosses the repository boundary. +- No circular imports. The exceptions package is strictly layered, and generated files are generated + in full — never a hand-edited region inside a generated file, and never a generated region inside a + hand-written one. +- `infrahub_sdk.exceptions` is the one supported import path for every exception, and no name + importable from it today may stop being importable from it. The restructuring must be invisible from + outside the package. +- The raised class is a function of the response alone — the first error's code, its payload's validity, + and the transport the SDK observed — and never of binding freshness. Regenerating bindings must not + change which exception a byte-identical response produces for a code the SDK already knows. + +**Scale/Scope**: 15 catalogue codes; 11 `AuthenticationError` raise sites and 4 `GraphQLError` +raise sites collapse onto two factories; one generated module; one CLI ladder reordering; one new +documentation topic page. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Assessment | +|-----------|------------| +| I. Async/Sync Dual API Parity | **Pass.** No new public client method, so `test_method_count` and `test_validate_method_signature` are untouched. The behaviour change lands on the async and sync paths of `_execute_graphql`, `_execute_graphql_with_file`, and the relogin wrappers, which are separate implementations; both are covered by parametrized tests over `["standard", "sync"]`. | +| II. Backward Compatibility & Public API Stability | **Pass, with two documented broadenings.** Nothing is removed or renamed, so no deprecation path is required. `except GraphQLError` additionally catches client-side node/branch/schema lookup misses, and `NodeNotFoundError.identifier` widens to admit the plain string the file handler already passes. Both get towncrier fragments per FR-016. `infrahub_sdk.exceptions` is treated as public and is the one supported import path; a snapshot test pins that no name importable from it disappears, so restructuring it into a package is invisible from outside. | +| III. Layered Architecture | **Pass.** All envelope parsing, resolution, and message construction lives in `infrahub_sdk/`. The CLI change is confined to presentation: reordering its `isinstance` ladder and degrading the GraphQL renderer when there are no server errors to render. | +| IV. Type Safety & Typed Errors | **Pass, with no suppressions.** This principle is the feature. Generated payload models are pydantic v2; every failure mode gets a specific subclass under `Error`; each catalogued class carries its payload's fields as attributes typed exactly as the catalogue declares them. `Any` appears only where the value genuinely is unknown at the type level — the raw decoded JSON in `extensions` and `errors`, the latter already annotated that way today. No `# type: ignore` is anticipated anywhere; if the R15 spike shows one is needed, that is a signal the shape is wrong rather than a licence to add it. | +| V. Test-First Development | **Pass.** Tests ship in the same change: envelope fixtures per code, cross-version fallback cases, ladder assertions, relogin cases, and both-client parity. Deliberate behaviour changes (the message change, the re-rooting) are pinned by tests that assert the new behaviour rather than being worked around. Because every fixture is authored alongside the parser that reads it, the mocked suite alone could pass against an envelope shape the server never sends — so two real catalogued failures are also driven through testcontainers, which is where the constitution puts behaviour that depends on real server responses. | +| VI. Format & Lint Before Commit | **Pass, with no silencing.** `uv run invoke format lint-code` and `lint-docs`; the generated modules are `ruff format`ed by the generator, as the other generated artefacts are. This row first planned to re-export the generated classes with `from .catalogue import *` and to record the resulting `F403` as a commented `per-file-ignores` entry. The façade lists its re-exports by explicit name instead, so no `F403` arises and no entry was added: a wildcard would also have promoted the payload models, the lookup maps and the dispatch helper onto the package surface. The hand-maintained list is held against what the source modules define by a test, which is what the wildcard was meant to buy. | +| VII. Documentation Accuracy | **Pass.** A new hand-written topic page describes the hierarchy and the cross-version guarantees, linking to Infrahub's published catalogue for the code list rather than restating it. Converting `exceptions.py` into a package requires categorising it in `tasks.py::get_modules_to_document`; it goes in `packages_to_ignore`, which preserves today's `sdk_ref` output exactly and avoids coupling the SDK's `docs-validate` to an Infrahub-side regeneration. | + +**Result**: no violations. Complexity Tracking is empty. + +## Project Structure + +### Documentation (this feature) + +```text +dev/specs/ifc-3034-error-catalogue/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ ├── exception-hierarchy.md # The consumer-facing contract +│ └── generator-contract.md # The Infrahub-to-SDK generation contract +├── checklists/ +│ └── requirements.md +└── tasks.md # Phase 2 output (/speckit-tasks, not created here) +``` + +### Source Code + +This repository (`infrahub-sdk-python`): + +```text +infrahub_sdk/ +├── exceptions/ # exceptions.py becomes a package, layered strictly one-way +│ ├── base.py # layer 0: today's exceptions.py + ApiError + adopted-code markers +│ ├── catalogue.py # GENERATED in full — layer 1: payload models, per-code classes, map +│ ├── factory.py # layer 2: raise-time resolution from a response envelope +│ └── __init__.py # layer 3: façade, re-exports base + catalogue, defines __all__ +├── client.py # Raise sites and the relogin wrappers call the factories +├── object_store.py # REST auth raise sites call the auth factory +├── file_handler.py # REST auth raise site calls the auth factory +├── analyzer.py # Pre-existing GraphQLError(str) misuse, corrected +└── ctl/ + └── utils.py # Ladder reordering and renderer degradation + +tests/ +├── fixtures/ +│ └── error_catalogue/ # Response-envelope fixtures per code and per cross-version case +└── unit/ + ├── sdk/ + │ ├── test_exceptions.py # Hierarchy, single parents, naming, adoption, messages + │ ├── test_exceptions_layering.py # Asserts the import graph stays one-way + │ ├── test_exceptions_public_names.py # No name importable from the package may disappear + │ ├── test_error_catalogue.py # Factory: resolution, precedence, fallbacks, totality + │ ├── test_relogin_headers.py # Extended with the typed refresh decision + │ └── test_client.py # Both-client raise-path assertions + └── ctl/ + └── test_utils.py # Ladder behaviour and no-server-errors rendering + +tests/integration/ +├── test_infrahub_client.py # Real catalogued failures, async +└── test_infrahub_client_sync.py # The same two failures, sync + +docs/docs/python-sdk/topics/ +└── error_handling.mdx # New topic page (sidebar globs this directory) + +changelog/ # towncrier fragments: typed errors, identifier widening, broadening +pyproject.toml # pytest markers: catalogue, crossversion, malformed, message +tasks.py # Add `exceptions` to packages_to_ignore for API-doc generation +``` + +The `opsmill/infrahub` repository (requirements FR-025 to FR-027): + +```text +backend/templates/ +└── generate_sdk_errors.j2 # New template: payload models, exception classes, resolution map +tasks/ +└── backend.py # Renderer called from `generate`; diff added to validate_generated +.github/ +└── workflows/ci.yml # backend-validate-generated gains the catalogue trigger +``` + +The existing path filters need no change: a submodule artefact cannot be filtered on from the +superproject, and `sdk_files`, `backend_files`, and `error_catalogue_files` already cover the three +things that can change. See [research.md](./research.md) R2. + +**Structure Decision**: `infrahub_sdk/exceptions.py` becomes the `infrahub_sdk/exceptions/` package, +with a strictly one-way import graph and no cycle anywhere in it: + +```text +base.py (written) → imports nothing from inside the package +catalogue.py (generated) → imports base +factory.py (written) → imports base and catalogue +__init__.py (written) → imports all of the above +``` + +Each module may import only from a strictly lower layer, and no module ever imports the package +façade — internal code always names the concrete submodule. `base.py` sitting at the bottom with no +intra-package imports at all is what the payload decision buys: because a payload's fields are promoted +to attributes rather than exposed as an object, no hand-written class needs to name a generated model +type, so the generated module needs no separate models module below `base.py` and no type-only import +to keep the hand-written hierarchy independent of generated code. + +The rule is enforced by `tests/unit/sdk/test_exceptions_layering.py`, which parses each module and +fails on any upward import, so the property cannot decay silently. Every `from .exceptions import X` +in the codebase keeps working unchanged. See [research.md](./research.md) R1 and R6. + +## Sequencing + +Priority labels in the specification describe value, not order. The generator is US5 (P2) but produces +the per-code classes US1 (P1) delivers, so task ordering must follow the dependency: the Infrahub-side +generator and its first hand-verified run come first. FR-002 softens this — the envelope parses onto the +base classes with no bindings at all, so `code`, `http_status`, and the typed relogin decision (US4) are +independently landable — but the typed per-code classes are not. See [research.md](./research.md) R16. + +## Prerequisite inside the SDK + +The generator aborts on a derived name that collides with an existing class which has not declared that +code, so the three `CODE` declarations in `base.py` are a prerequisite for generation succeeding at all +— not merely for it producing the right output. Verified by executing the discovery walk against the +current sources; see [research.md](./research.md) R4. Task ordering must put the adoption declarations +before the generator's first run, against the instinct to build the generator first. + +## Cross-repository landing order + +The SDK change lands first and Infrahub then bumps its submodule pointer to it — the pattern both +repositories already follow, and the only order under which Infrahub's content-level validation can +pass. See [research.md](./research.md) R17, which notes the one confirmation still worth getting. + +## Complexity Tracking + +No Constitution Check violations, so nothing to justify here. diff --git a/dev/specs/ifc-3034-error-catalogue/quickstart.md b/dev/specs/ifc-3034-error-catalogue/quickstart.md new file mode 100644 index 000000000..c0552b2b9 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/quickstart.md @@ -0,0 +1,202 @@ +# Quickstart: validating the error catalogue in the SDK + +Runnable checks that prove the feature works end to end. Each scenario names what it proves and the +success criterion it maps to. Details of the promised behaviour live in +[contracts/exception-hierarchy.md](./contracts/exception-hierarchy.md); the generation half lives in +[contracts/generator-contract.md](./contracts/generator-contract.md). + +## Prerequisites + +```bash +uv sync --all-groups --all-extras +``` + +Two checkouts are involved. Scenarios 1 to 6 run here; scenario 7 runs from an Infrahub checkout with +the SDK as its `python_sdk` submodule. + +## Scenario 1 — Typed errors and their payloads + +Proves that every catalogue code is reachable as its own type with its payload's fields as typed +attributes, and that a developer never has to read a message (SC-001). + +```bash +uv run pytest tests/unit/sdk/test_error_catalogue.py -v +``` + +Expected: one passing case per catalogue code. Each asserts the raised class and concrete attribute +values read directly off the exception — `exc.node_kind` and `exc.fields` for `UNIQUENESS_VIOLATION`, +`exc.node_type` and `exc.identifier` for `NODE_NOT_FOUND` — and asserts `exc.code` and +`exc.http_status` match the catalogue entry. No test reads a payload object, because there isn't one. + +## Scenario 2 — Cross-version tolerance + +Proves that nothing raises during parsing on any server version, old or new (SC-004). + +```bash +uv run pytest tests/unit/sdk/test_error_catalogue.py -k crossversion -v +``` + +Expected: passing cases for an unknown code (generic class for the branch, `code` readable as a +string), an unknown payload field (ignored), an absent `extensions` (`code is None`, today's +behaviour), a pre-catalogue integer `code` on `/graphql` (`code is None`), and a payload that +violates the catalogue contract (the generic class for the branch, with `exc.code` still readable). No +case raises during parsing. + +Then prove the factory cannot make things worse than they were, by feeding it malformed envelopes — +`errors` as a bare string, `extensions` as a list, `code` as a nested object: + +```bash +uv run pytest tests/unit/sdk/test_error_catalogue.py -k malformed -v +``` + +Expected: each degrades to today's generic exception. A bug in resolution can never replace the +server's error with an SDK `TypeError`. + +## Scenario 3 — The hierarchy and the existing clauses + +Proves that no `except` clause loses coverage, that the CLI ladder is not shadowed, and that one +clause catches every server-reported error on either transport (SC-003, SC-008). + +```bash +uv run pytest tests/unit/sdk/test_exceptions.py tests/unit/sdk/test_exceptions_public_names.py \ + tests/unit/ctl/test_utils.py -v +uv run pytest tests/unit/ -q +``` + +Expected: a real 401 carrying `TOKEN_EXPIRED` is caught by `except AuthenticationError` with +`exc.code == "TOKEN_EXPIRED"`, and a `PERMISSION_DENIED` inside an HTTP 200 is caught by +`except GraphQLError` with `exc.code == "PERMISSION_DENIED"`; no class in the package has more than one +parent; `NodeInvalidError` is an instance of `GraphQLError`; `ApiError` catches both a +GraphQL and an authentication failure; every name importable from `infrahub_sdk.exceptions` before the +change is still importable from it; reading `exc.errors`, `exc.query`, and `exc.variables` off a purely +client-side `NodeNotFoundError` returns empty/`None` rather than raising `AttributeError`; driving +`handle_exception` with `NodeNotFoundError` produces the not-found rendering rather than the GraphQL +error rendering; a catalogued failure renders as its code plus the server's message rather than as +"Authentication failure" or a bare error list; rendering an exception with no server errors behind it +prints the message. The full unit suite is green, with any deliberately changed assertion updated rather +than skipped. + +## Scenario 3b — The exceptions package has no import cycle + +Proves the package's import graph points strictly downward, so the cycle the layout was designed to +avoid cannot creep back in. + +```bash +uv run pytest tests/unit/sdk/test_exceptions_layering.py -v +uv run python -c "import infrahub_sdk.exceptions" +uv run python -c "import infrahub_sdk.exceptions.base" +``` + +Expected: the layering test passes, reporting any intra-package import that points at its own layer or +higher, and any import reaching another part of the SDK at all — including imports written inside a +function body, which is how a cycle usually returns once the obvious route is closed. Both bare imports +succeed on their own: the package depends on nothing else in the SDK, and `base.py` depends on nothing +else in the package, generated or otherwise. + +## Scenario 4 — Async and sync parity + +Proves both clients raise the same type with the same attributes (SC-006). + +```bash +uv run pytest tests/unit/sdk/test_error_catalogue.py tests/unit/sdk/test_client.py -k "standard or sync" -v +``` + +Expected: every raise-path case passes for both `client_type` values, asserting the same class and the +same payload attributes. + +## Scenario 5 — No string matching left, and refresh still works + +Proves the silent-refresh decision is typed while a pre-catalogue server still refreshes (SC-002). + +```bash +uv run pytest tests/unit/sdk/test_relogin_headers.py -v +grep -rn "Expired Signature" infrahub_sdk/ +``` + +Expected: a refresh is attempted for a 401 carrying `TOKEN_EXPIRED` and for a 401 carrying the legacy +`"Expired Signature"` message, and not for an unrelated 401. The `grep` returns exactly one site — the +documented pre-catalogue fallback inside the shared decision helper, down from the two occurrences +today, one in each relogin wrapper — and no other message match for a catalogued failure. The GraphQL +schema-validation probing used for server feature detection is out of scope and still present. + +## Scenario 6 — Messages, lint, and docs + +Proves the message change and the repository gates (SC-007). + +```bash +uv run pytest tests/unit/sdk/test_exceptions.py -k message -v +uv run invoke format lint-code +uv run invoke docs-generate && uv run invoke docs-validate +uv run invoke lint-docs +ls changelog/ +``` + +Expected: a *server-reported* catalogued failure's message names the code and the server's message and +contains no query text; an uncatalogued failure's message is byte-identical to today's, as is that of a +unified class raised with no catalogue code behind it. `docs-validate` passes with +no change to `sdk_ref`, since the `exceptions` package is categorised as ignored for API-doc +generation — if it is not categorised at all, `docs-generate` fails with +`Uncategorized packages under infrahub_sdk/`. `changelog/` carries fragments for the typed errors, the +`identifier` widening, and the `except GraphQLError` broadening. + +## Scenario 6b — The envelope shape is what the server actually sends + +Proves the contract against a live server rather than against fixtures written alongside the parser +that reads them. Requires Docker. + +```bash +uv run pytest tests/integration/test_infrahub_client.py tests/integration/test_infrahub_client_sync.py \ + -k "catalogue" -v +``` + +Expected: saving a node that collides on a unique attribute raises `UniquenessViolationError` with the +node kind and colliding fields populated from the real payload; deleting a missing node raises +`NodeNotFoundError` with its kind and identifier. Both pass on the async and sync clients. If these +fail while scenario 1 passes, the unit fixtures encode an envelope the server does not send. + +## Scenario 7 — Generated bindings cannot silently drift + +Proves that a catalogue change omitting regeneration fails validation, and that a regenerated artefact +is byte-identical to a fresh generation (SC-005). Run from the Infrahub checkout. + +```bash +uv run invoke backend.generate +git -C python_sdk diff --exit-code infrahub_sdk/exceptions/catalogue.py # clean: byte-identical +uv run invoke backend.validate-generated # passes +``` + +Then prove the negative: add a code to the backend catalogue, regenerate the catalogue JSON alone, and +re-run the validator. + +```bash +uv run invoke backend.export-error-catalogue +uv run invoke backend.validate-generated # must fail, naming the stale submodule artefact +``` + +Expected: the second run exits non-zero with a hint pointing at `uv run invoke backend.generate`. +Revert the catalogue change afterwards. + +The generator is a pure text transform over `schema/error-catalogue.json` plus an `ast` parse of the +SDK's `base.py`, so it needs no running Infrahub — only the submodule checked out, which every Python +job in that repository already requires. + +## Manual end-to-end check + +Against a running Infrahub, with the SDK installed: + +```python +from infrahub_sdk import InfrahubClient +from infrahub_sdk.exceptions import ApiError, UniquenessViolationError + +client = InfrahubClient() +node = await client.create(kind="TestPerson", name="Jane") # a name that already exists +try: + await node.save() +except UniquenessViolationError as exc: + print(exc.code, exc.http_status, exc.node_kind, exc.fields) +except ApiError as exc: + print("uncatalogued or unrecognised:", exc.code) +``` + +Expected: the specific class, its catalogue code and status, and the colliding field names read +directly off the exception with no guard and no intermediate object — and no query text in the message. diff --git a/dev/specs/ifc-3034-error-catalogue/research.md b/dev/specs/ifc-3034-error-catalogue/research.md new file mode 100644 index 000000000..a5b3c8919 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/research.md @@ -0,0 +1,819 @@ +# Research: Error Catalogue in the Python SDK + +Every decision below was reached against the two checkouts as they stand, not from the specification +alone. The specification carried no `NEEDS CLARIFICATION` markers; what follows resolves the +mechanism questions it deliberately left to the plan. + +## Survey findings the decisions rest on + +Every count and example below is read from `schema/error-catalogue.json` on **`opsmill/infrahub@develop`**, +which is the line this feature pairs with. That matters: `stable` currently carries 14 codes and no +`UNIQUENESS_VIOLATION`, so a reviewer reading the wrong branch will find the numbers off by one and the +headline example missing. The design does not depend on the count — the generator reads whatever the +catalogue holds — but the acceptance scenarios do, and US1 scenario 1 is only demonstrable against a +server whose catalogue includes `UNIQUENESS_VIOLATION`. + +On `develop` the catalogue (`infrahub_catalogue_version: "1"`) holds 15 codes. Twelve declare a non-auth +status (400, 404, 422, 423, 500) and three declare 401/403 (`AUTHENTICATION_REQUIRED`, `TOKEN_EXPIRED`, +`PERMISSION_DENIED`). Two codes declare an empty payload (`AUTHENTICATION_REQUIRED`, +`UNDEFINED_ERROR`). One field carries `format: date-time` (`TOKEN_EXPIRED.expired_at`); the rest are +strings, nullable strings, and one string array (`UNIQUENESS_VIOLATION.fields`). + +Three derived class names collide exactly with hand-written SDK classes: `NODE_NOT_FOUND` → +`NodeNotFoundError`, `BRANCH_NOT_FOUND` → `BranchNotFoundError`, `SCHEMA_NOT_FOUND` → +`SchemaNotFoundError`. + +Raise sites that need to change: `GraphQLError` is raised at `infrahub_sdk/client.py:1360` and +`:2348` (`_execute_graphql`, async and sync) and `:1429` and `:2417` (the file-upload variants). +`AuthenticationError` is raised from the same four GraphQL paths (`:1350`, `:1688`, `:2338`, `:3857`) +and from six REST paths in `infrahub_sdk/object_store.py` plus one in `infrahub_sdk/file_handler.py`, +all with the identical four-line "decode, collect messages, join with ` | `" shape. + +Infrahub already owns two generation paths that write into the submodule +(`tasks/backend.py::_generate_schemas` and `::_generate_protocols`) and validates both with +`git -C python_sdk diff --exit-code` inside `validate_generated`. Separately, +`tasks/frontend.py::regenerate_error_bindings` regenerates the three catalogue-derived artefacts +(the JSON, the frontend TypeScript bindings, the docs page) and `check_error_bindings` diffs them. +The frontend's hand-rolled generator (`frontend/app/scripts/generate-error-bindings.mjs`) is the +closest precedent for what the SDK generator must do. + +Infrahub declares the SDK as `infrahub-sdk = { path = "python_sdk", editable = true }`, so `uv sync` +fails outright without the submodule. Every Python job in that repository therefore already needs +`submodules: true`, and 20 of the 32 checkouts in `ci.yml` set it. + +## R1 — Where the generated bindings live in the SDK, with no circular imports + +**Decision**: convert `infrahub_sdk/exceptions.py` into a package of four modules in a strict, +enforced layer order. No module imports a module at its own level or above, and no module imports the +package façade. + +| Layer | Module | Written or generated | May import | +|-------|--------|----------------------|------------| +| 0 | `base.py` | hand-written | nothing from inside the package | +| 1 | `catalogue.py` | generated, in full | `base` | +| 2 | `factory.py` | hand-written | `base`, `catalogue` | +| 3 | `__init__.py` | hand-written | all of the above | + +**Rationale**: two constraints pull against each other. The generated classes must descend from the +base classes, and `infrahub_sdk.exceptions` must re-export the generated classes (FR-005). Keeping +both in one module makes that a cycle whose only resolution is a wildcard import at the bottom of the +file — fragile, and in exactly the module that must never fail to import. Splitting hand-written from +generated makes the dependency one-way and puts the façade above both. + +Nothing in `base.py` needs anything from the generated module. That falls out of the payload decision +in R6: because a payload's fields are promoted to directly typed attributes on the exception rather +than exposed as a payload object, no hand-written class needs to name a generated model type — not even +the three adopted classes. So the hand-written hierarchy imports and behaves identically whether or not +the generated module is present, with no type-only import required to achieve it. + +**Enforcement**: `tests/unit/sdk/test_exceptions_layering.py` parses each module in the package with +`ast` and asserts every intra-package import points to a strictly lower layer. It walks *every* +`Import` and `ImportFrom` node, not only module-level ones — a function-body import is the classic way +a cycle gets reintroduced once the obvious route is closed — and it counts imports inside +`TYPE_CHECKING` blocks. An upward import fails the pull request that adds it, so the property cannot +decay into the cycle it was designed out of. No new dependency. + +**Consequences to handle**: + +- `tasks.py::get_modules_to_document` auto-discovers packages under `infrahub_sdk/` and raises + `ValueError` for any package that is not explicitly categorised, so `"exceptions"` must be + categorised. It goes in `packages_to_ignore`. Documenting it would generate `sdk_ref` pages from the + *generated* classes, which couples the SDK's own `docs-validate` to an Infrahub-side regeneration: + Infrahub writes new bindings into the submodule, nothing re-runs the SDK's `docs-generate`, and the + next SDK pull request fails on stale docs for a change made in another repository. `exceptions.py` is + not documented in `sdk_ref` today, so ignoring the package preserves current behaviour exactly and + creates no coupling. FR-028 is satisfied by the hand-written topic page, which is the better artefact + for a hierarchy anyway. +- The façade re-exports by explicit name from both `base` and `catalogue`, rather than the wildcards + this section first proposed. A wildcard would have kept the export surface automatic as codes are + added, at the cost of an `F403` `per-file-ignores` entry; it would also have promoted `catalogue`'s + payload models, lookup maps and dispatch helper onto the package surface, where each name becomes a + stability promise the package never chose to make. Written out by hand, the file says what it + exports, and a test holds it against what the source modules define so the two cannot drift. + `infrahub_sdk.exceptions` is the supported import path for consumers; the submodules beneath it are + internal, and the façade is what makes that true rather than aspirational. +- First generation is a bootstrap: the SDK pull request lands `catalogue.py` produced by running the + Infrahub generator from the paired branch, verified by hand once, which is what US5 anticipates. + +**Alternatives considered**: + +- *A sibling `infrahub_sdk/error_catalogue.py`.* Fails FR-005 — `infrahub_sdk.exceptions` could not + re-export it without reintroducing the cycle. +- *Keep one `exceptions.py` and wildcard-import the generated module at the bottom of the file.* + Works, and is precisely the fragility this decision exists to avoid. +- *Generate the whole of `exceptions.py`.* Rejected. Roughly thirty hand-written exceptions + unrelated to the catalogue live there — rate limiting, fragment rendering, YAML validation, and the + three unified classes with custom constructor and `__str__` logic. Generating the file would put all + of that under a generator owned by another repository, and would require the generator to carry + hand-written class bodies as template data. What this design does take from that idea is the part + worth keeping: the generated file is generated *in full*, so there is never a hand-edited region + inside a generated file or a generated region inside a hand-written one. +- *Split the generated models into their own module below `base.py`.* Necessary only if a hand-written + class must name a generated model type, which the promotion decision in R6 removes. Without that + need the split buys nothing and costs a module, a second artefact to validate, and an ordering + constraint on generation. + +## R2 — Where the generator lives and how validation is wired + +**Decision**: one Jinja2 template `backend/templates/generate_sdk_errors.j2`, rendered by a +`_generate_sdk_error_bindings` helper in `tasks/backend.py` and called from the existing +`backend.generate` task, with `backend.validate-generated` gaining +`git -C python_sdk diff --exit-code infrahub_sdk/exceptions/catalogue.py`. + +No new command, and no task outside the `backend` namespace generates the SDK's bindings. + +**Rationale**: the entry point already exists and already does exactly this job. + +```python +@task +def generate(context: Context) -> None: + """Generate internal backend models.""" + _generate_schemas(context=context) # → python_sdk/infrahub_sdk/schema/generated + _generate_protocols(context=context) # → python_sdk/infrahub_sdk/protocols.py +``` + +`backend.generate` writes into the SDK submodule today, paired with `backend.validate-generated`. The +error bindings are a third artefact of the same kind — Infrahub-generated, SDK-destined — so they +belong in the same pair rather than in a second entry point for the same category of work. + +The namespace boundary that matters here is **producer, not location**. `backend` names the side that +generates; that it writes into `python_sdk/` is the point of the task, not a violation of it. The +frontend is a sibling *consumer* of the same catalogue, which is why hooking regeneration into +`frontend.regenerate-error-bindings` was wrong: a consumer generating another consumer's artefact. + +Two incidental notes for the implementer. `generate`'s docstring says "internal backend models", which +has been inaccurate since it started generating SDK protocols; adding a third artefact is a good moment +to correct it. And `_generate_custom_graphql_types` is called by `validate_generated` but not by +`generate`, so `generate` is already the smaller set of the two — adding the error bindings to both +keeps them in step. + +**On the CI job**: no new step and no new job. `backend-validate-generated` runs +`backend.validate-generated`, which now covers the error bindings too. Submodule availability is not a +discriminator in that placement: `infrahub-sdk = { path = "python_sdk", editable = true }` in Infrahub's +`pyproject.toml` means `uv sync` fails without the submodule, so every Python job in that repository +already requires `submodules: true` — 20 of the 32 checkouts in `ci.yml` set it today. + +**Consequences**: + +- That job's `if` currently fires on `backend == 'true' || documentation == 'true'`. Add + `error_catalogue == 'true'` so a hand-edit of `schema/error-catalogue.json` alone cannot slip past. + This is the only CI edit required. +- **No new path filter.** An entry for the submodule artefact could never match: from the superproject + a submodule is a single gitlink, so a change inside it appears only as a change to `python_sdk` — + the same limitation that forces the diff to run inside the submodule. The repository already handles + this with `sdk_files: - "python_sdk"`, commented "Catch updates to the submodule commit" and included + in `backend_all`. The generator template is likewise already covered, since `backend_files` is + `backend/**`, and `error_catalogue_files` already lists the catalogue JSON. +- The failure hint names `uv run invoke backend.generate`. +- The SDK's bindings move with the other artefacts Infrahub generates into the SDK — the schema models + and the protocols — which is the grouping that matters for this feature. The catalogue's *other* + derived artefacts (the frontend bindings and the docs page) are regenerated by their own consumers, + and CI is what keeps all of them in step: the `error_catalogue` filter gates every one of their + checks, so a catalogue change that skips any of them fails the pull request that made it. + +**Pre-existing wart, deliberately left alone**: `tasks/frontend.py::regenerate_error_bindings` already +calls `backend.export_error_catalogue` and `docs.generate_error_catalogue`, so it is a catalogue +orchestrator carrying a frontend label. Tidying that is not this feature's job — but it is why +regeneration must not be hooked there: a consumer generating another consumer's artefact. + +**Alternatives considered**: + +- *A pytest in `backend/tests/unit/errors/test_export.py` that renders in memory and byte-compares + against the committed files*, mirroring `test_export_matches_committed_file` for the catalogue JSON. + Attractive — precise failure output, no git involved — but the renderer's output only becomes + canonical after `ruff format`, so the test would have to shell out to ruff or the template would + have to emit already-formatted output for all 15 codes. Rejected as one mechanism too many; + `validate_generated` already does render-then-format-then-diff for the two other submodule + artefacts. Worth revisiting if the diff output ever proves hard to act on. +- *Adding the SDK generation to `frontend.regenerate-error-bindings` and the check to + `frontend.check-error-bindings`.* Rejected: the frontend namespace should generate frontend artefacts + only, and a consumer should not generate another consumer's artefact. It would also have needed a + Python toolchain in a Node-only CI job to validate a Python artefact, making the job name lie. +- *A new `generate` / `validate` pair in `tasks/sdk.py`, matching the shape `tasks/schema.py` uses.* + Internally consistent, and the right answer if no entry point existed — but `backend.generate` and + `backend.validate-generated` already are that pair for everything Infrahub generates into the + submodule. A second entry point for the same category of work costs discoverability and invites the + two from drifting apart. +- *A standalone Python script mirroring the frontend's `.mjs` generator.* Would duplicate the + template-render-then-ruff pipeline `tasks/backend.py` already has. +- *No generator at all — hand-write the 15 classes once.* Worth stating plainly, because 15 classes is + not much typing. The generator's value is not saved keystrokes, it is US5: a catalogue change that + skips the SDK fails the pull request that made it, instead of surfacing months later as a code that + quietly falls back. Hand-written classes buy the typing and none of the drift protection. + +## R3 — Name derivation + +**Decision**: the exception class name is the code's underscore-separated parts capitalised and +joined, with `Error` appended only when the result does not already end in `Error`. The payload model +name is `data_schema.title` verbatim. + +Worked examples: `UNIQUENESS_VIOLATION` → `UniquenessViolationError` / `UniquenessViolationData`; +`UNDEFINED_ERROR` → `UndefinedError` (not `UndefinedErrorError`) / `UndefinedErrorData`; +`MERGE_IN_PROGRESS` → `MergeInProgressError` / `MergeInProgressData`. + +**Rationale**: FR-006 and FR-007. Taking the payload name from the declared title rather than +deriving it keeps SDK and frontend binding names identical, which the frontend generator already +relies on. The generator asserts a non-empty `data_schema.title`, as the frontend one does, so a +catalogue entry that omits it fails generation loudly. + +**Undeclared collisions must fail generation.** Deriving a name that already exists in `base.py` is +fine when the SDK declared that adoption (R4) and a latent disaster otherwise. The façade re-exports +`base` and then `catalogue`, so a generated class would win the name and silently shadow the +hand-written one — changing what an existing `except` clause catches, from a change made in the other +repository. This is not hypothetical. The SDK already defines `ValidationError`, `RateLimitError`, +`InvalidResponseError`, `FileNotValidError`, `ResourceNotDefinedError`, and `TimestampFormatError`, and +the codes `VALIDATION_ERROR`, `RATE_LIMIT`, `INVALID_RESPONSE`, `FILE_NOT_VALID`, +`RESOURCE_NOT_DEFINED`, and `TIMESTAMP_FORMAT` derive exactly those names — checked against the current +module, and this is the set the collision check in R4 is exercised against. A 429 code in the catalogue +is an entirely ordinary thing to add. + +The generator therefore collects every class name defined in `base.py`, not only those carrying a +`CODE`, and aborts on any derived name that matches one without a matching `CODE` declaration. The +remedy is then a deliberate SDK decision — adopt the code, or rename — taken in the pull request that +adds the code rather than discovered months later. + +## R4 — Codes whose class already exists + +**Decision**: adoption is discovered, not configured. The hand-written class declares the code it +represents (`CODE = "NODE_NOT_FOUND"` on `NodeNotFoundError`, and likewise for `BranchNotFoundError` +and `SchemaNotFoundError`); the generator parses `infrahub_sdk/exceptions/base.py` with `ast`, +collects every class whose body assigns a `CODE` string, and for those codes emits an import plus a +`CODE_TO_EXCEPTION` entry instead of a class definition. It still emits the payload model for an +adopted code. + +**Rationale**: the alternative is a hand-maintained code-to-class table in the generator, which is +the kind of thing FR-008 exists to avoid and which would live in the wrong repository — the decision +to unify a name is an SDK decision. Discovery puts the declaration next to the class it describes, so +adopting a fourth code later is a one-line SDK change with no generator edit. + +Reading the source with `ast` rather than importing the SDK keeps the generator a pure text transform, +as the frontend's generator is, and means generation never depends on the SDK checkout being in an +importable state. The same walk collects every class *name* defined in `base.py`, which is what the +collision check in R3 needs. + +**Fallback**: if the `ast` walk proves awkward, import `infrahub_sdk.exceptions.base` and read `CODE` +off the classes — `base.py` imports nothing from inside the package, so it is importable on its own. +Such a walk must filter to classes whose *own* `__dict__` carries `CODE`, because `NodeInvalidError` +inherits `CODE = "NODE_NOT_FOUND"` from `NodeNotFoundError`; it would otherwise see two classes +claiming the same code and let dict ordering pick the winner. The `ast` walk is immune, since it only +sees class bodies. + +**Executed against the real files.** The walk finds all 32 classes in today's `exceptions.py`, and +classifying every catalogue code through it produces exactly the split this plan claims: nine to +generate, three with no class (the 401/403 codes), and three colliding with an existing class. Because +the SDK has not yet declared any `CODE`, those three currently classify as **abort**, which is the +correct answer pre-adoption and becomes **adopt** the moment `base.py` declares them. The collision +check was run against every hypothetical code R3 enumerates and fires on all of them, and a synthetic +subclass inheriting `CODE` is correctly invisible to the walk. + +**An ordering constraint this surfaced.** The generator aborts until `base.py` declares the three +`CODE` attributes, so those declarations must land in the SDK *before* the generator is first run +against a catalogue containing `NODE_NOT_FOUND`, `BRANCH_NOT_FOUND`, or `SCHEMA_NOT_FOUND` — which is +to say, before the first run at all. That is consistent with R17's landing order but sharper than it: +not merely "the SDK lands first", but "the adoption declarations are a prerequisite for generation +succeeding". Worth stating in the task breakdown, since the natural instinct is to build the generator +first. + +**Alternative considered**: generating all 15 classes under distinct names and having the +hand-written unified classes subclass them. Rejected — the resolution map would then point at the +generated base, so the factory would raise the generated class and never the unified one that +consumers catch. + +## R5 — Which codes get their own class + +**Decision**: the code's declared HTTP status decides whether a code gets a class at all, not which +parent it takes. A code declaring 401 or 403 gets **no class**; every other code gets one, descending +from `GraphQLError` alone. No class in the design has more than one parent. + +Today that yields twelve codes with a class under `GraphQLError` — nine emitted by the generator and +three adopted from `base.py` per R4 — while `AUTHENTICATION_REQUIRED`, `TOKEN_EXPIRED`, and +`PERMISSION_DENIED` carry their identity in `exc.code` on whichever generic class their transport +already produces. + +```text +Error +└── ApiError + ├── GraphQLError # + code="PERMISSION_DENIED" on an HTTP 200 + │ ├── NodeNotFoundError, BranchNotFoundError, SchemaNotFoundError (adopted) + │ └── + └── AuthenticationError # + code="TOKEN_EXPIRED" on a real 401 +``` + +**The problem this solves.** The three authentication codes are the only ones that reach the SDK on two +different transports, and the transport determines which existing `except` clause has to keep working. +`backend/infrahub/graphql/app.py:298-300` returns `status_code=200` for every executed query, and +`graphql/error_formatter.py` maps resolver-raised failures onto catalogue codes inside that response's +`errors` array — including all three authentication codes. Only failures escaping *before* execution +get a real 401/403, which `api/exception_handlers.py:52-55` states outright. So: + +| Arrival | Raised today | Must keep being caught by | +|---------|--------------|---------------------------| +| Real 401/403 on `/graphql` | `AuthenticationError` | `except AuthenticationError` | +| Inside a 200 `errors` array | `GraphQLError` | `except GraphQLError` | + +One class per code can satisfy both only by inheriting from both branches. Not generating a class lets +the transport rule already in FR-012 pick the right generic class, and `exc.code` carries the +distinction. + +**Why not the diamond.** An earlier version of this decision generated +`class PermissionDeniedError(GraphQLError, AuthenticationError)`. It worked — verified: sibling bases, +the expected linearisation, both `isinstance` checks passing — but it cost more than it bought: + +- Multiple inheritance in a public exception hierarchy, which the first maintainer to read it misread + as `GraphQLError` descending from `AuthenticationError`. +- Two constructor footguns, both found in review rather than by design. Method resolution handed those + classes `GraphQLError.__init__`, whose first positional parameter is `errors`, so constructing one + positionally with a message corrupted the error list. And `GraphQLError.__init__`'s cooperative + `super().__init__` reached `AuthenticationError.__init__` rather than `Error.__init__`, where its + default message escaped substitution only because `GraphQLError` always computes a non-empty message + first. +- Two shapes in the generator instead of one. + +All of that to distinguish three codes whose payloads are empty (`AuthenticationRequiredData`) or +entirely nullable and usually unset (`TokenExpiredData.expired_at`, `PermissionDeniedData.action` and +`.resource_kind`). The structural cost is permanent and paid by every consumer; the benefit is a typed +attribute nobody can populate yet. + +**What this costs.** The three authentication codes have no typed payload attributes. If the catalogue +later gives one of them substantive fields, they are reachable only through `exc.extensions["data"]` +until the decision is revisited — at which point the trade has a concrete benefit to weigh rather than +a speculative one. US3's acceptance scenarios were amended to match, since they had specified distinct +types before the HTTP 200 behaviour was verified. + +**Rejected alternatives**: + +- *Classes under `AuthenticationError` only.* Single inheritance, all 15 codes typed, but + `except GraphQLError` stops catching a 200-response permission failure — an FR-018 violation for + exactly the codes FR-008 is about. +- *Classes under `GraphQLError` only.* Breaks `except AuthenticationError` around a GraphQL call, which + is where consumers put their re-login handling. Worse than the above. +- *Registering the generated classes as virtual subclasses of `AuthenticationError` via `ABCMeta`.* + Does not work: CPython matches `except` clauses with real subtype checks and ignores + `__subclasshook__`. +- *All 15 codes as direct children of `ApiError`, retiring the branch split.* The cleanest tree, and it + serves `except ApiError` well, but it breaks every existing `except GraphQLError` for the common data + failures. Too large a compatibility break for an SDK. + +**Note on `http_status`**: a generated class's `http_status` class attribute is the catalogue's declared +value, which is what US1 acceptance scenario 3 asserts. The wire value can differ — +`api/exception_handlers.py:26-27` replaces a declared 500 with the real HTTP status when it has a more +accurate one — and stays available as `exc.extensions["http_status"]`. + +## R6 — How a consumer reads the payload + +**Decision**: the payload's fields are promoted to directly typed attributes on the exception class. +The payload model validates the envelope and populates them; it is not the access path. + +```python +# generated +class UniquenessViolationError(GraphQLError): + CODE = "UNIQUENESS_VIOLATION" + code = "UNIQUENESS_VIOLATION" + http_status = 422 + DATA_MODEL = UniquenessViolationData + + def __init__(self, node_kind: str, fields: list[str], **envelope: Any) -> None: + self.node_kind = node_kind + self.fields = fields + super().__init__(**envelope) +``` + +```python +# consumer +except UniquenessViolationError as exc: + print(exc.node_kind, exc.fields) # str, list[str] +``` + +Attribute types mirror the catalogue exactly: a required field is not optional, and a nullable field +carries its declared default. There is no payload attribute on `ApiError` and none on the generated +classes either. + +**Rationale**: this is what US1 actually asks for — "carrying the node kind and the colliding field +names as typed attributes". A payload *object* on the exception was never a requirement; it was a +design choice, and a costly one. Exposing `data` on the base forces the base to name a type for it, +and narrowing that type on each subclass is an unsound override of a mutable attribute, so it can only +be bought with `Any` on the base, a read-only property pair, or a generic hierarchy. Promotion makes +the whole question disappear: there is nothing on the base to narrow. + +It is also the access pattern the design already used for the three adopted classes, whose payload +fields land on the existing `node_type` and `identifier` attributes (R9). Promoting everywhere removes +an inconsistency where three codes were read one way and twelve another. + +Two further simplifications fall out. `base.py` no longer needs to name a generated model type, so the +package needs no separate generated models module and no type-only import to stay independent of +generated code (R1). And the `Optional` payload attribute — with its `if exc.data:` guard at every call +site — is gone. + +**Construction**: the factory never assembles attributes itself. Each class exposes a +`from_payload(cls, payload, **envelope)` classmethod — generated for generated classes, hand-written +for the three adopted ones, where it maps `node_kind` onto the existing `node_type` and so on. The +factory validates with `DATA_MODEL` and calls `cls.from_payload(...)`, so promotion stays with the +class that knows its own attribute names and the factory carries no per-code branching. + +**Payload model shape**: generated payload models are pydantic v2 `BaseModel`s with +`model_config = ConfigDict(extra="ignore")`, honouring the catalogue's `required` list so a required +field is a required model field. JSON Schema is mapped as: `string` → `str`, `integer` → `int`, +`number` → `float`, `boolean` → `bool`, `string` with `format: date-time` → `datetime`, +`array` → `list[T]`, `anyOf: [T, {"type": "null"}]` → `T | None` with the declared default. Any +construct outside that vocabulary fails generation with the offending fragment in the message, as the +frontend generator does. A code with an empty `properties` object still gets a real model class with +no fields, not a special case. + +**Decision on validation failure**: if the payload does not validate — a server violating its own +emission contract, or a `date-time` the SDK cannot parse — the operation falls back to the generic class +for the branch, with `code` readable, the raw `extensions` retained, and a debug-level log recording the +failure. A `ValidationError` never escapes a raise path. + +**Rationale**: FR-004 requires tolerating unknown *fields*, which `extra="ignore"` delivers, and User +Story 2 requires that nothing raises during parsing. Promotion then forces this: a required field is a +non-optional attribute, and there is nothing to populate it with when validation fails. The alternative +is making every promoted attribute optional on every class, which taxes every consumer of the feature's +central use for a narrow server-side glitch. + +An earlier form of this decision kept the specific class and left the payload `None`, on the grounds +that FR-013 requires the raised type to be "a pure function of the response" and so it must not depend +on payload validity. That reasoning was overreaching. A malformed payload *is* part of the response; +FR-013's concern is that the type must not depend on **binding freshness** — on which SDK version the +consumer happens to hold — and falling back on an invalid payload does not touch that property. + +The case is also narrower than it first appears. `graphql/error_formatter.py:59-60` initialises the +payload to `UndefinedErrorData()` and only overwrites it when an `isinstance` guard matches, so the +server can emit `data: {}` under a code whose schema declares required fields. But for the adopted +codes — the ones US1's acceptance scenarios exercise — the guard tests the very exception type the code +was resolved from, so it cannot realistically fail. + +## R7 — Raise-time resolution + +**Decision**: two hand-written factories in `infrahub_sdk/exceptions/factory.py`: + +- `graphql_error_from_response(errors, query, variables)` for the `errors`-array path. +- `authentication_error_from_response(response)` for the 401/403 path, which subsumes the four-line + shape repeated at eleven call sites and preserves today's ` | `-joined message. + +Resolution reads `extensions.code` from the **first** error in the response and looks it up in +`CODE_TO_EXCEPTION`. A hit validates `extensions.data` with the class's `DATA_MODEL` and raises via +`cls.from_payload(...)`. A miss — unrecognised code, absent `extensions`, a non-string `code`, or a +payload that fails validation — raises the generic class for the transport the SDK observed. + +**Which class is raised and what `code` reports are separate questions.** `exc.code` is set from the +wire whenever the wire carried a *string* code, whether or not a class matched it: an unrecognised code +from a newer server is readable there (US2 acceptance scenario 1), and so is a recognised code whose +payload failed to validate. `exc.code` is `None` only when there was no string code to read — an absent +`extensions`, or the REST envelope's integer `code` (FR-003). Conflating the two would break both places +that key on `exc.code is not None`: the CLI branch in R11 and the server-reported test in R9. + +The complete `errors` list is retained on the exception in every case, unreordered (FR-013) — which is +why `errors` belongs on `ApiError` rather than only on `GraphQLError`, since the authentication branch +must retain it too and FR-015 freezes `AuthenticationError`'s constructor. + +**The fallback follows the transport, never the code's declared status.** For an unrecognised code the +SDK has no binding and therefore cannot know the declared status at all; for a recognised one the +declared status describes the failure, not the transport. Routing by declared status would send a +401/403 code arriving inside an HTTP 200 body to the authentication branch, where an existing +`except GraphQLError` would stop catching it. + +Under R5 this rule carries more weight than it first appears: the three authentication codes have no +class of their own, so *every* one of them takes this path. The transport rule is not an edge-case +fallback for them, it is the mechanism by which they reach the right class at all. + +**Construct with keyword arguments, always.** A generated class's `__init__` takes its promoted fields +first and forwards the envelope to `super().__init__`, which resolves to `GraphQLError.__init__` — +whose *first positional parameter* is `errors`. Anything constructing one positionally with a message +assigns a string into a field expecting a list of error dicts, reproducing the corruption +`analyzer.py` already has. The factories construct with keywords only, and a test asserts `exc.errors` +is a sequence of dicts on both paths. + +**The auth factory must tolerate a non-JSON body.** Two of the sites it replaces call +`exc.response.json()` directly, so a 401 carrying an HTML error page from a proxy currently raises a +JSON decode error in place of the authentication error. The factory reads `response.json()` and falls +back to the plain status when the body is not JSON, which is the same tolerance R10 requires of the +relogin helper for the same reason. Not `utils.decode_json`: what it adds over `.json()` is raising +`JsonDecodeError` carrying the response URL and body, and a factory built to absorb that case catches +and discards both — the status and the server's reason are what the caller needs here — and since +`infrahub_sdk.utils` imports the exceptions package, reaching for it would force a deferred import +inside the function body, the exact shape R1's layering test exists to catch. The exceptions package +therefore depends on nothing else in the SDK, which is a property the test now asserts directly. + +**The factory must be total.** It sits on the failure path of every client method, so an unexpected +exception inside it would replace a legitimate server error with an SDK `TypeError` and lose the +original failure entirely — the worst possible blast radius for a library. Resolution is therefore +wrapped so that *any* unexpected error degrades to constructing today's generic exception. Tested by +feeding the factory deliberately malformed envelopes: `errors` as a string (which `analyzer.py` +produces today), `extensions` as a list, `code` as a nested object. + +**Fallback logging**: every fallback — unresolved code, absent envelope, invalid payload — logs at +debug with the code involved. Cross-version fallbacks are precisely the signal a maintainer wants from +the field when an SDK meets a newer server, and silence makes SC-004's guarantees observable only in +tests. + +**Rationale**: the first error governs unconditionally, so the raised type is a pure function of the +response rather than of which generated bindings the SDK happens to hold — the reasoning FR-013 +records. Reading `extensions.code` only when it is a `str` is what keeps the REST envelope's integer +`code` from ever being surfaced as a catalogue code (FR-003). + +**Defensive detail**: two call sites already construct `GraphQLError` with something that is not a +list of dicts. `infrahub_sdk/analyzer.py:42` passes the bare string `"Schema is not provided"`, and +`infrahub_sdk/testing/schemas/animal.py:154` passes `[resp.errors]`, a list whose single element is not +a dict. The factory is on neither path, but the CLI renderer and anything iterating `errors` is. +Correct both call sites, and keep `print_graphql_errors`' non-list guard — which needs a `return` it +does not currently have, since today it prints the non-list value and then falls through and iterates +it anyway. + +## R8 — Message construction + +**Decision**: `GraphQLError.__init__` gains an optional `message` parameter. The factory passes a +message naming the code and the server's message for a catalogued failure and passes nothing for an +uncatalogued one, so today's `f"An error occurred while executing the GraphQL Query {query}, +{errors}"` string is reproduced byte-for-byte in the uncatalogued case (FR-023, SC-007). `query` and +`variables` remain attributes in both cases (FR-024). + +**Consequence**: `tests/unit/sdk/test_graph_traversal.py:383` asserts on `GraphQLError`'s message +text (`match="Source node not found"`). That path stays uncatalogued today, so the assertion holds; +the test is re-checked deliberately rather than assumed, per the specification's edge case. + +## R9 — Unifying `NodeNotFoundError`, `BranchNotFoundError`, `SchemaNotFoundError` + +**Decision**: these three promote their payload fields onto the attributes they already have, via a +hand-written `from_payload`, so a consumer reads the same attribute regardless of which path raised the +error: + +| Class | Existing attribute | Promoted from | +|-------|--------------------|---------------| +| `NodeNotFoundError` | `node_type`, `identifier` | `node_kind`, `identifier` | +| `BranchNotFoundError` | `identifier` | `branch_name` | +| `SchemaNotFoundError` | `identifier` | `kind` | + +This is the same promotion R6 applies to every other code; the only difference is that the target +attribute names already exist and are not the catalogue's, so the mapping is hand-written rather than +generated. + +`identifier`'s annotation widens to `Mapping[str, list[str]] | str`, which is what FR-016 calls +"documented as such": the plain string is not new behaviour, it is what +`infrahub_sdk/file_handler.py:168` already passes and what the current annotation wrongly excludes. The +three classes are re-rooted under `GraphQLError` (via `ApiError`), and `NodeInvalidError` inherits that +re-rooting — asserted by a test, not assumed. + +Because there is no payload attribute, "did this come from the server?" is answered by `exc.code is not +None`, which is true of every server-reported error rather than only of ones carrying a payload. + +**Rationale**: this satisfies "one documented way to obtain the identifying detail that works +regardless of which path raised the error" without inventing a new accessor that existing consumers +do not know about. Nothing in this repository reads these attributes except the classes' own `__str__` +rendering, so the blast radius is entirely external, which is why the widening goes in the release +notes. + +**Alternative considered**: a new normalised property (`identifier_display` or similar) alongside an +unchanged `identifier`. Rejected — it adds surface for a problem the widening already solves, and +leaves the file handler's existing string still outside the declared type. + +### Promoted attributes on an adopted class are optional, by policy + +Neither provenance can populate the full attribute set, and they fail to in *both* directions: + +`NodeNotFoundError` is constructed at nine sites — eight `raise` statements plus one built for deferred +raising at `store.py:184`: + +| Attribute | The nine construction sites | Catalogue payload | +|-----------|-----------------------------|-------------------| +| `identifier` | a mapping at eight (five in `store.py`, two in `client.py`, one in `ctl/object/utils.py`); a plain string at `file_handler.py:168` | a single string, declared required | +| `node_type` | supplied by five; the four `store.py` `raise` sites omit it and fall back to `"unknown"` | `node_kind`, declared required | +| `branch_name` | supplied by three | **not in the payload at all** | + +So a promoted attribute on an adopted class MUST be optional and documented as "populated when the +server reported it", even where the catalogue declares the underlying field required. A field can only +be non-optional on a class that is always constructed from that field, and these three are not. + +Whether the SDK can fill a *new* catalogue field client-side depends entirely on the field: were +`branch_name` added to `NodeNotFoundData`, three raise sites could supply it and the four store lookups +could not, since a store miss has no branch in scope. Something genuinely server-side — a database +identifier, a permission context — could not be supplied anywhere. That variability is why the policy +has to be optional-by-default rather than decided per field. + +**Tripwire for revisiting**: an adopted code gaining a field that is both *required* and semantically +server-only. At that point the unification has a concrete cost rather than a speculative one, and the +split below becomes the answer. + +### Direction of travel: separate the SDK's own errors from the server's + +Unification is a deliberate waypoint, not the end state. The maintainer's intent is to eventually give +the SDK's own failures their own classes, distinct from the ones representing what the server reported, +which would retire both problems above: `identifier` would stop carrying two meanings, and +`branch_name` would be coherent on the class that actually has a branch. + +That is not done here because it cannot be done without a breaking change. The derived name for +`NODE_NOT_FOUND` *is* `NodeNotFoundError`, so a split requires renaming the existing class (which +FR-005 forbids), giving the generated class a non-derived name (which FR-006 forbids), or moving the +client-side raises to a new class (which breaks every existing `except NodeNotFoundError` around a store +lookup). All three cost more today than an optional attribute. + +The path that does work is the constitution's deprecation path, in this order: ship the new +client-side classes as subclasses of the existing ones, so every current `except` clause keeps +catching them; move the SDK's own raise sites to the new classes; emit a `DeprecationWarning` from the +old names naming the replacements; keep them working for at least one minor release; and drop them in a +major. That sequence is a separate change with its own release note, and it is the reason this one +accepts the dual meaning rather than designing around it. + +## R10 — The typed silent-refresh decision + +**Decision**: `handle_relogin` and `handle_relogin_sync` decide via a shared helper that reads +`errors[0].extensions.code == "TOKEN_EXPIRED"`, falling back to the existing +`"Expired Signature" in messages` check when no code is present (FR-019). The helper tolerates a +non-JSON or empty 401 body rather than letting `response.json()` raise, since the wrapper sees REST +responses too and only GraphQL carries the catalogue envelope. + +Both the code check and the legacy fallback live *inside* that helper, so the literal +`"Expired Signature"` appears exactly once in the SDK afterwards, where it appears twice today — once +in each wrapper. That is what makes the grep in the quickstart's scenario 5 a meaningful check rather +than a count of duplicated logic. + +**Rationale**: the wrapper inspects the raw response before any exception exists, so it cannot reuse +the factory; a small shared reader keeps the async and sync copies from drifting. Keeping the legacy +check as a fallback is what makes a pre-catalogue server still refresh. + +**Out of scope, deliberately**: the GraphQL schema-validation probing used for server feature +detection matches on *uncatalogued* conditions and stays exactly as it is (FR-021). + +## R11 — The re-rooted classes' missing attributes, and the CLI ladder + +**The defect this fixes.** `NodeNotFoundError.__init__` does not call `GraphQLError.__init__`, and +neither do `BranchNotFoundError`'s or `SchemaNotFoundError`'s. Re-rooting them under `GraphQLError` +therefore produces instances on which `errors`, `query`, and `variables` do not exist *at all*. A +consumer writing `except GraphQLError as exc: … exc.errors` gets an `AttributeError` on any +client-side lookup miss, and `print_graphql_errors(errors=exc.errors)` raises while reading its +argument. Note that degrading the renderer on an *empty* `errors` does not address this: the attribute +is absent, not empty, so the renderer raises before it can check. + +**Decision**: + +- `handle_exception` gains a branch *above* the class-based ladder, keyed on `exc.code is not None` — + any server-reported error carrying a catalogue code — which renders the code and the server's + message, plus the GraphQL path where server errors exist. An error with no code falls through to + today's ladder unchanged. +- The three adopted classes call `super().__init__(errors=[], query=None, variables=None, message=...)` + explicitly, so their envelope attributes are set by the constructor that owns them. This is the fix + rather than relying on class-level defaults: a default standing in for constructor state also makes + `exc.errors` a *tuple* on a client-side raise and a *list* everywhere else, so the documented type + would be wrong for exactly the classes this feature unifies. +- `ApiError` still declares defaults for `errors`, `query`, and `variables` — an immutable empty tuple + for `errors` — but only as a can't-crash floor for a directly constructed `AuthenticationError`, whose + constructor FR-015 freezes. Anything built from a response goes through a constructor or factory that + sets a list. +- `print_graphql_errors` degrades to the exception's message when there is nothing to render. Its + `isinstance(errors, list)` guard also needs the `return` it currently lacks, since today it prints the + non-list value and then falls through and iterates it anyway. +- In `infrahub_sdk/ctl/utils.py::handle_exception`, move the + `(SchemaNotFoundError, NodeNotFoundError, ResourceNotDefinedError, GraphQLQueryError)` branch + *above* the `GraphQLError` branch, since re-rooting makes the later branch unreachable and would + silently change CLI output for exactly the errors this feature makes specific. + +**Tests**: read `exc.errors`, `exc.query`, and `exc.variables` off a purely client-side +`NodeNotFoundError`; drive `handle_exception` with each class to assert the ladder's behaviour rather +than reading the source; render an exception with no server errors behind it. + +**Why a keyed branch rather than a reordered class branch.** The requirement is that a user can see why +something failed. Neither existing branch delivers that for a catalogued failure: the +`AuthenticationError` branch would print "Authentication failure: …" for a `PERMISSION_DENIED`, which +mislabels it — the user is authenticated and simply not permitted — while the `GraphQLError` branch +prints the server error list without naming the condition. Rendering the code plus the server's message +names the actual failure in both cases. + +Keying the branch on `exc.code is not None` rather than on a class also removes the shadowing hazard for +catalogued errors permanently: it tests data rather than class identity, so no future re-rooting can +make it unreachable. And because it only claims errors carrying a code, every uncatalogued failure keeps +today's rendering byte-identical, which is the same guarantee FR-023 makes for messages. + +**Also checked**: `infrahub_sdk/ctl/cli_commands.py:237` and `infrahub_sdk/ctl/validate.py:88` each +catch `GraphQLError` with no competing branch, so re-rooting cannot shadow anything there. The class +ladder still needs its reordering, because a client-side `NodeNotFoundError` has no code and so reaches +it. + +## R12 — Testing strategy + +**Decision**: response-envelope fixtures under `tests/fixtures/error_catalogue/`, loaded via +`read_fixture()`, driven through `httpx_mock` at the transport boundary — no `unittest.mock`. +Parametrized cases use the dataclass-with-`name` pattern, and every `pytest.raises` carries `match=`. + +Coverage is split explicitly by layer, because SC-006 read literally ("across all catalogued codes") +would mean 15 codes on both clients through the client layer, which fights the constitution's +requirement that unit tests stay fast: + +| Layer | Scope | +|-------|-------| +| Factory | Exhaustive: every catalogue code, its raised class, and every promoted attribute's value. All cross-version cases — unknown code, unknown payload field, absent `extensions`, pre-catalogue integer `code`, invalid payload falling back to the generic class — plus the malformed-envelope totality cases. | +| Client | Representative parity set covering both branches, both transports, and the file-upload variant, parametrized over `["standard", "sync"]` via the `BothClients` fixture. | +| Hierarchy | Each authentication code reaching the class its transport dictates while `exc.code` identifies it; `NodeInvalidError` inheriting the re-rooting; attribute access on a client-side raise; the ladder's behaviour. Also that no class in the package has more than one parent, asserted directly, so the diamond cannot creep back. | +| Public surface | Every name importable from `infrahub_sdk.exceptions` before the change is still importable from it, pinned against a committed snapshot list. | +| Broadenings | Both accepted behaviour changes asserted directly: `except GraphQLError` catches a client-side `NodeNotFoundError`; a catalogued message differs from the generic one while an uncatalogued message stays byte-identical. | +| Integration | A small number of real catalogued failures driven against a live server via testcontainers, on both clients. | + +**Rationale**: the constitution requires both paths tested, concrete assertions, and deliberate +behaviour changes pinned by a test rather than worked around. The public-surface snapshot is what makes +"no name importable from `infrahub_sdk.exceptions` may disappear" a check rather than an intention — +necessary because the module is being restructured into a package. + +**Why unit tests alone are not sufficient here.** Every fixture in the layers above is written by the +same hand that writes the parser, so the whole suite can pass green against an envelope shape the +server never sends — and the shape is the entire contract this feature consumes. The constitution is +explicit that behaviour depending on real server responses belongs in integration tests, and the tier +already exists (`tests/integration/`, `infrahub-testcontainers`, with per-client files +`test_infrahub_client.py` and `test_infrahub_client_sync.py`). + +Scope it small and keep it there: drive two genuinely reachable failures — a uniqueness violation on +`.save()` and a missing node on `.delete()` — against a live server, and assert the raised class, the +code, and the promoted attributes. Two cases are enough to validate the envelope shape that every +unit fixture then reuses; the exhaustive per-code coverage stays at the fast, mocked layer where it +belongs. + +## R13 — Documentation + +**Decision**: a new hand-written `docs/docs/python-sdk/topics/error_handling.mdx` covering the +hierarchy, how to catch by branch versus by code, the cross-version guarantees, the two accepted +broadenings, and the note that `infrahub_sdk.exceptions` is the supported import path. It links to +Infrahub's published catalogue reference for the code list instead of restating it. The Python SDK +sidebar globs the `topics` directory, so no sidebar edit is needed. + +**Rationale**: restating 15 codes in this repository creates a second source of truth that rots the +first time a code is added upstream, and nothing here validates it — a direct Principle VII risk. +Infrahub already generates that list from the same artefact. What is genuinely SDK-specific is the +hierarchy and the guarantees, and that is what the page carries. + +One content note: a catalogued message now names the failing action and resource kind where the +catalogue provides them (`PermissionDeniedData` carries both), and that text reaches logs and CLI +output where a wall of query text used to be. Worth a line for anyone shipping SDK logs onward. + +## R14 — Release notes + +**Decision**: towncrier fragments in `changelog/`, one per user-visible change — the typed errors, the +`identifier` widening, and the `except GraphQLError` broadening. + +**Rationale**: FR-016 requires the widening to be called out in the change's release notes, and this +repository's release notes are files, not prose in a pull request: `[tool.towncrier]` in +`pyproject.toml` sets `directory = "changelog"` with `orphan_prefix = "+"` for entries without an issue +number. Naming the fragments as work makes FR-016 verifiable instead of aspirational. + +## R15 — Type-check the generated class shape before generating 15 of it + +**Done, and it passes.** A hand-written class in the generated shape — promoted attributes assigned in +`__init__`, a `from_payload` classmethod returning `Self`, `**envelope` forwarded to +`super().__init__`, plus the adopted variant with optional attributes — was checked against both +checkers at the versions this repository pins (`mypy` 1.11.2, `ty` 0.0.14): + +```text +mypy: Success: no issues found in 1 source file +ty: All checks passed! +``` + +Zero suppressions were needed. The types a consumer actually sees were asserted by typed assignment +rather than inspected, so a wrong one would have been an error rather than a note: + +| Expression | Type | +|------------|------| +| `exc.node_kind` on a generated class | `str` — not optional, not `Any` | +| `exc.fields` | `list[str]` | +| `exc.code` on `ApiError` | `str \| None` | +| `exc.errors` | `Sequence[dict[str, Any]]` | +| `UniquenessViolationError.from_payload(...)` | `UniquenessViolationError`, so `Self` resolves concretely | +| `exc.identifier` on the adopted class | `Mapping[str, list[str]] \| str` | + +The risk had already dropped twice over — promotion (R6) removed the variance problem that made it +real, and dropping the diamond (R5) removed the least ordinary construct in the shape — so this is a +confirmation rather than a decision input. It stays recorded because the constitution requires both +checkers clean, and because the numbers above are the contract the template has to reproduce. + +Note that no suppression is anticipated anywhere in this design. If the spike shows one is needed, that +is a signal the shape is wrong rather than a licence to add it. + +## R16 — Sequencing across the two repositories + +**Decision**: the Infrahub-side generator and its first hand-verified run come before the SDK-side +typed-raising work can be demonstrated, regardless of user-story priority labels. + +**Rationale**: US1 is P1 and US5 is P2, but the per-code classes US1 delivers are produced by the +generator US5 builds. Priority labels describe value, not order. Task generation must take the order +from the dependency, not from the labels, or the P1 story will be picked up first and immediately +block. FR-002 does soften this — the envelope parses onto the base classes with no bindings at all, so +`code`, `http_status`, and the relogin fix (US4) are independently landable — but the typed per-code +classes are not. + +## R17 — Landing order across the two repositories + +**Decision**: the SDK change lands first, then Infrahub bumps its submodule pointer to it. + +**Rationale**: this is the pattern the two repositories already follow. Infrahub's pointer-moving +commits — `chore(sdk): bump python_sdk to head of infrahub-develop` and feature commits that move the +pointer inline — target SDK commits already present on the SDK's `infrahub-develop` branch, so the SDK +side is merged before the pointer advances. + +It is also the only order that works here. Infrahub's `validate_generated` diffs the submodule's +*content*, so the generated module must already exist in the SDK at the pointer Infrahub carries. The +first generation is therefore hand-run locally to produce the artefact for the SDK pull request, which +is what US5 anticipates, and every regeneration afterwards follows the same order. + +Inferred from the repositories' history rather than from a written practice, so worth one confirmation +from whoever owns the release flow before the paired pull requests go up. + +## R18 — What this plan does not touch + +The git integrator's repository-import failure handling, anything about what the server emits, and +any release-time gate on either side. Pull-request-time validation is the only enforcement mechanism, +matching how the existing generated artefacts are treated (FR-027). diff --git a/dev/specs/ifc-3034-error-catalogue/spec.md b/dev/specs/ifc-3034-error-catalogue/spec.md new file mode 100644 index 000000000..f2c9791c2 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/spec.md @@ -0,0 +1,501 @@ +# Feature Specification: Error Catalogue in the Python SDK + +**Feature Branch**: `pog-error-catalogue-IFC-3034` + +**Created**: 2026-08-21 + +**Status**: Draft + +**Input**: IFC-3034 — Implement the error catalogue in the Python SDK. Related: IFC-2279 (spike), INFP-468 (backend catalogue), GitHub #7498. + +## Context + +Infrahub's GraphQL error catalogue gives every GraphQL error a stable string `extensions.code`, an +integer `extensions.http_status`, and a typed `extensions.data` payload, published as a +machine-readable schema at `schema/error-catalogue.json` in the Infrahub repository. The frontend +already consumes it through generated TypeScript bindings. + +The SDK consumes none of it. `execute_graphql` raises a generic `GraphQLError` whose message embeds +the entire query text, and consumers that need to branch on a failure still match on message +strings. This feature makes ordinary SDK operations raise the specific error for the failure. + +Two wire shapes matter, and they are not the same: + +- **`/graphql`** carries the catalogue envelope: string `code`, integer `http_status`, typed `data`. +- **`/api/...` (REST)** carries the legacy envelope, where `extensions.code` is an *integer* + mirroring the HTTP status. There is no catalogue code and no `data`. + +The catalogue is therefore GraphQL-only, and a REST `extensions.code` is a different thing with a +different type that must never be mistaken for a catalogue code. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Branch on a specific server failure (Priority: P1) + +A developer writing automation against Infrahub needs to react differently to different failures. A +`.save()` that collides on a uniqueness constraint should be distinguishable from a validation +failure, and the collision detail should be available as attributes rather than parsed out of prose. + +**Why this priority**: This is the feature. Everything else either protects it or maintains it. + +**Independent Test**: Drive each catalogued failure against a server (or a fixture of its response +envelope) and assert the raised type and the typed attributes carrying its detail, without reading any +message. + +**Acceptance Scenarios**: + +1. **Given** a node whose unique attribute already exists, **When** the developer calls `.save()`, + **Then** `UniquenessViolationError` is raised carrying the node kind and the colliding field names + as typed attributes. +2. **Given** a node that no longer exists, **When** the developer calls `.delete()`, **Then** + `NodeNotFoundError` is raised carrying the node kind and identifier. +3. **Given** any catalogued failure, **When** it is raised, **Then** `exc.code` equals the catalogue + code string and `exc.http_status` equals the catalogue status. +4. **Given** a developer who catches `GraphQLError` today, **When** a catalogued GraphQL failure + occurs, **Then** the specific subclass is caught by that existing clause. + +--- + +### User Story 2 - Keep working against any server version (Priority: P1) + +The SDK and the server are versioned and released independently, so any SDK version may talk to any +server version. Neither direction may break. + +**Why this priority**: The ticket states this is a hard requirement, not a nice-to-have. User Story 1 +is not shippable without it — typed errors that raise on an unrecognised payload would be a +regression, not a feature. + +**Independent Test**: Replay response fixtures representing a newer server, an older server, and a +pre-catalogue server against the parsing layer, asserting no parse failure and correct fallback in +each case. + +**Acceptance Scenarios**: + +1. **Given** a code the SDK has never heard of, emitted by a newer server, **When** the SDK raises, + **Then** it raises the generic fallback for that transport branch with `exc.code` readable as a + plain string, and does not raise on parse. +2. **Given** an existing code whose payload has gained a new attribute in a newer server, **When** + an older SDK parses it, **Then** the unknown attribute is ignored and behaviour is unchanged. +3. **Given** a server that predates the catalogue, or an error carrying no `extensions`, **When** the + SDK raises, **Then** behaviour matches today's and `exc.code` is `None`. +4. **Given** a pre-catalogue server emitting an *integer* `extensions.code` on `/graphql`, **When** + the SDK parses it, **Then** it is not surfaced as a catalogue code and `exc.code` is `None`. +5. **Given** any of the above, **When** the developer regenerates nothing, **Then** correctness is + unaffected — regeneration buys typed handling of newly catalogued errors, never correctness. + +--- + +### User Story 3 - Catch server-reported errors uniformly across transports (Priority: P2) + +Authentication failures reach the developer from both the REST and GraphQL paths. Today they collapse +into a single `AuthenticationError` that cannot distinguish "no credentials" from "token expired" +from "not permitted". The catalogue splits these into three codes, and the SDK needs a hierarchy +where that split is expressible without stranding the REST path. + +**Why this priority**: It restructures the hierarchy every other story hangs off, but User Story 1 +delivers value with the existing flat `AuthenticationError` still in place. + +**Independent Test**: Assert the class hierarchy directly, and assert that each existing `except` +clause in the SDK and CLI still catches what it caught before. + +**Acceptance Scenarios**: + +1. **Given** a GraphQL request with an expired token returning a real 401, **When** it fails, **Then** + `AuthenticationError` is raised as it is today and caught by an existing + `except AuthenticationError` clause, carrying `exc.code == "TOKEN_EXPIRED"`. +2. **Given** a GraphQL request the user is not permitted to make, **When** it fails, **Then** the + failure is distinguishable from a missing-credentials one by `exc.code`, which reads + `"PERMISSION_DENIED"` rather than `"AUTHENTICATION_REQUIRED"`. +3. **Given** a REST request that fails authentication, **When** it fails, **Then** + `AuthenticationError` is raised as it is today, with `exc.code` as `None`. +4. **Given** a developer who wants to catch anything the server rejected regardless of transport, + **When** they catch `ApiError`, **Then** both GraphQL and auth failures are caught. +5. **Given** any of the three authentication codes returned inside an HTTP 200 response, because the + failure was raised from within a resolver, **When** it fails, **Then** `GraphQLError` is raised as it + is today, carrying that code, so an existing `except GraphQLError` clause keeps catching it. + +**Note on the three authentication codes**: they are distinguished by `exc.code` rather than by +distinct exception types. See FR-008 — a single class per code cannot satisfy both scenario 1 and +scenario 5 without multiple inheritance, and the code carries the distinction at no structural cost. +Any of the three can arrive by either route, so `except ApiError` is the only clause that catches a +given code regardless of how the server happened to fail. + +--- + +### User Story 4 - Stop the SDK string-matching its own server (Priority: P2) + +The SDK's silent token-refresh path decides whether to re-login by matching the literal string +`"Expired Signature"` in the response body. The catalogue makes that a typed decision. + +**Why this priority**: A correctness improvement to existing behaviour, valuable independently, but +it depends on the envelope parsing from User Story 1. + +**Independent Test**: Drive the relogin path with a catalogue `TOKEN_EXPIRED` envelope, with the +legacy string on a pre-catalogue server, and with an unrelated 401, asserting a refresh is attempted +in the first two cases and not the third. + +**Acceptance Scenarios**: + +1. **Given** a 401 carrying `TOKEN_EXPIRED`, **When** the SDK receives it, **Then** it refreshes the + token and retries, without inspecting any message text. +2. **Given** a 401 from a pre-catalogue server carrying the legacy `"Expired Signature"` message, + **When** the SDK receives it, **Then** it still refreshes and retries. +3. **Given** a 401 that is neither, **When** the SDK receives it, **Then** no refresh is attempted. + +--- + +### User Story 5 - Bindings that cannot silently drift (Priority: P2) + +A catalogue change that is not reflected in the SDK's bindings must surface as a failure, in the +change that caused it, rather than as silence that is noticed months later when a code falls back. + +**Why this priority**: Without it the typed errors decay. It is P2 rather than P1 only because the +first generation can be landed and verified by hand once. + +**Independent Test**: Modify the catalogue without regenerating, and confirm the validation step +fails; regenerate, and confirm it passes. + +**Acceptance Scenarios**: + +1. **Given** a change to the catalogue in the Infrahub repository, **When** the bindings in the SDK + submodule are not regenerated, **Then** Infrahub's generated-artefact validation fails the pull + request that changed the catalogue. +2. **Given** a regenerated set of bindings, **When** validation runs, **Then** it passes and the + generated file is byte-identical to a fresh generation. +3. **Given** the generated bindings file, **When** a developer opens it, **Then** it is marked as + generated and not to be edited, consistent with the repository's other generated artefacts. + +--- + +### User Story 6 - Messages that are about the failure (Priority: P3) + +`GraphQLError`'s message embeds the whole query text, so a one-line failure produces a wall of +output in logs and CLI sessions. + +**Why this priority**: Observable quality-of-life improvement, no functional dependency either way. + +**Independent Test**: Trigger a catalogued failure and an uncatalogued one, and compare their +messages. + +**Acceptance Scenarios**: + +1. **Given** a catalogued failure, **When** its message is rendered, **Then** it names the code and + the server's message and does not contain the query text. +2. **Given** an uncatalogued failure, **When** its message is rendered, **Then** it is unchanged from + today's, query text included. +3. **Given** any GraphQL failure, **When** a developer needs the query, **Then** it is still + available on the exception. + +### Edge Cases + +These are specific hazards found while surveying the current code, not hypotheticals. + +- **Ordered `isinstance` ladder is shadowed.** The CLI's error handler tests + `isinstance(exc, GraphQLError)` *before* it tests + `isinstance(exc, (SchemaNotFoundError, NodeNotFoundError, ...))`. Re-rooting those classes under + `GraphQLError` makes the later branch unreachable, silently changing CLI output for exactly the + errors this feature makes specific. The ladder must be reordered, and the same shadowing hazard + checked wherever else the SDK or CLI tests these classes in sequence. +- **A GraphQL error renderer with no server errors to render.** The CLI's `GraphQLError` branch + renders `exc.errors`, which is a list of server error dicts. A unified `NodeNotFoundError` raised + purely client-side has no server response behind it, so that list is empty. Rendering must degrade + to the message rather than printing nothing. +- **`identifier` already means two different things.** The client-side `NodeNotFoundError` declares + `identifier` as a mapping of filters, and the store and client lookup paths pass one. The file + handler, however, already passes a plain string, which the declared type does not admit — so the + attribute is heterogeneous today, before any unification. The catalogue payload adds a third + reading: a single server-side identifier string. Unification does not create this problem, it + forces a decision on it. FR-016 pins the resulting contract; the mechanism is left to the plan. + Nothing in this repository reads the attribute except the exception's own string rendering, so the + compatibility risk is entirely external. +- **A subclass inherits the re-rooting.** `NodeInvalidError` subclasses `NodeNotFoundError`, so it + silently becomes a `GraphQLError` too. Intended, but it must be asserted rather than assumed. +- **Pre-existing constructor misuse, in two places.** One call site constructs `GraphQLError` with a + plain string where the constructor expects a list of error dicts, so `errors` holds a string; another + passes a list whose single element is not a dict. Any code that now iterates `errors` to resolve a + code will meet both. Re-rooting makes this worse before it makes it better: once a class inherits a + constructor whose *first positional parameter* is `errors`, passing a message positionally silently + produces the same corruption. +- **A message-matching test inside our own suite.** At least one existing test asserts on + `GraphQLError`'s message text. Message changes must be reflected in the suite deliberately, not + worked around. +- **More than one error in one response.** A GraphQL response may carry several errors with different + codes. The rule for which code determines the raised class must be explicit, and no error may be + discarded from the exception. +- **`UNDEFINED_ERROR` is a code, not the absence of one.** A server that explicitly says + `UNDEFINED_ERROR` is reporting a catalogue gap on its side. That is distinct from an error carrying + no `extensions` at all, and the two must not collapse. +- **Codes with no payload.** Several codes declare an empty payload object. These must still produce + a usable class rather than a special case. +- **Silent-refresh runs on both transports.** The relogin wrapper inspects raw responses from REST + *and* GraphQL calls, but only GraphQL carries the catalogue envelope. It must read the code where + one exists and fall back to the legacy check where one does not. +- **GraphQL data errors arrive as HTTP 200, and so do some auth failures.** Catalogued data errors + come back with status 200 and an `errors` array. Only auth failures that escape *before* query + execution come back as real 401/403 responses on a separate code path; a permission or + authentication failure raised inside a resolver is formatted at the GraphQL layer and returned in + the 200 response's `errors` array like any other error. So the transport a code arrives on cannot be + inferred from the code, and a code's declared `http_status` is metadata about the failure rather than + the status the SDK saw. The declared status can also differ from the status on the wire: the server + replaces a declared 500 with the real HTTP status when it has a more accurate one. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Envelope parsing + +- **FR-001**: The SDK MUST expose a base class representing "the server reported an error", carrying + the catalogue code, the HTTP status, the raw error envelope, and the server's error list, from which + both the GraphQL and the authentication branches descend. The base MUST NOT declare an attribute for + the typed payload: a payload's fields belong to the specific class that has a type for them, and a + base-level payload attribute could only be typed loosely enough to be useless. +- **FR-002**: The SDK MUST parse the error envelope onto the shared base class of FR-001 — not onto the + generated per-code classes — so the code is readable against any server version without regenerated + bindings. +- **FR-003**: The code attribute MUST always exist, holding either a catalogue code string or `None`. + Reading it MUST NOT raise, so a consumer can test it without first testing which class it holds. The + REST envelope's integer `code` MUST NOT be surfaced through it; the HTTP status is already available + separately. +- **FR-004**: Payload parsing MUST tolerate unknown fields, which is the inverse of the server's + strict emission contract. Where a payload does not validate at all, the operation MUST fall back to + the generic class for the branch, with the code still readable, and MUST NOT raise from parsing. + This case is reachable rather than defensive — the server has a fallback path that emits an empty + payload under a code whose schema declares required fields. + + Rationale: the specific class exposes the payload's fields as attributes typed exactly as the + catalogue declares them, so a required field is not optional. There is nothing to populate those + attributes with when validation fails, and the alternative — making every payload attribute + optional on every class — would tax every consumer of the feature's central use for a narrow + server-side glitch. Note that this does not weaken FR-013: payload validity is a property of the + response, so the raised class remains a function of the response alone and never of which + generated bindings the SDK holds. + +#### Generated bindings + +- **FR-005**: Every catalogue code MUST have one typed payload model, and every code except the + 401/403 ones (see FR-008) MUST also have one exception class rooted at the SDK's base `Error` class. + Both MUST be importable from `infrahub_sdk.exceptions`, but the payload model is the parsing + mechanism rather than the access path: where a code has a class, each payload field MUST be reachable + as a directly typed attribute on the exception itself, typed as the catalogue declares it. Every name + importable from `infrahub_sdk.exceptions` before this change MUST remain importable from it + afterwards, and that MUST be pinned by a test rather than asserted, since the module is being + restructured. +- **FR-006**: Exception class names MUST derive from the code deterministically, without producing a + doubled `Error` suffix for codes that already end in `_ERROR`. A derived name that collides with an + exception the SDK already defines MUST either be an intentional adoption, declared by the SDK, or + fail generation. It MUST NOT silently produce two classes with one name. + + Rationale: the SDK already defines `ValidationError`, `RateLimitError`, `InvalidResponseError`, + `FileNotValidError`, and `ResourceNotDefinedError`, every one of which is the name a plausible future + code would derive. A collision that is not caught would shadow the hand-written class of that name, + changing what an existing `except` clause catches — in this repository, from a change made in + another one. +- **FR-007**: Payload model names MUST come from the catalogue's declared payload title, so SDK and + frontend bindings agree on naming. +- **FR-008**: The code's declared HTTP status MUST determine *whether a code gets its own exception + class*, with no hand-maintained per-code mapping. A code declaring 401 or 403 MUST NOT get a class; + every other code MUST get one, descending from the GraphQL branch. Where a code has no class, the + generic class for the observed transport is raised and carries the code (FR-012), so the failure is + still identifiable without reading a message. + + Rationale: the three authentication codes can arrive on two different transports, and the transport + determines which existing `except` clause has to keep working. A real 401 must stay catchable by + `except AuthenticationError`; a permission failure raised inside a resolver comes back as an HTTP 200 + GraphQL response and must stay catchable by `except GraphQLError`. One class per code cannot satisfy + both without inheriting from both branches, and a diamond in a public exception hierarchy is a + permanent structural cost paid by every consumer to distinguish three codes that `exc.code` already + distinguishes. The asymmetry is deliberate: 12 codes carry typed payload attributes, and 3 carry + their identity in `exc.code`, whose payloads today are empty or entirely nullable. + + Consequence to accept: if the catalogue later gives an authentication code substantive payload + fields, they will be reachable only through `exc.extensions["data"]` until this decision is + revisited. +- **FR-009**: The generated artefact MUST carry the same "generated, do not edit" marking as the + repository's other generated files, and MUST record the catalogue version it was generated from. +- **FR-010**: The SDK MUST NOT contain a copy of the catalogue schema. The generated bindings are the + only artefact that crosses the repository boundary. + +#### Raising the specific error + +- **FR-011**: Every operation that today raises the generic GraphQL error MUST raise the specific + class when the response carries a recognised code. +- **FR-012**: Where no class matches the code — unrecognised, absent, or an integer from a + pre-catalogue server — or where the matched class's payload does not validate, the operation MUST + raise the generic class for **the transport it observed**: the GraphQL error for anything read from an + `errors` array, and the authentication error only for a response the SDK saw as HTTP 401 or 403. The + fallback MUST NOT be selected from the code's declared HTTP status. + + Rationale: for an unrecognised code the SDK holds no binding and so cannot know the declared status + at all, and for a recognised one the declared status describes the failure rather than the transport. + Routing by declared status would therefore send a recognised 401/403 code whose payload fails to + validate, arriving inside an HTTP 200 body, to the authentication branch — where an existing + `except GraphQLError` would stop catching it, the coverage loss FR-018 forbids. The transport rule is + the only thing that preserves that coverage — and under FR-008 it carries more weight still, since the + three authentication codes have no class of their own and therefore *always* take this path. + + Where a string code was on the wire it MUST remain readable as `exc.code` even though the generic + class was raised; `exc.code` is `None` only when no string code was present. +- **FR-013**: Where a response carries several errors, the **first** error in the response determines + the class raised. The exception MUST retain the complete list, and MUST NOT discard or reorder it. + The first error governs even when it carries no code and a later one does, in which case the generic + class for the branch is raised. + + Rationale: this makes the raised type a pure function of the response, independent of which version + of the generated bindings the SDK holds. Selecting the first *recognised* code instead would make + the type depend on binding freshness, so regenerating bindings could change which exception a + consumer receives for a byte-identical response — the opposite of the guarantee in FR-010 and User + Story 2. +- **FR-014**: Async and sync clients MUST behave identically, per the constitution's parity + principle, and both paths MUST be tested. + +#### Reconciling names that already exist + +- **FR-015**: `AuthenticationError` MUST keep its name and constructor, and MUST remain the class + raised for every failure the SDK observes as HTTP 401 or 403 — on the REST path, where `exc.code` + stays `None`, and on the GraphQL path, where it carries the catalogue code. It gains no subclasses; + per FR-008 the three authentication codes get no class of their own. +- **FR-016**: `NodeNotFoundError` MUST be unified into a single class covering both the client-side + and the server-reported cases, re-rooted so that an existing `except GraphQLError` clause catches + it. The consequent broadening — that clause now also catches purely client-side lookup misses — is + accepted. + + The unified class MUST satisfy all of the following observable contract. The mechanism that achieves + it is left to the plan; the contract is not. + + - Every construction shape in use today MUST keep working unchanged. That includes the mapping of + filters passed on the store and client lookup paths **and** the plain string the file handler + already passes, which the current type annotation does not actually admit. + - The server-reported node kind and identifier MUST be reachable as typed attributes when the error + came from the server. + - There MUST be one documented way to obtain the identifying detail that works regardless of which + path raised the error, so a consumer never has to test which case it is holding. + - Any attribute whose type widens as a result MUST be documented as such, and the widening MUST be + called out in the change's release notes, since external consumers read these attributes even + though nothing in this repository does. + - Every attribute carrying a payload field on a unified class MUST be optional, even where the + catalogue declares that field required, and MUST be documented as populated only when the server + reported the error. Neither provenance can populate the full attribute set: the catalogue supplies + no `branch_name`, and four of the nine construction sites supply no node kind. A field can + only be non-optional on a class that is always constructed from it. +- **FR-017**: `BranchNotFoundError` and `SchemaNotFoundError` MUST be reconciled the same way as + `NodeNotFoundError`. +- **FR-018**: Every existing `except` clause and `isinstance` check in the SDK and CLI MUST still + catch what it caught before the change, with ordered ladders corrected where re-rooting shadows a + later branch. + +#### Removing string matching + +- **FR-019**: The silent token-refresh decision MUST be made from the catalogue code where one is + present, retaining the existing message check only as the fallback for servers that predate the + catalogue. +- **FR-020**: The SDK's remaining message-string checks for catalogued failures MUST be replaced with + typed handling. +- **FR-021**: Checks that detect *uncatalogued* conditions — notably GraphQL schema-validation probing + used for server feature detection — are explicitly out of scope and MUST be left in place. + +#### Messages + +- **FR-022**: A server-reported catalogued error's message MUST name the code and the server's message, + and MUST NOT embed the query text. Where one of the unified classes is raised with no catalogue code + behind it — a client-side lookup miss, or the REST 404 the file handler turns into a + `NodeNotFoundError` — its message MUST remain exactly as it is today. +- **FR-023**: An uncatalogued error's message MUST remain exactly as it is today, query text included. +- **FR-024**: The query and variables MUST remain available as attributes on the exception in both + cases. + +#### Generation and validation (Infrahub repository) + +- **FR-025**: Infrahub MUST generate the SDK's error bindings into the SDK submodule as part of its + existing generation task, alongside the schema models and protocols it already generates there. +- **FR-026**: Infrahub's existing generated-artefact validation MUST be extended to fail when the + submodule's bindings do not match a fresh generation, so a catalogue change that skips regeneration + fails the pull request that made it. +- **FR-027**: No release-time gate is added on either side. Pull-request-time validation is the + mechanism, matching the treatment the existing generated artefacts receive. + +#### Documentation + +- **FR-028**: SDK documentation MUST describe the exception hierarchy, how to catch by branch and by + code, and the cross-version behaviour a consumer can rely on, updated in the same change as the + behaviour. It MUST reference the server's published catalogue for the code list rather than + restating it, so a code added upstream cannot leave the SDK's documentation quietly wrong. It MUST + also note that a catalogued message now names the failing action and resource kind where the + catalogue provides them, since that text reaches logs and CLI output. + +### Key Entities + +- **Catalogue code**: A stable string naming one failure mode, with a declared description, stability + level, HTTP status, and payload schema. Owned by Infrahub; the SDK is a consumer. +- **Error envelope**: What the server puts on the wire for one error. Two shapes exist — the catalogue + envelope on GraphQL, and the legacy integer-code envelope on REST. +- **Payload model**: The typed `data` for one code, tolerant of fields it does not recognise. Used to + validate the envelope and populate the exception's attributes; not the way a consumer reads them. +- **Exception hierarchy**: A tree rooted at the SDK's `Error`; below it a base for server-reported + errors, splitting into the authentication branch and the GraphQL branch, with one generated class per + non-401/403 code under the GraphQL branch. No class has more than one parent. +- **Generated bindings module**: The single artefact crossing from Infrahub into the SDK, holding the + payload models, the per-code classes with their promoted attributes, and the code-to-class + resolution used at raise time. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Every code in the catalogue is identifiable without reading a message — the non-401/403 + codes as their own exception type with the catalogue's payload fields readable as typed attributes, + and the 401/403 codes through `exc.code` on the class their transport already produced. +- **SC-002**: No message-string matching remains in the SDK for any failure the catalogue covers. +- **SC-003**: The existing test suite passes with no `except` clause losing coverage it had before; + every deliberate behaviour change is pinned by a test that asserts the new behaviour. +- **SC-004**: Every cross-version case — unknown code, unknown payload field, absent envelope, + pre-catalogue integer code — is covered by a test and none of them raises during parsing. +- **SC-005**: A catalogue change that omits regeneration fails validation in the pull request that + introduced it, and a regenerated artefact is byte-identical to a fresh generation. +- **SC-006**: Async and sync clients raise the same type with the same attributes for the same + failure, across all catalogued codes. +- **SC-007**: A catalogued failure's message contains no query text, while an uncatalogued failure's + message is byte-identical to today's. +- **SC-008**: A developer can catch every server-reported error, on either transport, with one + `except` clause. + +## Assumptions + +- **The catalogue is GraphQL-only.** Confirmed against the server: REST responses keep the legacy + integer-code envelope. If REST later adopts the catalogue, the base class introduced here is where + it would attach, but no REST parsing is in scope. +- **Class and code names are the product, not implementation detail.** For an SDK the exception + hierarchy *is* the user-facing contract, so this spec names classes and codes. It deliberately does + not specify module layout, file names, generator implementation, or test framework mechanics. +- **Generation belongs to Infrahub.** The SDK cannot regenerate its own protocols or schema models + today either; those come from Infrahub's generation task writing into the submodule. Error bindings + follow that established pattern rather than introducing a second mechanism, which also removes any + need to keep a vendored catalogue copy in sync. +- **Unification is a waypoint, not the end state.** Giving the SDK's own failures their own classes, + distinct from the ones representing what the server reported, is the intended direction — it would + retire `identifier`'s dual meaning and make `branch_name` coherent. It is out of scope here because it + cannot be done without a breaking change, and it belongs on the constitution's deprecation path + rather than inside this change. See research R9 for the sequence. +- **`infrahub_sdk.exceptions` is the supported import path, and it is treated as public.** A consumer + should never need to know which module inside it defines a given exception: every exception the SDK + raises — hand-written or generated — is importable from `infrahub_sdk.exceptions`, and no name + importable from it today may stop being importable from it. Modules beneath it are internal and are + not an import path for consumers. This is a stronger promise than the constitution's tiering + strictly requires, and it is made deliberately, because `infrahubctl`, the Ansible collection, and + external consumers already import from it directly. +- **The broadenings are accepted deliberately**: `except GraphQLError` will additionally catch node, + branch, and schema lookup misses that never involved a GraphQL request at all — both the client-side + ones and the REST 404 the file handler turns into a `NodeNotFoundError`; code that catches the + generic error to inspect its message will now sometimes receive a subclass with a different message, + or the same class carrying a catalogued message; and a failure the server reports under an adopted + code now raises that code's class rather than the generic one, so an `except NodeNotFoundError` + clause sees server-reported misses as well as client-side ones. They are left uncounted here because + adopting a further code adds another. Each follows from an answered decision rather than oversight. + + Nothing changes about which class a 401 or 403 produces: it remains `AuthenticationError`, and a + permission failure inside an HTTP 200 response remains a `GraphQLError`. That is what FR-008's + no-class rule for the authentication codes preserves. +- **The repository-import failure handling in the git integrator (GitHub #7498) is out of scope**, as + is any change to what the server emits. +- **Both repositories are in scope for this document.** Requirements FR-025 to FR-027 land in the + Infrahub repository and must be executed from that checkout; everything else lands here. diff --git a/dev/specs/ifc-3034-error-catalogue/tasks.md b/dev/specs/ifc-3034-error-catalogue/tasks.md new file mode 100644 index 000000000..4b296af41 --- /dev/null +++ b/dev/specs/ifc-3034-error-catalogue/tasks.md @@ -0,0 +1,649 @@ +# Tasks: Error Catalogue in the Python SDK + +**Input**: Design documents from `dev/specs/ifc-3034-error-catalogue/` + +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), +[data-model.md](./data-model.md), [contracts/](./contracts/) + +**Tests**: Included. Constitution Principle V requires tests to ship in the same change, and +[research.md](./research.md) R12 fixes the layer split they follow. + +**Organization**: Tasks are grouped by user story. Phase numbering is a nominal sequence, not a strict +dependency order - see the note below for the two adjacencies that are actually forced. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1-US6) +- Every task names the exact file it touches + +## Path conventions + +Two checkouts are involved: + +- **SDK** (this repository): paths are relative to the repository root, e.g. `infrahub_sdk/exceptions/base.py`. +- **Infrahub** (`opsmill/infrahub`, phase 7 only): paths are prefixed `[infrahub]`, e.g. + `[infrahub] tasks/backend.py`. + +## Phase order is a nominal sequence, not a strict dependency order + +Phases are numbered so there is one obvious path through the work. Only two adjacencies are actually +forced, and both cut against the priority labels ([research.md](./research.md) R16 and R4): + +1. **US3 must precede US5.** The three `CODE` declarations (T034) are a prerequisite for generation + succeeding at all, not merely for its output: the generator aborts on an undeclared name collision. +2. **US5 must precede US1.** The generator produces the per-code classes US1 delivers, so the P1 MVP + story lands last among the behaviour phases. Priority labels describe value, not order. + +Everything else is free. **US2, US3, US4, and US6 depend only on Foundational and are mutually +independent**, so do not serialize them just because they are numbered in sequence. The envelope parses +onto the base classes with **no generated bindings at all**, which is what makes US2 and US4 landable +before anything has been generated. See Parallel opportunities below for the file overlaps to watch if +they are worked concurrently. + +Nominal sequence: Setup → Foundational → US2 → US3 → US4 → US6 → US5 → US1 → Polish. +US1 remains the feature's reason for existing; it is simply the last brick, not the first. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Restructure `exceptions.py` into a package without changing a single observable behaviour, +and pin that invisibility before touching anything. + +- [X] T001 Capture the current public surface as a committed snapshot: write every name importable from + `infrahub_sdk.exceptions` today into `tests/fixtures/error_catalogue/public_names.json`, and add + `tests/unit/sdk/test_exceptions_public_names.py` asserting every snapshot name is still importable. + Author this **before** the restructure so the snapshot records the pre-change surface. + **The committed snapshot is post-change: it includes `ApiError`.** It still pins that every name + importable before the split is importable now, which is the property that matters, but it is not + the untouched pre-change baseline this task asked for. +- [X] T002 [P] Create the response-envelope fixture directory `tests/fixtures/error_catalogue/` with a + `README.md` stating that each file is a verbatim server response envelope, not a hand-shaped dict. + **The README distinguishes two kinds instead.** The claim cannot hold for the `malformed_*` + fixtures, since a correct server does not produce them; they are constructed and the README says + so. The captured/not-parser-shaped rule stands for every fixture representing a real response. +- [X] T003 Convert `infrahub_sdk/exceptions.py` into `infrahub_sdk/exceptions/base.py` by verbatim move + (no behaviour edits in this task), and add `__all__` to it listing every class it defines. +- [X] T004 Create the façade `infrahub_sdk/exceptions/__init__.py` re-exporting every class `base` + defines, by explicit name rather than the `from .base import *` this task first said, and nothing + else yet. +- [X] T005 [P] Add `"exceptions"` to `packages_to_ignore` in `tasks.py::get_modules_to_document`, so + `docs-generate` does not fail with `Uncategorized packages under infrahub_sdk/` and `sdk_ref` + output stays byte-identical. +- [X] T006 [P] ~~Add the `per-file-ignores` entry for `infrahub_sdk/exceptions/__init__.py` in + `pyproject.toml` silencing `F403`/`F405`, with a comment giving the reason, mirroring the existing + `infrahub_sdk/schema/generated/*.py` entry.~~ **Superseded by T004 and T064**: the façade + re-exports by explicit name, so no `F403`/`F405` arises and no entry was added. What this + feature does add to `pyproject.toml` is its four pytest markers: `catalogue`, `crossversion`, + `malformed` and `message`. +- [X] T007 Run `uv run pytest tests/unit/ -q` and `uv run invoke format lint-code docs-generate docs-validate` + to confirm the restructure is invisible from outside the package. + +**Checkpoint**: `infrahub_sdk.exceptions` is a package; nothing else has changed. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The parsed envelope on the base classes, and the two raise-time factories every existing +raise site funnels through. Every user story below depends on this phase. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T008 Add `ApiError(Error)` to `infrahub_sdk/exceptions/base.py` with class-level defaults for + `code` (`str | None`), `http_status` (`int | None`), `extensions` (`dict[str, Any] | None`), + `errors` (immutable empty tuple), `query`, and `variables`, adding no required constructor + arguments. The defaults are a can't-crash floor, not a substitute for constructor state. + **Landed without `query` and `variables` on the base.** Only `GraphQLError` ever sets them, so on + `ApiError` they would be permanently `None` on every `AuthenticationError`, telling a caller that a + REST failure had no query rather than that it can never have one. They live on `GraphQLError`. +- [X] T009 Re-root `GraphQLError` under `ApiError` in `infrahub_sdk/exceptions/base.py` and give its + constructor an optional `message` parameter. When `message` is omitted the string it builds MUST be + byte-identical to today's. **Re-rooting landed; the `message` parameter is deferred to its first + caller.** Nothing in this issue passes it, and an unused public parameter is surface the SDK would + have to keep. T035 adds it as part of the same edit that first calls it. The byte-identical default + message is unaffected and is pinned by `test_malformed_envelope_keeps_the_payload_in_the_message`. +- [X] T010 Re-root `AuthenticationError` under `ApiError` in `infrahub_sdk/exceptions/base.py`, leaving + its name, constructor signature, and default message untouched. +- [X] T011 Create `infrahub_sdk/exceptions/factory.py` with `graphql_error_from_response(errors, query, + variables)`: read `extensions.code` from the **first** error only when it is a `str`, read + `extensions.http_status`, retain the complete unreordered error list, and construct with keyword + arguments only. +- [X] T012 Add `authentication_error_from_response(response)` to `infrahub_sdk/exceptions/factory.py`, + subsuming the eleven-site "decode, collect messages, join with ` | `" shape and preserving that + message byte-for-byte, falling back to the plain status when the body is not JSON, since two of the + sites it replaces call `response.json()` directly. **Landed reading `response.json()` directly + rather than `decode_json`.** What `decode_json` adds over `.json()` is raising `JsonDecodeError` + carrying the response URL and body, and this factory catches and discards both, so it was building + context it never surfaces: on this path the status and the server's reason are what the caller + needs. It also cost a deferred import inside the function body — `infrahub_sdk.utils` + imports this package, so the module-level spelling is a cycle — which is precisely the shape T015's + layering test exists to catch. The tolerance R10 asks for is unchanged. +- [X] T013 Make both factories total in `infrahub_sdk/exceptions/factory.py`: wrap resolution so any + unexpected error degrades to constructing today's generic exception rather than replacing the + server's failure with an SDK `TypeError`, and log every fallback at debug level with the code + involved. +- [X] T014 Extend the façade `infrahub_sdk/exceptions/__init__.py` to import the factory module, keeping + imports pointing strictly downward. **Landed declaring `__all__` on the façade as well**, and writing + the class list out by hand rather than star-importing it: a wildcard hides what the package exports, + which is the one thing this file exists to state. Importing the submodules makes `base` and `factory` + attributes of the package, so without `__all__` an `import *` hands a caller two names that are an + artefact of the layout and shadow those names in their scope. The raise-time factories stay + importable by name; they are simply not part of the wildcard, since what an end user catches is the + classes. Three tests hold the hand-written lists in step with `base`: the façade's `__all__` against + `base.__all__`, every declared name against what the import block actually binds, and + `base.__all__` against the classes `base` defines — the last one closing a gap where omitting a + class from `base.__all__` left it absent everywhere downstream with nothing to notice. +- [X] T015 Add `tests/unit/sdk/test_exceptions_layering.py`, parsing every module in + `infrahub_sdk/exceptions/` with `ast` and failing on any intra-package import that points at its own + layer or higher. It MUST walk imports inside function bodies and `TYPE_CHECKING` blocks, not only + module-level ones. **Landed also asserting the package imports nothing else in the SDK**, in either + the relative or the absolute spelling. The internal ordering was only ever half the property: every + other module is free to raise, so any dependency in that direction is a cycle waiting to be found, + and the deferred import that works around one is invisible to the intra-package check. +- [X] T016 Replace the four `GraphQLError` raise sites in `infrahub_sdk/client.py` (`_execute_graphql` + and the file-upload variants, async and sync) with `graphql_error_from_response`. +- [X] T017 Replace the four `AuthenticationError` raise sites in `infrahub_sdk/client.py` with + `authentication_error_from_response`. +- [X] T018 [P] Replace the six `AuthenticationError` raise sites in `infrahub_sdk/object_store.py` with + `authentication_error_from_response`. +- [X] T019 [P] Replace the `AuthenticationError` raise site at `infrahub_sdk/file_handler.py:164` with + `authentication_error_from_response`. +- [X] T020 [P] Correct the pre-existing constructor misuse at `infrahub_sdk/analyzer.py:42`, which passes + the bare string `"Schema is not provided"` where a list of error dicts is expected. + **No change needed - the premise does not hold.** `analyzer.py` imports `GraphQLError` from + `graphql` (graphql-core), whose constructor takes a message string, so the call is correct. +- [X] T021 [P] Correct the pre-existing constructor misuse at + `infrahub_sdk/testing/schemas/animal.py:154`, which passes a list whose single element is not a dict. + **No change needed - the premise does not hold.** `SchemaLoadResponse.errors` is annotated + `dict` and pydantic rejects a list, so `[resp.errors]` is already a `list[dict]`. +- [X] T022 Add `tests/unit/sdk/test_error_catalogue.py` covering the generic factory path with no bindings + present: `code` and `http_status` readable off a `GraphQLError` and an `AuthenticationError`, the + complete error list retained unreordered, and `exc.errors` asserted to be a sequence of dicts on + both paths. + +**Checkpoint**: The envelope is readable against any server version with no generated bindings at all. + +--- + +## Phase 3: User Story 2 - Keep working against any server version (Priority: P1) + +**Goal**: Parsing never raises, on any server version, for any envelope shape. + +**Independent Test**: Replay fixtures representing a newer server, an older server, and a pre-catalogue +server through the factory and assert no parse failure and the correct fallback in each case +(quickstart scenario 2). + +### Tests for User Story 2 + +- [X] T023 [P] [US2] Add cross-version envelope fixtures under `tests/fixtures/error_catalogue/`: an + unknown string code, a known code carrying an extra payload field, an error with no `extensions`, a + pre-catalogue integer `extensions.code` on `/graphql`, and a payload that violates its own declared + schema. +- [X] T024 [P] [US2] Add malformed-envelope fixtures under `tests/fixtures/error_catalogue/`: `errors` as + a bare string, `extensions` as a list, and `code` as a nested object. +- [X] T025 [US2] Add the `crossversion`-marked cases to `tests/unit/sdk/test_error_catalogue.py`: each + fixture from T023 raises the generic class for its transport, none raises during parsing, and + `exc.code` reads the wire string for the unknown code and `None` for the absent-envelope and + integer-code cases. +- [X] T026 [US2] Add the `malformed`-marked totality cases to `tests/unit/sdk/test_error_catalogue.py`, + asserting each fixture from T024 degrades to today's generic exception rather than an SDK `TypeError`. + +### Implementation for User Story 2 + +- [X] T027 [US2] Confirm in `infrahub_sdk/exceptions/factory.py` that an integer `extensions.code` never + reaches `exc.code`, and that `exc.code` is set from the wire whenever a string code was present, + including when no class matched it. Which class is raised and what `code` reports are separate + questions. +- [X] T028 [US2] Add the debug-level fallback log assertions to `tests/unit/sdk/test_error_catalogue.py`, + using `caplog`, so a cross-version fallback is diagnosable in the field rather than only in tests. + +**Checkpoint**: Every cross-version case is covered and none of them raises during parsing. + +--- + +## Phase 4: User Story 3 - Catch server-reported errors uniformly across transports (Priority: P2) + +**Goal**: The hierarchy is a tree under `ApiError`, the three unified classes are re-rooted with their +envelope state actually set, and no `except` clause or `isinstance` ladder loses coverage. + +**Prerequisite for phase 7**: the `CODE` declarations added here are what stop the generator aborting. + +**Independent Test**: Assert the class hierarchy directly and assert each existing `except` clause in +the SDK and CLI still catches what it caught before (quickstart scenario 3). + +### Tests for User Story 3 + +- [X] T029 [P] [US3] Add hierarchy tests to `tests/unit/sdk/test_exceptions.py`: no class in + `infrahub_sdk.exceptions` has more than one parent; `GraphQLError` and `AuthenticationError` are + siblings under `ApiError`; `NodeInvalidError` is an instance of `GraphQLError`. +- [X] T030 [P] [US3] Add transport tests to `tests/unit/sdk/test_exceptions.py`: a real 401 carrying + `TOKEN_EXPIRED` is caught by `except AuthenticationError` with `exc.code == "TOKEN_EXPIRED"`, and a + `PERMISSION_DENIED` inside an HTTP 200 body is caught by `except GraphQLError` with + `exc.code == "PERMISSION_DENIED"`. Add the matching envelope fixtures under + `tests/fixtures/error_catalogue/`. +- [X] T031 [P] [US3] Add the attribute-access test to `tests/unit/sdk/test_exceptions.py`: reading + `exc.errors`, `exc.query`, and `exc.variables` off a purely client-side `NodeNotFoundError` returns + empty/`None` rather than raising `AttributeError`, and `exc.errors` is a `list`, not the base's tuple. +- [X] T032 [P] [US3] Add the broadening test to `tests/unit/sdk/test_exceptions.py`: `except GraphQLError` + catches a client-side `NodeNotFoundError`, asserting the accepted behaviour change rather than + working around it. +- [X] T033 [P] [US3] Add ladder tests to `tests/unit/ctl/test_utils.py` driving `handle_exception` with + `NodeNotFoundError`, `SchemaNotFoundError`, a catalogued `GraphQLError`, and a catalogued + `AuthenticationError`, asserting rendered output rather than reading the source. + +### Implementation for User Story 3 + +- [X] T034 [US3] Declare `CODE = "NODE_NOT_FOUND"` on `NodeNotFoundError`, `CODE = "BRANCH_NOT_FOUND"` on + `BranchNotFoundError`, and `CODE = "SCHEMA_NOT_FOUND"` on `SchemaNotFoundError` in + `infrahub_sdk/exceptions/base.py`. +- [X] T035 [US3] Re-root the three classes under `GraphQLError` in `infrahub_sdk/exceptions/base.py`, each + calling `super().__init__(errors=[], query=None, variables=None, message=...)` explicitly so their + envelope attributes are set by the constructor that owns them. This is the first caller of + `GraphQLError`'s optional `message` parameter, which T009 deferred, so add the parameter here. +- [X] T036 [US3] Widen `NodeNotFoundError.identifier` to `Mapping[str, list[str]] | str` in + `infrahub_sdk/exceptions/base.py`, admitting the plain string `infrahub_sdk/file_handler.py:168` + already passes. **Landed in issue 1.** Hardening the 404 branch against a body carrying no + `detail` narrowed the argument from `Any` to `str`, which made the type checker report the + violation that had been there all along. Widening is the fix; a suppression would not be. +- [X] T037 [US3] Add a hand-written `from_payload` classmethod to each of the three classes in + `infrahub_sdk/exceptions/base.py`, mapping `node_kind`→`node_type` and `identifier`→`identifier`, + `branch_name`→`identifier`, and `kind`→`identifier`. Every promoted attribute on these three stays + **optional**, since neither provenance can populate the full set. + **Landed taking the payload alone, with no envelope parameters.** The envelope has no caller until + T072 routes the factory through `from_payload`, and T009's precedent is to add a parameter with the + edit that first passes it. The payload argument is typed by a small `Protocol` per class rather than + by the generated model, which `base.py` may not import: it sits at the bottom of the package. +- [X] T038 [US3] Add the catalogued branch to `handle_exception` in `infrahub_sdk/ctl/utils.py`, placed + **above** the class ladder and keyed on `exc.code is not None`, rendering the code and the server's + message. An error with no code falls through to today's ladder unchanged. + **The branch escapes the message and still renders the remaining server errors**, both found by + review. Rich reads `[main]` in a server message as markup and deletes it, which is why the two + renderers beside this one already escape; and since the code names only the governing error, a + multi-error response needs the rest listed under it or errors two onward never reach the user. +- [X] T039 [US3] Move the `(SchemaNotFoundError, NodeNotFoundError, ResourceNotDefinedError, + GraphQLQueryError)` branch **above** the `GraphQLError` branch in + `infrahub_sdk/ctl/utils.py::handle_exception`, which re-rooting would otherwise make unreachable. + **`BranchNotFoundError` was added to that tuple**, which predates this design and so did not list + it. It is one of the three classes being re-rooted, so leaving it out is the exact silent CLI + change the reordering exists to prevent: it would fall to the `GraphQLError` branch and render an + empty server error list where it used to print its message. +- [X] T040 [US3] Fix `print_graphql_errors` in `infrahub_sdk/ctl/utils.py`: degrade to the exception's + message when there are no server errors to render. The `isinstance(errors, list)` guard this task + also meant to fix is already gone: issue 1 made `exc.errors` a list of dicts by construction, so the + guard became unreachable and was removed with the annotation widened to `Sequence[dict[str, Any]]`. + **Landed in issue 1, via an optional `fallback` argument** rather than by passing the exception, so + the existing signature keeps working for any caller outside this repository. Pulled forward because + the empty-list gap is a regression issue 1 introduces: without it `infrahubctl` exits non-zero with + no output at all where it previously printed the payload. +- [X] T041 [US3] Verify no other ordered `isinstance` ladder is shadowed by the re-rooting: check + `infrahub_sdk/ctl/cli_commands.py:237` and `infrahub_sdk/ctl/validate.py:88`, and grep the SDK and + CLI for further sequential tests of these classes. **Landed in issue 1, and the two sites needed + fixing rather than only checking.** Both rendered a `str` entry through an `isinstance(error, str)` + branch that filtering `exc.errors` to dicts made unreachable, taking the `--branch` hint with it and + reporting `0 error(s)` for a payload the SDK could not read. Both now share + `print_graphql_query_errors`, which keys the hint on the server's message and degrades to the + exception's message. No further sequential tests of these classes exist outside `handle_exception`. +- [X] T042 [US3] Update `tests/unit/sdk/test_exceptions_public_names.py` so the snapshot check runs against + the re-rooted hierarchy, confirming the restructure is still invisible from outside the package. + **The snapshot itself is unchanged, so the existing check needed an addition rather than an edit.** + It collects exception classes by name, which re-rooting cannot disturb; what re-rooting can disturb + is where each class sits, so a new case asserts every snapshot name still descends from `Error` — + a class that leaves that root is as invisible to `except Error` as one that stopped being importable. + +**Checkpoint**: The hierarchy is a tree, every existing clause still catches what it caught, and the +generator's adoption prerequisite is satisfied. + +--- + +## Phase 5: User Story 4 - Stop the SDK string-matching its own server (Priority: P2) + +**Goal**: The silent token-refresh decision is typed, with the legacy string check surviving only as the +pre-catalogue fallback and appearing exactly once in the SDK. + +**Independent Test**: Drive the relogin path with a `TOKEN_EXPIRED` envelope, with the legacy +`"Expired Signature"` message, and with an unrelated 401 (quickstart scenario 5). + +### Tests for User Story 4 + +- [X] T043 [P] [US4] Extend `tests/unit/sdk/test_relogin_headers.py` with three cases: a 401 carrying + `TOKEN_EXPIRED` refreshes and retries, a 401 carrying the legacy `"Expired Signature"` message + refreshes and retries, and an unrelated 401 does not. +- [X] T044 [P] [US4] Add a case to `tests/unit/sdk/test_relogin_headers.py` driving a 401 with a non-JSON + body (an HTML proxy error page) and an empty body, asserting neither raises a decode error. + +### Implementation for User Story 4 + +- [X] T045 [US4] Add the shared refresh-decision helper to `infrahub_sdk/client.py`, reading + `errors[0].extensions.code == "TOKEN_EXPIRED"` and falling back to the existing + `"Expired Signature" in messages` check when no code is present. It MUST tolerate a non-JSON or empty + body rather than letting `response.json()` raise, since the wrapper sees REST responses too. + **Landed scanning every error, not `errors[0]`.** A stale token is a fact about the request, not + about which error happens to lead; reading only the first would refuse to refresh when the server + orders the codes differently. Tolerance also covers a body that decodes to valid JSON that is not an + object, which `response.json().get(...)` raised an `AttributeError` on. +- [X] T046 [US4] Route both `handle_relogin` and `handle_relogin_sync` in `infrahub_sdk/client.py` through + that helper, so the literal `"Expired Signature"` appears exactly once in the SDK, down from twice. +- [X] T047 [US4] Confirm `grep -rn "Expired Signature" infrahub_sdk/` returns exactly one site, and that + the GraphQL schema-validation probing used for server feature detection is untouched - it detects an + uncatalogued condition and is deliberately out of scope. + +**Checkpoint**: The refresh decision is typed and a pre-catalogue server still refreshes. + +--- + +## Phase 6: User Story 6 - Messages that are about the failure (Priority: P3) + +**Goal**: A server-reported catalogued failure's message names the code and the server's message and +carries no query text; an uncatalogued failure's message is byte-identical to today's. + +**Independent Test**: Trigger a catalogued failure and an uncatalogued one and compare their messages +(quickstart scenario 6). + +### Tests for User Story 6 + +- [X] T048 [P] [US6] Add `message`-marked cases to `tests/unit/sdk/test_exceptions.py`: a server-reported + catalogued failure's message names the code and the server's message and contains no query text; an + uncatalogued failure's message is byte-identical to today's string; and a unified class raised with + no catalogue code behind it keeps today's message exactly. +- [X] T049 [P] [US6] Add a case to `tests/unit/sdk/test_exceptions.py` asserting `exc.query` and + `exc.variables` remain populated on a catalogued failure. + +### Implementation for User Story 6 + +- [X] T050 [US6] Have the factories in `infrahub_sdk/exceptions/factory.py` pass a message naming the code + and the server's message for a catalogued failure, and pass nothing for an uncatalogued one so + `GraphQLError` reproduces today's string. + **The authentication factory names the code too, which changed two assertions issue 1 landed.** + FR-022 is about a server-reported catalogued failure, not about a transport, and both factories + produce one; the auth message keeps its whole existing fallback chain — joined server messages, + then the REST `detail`, then the plain status — with the code named ahead of whichever survived. + `test_message_joins_the_server_messages` and `test_a_403_is_parsed_the_same_way_as_a_401` now + assert the new string rather than working around it. + **On the GraphQL path only the governing error's message is named**, corrected after review. An + earlier form joined every message behind the first error's code, filing later errors under a code + that was not theirs; the complete list was always retained on `exc.errors` either way. + **A second changelog fragment covers this**, since T077's is issue 3's and T079's is the + re-rooting: the message change ships here and is visible to anyone matching on the old strings. +- [X] T051 [US6] Re-check the message assertion at `tests/unit/sdk/test_graph_traversal.py:383` + (`match="Source node not found"`) deliberately: that path stays uncatalogued, so confirm the + assertion still holds rather than assuming it. + **Confirmed by running it: the assertion holds untouched.** The response it mocks carries no + `extensions` at all, so no code resolves, and the message is today's string with the server's + error list embedded in it verbatim. + +**Checkpoint**: Message behaviour is pinned in both directions. + +--- + +## Phase 7: User Story 5 - Bindings that cannot silently drift (Priority: P2) + +**Goal**: Infrahub generates `infrahub_sdk/exceptions/catalogue.py` into the SDK submodule as part of its +existing generation task, and its existing validation fails when the committed artefact is stale. + +**Runs from the Infrahub checkout.** Depends on phase 4 (T034): the generator aborts on an undeclared name +collision, so the three `CODE` declarations must already be in the SDK. + +**Independent Test**: Change the catalogue without regenerating and confirm validation fails; regenerate +and confirm it passes (quickstart scenario 7). + +### Implementation for User Story 5 + +- [ ] T052 [US5] Create `[infrahub] backend/templates/generate_sdk_errors.j2` emitting, in sorted code + order: a header marking the file generated and not to be edited, naming + `schema/error-catalogue.json`, recording `infrahub_catalogue_version`, and giving the regeneration + command; one pydantic payload model per code; one exception class per non-adopted, non-401/403 code; + `CODE_TO_EXCEPTION`; and `__all__`. +- [ ] T053 [US5] Implement name derivation in the template's helper in `[infrahub] tasks/backend.py`: the + code's parts capitalised and joined with `Error` appended only when the result does not already end + in `Error`, and the payload model name taken from `data_schema.title` verbatim. +- [ ] T054 [US5] Implement the JSON-Schema-to-Python type mapping in `[infrahub] tasks/backend.py` covering + `string`, `integer`, `number`, `boolean`, `string`+`format: date-time`, `array`, and + `anyOf: [T, null]`. Anything outside that vocabulary MUST abort with the offending fragment in the + message. +- [ ] T055 [US5] Implement the adoption walk in `[infrahub] tasks/backend.py`: parse + `python_sdk/infrahub_sdk/exceptions/base.py` with `ast`, collect every class whose **own body** + assigns a `CODE` string, and emit an import plus a `CODE_TO_EXCEPTION` entry for those codes instead + of a class definition. The payload model is still emitted. Parsing rather than importing is what + keeps `NodeInvalidError`'s inherited `CODE` invisible. +- [ ] T056 [US5] Implement the collision check in `[infrahub] tasks/backend.py` from the same walk: abort + when a derived name matches a class defined in `base.py` that has not declared that code. Exercise it + against `ValidationError`, `RateLimitError`, `InvalidResponseError`, `FileNotValidError`, + `ResourceNotDefinedError`, and `TimestampFormatError`, every one of which a plausible future code + would derive. +- [ ] T057 [US5] Add the remaining abort conditions to `[infrahub] tasks/backend.py`: no integer + `http_status`, no non-empty `data_schema.title`, empty `codes`, or a non-object root. +- [ ] T058 [US5] Emit no class for a code declaring 401 or 403, and root every other class at + `GraphQLError` with exactly one parent, in `[infrahub] backend/templates/generate_sdk_errors.j2`. +- [ ] T059 [US5] Emit each generated class's promoted attributes typed exactly as the catalogue declares + them (required fields non-optional, nullable fields carrying their declared default), assigned in + `__init__`, plus a `from_payload` classmethod and a docstring carrying the catalogue's `description` + and `stability`, in `[infrahub] backend/templates/generate_sdk_errors.j2`. +- [ ] T060 [US5] Add `_generate_sdk_error_bindings` to `[infrahub] tasks/backend.py` (render, then + `ruff format`, as the sibling generators do) and call it from the existing `generate` task. Correct + `generate`'s docstring, which has said "internal backend models" since it started generating SDK + protocols. +- [ ] T061 [US5] Extend `validate_generated` in `[infrahub] tasks/backend.py` with + `git -C python_sdk diff --exit-code infrahub_sdk/exceptions/catalogue.py`, with a failure hint naming + `uv run invoke backend.generate`. The diff MUST run inside the submodule; from the superproject + `git diff` sees only the gitlink. +- [ ] T062 [US5] Add `error_catalogue == 'true'` to the `backend-validate-generated` job trigger in + `[infrahub] .github/workflows/ci.yml`, so a hand-edit of the catalogue JSON alone cannot slip past. + This is the only CI edit; no new path filter can match a file inside a submodule. +- [X] T063 [US5] Run `uv run invoke backend.generate` from the Infrahub checkout and hand-verify the + resulting `infrahub_sdk/exceptions/catalogue.py` in the SDK: nine generated classes, three adopted + imports, fifteen payload models, and no class for the three 401/403 codes. +- [X] T064 [US5] Commit the generated `infrahub_sdk/exceptions/catalogue.py` in the SDK repository and + re-export its nine exception classes from the façade `infrahub_sdk/exceptions/__init__.py` **by + explicit name**. Not `from .catalogue import *`, as this task first said: a wildcard also promotes + the payload models, the lookup maps and the dispatch helper onto the package surface, where each + name becomes a stability promise the package never chose to make. +- [ ] T065 [US5] Prove the negative from the Infrahub checkout: add a code to the backend catalogue, run + `uv run invoke backend.export-error-catalogue` alone, confirm `backend.validate-generated` exits + non-zero naming the stale artefact, then revert. + +**Checkpoint**: A catalogue change that skips regeneration fails the pull request that made it. + +--- + +## Phase 8: User Story 1 - Branch on a specific server failure (Priority: P1) 🎯 MVP + +**Goal**: Every catalogued failure raises its own class carrying the payload's fields as directly typed +attributes. + +**Independent Test**: Drive each catalogued failure through a fixture of its response envelope and assert +the raised type and the typed attributes, reading no message (quickstart scenarios 1, 4, 6b). + +### Tests for User Story 1 + +- [X] T066 [P] [US1] Add one response-envelope fixture per catalogue code under + `tests/fixtures/error_catalogue/`, each a verbatim server response rather than a hand-shaped dict. +- [X] T067 [US1] Add the exhaustive factory cases to `tests/unit/sdk/test_error_catalogue.py`: one per + code, asserting the raised class, every promoted attribute's concrete value, and that `exc.code` and + `exc.http_status` match the catalogue entry. No case reads a payload object, because there is none. +- [X] T068 [P] [US1] Add the adopted-class cases to `tests/unit/sdk/test_error_catalogue.py`: a + server-reported `NODE_NOT_FOUND` populates `node_type` and `identifier`, `BRANCH_NOT_FOUND` and + `SCHEMA_NOT_FOUND` populate `identifier`, and `exc.code is not None` distinguishes a server-reported + raise from a client-side one. +- [X] T069 [P] [US1] Add the representative parity set to `tests/unit/sdk/test_client.py`, parametrized + over `["standard", "sync"]` via the `BothClients` fixture, covering both branches, both transports, + and the file-upload variant, asserting the same class and the same attributes on each. +- [X] T070 [P] [US1] Add a `catalogue`-marked case to `tests/integration/test_infrahub_client.py`: saving a + node that collides on a unique attribute raises `UniquenessViolationError` with the node kind and + colliding fields from the real payload, and deleting a missing node raises `NodeNotFoundError` with + its kind and identifier. +- [X] T071 [P] [US1] Add the same two `catalogue`-marked cases to + `tests/integration/test_infrahub_client_sync.py`. + +### Implementation for User Story 1 + +- [X] T072 [US1] Extend `graphql_error_from_response` in `infrahub_sdk/exceptions/factory.py` to look the + first error's code up in `CODE_TO_EXCEPTION`, validate `extensions.data` with the class's + `DATA_MODEL`, and raise via `cls.from_payload(...)`. The factory never assembles attributes itself. +- [X] T073 [US1] Implement the validation-failure fallback in `infrahub_sdk/exceptions/factory.py`: an + invalid payload falls back to the generic class for the observed transport with `exc.code` still + readable, the raw `extensions` retained, and a debug log. A pydantic `ValidationError` never escapes + a raise path. +- [X] T074 [US1] Confirm in `infrahub_sdk/exceptions/factory.py` that the fallback follows **the transport + the SDK observed** and never the code's declared status: the GraphQL branch for anything read from + an `errors` array, the authentication branch for a response the SDK rejected before the query ran. + That is usually a 401 or 403, and also a token refresh that failed on any other status, which is + why the branch reads the status off the response rather than assuming one. This is the only rule + under which the three authentication codes reach the right class at all. +- [X] T075 [US1] Add a test to `tests/unit/sdk/test_error_catalogue.py` asserting the first error governs + even when it carries no code and a later one does, and that the complete list is retained unreordered. + +**Checkpoint**: Every catalogue code is identifiable without reading a message, on both clients. + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +- [X] T076 [P] Write `docs/docs/python-sdk/topics/error_handling.mdx` covering the hierarchy, catching by + branch versus by code, the cross-version guarantees, the two accepted broadenings, and the note that + `infrahub_sdk.exceptions` is the supported import path. Link to Infrahub's published catalogue for + the code list rather than restating it, and note that a catalogued message now names the failing + action and resource kind where the catalogue provides them. +- [X] T077 [P] Add a towncrier fragment for the typed errors in `changelog/`. +- [X] T078 [P] Add a towncrier fragment for the `NodeNotFoundError.identifier` widening in `changelog/`. + **Landed in issue 1**, alongside the widening itself (T036). +- [X] T079 [P] Add a towncrier fragment for the `except GraphQLError` broadening in `changelog/`. +- [ ] T080 Run `uv run invoke format lint-code` and confirm both `mypy` and `ty` pass with **zero** + suppressions. A needed `# type: ignore` is a signal the shape is wrong, not a licence to add one. +- [ ] T081 Run `uv run invoke docs-generate && uv run invoke docs-validate` and confirm `sdk_ref` output is + unchanged, then `uv run invoke lint-docs`. +- [ ] T082 Run the full quickstart: every scenario in [quickstart.md](./quickstart.md), including the + testcontainers scenario 6b and the Infrahub-side scenario 7. +- [ ] T083 Confirm the landing order with whoever owns the release flow before the paired pull requests go + up: the SDK change merges first, then Infrahub bumps its submodule pointer to it. This is inferred + from the repositories' history rather than from a written practice. + +--- + +## Dependencies & Execution Order + +### Phase dependencies + +- **Setup (Phase 1)**: no dependencies. +- **Foundational (Phase 2)**: depends on Setup. **Blocks every user story.** +- **US2 (Phase 3)**: depends on Foundational only. Needs no generated bindings. +- **US3 (Phase 4)**: depends on Foundational. **Blocks US5** - T034's `CODE` declarations are what stop + the generator aborting. +- **US4 (Phase 5)**: depends on Foundational only. Independent of US3 and US5. +- **US6 (Phase 6)**: depends on `GraphQLError`'s optional `message` parameter, which T009 deferred to its + first caller. Whichever of T035 and US6 lands first adds it. Independent of US5. +- **US5 (Phase 7)**: depends on US3 (T034). Runs from the Infrahub checkout. +- **US1 (Phase 8)**: depends on US5 (T064 lands `catalogue.py`) and on US3 (T037's adopted `from_payload`). +- **Polish (Phase 9)**: depends on all of the above. + +### Parallel opportunities + +- **Phase 1**: T002, T005, T006 are independent files. +- **Phase 2**: T018, T019, T020, T021 touch four different modules and can run together once T011-T013 exist. +- **Phases 3, 4, 5, and 6 are mutually independent** once Foundational is done: US2, US3, US4, and US6 + each depend on Phase 2 alone. Two file overlaps to coordinate if they are genuinely worked in + parallel: US2 and US6 both edit `infrahub_sdk/exceptions/factory.py`, and US3 and US6 both add cases + to `tests/unit/sdk/test_exceptions.py`. US4 touches neither. +- **Phase 4**: T029-T033 are five independent test additions. +- **Phase 8**: T066, T068, T069, T070, T071 are independent; T070 and T071 are the two integration files. +- **Phase 9**: T076-T079 are four independent files. + +### Within each story + +- Fixtures before the tests that load them. +- Tests before the implementation that satisfies them. +- Base classes before the factory; the factory before the raise sites. +- Deliberate behaviour changes are pinned by a test asserting the **new** behaviour, never worked around. + +--- + +## Parallel Example: User Story 3 + +```bash +# Five independent test additions, one file each: +Task: "Hierarchy tests in tests/unit/sdk/test_exceptions.py" +Task: "Transport tests in tests/unit/sdk/test_exceptions.py" +Task: "Attribute-access test in tests/unit/sdk/test_exceptions.py" +Task: "Broadening test in tests/unit/sdk/test_exceptions.py" +Task: "Ladder tests in tests/unit/ctl/test_utils.py" +``` + +## Parallel Example: Phase 2 raise-site wiring + +```bash +Task: "Replace the six AuthenticationError raise sites in infrahub_sdk/object_store.py" +Task: "Replace the AuthenticationError raise site in infrahub_sdk/file_handler.py" +Task: "Correct the constructor misuse in infrahub_sdk/analyzer.py" +Task: "Correct the constructor misuse in infrahub_sdk/testing/schemas/animal.py" +``` + +--- + +## Pull requests and issues + +Task count is not pull-request count. The 83 tasks above are implementation granularity; the boundaries +below are set by three things, none of which is task count: + +1. **The repository split.** The generator lives in `opsmill/infrahub`, so it is a separate pull request + whatever else happens. +2. **The landing order** ([research.md](./research.md) R17): the SDK merges first, then Infrahub bumps its + submodule pointer. Infrahub's `validate_generated` diffs submodule *content*, so `catalogue.py` must + already exist at the pointer Infrahub carries. +3. **Where the review risk actually is.** The compatibility-sensitive work (re-rooting three classes, + reordering the CLI ladder, two deliberate broadenings) is the only place FR-018 can be violated. It + gets its own pull request so a reviewer reads it on its own. + +### Four issues, four pull requests + +| # | Issue | Repo | Tasks | Scope | +|---|-------|------|-------|-------| +| 1 | Parse the catalogue envelope onto the base classes | SDK | T001-T028, T036, T040-T041, T043-T047, T078 | The exceptions package, `ApiError`, the two factories, every raise site rewired, cross-version tolerance, and the typed refresh decision. No generated bindings, no re-rooting, no class anyone catches changes shape. | +| 2 | Unify and re-root the not-found classes | SDK | T029-T035, T037-T039, T042, T048-T051, T079 | The `CODE` declarations, the re-rooting, the CLI ladder, the message change, and the changelog fragment documenting the `except GraphQLError` broadening. | +| 3 | Typed per-code exceptions | SDK | T063-T064, T066-T077 | The generated `catalogue.py`, per-code resolution and its validation-failure fallback, both-client parity, the integration tests, the topic page, and the typed-errors changelog fragment. | +| 4 | Generate the SDK's error bindings | **Infrahub** | T052-T062, T065 | The template, the renderer, the adoption walk and collision check, `validate_generated`, the CI trigger, and the submodule pointer bump. | + +T080-T082 (format, lint, type-check, docs-validate, quickstart) run on **every** pull request for the +scenarios that request covers; quickstart scenario 7 runs only on issue 4. T083 (confirm the landing order +with whoever owns the release flow) belongs to issue 1's timeframe, before issues 3 and 4 go up. + +Two pull requests would also work (one SDK, one Infrahub), since the tasks are ordered so a single branch +carries them. It is not recommended: issue 2 is where "every existing `except` clause still catches what it +caught" has to be verified, and the implementation that has to be verified is only eleven tasks +(T029-T035, T037-T039, T042) across three files, ahead of its tests and changelog fragment. + +### Issue dependencies to record + +- **Issue 2 blocks issue 4.** The three `CODE` declarations (T034) are what stop the generator aborting on + an undeclared name collision. Generation fails outright without them; this is not a preference about + ordering. +- **Issue 3 depends on issues 2 and 4.** +- Not a dependency, but worth scheduling around: issues 1 and 2 both touch + `infrahub_sdk/exceptions/base.py`, so running them in sequence avoids a conflict rather than + satisfying a constraint. + +### Issue 4 is developed before issue 3 but merges after it + +`catalogue.py` in issue 3 is produced by running the generator from issue 4's unmerged branch (T063). So +issue 4 sits in progress while issue 3, which consumes its output, merges. That is the correct order, not +a mistake to be tidied up: the SDK pull request must carry the generated artefact before Infrahub's +content-level validation can pass against the pointer it bumps to. + +## Implementation Strategy + +### MVP scope + +The feature's value is US1, and US1 cannot ship without US2 (typed errors that raise on an unrecognised +payload would be a regression, not a feature) or without the generator that produces its classes. So the +MVP is **Phases 1-4 plus 7-8**: US2, US3, US5, US1. US4 and US6 are genuinely optional to the MVP and can +follow. + +--- + +## Notes + +- [P] tasks touch different files and have no dependency on an incomplete task. +- Commit after each task or logical group; run `uv run invoke format lint-code` before each commit. +- Do not reference this document, its task IDs, or the ticket in any file that ships: code, comments, + docstrings, changelog fragments, or documentation. Every shipped file must stand on its own. +- Stop at any checkpoint to validate the increment independently. diff --git a/dev/specs/infp-532-only-field-selection/alignment-check.md b/dev/specs/infp-532-only-field-selection/alignment-check.md new file mode 100644 index 000000000..14f226457 --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/alignment-check.md @@ -0,0 +1,58 @@ +# Spec/Ask Alignment Check: Fetch Only the Named Fields with `only` + +**Date**: 2026-10-14, updated 2026-10-16 | **Spec**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md) + +## Source + +The idea brief for [INFP-532](https://opsmill.atlassian.net/browse/INFP-532), pasted inline by the owner when the prep run started. It contains no links, so nothing was fetched. + +The brief settles the points where these older sources differ from it, so they were not used as sources of requirements: + +- the INFP-532 card, which proposes making `include` exclusive; +- the internal product review; +- the earlier PRD and the prototype branch `field-selection-only-infp-532`. + +`spec.md` was compared with the brief at commit `ce2bc721`, after the critique changes. + +## Verdict + +⚠️ MINOR DRIFT (proceeding) + +The spec carries all of the brief: + +- every functional requirement (FR-001 to FR-013); +- both journeys and all their acceptance scenarios; +- every edge case and success criterion (SC-001 to SC-006); +- the constitution alignment, governance gates and assumptions; +- every out-of-scope item and follow-up. + +Nothing the brief excludes was added. The differences below are clarifications made during the critique or decided by the owner afterwards. Each one resolves a case the brief leaves open or reconciles two of its statements, except the change to FR-005 on 2026-10-16, which the owner decided after a pull-request review and which replaces what the brief asked for `hfid` in `only` (owner decision 5). + +## Findings + +| Severity | Category | PRD reference | Spec reference | Description | +|---|---|---|---|---| +| Minor | changed | FR-001 "The query contains what FR-002 to FR-005 define, and nothing else." | FR-001; User Story 1, scenario 1 | The spec covers FR-002 to FR-007, the range the brief's SC-001 uses. It also says what "nothing else" leaves in place: filters, ordering, pagination, `count`, and the data that `property=True` and `include_metadata=True` add for the requested fields. The brief does not mention `property` or `include_metadata`. Keeping them was confirmed by the owner on 2026-10-14 (critique P7, research R4). | +| Minor | added | FR-005 | FR-005 | The spec adds that, for a generic kind, each implementing kind's HFID applies to its nodes. The brief does not cover generic kinds whose implementing kinds have their own HFID, as `Cat` does in the SDK's test schema (critique P5). Since 2026-10-16 the spec says instead that `hfid` is requested for a generic kind when the generic or an implementing kind has an HFID (owner decision 5). | +| Minor | changed | FR-002; FR-005; Out of Scope: "Using the server's `hfid` as a fallback for `node.hfid`" | FR-002, FR-005, Out of Scope | `hfid` in `only` now requests the HFID that the server stores, and a node from that query returns it as `node.hfid`. The spec no longer fetches the parts of the HFID. Out of Scope now leaves out the server's `hfid` only for nodes not from `only` and for nodes whose query did not name `hfid`. Decided by the owner on 2026-10-16 (owner decision 5). | +| Minor | changed | SC-002 | SC-002 | SC-002 now leaves out added fields that are named in `only`. As the brief wrote it, SC-002 conflicts with its own edge case for generic kinds: adding a named field to another implementing kind must add that field to the query (critique P6). | +| Minor | added | Key Entities | Key Entities, "Reading a field" | The spec defines what counts as reading a field for each field type, so FR-010 and SC-005 can be tested for relationships. This adds detail; it does not change the requirement. | +| Note | plan only | Out of Scope: "Using the server's `hfid` as a fallback for `node.hfid`" | research.md R10 | After `device.site.fetch(only=[...])`, `device.site.hfid` keeps the server value the reference carried when the fetched peer's HFID cannot be calculated. This concerns the relationship's `hfid`, not `node.hfid`. Confirmed by the owner on 2026-10-14. | +| Minor | changed | FR-006 "fetched only when named, as references" | FR-006 | Hierarchy fields named in `only` now follow FR-003 and FR-004 like other named relationships: references, or their peers' default fields with `prefetch_relationships=True`. The brief's FR-006 and FR-004 left this open. The owner chose this reading on 2026-10-14 (critique P3). | + +## Owner decisions (2026-10-14) + +After the first version of this report, the owner answered the four open decisions: + +1. `RelatedNode.hfid` and `hfid_str` keep the reference's server value after `fetch(only=...)` when the fetched peer's HFID cannot be calculated. Confirmed as planned (research R10). +2. `property=True` and `include_metadata=True` keep adding their data for the named fields under `only`. Confirmed as planned (spec FR-001, research R4). +3. Hierarchy fields named in `only` follow FR-003 and FR-004, so `prefetch_relationships=True` gives their default fields. This reverses the plan's first reading. FR-006, research R4, R9 and R15, the contract and tasks T012, T014, T017 and T021 were updated. +4. `upload_if_changed`, `matches_local_checksum` and `download_file(skip_if_unchanged=True)` raise `FieldNotLoadedError` when `checksum` was not fetched. Confirmed as planned (research R13). + +## Owner decision (2026-10-16) + +5. `hfid` in `only` requests the HFID that the server stores, instead of the fields that make it up. Since Infrahub 1.5.0 the server stores each node's HFID as an attribute. When a query requests `hfid`, it returns the stored value and calculates the HFID only when no value is stored (research R5 links the server code). Requesting the parts made the server follow each relationship in the HFID and read the peer's attribute, plus the peer's own `hfid` and `display_label`, which costs more than reading the stored value and works against the purpose of `only`. Source: the [review thread](https://github.com/opsmill/infrahub-sdk-python/pull/1423#discussion_r4294381206). FR-002, FR-005, the edge cases and Out of Scope in the spec, research R4, R5, R8, R9, R10, R15 and R17, the plan, the data model, the contract and the quickstart were updated. + +## Action + +Proceed. No remediation pass was needed (0 of 2 used). Every open decision is settled, including the change to FR-005 that the owner decided on 2026-10-16. diff --git a/dev/specs/infp-532-only-field-selection/checklists/requirements.md b/dev/specs/infp-532-only-field-selection/checklists/requirements.md new file mode 100644 index 000000000..d2c065fc2 --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Fetch Only the Named Fields with `only` + +**Purpose**: Validate specification completeness and quality before proceeding to planning + +**Created**: 2026-10-14 + +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs). Not applicable as written: for an SDK, method, parameter and error names are what the developer uses, so the spec names them. It does not describe how the query is built. +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders. The readers are SDK developers; the spec uses their terms. +- [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). Not applicable as written: SC-001 to SC-005 are stated in terms of queries and SDK errors, which are the developer-facing outcome of an SDK feature. +- [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 + +- Validation passed on the first iteration. +- The idea brief left no open questions, so the spec has no [NEEDS CLARIFICATION] markers. +- "Reading a field" is defined under Key Entities so that FR-010 and SC-005 can be tested for relationships as well as attributes. diff --git a/dev/specs/infp-532-only-field-selection/contracts/public-api.md b/dev/specs/infp-532-only-field-selection/contracts/public-api.md new file mode 100644 index 000000000..9208d9f4e --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/contracts/public-api.md @@ -0,0 +1,126 @@ +# Contract: Public API for `only`, `is_loaded` and the New Errors + +**Feature**: INFP-532 | **Spec**: [../spec.md](../spec.md) | **Research**: [../research.md](../research.md) + +Everything below applies to both clients. The sync classes have the same signatures and behaviour with `InfrahubClientSync`, `InfrahubNodeSync`, `RelatedNodeSync` and `RelationshipManagerSync`. + +## 1. Query methods + +`only` is appended as the last named parameter, after `priority` and before `**kwargs`, in every overload and in the implementation. + +```python +async def get(self, kind, raise_when_missing=True, at=None, branch=None, timeout=None, id=None, hfid=None, + include=None, exclude=None, populate_store=True, fragment=False, prefetch_relationships=False, + property=False, include_metadata=False, query_name=None, priority=None, + only: list[str] | None = None, **kwargs) + +async def all(self, kind, at=None, branch=None, timeout=None, populate_store=True, offset=None, limit=None, + include=None, exclude=None, fragment=False, prefetch_relationships=False, property=False, + parallel=False, order=None, include_metadata=False, query_name=None, priority=None, + only: list[str] | None = None) + +async def filters(self, kind, at=None, branch=None, timeout=None, populate_store=True, offset=None, limit=None, + include=None, exclude=None, fragment=False, prefetch_relationships=False, partial_match=False, + property=False, parallel=False, order=None, include_metadata=False, query_name=None, + priority=None, only: list[str] | None = None, **kwargs) +``` + +Behaviour: + +| Call | Result | +|---|---| +| `only=None` (or not passed) | Query and nodes exactly as today | +| `only=[...]` with `include` or `exclude` not `None` | `FieldSelectionConflictError`, no request sent | +| `only=[...]` with an unknown name | `FieldNotFoundError(kind, field)`, before the data query | +| `only=[...]` on a generic kind with `fragment=True` | Same query as without `fragment`; no default fields of implementing kinds are added | +| `only=[]` | Node requests `id`, `display_label`, `__typename` only | +| `only=["name"]` | Node requests `id`, `display_label`, `__typename`, `name { value }` | +| `only=["site"]` | Adds `site { node { id hfid display_label __typename } }` | +| `only=["site"], prefetch_relationships=True` | The `site` peer also gets its default fields; peer nodes are built and stored | +| `only=["hfid"]` | Node requests `id`, `hfid`, `display_label`, `__typename`; no field, relationship or peer that the HFID is made of. For a generic kind, `hfid` is requested when the generic or an implementing kind has an HFID | +| `only=["hfid"]` on a kind without an HFID | Same as `only=[]` | +| `only=["parent"]` on a hierarchical kind | Adds `parent` as a reference, in an inline fragment on the peer kind that the kind's schema declares for `parent`, or on the hierarchy generic when the schema declares none; no other hierarchy field. `children` works the same way; `ancestors` and `descendants` always use the hierarchy generic | +| `only=["parent"], prefetch_relationships=True` | The `parent` peer also gets the default fields of that peer kind, without its own hierarchy fields; the peer node is built and stored | +| `only=["id", "display_label"]` | Same as `only=[]` | +| Generic kind, name only on some implementing kinds | Requested in `...on Kind` blocks for those kinds, with or without `fragment=True` | + +`property=True` and `include_metadata=True` keep adding their data to the named fields and the node. + +`generate_query_data_init`, `generate_query_data` and `generate_query_data_node` on `InfrahubNode` and `InfrahubNodeSync` do not change. The query for `only` is built by private methods that `filters` calls. + +## 2. Fetching peers + +```python +# RelatedNode / RelatedNodeSync +async def fetch(self, timeout: int | None = None, priority: Priority | None = None, + only: list[str] | None = None) -> None + +# RelationshipManager / RelationshipManagerSync +async def fetch(self, only: list[str] | None = None) -> None +``` + +| Call | Result | +|---|---| +| `RelatedNode.fetch(only=[...])` | One `get` of the peer with `only`; the peer replaces the cached peer and goes to the store. `hfid` and `hfid_str` keep the reference's server value when the fetched peer has no HFID: its response contained no `hfid` (or an empty one) and its fields do not give one | +| `RelatedNode.fetch()` on a relationship that is not loaded, node from `only` | `FieldNotLoadedError` | +| `RelatedNode.fetch()` on a relationship with no peer, any other node | `Error`, as today | +| `RelationshipManager.fetch(only=[...])` | In this order: checks `only` against the declared peer kind (no request yet); reloads the relationship if it is not loaded; checks `only` against every actual peer kind; then one `filters(..., only=...)` per peer kind in a batch | +| Reload on a node from `only` | `get(kind, id=..., only=[relationship name], populate_store=False)`; works for `ancestors` and `descendants` | +| Reload on any other node | As today (`include` plus `exclude`) | +| A name unknown on the declared peer kind | `FieldNotFoundError`, no request sent | +| A name missing on one of the actual peer kinds | `FieldNotFoundError`, no peer query sent | + +## 3. `is_loaded` + +```python +@property +def is_loaded(self) -> bool +``` + +On `Attribute`, `RelatedNode`, `RelatedNodeSync`, `RelationshipManager` and `RelationshipManagerSync`, and on the `Attribute` and `RelatedNodeBase` protocols in `infrahub_sdk/protocols_base.py`. It returns `True` when the response contained the field, when the field was assigned since, when the field object has no node, or when the node has no `id`. It never raises. + +## 4. Reads that raise `FieldNotLoadedError` + +A read raises only when the field is not loaded (`is_loaded` is `False`) **and** the node is from `only`: a node returned by a query with `only`, a peer returned with it, or a peer fetched with `fetch(only=...)`. On every other node, these reads behave as today. + +| Field object | Raises on | Never raises `FieldNotLoadedError` on | +|---|---|---| +| `Attribute` | `value` | `is_loaded`, `name`, `id`, `is_default`, `is_from_profile`, `is_inherited`, `updated_at`, `is_protected`, `source`, `owner`, `updated_by` | +| `RelatedNode`, `RelatedNodeSync` (cardinality one, `parent`) | `id`, `hfid`, `hfid_str`, `display_label`, `typename`, `kind`, `peer`, `get()`, `fetch()` | `is_loaded`, `initialized`, `is_resource_pool`, `is_from_profile`, `name`, `schema`, relationship properties | +| `RelationshipManager`, `RelationshipManagerSync` (cardinality many, `children`, `ancestors`, `descendants`) | `peers`, `peer_ids`, `peer_hfids`, `peer_hfids_str`, indexing, iteration, `is_from_profile` | `is_loaded`, `initialized`, `has_update`, `name`, `schema`, `fetch()` | + +Node methods that read through these accessors raise in the same cases, for example `get_path_value` and `get_flat_value`. So do `matches_local_checksum`, `download_file(skip_if_unchanged=True)` and `upload_if_changed`, when `checksum` was not fetched. + +Operations that never raise on a node from `only`: + +- `save()` (create and update); +- `create(allow_upsert=True)`; +- `update()`; +- `hfid`, `hfid_str`, `get_human_friendly_id()`, `get_human_friendly_id_as_string()`. When `only` named `hfid`, they return the server's value, or `None` when it is empty or null, until a field the HFID is built from is changed on the node; otherwise they are calculated from the fetched fields; +- putting the node in the store; +- `__repr__` and `__str__`. + +When the HFID is calculated, every node uses the same private reader. A field that a node from `only` did not fetch counts as missing, also when the reader reaches that node as a peer in the store. Every other field is read as `get_path_value` reads it. So the HFID methods above, and putting a node in the store, do not raise on any other node either, even when a peer that the HFID reads came from `only`. + +`add`, `extend` and `remove` on a cardinality-many relationship that is not loaded keep raising `UninitializedError`, as today. + +## 5. Errors + +All in `infrahub_sdk.exceptions`, each with `Error` as its only base class. + +```python +class FieldNotLoadedError(Error): + def __init__(self, kind: str, field: str, only: Sequence[str], peer_of: str | None = None) -> None: ... + # message: "InfraDevice.description was not fetched: the node was queried with only=['name']. Add 'description' to only, or check is_loaded before reading it." + # with peer_of: "InfraSite.description was not fetched: the node is a peer of InfraDevice.site, queried with only=['site']. ..." + +class FieldNotFoundError(Error): + def __init__(self, kind: str, field: str) -> None: ... + # message: "'nmae' is not an attribute or relationship of InfraDevice." + +class FieldSelectionConflictError(Error): + def __init__(self) -> None: ... + # message: "only cannot be combined with include or exclude." +``` + +The exact wording is set during implementation. The tests match on the kind, the field and the `only` list. diff --git a/dev/specs/infp-532-only-field-selection/critiques/critique-20261014-1.md b/dev/specs/infp-532-only-field-selection/critiques/critique-20261014-1.md new file mode 100644 index 000000000..39571d743 --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/critiques/critique-20261014-1.md @@ -0,0 +1,235 @@ +# Critique Report: Fetch Only the Named Fields with `only` + +**Date**: 2026-10-14 +**Feature**: [spec.md](../spec.md) +**Plan**: [plan.md](../plan.md) +**Verdict**: ⚠️ PROCEED WITH UPDATES + +--- + +## Executive Summary + +The spec carries every requirement, journey, edge case, success criterion and scope limit of the idea brief, and adds nothing the brief excludes. The plan's design (a separate query path for `only`, a per-field "the response contained this field" record, a marker on nodes from `only`, and private reads for the SDK's own operations) is sound and keeps default, `include` and `exclude` queries unchanged. Its code references are accurate. The claim in R8 holds: tracing `save()`, `create(allow_upsert=True)`, `update()`, `_generate_input_data`, `_strip_unmodified`, `_generate_mutation_query`, `_process_mutation_result`, `get_human_friendly_id`, `store.set`, `__repr__`, `__eq__`/`__hash__` and batch create found no read of an unloaded field outside the R8 list, given the four details in E5. No conflict with the constitution was found. + +Three problems block tasks. E1 and E2 were confirmed with a script run against this tree; E3 follows from the order of steps that R10 states. + +- E1: peers of relationships that exist only on an implementing kind are never built. For generic kinds, this breaks FR-004 and FR-005. +- E2: `fetch()` on `ancestors` and `descendants` raises `ValueError` when they are not loaded, which they are not under FR-006. +- E3: the reload in `RelationshipManager.fetch(only=...)` sends a data query before the name check, which SC-004 forbids. + +All three can be fixed by editing research.md and the contract; none needs a new design. The other findings are wording fixes in the spec, implementation details the tasks need, extra tests, and two questions for the owner. + +--- + +## Product Lens Findings 🎯 + +### Problem Validation + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P1 | ✅ (strength) | The spec solves the brief's problem: the developer names the fields, the query does not grow with the schema, and unfetched reads raise a typed error that names the field and the `only` list. FR-001 to FR-013, SC-001 to SC-006, the edge cases and the out-of-scope list match the brief. No customer is named. | None. | + +### User Value Assessment + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P2 | 💡 | FR-001 and acceptance scenario 1 say the query contains the listed fields "and nothing else". The plan keeps `count`, the filter arguments, ordering and pagination (R4), and `count` is needed for paging (`client.py:1394`). It also keeps the data that `property=True` and `include_metadata=True` add. Tests written from the spec text would contradict the plan. | spec.md FR-001: "For the queried node and its peers, the query MUST request what FR-002 to FR-007 define and no other field. Query arguments, `count`, and the data that `property=True` and `include_metadata=True` add for the requested fields are kept." In acceptance scenario 1, write "the node requests `id`, `display_label`, `__typename` and `name`, and no other field". | + +### Alternative Approaches + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P3 | 🤔 | FR-004 expands the peers of named relationships under `prefetch_relationships=True`. FR-006 says hierarchy fields come back "as references". FR-003 also says "as references", and FR-004 then expands those, so FR-006 can be read the same way: references unless prefetch is set. R4 reads FR-006 as references in every case. The default path expands hierarchy fields under prefetch today (`node.py:1325-1337`), so `only=["parent"], prefetch_relationships=True` is the one named relationship that prefetch would not expand. | Ask the owner which reading is meant. Then write the answer into spec.md FR-006 ("even with `prefetch_relationships=True`" or "unless `prefetch_relationships=True`") and align R9 (see E4). | +| P7 | 🤔 | R4 keeps `property=True` and `include_metadata=True` working under `only`. The brief does not say. The reading is reasonable: the developer asks for them explicitly and they add no field of the kind. | Ask the owner to confirm. Then state it in spec.md Edge Cases, together with the P2 wording. | + +Other interpretations of the brief were checked and found to be the best reading: + +- File-checksum methods raise when `checksum` was not fetched (R13). This is consistent with FR-010, and the other option hides a missing field behind a full transfer. +- The public `get_path_value` raises on a node from `only`. This is a read by the developer; E11 covers its docstring. + +### Edge Cases & UX + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P4 | 💡 | The spec says `node.hfid` is `None` only when the HFID fields were not fetched and `hfid` was not named. The plan adds two more `None` cases that the spec does not state. (a) With `populate_store=False`, an HFID path through a relationship gives `None` even when `hfid` is named (R5). (b) `device.site.fetch(only=["description"])` sets the cached peer (`related_node.py:369-371`). From then on, `device.site.hfid` returns the peer's calculated HFID (`related_node.py:150-152`), which is `None` when the HFID fields were not fetched. R5 rejected setting the cached peer for exactly this reason. | Apply X1. If X1 is not adopted, list both cases under spec.md Edge Cases "HFID that cannot be calculated". For (b), research.md R10 has two options for the owner to choose from. Option 1: under `only`, `fetch` does not set the cached peer, and `.peer` finds the peer in the store, where `fetch` puts it. This makes `.peer` depend on the store, the problem X1 describes. Option 2: `fetch` keeps setting the cached peer, and `RelatedNode.hfid` returns the reference's server `hfid` when the peer's calculated HFID is `None`. Update the contract §2 row "RelatedNode.fetch(only=[...])" to match the choice. | +| P5 | 💡 | FR-005 says that for a kind without an HFID, naming `hfid` changes nothing. R5 applies each implementing kind's own HFID in its `...on Kind` block. For a generic kind without an HFID whose implementing kinds have one, the two disagree. | spec.md FR-005: add "For a generic kind, the HFID of each implementing kind applies to the nodes of that kind." | + +### Success Measurement + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| P6 | 💡 | SC-002 says adding attributes or relationships to a kind leaves an existing `only` query unchanged. For a generic kind, adding a field that is named in `only` to another implementing kind adds an `...on Kind` block by design, so the criterion as written fails. | spec.md SC-002: "Adding attributes or relationships that are not named in `only` to a kind, or to any kind implementing it, leaves the query for an existing `only` call unchanged." | + +--- + +## Engineering Lens Findings 🔬 + +### Architecture Soundness + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E1 | 🎯 | **Peers inside `...on Kind` blocks are never built.** R4 and contract §1 put names that exist only on implementing kinds in `...on Kind` blocks, with or without `fragment=True`. The existing generator aliases the fields in those blocks as `__alias__Kind__name` (`node.py:1493`, `:1530`). The aliases are needed because two kinds can have the same field name with different types. `from_graphql` strips the aliases (`node.py:899`), but `_process_relationships` reads the raw response item that `client.py:857-863` passes it (`node.py:1806`, `:1823`). A script run against this tree confirmed it: a node built from an aliased `site` has `node.site.id == "s1"`, and `_process_relationships` builds no peer. With `prefetch_relationships=True`, `node.rel.peer` then raises `NodeNotFoundError` (FR-004 fails). An HFID path through such a relationship leaves `node.hfid` `None` (FR-005 fails). The planned integration test cannot catch this, because the animal schema's HFID relationship `owner` is defined on the generic. | research.md R4: "Implementing-kind blocks use the existing `__alias__Kind__name` aliases." R9: "Under `only`, `_process_relationships` reads the alias-stripped node data, so relationships of implementing kinds are processed." R15: add a unit case for a generic kind whose implementing kind has its own relationship and uses it in its HFID, with and without prefetch. | +| E4 | 💡 | **R9 would store reference-only `parent` peers.** R4 keeps `parent` and `children` as references with `prefetch_relationships=True`. R9 builds peer nodes for "relationships named in `only`, when `prefetch_relationships=True`". `parent` and `children` are schema relationships (`tests/unit/sdk/test_hierarchical_nodes.py:29-43`), so they are in `node._relationships`. `_process_relationships` would build reference-only peers, mark them and store them (`client.py:1409-1411`). That replaces fuller copies with empty ones and breaks the spec edge case where `.peer` raises `NodeNotFoundError`, which is the outcome R9 was written to avoid. | research.md R9: "While R4 keeps hierarchy fields as references, `parent` and `children` are never in the list of relationships to process." Add a test. Revisit after the P3 answer. | +| E6 | 💡 | **R6 does not describe three bindings.** (a) The `RelatedNode` objects inside a `RelationshipManager` (`relationship.py:209-221`, `add` at `:296`) have no data key of their own. (b) `parent` and `children` are built twice (`node.py:919-950`). `node.parent` returns the cardinality-one copy, not the `_hierarchical_data` copy (checked). (c) Assignment builds a new `RelatedNode` (`node.py:979-983`). | research.md R6 and data-model.md: "RelatedNodes inside a manager are not bound and are always loaded, so `peer_ids`, `add` and `remove` never raise. Both copies of `parent` and `children` are bound. The RelatedNode built on assignment is bound and marked assigned." | +| E7 | 💡 | **The fragment loop is outside the planned branch.** The Design Notes put the `only` branch in `generate_query_data_init` and `generate_query_data_node`. The fragment loop is in `generate_query_data` (`node.py:1425-1449`) and calls each implementing kind's `generate_query_data_node` with `include` and `exclude`. With `fragment=True` and `only`, it would add every default field of every implementing kind, which breaks FR-001. | plan.md Design Notes: "`generate_query_data` takes the `only` path before the fragment loop; the loop runs only when `only is None`." R15: `fragment=True` and `fragment=False` give the same `only` query. | +| E9 | 💡 | **Shared helpers must not do I/O.** The plan puts "name check for a kind and its implementing kinds" in `_only.py` for both clients. Schema lookups are awaited on one client and plain calls on the other. | plan.md Structure Decision: "`_only.py` functions take resolved schemas and do no I/O. Each client and node class reads the implementing kinds' schemas itself (with the query's branch, R14) and passes them in." | +| E10 | 💡 | **A second public entry for `only`.** R1 and contract §1 add `only` to `generate_query_data_init`, `generate_query_data` and `generate_query_data_node`, which are public and documented in the reference. The brief asks for `get`, `all`, `filters` and `fetch` only. These methods do not check names, so a misspelt name passed there is ignored without an error. | Keep the `only` query path behind a private parameter or method that `filters` calls; tests can call it directly. If the public parameter stays, contract §1 must say that unknown names are ignored there, and spec.md Governance must list the extra public parameter. | +| E13 | 💡 | **The R12 rationale is half right.** Generated protocols type cardinality-one relationships as `RelationshipAttribute[...]`, which resolves to the concrete `RelatedNode` (`related_node.py:515-536`, `protocols.py:43-44`). Only the change to the `Attribute` protocol is needed for typed code. | research.md R12: correct the rationale. Adding `is_loaded` to the `RelatedNodeBase` protocol is harmless but not needed for generated protocols. | + +### Failure Mode Analysis + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E2 | 🎯 | **`fetch()` on `ancestors` and `descendants` raises `ValueError` when they are not loaded.** The reload in `RelationshipManager.fetch` calls `exclude.remove(self.schema.name)` on the parent's `relationship_names + attribute_names` (`relationship.py:245-246`, sync `:442-443`). `ancestors` and `descendants` are pseudo-schemas that are not in `relationship_names` (`schema/main.py:240`, `:340-353`). A script run against this tree confirmed it: `node.ancestors.fetch()` and the sync `descendants.fetch()` raise `ValueError: list.remove(x): x not in list`. Under FR-006 these managers are not loaded unless named, so `fetch(only=...)` on them (FR-012) fails. Contract §4 also says `fetch()` never raises. Separately, the reload puts the reloaded parent in the store without the `only` marker, so code reading the store gets `None` instead of `FieldNotLoadedError` (plan.md "Existing issues"). | research.md R10: "When the parent node carries the `only` marker, the reload calls `get(..., only=[self.schema.name])` instead of `include` and `exclude`." R3 accepts hierarchy names. The stored parent then carries the marker, and default nodes keep today's reload. Add the case to contract §2 and a test of `ancestors.fetch(only=[...])` on a node from `only`, on both clients. | +| E3 | 🎯 | **The reload is sent before the name check.** R10 runs the reload of a manager that is not loaded, then checks the names against the peer kinds. `manager.fetch(only=["nmae"])` therefore sends a data query before raising. SC-004 requires a misspelt name to be reported "before the data query is sent, in every case". | research.md R10: "Before the reload, check `only` against the relationship's declared peer kind (`self.schema.peer`), with the FR-008 rule when that kind is a generic. After the reload, check every actual peer kind as planned." R15: assert that `httpx_mock` records no request in this case. | +| E5 | 💡 | **R8 is complete, but four details decide whether it holds.** (1) `initialized` (`related_node.py:190`) uses the peer's calculated HFID when a peer object is set. An unsaved peer with only an HFID depends on this during batch create and object loading, so the private version must keep it and not read `_hfid`. (2) `RelationshipManagerBase.__init__` assigns `self.peers = []` (`relationship.py:58`) and appends to it (`:209`, `:217`, `:406`, `:414`). Once the `peers` setter marks the manager loaded, these lines must write `_peers`, or every manager reports `is_loaded` `True`. (3) The `peers` getter must return the stored list itself, because `infrahubctl object update` clears it and appends to it in place (`ctl/object/update.py:217-227`). (4) The SDK tests the truthiness of relationship objects (`node.py:166`, `:580`, `:1805`, `:1822`). A raising `__len__` or `__bool__` would make `save()` raise. | Add the four points to research.md R8. Add a regression test for point 1: a relationship to an unsaved peer that has only an HFID is sent with `hfid`, as today. | +| E8 | 💡 | **Nodes from `only=["hfid"]` are not indexed by HFID in the store.** `filters` stores the queried nodes before their peers (`client.py:1404-1411`), and `store.set` calculates the HFID at that moment (`store.py:56-60`). For an HFID through a relationship, the peer is not in the store yet, so the node is not indexed by HFID. An older, fuller copy stays under that HFID while lookups by `id` return the new copy. The store guide shows `store.get(key=tag.hfid, kind=...)` returning the same node as a lookup by `id` (`docs/docs/python-sdk/guides/store.mdx:107-132`). | research.md R9: "Under `only`, peers are put in the store before the queried nodes." R15: test that `client.store.get(key=node.hfid, kind=...)` returns the node from `only`. R16: add one sentence to the store guide saying that nodes from `only` replace fuller copies of the same node. | + +### Security & Privacy + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E15 | ✅ | No change to authentication, tokens or data sent to the server beyond a smaller selection set. Unfetched fields are neither sent nor cleared on save (`attribute.py:157-159`, `node.py:474-484`). | None. | + +### Performance & Scalability + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E12 | 💡 | R6 gives every attribute and relationship of every node, including nodes from default queries, a reference to its node. That adds a reference cycle per field. Such nodes are freed only by the cycle collector, which matters for large loads such as the SC-006 workload. | research.md R6: "The reference to the node is a `weakref`." | + +### Testing Strategy + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E14 | 💡 | The R15 matrix has no case for the problems above. The integration schema cannot reach E1, and the R8 matrix does not cover E5 point 1. | research.md R15: add unit cases, on both clients, for the following:
  • a generic kind with a relationship only on an implementing kind, with prefetch and in its HFID (E1);
  • `ancestors.fetch(only=...)` on a node from `only` (E2);
  • `fetch(only=["nmae"])` on a manager that is not loaded, with no request sent (E3);
  • `only=["parent"]` with prefetch stores no peer (E4);
  • a relationship to an unsaved peer that has only an HFID (E5);
  • `fragment=True` and `fragment=False` give the same query (E7);
  • `store.get` by HFID (E8).
| + +### Operational Readiness + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E11 | 💡 | Several public docstrings describe behaviour that changes for nodes from `only`:
  • `get_path_value` says it returns `None` when a path component cannot be resolved (`node.py:155-157`);
  • `matches_local_checksum` lists only `FeatureNotSupportedError` and `ValueError` (`node.py:1137-1140`), and `download_file` and `upload_if_changed` have similar `Raises:` sections;
  • `RelatedNode.fetch` lists only `Error` (`related_node.py:362-363`).
R16 regenerates the reference but does not say that these docstrings change. Constitution VII is non-negotiable. | research.md R16: "Before running `docs-generate`, update the `Raises:` section of every public method in contract §4, and of `RelatedNode.fetch` and `RelationshipManager.fetch`." | + +### Dependencies & Integration + +| ID | Severity | Finding | Suggestion | +|----|----------|---------|------------| +| E16 | ✅ | `infrahub_sdk/ctl/`, `transfer/` and `query_groups.py` read only nodes from default queries (for example `query_groups.py:121`, `transfer/importer/json.py:126`), so they never meet the marker. No new dependency. The three new errors do not collide with names in the generated `catalogue.py`, and the one-base-class rule (`tests/unit/sdk/test_exceptions.py:106-110`) is respected. | None, apart from E5 point 3. | + +--- + +## Cross-Lens Insights 🔗 + +| ID | Finding | Product Impact | Engineering Impact | Suggestion | +|----|---------|---------------|-------------------|------------| +| X1 | The HFID of a node from `only` depends on what the store holds when it is read. | `node.hfid` can be `None` even when the developer named `hfid`: with `populate_store=False`, or after a later query stores a copy of the peer without the HFID field. `device.site.hfid` can change from a value to `None` after `fetch(only=...)` (P4). | The HFID calculation finds peers through `RelatedNode.get()` and the store (`node.py:169-174`, `related_node.py:409-413`), and the store returns the latest copy by `id` (`store.py:53-54`). | research.md R5: "Peers built for HFID paths are also kept in a private slot on the node, which only the private HFID calculation reads. The relationship's cached peer is not set, so `RelatedNode.hfid` keeps the server value." This makes `hfid` in `only` work with `populate_store=False` and protects it from later writes to the store. If the owner prefers R5 as it is, record both `None` cases in the spec (P4). | + +--- + +## Findings Summary + +| Metric | Count | +|--------|-------| +| 🎯 Must-Address | 3 | +| 💡 Recommendations | 16 | +| 🤔 Questions | 2 | +| Product findings | 7 (1 strength) | +| Engineering findings | 16 (2 strengths) | +| Cross-lens findings | 1 | + +--- + +## Consolidated Findings Table + +| ID | Lens | Severity | Category | Finding | Suggestion | +|----|------|----------|----------|---------|------------| +| E1 | Engineering | 🎯 | Architecture | `_process_relationships` reads the raw response, so peers of relationships that exist only on an implementing kind (under `__alias__Kind__rel`) are never built; FR-004 and FR-005 fail for generic kinds. | R4: state the aliases. R9: process the alias-stripped data. R15: add a generic case. | +| E2 | Engineering | 🎯 | Failure mode | `ancestors.fetch()` and `descendants.fetch()` raise `ValueError` when not loaded (`relationship.py:245-246`); FR-012 fails under FR-006. | R10: under `only`, the reload uses `get(only=[name])`. Update contract §2 and add a test. | +| E3 | Engineering | 🎯 | Failure mode | The reload query is sent before the name check, against SC-004. | R10: check against `self.schema.peer` before the reload. Add a no-request test. | +| P2 | Product | 💡 | Spec wording | "Nothing else" in FR-001 contradicts `count`, the filters, `property` and `include_metadata`. | Reword FR-001 and acceptance scenario 1. | +| P4 | Product | 💡 | Edge cases | Two `None` cases for HFID (`populate_store=False`; after `fetch(only)`) are not in the spec; R10 sets the cached peer that R5 rejected. | Apply X1, or list both cases in the spec. R10: do not set the cached peer under `only`. | +| P5 | Product | 💡 | Spec wording | FR-005 "kind without an HFID" disagrees with R5 for generic kinds. | Add the per-implementing-kind rule to FR-005. | +| P6 | Product | 💡 | Success criteria | SC-002 fails by design for generic kinds when a named field is added to another implementing kind. | Limit SC-002 to fields not named in `only`. | +| E4 | Engineering | 💡 | Architecture | R9 would build, mark and store reference-only `parent` and `children` peers under prefetch. | R9: exclude hierarchy names while R4 keeps them as references. | +| E5 | Engineering | 💡 | Failure mode | R8 holds only if: `initialized` keeps the peer-HFID rule; `__init__` writes `_peers`; `peers` returns the same list; no raising `__len__` or `__bool__` is added. | Add the four points to R8, and a regression test. | +| E6 | Engineering | 💡 | Architecture | R6 does not cover RelatedNodes inside managers, the two copies of `parent` and `children`, or the RelatedNode built on assignment. | State these bindings in R6 and data-model.md. | +| E7 | Engineering | 💡 | Architecture | The fragment loop in `generate_query_data` would add default fields under `only` with `fragment=True`. | Branch before the loop, and add a test. | +| E8 | Engineering | 💡 | Failure mode | Nodes are stored before peers, so nodes from `only=["hfid"]` are not indexed by HFID. | Store peers first under `only`; add a test and a store-guide note. | +| E9 | Engineering | 💡 | Parity | `_only.py` cannot do schema lookups for both clients. | Make `_only.py` functions take resolved schemas. | +| E10 | Engineering | 💡 | Scope | `only` on the public `generate_query_data*` methods is a second public entry, not checked, and not in the brief. | Make it private, or document that unknown names are ignored there. | +| E11 | Engineering | 💡 | Documentation | `Raises:` sections and the `get_path_value` return contract become wrong for nodes from `only` (VII). | R16: update the docstrings before `docs-generate`. | +| E12 | Engineering | 💡 | Performance | A field-to-node reference on every field of every node adds reference cycles. | Use a `weakref`. | +| E13 | Engineering | 💡 | Typing | The R12 rationale for the `RelatedNodeBase` protocol is not needed for generated protocols. | Correct R12. | +| E14 | Engineering | 💡 | Testing | The R15 matrix misses the cases behind E1 to E8. | Add the listed unit cases. | +| X1 | Cross-lens | 💡 | Design | HFID values on nodes from `only` depend on the store at read time. | Keep HFID peers in a private slot on the node. | +| P3 | Product | 🤔 | Interpretation | Hierarchy fields with prefetch: FR-006 "as references" can be read like FR-003 (expanded by FR-004). | Owner decides; write the answer into FR-006. | +| P7 | Product | 🤔 | Interpretation | `property` and `include_metadata` under `only` are not in the brief. | Owner confirms; state it in the spec. | + +--- + +## Recommended Actions + +### 🎯 Must-Address (Before Proceeding) + +1. **E1**: research.md R4: implementing-kind blocks keep the `__alias__Kind__name` aliases. R9: `_process_relationships` reads the alias-stripped node data under `only`. R15: add a generic-kind case whose implementing kind has its own relationship in its HFID, with and without prefetch. +2. **E2**: research.md R10: when the parent carries the `only` marker, the reload of a manager that is not loaded calls `get(..., only=[self.schema.name])`. Add the row to contract §2, and a test of `ancestors.fetch(only=[...])` on both clients. +3. **E3**: research.md R10: check `only` against `self.schema.peer` (FR-008 rule for a generic peer) before the reload, then against every actual peer kind. R15: no request is recorded for a misspelt name. + +### 💡 Recommendations (Strongly Suggested) + +1. **X1 and P4**: keep HFID peers in a private slot on the node (R5). Under `only`, `RelatedNode.fetch` does not set the cached peer (R10, contract §2). If not adopted, list both `None` cases in spec Edge Cases. +2. **E4**: R9 excludes `parent` and `children` from the relationships to process while R4 keeps them as references. +3. **E5**: add the four `peers`, `initialized` and truthiness details to R8, with a regression test for an unsaved peer that has only an HFID. +4. **E7**: plan Design Notes: `generate_query_data` branches before the fragment loop. +5. **E8**: R9 stores peers before queried nodes under `only`. Add a test and a store-guide sentence. +6. **E11**: R16 updates the `Raises:` sections and the `get_path_value` docstring before `docs-generate`. +7. **P2, P5, P6**: reword spec FR-001 and acceptance scenario 1, FR-005 and SC-002 as proposed. +8. **E6, E9, E10, E12, E13, E14**: apply the edits in the tables above. + +### 🤔 Questions (Need Stakeholder Input) + +1. **P3**: With `prefetch_relationships=True`, should `parent`, `children`, `ancestors` and `descendants` named in `only` come back with their default fields (as FR-004 does for other relationships), or stay references (the plan's reading of FR-006)? +2. **P7**: Should `property=True` and `include_metadata=True` keep adding their data for the named fields under `only`? + +--- + +**Severity Legend**: + +- 🎯 **Must-Address**: Blocks proceeding to implementation +- 💡 **Recommendation**: Strongly suggested improvement +- 🤔 **Question**: Needs stakeholder input to resolve + +--- + +*Generated by `/speckit.critique` — Dual-lens strategic and technical review for spec-driven development.* + +--- + +## Resolution (applied before task generation) + +The owner was not asked during this run; each finding was settled autonomously, as the prep workflow requires. + +| ID | Outcome | Where | +|----|---------|-------| +| E1 | Applied. Under `only`, peer nodes are built from the node's data with aliases removed. | research.md R4, R9, R15 | +| E2 | Applied. On a node from `only`, a relationship that is not loaded is reloaded with `get(..., only=[name], populate_store=False)`, which also works for `ancestors` and `descendants`. | research.md R10, contract §2 | +| E3 | Applied. `RelationshipManager.fetch(only=...)` checks the names against the declared peer kind before any request, then against every actual peer kind before the batch. | research.md R10, contract §2 | +| P2 | Applied. FR-001 and acceptance scenario 1 now say "no other field", and keep filters, ordering, pagination, `count`, `property` and `include_metadata`. | spec.md | +| P3 | Decided by the owner (2026-10-14): hierarchy fields named in `only` follow FR-003 and FR-004 like other named relationships, so `prefetch_relationships=True` gives their default fields. FR-006 reworded. | spec.md FR-006, research.md R4, R9 | +| P4 | Applied with X1 for case (a). For case (b), `fetch` keeps setting the cached peer, and `RelatedNode.hfid`/`hfid_str` keep the reference's server value when a peer from `only` has no calculable HFID. Confirmed by the owner (2026-10-14). | research.md R5, R10, contract §2 | +| P5 | Applied. FR-005 says each implementing kind's HFID applies to its nodes. | spec.md | +| P6 | Applied. SC-002 excludes added fields that are named in `only`. | spec.md | +| P7 | Decided, then confirmed by the owner (2026-10-14): `property=True` and `include_metadata=True` keep adding their data for the named fields. | spec.md FR-001, research.md R4 | +| E4 | Superseded by the owner's P3 decision: under prefetch, `parent` and `children` peers are built and stored as today; `ancestors` and `descendants` peers are not built, as today. | research.md R9 | +| E5 | Applied. The four conditions are recorded. | research.md R8 | +| E6 | Applied. | research.md R6 | +| E7 | Applied. The existing fragment loop is not run under `only`. | research.md R4, contract §1 | +| E8 | Applied through X1: the HFID peers are kept on the node, so the node is indexed by HFID when it is stored. A store-guide sentence and a test are added. | research.md R5, R15, R16 | +| E9 | Applied. | plan.md Design Notes | +| E10 | Applied. The public `generate_query_data*` methods do not change; private methods build the query for `only`. | research.md R1, plan.md, contract §1, data-model.md | +| E11 | Applied. | research.md R16 | +| E12 | Declined. A plain reference is kept, as `RelationshipManager.node` already is; a weak reference would make `is_loaded` depend on whether the developer still holds the node. | research.md R6 | +| E13 | Applied. | research.md R12 | +| E14 | Applied. | research.md R15 | +| X1 | Applied. | research.md R5, R8 | + +Verdict after resolution: ✅ PROCEED to task generation. diff --git a/dev/specs/infp-532-only-field-selection/data-model.md b/dev/specs/infp-532-only-field-selection/data-model.md new file mode 100644 index 000000000..ba239c9f6 --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/data-model.md @@ -0,0 +1,92 @@ +# Data Model: Fetch Only the Named Fields with `only` + +**Feature**: INFP-532 | **Date**: 2026-10-14 | **Spec**: [spec.md](./spec.md) | **Research**: [research.md](./research.md) + +This feature adds no stored data. It adds one parameter, one property on field objects and three error types. It also adds private state on nodes and field objects that records what the server returned. + +## `only` parameter (public) + +- **Type**: `list[str] | None`, default `None`. +- **Accepted by**: + - `get`, `all` and `filters` on `InfrahubClient` and `InfrahubClientSync`; + - `fetch` on `RelatedNode`, `RelatedNodeSync`, `RelationshipManager` and `RelationshipManagerSync`. +- **Not accepted by** the public `generate_query_data*` methods, which do not change. Private methods build the query for `only`. +- **Values**: + - `None`: the default set, exactly as today. + - `[]`: `id`, `display_label` and `__typename` only. + - A list of names. Each name is one of: + - an attribute or relationship of the kind; + - for a generic kind, an attribute or relationship of the generic or of any kind in `used_by`; + - `parent`, `children`, `ancestors` or `descendants`, when the kind has a hierarchy; + - `id`, `display_label` (accepted, add nothing) or `hfid` (requests the HFID the server stores, research R5). +- **Validation**: + - `only` with `include` or `exclude` that is not `None` raises `FieldSelectionConflictError` before any request. + - A name outside the list above raises `FieldNotFoundError` before the data query. + - For `RelationshipManager.fetch`, every name must be accepted by every peer kind being fetched. +- **Duplicates**: allowed and ignored. + +## "The response contained this field" record (private) + +Kept on each field object of every node (research R6): + +| Field object | Set to true when | Source today | +|---|---|---| +| `Attribute` | the node's data has the attribute's name as a key; the `value` setter runs | new private flag | +| `RelatedNode` (cardinality one, `parent`) | the node's data has the relationship's name as a key, even with `{"node": null}`; the relationship is assigned through the node | new private flag | +| `RelationshipManager` (cardinality many, `children`, `ancestors`, `descendants`) | `initialized` is true (data was given, or `fetch()` ran); the `peers` setter runs | existing `initialized`, plus a private flag for the setter | + +Each attribute and cardinality-one relationship also holds a private reference to its node, bound after construction. A cardinality-many relationship reads its node from `node`, which its constructor already takes. Field objects built without a node have none. + +## `is_loaded` (public, read-only property) + +On `Attribute`, `RelatedNode`, `RelatedNodeSync`, `RelationshipManager` and `RelationshipManagerSync`: + +```text +is_loaded = record is true + or the field object has no node + or the node has no id +``` + +It never raises and makes no request. + +## "Node from `only`" marker (private) + +Kept on `InfrahubNode` and `InfrahubNodeSync`. It is a small frozen value with: + +- `only`: the `only` list of the query that returned the node, as a tuple; +- `peer_of`: `None` for the queried nodes, or `"Kind.relationship"` for a peer returned with them. + +Set by `_process_nodes_and_relationships` when the query had `only`: + +- on each queried node; +- on each peer node built from the same response (research R9). + +Peers from `fetch(only=...)` get their own marker, because they come from `filters`. Nodes built any other way have no marker and never raise `FieldNotLoadedError`. + +## Server HFID (private) + +Kept on `InfrahubNode` and `InfrahubNodeSync` that come from a query with `only`, when the node's data contained `hfid`, which the query requests only when `only` names it: the list the server returned, empty for a null value. It is set together with the marker. `get_human_friendly_id` returns it, or `None` when it is empty, instead of calculating the HFID. Nodes not from `only`, and peers returned with them, never have it (research R5). + +## Error types (public) + +All three are in `infrahub_sdk/exceptions/base.py`, subclass `Error` only, and are exported from `infrahub_sdk.exceptions`. + +| Error | Attributes | Raised when | +|---|---|---| +| `FieldNotLoadedError` | `kind`, `field`, `only`, `peer_of` | a raising accessor (contract table) reads a field that is not loaded, on a node with the marker | +| `FieldNotFoundError` | `kind`, `field` | a name in `only` is not accepted for the kind (or for one of the peer kinds in `RelationshipManager.fetch`) | +| `FieldSelectionConflictError` | none | `only` is passed with `include` or `exclude` that is not `None` | + +## State transitions of a field on a node from `only` + +```text +not loaded --(value assigned / relationship assigned / peers assigned)--> loaded +not loaded --(RelationshipManager.fetch())--> loaded (cardinality many only) +loaded --(nothing makes a field not loaded again) +``` + +A new node (no `id`) has every field loaded. After saving, the node has an `id`. From then on, fields whose record is false report `is_loaded == False`. Reading them raises only if the node has the marker; a new node never has it. + +## Store + +The store is unchanged. Nodes from `only` and peers built for them (research R9) go into it as today when `populate_store` is on. The most recent copy wins lookups by `id` (`store.py:47-60`). diff --git a/dev/specs/infp-532-only-field-selection/opsmill-implement-report.md b/dev/specs/infp-532-only-field-selection/opsmill-implement-report.md new file mode 100644 index 000000000..60e2bce85 --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/opsmill-implement-report.md @@ -0,0 +1,153 @@ +# Implementation Report: Fetch Only the Named Fields with `only` + +**Status**: ✅ DONE + +| Item | Value | +|---|---| +| Spec directory | `dev/specs/infp-532-only-field-selection` | +| Branch | `ds/infp-532-only-field-selection` | +| Base commit (implementation start) | `89ccb4a5` | +| Head commit (code) | `0be31de9` | +| Wall-clock | 2026-10-14 21:14 UTC → 2026-10-15 00:51 UTC (about 3 h 40 min) | +| Tasks | 34 of 34 `[X]` | +| Final unit suite | `tests/unit/`: 2830 passed, 2 skipped, 1 xfailed, 8 failed (environmental, see §6) | +| Final integration run | `tests/integration/test_node_only.py`: 14 passed against Infrahub 1.11.5 | + +## 1. Summary + +The SDK accepts `only=[...]` on `get`, `all` and `filters` (async and sync) and on `fetch` of `RelatedNode`, `RelatedNodeSync`, `RelationshipManager` and `RelationshipManagerSync`. + +- A query with `only` requests `id`, `display_label`, `__typename` and the named fields, and no other field of the kind. +- `hfid` in `only` is replaced by the fields of the kind's human-friendly ID. For a generic kind, each implementing kind's own HFID applies. Changed on 2026-10-16: `hfid` now requests the HFID that the server stores (§10). +- Reading a field that a node from `only` (or a peer returned with it) did not fetch raises `FieldNotLoadedError`. +- Every attribute and relationship has `is_loaded`. +- `FieldNotFoundError` and `FieldSelectionConflictError` are raised before the data query. +- Saving, upserting, the HFID and the store do not raise on nodes from `only`. +- Queries without `only` are unchanged: no existing query-generation test was edited, and nodes not from `only` never raise the new error. + +## 2. Chunk ledger + +| # | Chunk | Tasks | ✅ / ⚠️ / ❌ | Commits | Flagged upward | +|---|---|---|---|---|---| +| 1 | Phase 1 Setup (T001) | 1 | 1 / 0 / 0 | none (run by the orchestrator) | The `infrahub_testcontainers` pytest plugin crashes at session start on this Mac; every run disables it. | +| 2a | Phase 2: errors and helpers (T002, T003, T005, T006) | 4 | 4 / 0 / 0 | `a5f7fefc`, `dc7075df` | A generic kind accepts hierarchy names when it is hierarchical or any implementing kind is. Wrote the peer hint of `FieldNotLoadedError` (later reworded in review). | +| 2b | Phase 2: record and `is_loaded` (T004, T007–T011) | 6 | 6 / 0 / 0 | `f8d589ac` | A manager's record is `initialized or peers assigned`. `peers` became a property over `_peers`; no `__len__`/`__bool__` added. | +| 3a | US1: query generation (T012, T013, T017, T018) | 4 | 4 / 0 / 0 | `e5c3efa5`, `699a2ac2`, `5d44ee59` | The first version let `prefetch_relationships` expand a relationship added only for the HFID (my briefing was ambiguous). Fixed in `5d44ee59` with schema-growth tests that fail without the fix. | +| 3b-i | US1: client wiring, peers, HFID (T014, T020, T021, T022) | 4 | 4 / 0 / 0 | `0b36b35e` | The assertion "reading an unfetched field from the store raises" was moved to 3b-ii, because it needs the raising accessors. | +| 3b-ii | US1: raising reads, safe saves (T015, T016, T019, T023) | 4 | 4 / 0 / 0 | `e5fae4ef` | No internal SDK read needed changing: save, upsert, HFID and store only read loaded fields or private storage. | +| 4 | US2: `fetch(only=...)` (T024–T026) | 3 | 3 / 0 / 0 | `4b26e7e7`, `e31263b9` | Seven deliberate breaks of the code were each caught by a test. | +| 5a | Polish: integration tests (T027) | 1 | 1 / 0 / 0 | `a4adf56c` | Each test class boots its own stack (≈3.5 min). | +| 5b | Polish: docs, changelog, checks (T028–T034) | 7 | 7 / 0 / 0 | `ef2b1617`, `71455293`, `3f24468d` | `lint-docs` exits 1 on the base commit as well (vale findings in generated pages, see §6). | +| R-A | Review fixes, code and docs | — | — | `be220357`, `e8cb0ed6` | See §5. | +| R-B | Review fixes, tests | — | — | `5cb84017`, `05092de8`, `e3ccf9ce`, `d14e41f8` | Found that the HFID of nodes outside `only` changed in one edge case; fixed in `0be31de9`. | +| R-C | Orchestrator fix | — | — | `0be31de9` | Nodes outside `only` compute their HFID with the original public lookup again. | + +Chunk 3 of `tasks.md` (User Story 1, 12 tasks) was split into three chunks (3a, 3b-i, 3b-ii) along the seams "query", "nodes and peers come back right", "reads raise and SDK operations do not". The other phases ran as written. + +## 3. Tasks not completed + +None. All 34 tasks are `[X]`. + +## 4. Local-pass evidence + +Unit tests ran with `cd && env -u INFRAHUB_API_TOKEN -u INFRAHUB_ADDRESS uv run pytest -rA -q -p no:cacheprovider -p no:pytest-infrahub-performance-test`. Integration tests ran with the same flags plus `DOCKER_HOST` set to the Docker Desktop socket in the user's home directory and `--no-cov`. The rows below are from the final runs on the head commit `0be31de9`. Each row is one test function; the count is the number of `PASSED` lines it produced (both clients, all cases). + +| Test id | Type | Run command | Passed at (ISO 8601) | Environment context | Verbatim pass line | +|---|---|---|---|---|---| +| `tests/unit/sdk/test_node_only_helpers.py` (10 functions: conflict check, field names, name check accept/reject, peer check accept/reject, HFID expansion, marker errors) | unit | unit command on the six files below | 2026-10-15T00:48:05Z | n/a | `PASSED tests/unit/sdk/test_node_only_helpers.py::test_name_check_rejects[...]` (48 PASSED lines in the file) | +| `tests/unit/sdk/test_node_only_query.py::test_only_query` | unit | same | 2026-10-15T00:48:05Z | n/a | 64 × `PASSED tests/unit/sdk/test_node_only_query.py::test_only_query[...]` | +| `tests/unit/sdk/test_node_only_query.py::test_only_query_unchanged_when_schema_grows` | unit | same | 2026-10-15T00:48:05Z | n/a | 8 × `PASSED ...::test_only_query_unchanged_when_schema_grows[...]` | +| `tests/unit/sdk/test_node_only_query.py` (arguments, hierarchy prefetch, unknown name, name order) | unit | same | 2026-10-15T00:48:05Z | n/a | 2 × `PASSED` each | +| `tests/unit/sdk/test_client_only.py` (13 functions: conflict and unknown-name errors before any request, string `only`, request bodies, nodes and peers in store, HFID with and without store, store lookup by HFID, narrower peer, replaced copy, ignored names in include/exclude) | unit | same | 2026-10-15T00:48:05Z | n/a | 68 `PASSED` lines, e.g. `PASSED tests/unit/sdk/test_client_only.py::test_only_request_body[...]` | +| `tests/unit/sdk/test_node_only_access.py::test_read_of_a_field_not_fetched_raises` / `::test_read_of_a_fetched_field_returns_its_value` | unit | same | 2026-10-15T00:48:05Z | n/a | 34 × `PASSED` each | +| `tests/unit/sdk/test_node_only_access.py::test_hfid_of_a_default_node_is_built_from_its_path_values` | unit | same | 2026-10-15T00:48:05Z | n/a | 22 × `PASSED` (fails 2 of 22 without `0be31de9`) | +| `tests/unit/sdk/test_node_only_access.py` (27 other functions: `is_loaded` on default nodes, assignment, hierarchy, peers, file checksum checks, update/create bodies, HFID, store, mutation result, repr, edits of an unloaded relationship) | unit | same | 2026-10-15T00:48:05Z | n/a | 132 `PASSED` lines | +| `tests/unit/sdk/test_relationship_only.py` (12 functions: `RelatedNode.fetch(only=...)`, HFID after fetch, unknown names, manager fetch with mixed kinds, reload before the per-kind check, reload on a node from `only`, `ancestors`/`descendants`, markers on peers, behaviour without `only`) | unit | same | 2026-10-15T00:48:05Z | n/a | 40 `PASSED` lines | +| `tests/unit/sdk/test_exceptions.py::TestFieldSelectionErrors` (5 functions incl. serialisation) | unit | same | 2026-10-15T00:48:05Z | n/a | 34 `PASSED` lines | +| Six files above, total | unit | `... pytest tests/unit/sdk/test_node_only_helpers.py tests/unit/sdk/test_node_only_query.py tests/unit/sdk/test_node_only_access.py tests/unit/sdk/test_client_only.py tests/unit/sdk/test_relationship_only.py "tests/unit/sdk/test_exceptions.py::TestFieldSelectionErrors" -rA -q ...` | 2026-10-15T00:48:05Z | n/a | `492 passed in 4.67s` | +| `tests/integration/test_node_only.py::TestNodeOnly` (7 tests) | integration | `DOCKER_HOST=... uv run pytest tests/integration/test_node_only.py -rA ... --no-cov` | 2026-10-15T00:50:40Z | image `registry.opsmill.io/opsmill/infrahub:1.11.5` (default from `infrahub-testcontainers` 1.11.5), testcontainers stacks removed by the harness, no workaround | `PASSED tests/integration/test_node_only.py::TestNodeOnly::test_save_of_node_from_only_keeps_its_other_fields` (and the 6 others) | +| `tests/integration/test_node_only.py::TestNodeOnlySync` (7 tests) | integration | same | 2026-10-15T00:50:40Z | same | `PASSED tests/integration/test_node_only.py::TestNodeOnlySync::test_manager_fetch_with_only_reads_the_named_fields_of_each_peer_kind` (and the 6 others); `14 passed, 16 warnings in 170.95s` | + +Whole unit suite on `0be31de9`: `8 failed, 2830 passed, 2 skipped, 1 xfailed`. The 8 failures are in `tests/unit/pytest_plugin/test_plugin.py`; the same 8 fail on the base commit (§6). + +## 5. Review findings + +Six reviewers ran in parallel on `89ccb4a5..3f24468d`: code, tests, comments and docs, error handling, type design, and simplification (report only). No critical findings. + +| Severity | File | Summary | Disposition | +|---|---|---|---| +| High | `docs/.../guides/query_data.mdx` | The example schema gave `TestInterface` an HFID through `device`, which was optional; the server only accepts HFID paths through a mandatory cardinality-one relationship. | ✅ `device` made mandatory in the example (`e8cb0ed6`) | +| High | `tests/unit/sdk/test_relationship_only.py` | The "reload first" case never reloaded, so the required check order had no test. | ✅ New test; fails if the reload is skipped or the per-kind check moves before it (`05092de8`) | +| High | `query_data.mdx` | "Hierarchy fields are requested only when named" was false when the HFID goes through `parent`. | ✅ Caveat added (`e8cb0ed6`) | +| High | `related_node.py`, `relationship.py` | `fetch` docstrings said they fetch the "full" peer. | ✅ Reworded (`be220357`) | +| Medium | `client.py`, `node.py` | The field plan was computed twice per call (and names checked twice); `only` and `only_fields` passed separately; marker code duplicated in both clients. | ✅ Planned once and passed in; `only` carried in `OnlyQueryFields`; one `_mark_from_only` method (`be220357`) | +| Medium | `exceptions/base.py` | The peer error advised `fetch(only=[field])`, which would replace the peer with a narrower copy. | ✅ Hint reworded (`be220357`) | +| Medium | `exceptions/base.py` | The three new errors could not be pickled or copied. | ✅ `__reduce__` added, round-trip test (`be220357`) | +| Medium | `query_data.mdx`, `store.mdx` | "One query per peer kind" (it is a count query plus pages); store sentence incomplete. | ✅ Reworded (`e8cb0ed6`) | +| Medium | tests | Shared helpers imported from other test modules; fixture defined three times. | ✅ Moved to `tests/helpers/only_data.py` and `tests/unit/sdk/conftest.py`; test ids unchanged (`5cb84017`) | +| Medium | tests | Missing: `add`/`extend`/`remove` on a node from `only`; ignored unknown names in `include`/`exclude`; HFID path forms on default nodes; `RelatedNode.fetch` with an unknown name; manager fetch and prefetch against a real server. | ✅ Added (`05092de8`) | +| Medium | `node.py` (found while fixing) | The HFID of nodes outside `only` used the new reader; one edge case (a path without `__value` on an attribute missing from the data) gave `None` instead of the old value. | ✅ Original lookup restored for those nodes, with a test (`0be31de9`) | +| Medium | `relationship.py` | Managers keep two references to their node (`node` and `_node`). | Deferred: merging would change `is_loaded` for managers built on their own | +| Low | several | `only` given as a string; `getattr` default; class docstrings; `get` docstring; guide wording; weak `hfid_str` test cases; low-value helper tests; `Raises:` for the new `TypeError`. | ✅ Fixed (`be220357`, `e8cb0ed6`, `05092de8`, `e3ccf9ce`) | +| Low | `_only.py`, field classes | `KindFields` invariants only documented; mappings are plain dicts in frozen dataclasses; `_raise_if_not_loaded` repeated in three classes; `Any` annotations; a `getattr` marker read; peers from `fetch(only=...)` carry no `peer_of`; small repeated checks. | Deferred (maintainability only, no behaviour impact) | + +## 6. Autonomous decisions + +- **Setup run by the orchestrator.** T001 changes no code, so I ran it myself instead of dispatching a chunk. +- **Local test environment.** Every run disables the `pytest-infrahub-performance-test` plugin (its psutil `cpu_freq` call crashes at session start on this Mac with Python 3.14). Because of the same plugin, the 8 tests in `tests/unit/pytest_plugin/test_plugin.py`, which start inner pytest sessions, fail locally on the base commit and on every commit of this branch. CI runs on Linux and is not affected. +- **Chunk split.** User Story 1 was split into three chunks (see §2), and one assertion of T014 was moved to the next chunk. +- **Prefetch and HFID-only relationships.** My first briefing for chunk 3a was ambiguous and the chunk let `prefetch_relationships` expand a relationship added only for the HFID. I sent the same agent back to fix it against FR-004, FR-005 and SC-002 (`5d44ee59`). +- **Review triage.** I fixed every high finding, 12 of 13 medium findings and the low findings that were cheap and affected accuracy or tests. I deferred the rest (§5). +- **HFID of nodes outside `only`.** The tests pass with either behaviour, but constitution II says nothing changes for nodes outside `only`, so I restored the original lookup for them (`0be31de9`). +- **Guide example schema.** Making `TestInterface.device` mandatory changes an existing example, which is needed for the HFID example to be valid on a real server. +- **`lint-docs`.** It exits 1 on the base commit too: vale spelling findings in generated reference pages (`typename`, `accessor`, `upserted`, …) on lines this branch did not write. `docs-validate`, `rumdl`, ruff, mypy and ty pass. +- **Pushes.** The branch was pushed after the foundational phase (`f8d589ac`) and after User Story 1 (`e5fae4ef`), as agreed. The rest is not pushed yet. + +## 7. Suggested next steps + +1. Push the branch and open the pull request against `infrahub-develop`. +2. Decide whether to address the deferred findings in §5 in this pull request or later. +3. Follow-ups from the spec, each in its own pull request: + - the `infrahubctl object update --set` issue on cardinality-many relationships; + - the existing issues listed in plan.md "Design Notes" (the default reload of a manager stores a partial parent and fails on `ancestors`/`descendants`; reference-only peers stored when `include` names a relationship; the fragment loop reading the default branch's schemas). +4. Run `speckit-opsmill-extract` after the pull request is reviewed. + +## 8. Changes after the pull-request review + +A review of [opsmill/infrahub-sdk-python#1423](https://github.com/opsmill/infrahub-sdk-python/pull/1423) led to commit [`909a2936`](https://github.com/opsmill/infrahub-sdk-python/commit/909a293673cdb487da2e78e4e8dd1672ff9aee28). It changes the following behaviour and code: + +- **HFID through `parent`.** A hierarchy field that the kind also declares as a relationship now uses the declared peer kind for its inline fragment and its peer fields. Before, the query used the hierarchy generic and left out any HFID attribute that only the parent kind has, so `node.hfid` was `None`. A peer attribute that the peer kind does not have now raises `FieldNotFoundError` instead of being left out of the query. With `prefetch_relationships`, the peers of `parent` and `children` get the default fields of the declared peer kind. +- **One HFID reader.** Every node calculates its HFID with the private reader. It replaces the split restored in `0be31de9` (§5, §6), and gives the same results as before for nodes outside `only`. It also stops a node from a default query from raising `FieldNotLoadedError` when its peer in the store came from `only`. +- **Managers keep one reference to their node.** The finding deferred in §5 is fixed: managers read `node`. The test that built a manager with a node and expected `is_loaded` to be `True` now builds it without a node. +- **Smaller changes.** The default query wraps hierarchy fields with the same helper as the query with `only`. `fetch()` passes `only` to the client only when it is set. `all()` no longer repeats the check that `filters()` runs. + +The review also found that every field holds a reference to its node, which adds reference cycles (critique E12). No change was made: nodes from the default schemas were already in a cycle through their relationship managers before this branch. + +Unit suite after the commit, rebased on `521e7459`: 2858 passed, 2 skipped, 1 xfailed, 8 failed. The 8 failures are the tests in `tests/unit/pytest_plugin/test_plugin.py` described in §6. The integration tests were not run again. + +## 9. Later changes to code, docs and changelog + +- [`74cf1d34`](https://github.com/opsmill/infrahub-sdk-python/commit/74cf1d34) states in the docstrings of `expand_hfid` and `get_human_friendly_id` that the SDK calculates `node.hfid` from fields and never reads the server's `hfid`, which is why `hfid` in `only` fetches those fields. A new test checks this. The description of `only` on `get`, `all` and `filters` is shortened to three lines; the details are in the query guide. +- [`6ca76bca`](https://github.com/opsmill/infrahub-sdk-python/commit/6ca76bca) shortens the changelog fragment to one sentence, created with `towncrier create`. +- [`0ca1a463`](https://github.com/opsmill/infrahub-sdk-python/commit/0ca1a4636573864e8ff705fa005db6a02159ce52) builds the arguments of the reload in `RelationshipManager.fetch` and of the two query builders in `_build_filters_query` once, then calls once. The requests are unchanged, so the contract (§2 reload rows) still holds. + +Recorded as a follow-up in a [review comment](https://github.com/opsmill/infrahub-sdk-python/pull/1423#discussion_r4281924788): build the query for `only` with the default builder instead of its own builder. A local probe built the query both ways for every case in `tests/unit/sdk/test_node_only_query.py`, and 20 of 32 matched. The rest differ because of the HFID expansion and three gaps in the default builder, which the comment lists. Before that refactoring, the default builder needs a private way to request a relationship without fetching its peer's fields; no public parameter is to be added. + +Unit suite on `0ca1a463`: 2862 passed, 2 skipped, 1 xfailed, 8 failed (the same 8 as in §6). + +## 10. HFID from the server + +On 2026-10-16 the owner decided that `hfid` in `only` requests the HFID that the server stores, instead of the fields that make it up ([review thread](https://github.com/opsmill/infrahub-sdk-python/pull/1423#discussion_r4294381206); research R5 gives the reason and links the server code). Commit [`aa48ae25`](https://github.com/opsmill/infrahub-sdk-python/commit/aa48ae2551848db68c370ece7abc752e6f954fd5) makes the change: + +- `hfid` in `only` adds the server's `hfid` field to the queried node and nothing else. For `client.all(kind="TestingCat", only=["hfid"])`, the query no longer requests `name`, `color` and `owner { node { id hfid display_label __typename name { value } } }`. +- A node from that query returns the server's value from `hfid`, `hfid_str`, `get_human_friendly_id()` and `get_human_friendly_id_as_string()`, or `None` when the value is empty or null, and the store indexes the node by it. Without `hfid` in `only`, the HFID is calculated from the fetched fields, as before. Nodes from default queries, and peers built for a node from `only`, keep calculating theirs. +- Removed because they only served the expansion: + - `expand_hfid` and `HfidFields`; + - the peer attributes and the split between named and HFID-only relationships in `KindFields`, and `OnlyPeerRelationships.hfid`; + - the HFID peers kept on a node (`_hfid_peers`) and their use in the private HFID reader; + - the `FieldNotFoundError` for an HFID peer attribute that the peer kind does not have. +- Commit [`a4015e84`](https://github.com/opsmill/infrahub-sdk-python/commit/a4015e84f8aca72df36e14909a754b55837f0b70) stops using the stored value once a field the HFID is built from changes on the node; the HFID is then calculated from the fetched fields. +- Kept from `909a2936`: a hierarchy field that the kind also declares as a relationship uses the declared peer kind for its inline fragment and, with `prefetch_relationships`, for its peer fields (`_hierarchy_peer_kind`). Only the HFID part of that commit was removed. +- Queries without `only` are unchanged. No test in `tests/unit/sdk/test_node.py` or `tests/unit/sdk/test_hierarchical_nodes.py` was edited. A local check rendered the default queries of four kinds of the animal schema, with and without `prefetch_relationships`, before and after the commit, and the text was the same. + +Unit suite on `a4015e84`: 2907 passed, 2 skipped, 1 xfailed, 8 failed (the same 8 failures as in §6). `tests/integration/test_node_only.py`: 14 passed against Infrahub 1.11.5. diff --git a/dev/specs/infp-532-only-field-selection/plan.md b/dev/specs/infp-532-only-field-selection/plan.md new file mode 100644 index 000000000..db5d5190c --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/plan.md @@ -0,0 +1,165 @@ +# Implementation Plan: Fetch Only the Named Fields with `only` + +**Branch**: `ds/infp-532-only-field-selection` | **Date**: 2026-10-14 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/infp-532-only-field-selection/spec.md` + +## Summary + +Developers get an `only=[...]` parameter on `get()`, `all()` and `filters()` on both clients, and on `fetch()` on both relationship classes. A query with `only` requests `id`, `display_label` and `__typename` plus the named fields, and nothing else, so it stays the same when the schema grows. Nodes from such a query raise `FieldNotLoadedError` when the code reads a field they do not carry. Every attribute and relationship gets an `is_loaded` property, and three new error types are added. Queries without `only` do not change. + +Technical approach (details and alternatives in [research.md](./research.md)): + +1. **Signatures** (R1): `only: list[str] | None = None` is appended as the last named parameter of `get`, `all` and `filters`, in every overload and implementation, on both clients. The public `generate_query_data*` methods do not change. +2. **Checks before any request** (R2, R3): the conflict check is the first statement of `get`, `all` and `filters`. `filters` checks the names against the kind's schema after resolving it and before generating the data query. For generic kinds the check includes every implementing kind's schema, read from the same branch's cache. +3. **Query generation** (R4, R5, R14): new private methods on both node classes build the query for `only`, and `filters` calls them when `only` is not `None`. The existing generators are not touched. + - The node requests `id`, `display_label` and `__typename`, and the server's `hfid` only when `only` names it. + - Named attributes are requested, and named relationships as references. + - With `prefetch_relationships`, the peers of named relationships get their default fields. + - Hierarchy fields are requested only when named, and then follow the same rules as other named relationships. + - `hfid` requests the HFID that the server stores, when the kind, or for a generic kind the generic or an implementing kind, has an HFID. It adds no part of the HFID. + - For generic kinds, names not on the generic go in `...on Kind` blocks under the existing alias form, with or without `fragment=True`. +4. **Recording what the response contained** (R6): every field object records whether its node's data contained it, and holds a private reference to its node. `is_loaded` combines that record with "the field was assigned" and "the node has no `id`". +5. **Raising** (R7): nodes built from an `only` response, and the peers built with them, carry a private marker holding the `only` list. The accessors listed in the [contract](./contracts/public-api.md) raise `FieldNotLoadedError` when the field is not loaded and the node has the marker. +6. **SDK reads that must not raise** (R8): saving, upserting, HFID calculation and storing read private storage at a short list of places. `RelationshipManager.peers` becomes a property backed by `_peers`. A node from `only` whose response contained `hfid` keeps that value privately and returns it as its HFID (R5). +7. **Peer nodes** (R9): under `only`, peer nodes are built from the response with aliases removed, and only for named relationships with `prefetch_relationships` (for hierarchy fields, the same peers today's prefetch builds: `parent` and `children`). They go to the store. +8. **`fetch(only=...)`** (R10): `RelatedNode.fetch` passes `only` to `client.get`. When the cached peer is from `only` and has no HFID, `RelatedNode.hfid` keeps the reference's server value. `RelationshipManager.fetch` runs its checks in this order: + - checks the names against the declared peer kind before any request; + - reloads a relationship that is not loaded (on nodes from `only`, with `get(..., only=[name], populate_store=False)`); + - checks every actual peer kind before adding any query to the batch. +9. **Errors and typing** (R11, R12): `FieldNotLoadedError`, `FieldNotFoundError` and `FieldSelectionConflictError` in `exceptions/base.py`, registered where the public-name tests expect them. `is_loaded` is added to the `Attribute` and `RelatedNodeBase` protocols. +10. **Docs** (R16): a section on `only` in the query guide, regenerated reference pages and a changelog fragment. + +## Technical Context + +**Language/Version**: Python 3.10–3.13 + +**Primary Dependencies**: pydantic >=2.0, httpx, graphql-core (no new dependency) + +**Storage**: N/A. The in-memory client store is used as today. + +**Testing**: pytest with async auto mode, pytest-httpx (`httpx_mock`), testcontainers for integration tests + +**Target Platform**: Library used by integrations, generators, transforms, `infrahubctl` and the Infrahub Ansible collection + +**Project Type**: Single Python library with an async and a sync client + +**Performance Goals**: A query with `only` is smaller than the default query for the same kind. No extra request is added: the name check uses the schema the client already loads, and the cache serves the schemas of implementing kinds. + +**Constraints**: + +- Queries without `only` are unchanged (SC-003). +- The async and sync clients behave the same (constitution I). +- No new dependency. +- `protocols.py` and `catalogue.py` are generated and are not edited. +- The parameter goes last for positional callers. + +**Scale/Scope**: + +- `infrahub_sdk/client.py`: 3 methods × 2 clients, 20 overloads. +- `infrahub_sdk/node/`: `node.py`, `attribute.py`, `related_node.py`, `relationship.py`, and one new private helper module. +- `infrahub_sdk/exceptions/`: 3 classes. +- `infrahub_sdk/protocols_base.py`: 2 protocols. +- Tests, query guide, generated reference and changelog. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +Checked against `dev/constitution.md` (version 1.0.0). + +| Principle | Status | How the plan meets it | +|---|---|---| +| I. Async/sync parity | Pass | Every new parameter, property and error is on both clients and both relationship classes. The node query generators exist in both node classes. Shared decisions (name check, names to request, whether to request `hfid`) go in one private helper module used by both. Tests are parametrised over `client_type`. | +| II. Backward compatibility | Pass | Additive: `only` goes last and defaults to `None`. Existing queries are pinned by the unedited query-generation tests (SC-003). For nodes not from `only`, nothing raises. `is_loaded` is new. The public-API change was approved in the product review (spec, Governance Gates Crossed). | +| III. Layered architecture | Pass | All logic is in the SDK. `infrahub_sdk/ctl/` is not changed. | +| IV. Type safety and typed errors | Pass | Three new subclasses of `Error`, each with one base class as the SDK's tests require. All signatures are typed. `is_loaded` is added to the protocols in `protocols_base.py`. | +| V. Test-first | Pass | Tests are written before the code they cover (see tasks), on both clients, with concrete expected values and `match=` on every `pytest.raises`. Integration tests run against a real server. | +| VI. Format and lint | Pass | `uv run invoke format lint-code` and `uv run invoke lint-docs` are tasks before each commit. | +| VII. Documentation accuracy | Pass | The query guide and the generated reference are updated in the same pull request. The `only` section describes `only` only. | + +**Result**: Pass, with no exceptions to justify. Rechecked after design: unchanged. + +## Project Structure + +### Documentation (this feature) + +```text +specs/infp-532-only-field-selection/ +├── plan.md # This file +├── research.md # Phase 0: design decisions R1–R17 +├── data-model.md # Phase 1: parameter, records, marker, errors +├── quickstart.md # Phase 1: how to check the feature works +├── contracts/ +│ └── public-api.md # Phase 1: signatures, reads that raise, errors +├── checklists/ +│ └── requirements.md # From /speckit-specify +└── tasks.md # Phase 2 (/speckit-tasks) +``` + +### Source Code (repository root) + +```text +infrahub_sdk/ +├── client.py # EDIT: only on get/all/filters (all overloads, both clients); conflict check; +│ # name check in filters; marker on nodes and peers in +│ # _process_nodes_and_relationships; peer processing limited under only +├── node/ +│ ├── _only.py # ADD (private helpers): names to request for a kind, whether to request hfid, +│ │ # name check for a kind and its implementing kinds +│ ├── node.py # EDIT: private only-query methods (both node classes), reusing the +│ │ # existing peer and hierarchy helpers; server hfid kept on nodes from only; +│ │ # field record and owner binding in _init_attributes / _init_relationships; +│ │ # non-raising HFID path; marker attribute; _process_relationships name filter +│ ├── attribute.py # EDIT: is_loaded; record flag; raising value getter; internal reads use _value +│ ├── related_node.py # EDIT: is_loaded; raising accessors; initialized reads private state; +│ │ # fetch(only=...) on both classes +│ └── relationship.py # EDIT: is_loaded; peers property backed by _peers; raising accessors; +│ # fetch(only=...) with a check of every peer kind first (both classes) +├── exceptions/ +│ ├── base.py # EDIT: FieldNotLoadedError, FieldNotFoundError, FieldSelectionConflictError +│ └── __init__.py # EDIT: export the three errors +└── protocols_base.py # EDIT: is_loaded on the Attribute and RelatedNodeBase protocols + +tests/ +├── fixtures/error_catalogue/public_names.json # EDIT: the three new names +├── unit/sdk/ +│ ├── test_node_only_helpers.py # ADD: name check, conflict check, when hfid is requested +│ ├── test_node_only_query.py # ADD: query generation under only (both clients) +│ ├── test_node_only_access.py # ADD: reads that raise, is_loaded, operations that must not raise +│ ├── test_client_only.py # ADD: get/all/filters checks and nodes from only (httpx_mock) +│ ├── test_relationship_only.py # ADD: fetch(only=...) on both relationship classes +│ └── test_exceptions.py # EDIT: messages of the three errors +└── integration/ + └── test_node_only.py # ADD: animal schema, both clients + +docs/docs/python-sdk/ +├── guides/query_data.mdx # EDIT: section on only, is_loaded, fetch(only=...) +└── sdk_ref/infrahub_sdk/ # REGENERATED: client.mdx, node/*.mdx + +changelog/+only-field-selection.added.md # ADD +``` + +**Not changed**: `infrahub_sdk/ctl/`, `infrahub_sdk/transfer/`, `infrahub_sdk/query_groups.py`, `infrahub_sdk/protocols.py`, `infrahub_sdk/exceptions/catalogue.py`, and every existing query-generation test. + +**Structure Decision**: the existing single-library layout. The new private module `infrahub_sdk/node/_only.py` holds the decisions both node classes and both clients share, so the async and sync code cannot drift (constitution I). It has no public names and is not re-exported. + +## Design Notes + +- **Two paths in query generation**: the query for `only` is built by new private methods, and `filters` picks them when `only` is not `None`. The public generators and their bodies stay as they are. That is the simplest way to keep SC-003, because the default path is not touched. +- **No I/O in `_only.py`**: the helpers take schemas that the caller has already looked up and return plain data. The async and sync code look the schemas up with their own client and then call the same helpers. +- **Owner binding**: field objects get a private `_bind(node, present)` call after construction in `_init_attributes` and `_init_relationships`, so the public constructors keep their signatures. +- **Marker and message**: the marker is a frozen dataclass with `only` (tuple) and `peer_of` (`"Kind.relationship"` or `None`). `FieldNotLoadedError` is built from the marker, the node's kind and the field name. +- **Order in `filters`**: conflict check → schema lookup (existing) → name check → query generation (existing call, plus `only`) → requests → node building with marker → store. In `get`, the conflict check comes before its own schema lookup. +- **`hfid` in `only`**: the query requests the HFID the server stores, on the queried node only. The node keeps the value in a private attribute, set together with its marker, and `node.hfid` returns it (R5). So `node.hfid` works with `populate_store=False`, the node is indexed by HFID when it is stored, and later writes to the store do not change it. Nodes not from `only` never get the attribute, so their HFID is calculated as before. +- **Existing issues found and left alone**: + - `RelationshipManager.fetch()` on a manager that is not loaded stores the reloaded, partial parent. + - `_process_relationships` stores reference-only peers when `include` names any relationship. + - The fragment loop reads implementing kinds' schemas from the default branch. + - `ancestors.fetch()` and `descendants.fetch()` raise `ValueError` on nodes not from `only` when those fields are not loaded (`relationship.py:245-246`). + + Fixing them would change behaviour outside `only`. They are listed for a follow-up. + +## Complexity Tracking + +No constitution violations to justify. diff --git a/dev/specs/infp-532-only-field-selection/quickstart.md b/dev/specs/infp-532-only-field-selection/quickstart.md new file mode 100644 index 000000000..0a18eee4e --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/quickstart.md @@ -0,0 +1,70 @@ +# Quickstart: Validating `only` + +**Feature**: INFP-532 | **Spec**: [spec.md](./spec.md) | **Contract**: [contracts/public-api.md](./contracts/public-api.md) + +This guide lists the checks that show the feature works. The contract defines the expected behaviour; this guide does not repeat it. + +## Prerequisites + +```bash +uv sync --all-groups --all-extras +``` + +Integration tests need a running Docker daemon (testcontainers). + +## 1. Unit tests + +```bash +uv run pytest tests/unit/sdk/ -q +``` + +Expected: + +- Every existing query-generation test in `tests/unit/sdk/test_node.py` and `tests/unit/sdk/test_hierarchical_nodes.py` passes without being edited (SC-003). +- The new tests for `only` pass on both clients. They cover query generation, the unchanged query after a schema change, the errors raised before any request, the table of reads that raise, the operations that must not raise, and `fetch(only=...)`. +- `tests/unit/sdk/test_client.py::test_validate_method_signature` passes, so the async and sync signatures match. +- `tests/unit/sdk/test_exceptions_public_names.py` and `tests/unit/sdk/test_exceptions.py` pass with the three new errors registered. + +## 2. Integration tests + +```bash +uv run pytest tests/integration/ -q -k only +``` + +Expected: the tests on the animal schema (`infrahub_sdk/testing/schemas/animal.py`) pass on both clients, against a real Infrahub server. They cover a generic kind, and `only=["hfid"]` on an implementing kind with its own HFID, whose value comes from the server. + +## 3. Manual check against a running Infrahub + +With a local Infrahub that has the animal schema loaded: + +```python +from infrahub_sdk import InfrahubClientSync +from infrahub_sdk.exceptions import FieldNotLoadedError + +client = InfrahubClientSync() +dogs = client.all(kind="TestingDog", only=["name", "owner"]) +dog = dogs[0] + +dog.name.value # the dog's name +dog.breed.is_loaded # False +dog.owner.fetch(only=["name"]) +dog.owner.peer.name.value # the owner's name + +try: + dog.breed.value +except FieldNotLoadedError as exc: + print(exc) # names TestingDog.breed and only=['name', 'owner'] +``` + +Set `INFRAHUB_ADDRESS` (and `INFRAHUB_API_TOKEN` if needed) before running the script. The queries the SDK sends can be inspected with the client's request recorder, or in the server's GraphQL logs: they contain only `id`, `display_label`, `__typename` and the named fields. + +## 4. Lint, types and generated docs + +```bash +uv run invoke format lint-code +uv run invoke docs-generate +uv run invoke docs-validate +uv run invoke lint-docs +``` + +Expected: no errors from ruff, mypy or ty, and the regenerated reference pages under `docs/docs/python-sdk/sdk_ref/` are committed. diff --git a/dev/specs/infp-532-only-field-selection/research.md b/dev/specs/infp-532-only-field-selection/research.md new file mode 100644 index 000000000..ff04b45ef --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/research.md @@ -0,0 +1,232 @@ +# Research: Fetch Only the Named Fields with `only` + +**Feature**: INFP-532 | **Date**: 2026-10-14 | **Spec**: [spec.md](./spec.md) + +The requirements are fixed by the idea brief (see the spec's Input line). This document records how the SDK code works today and the design decisions that follow from it. References are to the tree at `13af0d22` (`origin/infrahub-develop`). + +The prototype branch `field-selection-only-infp-532` was read for the problems it ran into. Its design is not adopted where the brief differs; R17 lists the differences. + +## How the SDK builds queries and nodes today + +- **Query methods**: `get` (`infrahub_sdk/client.py:758`), `all` (`:1143`) and `filters` (`:1258`) on `InfrahubClient`, with the same signatures on `InfrahubClientSync` (`:3434`, `:2988`, `:3144`). `get` builds a filter dictionary and calls `filters`; `all` calls `filters` with `query_name=f"All_{kind}"`. Each method has overloads: 6 for `get`, 2 for `all` and 2 for `filters`, per client. +- **Query generation**: `filters` calls `InfrahubNode(...).generate_query_data(...)` (`client.py:1319`, sync `:3205`). In `infrahub_sdk/node/node.py`: + - `generate_query_data_init` (`:654`) requests `id`, `hfid`, `display_label` and `__typename` for the queried node. + - `generate_query_data_node` (`:1453`, sync `:2680`) adds every attribute that is not excluded. It adds every cardinality-one relationship, and every cardinality-many relationship of kind Attribute or Parent or named in `include`. + - Each peer is requested as `id`, `hfid`, `display_label`, `__typename` (`related_node.py:298`, `relationship.py:141`). + - With `prefetch_relationships=True`, or when `include` names the relationship, the peer gets its default fields as well (`node.py:1514-1521`). +- **Hierarchy fields**: `_process_hierarchical_fields` (`node.py:1307`, sync `:2535`) adds `parent`, `children`, `ancestors` and `descendants` when the kind has a hierarchy and the name is in `include` or `prefetch_relationships` is set. +- **Generic kinds**: without `fragment=True`, only the generic's own fields are requested. With `fragment=True`, each kind in `used_by` gets an `...on Kind` block with aliased field names (`node.py:1425-1449`). That loop calls `client.schema.get(kind=child)` without `branch`. +- **Building nodes**: `InfrahubNodeBase.__init__` (`node.py:91`) keeps `id`, `display_label` and `__typename` from the response and discards the node's server `hfid`. + - `_init_attributes` (`:271`) creates an `Attribute` for every schema attribute, from `data.get(name)`. A field the server did not return and a field whose value is null both end up as `value=None`. + - `_init_relationships` (`:901`) creates a `RelatedNode` or `RelationshipManager` with `data=None` for a missing relationship. `RelationshipManager.initialized` is `data is not None`. +- **Peers in the store**: `_process_nodes_and_relationships` (`client.py:826`) builds peer nodes with `_process_relationships` (`node.py:1803`) when `prefetch_relationships` is set or `include` names any relationship. + - In that case it builds a node for the peer of every cardinality-one relationship in the response, even one requested as a reference only. + - `filters` puts the nodes and these peers in the store when `populate_store` is on (`client.py:1404-1411`). +- **HFID**: `get_human_friendly_id` (`node.py:196`) reads each path of the schema's `human_friendly_id` through `get_path_value` (`:141`). For a path through a relationship, such as `location__name__value`, it finds the peer with `RelatedNode.get()`: first the peer cached on the relationship, then the store. + - `_process_relationships` does not set the cached peer, so the peer must be in the store. +- **Saving**: an attribute that was never set and has the value `None` produces an empty payload and is not sent (`attribute.py:157-159`). A relationship that is not initialized is skipped (`node.py:474-484`). Saving therefore already leaves unfetched fields alone. +- **Errors**: `infrahub_sdk/exceptions/` is a package. + - Client-side errors live in `base.py`. + - `catalogue.py` is generated and must not be edited. + - `tests/unit/sdk/test_exceptions.py:105-119` requires every exported error to have exactly one base class. + - `tests/unit/sdk/test_exceptions_public_names.py` requires every exported error to descend from `Error` and to be listed in `tests/fixtures/error_catalogue/public_names.json`. +- **Tests**: query-generation tests in `tests/unit/sdk/test_node.py` compare the whole generated query with `==`, for example `test_query_data_include` (`:1283`) and `test_query_data_exclude` (`:1403`). Both clients are covered with `@pytest.mark.parametrize("client_type", client_types)`. + +## R1. Where `only` goes in the signatures + +- **Decision**: add `only: list[str] | None = None` as the last named parameter of `get`, `all` and `filters` on both clients, in every overload and in the implementation. In `get` and `filters` it goes before `**kwargs`. The public `generate_query_data_init`, `generate_query_data` and `generate_query_data_node` do not change. The query for `only` is built by new private methods on both node classes, which `filters` calls when `only` is not `None`. +- **Rationale**: callers that pass arguments by position keep working (constitution II). `test_validate_method_signature` (`tests/unit/sdk/test_client.py:138`) checks that the async and sync implementations match; `docs-validate` checks the overloads through the generated reference. Keeping the public generators unchanged avoids a second public way to use `only` that would skip the name check, and leaves the default path untouched (SC-003). +- **Alternatives considered**: + - Placing `only` next to `include` and `exclude` reads better, but would shift every later parameter for positional callers. Rejected. + - Adding `only` to the public `generate_query_data*` methods. Rejected (critique E10): the brief does not ask for it, and those methods do not check names. + +## R2. Combining `only` with `include` or `exclude` (FR-009) + +- **Decision**: raise `FieldSelectionConflictError` when `only is not None` and either `include is not None` or `exclude is not None`, including empty lists. The check is the first statement of `get`, `all` and `filters`, before the schema lookup, so no request is sent. +- **Rationale**: FR-009 says "before any request". `get` reads the schema before calling `filters` (`client.py:780-798`), so the check cannot wait until `filters`. An empty list is still a choice the caller made; accepting it would hide a list that was computed down to empty by mistake. +- **Alternatives considered**: treating an empty `include` or `exclude` as not passed. Rejected for that reason. + +## R3. Checking the names in `only` (FR-008) + +- **Decision**: `filters` checks the names after it resolves the kind's schema and before it generates the data query. A name is accepted when it is: + - an attribute or relationship of the kind; + - for a generic kind, an attribute or relationship of the generic or of any kind in `used_by`; + - `parent`, `children`, `ancestors` or `descendants`, when the kind has a hierarchy; + - `id`, `display_label` or `hfid`. +- Every other name raises `FieldNotFoundError`, naming the field and the kind. Implementing kinds' schemas come from the client's schema cache for the same branch (`client.schema.get(kind=..., branch=branch)`), which holds the whole branch after the first lookup. +- **Rationale**: FR-008 asks for the error before the data query is sent. The schema lookup already happens before query generation and is cached, so checking there adds no request in the usual case. `ancestors` and `descendants` are not in `relationship_names`, so they need their own rule. +- **Alternatives considered**: checking inside `generate_query_data`. Rejected: that method is public and is also used without a client request, and the checks for a generic kind need the client's schema cache. + +## R4. The query under `only` (FR-002, FR-003, FR-004, FR-006, FR-007) + +- **Decision**: + - The queried node requests `id`, `display_label` and `__typename`. It requests the server's `hfid` only when `only` names `hfid` (R5). + - An attribute is requested only when named. A relationship of any cardinality and kind is requested only when named, as a reference (`id`, `hfid`, `display_label`, `__typename`). + - With `prefetch_relationships=True`, a named relationship's peer also gets its default fields, as today (`peer_node.generate_query_data_node(property, include_metadata)`). Relationships that are not named are not requested. + - `parent`, `children`, `ancestors` and `descendants` are requested only when named. Without `prefetch_relationships` they are references. With it, they get the same peer fields today's prefetch requests for them: the hierarchy generic's default fields, without the four hierarchy names, inside `...on ` (`_process_hierarchical_fields`, `node.py:1307-1356`). When the kind also declares `parent` or `children` as a relationship, the inline fragment and the peer fields use the declared peer kind instead (added after the pull-request review, see the implementation report §8). Under `only`, `parent` and `children` come from one place only (`_process_hierarchical_fields`), so the relationship loop does not add them a second time. + - `only=[]` requests `id`, `display_label` and `__typename` only. + - `only=None` takes the existing code path unchanged. + - For a generic kind, a name that the generic has is requested on the generic. A name that only some implementing kinds have is requested in an `...on Kind` block for each of those kinds, under the existing alias form `__alias__Kind__name`, whether or not `fragment=True` is set. The existing fragment loop in `generate_query_data` (`node.py:1425-1449`) is not run under `only`, because it would add each implementing kind's default fields. +- **FR-004 and FR-006**: hierarchy fields named in `only` follow FR-003 and FR-004 like any other named relationship. FR-006 says only that they are fetched when named. The plan first read FR-006's "as references" literally; the owner chose this reading on 2026-10-14 (critique P3), because today's prefetch already returns these fields with their default fields. +- **`property=True` and `include_metadata=True`**: they keep working under `only` and apply to the named fields and to the node. Confirmed by the owner on 2026-10-14. They add data about the fields the developer named, not more fields of the kind, and the developer asks for them explicitly. FR-001's "nothing else" is read as "no other fields of the kind". +- **Unchanged under `only`**: filters, `order`, `partial_match`, `offset`, `limit` and `count`. +- **Rationale**: this follows FR-002 to FR-007 directly. Keeping the `only=None` path unchanged is what SC-003 needs, and it is checked by leaving every existing query-generation test unedited. + +## R5. `hfid` in `only` (FR-005) + +- **Decision**: `id` and `display_label` are always requested, so naming them adds nothing. `hfid` requests the server's `hfid` field on the queried node, in the place the default query puts it (after `id`), and nothing else: no field, relationship or peer that the HFID is made of. + - For a generic kind, `hfid` is requested on the generic part of the query when the generic or any kind in `used_by` has an HFID. + - For a kind with no HFID, naming `hfid` adds nothing. +- **Reading it**: a node from a query with `only` keeps the `hfid` that its response contained in a private attribute, set when the node gets its marker (R7). `hfid`, `hfid_str`, `get_human_friendly_id()` and `get_human_friendly_id_as_string()` return that value, or `None` when it is empty or null, and the store indexes the node by it. Once an attribute value or a cardinality-one relationship that the HFID is built from is changed on the node, the stored value no longer matches, so the HFID is calculated from the fetched fields instead. A node from `only` whose response did not contain `hfid` calculates its HFID from the fields it fetched (R8). +- **Nodes that keep calculating their HFID**: nodes not from `only`, and peers built for a node from `only` (R9), never get the private attribute. Their HFID is calculated as before, even though the response contains a server `hfid` for them (in the default query and in every peer reference). +- **Rationale**: since Infrahub 1.5.0, the server stores each node's HFID as an attribute, computed when the node is written (commit [`d30588206c`](https://github.com/opsmill/infrahub/commit/d30588206c), "IFC-1812 Refactor HFID & display_labels"). When a query requests `hfid`, the server adds `human_friendly_id` to the attributes it loads with the node ([`resolver.py:254`](https://github.com/opsmill/infrahub/blob/infrahub-v1.11.7/backend/infrahub/graphql/resolvers/resolver.py#L254) in Infrahub 1.11.7), and `Node.get_hfid()` returns the stored value; it calculates the value only when none is stored ([`core/node/__init__.py:231`](https://github.com/opsmill/infrahub/blob/infrahub-v1.11.7/backend/infrahub/core/node/__init__.py#L231)). Requesting the parts of the HFID instead makes the server follow each relationship that a part goes through, and read the peer's attribute plus the peer's own `hfid` and `display_label`. That costs more than reading the stored value, which works against the purpose of `only`. The owner chose the stored value on 2026-10-16 ([review thread](https://github.com/opsmill/infrahub-sdk-python/pull/1423#discussion_r4294381206)). +- **Alternatives considered**: + - Replacing `hfid` with the fields of the kind's `human_friendly_id`, with one peer attribute for a part that goes through a relationship, and keeping those peers in a private slot on the node so that `node.hfid` did not depend on the store. This was the first design and was implemented. The owner replaced it on 2026-10-16 for the cost described above. + - Reading the server's `hfid` on every node. Rejected: it would change `node.hfid` for nodes not from `only` (SC-003, constitution II). + +## R6. Recording which fields the response contained (FR-011) + +- **Decision**: every field object records, in a private flag, whether the data its node was built from contained the field. This applies to every node, not only to nodes from `only`. + - `Attribute`: `_init_attributes` passes `name in data`. The `value` setter sets the flag. + - `RelatedNode` (including `parent`): `_init_relationships` passes `name in data`. Assigning `node.site = ...` builds a new `RelatedNode`, which is marked as assigned. `{"node": null}` in the response counts as contained. + - `RelationshipManager` (including `children`, `ancestors` and `descendants`): uses the existing `initialized` flag, which `fetch()` sets. The `peers` setter also marks the manager as loaded. +- Each field object gets a private reference to its node, bound after construction so that the public constructors keep their signatures. `is_loaded` returns `True` when the flag is set, when the field has no node, or when the node has no `id`, and `False` otherwise. It never raises. +- **Which objects are bound**: + - every `Attribute`, `RelatedNode` and `RelationshipManager` that `_init_attributes` and `_init_relationships` create; + - for hierarchical kinds, both places that hold `parent` and `children` (the relationship and `_hierarchical_data`); + - the `RelatedNode` that `InfrahubNode.__setattr__` builds when a relationship is assigned, which is marked as assigned. +- The `RelatedNode` objects inside a `RelationshipManager`'s list are not bound, so they are always loaded. +- **Reference to the node**: a plain reference, as `RelationshipManager.node` already is (`relationship.py:198`). A weak reference was considered (critique E12) and rejected: it would make `is_loaded` and the raising depend on whether the developer still holds the node, and the reference cycle already exists for relationship managers. +- **Rationale**: FR-011 asks for `is_loaded` on any node, including after `exclude` and after saving a new node. Checking the node's `id` when `is_loaded` is read covers the "after a new node is saved" edge case with no change to the save code. Field objects are handed out on their own (`attr = node.name`), so the check has to be reachable from the field. +- **Alternatives considered**: + - Recording the flag only for nodes from `only`, as the code map suggested. Rejected: FR-011 applies to every node. + - Keeping a set of field names on the node. Rejected for the reason above. +- **Note**: for nodes not from `only`, `is_loaded` can now be `False` (for example after `exclude`), and nothing else changes for them. + +## R7. Raising `FieldNotLoadedError` (FR-010) + +- **Decision**: a node built by a query with `only` carries a private marker: the `only` list and, for a peer, the relationship it was returned through (for example `InfraDevice.site`). The accessors in the contract's table raise `FieldNotLoadedError` when the field is not loaded and its node carries the marker. On every other node they return what they return today. +- The marker is set by `_process_nodes_and_relationships` on the nodes it builds and on the peers it builds from the same response (R9). Peers fetched with `fetch(only=...)` come from `filters` and get their own marker. +- **Message**: names the kind, the field and the `only` list, for example `InfraDevice.description was not fetched: the node was queried with only=['name']. Add 'description' to only, or check is_loaded before reading it.` For a peer it also names the relationship: `... the node is a peer of InfraDevice.site, queried with only=['site']`. +- **Which reads raise**: the contract lists every accessor. In short: + - attributes raise on `value` only; + - cardinality-one relationships raise on the peer and its identifiers, and on `fetch()`; + - cardinality-many relationships raise on the peer list and everything derived from it. +- **What never raises**: `is_loaded`, `initialized`, `has_update`, `name`, `schema`, and the properties of attributes and relationships (`source`, `owner`, `is_protected` and similar). +- **Rationale**: FR-010 limits raising to nodes from `only` and the peers returned with them. The brief puts the warning for all other nodes out of scope. `RelatedNode.initialized` and `RelationshipManager.initialized` are how the SDK itself decides what to save (`node.py:474-484`, `:576-613`), so they must not raise. + +## R8. The SDK's own reads must not raise (FR-010) + +- **Decision**: the error is raised only by the public accessors and only for a field that is not loaded, so SDK code that reads loaded fields is unaffected. The SDK code that can touch unloaded fields during saving, upserting, HFID calculation and storing reads private storage instead: + - `Attribute._initialize_graphql_payload`, `is_from_pool_attribute` and `is_unresolved_pool_attribute` read `_value` (`attribute.py:151-177`, `:233-248`). `_process_mutation_result` calls `is_from_pool_attribute` on every attribute (`node.py:1607`). + - `RelatedNode.initialized` reads the cached peer, `_id` and `_hfid` (`related_node.py:190`). `_generate_input_data` and `_strip_unmodified` call it on every relationship (`node.py:474`, `:583`). + - `RelationshipManager` methods (`add`, `extend`, `remove`, `fetch`, `_generate_input_data`, `is_from_profile`) read `_peers`. `peers` becomes a property backed by `_peers`. + - `get_human_friendly_id` uses a private version of `get_path_value` that treats a field that is not loaded as missing (`None`). It finds the peer without raising, through the cached peer and the store. A node from `only` whose response contained `hfid` returns that value instead (R5). `store.set` (`store.py:56`), `hfid`, `hfid_str` and `_generate_input_data` all go through `get_human_friendly_id`. +- **Conditions for this list to stay complete** (critique E5): + - `RelatedNode.initialized` keeps using the peer's calculated HFID when a peer is cached, which goes through the non-raising calculation. + - `RelationshipManager.__init__` and every internal write use `_peers`, not `peers` (`relationship.py:58`, `:209`, `:217`). + - The `peers` getter returns the stored list itself, not a copy, because callers change it in place (for example `infrahub_sdk/ctl/object/update.py`). + - No `__len__` or `__bool__` is added to the field classes. The SDK tests field objects for truth (`node.py:166`, `:580`, `:1805`, `:1822`), and a raising `__len__` would break that. +- The public `get_path_value` keeps reading through the public accessors, so on a node from `only` it raises for a field that is not loaded. That is a read by the developer. +- A test matrix covers the operations FR-010 names, on a node from `only` whose fields are all unfetched, on both clients: `save()` for create and update, `create(allow_upsert=True)`, `hfid`, `store.set` and the mutation-result processing. +- **Rationale**: the error cannot also be an `AttributeError` (one base class per error, see the baseline), so `getattr(x, "value", None)` does not catch it. The list of affected reads is short because only unloaded fields raise. +- **Alternatives considered**: a context variable that switches raising off during SDK operations, as in the prototype. Rejected: it hides which code reads what, it needs care with `asyncio` tasks created inside the switched-off region, and the explicit list is short. + +## R9. Building peer nodes under `only` + +- **Decision**: under `only`, `_process_nodes_and_relationships` builds and stores peer nodes only for relationships named in `only`, when `prefetch_relationships=True`. For hierarchy fields this is what today's prefetch builds: peer nodes for `parent` and `children`, which are schema relationships. The peers of `ancestors` and `descendants` stay on the relationship and are not built as nodes, as today. +- Under `only`, the peers are read from the node's data with the `__alias__Kind__` prefixes removed (the node's `_data` after `_strip_alias`, `node.py:616`). Today `_process_relationships` reads the raw response (`node.py:1806`, `:1823`), so the peers of a relationship that only an implementing kind has would never be built (critique E1). +- `_process_relationships` gets a private argument with the relationship names to process. Without `only`, the existing condition and loop are unchanged. +- **Rationale**: today, when `include` names any relationship, every cardinality-one peer in the response is built as a node, even one requested as a reference only, and stored with every attribute `None`. Under `only` this would replace fuller copies in the store with empty ones. It would also break the edge case where `device.site.peer` raises `NodeNotFoundError` when the site is not in the store. + +## R10. `fetch(only=...)` on relationships (FR-012) + +- **Decision**: + - `RelatedNode.fetch(timeout=None, priority=None, only=None)` and `RelatedNodeSync.fetch(...)` pass `only` to `client.get(kind=self.typename, id=self.id, ...)`. `client.get` checks the names against the peer's kind before the data query is sent. + - `fetch()` on a cardinality-one relationship that is not loaded, on a node from `only`, raises `FieldNotLoadedError`, because the peer's `id` and kind are unknown. On other nodes it keeps raising `Error` as today (`related_node.py:367`). + - `RelationshipManager.fetch(only=None)` and `RelationshipManagerSync.fetch(only=None)`, in this order: + 1. When `only` is given, check its names against the relationship's declared peer kind (`self.schema.peer`) with the rule of R3 (for a generic peer kind: the generic or any implementing kind). This runs before any request, so a misspelt name never sends a query (SC-004, critique E3). + 2. When the manager is not loaded, reload the relationship. On a node from `only`, the reload is `client.get(kind=..., id=..., only=[self.schema.name], populate_store=False)`. That works for `ancestors` and `descendants` as well, which the existing `include`/`exclude` reload cannot handle (`relationship.py:245-246` raise `ValueError` because those names are not in `relationship_names`, critique E2). With `populate_store=False`, the reloaded copy of the node does not replace the developer's node in the store. On any other node, the existing reload runs unchanged. + 3. When `only` is given, check its names against every distinct peer kind in the loaded list. Any name missing on any of those kinds raises `FieldNotFoundError` before any peer query is added to the batch. + 4. Add one `filters(kind=..., ids=..., only=only, ...)` per peer kind to the batch, as today. + - Peers fetched this way go to the store and carry the `only` marker (R7). + - **`RelatedNode.hfid` after `fetch(only=...)`**: `fetch` keeps setting the cached peer, as today (`related_node.py:369-371`). When the cached peer is a node from `only` and has no HFID, because its response did not contain `hfid` (or contained an empty value) and the fields it carries do not give one, `RelatedNode.hfid` and `hfid_str` return the HFID the reference carried from the server. So `device.site.hfid` keeps its value after `device.site.fetch(only=["description"])` (critique P4). For a cached peer that is not from `only`, `hfid` is unchanged. Confirmed by the owner on 2026-10-14: this concerns the relationship's `hfid`, not the excluded fallback for `node.hfid`. +- **Rationale**: FR-012 requires every name on every peer kind, checked before the peer queries are sent. The batch sends queries concurrently, so the check has to run before any query is added. `fetch()` on a manager is how a developer loads a cardinality-many relationship, so it must work on a manager that is not loaded, including `ancestors` and `descendants`, which FR-006 leaves unloaded unless named. +- **Alternatives considered**: not setting the cached peer under `only`, so `.peer` reads the store. Rejected: `.peer` would then depend on what the store holds. +- **Not changed**: on nodes not from `only`, the reload keeps using `include` and `exclude`, as today, including its existing failure on `ancestors` and `descendants`. + +## R11. Error types + +- **Decision**: three new classes in `infrahub_sdk/exceptions/base.py`, each with `Error` as its only base class: + - `FieldNotLoadedError(kind, field, only, peer_of=None)`: reading a field that a node from `only` does not carry. + - `FieldNotFoundError(kind, field)`: a name in `only` that is not a field of the kind. + - `FieldSelectionConflictError()`: `only` combined with `include` or `exclude`. +- Each class keeps its arguments as attributes and builds its message the way `FragmentNotFoundError` does (`base.py:408`). Each is added to `base.__all__`, `exceptions/__init__.py` and `tests/fixtures/error_catalogue/public_names.json`. None of the names exists in the generated `catalogue.py`. +- **Rationale**: constitution IV asks for a specific subclass for each new failure. The SDK's tests allow one base class only, so the new errors cannot also subclass `AttributeError` or `ValueError`. Today a name in both `include` and `exclude` raises a plain `ValueError` (`node.py:712-715`). That stays, and the new errors follow constitution IV instead. + +## R12. Typed surface + +- **Decision**: add `is_loaded` to the `Attribute` and `RelatedNodeBase` protocols in `infrahub_sdk/protocols_base.py`. `infrahub_sdk/protocols.py` is generated and is not edited. +- **Rationale**: generated protocols type attributes with the `protocols_base` attribute protocols (`String`, `StringOptional` and so on, all based on `Attribute`), so `node.description.is_loaded` needs `is_loaded` on `Attribute`. They type relationships with `RelationshipAttribute`, whose access on a node returns the concrete `RelatedNode` (`related_node.py:527-553`), and with the concrete `RelationshipManager` (`protocols.py:10-14`). Both classes get `is_loaded` directly. Adding it to `RelatedNodeBase` keeps that protocol in line with the class it describes. + +## R13. File objects + +- **Decision**: `matches_local_checksum`, `download_file(skip_if_unchanged=True)` and `upload_if_changed` read `checksum.value` through the public accessor (`node.py:1109`, `:1145`, and `upload_if_changed`). On a node from `only` that did not fetch `checksum`, they raise `FieldNotLoadedError`. `save()` itself does not read `checksum`. +- **Rationale**: these methods compare a local file with the server checksum. When the checksum was not fetched, they cannot compare, and uploading anyway would hide the missing field and cost a full upload. FR-010 covers `save()`, which does not read the checksum. Confirmed by the owner on 2026-10-14. +- **Alternatives considered**: treating a checksum that was not fetched as "no server checksum", as the prototype did. Rejected for the reason above. + +## R14. Schema lookups for implementing kinds + +- **Decision**: under `only`, the schema of each kind in `used_by` is read with the query's `branch`. The existing fragment loop without `only` keeps its current call (`node.py:1427`). +- **Rationale**: on a branch where an implementing kind gained a field, the check in R3 and the query must agree. Changing the existing loop would change existing queries, which SC-003 forbids. + +## R15. Test strategy + +- **Unit tests** cover both clients through `client_type` parametrisation and use dataclass cases (`.agents/rules/python-testing.md`): + - Query generation under `only`, comparing the whole generated query: attributes, cardinality-one and cardinality-many relationships, `prefetch_relationships`, `hfid` (a kind with an HFID, a generic kind without one whose implementing kind has one, a kind with no HFID, merged with named fields and with `prefetch_relationships`), hierarchy fields, generic kinds, `only=[]`, `id` and `display_label`. Existing fixtures: `schema_with_hfid` (`tests/unit/sdk/conftest.py:229`), `hierarchical_schema` (`tests/unit/sdk/test_hierarchical_nodes.py:19`), `mock_schema_query_02`, `location_schema`, `device_schema`. + - SC-002: the same `only` call before and after adding an attribute and a relationship to the schema gives the same query. + - SC-003: no existing query-generation test is edited. They must pass unchanged. + - SC-004 and FR-009: the errors are raised and `httpx_mock` records no GraphQL request. + - SC-005: the accessor table, on nodes from `only`, on peers returned with them and on peers from `fetch(only=...)`: each listed read raises when not loaded, and `is_loaded` is `False` in exactly those cases. On a default node the same reads return what they return today. + - FR-010: the internal-operations matrix from R8. + - FR-012: `fetch(only=...)` on cardinality-one and cardinality-many relationships, including mixed peer kinds with a name missing on one kind, a misspelt name on a manager that is not loaded (no request sent), and `ancestors.fetch(only=...)` and `descendants.fetch(only=...)` on a node from `only` where they are not loaded. + - Generic kinds: a relationship that only an implementing kind has, named in `only` with `prefetch_relationships=True`, gives a peer node in the store. + - HFID: + - `node.hfid` from `only=["hfid"]` is the server's value, also over fetched fields that would give another value, and `None` for an empty or null value; + - with `populate_store=True` and `populate_store=False`, and no peer is stored for it; + - `client.store.get(key=node.hfid, kind=...)` returns the node from `only`; + - without `hfid` in `only`, `node.hfid` is calculated from the fetched fields; + - a node from a default query keeps the calculated HFID when the response's `hfid` differs; + - `device.site.hfid` is unchanged after `device.site.fetch(only=["description"])`, and after `device.site.fetch(only=["hfid"])` when the server returns a null `hfid`. + - Hierarchy fields named in `only`: references without `prefetch_relationships`; with it, the default fields of the hierarchy generic, or of the peer kind the kind declares for `parent` or `children`, peer nodes built and stored for `parent` and `children` only, and no other hierarchy field added. + - `fragment=True` with `only` adds no default fields of implementing kinds. + - Error classes: messages and registration (the existing public-name tests pick them up). +- **Integration tests**, one per client, on `SchemaAnimal` (`infrahub_sdk/testing/schemas/animal.py`): a generic kind, and `only=["hfid"]` on an implementing kind with its own HFID (`Cat`), whose three parts include one through a relationship (`owner__name__value`) and come from the server. + +## R16. Documentation and changelog + +- **Decision**: + - Add a section on `only` to `docs/docs/python-sdk/guides/query_data.mdx`, after "Excluding attribute and relationships". It covers `only`, `is_loaded`, `FieldNotLoadedError`, `hfid` in `only` and `fetch(only=...)` under "Fetching relationships manually". It makes no claim about what `include` returns for peers. + - Add one sentence to `docs/docs/python-sdk/guides/store.mdx` saying that a node from `only` replaces a fuller copy of the same node in the store, and that `is_loaded` tells which fields it carries. + - Update the `Raises:` sections of the docstrings that change behaviour before running `docs-generate`: `get_path_value`, `get_flat_value`, `matches_local_checksum`, `download_file`, `upload_if_changed`, and both `fetch` methods on both relationship classes. + - Run `uv run invoke docs-generate` and commit the regenerated reference pages. + - Add `changelog/+only-field-selection.added.md`. +- **Rationale**: constitution VII; the brief puts the correction of the `include` section out of scope. + +## R17. Scope compared with the prototype + +The prototype changes 58 files. This plan keeps to the brief. + +| Area | Prototype | This plan | +|---|---|---| +| Reading an unfetched field on any other node | Warning in 1.x, private switch for errors in 2.0 | No change (out of scope) | +| `hfid` in `only` | Adds the server `hfid` to the query | Adds the server `hfid` to the query (FR-005, R5) | +| Names that exist only on implementing kinds | Need `fragment=True` | Accepted without it (FR-008) | +| `fetch(only=...)` on mixed peer kinds | Filters `only` per kind and skips kinds without the names | Raises unless every kind has every name (FR-012) | +| `fetch(exclude=...)` | Added | Out of scope | +| SDK's own queries (`query_groups.py`, `ctl/generator.py`, `ctl/check.py`) | Moved to `only` | Not changed | +| CLI formatters and JSON importer | Skip unfetched fields | Not changed | +| `infrahubctl object update --set` fix | Included | Separate pull request (follow-up) | +| Keeping the SDK's own reads from raising | Context variable | Private reads at the listed places (R8) | + +Files outside `infrahub_sdk/client.py`, `infrahub_sdk/node/`, `infrahub_sdk/exceptions/`, `infrahub_sdk/protocols_base.py`, tests, docs and the changelog are not changed. In particular, `infrahub_sdk/ctl/`, `infrahub_sdk/transfer/` and `infrahub_sdk/query_groups.py` are not changed. diff --git a/dev/specs/infp-532-only-field-selection/spec.md b/dev/specs/infp-532-only-field-selection/spec.md new file mode 100644 index 000000000..48ff5a85d --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/spec.md @@ -0,0 +1,159 @@ +# Feature Specification: Fetch Only the Named Fields with `only` + +**Feature Branch**: `ds/infp-532-only-field-selection` + +**Created**: 2026-10-14 + +**Status**: Draft + +**Input**: Idea brief for [INFP-532](https://opsmill.atlassian.net/browse/INFP-532), written after an internal product review (2026-08-28 to 2026-09-04). The brief is the reference for this feature. The INFP-532 card still describes an earlier proposal (making `include` exclusive) and was not updated. The branch `field-selection-only-infp-532` holds an earlier prototype, used for reference only. + +## Background + +When a developer queries nodes with `get()`, `all()` or `filters()`, the SDK requests a default set of fields: every attribute and most relationships of the kind. Two parameters change that set: + +- `include` adds fields to the default set. +- `exclude` removes fields from the default set. + +A developer cannot ask for only some fields. To fetch less, they list every unwanted field in `exclude`, and that list goes out of date each time someone adds a field to the schema. Queries therefore fetch more than the code reads, and they grow whenever the schema grows. Developers who write integrations, scripts and generators asked for a way to fetch only what they need ([INFP-532](https://opsmill.atlassian.net/browse/INFP-532)). + +The INFP-532 card proposed making `include` exclusive. The product review chose a new parameter, `only`, so that no existing query changes. The same review asked that reading a field that was not fetched raise an error, instead of returning `None` as it does today after `exclude`. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Fetch only the named fields (Priority: P1) + +A developer calls `client.all("InfraDevice", only=["name", "site"])` and gets devices that carry `name` and a reference to their site. The query requests those fields plus the fields the SDK needs to build a node, and it stays the same when the schema grows. When the code reads a field it did not name, the SDK raises an error that says which field was not fetched. The developer can check beforehand whether a field was fetched. + +**Why this priority**: This is the request on INFP-532: queries whose size the developer controls and that do not grow with the schema. + +**Independent Test**: Generate queries with `only` absent, empty and set to several lists against the test schemas, compare each query with the expected one, and read fields on the returned nodes, on both the async and the sync client. + +**Acceptance Scenarios**: + +1. **Given** the kind `InfraDevice`, **When** the developer calls `all(kind="InfraDevice", only=["name"])`, **Then** the query requests `id`, `display_label`, `__typename` and `name` for each device, and no other field. +2. **Given** the same call, **When** an attribute is added to `InfraDevice`, **Then** the generated query is unchanged. +3. **Given** a device returned by that call, **When** the developer reads `device.description.value`, **Then** `FieldNotLoadedError` is raised, naming `InfraDevice.description` and `only=['name']`. +4. **Given** the same device, **When** the developer checks `device.description.is_loaded`, **Then** it is `False` and nothing is raised. +5. **Given** `all(kind="InfraDevice", only=["name", "site"])`, **When** the query is generated, **Then** `site` is requested as a reference to the peer: `id`, `hfid`, `display_label` and `__typename`. +6. **Given** the same call with `prefetch_relationships=True`, **When** the query is generated, **Then** the site is requested with its default fields, and no relationship other than `site` is requested. +7. **Given** a call that does not pass `only`, **When** the query is generated, **Then** it is the same as before this feature. + +--- + +### User Story 2 - Fetch chosen fields of a peer (Priority: P2) + +A developer who fetched a device with `only=["site"]` holds a reference to the site. They call `device.site.fetch(only=["name"])` and read the site's name, without fetching every default field of the site and without writing a second query by hand. + +**Why this priority**: It completes User Story 1 for relationships. Without it, reading one field of a peer needs `prefetch_relationships=True`, which fetches every default field of the peer and grows with the peer's schema. It depends on User Story 1 and is tested on its own. + +**Independent Test**: Call `fetch(only=[...])` on cardinality-one and cardinality-many relationships, including a relationship whose peers are of several kinds, and check the queries sent and which peer fields can be read, on both clients. + +**Acceptance Scenarios**: + +1. **Given** a device fetched with `only=["site"]`, **When** the developer calls `device.site.fetch(only=["name"])`, **Then** one query for the site requests `id`, `display_label`, `__typename` and `name`, and `device.site.peer.name.value` returns the site's name. +2. **Given** the same site, **When** the developer reads `device.site.peer.description.value`, **Then** `FieldNotLoadedError` is raised. +3. **Given** a cardinality-many relationship whose peers are of several kinds, **When** the developer calls `fetch(only=[...])` with a name that one of those kinds does not have, **Then** the unknown-field error is raised before the peer queries are sent. + +--- + +### Edge Cases + +- **Empty list**: `only=[]` fetches `id`, `display_label` and `__typename` only. Not passing `only`, or passing `None`, keeps the default set. +- **Misspelt name**: `only=["nmae"]` raises the unknown-field error before the data query is sent, naming `nmae` and the kind. +- **Fields that are always fetched**: `id` and `display_label` in `only` are accepted and change nothing. +- **`hfid` in `only`**: requests the HFID that the server stores for each node, and none of the fields it is made of. `node.hfid` returns that value until a field the HFID is built from is changed on the node; from then on it is calculated from the fetched fields. For a kind without an HFID, naming `hfid` changes nothing. +- **Client store**: a node from `only` goes into the store and can replace a fuller copy of the same node. Code that later reads it from the store gets `FieldNotLoadedError` for the fields it lacks, and can check `is_loaded` first. The SDK's own HFID calculation does not raise when it reads such a node from the store: for any node, a field that the stored node lacks counts as missing. +- **Saving**: saving a node from `only` sends `id` and the changed fields only. Fields that were not fetched are neither sent nor cleared on the server. +- **HFID that cannot be calculated**: `node.hfid` on a node from `only` is `None` when `hfid` was not named and its HFID fields were not fetched, and when `hfid` was named and the server returned an empty value. +- **Reference without a stored peer**: `device.site.peer` on a reference raises the existing `NodeNotFoundError` when the site is not in the store, as after a default query today. +- **New node after saving**: after a node the developer created is saved, the fields it never set have `is_loaded` `False`. Reading them raises only on nodes from `only`; on other nodes they behave as today. +- **Generic kinds**: when only some kinds that implement a generic have a named field, the field is fetched for the nodes of those kinds. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Choosing fields on the query methods + +- **FR-001**: Users MUST be able to pass `only=[...]` to `get()`, `all()` and `filters()` on `InfrahubClient` and `InfrahubClientSync`. For the queried nodes and their peers, the query MUST request the fields FR-002 to FR-007 define, and no other field. The query's filters, ordering, pagination and `count` stay as they are today, and so does the data that `property=True` and `include_metadata=True` add for the requested fields. +- **FR-002**: For the queried node, a query with `only` MUST fetch `id`, `display_label` and `__typename`, plus the named attributes and relationships. The server's `hfid` field MUST be fetched for the queried node only when `only` names `hfid` (FR-005). +- **FR-003**: A relationship named in `only` MUST return its peers as references (`id`, `hfid`, `display_label`, `__typename`), as a relationship in the default set does today. +- **FR-004**: With `prefetch_relationships=True`, the peers of the relationships named in `only` MUST come back with their default fields. Relationships not named in `only` MUST NOT be queried. +- **FR-005**: In `only`, `id` and `display_label` MUST be accepted and change nothing. `hfid` MUST request the server's stored `hfid` field on the queried node, and no field, relationship or peer that the HFID is made of. On a node from that query, `hfid`, `hfid_str`, `get_human_friendly_id()` and `get_human_friendly_id_as_string()` MUST return the server's value, or `None` when that value is empty or null, and the client store MUST index the node by it. Once a field the HFID is built from is changed on the node, they MUST be calculated from the fetched fields instead, so they never return a value that no longer matches the node. When `hfid` is not named, they MUST be calculated from the fields the node fetched, and be `None` when a part was not fetched. For a generic kind, `hfid` MUST be requested when the generic or any kind implementing it has an HFID. For a kind without an HFID, naming `hfid` MUST change nothing. +- **FR-006**: `parent`, `children`, `ancestors` and `descendants` MUST be fetched only when named in `only`. When named, they follow FR-003 and FR-004 like any other named relationship: references, or their peers' default fields when `prefetch_relationships=True`. +- **FR-007**: `only=[]` MUST fetch `id`, `display_label` and `__typename` only. Not passing `only`, or passing `None`, MUST keep the default set. + +#### Checking the names + +- **FR-008**: A name in `only` that is not a field of the kind MUST raise a new typed error before the data query is sent, naming the field and the kind. For a generic kind, a name is known if the generic or any kind implementing it has the field. +- **FR-009**: Combining `only` with `include` or with `exclude` MUST raise a new typed error before any request is sent. + +#### Reading fields on nodes from `only` + +- **FR-010**: Reading a field that a node from `only`, or a peer returned with it, does not carry MUST raise `FieldNotLoadedError`, naming the kind, the field and the `only` list. Saving, upserting, calculating the HFID and storing such a node MUST NOT raise. +- **FR-011**: Users MUST be able to check `is_loaded` on any attribute, cardinality-one relationship and cardinality-many relationship. It MUST be `True` when the response contained the field, when the user assigned the field since, or when the node has no `id` yet, and `False` otherwise. Reading `is_loaded` MUST never raise. + +#### Choosing fields when fetching peers + +- **FR-012**: Users MUST be able to pass `only=[...]` to `fetch()` on `RelatedNode`, `RelatedNodeSync`, `RelationshipManager` and `RelationshipManagerSync`. Peers MUST come back with only the named fields plus `id`, `display_label` and `__typename`, and reading a field they do not carry MUST raise as FR-010 defines. Every name MUST exist on every peer kind being fetched; otherwise the FR-008 error MUST be raised before the peer queries are sent. + +#### Keeping existing behaviour + +- **FR-013**: `include` and `exclude` MUST keep their current behaviour and MUST NOT be deprecated. Unknown names in `include` and `exclude` MUST stay ignored, as today. + +### Key Entities + +- **Node, attribute, relationship, peer, client store**: existing; unchanged for nodes not fetched with `only`. +- **`only` parameter** *(new)*: the list of field names the developer wants, on the query methods and on `fetch()`. Not passed or `None` means the default set; an empty list means `id`, `display_label` and `__typename` only. +- **Reading a field**: for an attribute, reading its value; for a cardinality-one relationship, reading its peer or the peer's identifiers; for a cardinality-many relationship, reading its list of peers. Accessing the attribute or relationship itself, for example to check `is_loaded`, is not reading it. +- **`is_loaded`** *(new)*: a property on attributes and on both kinds of relationship that says whether the SDK holds the field's value. +- **"The response contained this field" record** *(new, internal)*: kept for each field when a node is built from a server response, and used by `is_loaded`. Not public. +- **Errors** *(new)*: `FieldNotLoadedError`, one error for an unknown name in `only`, and one error for combining `only` with `include` or `exclude`. All three are subclasses of the SDK's base `Error`. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: For every case in the test schemas, a query with `only` requests exactly the fields FR-002 to FR-007 define, on both clients. +- **SC-002**: Adding attributes or relationships to a kind, or to a kind that implements it, leaves the query for an existing `only` call unchanged, unless the added field is one of the names in `only`. +- **SC-003**: Upgrading without changing code leaves every default, `include` and `exclude` query exactly as before. +- **SC-004**: A misspelt name in `only` is reported before the data query is sent, in every case. +- **SC-005**: On nodes from `only`, reading a field they do not carry raises `FieldNotLoadedError` in every case, and `is_loaded` is `False` exactly in those cases. +- **SC-006** *(measured after release, not a release gate)*: on the `infrahub-sync` workload measured for the PRD, a load migrated to `only` transfers fewer bytes than the baseline. No target number is set. + +## Constitution Alignment + +- **I. Async/sync parity**: every new parameter, property and error is available on both clients and tested on both. +- **II. Backward compatibility**: an additive feature. `only` is placed after the existing parameters; SC-003 checks that existing queries do not change. +- **III. Layered architecture**: all logic lives in the SDK; the CLI does not change. +- **IV. Typed errors**: three new subclasses of `Error`. +- **V. Test-first**: tests ship in the same pull request, on both clients. +- **VII. Documentation accuracy**: the query guide documents `only`, `is_loaded` and `fetch(only=...)` in the same pull request. The `only` section describes `only` only and makes no claims about the peers that `include` returns. + +## Governance Gates Crossed + +- **Changing public API signatures**: additive. Validated in the product review; no separate approval is needed (owner). +- **Adding new dependencies**: none. + +## Assumptions + +- Leaving a field out of `only` changes only what the server returns, not what it resolves. Values that come from profiles are unaffected. +- The client store keeps the most recent copy of each node ([opsmill/infrahub-sdk-python#413](https://github.com/opsmill/infrahub-sdk-python/issues/413) is unresolved). This feature does not wait for [opsmill/infrahub-sdk-python#1120](https://github.com/opsmill/infrahub-sdk-python/pull/1120). +- The INFP-532 card is not updated. The idea brief and the product review are the reference for the parameter. + +## Out of Scope + +- Changing or deprecating `include` or `exclude`, and correcting the `include` section of the query guide. +- From the prototype branch: the warning on reads of unfetched fields for all other nodes, and the switch that turns those reads into errors in 2.0. +- From the prototype branch: moving the SDK's own queries to `only` (query groups, `infrahubctl generator`, `infrahubctl check`) and changes to CLI output. +- `fetch(exclude=...)`. +- Path syntax in `only`, such as `site__name`. +- Removing `hfid` and `display_label` from peer references. +- Using the server's `hfid` for `node.hfid` on nodes not from `only`, or on nodes from `only` whose query did not name `hfid`. + +## Follow-ups (outside this feature) + +- Comment on [opsmill/infrahub-sdk-python#1120](https://github.com/opsmill/infrahub-sdk-python/pull/1120) that it can reuse the internal "the response contained this field" record instead of adding a public `is_fetched`. +- `infrahubctl object update --set` on a cardinality-many relationship: the prototype's implementation report says it reports success but sends no peers. Reproduce it and fix it in its own pull request. +- Agree with the prototype's author on closing the branch `field-selection-only-infp-532`. diff --git a/dev/specs/infp-532-only-field-selection/tasks.md b/dev/specs/infp-532-only-field-selection/tasks.md new file mode 100644 index 000000000..44791dc73 --- /dev/null +++ b/dev/specs/infp-532-only-field-selection/tasks.md @@ -0,0 +1,283 @@ +--- +description: "Task list for fetching only the named fields with only (INFP-532)" +--- + +# Tasks: Fetch Only the Named Fields with `only` + +**Input**: Design documents from `specs/infp-532-only-field-selection/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/public-api.md, quickstart.md + +**Tests**: included. Constitution V requires tests in the same change, written before the code they cover, on both clients. SC-001 to SC-005 are checked by tests. + +**Organization**: grouped by user story. User Story 1 (P1) is the first increment that can ship on its own. User Story 2 (P2) depends on it. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: can run in parallel (different files, no dependency on an incomplete task) +- **[Story]**: US1 or US2; setup, foundational and polish tasks have no story label + +## Path Conventions + +Single Python library. Production code is under `infrahub_sdk/`, tests under `tests/`. Paths are relative to the repository root (`specs/` is a link to `dev/specs/`). + +## Rules for every task + +- Every behaviour is tested on the async and the sync client (`@pytest.mark.parametrize("client_type", client_types)` as in `tests/unit/sdk/test_node.py`, or the `BothClients` fixture from `tests/unit/sdk/conftest.py`). +- Test cases are dataclasses with `name` first, passed with `pytest.param(case, id=case.name)`. Every `pytest.raises` has `match=`. Assertions compare concrete values. No `unittest.mock`; use `httpx_mock` and `monkeypatch` only (`.agents/rules/python-testing.md`). +- No reference to the ticket, the spec, FR or SC numbers, or user stories in code, comments, docstrings or test names (`.agents/rules/code-comments.md`). +- Do not edit any existing query-generation test (SC-003). Do not change `infrahub_sdk/ctl/`, `infrahub_sdk/transfer/`, `infrahub_sdk/query_groups.py`, `infrahub_sdk/protocols.py` or `infrahub_sdk/exceptions/catalogue.py` (plan, "Not changed"). +- Run `uv run invoke format lint-code` before each commit. + +--- + +## Phase 1: Setup + +**Purpose**: confirm a clean starting point. + +- [X] T001 Install dependencies with `uv sync --all-groups --all-extras` and run `uv run pytest tests/unit/sdk/ -q` on the unchanged tree. Record the pass count in the commit message of the first code commit, as the baseline for SC-003. + +--- + +## Phase 2: Foundational (blocking prerequisites) + +**Purpose**: the error types, the shared helpers, and the "the response contained this field" record with `is_loaded`. Both user stories need them. + +**⚠️ No user story work starts until this phase is complete.** + +### Tests first + +- [X] T002 [P] Add tests for the three new errors in `tests/unit/sdk/test_exceptions.py`: `FieldNotLoadedError(kind, field, only, peer_of=None)` names the kind, the field and the `only` list, and the relationship when `peer_of` is set; `FieldNotFoundError(kind, field)` names the field and the kind; `FieldSelectionConflictError()` names `only`, `include` and `exclude`. Each keeps its arguments as attributes (contract §5). +- [X] T003 [P] Add tests for the private helpers in `tests/unit/sdk/test_node_only_helpers.py`, with schemas built in the test or taken from `tests/unit/sdk/conftest.py`: + - **conflict check**: raises when `only` is set and `include` or `exclude` is not `None`, including empty lists; passes when either side is `None` (research R2). + - **name check**: accepts attributes, relationships, `id`, `display_label` and `hfid`. It accepts `parent`, `children`, `ancestors` and `descendants` only for a hierarchical kind. For a generic kind, it accepts names of the generic or of any implementing kind. Any other name raises `FieldNotFoundError` naming the field and the kind (research R3). + - **strict peer check**: a name missing on one of several peer kinds raises (research R10). + - **HFID expansion**: `name__value` gives the attribute `name`. `location__name__value` gives the relationship `location` with the peer attribute `name`. A kind with no HFID gives nothing. For a generic kind, each implementing kind uses its own HFID: `Cat` from `infrahub_sdk/testing/schemas/animal.py` (research R5). +- [X] T004 [P] Add `is_loaded` tests on nodes not from `only` in `tests/unit/sdk/test_node_only_access.py` (contract §3, data-model "record"), on both clients: + - attribute, cardinality-one relationship, cardinality-many relationship, `parent`, `children`, `ancestors` and `descendants`, present and absent in the data; + - `{"node": null}` counts as present; + - assignment of an attribute value, of a relationship and of `peers` makes the field loaded; + - `fetch()` on a manager makes it loaded; + - a new node (no `id`) has every field loaded; after a mocked save gives it an `id`, the fields it never set are not loaded; + - `RelatedNode` objects inside a manager are always loaded; + - `is_loaded` never raises; + - reading any field on these nodes returns what it returns today (no `FieldNotLoadedError`). + +### Implementation + +- [X] T005 Add `FieldNotLoadedError`, `FieldNotFoundError` and `FieldSelectionConflictError` to `infrahub_sdk/exceptions/base.py` (each with `Error` as its only base, messages per contract §5, arguments kept as attributes, following `FragmentNotFoundError`). Add them to `base.__all__`, to the import block and `__all__` in `infrahub_sdk/exceptions/__init__.py`, and to `tests/fixtures/error_catalogue/public_names.json`. Make T002 pass and keep `tests/unit/sdk/test_exceptions_public_names.py`, `tests/unit/sdk/test_exceptions_layering.py` and `tests/unit/sdk/test_exceptions.py` green. +- [X] T006 Create the private module `infrahub_sdk/node/_only.py` with the conflict check, the name check (kind schema plus implementing kinds' schemas passed in), the strict peer check, the HFID expansion and the frozen marker dataclass (`only` tuple, `peer_of`). It takes schemas already looked up and does no I/O (plan, Design Notes). Make T003 pass. +- [X] T007 Add the record and `is_loaded` to `Attribute` in `infrahub_sdk/node/attribute.py`. Add a private `_bind(node, present)`. The `value` setter marks the attribute as loaded. `is_loaded` follows data-model "`is_loaded`". Switch `_initialize_graphql_payload`, `is_from_pool_attribute` and `is_unresolved_pool_attribute` to read `_value` (research R8). +- [X] T008 Add the record, `_bind` and `is_loaded` to `RelatedNode` and `RelatedNodeSync` (shared base) in `infrahub_sdk/node/related_node.py`. Make `initialized` read the cached peer, `_id` and `_hfid` directly (research R8, condition list). +- [X] T009 In `infrahub_sdk/node/relationship.py`: + - make `peers` a property backed by `_peers`, with a setter that marks the manager as loaded and a getter that returns the stored list itself; + - switch every internal read and write (`__init__`, `add`, `extend`, `remove`, `fetch`, `_generate_input_data`, `is_from_profile`, `__getitem__`) to `_peers`; + - add `_bind` and `is_loaded` (based on `initialized` and the setter flag), on both manager classes. + + Do not add `__len__` or `__bool__` (research R8). +- [X] T010 In `infrahub_sdk/node/node.py`, for `InfrahubNode` and `InfrahubNodeSync`, bind every field object with `present = name in data`: + - in `_init_attributes`; + - in `_init_relationships`, for both copies of `parent` and `children` and for `_hierarchical_data`; + - for the `RelatedNode` built by `__setattr__` when a relationship is assigned, which is marked as assigned. + + Add the private marker attribute (default `None`) and the private HFID-peer slot (default empty). Make T004 pass. +- [X] T011 [P] Add `is_loaded` to the `Attribute` and `RelatedNodeBase` protocols in `infrahub_sdk/protocols_base.py` (research R12). + +**Checkpoint**: `uv run pytest tests/unit/sdk/ -q` passes with the baseline count plus the new tests. No existing test was edited. + +--- + +## Phase 3: User Story 1 - Fetch only the named fields (Priority: P1) 🎯 first increment + +**Goal**: `get()`, `all()` and `filters()` accept `only`. The query contains only the named fields plus `id`, `display_label` and `__typename`. Nodes from `only` raise `FieldNotLoadedError` on reads of fields they do not carry. Saving, upserting, the HFID and the store keep working. + +**Independent test**: with `httpx_mock`, call `all(kind=..., only=[...])` on both clients against the test schemas. Check the request body, the nodes returned, which reads raise, and that `is_loaded` is `False` exactly for those reads. + +### Tests for User Story 1 (write first, confirm they fail) + +- [X] T012 [P] [US1] Query-generation tests in `tests/unit/sdk/test_node_only_query.py` that call the new private query methods on both node classes and compare the whole generated query (research R4, R5; contract §1). Cases: + - `only=["name"]`; + - `only=[]`; + - `id` and `display_label`; + - a cardinality-one and a cardinality-many relationship as references; + - `prefetch_relationships=True` expanding only named relationships; + - `parent`, `children`, `ancestors`, `descendants` as references when named; with `prefetch_relationships`, with the hierarchy generic's default fields, and no unnamed hierarchy field added; + - `hfid` through an attribute and through a relationship (`schema_with_hfid`), merged with a named relationship and with `prefetch_relationships`; + - a kind with no HFID; + - a generic kind: a name on the generic, a name only on some implementing kinds under `__alias__Kind__name`, and per-kind HFID; + - `fragment=True` adding nothing; + - `property=True` and `include_metadata=True` on named fields; + - filters, `order`, `partial_match`, `offset`, `limit` and `count` unchanged. + + The node never requests `hfid`. +- [X] T013 [P] [US1] Schema-growth test in `tests/unit/sdk/test_node_only_query.py`: the same `only` call gives the same query after an attribute and a relationship are added to the kind and to another implementing kind, when they are not named in `only` (SC-002). +- [X] T014 [P] [US1] Client tests in `tests/unit/sdk/test_client_only.py`, on both clients, with `httpx_mock`: + - **errors before any request**: `get`, `all` and `filters` raise `FieldSelectionConflictError`, and no request is recorded (including the schema request in `get`). A misspelt name raises `FieldNotFoundError` and no GraphQL query is recorded. + - **names on generic and hierarchical kinds**: a name only on an implementing kind is accepted. `ancestors` is accepted on a hierarchical kind and rejected on a kind without a hierarchy. + - **requests**: the body of the GraphQL request equals the expected query. + - **nodes and peers**: nodes carry the marker. Under `prefetch_relationships`, peer nodes are built and stored only for named relationships (for hierarchy fields: `parent` and `children`, as today's prefetch does). A relationship that only an implementing kind has gets its peer built and stored (research R9, critique E1). + - **edge case**: `device.site.peer` raises `NodeNotFoundError` when the site is not in the store. + - **HFID**: + - `node.hfid` is calculated with `populate_store=True` and with `populate_store=False`; + - `client.store.get(key=node.hfid, kind=...)` returns the node from `only`; + - `node.hfid` does not change after a later query stores a narrower copy of the peer; + - `node.hfid` is `None` when its HFID fields were not fetched and `hfid` was not named. + - **store**: a node from `only` replaces a fuller copy in the store, and reading its unfetched fields from the store raises. +- [X] T015 [P] [US1] Read tests in `tests/unit/sdk/test_node_only_access.py`, on both clients, for nodes from `only` and peers returned with them. Every row of the contract §4 table: + - raises `FieldNotLoadedError` when the field is not loaded, with a message naming the kind, the field and the `only` list (and the relationship for a peer); + - does not raise when the field is loaded. + + The "never raises" reads do not raise. `get_path_value`, `get_flat_value`, `matches_local_checksum`, `download_file(skip_if_unchanged=True)` and `upload_if_changed` raise when the field they read was not fetched (research R13). `is_loaded` is `False` in exactly the raising cases (SC-005). +- [X] T016 [P] [US1] Tests for operations that must not raise, in `tests/unit/sdk/test_node_only_access.py`, on both clients, on a node from `only` with every other field unfetched (research R8, contract §4): + - `save()` for create and update; + - `create(allow_upsert=True)`; + - `update()`; + - `hfid`, `hfid_str`, `get_human_friendly_id()`, `get_human_friendly_id_as_string()`; + - `client.store.set(node)`; + - processing a mutation result; + - `repr()`. + + Check the update mutation body: it holds `id` and the changed fields only, so unfetched fields are neither sent nor cleared. + +### Implementation for User Story 1 + +- [X] T017 [US1] In `infrahub_sdk/node/node.py`, add the private query methods for `only` to `InfrahubNode`, using the `_only.py` helpers and the existing peer-query helpers (`RelatedNode._generate_query_data`, `RelationshipManager._generate_query_data`, `generate_query_data_node` for prefetched peers) (research R4, R5, R14): + - **queried node**: `id`, `display_label`, `__typename`, no `hfid`; + - **named fields**: named attributes; named relationships as references; + - **prefetch**: default fields for named peers when `prefetch_relationships` is set; + - **hierarchy fields**: when named, references, or with `prefetch_relationships` the peer fields `_process_hierarchical_fields` requests today; + - **generic kinds**: `...on Kind` blocks with the existing alias form, using implementing kinds' schemas from the query's branch; + - **HFID**: expanded paths; + - **`property` and `include_metadata`**: applied to the named fields; + - **query arguments**: filters, `order`, `partial_match`, `offset` and `limit` as `generate_query_data_init` does. + + The public `generate_query_data*` methods stay unchanged. Make the async rows of T012 and T013 pass. +- [X] T018 [US1] Mirror T017 in `InfrahubNodeSync` in `infrahub_sdk/node/node.py`. Make the sync rows of T012 and T013 pass. +- [X] T019 [US1] Raising accessors in `infrahub_sdk/node/attribute.py`, `infrahub_sdk/node/related_node.py` and `infrahub_sdk/node/relationship.py`: each "raises on" read in contract §4 checks `is_loaded` and the node's marker, and raises `FieldNotLoadedError` built from the marker, the node's kind and the field name. Reads on nodes without the marker are unchanged. +- [X] T020 [US1] In `infrahub_sdk/node/node.py`, for both node classes: + - add a private, non-raising version of `get_path_value`, which treats a field that is not loaded as missing and looks for a peer in the HFID-peer slot first, then through the cached peer and the store; make `get_human_friendly_id` use it; + - leave the public `get_path_value` reading through the public accessors; + - give `_process_relationships` a private argument with the relationship names to process, which reads from the node's data with aliases removed. Without the argument, its behaviour is unchanged (research R5, R8, R9). +- [X] T021 [US1] In `infrahub_sdk/client.py`, `InfrahubClient`: + - add `only: list[str] | None = None` as the last named parameter of `get` (6 overloads and the implementation), `all` (2 and the implementation) and `filters` (2 and the implementation), before `**kwargs`. Pass it from `get` and `all` to `filters`. + - put the conflict check first in all three methods. + - in `filters`, after the schema lookup: look up the implementing kinds' schemas with `branch`, run the name check, and call the private query method when `only` is not `None`. + - in `_process_nodes_and_relationships`, under `only`: set the marker on each node; build peer nodes only for named relationships with `prefetch_relationships` (for hierarchy fields, `parent` and `children` as today) and for HFID paths; set the peer marker with `peer_of`; fill the HFID-peer slot. Add the peers to the related nodes that go to the store. + + Document `only` in the docstrings of `all` and `filters` next to `include` and `exclude`, and add a docstring to `get` that documents it. +- [X] T022 [US1] Mirror T021 in `InfrahubClientSync` in `infrahub_sdk/client.py`. Run `tests/unit/sdk/test_client.py::test_validate_method_signature` and `test_method_sanity`. +- [X] T023 [US1] Update the `Raises:` sections of `get_path_value`, `get_flat_value`, `matches_local_checksum`, `download_file` and `upload_if_changed` on both node classes in `infrahub_sdk/node/node.py` (research R16). + +**Checkpoint**: T012 to T016 pass on both clients, and `uv run pytest tests/unit/sdk/ -q` passes without any existing test edited. User Story 1 can ship on its own here. + +--- + +## Phase 4: User Story 2 - Fetch chosen fields of a peer (Priority: P2) + +**Goal**: `fetch(only=[...])` on both relationship classes returns peers with only the named fields, checks the names before any peer query, and works on relationships that a node from `only` did not load. + +**Independent test**: on a device from `only=["site"]`, `device.site.fetch(only=["name"])` sends one query with the expected fields, `device.site.peer.name.value` returns the name, and reading `device.site.peer.description.value` raises. Cardinality-many cases are covered as well. + +### Tests for User Story 2 (write first, confirm they fail) + +- [X] T024 [P] [US2] Tests in `tests/unit/sdk/test_relationship_only.py`, on both clients, with `httpx_mock` (research R10, contract §2): + - **`RelatedNode.fetch(only=["name"])`**: + - sends one query with `id`, `display_label`, `__typename` and `name`; + - the peer is readable and raises on `description`; + - `device.site.hfid` keeps the server value after `fetch(only=["description"])`. + - **`RelatedNode.fetch()` on a relationship that is not loaded**: raises `FieldNotLoadedError` on a node from `only`, and `Error` on a default node. + - **`RelationshipManager.fetch(only=[...])`, mixed peer kinds**: a name missing on one kind raises `FieldNotFoundError` and no peer query is sent. + - **misspelt name on a manager that is not loaded**: raises, and no request is sent. + - **reload on a node from `only`**: uses `get(..., only=[name], populate_store=False)`. The request body is checked, and the store still holds the developer's node. + - **`ancestors` and `descendants`**: `ancestors.fetch(only=[...])` and `descendants.fetch(only=[...])` on a node from `only` load the peers. + - **peers from `fetch(only=...)`**: they carry the marker and raise on unfetched fields. + - **without `only`**: `fetch()` on both classes behaves as today. + +### Implementation for User Story 2 + +- [X] T025 [US2] In `infrahub_sdk/node/related_node.py`: + - add `only: list[str] | None = None` as the last parameter of `RelatedNode.fetch` and `RelatedNodeSync.fetch`, and pass it to `client.get`; + - when the cached peer is a node from `only` and its HFID cannot be calculated, make `hfid` and `hfid_str` return the HFID the reference carried; + - update the `Raises:` sections. +- [X] T026 [US2] In `infrahub_sdk/node/relationship.py`, add `only: list[str] | None = None` to `RelationshipManager.fetch` and `RelationshipManagerSync.fetch`. Steps, in order: + 1. Check the names against `self.schema.peer`, before any request. + 2. If the manager is not loaded, reload it. On a node from `only`, use `client.get(kind=..., id=..., only=[self.schema.name], populate_store=False)`. On any other node, keep the existing reload. + 3. Run the strict check against every actual peer kind. + 4. Add one `filters(..., only=only)` per peer kind to the batch. + + Update the `Raises:` sections. + +**Checkpoint**: T024 passes on both clients, and the full unit suite passes. + +--- + +## Phase 5: Polish and cross-cutting concerns + +- [X] T027 [P] Integration tests in `tests/integration/test_node_only.py`, following `tests/integration/test_infrahub_client.py` and `tests/integration/test_infrahub_client_sync.py` (one test class per client). Use `SchemaAnimal` from `infrahub_sdk/testing/schemas/animal.py`. + - `all(kind="TestingDog", only=["name", "owner"])` returns dogs with `name`, and `breed` raises. + - `all(kind="TestingAnimal", only=["name", "breed"])` returns `breed` for dogs and cats. + - `only=["hfid"]` on `TestingCat` gives `node.hfid` with the cat's own three HFID parts. + - `dog.owner.fetch(only=["name"])` makes the owner's name readable. + - Saving a node from `only` after changing one attribute leaves its other fields unchanged on the server. +- [X] T028 [P] Add the section on `only` to `docs/docs/python-sdk/guides/query_data.mdx` after "Excluding attribute and relationships". It covers: + - `only`, `only=[]`, `id` and `display_label`; + - `hfid` in `only`; + - relationships as references, and `prefetch_relationships` with `only`; + - hierarchy fields; + - generic kinds; + - `is_loaded` and `FieldNotLoadedError`; + - the errors raised before a request. + + Add `fetch(only=...)` under "Fetching relationships manually". Make no claim about what `include` returns for peers. Add one sentence to `docs/docs/python-sdk/guides/store.mdx`: a node from `only` replaces a fuller copy of the same node, and `is_loaded` tells which fields it carries (research R16). +- [X] T029 [P] Add `changelog/+only-field-selection.added.md`. It describes `only` on `get`, `all`, `filters` and `fetch`, `is_loaded`, and the three new errors, in the style of the existing fragments in `changelog/`. +- [X] T030 Run `uv run invoke docs-generate` and commit the regenerated pages under `docs/docs/python-sdk/sdk_ref/`. Then run `uv run invoke docs-validate`. Check that `infrahub_sdk/node/_only.py` gets no reference page; if it does, exclude it in `tasks.py` the way other private modules are. +- [X] T031 Run `uv run invoke format lint-code` and `uv run invoke lint-docs`. Fix every new finding without silencing a rule, unless an inline reason is given (constitution IV, VI). +- [X] T032 Run `uv run pytest tests/unit/ -q` and `uv run pytest tests/integration/test_node_only.py -q`. Confirm that `git diff origin/infrahub-develop -- tests/unit/sdk/test_node.py tests/unit/sdk/test_hierarchical_nodes.py` shows no changed lines (SC-003). +- [X] T033 Check the scope with `git diff --stat origin/infrahub-develop`. Only the files listed in plan.md "Source Code" and the regenerated docs may change. Run `grep -rn "INFP-532\|FR-0\|SC-0\|User Story" infrahub_sdk tests` and confirm it returns nothing. +- [X] T034 Run the checks in [quickstart.md](./quickstart.md) sections 1, 2 and 4. + +--- + +## Dependencies and execution order + +### Phase dependencies + +- **Setup (Phase 1)**: none. +- **Foundational (Phase 2)**: after Setup. Blocks both user stories. +- **User Story 1 (Phase 3)**: after Foundational. +- **User Story 2 (Phase 4)**: after User Story 1. `fetch(only=...)` uses the `only` path of `get` and `filters` (T021, T022) and the raising accessors (T019). +- **Polish (Phase 5)**: after the stories it documents or tests. T027 and T028 need both stories. + +### Task dependencies inside phases + +- T005 needs T002. T006 needs T003 and T005. T007 to T010 need T004, and T010 needs T007 to T009. +- T017 needs T006 and T012. T018 needs T017. +- T019 needs T007 to T010 and T015. +- T020 needs T017 and T018. +- T021 needs T017, T019, T020 and T014. T022 needs T021. +- T016 passes once T019 and T020 are done. +- T025 and T026 need T024 and T022. +- T030 needs T023, T025 and T026 (docstrings). + +### Parallel opportunities + +- Phase 2 tests: T002, T003 and T004 (different files). +- T011 runs alongside T007 to T010. +- Phase 3 tests: T012 to T016 can be written together (T015 and T016 share a file; write them in sequence if one person does both). +- Phase 5: T027, T028 and T029. + +## Parallel example: User Story 1 + +```text +Task: "Query-generation tests in tests/unit/sdk/test_node_only_query.py" (T012, T013) +Task: "Client tests in tests/unit/sdk/test_client_only.py" (T014) +Task: "Read and must-not-raise tests in tests/unit/sdk/test_node_only_access.py" (T015, T016) +``` + +## Implementation strategy + +1. **First increment**: Phases 1 to 3. `only` on the query methods, with errors, `is_loaded` and the HFID. Ship once the checkpoint passes; User Story 2 adds to it without changing it. +2. **Second increment**: Phase 4, `fetch(only=...)`. +3. **Finish**: Phase 5, integration tests, docs, changelog and checks. Docs and changelog ship in the same pull request as the code (constitution VII). + +Commit after each task or logical group, with `uv run invoke format lint-code` run first. diff --git a/docs/docs/infrahubctl/infrahubctl-troubleshooting.mdx b/docs/docs/infrahubctl/infrahubctl-troubleshooting.mdx new file mode 100644 index 000000000..4d08c0bc6 --- /dev/null +++ b/docs/docs/infrahubctl/infrahubctl-troubleshooting.mdx @@ -0,0 +1,84 @@ +# `infrahubctl troubleshooting` + +**Usage**: + +```console +$ infrahubctl troubleshooting [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--install-completion`: Install completion for the current shell. +* `--show-completion`: Show completion for the current shell, to copy it or customize the installation. +* `--help`: Show this message and exit. + +**Commands**: + +* `trace-sample-ratio`: Read, set or clear the temporary trace... + +## `infrahubctl troubleshooting trace-sample-ratio` + +Read, set or clear the temporary trace sampling ratio. + +**Usage**: + +```console +$ infrahubctl troubleshooting trace-sample-ratio [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--help`: Show this message and exit. + +**Commands**: + +* `get`: Show the trace sampling ratio of the API... +* `set`: Set a temporary trace sampling ratio on... +* `clear`: Clear the temporary trace sampling ratio,... + +### `infrahubctl troubleshooting trace-sample-ratio get` + +Show the trace sampling ratio of the API server that answers. + +**Usage**: + +```console +$ infrahubctl troubleshooting trace-sample-ratio get [OPTIONS] +``` + +**Options**: + +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. + +### `infrahubctl troubleshooting trace-sample-ratio set` + +Set a temporary trace sampling ratio on every API server and task worker. + +**Usage**: + +```console +$ infrahubctl troubleshooting trace-sample-ratio set [OPTIONS] +``` + +**Options**: + +* `--ratio FLOAT RANGE`: Share of new root traces to record [0<=x<=1; required] +* `--duration INTEGER RANGE`: Number of seconds the ratio applies [1<=x<=86400; required] +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. + +### `infrahubctl troubleshooting trace-sample-ratio clear` + +Clear the temporary trace sampling ratio, so every process uses its default ratio again. + +**Usage**: + +```console +$ infrahubctl troubleshooting trace-sample-ratio clear [OPTIONS] +``` + +**Options**: + +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. diff --git a/docs/docs/python-sdk/guides/installation.mdx b/docs/docs/python-sdk/guides/installation.mdx index 5478a956a..cfbbc2410 100644 --- a/docs/docs/python-sdk/guides/installation.mdx +++ b/docs/docs/python-sdk/guides/installation.mdx @@ -29,18 +29,50 @@ pip install 'infrahub-sdk[ctl]' -### tests +### testing -The `tests` extra provides all the components for the testing framework of Transforms, Queries and Checks. +The `testing` extra provides the components for the testing framework of Transforms, Queries and Checks. It installs `pytest` and enables the bundled `pytest-infrahub` plugin, which discovers the `infrahub_tests` blocks in your repository's `.infrahub.yml` and turns them into test cases. ```shell -pip install 'infrahub-sdk[tests]' +pip install 'infrahub-sdk[testing]' ``` +### testcontainers + + + +The `testcontainers` extra provides `infrahub_sdk.testing.docker`, which starts a real Infrahub in containers so your tests can run against a live instance rather than mocked responses. Inherit `TestInfrahubDockerClient` to get a client connected to that instance. + +```shell +pip install 'infrahub-sdk[testcontainers]' +``` + +:::note + +This extra is considerably larger than the others, since running Infrahub in containers pulls in Docker, FastAPI and Prefect client libraries. Install `testing` on its own if you only need to test Transforms, Queries and Checks. + +::: + + + +### tracing + + + +The `tracing` extra installs the OpenTelemetry API, so the SDK can send the W3C `traceparent` header with each request and the Infrahub server records its spans in your application's trace. The SDK sends the header only when both conditions are true: OpenTelemetry is installed, and the request runs inside an active span of your application. + +```shell +pip install 'infrahub-sdk[tracing]' +``` + +You do not need this extra if your application already installs OpenTelemetry. + + + ### all diff --git a/docs/docs/python-sdk/guides/query_data.mdx b/docs/docs/python-sdk/guides/query_data.mdx index fb69b96b0..c1a150ff9 100644 --- a/docs/docs/python-sdk/guides/query_data.mdx +++ b/docs/docs/python-sdk/guides/query_data.mdx @@ -240,6 +240,7 @@ nodes: optional: true - name: Interface namespace: Test + human_friendly_id: ["device__name__value", "name__value"] attributes: - name: name kind: Text @@ -250,7 +251,7 @@ nodes: cardinality: one kind: Parent peer: TestDevice - optional: true + optional: false ``` ### Attributes @@ -414,6 +415,190 @@ You can exclude attributes and relationships that are retrieved with a query by +### Fetching only the named attributes and relationships + +Pass `only` to `get`, `filters` or `all` to request the attributes and relationships you name and no others. Each node also gets its `id`, `display_label` and `__typename`. The query that the SDK sends names only those fields, so the response does not contain the other attributes and relationships of the kind. + + + + + ```python + devices = await client.all(kind="TestDevice", only=["name", "site"]) + for device in devices: + print(device.name.value, device.site.display_label) + ``` + + + + + ```python + devices = client.all(kind="TestDevice", only=["name", "site"]) + for device in devices: + print(device.name.value, device.site.display_label) + ``` + + + + +The names in `only` are the names of the attributes and relationships in the schema of the kind, plus `hfid` and the hierarchy fields described below. With `only=[]`, the query requests the `id`, `display_label` and `__typename` of each node and nothing else. `only` accepts `id` and `display_label`, but they add nothing, because every node gets them. + +`property=True` and `include_metadata=True` work with `only`. `property=True` adds the properties of the named fields, and `include_metadata=True` adds the metadata of the node and of the named fields. `only` cannot be combined with `include` or `exclude`. + +#### Reading a field that was not fetched + +A node returned by a query with `only` contains only the fields that the query fetched. Reading any other field raises `FieldNotLoadedError`, so a field that was not fetched is never read as an empty value. To know whether a node contains a field, check `is_loaded` on the attribute or the relationship. `is_loaded` never raises. + + + + + ```python + from infrahub_sdk.exceptions import FieldNotLoadedError + + device = await client.get(kind="TestDevice", name__value="atl1-edge1", only=["site"]) + print(device.site.id) + print(device.name.is_loaded) # False + + try: + print(device.name.value) + except FieldNotLoadedError as exc: + print(exc) + ``` + + + + + ```python + from infrahub_sdk.exceptions import FieldNotLoadedError + + device = client.get(kind="TestDevice", name__value="atl1-edge1", only=["site"]) + print(device.site.id) + print(device.name.is_loaded) # False + + try: + print(device.name.value) + except FieldNotLoadedError as exc: + print(exc) + ``` + + + + +The message names the kind, the field and the `only` list: + +```text +TestDevice.name was not fetched: the node was queried with only=['site']. Add 'name' to only, or check is_loaded before reading it. +``` + +These reads raise `FieldNotLoadedError` when the field was not fetched: + +- on an attribute: `value`; +- on a relationship of cardinality one: `id`, `hfid`, `hfid_str`, `display_label`, `typename`, `kind`, `peer`, `get()` and `fetch()`; +- on a relationship of cardinality many: `peers`, `peer_ids`, `peer_hfids`, `peer_hfids_str`, `is_from_profile`, indexing and iteration. + +Node methods that read these fields raise in the same cases, for example `get_path_value` and `get_flat_value`. On a file node, `matches_local_checksum` and `upload_if_changed` raise when the query did not fetch `checksum`, and so does `download_file(skip_if_unchanged=True)` when `dest` is an existing file. + +The SDK raises `FieldNotLoadedError` only on nodes returned by a query with `only`, on the peers returned with them, and on peers fetched with `fetch(only=...)`. On any other node, these reads behave as before, even when `is_loaded` is `False`, for example for an attribute that a query with `exclude` did not fetch. + +#### Saving a node from a query with `only` + +You can change and save a node returned by a query with `only`. `save()` sends the node's `id` and the fields you changed, so the fields that the query did not fetch keep their values in Infrahub. Reading `node.hfid` and adding the node to the store do not raise either. + + + + + ```python + device = await client.get(kind="TestDevice", name__value="atl1-edge1", only=["name"]) + device.name.value = "atl1-edge01" + await device.save() + ``` + + + + + ```python + device = client.get(kind="TestDevice", name__value="atl1-edge1", only=["name"]) + device.name.value = "atl1-edge01" + device.save() + ``` + + + + +#### Relationships named in `only` + +A relationship named in `only` is returned as a reference to its peers: their `id`, `hfid`, `display_label` and `__typename`. A relationship that you do not name is not requested, whatever its kind or cardinality. + +With `prefetch_relationships=True`, the SDK also requests the default fields of the peers of the named relationships, builds peer nodes from them and adds the peer nodes to the store. The peer nodes follow the same rule as the node: reading a field that they do not contain raises `FieldNotLoadedError`. + + + + + ```python + device = await client.get( + kind="TestDevice", name__value="atl1-edge1", only=["name", "site"], prefetch_relationships=True + ) + print(device.site.peer.name.value) + ``` + + + + + ```python + device = client.get( + kind="TestDevice", name__value="atl1-edge1", only=["name", "site"], prefetch_relationships=True + ) + print(device.site.peer.name.value) + ``` + + + + +#### Human-friendly IDs of nodes from a query with `only` + +Name `hfid` in `only` to fetch the human-friendly ID that Infrahub stores for each node. `node.hfid`, `node.hfid_str`, `get_human_friendly_id()` and `get_human_friendly_id_as_string()` return that value, and the SDK adds the node to the store under it. If you change a field that the human-friendly ID is built from, the SDK stops using the stored value and calculates the human-friendly ID from the node's fields, which gives `None` when one of its parts was not fetched. Naming `hfid` does not fetch the attributes and relationships that make up the human-friendly ID: reading them raises `FieldNotLoadedError` unless you name them too. + + + + + ```python + interfaces = await client.filters(kind="TestInterface", device__name__value="atl1-edge1", only=["hfid"]) + for interface in interfaces: + print(interface.hfid) # for example ["atl1-edge1", "Ethernet1"] + ``` + + + + + ```python + interfaces = client.filters(kind="TestInterface", device__name__value="atl1-edge1", only=["hfid"]) + for interface in interfaces: + print(interface.hfid) # for example ["atl1-edge1", "Ethernet1"] + ``` + + + + +If `only` does not name `hfid`, the SDK calculates `node.hfid` from the fields that the query fetched. `node.hfid` is then `None` when one of those fields was not fetched. + +For a generic kind, the query requests `hfid` when the generic or one of the kinds that implement it has a human-friendly ID. For a kind without a human-friendly ID, naming `hfid` changes nothing. + +#### Hierarchy fields named in `only` + +A kind that is part of a hierarchy has four more fields: `parent`, `children`, `ancestors` and `descendants`. They are different from relationships of kind `Parent`, such as `device` on `TestInterface`, which `only` handles like any other relationship. + +`only` accepts the four hierarchy fields only on a kind that is part of a hierarchy, and the query requests a hierarchy field only when you name it. A named hierarchy field is returned as a reference. With `prefetch_relationships=True`, the SDK also requests the default fields of the peers, without their own hierarchy fields, and builds peer nodes and adds them to the store for `parent` and `children` only. The peers of `parent` and `children` are of the peer kind that the schema of the kind declares for them, or of the hierarchy generic when the schema does not declare them. The peers of `ancestors` and `descendants` are of the hierarchy generic. + +#### Generic kinds queried with `only` + +When you query a generic kind, `only` accepts the attributes and relationships of the generic and of any kind that implements it. If only some implementing kinds have a field, the query requests it for the nodes of those kinds. `fragment=True` adds no fields to a query with `only`. + +#### Errors raised before the nodes are queried + +Two errors from `infrahub_sdk.exceptions` report a mistake in the arguments before the SDK requests any node data: + +- `FieldSelectionConflictError`: `only` is combined with `include` or `exclude`, even with an empty list. The SDK raises it before it sends any request. +- `FieldNotFoundError`: a name in `only` is not a field of the kind or of any kind that implements it. The error names the field and the kind. + ### Fetching relationships manually The `fetch` method can be used to retrieve relationships, initialize them; and retrieving the related nodes manually. @@ -475,6 +660,52 @@ The `fetch` method can also be used on relationship that were previously initial +#### Fetching only the named fields of the peers + +`fetch` accepts `only` on relationships of cardinality one and of cardinality many. Each peer gets its `id`, `display_label`, `__typename` and the named fields, and reading any other field of the peer raises `FieldNotLoadedError`. + + + + + ```python + device = await client.get(kind="TestDevice", name__value="atl1-edge1", only=["name", "site"]) + + await device.site.fetch(only=["name"]) + print(device.site.peer.name.value) + + await device.interfaces.fetch(only=["name"]) + for interface in device.interfaces.peers: + print(interface.peer.name.value) + ``` + + + + + ```python + device = client.get(kind="TestDevice", name__value="atl1-edge1", only=["name", "site"]) + + device.site.fetch(only=["name"]) + print(device.site.peer.name.value) + + device.interfaces.fetch(only=["name"]) + for interface in device.interfaces.peers: + print(interface.peer.name.value) + ``` + + + + +On a relationship of cardinality one, `fetch(only=[...])` sends one query for the peer, keeps the peer on the relationship and adds it to the store. If the peer has no human-friendly ID, because `only` did not name `hfid` and the fetched fields are not enough to calculate it, `hfid` and `hfid_str` on the relationship return the human-friendly ID that Infrahub returned with the reference. + +On a relationship of cardinality many, `fetch(only=[...])` works in this order: + +1. It checks the names against the peer kind of the relationship, before it sends any request. +2. If the relationship was not fetched, it requests it. On a node returned by a query with `only`, it requests that relationship alone and does not change the node in the store. This also works for `ancestors` and `descendants`. +3. It checks that every name is a field of the kind of every peer. If a name is missing on one of them, it raises `FieldNotFoundError` before it queries any peer. +4. For each peer kind, it sends a query that counts the peers of that kind, then one query for each page of those peers, and adds the peers to the store. + +On a node returned by a query with `only`, `fetch()` on a relationship of cardinality one that the query did not fetch raises `FieldNotLoadedError`. To fetch that peer, name the relationship in `only`. + ### Prefetch relationships Related nodes of a relationship can be retrieved, using the `prefetch_relationships` argument for the different `query` methods. But this requires the usage of the internal client store diff --git a/docs/docs/python-sdk/guides/resource-manager.mdx b/docs/docs/python-sdk/guides/resource-manager.mdx index 13a9b0af9..fb1bc2e65 100644 --- a/docs/docs/python-sdk/guides/resource-manager.mdx +++ b/docs/docs/python-sdk/guides/resource-manager.mdx @@ -310,6 +310,88 @@ query { Notice that we have one IP address allocated by the Resource manager in the test branch. The query in the main branch shows us this allocation, indicating that it has been allocated and the resource cannot be allocated again. However, the IP address does not exist itself within the main branch. +## Create a number pool with ranges + +A number pool allocates from its ranges. Pass `ranges` as a list of dictionaries with `start`, `end` and `allocation_weight`. A range without a `start` or an `end` is open on that side, and ranges with a higher `allocation_weight` are allocated from first. Without `ranges`, the pool has one range with no start and no end. + + + + + ```python + pool = await client.create( + kind="CoreNumberPool", + name="VLAN IDs", + node="InfraVLAN", + node_attribute="vlan_id", + ranges=[ + {"start": 100, "end": 199, "allocation_weight": 10}, + {"start": 200, "end": 2999}, + ], + ) + await pool.save() + ``` + + + + + ```python + pool = client.create( + kind="CoreNumberPool", + name="VLAN IDs", + node="InfraVLAN", + node_attribute="vlan_id", + ranges=[ + {"start": 100, "end": 199, "allocation_weight": 10}, + {"start": 200, "end": 2999}, + ], + ) + pool.save() + ``` + + + + +The declared list is the complete set of ranges. Saving the same pool with `allow_upsert=True` sets the ranges of an existing pool to that list: Infrahub keeps the ranges whose bounds match, creates the new ones and deletes the others. + +To add, change or delete a single range, work with its `CoreNumberPoolRange` node. The pool takes its ranges as bounds, not as references to range nodes, so `add()`, `extend()` and `remove()` on its `ranges` relationship raise `FeatureNotSupportedError`, and `infrahubctl object update --set ranges=...` reports the same refusal. Editing the list held by `pool.ranges.peers` is not supported either, and the SDK does not report it. + + + + + ```python + # Add a range + new_range = await client.create(kind="CoreNumberPoolRange", start=3000, end=3999, pool=pool) + await new_range.save() + + # Change its weight + new_range.allocation_weight.value = 50 + await new_range.save() + + # Delete it + await new_range.delete() + ``` + + + + + ```python + # Add a range + new_range = client.create(kind="CoreNumberPoolRange", start=3000, end=3999, pool=pool) + new_range.save() + + # Change its weight + new_range.allocation_weight.value = 50 + new_range.save() + + # Delete it + new_range.delete() + ``` + + + + +Infrahub refuses a range that overlaps another range of the pool, and refuses a change or a deletion that would leave a number the pool tracks outside every range. The ranges of a pool generated from a schema attribute are set in the attribute's `parameters.ranges` instead, and cannot be changed through `CoreNumberPoolRange` nodes. + ## CoreNumberPool and attribute allocation `CoreNumberPool` allocates integer values (such as VLAN IDs or AS numbers) directly to node attributes. The pool assigns the integer value at the moment the node is created on the server. @@ -323,6 +405,87 @@ vlan = await client.create( await vlan.save() ``` +### Attach a number you choose to a pool + +To keep a number you choose and have the pool track it, pass both `value` and `from_pool`. The pool records the number as one a user provided, and does not allocate a new one. + + + + + ```python + vlan = await client.create( + kind="InfraVLAN", + name="VLAN-200", + vlan_id={"value": 200, "from_pool": {"id": pool_id}}, + ) + await vlan.save() + ``` + + + + + ```python + vlan = client.create( + kind="InfraVLAN", + name="VLAN-200", + vlan_id={"value": 200, "from_pool": {"id": pool_id}}, + ) + vlan.save() + ``` + + + + +On an existing node, call `attach_to_pool()` on the attribute with the pool or its ID. The pool tracks the number the attribute holds when the node is saved. + + + + + ```python + vlan = await client.get(kind="InfraVLAN", name__value="VLAN-300") + vlan.vlan_id.attach_to_pool(pool) + await vlan.save() + ``` + + + + + ```python + vlan = client.get(kind="InfraVLAN", name__value="VLAN-300") + vlan.vlan_id.attach_to_pool(pool) + vlan.save() + ``` + + + + +### Detach a number from its pool + +Call `detach_from_pool()` on the attribute. The attribute keeps its number, and the pool stops tracking it when the node is saved. + + + + + ```python + vlan = await client.get(kind="InfraVLAN", name__value="VLAN-200") + vlan.vlan_id.detach_from_pool() + await vlan.save() + ``` + + + + + ```python + vlan = client.get(kind="InfraVLAN", name__value="VLAN-200") + vlan.vlan_id.detach_from_pool() + vlan.save() + ``` + + + + +To find which pool tracks a number on a node you fetched, query the node with `property=True` and read the attribute's `source`, which is the number pool. + ### Limitation: `allow_upsert=True` with a pool-sourced HFID attribute `CoreNumberPool` assigns the integer value at server creation time, and a new value on each creation. When a node's human-friendly identifier (HFID) includes a pool-sourced attribute, the HFID is never stable, so an upsert can never match an existing node by it. Rather than updating, each `save(allow_upsert=True)` would create another node. The SDK blocks this up front to avoid silently duplicating data. diff --git a/docs/docs/python-sdk/guides/store.mdx b/docs/docs/python-sdk/guides/store.mdx index 8836cae60..d6ec7988a 100644 --- a/docs/docs/python-sdk/guides/store.mdx +++ b/docs/docs/python-sdk/guides/store.mdx @@ -135,6 +135,8 @@ You can retrieve objects from the store using their `id` or `hfid`. When using t +A node returned by a [query with `only`](../../python-sdk/guides/query_data#fetching-only-the-named-attributes-and-relationships), or a peer returned with it, replaces the copy stored under the same `id`, even one that contains more fields, so check `is_loaded` on a field of a node from the store before you read it. A lookup by human-friendly ID returns the earlier copy when the new node has no human-friendly ID, for example when the query named neither `hfid` nor the fields that make up the human-friendly ID. + ## Manually storing objects in the store You can store objects in the store manually using the `set` method. This has the advantage that you can choose a key that you want to use to reference the object in the store, besides the `id` or `hfid`. For example, we could use the name attribute value of the node as the key. diff --git a/docs/docs/python-sdk/introduction.mdx b/docs/docs/python-sdk/introduction.mdx index 718cbe58b..50d3f049f 100644 --- a/docs/docs/python-sdk/introduction.mdx +++ b/docs/docs/python-sdk/introduction.mdx @@ -120,7 +120,7 @@ Extras are available for additional functionality: ```bash uv add 'infrahub-sdk[ctl]' # Adds the infrahubctl CLI -uv add 'infrahub-sdk[tests]' # Adds the testing framework for transforms and checks +uv add 'infrahub-sdk[testing]' # Adds the testing framework for transforms and checks uv add 'infrahub-sdk[all]' # Everything ``` @@ -135,7 +135,7 @@ Extras are available for additional functionality: ```bash pip install 'infrahub-sdk[ctl]' # Adds the infrahubctl CLI -pip install 'infrahub-sdk[tests]' # Adds the testing framework for transforms and checks +pip install 'infrahub-sdk[testing]' # Adds the testing framework for transforms and checks pip install 'infrahub-sdk[all]' # Everything ``` 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 8fa8177ef..1b4b0356a 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 = ..., priority: Priority | 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 = ..., only: list[str] | None = ..., **kwargs: Any) -> SchemaType | None ```
@@ -25,39 +25,81 @@ 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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, priority: Priority | 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, only: list[str] | None = None, **kwargs: Any) -> InfrahubNode | SchemaType | None ``` +Retrieve a single node of a given kind, by ID, human-friendly ID or other filters. + +**Args:** + +- `kind`: kind of the node to query +- `raise_when_missing`: Raise NodeNotFoundError when no node matches, instead of returning +None. Defaults to True. +- `at`: Time of the query. Defaults to Now. +- `branch`: Name of the branch to query from. Defaults to default_branch. +- `timeout`: Overrides default timeout used when querying the GraphQL API. Specified in seconds. +- `id`: ID of the node. A value that is not a UUID is matched against the kind's default +filter when it has one. +- `hfid`: Human-friendly ID of the node. +- `include`: List of attributes or relationships to include in the query. +- `exclude`: List of attributes or relationships to exclude from the query. +- `populate_store`: Flag to indicate whether to populate the store with the retrieved node. +- `fragment`: Flag to use GraphQL fragments for generic schemas. +- `prefetch_relationships`: Flag to indicate whether to pre-fetch related node data. +- `property`: Flag to include the properties of the attributes and relationships. +- `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 Get_<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. +- `only`: Fetch only these attributes and relationships, plus `id`, `display_label` +and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not +fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. +- `**kwargs`: Additional filter criteria for the query. + +**Returns:** + +- InfrahubNode | None: The node, or None when no node matches and `raise_when_missing` is False. + +**Raises:** + +- `FieldSelectionConflictError`: `only` is combined with `include` or `exclude`; raised before any request. +- `FieldNotFoundError`: A name in `only` is not a field of the kind; raised before the data query. +- `TypeError`: `only` is a string instead of a list of field names; raised before the data query. +- `ValueError`: No filter was given, or `hfid` was given for a generic kind or a kind without a +human-friendly ID. +- `NodeNotFoundError`: No node matches and `raise_when_missing` is True. +- `IndexError`: More than one node matches. +
#### `delete` @@ -231,7 +273,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 = ..., priority: Priority | 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 = ..., only: list[str] | None = ...) -> list[SchemaType] ```
@@ -240,13 +282,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 = ..., priority: Priority | 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 = ..., only: list[str] | 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, priority: Priority | 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, only: list[str] | None = None) -> list[InfrahubNode] | list[SchemaType] ``` Retrieve all nodes of a given kind. @@ -262,6 +304,9 @@ Retrieve all nodes of a given kind. - `limit`: The limit for pagination. - `include`: List of attributes or relationships to include in the query. - `exclude`: List of attributes or relationships to exclude from the query. +- `only`: Fetch only these attributes and relationships, plus `id`, `display_label` +and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not +fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. - `fragment`: Flag to use GraphQL fragments for generic schemas. - `prefetch_relationships`: Flag to indicate whether to pre-fetch related node data. - `parallel`: Whether to use parallel processing for the query. @@ -275,12 +320,18 @@ client default for these requests only. When None, the client default (if any) i - list\[InfrahubNode]: List of Nodes +**Raises:** + +- `FieldSelectionConflictError`: `only` is combined with `include` or `exclude`; raised before any request. +- `FieldNotFoundError`: A name in `only` is not a field of the kind; raised before the data query. +- `TypeError`: `only` is a string instead of a list of field names; raised before the data query. +
#### `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 = ..., priority: Priority | 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 = ..., only: list[str] | None = ..., **kwargs: Any) -> list[SchemaType] ```
@@ -289,13 +340,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 = ..., priority: Priority | 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 = ..., only: list[str] | 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, priority: Priority | 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, only: list[str] | None = None, **kwargs: Any) -> list[InfrahubNode] | list[SchemaType] ``` Retrieve nodes of a given kind based on provided filters. @@ -311,6 +362,9 @@ Retrieve nodes of a given kind based on provided filters. - `limit`: The limit for pagination. - `include`: List of attributes or relationships to include in the query. - `exclude`: List of attributes or relationships to exclude from the query. +- `only`: Fetch only these attributes and relationships, plus `id`, `display_label` +and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not +fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. - `fragment`: Flag to use GraphQL fragments for generic schemas. - `prefetch_relationships`: Flag to indicate whether to pre-fetch related node data. - `partial_match`: Allow partial match of filter criteria for the query. @@ -326,6 +380,12 @@ client default for these requests only. When None, the client default (if any) i - list\[InfrahubNode]: List of Nodes that match the given filters. +**Raises:** + +- `FieldSelectionConflictError`: `only` is combined with `include` or `exclude`; raised before any request. +- `FieldNotFoundError`: A name in `only` is not a field of the kind; raised before the data query. +- `TypeError`: `only` is a string instead of a list of field names; raised before the data query. +
#### `clone` @@ -550,7 +610,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 = ..., priority: Priority | 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 = ..., only: list[str] | None = ..., **kwargs: Any) -> SchemaTypeSync | None ```
@@ -559,39 +619,81 @@ 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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 = ..., priority: Priority | 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 = ..., only: list[str] | 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, priority: Priority | 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, only: list[str] | None = None, **kwargs: Any) -> InfrahubNodeSync | SchemaTypeSync | None ``` +Retrieve a single node of a given kind, by ID, human-friendly ID or other filters. + +**Args:** + +- `kind`: kind of the node to query +- `raise_when_missing`: Raise NodeNotFoundError when no node matches, instead of returning +None. Defaults to True. +- `at`: Time of the query. Defaults to Now. +- `branch`: Name of the branch to query from. Defaults to default_branch. +- `timeout`: Overrides default timeout used when querying the GraphQL API. Specified in seconds. +- `id`: ID of the node. A value that is not a UUID is matched against the kind's default +filter when it has one. +- `hfid`: Human-friendly ID of the node. +- `include`: List of attributes or relationships to include in the query. +- `exclude`: List of attributes or relationships to exclude from the query. +- `populate_store`: Flag to indicate whether to populate the store with the retrieved node. +- `fragment`: Flag to use GraphQL fragments for generic schemas. +- `prefetch_relationships`: Flag to indicate whether to pre-fetch related node data. +- `property`: Flag to include the properties of the attributes and relationships. +- `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 Get_<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. +- `only`: Fetch only these attributes and relationships, plus `id`, `display_label` +and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not +fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. +- `**kwargs`: Additional filter criteria for the query. + +**Returns:** + +- InfrahubNodeSync | None: The node, or None when no node matches and `raise_when_missing` is False. + +**Raises:** + +- `FieldSelectionConflictError`: `only` is combined with `include` or `exclude`; raised before any request. +- `FieldNotFoundError`: A name in `only` is not a field of the kind; raised before the data query. +- `TypeError`: `only` is a string instead of a list of field names; raised before the data query. +- `ValueError`: No filter was given, or `hfid` was given for a generic kind or a kind without a +human-friendly ID. +- `NodeNotFoundError`: No node matches and `raise_when_missing` is True. +- `IndexError`: More than one node matches. +
#### `delete` @@ -817,7 +919,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 = ..., priority: Priority | 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 = ..., only: list[str] | None = ...) -> list[SchemaTypeSync] ```
@@ -826,13 +928,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 = ..., priority: Priority | 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 = ..., only: list[str] | 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, priority: Priority | 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, only: list[str] | None = None) -> list[InfrahubNodeSync] | list[SchemaTypeSync] ``` Retrieve all nodes of a given kind. @@ -848,6 +950,9 @@ Retrieve all nodes of a given kind. - `limit`: The limit for pagination. - `include`: List of attributes or relationships to include in the query. - `exclude`: List of attributes or relationships to exclude from the query. +- `only`: Fetch only these attributes and relationships, plus `id`, `display_label` +and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not +fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. - `fragment`: Flag to use GraphQL fragments for generic schemas. - `prefetch_relationships`: Flag to indicate whether to pre-fetch related node data. - `parallel`: Whether to use parallel processing for the query. @@ -861,12 +966,18 @@ client default for these requests only. When None, the client default (if any) i - list\[InfrahubNodeSync]: List of Nodes +**Raises:** + +- `FieldSelectionConflictError`: `only` is combined with `include` or `exclude`; raised before any request. +- `FieldNotFoundError`: A name in `only` is not a field of the kind; raised before the data query. +- `TypeError`: `only` is a string instead of a list of field names; raised before the data query. +
#### `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 = ..., priority: Priority | 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 = ..., only: list[str] | None = ..., **kwargs: Any) -> list[SchemaTypeSync] ```
@@ -875,13 +986,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 = ..., priority: Priority | 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 = ..., only: list[str] | 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, priority: Priority | 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, only: list[str] | None = None, **kwargs: Any) -> list[InfrahubNodeSync] | list[SchemaTypeSync] ``` Retrieve nodes of a given kind based on provided filters. @@ -897,6 +1008,9 @@ Retrieve nodes of a given kind based on provided filters. - `limit`: The limit for pagination. - `include`: List of attributes or relationships to include in the query. - `exclude`: List of attributes or relationships to exclude from the query. +- `only`: Fetch only these attributes and relationships, plus `id`, `display_label` +and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not +fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. - `fragment`: Flag to use GraphQL fragments for generic schemas. - `prefetch_relationships`: Flag to indicate whether to pre-fetch related node data. - `partial_match`: Allow partial match of filter criteria for the query. @@ -912,6 +1026,12 @@ client default for these requests only. When None, the client default (if any) i - list\[InfrahubNodeSync]: List of Nodes that match the given filters. +**Raises:** + +- `FieldSelectionConflictError`: `only` is combined with `include` or `exclude`; raised before any request. +- `FieldNotFoundError`: A name in `only` is not a field of the kind; raised before the data query. +- `TypeError`: `only` is a string instead of a list of field names; raised before the data query. +
#### `create_batch` diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx index a96c4ae4b..159f06151 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/graph_traversal/query.mdx @@ -17,7 +17,7 @@ optional fields omitted (the server applies its own defaults). ### `is_unknown_field_error` ```python -is_unknown_field_error(errors: list[dict[str, Any]], field_name: str) -> bool +is_unknown_field_error(errors: Sequence[dict[str, Any]], field_name: str) -> bool ``` Return True if the GraphQL errors indicate ``field_name`` is an unknown query field. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx index 44ba26c6c..cd5b4301a 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx @@ -22,6 +22,8 @@ used by ``InfrahubNode.update()`` to send only the changed fields to the API. - `id`: The unique identifier of the attribute, when known. - `value`: The current attribute value. Setting this marks the attribute as mutated. - `value_has_been_mutated`: True when ``value`` has been assigned after construction. +- `is_loaded`: True when the node's data contained the attribute, a value was assigned since, +the attribute does not belong to a node, or the node has no ``id`` yet. - `is_default`: True when the value comes from the schema default. - `is_from_profile`: True when the value is inherited from a profile. - `is_inherited`: True when the attribute is inherited from a generic. @@ -39,12 +41,63 @@ used by ``InfrahubNode.update()`` to send only the changed fields to the API. value(self) -> Any ``` +Return the attribute value. + +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the attribute was not fetched. + #### `value` ```python value(self, value: Any) -> None ``` +#### `is_loaded` + +```python +is_loaded(self) -> bool +``` + +Return whether this attribute holds a value from its node's data or from an assignment. + +Never raises and sends no request. + +**Returns:** + +- ``True`` when the data the node was built from contained the attribute, when a value +- was assigned since, when the attribute does not belong to a node, or when its node has no +- ``id`` yet. ``False`` otherwise. + +#### `attach_to_pool` + +```python +attach_to_pool(self, pool: CoreNodeBase | str) -> None +``` + +Attach the number this attribute holds to a number pool when the node is next saved. + +The pool then tracks the number as one a user provided, without allocating a new one. + +**Args:** + +- `pool`: The number pool node, or its ID. + +**Raises:** + +- `ValueError`: If the attribute holds no number, or if ``pool`` is a node that is not a resource pool or +has not been saved. + +#### `detach_from_pool` + +```python +detach_from_pool(self) -> None +``` + +Detach the number this attribute holds from its number pool when the node is next saved. + +The attribute keeps its number, and the pool stops tracking it. + #### `is_from_pool_attribute` ```python @@ -53,6 +106,9 @@ is_from_pool_attribute(self) -> bool Check whether this attribute's value is sourced from a resource pool. +This reflects only the pool requests made through this object. For a node fetched from the server, query it +with ``property=True`` and read the attribute's ``source``, which is the number pool tracking the value. + **Returns:** - True if the attribute value is a resource pool node or was explicitly allocated from a pool. @@ -70,5 +126,5 @@ A pool-backed attribute is unresolved when: - its value is a pool node object (the pool reference itself, not an allocated scalar), or - its value is None and the from_pool allocation dict is set. -An attribute whose _from_pool dict is set but whose value has already been populated -with the allocated scalar (e.g. after a prior save) is considered resolved. +An attribute whose _from_pool dict is set but which holds a number, either the one allocated by a +prior save or one given to attach to the pool, is considered resolved. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/inline_peers.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/inline_peers.mdx new file mode 100644 index 000000000..a2116c1c6 --- /dev/null +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/inline_peers.mdx @@ -0,0 +1,18 @@ +--- +title: inline_peers +sidebarTitle: inline_peers +--- + +# `infrahub_sdk.node.inline_peers` + +Relationships whose mutation takes the fields of their peers instead of a reference to one. + +## Functions + +### `peers_are_inline_input` + +```python +peers_are_inline_input(schema: RelationshipSchemaAPI) -> bool +``` + +Return whether a mutation takes this relationship's peers as fields instead of as references. 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 ef2823562..1d7c5555d 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 @@ -162,6 +162,8 @@ The node must have been saved (have an id) before calling this method. - `FeatureNotSupportedError`: If this node doesn't inherit from CoreFileObject. - `ValueError`: If the node hasn't been saved yet, file not found, or ``skip_if_unchanged=True`` was passed without a ``dest``. +- `FieldNotLoadedError`: ``skip_if_unchanged=True``, ``dest`` is an existing file, and the node + was queried with ``only`` without fetching ``checksum``. - `AuthenticationError`: If authentication fails. **Examples:** @@ -213,6 +215,7 @@ shapes as \:func\:`infrahub_sdk.file_handler.sha1_of_source`. - `FeatureNotSupportedError`: Node is not a ``CoreFileObject``. - `ValueError`: Node has no server-side checksum yet (unsaved or file never attached). +- `FieldNotLoadedError`: The node was queried with ``only`` without fetching ``checksum``. #### `upload_if_changed` @@ -253,6 +256,7 @@ from ``source.name`` when ``name`` is omitted. - `FeatureNotSupportedError`: Node is not a ``CoreFileObject``. - `ValueError`: ``source`` is ``bytes`` or ``BinaryIO`` and no ``name`` was supplied. +- `FieldNotLoadedError`: The node was queried with ``only`` without fetching ``checksum``. #### `delete` @@ -467,6 +471,30 @@ Fetch all nodes that were allocated for the pool and a given resource. - `ValueError`: If the node is not a resource pool. +#### `get_pool_allocations` + +```python +get_pool_allocations(self, resource: InfrahubNode) -> list[PoolAllocation] +``` + +Fetch the resources the pool holds for a given resource, with how each one came to be held. + +Requires Infrahub 1.12 or later. Unlike :meth:`get_pool_allocated_resources`, it does not fetch the +allocated nodes. + +**Args:** + +- `resource`: The resource from which the nodes were allocated\: the pool itself, or one of +its ranges or prefixes. + +**Returns:** + +- list\[PoolAllocation]: One entry per resource the pool holds. + +**Raises:** + +- `ValueError`: If the node is not a resource pool. + #### `get_pool_resources_utilization` ```python @@ -510,6 +538,8 @@ rather than bulk traversal. - `ValueError`: If a component does not match an attribute or relationship, or if a relationship hop targets a non cardinality-one relationship. +- `FieldNotLoadedError`: This node or a related node was queried with ``only`` and a field +the key reads was not fetched. **Examples:** @@ -691,6 +721,8 @@ The node must have been saved (have an id) before calling this method. - `FeatureNotSupportedError`: If this node doesn't inherit from CoreFileObject. - `ValueError`: If the node hasn't been saved yet, file not found, or ``skip_if_unchanged=True`` was passed without a ``dest``. +- `FieldNotLoadedError`: ``skip_if_unchanged=True``, ``dest`` is an existing file, and the node + was queried with ``only`` without fetching ``checksum``. - `AuthenticationError`: If authentication fails. **Examples:** @@ -742,6 +774,7 @@ shapes as \:func\:`infrahub_sdk.file_handler.sha1_of_source`. - `FeatureNotSupportedError`: Node is not a ``CoreFileObject``. - `ValueError`: Node has no server-side checksum yet (unsaved or file never attached). +- `FieldNotLoadedError`: The node was queried with ``only`` without fetching ``checksum``. #### `upload_if_changed` @@ -782,6 +815,7 @@ from ``source.name`` when ``name`` is omitted. - `FeatureNotSupportedError`: Node is not a ``CoreFileObject``. - `ValueError`: ``source`` is ``bytes`` or ``BinaryIO`` and no ``name`` was supplied. +- `FieldNotLoadedError`: The node was queried with ``only`` without fetching ``checksum``. #### `delete` @@ -996,6 +1030,30 @@ Fetch all nodes that were allocated for the pool and a given resource. - `ValueError`: If the node is not a resource pool. +#### `get_pool_allocations` + +```python +get_pool_allocations(self, resource: InfrahubNodeSync) -> list[PoolAllocation] +``` + +Fetch the resources the pool holds for a given resource, with how each one came to be held. + +Requires Infrahub 1.12 or later. Unlike :meth:`get_pool_allocated_resources`, it does not fetch the +allocated nodes. + +**Args:** + +- `resource`: The resource from which the nodes were allocated\: the pool itself, or one +of its ranges or prefixes. + +**Returns:** + +- list\[PoolAllocation]: One entry per resource the pool holds. + +**Raises:** + +- `ValueError`: If the node is not a resource pool. + #### `get_pool_resources_utilization` ```python @@ -1039,6 +1097,8 @@ rather than bulk traversal. - `ValueError`: If a component does not match an attribute or relationship, or if a relationship hop targets a non cardinality-one relationship. +- `FieldNotLoadedError`: This node or a related node was queried with ``only`` and a field +the key reads was not fetched. **Examples:** @@ -1085,6 +1145,18 @@ fetched via ``client.get(...)``. A server-side change to the file between the fetch and the call will not be detected unless the caller re-fetches the node first. +### `PoolAllocation` + +One resource a pool holds, as returned by :meth:`InfrahubNode.get_pool_allocations` and its sync twin. + +**Methods:** + +#### `from_graphql` + +```python +from_graphql(cls, data: dict[str, Any]) -> PoolAllocation +``` + ### `InfrahubNodeBase` Base class for :class:`InfrahubNode` and :class:`InfrahubNodeSync`. @@ -1135,7 +1207,12 @@ cardinality-one related node (``parent``), an attribute of that related node **Returns:** - The resolved value, or ``None`` when any path component cannot be -- resolved (for example, an unfetched related node not present in the store). +- resolved (for example, a related node that is not in the store). + +**Raises:** + +- `FieldNotLoadedError`: This node or the related node was queried with ``only`` and a field +the path reads was not fetched. #### `get_human_friendly_id` @@ -1147,7 +1224,9 @@ Compute the human-friendly ID for this node from its schema. The HFID is composed of the values addressed by the schema's ``human_friendly_id`` paths. When any component cannot be resolved, the HFID is -considered invalid and ``None`` is returned. +considered invalid and ``None`` is returned. A node from a query that named ``hfid`` in +``only`` returns the HFID the server stores instead, or ``None`` when that value is empty, +until a field the HFID is built from is changed on the node. **Returns:** 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 a79c1ad9a..df05d432c 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 @@ -14,7 +14,7 @@ Base class for representing a related node in a relationship. A ``RelatedNodeBase`` is the peer end of a cardinality-one relationship. It carries the lightweight identification of the peer (``id``, ``hfid``, ``typename``, ...) along with the relationship-edge properties (``source``, ``owner``, ``is_protected``, ...). -The full peer node is fetched lazily through :meth:`RelatedNode.fetch` / +The peer node is fetched lazily through :meth:`RelatedNode.fetch` / :meth:`RelatedNodeSync.fetch`. **Attributes:** @@ -22,6 +22,8 @@ The full peer node is fetched lazily through :meth:`RelatedNode.fetch` / - `schema`: The schema describing the relationship. - `name`: The name of the relationship slot on the parent node. - `updated_at`: ISO-8601 timestamp of the most recent edge update. +- `is_loaded`: True when the node's data contained the relationship, it was assigned since, +it does not belong to a node, or the node has no ``id`` yet. **Methods:** @@ -41,6 +43,10 @@ non-None id, so .id and .peer.id are NOT interchangeable. - str | None: The peer node ID, or ``None`` when neither the peer nor an ID is set. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `hfid` ```python @@ -49,9 +55,17 @@ hfid(self) -> list[Any] | None Return the human-friendly ID of the related node. +When the cached peer was fetched with ``only`` and has no HFID, because the response did not +contain one and the fields it carries do not give one, the HFID the relationship was loaded with +is returned instead. + **Returns:** -- list\[Any] | None: The peer HFID as a list of components, or ``None`` when not set. +- list[Any] | None: The peer HFID as a list of components, or ``None`` when not set. + +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. #### `hfid_str` @@ -62,13 +76,19 @@ hfid_str(self) -> str | None Return the human-friendly ID of the related node as a separator-joined string. The returned string includes the kind prefix and is therefore suitable as a key -for the client store. +for the client store. When the cached peer was fetched with ``only`` and has no HFID, +because the response did not contain one and the fields it carries do not give one, the +string is built from the HFID the relationship was loaded with. **Returns:** - str | None: The peer HFID joined with the HFID separator, or ``None`` when - unavailable (no resolved peer or missing HFID). +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `is_resource_pool` ```python @@ -93,6 +113,23 @@ Return whether this related node has an identifier. - ``True`` when an ID or HFID is known and the relationship can be referenced. +#### `is_loaded` + +```python +is_loaded(self) -> bool +``` + +Return whether this relationship holds the peer from its node's data or from an assignment. + +Never raises and sends no request. + +**Returns:** + +- ``True`` when the data the node was built from contained the relationship, even +- without a peer, when the relationship was assigned since, when it does not belong to a +- node (such as a peer inside a cardinality-many relationship), or when its node has no +- ``id`` yet. ``False`` otherwise. + #### `display_label` ```python @@ -105,6 +142,10 @@ Return the human-readable label of the related node. - str | None: The peer display label, or ``None`` when not provided. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `typename` ```python @@ -117,6 +158,10 @@ Return the GraphQL ``__typename`` of the related node. - str | None: The peer typename, or ``None`` when not provided. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `kind` ```python @@ -129,6 +174,10 @@ Return the schema kind of the related node. - str | None: The peer schema kind, or ``None`` when not provided. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `is_from_profile` ```python @@ -173,10 +222,10 @@ around :meth:`get`. #### `fetch` ```python -fetch(self, timeout: int | None = None, priority: Priority | None = None) -> None +fetch(self, timeout: int | None = None, priority: Priority | None = None, only: list[str] | None = None) -> None ``` -Fetch the full peer node from the backend and cache it on this object. +Fetch the peer with its default fields, or only the fields named in ``only``, and cache it on this object. After ``fetch()`` completes, attribute and relationship access on the peer is available via :attr:`peer` or :meth:`get`. @@ -186,9 +235,16 @@ 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. +- `only`: The only attributes and relationships to fetch on the peer, which also gets +its ``id``, ``display_label`` and ``__typename``. Reading a field of the peer that was not fetched +raises FieldNotLoadedError. **Raises:** +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. +- `FieldNotFoundError`: A name in ``only`` is not a field of the peer's kind; raised before the peer is +queried. +- `TypeError`: ``only`` is a string instead of a list of field names; raised before the peer is queried. - `Error`: If neither ``id`` nor ``typename`` is set on this related node. #### `peer` @@ -206,6 +262,10 @@ fetched or stored in the client store. - The resolved peer node. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `get` ```python @@ -230,6 +290,7 @@ and ``.id`` diverge. **Raises:** +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. - `ValueError`: If neither an ID nor an HFID is available to look up the peer. ### `RelatedNodeSync` @@ -246,10 +307,10 @@ store, and :attr:`peer` is a convenience accessor around :meth:`get`. #### `fetch` ```python -fetch(self, timeout: int | None = None, priority: Priority | None = None) -> None +fetch(self, timeout: int | None = None, priority: Priority | None = None, only: list[str] | None = None) -> None ``` -Fetch the full peer node from the backend and cache it on this object. +Fetch the peer with its default fields, or only the fields named in ``only``, and cache it on this object. After ``fetch()`` completes, attribute and relationship access on the peer is available via :attr:`peer` or :meth:`get`. @@ -259,9 +320,16 @@ 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. +- `only`: The only attributes and relationships to fetch on the peer, which also gets +its ``id``, ``display_label`` and ``__typename``. Reading a field of the peer that was not fetched +raises FieldNotLoadedError. **Raises:** +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. +- `FieldNotFoundError`: A name in ``only`` is not a field of the peer's kind; raised before the peer is +queried. +- `TypeError`: ``only`` is a string instead of a list of field names; raised before the peer is queried. - `Error`: If neither ``id`` nor ``typename`` is set on this related node. #### `peer` @@ -279,6 +347,10 @@ fetched or stored in the client store. - The resolved peer node. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `get` ```python @@ -303,6 +375,7 @@ and ``.id`` diverge. **Raises:** +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. - `ValueError`: If neither an ID nor an HFID is available to look up the peer. ### `RelationshipAttribute` diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/relationship.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/relationship.mdx index ecf44643e..8a09a1461 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/relationship.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/relationship.mdx @@ -21,11 +21,57 @@ members are not loaded and editing is not allowed. - `name`: The name of the relationship slot on the parent node. - `schema`: The schema describing the relationship. - `branch`: The branch the relationship is bound to. -- `peers`: The current peer set. +- `peers`: The current peer set. Assigning a list marks the +relationship as loaded. - `initialized`: ``True`` once the manager has been populated with data. +- `is_loaded`: ``True`` once the manager has been populated with data or its peers were +assigned, when it does not belong to a node, or when the node has no ``id`` yet. **Methods:** +#### `peers` + +```python +peers(self) -> list[RelatedNode[PeerT] | RelatedNodeSync[PeerT]] +``` + +Return the peer set. + +The list returned is the one the relationship holds, so changing it in place changes the +relationship. Editing it is not supported for a relationship that takes its peers as fields +instead of references: declare the complete list on the node instead. + +**Returns:** + +- list\[RelatedNode | RelatedNodeSync]: The peers, in insertion order. + +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + +#### `peers` + +```python +peers(self, value: list[RelatedNode[PeerT] | RelatedNodeSync[PeerT]]) -> None +``` + +#### `is_loaded` + +```python +is_loaded(self) -> bool +``` + +Return whether this relationship holds the peers from its node's data or from an assignment. + +Never raises and sends no request. + +**Returns:** + +- ``True`` when the relationship is initialized (the data the node was built from +- contained it, or :meth:`fetch` ran), when a list was assigned to ``peers``, when the +- relationship does not belong to a node, or when its node has no ``id`` yet. ``False`` +- otherwise. + #### `peer_ids` ```python @@ -38,6 +84,10 @@ Return the IDs of all peers that have one. - list\[str]: The IDs of the peers, in insertion order. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `peer_hfids` ```python @@ -50,6 +100,10 @@ Return the HFIDs of all peers that have one. - list\[list\[Any]]: The HFIDs of the peers as lists of components, in insertion order. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `peer_hfids_str` ```python @@ -62,6 +116,10 @@ Return the HFIDs of all peers as separator-joined strings. - list\[str]: The HFIDs of the peers as ``Kind__part1__part2`` strings. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + #### `has_update` ```python @@ -89,6 +147,10 @@ sourced from a profile. - ``True`` when at least one peer exists and all peers are from a profile. +**Raises:** + +- `FieldNotLoadedError`: The node was queried with ``only`` and the relationship was not fetched. + ### `RelationshipManager` Asynchronous manager for a cardinality-many relationship. @@ -104,17 +166,28 @@ accessed by index via ``manager[i]``. #### `fetch` ```python -fetch(self) -> None +fetch(self, only: list[str] | None = None) -> None ``` -Populate the peer set and resolve every peer to a full node. +Populate the peer set and fetch every peer with its default fields, or only the fields named in ``only``. When the manager is not yet initialized, the parent node is re-queried with this -relationship included so the peer list can be populated. The peers are then -fetched in a parallel batch grouped by kind and stored in the client store. +relationship included so the peer list can be populated. When the parent node was +queried with ``only``, the re-query requests this relationship alone and does not +replace the parent node in the client store. The peers are then fetched in a +parallel batch grouped by kind and stored in the client store. + +**Args:** + +- `only`: The only attributes and relationships to fetch on each peer, which also +gets its ``id``, ``display_label`` and ``__typename``. Every name must be a field of every peer's +kind. Reading a field of a peer that was not fetched raises FieldNotLoadedError. **Raises:** +- `FieldNotFoundError`: A name in ``only`` is not a field of the relationship's peer kind, raised before +any request, or is missing on the kind of one of the peers, raised before any peer is queried. +- `TypeError`: ``only`` is a string instead of a list of field names; raised before any request. - `UninitializedError`: If the manager is not initialized and the parent node has no ``id``. - `Error`: If any peer is missing an ``id`` or ``typename`` and cannot be resolved. @@ -137,6 +210,7 @@ or ``hfid`` keys, plus optional relationship properties). **Raises:** +- `FeatureNotSupportedError`: If the relationship takes its peers as fields instead of references. - `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. #### `extend` @@ -157,6 +231,7 @@ formats accepted by \:meth\:`add`. **Raises:** +- `FeatureNotSupportedError`: If the relationship takes its peers as fields instead of references. - `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. #### `remove` @@ -177,6 +252,7 @@ existing \:class\:`RelatedNode`, or a dict describing the peer. **Raises:** +- `FeatureNotSupportedError`: If the relationship takes its peers as fields instead of references. - `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. - `IndexError`: If the internal peer index is inconsistent with the lookup result. @@ -196,17 +272,28 @@ accessed by index via ``manager[i]``. #### `fetch` ```python -fetch(self) -> None +fetch(self, only: list[str] | None = None) -> None ``` -Populate the peer set and resolve every peer to a full node. +Populate the peer set and fetch every peer with its default fields, or only the fields named in ``only``. When the manager is not yet initialized, the parent node is re-queried with this -relationship included so the peer list can be populated. The peers are then -fetched in a parallel batch grouped by kind and stored in the client store. +relationship included so the peer list can be populated. When the parent node was +queried with ``only``, the re-query requests this relationship alone and does not +replace the parent node in the client store. The peers are then fetched in a +parallel batch grouped by kind and stored in the client store. + +**Args:** + +- `only`: The only attributes and relationships to fetch on each peer, which also +gets its ``id``, ``display_label`` and ``__typename``. Every name must be a field of every peer's +kind. Reading a field of a peer that was not fetched raises FieldNotLoadedError. **Raises:** +- `FieldNotFoundError`: A name in ``only`` is not a field of the relationship's peer kind, raised before +any request, or is missing on the kind of one of the peers, raised before any peer is queried. +- `TypeError`: ``only`` is a string instead of a list of field names; raised before any request. - `UninitializedError`: If the manager is not initialized and the parent node has no ``id``. - `Error`: If any peer is missing an ``id`` or ``typename`` and cannot be resolved. @@ -229,6 +316,7 @@ an existing \:class\:`RelatedNodeSync`, or a dict describing the peer (with **Raises:** +- `FeatureNotSupportedError`: If the relationship takes its peers as fields instead of references. - `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. #### `extend` @@ -249,6 +337,7 @@ formats accepted by \:meth\:`add`. **Raises:** +- `FeatureNotSupportedError`: If the relationship takes its peers as fields instead of references. - `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. #### `remove` @@ -269,5 +358,6 @@ an existing \:class\:`RelatedNodeSync`, or a dict describing the peer. **Raises:** +- `FeatureNotSupportedError`: If the relationship takes its peers as fields instead of references. - `UninitializedError`: If \:meth\:`fetch` has not been called on this manager yet. - `IndexError`: If the internal peer index is inconsistent with the lookup result. diff --git a/docs/docs/python-sdk/topics/error_handling.mdx b/docs/docs/python-sdk/topics/error_handling.mdx new file mode 100644 index 000000000..1039f2d8d --- /dev/null +++ b/docs/docs/python-sdk/topics/error_handling.mdx @@ -0,0 +1,212 @@ +--- +title: Understanding error handling in the Python SDK +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Understanding error handling in the Python SDK + +## Introduction + +Every exception the SDK defines is importable from `infrahub_sdk.exceptions`. That is the supported +import path, and the only one: the modules beneath it are internal and their layout may change. A +call can still raise a built-in such as `ValueError` for an argument the SDK rejects before it builds +a request; what follows covers the exceptions the SDK defines. + +```python +from infrahub_sdk.exceptions import ApiError, GraphQLError, UniquenessViolationError +``` + +When Infrahub rejects a request, it describes the failure with a stable code from its +[error catalogue](https://docs.infrahub.app/reference/error-catalogue), an HTTP status, and a typed +payload. The SDK turns that description into an exception class of its own, with the payload's fields +as directly typed attributes, so branching on a specific failure never means matching words in a +message. + +## The hierarchy + +```text +Error every exception the SDK raises +└── ApiError a failure carrying a parsed error envelope + ├── AuthenticationError the request was rejected before it ran + └── GraphQLError a failure read from an `errors` array + ├── NodeNotFoundError + ├── BranchNotFoundError + ├── SchemaNotFoundError + ├── UniquenessViolationError + ├── UndefinedError + └── ... a class for most catalogued codes +``` + +The tree is plain: no class has more than one parent, and `AuthenticationError` and `GraphQLError` +are siblings. Anything the SDK raises without a server behind it - a timeout, an unreadable file, a +malformed query - stays under `Error` and outside `ApiError`. + +Most catalogued codes have a class; the three authentication codes deliberately do not, for the +reason below. `AuthenticationError` covers the responses the SDK rejected before the query ran, +which is usually an HTTP 401 or 403 but also a token refresh that failed on any other status. + +## Catching by branch, or by code + +| Intent | Clause | +|--------|--------| +| Any failure carrying a parsed error envelope, on either transport | `except ApiError` | +| Any GraphQL-path failure | `except GraphQLError` | +| Any request the SDK saw rejected before it ran | `except AuthenticationError` | +| One specific catalogued failure | `except UniquenessViolationError`, and so on per code | +| Anything the SDK raises | `except Error` | + +Catching the specific class is the shortest route to the payload, because its attributes are typed +exactly as the catalogue declares them and a required field needs no guard: + + + + +```python +from infrahub_sdk.exceptions import ApiError, UniquenessViolationError + +try: + await node.save() +except UniquenessViolationError as exc: + print(exc.node_kind, exc.fields) +except ApiError as exc: + print("some other failure:", exc.code) +``` + + + + +```python +from infrahub_sdk.exceptions import ApiError, UniquenessViolationError + +try: + node.save() +except UniquenessViolationError as exc: + print(exc.node_kind, exc.fields) +except ApiError as exc: + print("some other failure:", exc.code) +``` + + + + +Both clients raise the same class with the same attributes for the same failure. + +### The three authentication codes + +`AUTHENTICATION_REQUIRED`, `TOKEN_EXPIRED`, and `PERMISSION_DENIED` have no class of their own. They +are the codes a server reports for the failures it rejects a request on, so they are the ones that +routinely arrive on either transport, and each transport already has a class that existing code +depends on: + +| Arrival | Class raised | `exc.code` | +|---------|--------------|------------| +| A real 401 or 403, when the failure escapes before the query runs | `AuthenticationError` | the catalogue code | +| Inside an HTTP 200 `errors` array, when a resolver raised it | `GraphQLError` | the catalogue code | + +Any of the three can arrive either way, so the arrival path is a property of how the server happened +to fail rather than of the code. To handle one of them whichever way it arrived, catch `ApiError` and +test the code: + +```python +except ApiError as exc: + if exc.code == "TOKEN_EXPIRED": + ... +``` + +`AuthenticationError` descends from `ApiError`, so an `except ApiError` clause placed first makes any +later `except AuthenticationError` unreachable. + +## Reading a caught error + +These are readable on every `ApiError`, including one raised with no server response behind it, so +inspecting them never needs a guard for a missing attribute: + +| Attribute | Contract | +|-----------|----------| +| `code` | The catalogue code string, or `None`. Never an integer. `None` means no code was resolved: a server predating the catalogue, a REST failure, an error carrying no `extensions`, or an integer `code` on the wire, which is what a pre-catalogue server puts there. | +| `http_status` | The status the failure declares, or `None`. This is metadata about the failure, not the status the transport observed: a catalogued data error arrives as HTTP 200. Where the envelope declares none, a class that declares its own supplies it - which the generated classes do and the three lookup-miss classes below do not, since they are also raised with no server behind them. | +| `extensions` | The raw `extensions` mapping of the governing error, or `None`. | +| `errors` | The complete server error list, in the order the server sent it. Empty for a raise the SDK decided on its own. | +| `query`, `variables` | The GraphQL query and variables the failed request carried, where the exception recorded them. `None` means the request was not recorded rather than that there was none - an authentication failure is observed at the transport, which has neither to hand. | + +The payload's fields are not on the base class. Each catalogued class carries its own, typed as the +catalogue declares them. `NodeNotFoundError`, `BranchNotFoundError`, and `SchemaNotFoundError` are the +exception: the SDK also raises those three on its own, for a lookup that returned nothing and for the +REST 404 behind a missing file, so their attributes may be unpopulated. Test `exc.code is not None` to +tell a server-reported raise from an SDK one. + +Those same three predate the catalogue, so they keep the attribute names they have always had rather +than the ones the catalogue declares: + +| Class | Catalogue field | Read it as | +|-------|-----------------|------------| +| `NodeNotFoundError` | `node_kind` | `exc.node_type` | +| `BranchNotFoundError` | `branch_name` | `exc.identifier` | +| `SchemaNotFoundError` | `kind` | `exc.identifier` | + +Every generated class uses the catalogue's own field names, so only these three need the mapping. + +The raw payload stays in `exc.extensions["data"]` for anything that forwards a failure verbatim. + +## Messages + +A failure the catalogue describes carries a message naming the code and the server's own words, with +no query text: + +```text +UNIQUENESS_VIOLATION: Node of kind TestPerson already has name 'John' +``` + +Where the catalogue provides them, those words name the failing action and the resource kind, so that +detail now appears in logs and CLI output in place of the query text that used to be there. The query +itself stays readable on `exc.query`. + +A failure the catalogue does **not** describe keeps the message it has always had, query text and full +error list included. Since a current server codes every error it reports, falling back to +`UNDEFINED_ERROR` where its catalogue has no entry, `exc.code is not None` is not the test for whether +the server described a failure. `code_names_the_failure(exc.code)` is, and it is importable from +`infrahub_sdk.exceptions`. + +`UNDEFINED_ERROR` has a class like any other code, `UndefinedError`, so a failure the server could not +describe still raises a subclass of `GraphQLError` rather than `GraphQLError` itself. Match on +`except GraphQLError` or on `exc.code`, never on `type(exc) is GraphQLError`. + +Where a response carries several errors, the first one determines the class raised and is the only one +named beside the code. The complete list stays on `exc.errors`, in the order the server sent it. + +## Talking to any server version + +Any SDK version talks to any server version. Once the SDK has a decoded response in hand, reading the +catalogue out of it never raises: an unknown code, a payload that does not match what the catalogue +declares, and an envelope from a server that predates it all degrade to a class the caller can catch. +A body the SDK cannot decode as JSON at all is a separate failure and still raises `JsonDecodeError`, +before any of this runs. + +| Situation | Behaviour | +|-----------|-----------| +| A code this SDK has a class for, read from an `errors` array | That class, built from the payload the response carried | +| Any code at all, on a request the SDK saw rejected before it ran | `AuthenticationError`, with `exc.code` set | +| A code this SDK has never heard of | The generic class for the transport, with `exc.code` set to the string the server sent | +| A known code whose payload gained a field | The unknown field is ignored | +| A server predating the catalogue, or an error with no `extensions` | `exc.code` is `None`, and the message is the one that version of the SDK has always produced | +| A payload that does not match what the catalogue declares | The generic class for the transport, with the code still readable | + +Every fallback is logged at debug level on the `infrahub_sdk` logger, naming the code where the +response carried one, so an SDK meeting a newer server is diagnosable without a debugger. + +Which generic class a fallback lands on follows the transport the SDK observed, never the status the +code declares. A code read from an `errors` array raises `GraphQLError` even when it declares 401. + +## Two clauses that now catch more + +Existing `except` clauses keep catching everything they caught before. Two of them now catch more. + +`except GraphQLError` also catches node, branch, and schema lookup misses that involved no GraphQL +request at all, because those three classes are re-rooted under it. Code that relied on them escaping +such a clause should catch the specific class ahead of it, as an ordered `except` ladder already must. + +A ladder that handles one of those three specifically now sees server-reported failures arrive there +as well as the ones the SDK decides on its own. That is the point of binding a code to a class, and `exc.code is not +None` separates the two. diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 527a63b4e..57974adcc 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -25,7 +25,6 @@ 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 from .exceptions import ( - AuthenticationError, Error, GraphQLError, NodeNotFoundError, @@ -34,7 +33,10 @@ ServerNotResponsiveError, URLNotFoundError, VersionNotSupportedError, + authentication_error_from_response, + graphql_error_from_response, ) +from .exceptions.factory import token_expired_in from .graph_traversal.models import PathTraversalResult, ReachableNodesResult from .graph_traversal.query import ( PATH_TRAVERSAL_QUERY, @@ -45,16 +47,18 @@ ) from .graphql import MultipartBuilder, Mutation, Query from .node import InfrahubNode, InfrahubNodeSync +from .node._only import check_only_conflict, only_peer_relationships, only_query_fields from .object_store import ObjectStore, ObjectStoreSync from .protocols_base import CoreNode, CoreNodeSync from .queries import QUERY_USER, get_commit_update_mutation from .query_groups import InfrahubGroupContext, InfrahubGroupContextSync from .rate_limit import RateLimitRetryHandler from .retry import CONNECTION_LOST_EXCEPTIONS, RetryState, TransientRetryHandler -from .schema import InfrahubSchema, InfrahubSchemaSync, NodeSchemaAPI +from .schema import GenericSchemaAPI, InfrahubSchema, InfrahubSchemaSync, NodeSchemaAPI from .store import NodeStore, NodeStoreSync from .task.manager import InfrahubTaskManager, InfrahubTaskManagerSync from .timestamp import Timestamp +from .trace_context import trace_context_headers from .types import AsyncRequester, HTTPMethod, Order, SyncRequester from .utils import decode_json, get_user_permissions, is_valid_uuid @@ -65,6 +69,8 @@ from httpx._types import ProxyTypes from .context import RequestContext + from .node._only import OnlyPeerRelationships, OnlyQueryFields + from .schema import MainSchemaTypesAPI SchemaType = TypeVar("SchemaType", bound=CoreNode) @@ -76,6 +82,19 @@ class ProcessRelationsNode(TypedDict): related_nodes: list[InfrahubNode] +class _QueryBuilderArgs(TypedDict): + """The arguments that the default query builder and the builder for `only` share.""" + + offset: int | str | None + limit: int | str | None + filters: dict[str, Any] | None + prefetch_relationships: bool + partial_match: bool + property: bool + order: Order | None + include_metadata: bool + + class ProxyConfig(TypedDict): proxy: ProxyTypes | None mounts: Mapping[str, AsyncBaseTransport | None] | None @@ -177,17 +196,42 @@ class ProcessRelationsNodeSync(TypedDict): related_nodes: list[InfrahubNodeSync] +def _should_refresh_token(response: httpx.Response) -> bool: + """Decide whether a 401 is a stale token worth one silent refresh and retry. + + The wrapper also sees REST responses, and a proxy can answer with anything at all, so a body that + is not a JSON object carries no refresh signal rather than raising. + """ + try: + body = response.json() + except ValueError: + return False + + if not isinstance(body, dict): + return False + + return token_expired_in(body.get("errors", [])) + + +def _can_refresh_token(client: InfrahubClient | InfrahubClientSync) -> bool: + """Whether this client has a login it could refresh into a working token. + + `login(refresh=True)` returns without touching the auth header unless the client authenticates + with a username and password, so retrying an API-token client would replay the same stale token + and earn a second 401 for nothing. + """ + return client.config.password_authentication + + def handle_relogin( func: Callable[..., Coroutine[Any, Any, httpx.Response]], ) -> Callable[..., Coroutine[Any, Any, httpx.Response]]: @wraps(func) async def wrapper(client: InfrahubClient, *args: Any, **kwargs: Any) -> httpx.Response: response = await func(client, *args, **kwargs) - if response.status_code == 401: - errors = response.json().get("errors", []) - if "Expired Signature" in [error.get("message") for error in errors]: - await client.login(refresh=True) - return await func(client, *args, **kwargs) + if response.status_code == 401 and _can_refresh_token(client) and _should_refresh_token(response=response): + await client.login(refresh=True) + return await func(client, *args, **kwargs) return response return wrapper @@ -197,11 +241,9 @@ def handle_relogin_sync(func: Callable[..., httpx.Response]) -> Callable[..., ht @wraps(func) def wrapper(client: InfrahubClientSync, *args: Any, **kwargs: Any) -> httpx.Response: response = func(client, *args, **kwargs) - if response.status_code == 401: - errors = response.json().get("errors", []) - if "Expired Signature" in [error.get("message") for error in errors]: - client.login(refresh=True) - return func(client, *args, **kwargs) + if response.status_code == 401 and _can_refresh_token(client) and _should_refresh_token(response=response): + client.login(refresh=True) + return func(client, *args, **kwargs) return response return wrapper @@ -369,13 +411,14 @@ def _request_headers(self, tracker: str | None = None, priority: Priority | None return headers def _merge_request_headers(self, headers: dict | None) -> dict: - """Merge a per-request header delta over the client's current base headers. + """Merge a per-request header delta over the client's current base headers and the active trace context. 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 {}) + merged.update(trace_context_headers()) if headers: merged.update(headers) return merged @@ -617,6 +660,7 @@ async def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> SchemaType | None: ... @@ -639,6 +683,7 @@ async def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> SchemaType: ... @@ -661,6 +706,7 @@ async def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> SchemaType: ... @@ -683,6 +729,7 @@ async def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> InfrahubNode | None: ... @@ -705,6 +752,7 @@ async def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> InfrahubNode: ... @@ -727,6 +775,7 @@ async def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> InfrahubNode: ... @@ -748,8 +797,50 @@ async def get( include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, + only: list[str] | None = None, **kwargs: Any, ) -> InfrahubNode | SchemaType | None: + """Retrieve a single node of a given kind, by ID, human-friendly ID or other filters. + + Args: + kind (str): kind of the node to query + raise_when_missing (bool, optional): Raise NodeNotFoundError when no node matches, instead of returning + None. Defaults to True. + at (Timestamp, optional): Time of the query. Defaults to Now. + branch (str, optional): Name of the branch to query from. Defaults to default_branch. + timeout (int, optional): Overrides default timeout used when querying the GraphQL API. Specified in seconds. + id (str, optional): ID of the node. A value that is not a UUID is matched against the kind's default + filter when it has one. + hfid (list[str], optional): Human-friendly ID of the node. + include (list[str], optional): List of attributes or relationships to include in the query. + exclude (list[str], optional): List of attributes or relationships to exclude from the query. + populate_store (bool, optional): Flag to indicate whether to populate the store with the retrieved node. + fragment (bool, optional): Flag to use GraphQL fragments for generic schemas. + prefetch_relationships (bool, optional): Flag to indicate whether to pre-fetch related node data. + property (bool, optional): Flag to include the properties of the attributes and relationships. + 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 Get_ 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. + only (list[str], optional): Fetch only these attributes and relationships, plus `id`, `display_label` + and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not + fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. + **kwargs (Any): Additional filter criteria for the query. + + Returns: + InfrahubNode | None: The node, or None when no node matches and `raise_when_missing` is False. + + Raises: + FieldSelectionConflictError: `only` is combined with `include` or `exclude`; raised before any request. + FieldNotFoundError: A name in `only` is not a field of the kind; raised before the data query. + TypeError: `only` is a string instead of a list of field names; raised before the data query. + ValueError: No filter was given, or `hfid` was given for a generic kind or a kind without a + human-friendly ID. + NodeNotFoundError: No node matches and `raise_when_missing` is True. + IndexError: More than one node matches. + + """ + check_only_conflict(only=only, include=include, exclude=exclude) branch = branch or self.default_branch schema = await self.schema.get(kind=kind, branch=branch) if query_name is None: @@ -786,6 +877,7 @@ async def get( include_metadata=include_metadata, query_name=query_name, priority=priority, + only=only, **filters, ) @@ -798,6 +890,56 @@ async def get( return results[0] + async def _build_filters_query( + self, + schema: MainSchemaTypesAPI, + branch: str, + query_name: str, + filters: dict[str, Any], + include: list[str] | None, + exclude: list[str] | None, + fragment: bool, + prefetch_relationships: bool, + partial_match: bool, + property: bool, + order: Order | None, + include_metadata: bool, + only: list[str] | None, + ) -> tuple[Query, OnlyQueryFields | None]: + """Build the query for every page of a filters call, and, with `only`, where it requests each field. + + Raises: + FieldNotFoundError: A name in `only` is not a field of the kind or of any kind implementing it. + + """ + node = InfrahubNode(client=self, schema=schema, branch=branch) + query_args: _QueryBuilderArgs = { + # Pagination is passed as GraphQL variables so the rendered query text stays + # identical across pages and can hit the server-side query cache. + "offset": "$offset", + "limit": "$limit", + "filters": filters, + "prefetch_relationships": prefetch_relationships, + "partial_match": partial_match, + "property": property, + "order": order, + "include_metadata": include_metadata, + } + if only is None: + only_fields = None + query_data = await node.generate_query_data( + include=include, exclude=exclude, fragment=fragment, **query_args + ) + else: + implementing_schemas = ( + [await self.schema.get(kind=implementing_kind, branch=branch) for implementing_kind in schema.used_by] + if isinstance(schema, GenericSchemaAPI) + else [] + ) + only_fields = only_query_fields(only=only, schema=schema, implementing_schemas=implementing_schemas) + query_data = await node._generate_only_query_data(fields=only_fields, **query_args) + return Query(query=query_data, name=query_name, variables={"offset": int, "limit": int}), only_fields + async def _process_nodes_and_relationships( self, response: dict[str, Any], @@ -806,6 +948,7 @@ async def _process_nodes_and_relationships( prefetch_relationships: bool, include: list[str] | None, timeout: int | None = None, + only_fields: OnlyQueryFields | None = None, ) -> ProcessRelationsNode: """Processes InfrahubNode and their Relationships from the GraphQL query response. @@ -815,6 +958,9 @@ async def _process_nodes_and_relationships( branch (str): The branch name. prefetch_relationships (bool): Flag to indicate whether to pre-fetch relationship data. timeout (int, optional): Overrides default timeout used when querying the GraphQL API. Specified in seconds. + only_fields (OnlyQueryFields, optional): Where the query with `only` requested each field. When given, + every node and every peer built with it records the `only` list, and peers are built only for the + relationships named in `only` when `prefetch_relationships` is set. Returns: ProcessRelationsNodeSync: A TypedDict containing two lists: @@ -824,12 +970,27 @@ async def _process_nodes_and_relationships( """ nodes: list[InfrahubNode] = [] related_nodes: list[InfrahubNode] = [] + only_plans: dict[str, OnlyPeerRelationships] = {} for item in response.get(schema_kind, {}).get("edges", []): node = await InfrahubNode.from_graphql(client=self, branch=branch, data=item, timeout=timeout) nodes.append(node) - if prefetch_relationships or (include and any(rel in include for rel in node._relationships)): + if only_fields is not None: + kind = node.get_kind() + if kind not in only_plans: + only_plans[kind] = only_peer_relationships( + fields=only_fields, kind=kind, prefetch_relationships=prefetch_relationships + ) + peers: dict[str, list[InfrahubNode]] = {} + for rel_name in only_plans[kind].peer_markers: + peers[rel_name] = [] + await node._process_relationships( + node_data=item, branch=branch, related_nodes=peers[rel_name], timeout=timeout, names=[rel_name] + ) + node._mark_from_only(plan=only_plans[kind], peers=peers) + related_nodes.extend(peer for rel_peers in peers.values() for peer in rel_peers) + elif prefetch_relationships or (include and any(rel in include for rel in node._relationships)): await node._process_relationships( node_data=item, branch=branch, @@ -1091,6 +1252,7 @@ async def all( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., ) -> list[SchemaType]: ... @overload @@ -1113,6 +1275,7 @@ async def all( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., ) -> list[InfrahubNode]: ... async def all( @@ -1134,6 +1297,7 @@ async def all( include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, + only: list[str] | None = None, ) -> list[InfrahubNode] | list[SchemaType]: """Retrieve all nodes of a given kind. @@ -1147,6 +1311,9 @@ async def all( limit (int, optional): The limit for pagination. include (list[str], optional): List of attributes or relationships to include in the query. exclude (list[str], optional): List of attributes or relationships to exclude from the query. + only (list[str], optional): Fetch only these attributes and relationships, plus `id`, `display_label` + and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not + fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. fragment (bool, optional): Flag to use GraphQL fragments for generic schemas. prefetch_relationships (bool, optional): Flag to indicate whether to pre-fetch related node data. parallel (bool, optional): Whether to use parallel processing for the query. @@ -1159,6 +1326,11 @@ async def all( Returns: list[InfrahubNode]: List of Nodes + Raises: + FieldSelectionConflictError: `only` is combined with `include` or `exclude`; raised before any request. + FieldNotFoundError: A name in `only` is not a field of the kind; raised before the data query. + TypeError: `only` is a string instead of a list of field names; raised before the data query. + """ if query_name is None: query_name = f"All_{get_kind_as_string(kind=kind)}" @@ -1180,6 +1352,7 @@ async def all( include_metadata=include_metadata, query_name=query_name, priority=priority, + only=only, ) @overload @@ -1203,6 +1376,7 @@ async def filters( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> list[SchemaType]: ... @@ -1227,6 +1401,7 @@ async def filters( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> list[InfrahubNode]: ... @@ -1250,6 +1425,7 @@ async def filters( # noqa: C901 include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, + only: list[str] | None = None, **kwargs: Any, ) -> list[InfrahubNode] | list[SchemaType]: """Retrieve nodes of a given kind based on provided filters. @@ -1264,6 +1440,9 @@ async def filters( # noqa: C901 limit (int, optional): The limit for pagination. include (list[str], optional): List of attributes or relationships to include in the query. exclude (list[str], optional): List of attributes or relationships to exclude from the query. + only (list[str], optional): Fetch only these attributes and relationships, plus `id`, `display_label` + and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not + fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. fragment (bool, optional): Flag to use GraphQL fragments for generic schemas. prefetch_relationships (bool, optional): Flag to indicate whether to pre-fetch related node data. partial_match (bool, optional): Allow partial match of filter criteria for the query. @@ -1278,7 +1457,13 @@ async def filters( # noqa: C901 Returns: list[InfrahubNode]: List of Nodes that match the given filters. + Raises: + FieldSelectionConflictError: `only` is combined with `include` or `exclude`; raised before any request. + FieldNotFoundError: A name in `only` is not a field of the kind; raised before the data query. + TypeError: `only` is a string instead of a list of field names; raised before the data query. + """ + check_only_conflict(only=only, include=include, exclude=exclude) branch = branch or self.default_branch schema = await self.schema.get(kind=kind, branch=branch) if query_name is None: @@ -1289,11 +1474,10 @@ 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", + query, only_fields = await self._build_filters_query( + schema=schema, + branch=branch, + query_name=query_name, filters=filters, include=include, exclude=exclude, @@ -1303,8 +1487,8 @@ async def filters( # noqa: C901 property=property, order=order, include_metadata=include_metadata, + only=only, ) - 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]: @@ -1330,6 +1514,7 @@ async def process_page(page_offset: int, page_number: int) -> tuple[dict, Proces prefetch_relationships=prefetch_relationships, timeout=timeout, include=include, + only_fields=only_fields, ) return response, process_result @@ -1436,7 +1621,7 @@ async def execute_graphql( AuthenticationError: If the server returns a 401 or 403 response. URLNotFoundError: If the server returns a 404 response. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's branch_name = branch_name or self.default_branch url = self._graphql_url(branch_name=branch_name, at=at) @@ -1457,10 +1642,7 @@ async def execute_graphql( resp.raise_for_status() except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = decode_json(response=exc.response) - errors = response.get("errors", []) - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc if exc.response.status_code == 404: raise URLNotFoundError(url=url) from exc # Any other status falls through: the body is expected to carry a GraphQL error envelope. @@ -1476,7 +1658,7 @@ async def execute_graphql( state=retry_state, url=url, reason=self._retry_handler.describe_graphql_errors(errors) ) continue - raise GraphQLError(errors=errors, query=query, variables=variables) + raise graphql_error_from_response(errors=errors, query=query, variables=variables) return response["data"] @@ -1518,8 +1700,10 @@ async def _execute_graphql_with_file( Raises: GraphQLError: When the GraphQL response contains errors. + AuthenticationError: If the server returns a 401 or 403 response. + httpx.HTTPStatusError: For any other non-2xx response. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's branch_name = branch_name or self.default_branch url = self._graphql_url(branch_name=branch_name) @@ -1548,7 +1732,12 @@ async def _execute_graphql_with_file( retry_state=retry_state, ) - resp.raise_for_status() + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + if exc.response.status_code in {401, 403}: + raise authentication_error_from_response(response=exc.response) from exc + raise response = decode_json(response=resp) if "errors" in response: @@ -1560,7 +1749,7 @@ async def _execute_graphql_with_file( state=retry_state, url=url, reason=self._retry_handler.describe_graphql_errors(errors) ) continue - raise GraphQLError(errors=errors, query=query, variables=variables) + raise graphql_error_from_response(errors=errors, query=query, variables=variables) return response["data"] @@ -1699,6 +1888,49 @@ async def _get(self, url: str, headers: dict | None = None, timeout: int | None timeout=timeout or self.default_timeout, ) + @handle_relogin + async def _put( + self, url: str, payload: dict, headers: dict | None = None, timeout: int | None = None + ) -> httpx.Response: + """Execute a HTTP PUT with HTTPX. + + Raises: + ServerNotReachableError: If we are not able to connect to the server. + ServerNotResponsiveError: If the server didn't respond before the timeout expired. + + """ + await self.login() + + headers = self._merge_request_headers(headers) + + return await self._request( + url=url, + method=HTTPMethod.PUT, + headers=headers, + timeout=timeout or self.default_timeout, + payload=payload, + ) + + @handle_relogin + async def _delete(self, url: str, headers: dict | None = None, timeout: int | None = None) -> httpx.Response: + """Execute a HTTP DELETE with HTTPX. + + Raises: + ServerNotReachableError: If we are not able to connect to the server. + ServerNotResponsiveError: If the server didn't respond before the timeout expired. + + """ + await self.login() + + headers = self._merge_request_headers(headers) + + return await self._request( + url=url, + method=HTTPMethod.DELETE, + headers=headers, + timeout=timeout or self.default_timeout, + ) + @asynccontextmanager async def _get_streaming( self, @@ -1859,10 +2091,7 @@ async def login(self, refresh: bool = False) -> None: # If we got a 401 while trying to refresh a token we must restart the authentication process # Other status codes indicate other errors if exc.response.status_code != 401: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc url = f"{self.address}/api/auth/login" response = await self._request( @@ -2316,10 +2545,13 @@ async def __aexit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - if exc_type is None and self.mode == InfrahubClientMode.TRACKING: - await self.group_context.update_group() - - self.mode = InfrahubClientMode.DEFAULT + try: + if exc_type is None and self.mode == InfrahubClientMode.TRACKING: + await self.group_context.update_group() + finally: + # update_group() can raise, and leaving the client in tracking mode would + # silently enroll every later save into the stale context. + self.mode = InfrahubClientMode.DEFAULT async def convert_object_type( self, @@ -2484,7 +2716,7 @@ def execute_graphql( AuthenticationError: If the server returns a 401 or 403 response. URLNotFoundError: If the server returns a 404 response. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's branch_name = branch_name or self.default_branch url = self._graphql_url(branch_name=branch_name, at=at) @@ -2505,10 +2737,7 @@ def execute_graphql( resp.raise_for_status() except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = decode_json(response=exc.response) - errors = response.get("errors", []) - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc if exc.response.status_code == 404: raise URLNotFoundError(url=url) from exc # Any other status falls through: the body is expected to carry a GraphQL error envelope. @@ -2524,7 +2753,7 @@ def execute_graphql( state=retry_state, url=url, reason=self._retry_handler.describe_graphql_errors(errors) ) continue - raise GraphQLError(errors=errors, query=query, variables=variables) + raise graphql_error_from_response(errors=errors, query=query, variables=variables) return response["data"] @@ -2566,8 +2795,10 @@ def _execute_graphql_with_file( Raises: GraphQLError: When the GraphQL response contains errors. + AuthenticationError: If the server returns a 401 or 403 response. + httpx.HTTPStatusError: For any other non-2xx response. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's branch_name = branch_name or self.default_branch url = self._graphql_url(branch_name=branch_name) @@ -2596,7 +2827,12 @@ def _execute_graphql_with_file( retry_state=retry_state, ) - resp.raise_for_status() + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + if exc.response.status_code in {401, 403}: + raise authentication_error_from_response(response=exc.response) from exc + raise response = decode_json(response=resp) if "errors" in response: @@ -2608,7 +2844,7 @@ def _execute_graphql_with_file( state=retry_state, url=url, reason=self._retry_handler.describe_graphql_errors(errors) ) continue - raise GraphQLError(errors=errors, query=query, variables=variables) + raise graphql_error_from_response(errors=errors, query=query, variables=variables) return response["data"] @@ -2945,6 +3181,7 @@ def all( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., ) -> list[SchemaTypeSync]: ... @overload @@ -2967,6 +3204,7 @@ def all( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., ) -> list[InfrahubNodeSync]: ... def all( @@ -2988,6 +3226,7 @@ def all( include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, + only: list[str] | None = None, ) -> list[InfrahubNodeSync] | list[SchemaTypeSync]: """Retrieve all nodes of a given kind. @@ -3001,6 +3240,9 @@ def all( limit (int, optional): The limit for pagination. include (list[str], optional): List of attributes or relationships to include in the query. exclude (list[str], optional): List of attributes or relationships to exclude from the query. + only (list[str], optional): Fetch only these attributes and relationships, plus `id`, `display_label` + and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not + fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. fragment (bool, optional): Flag to use GraphQL fragments for generic schemas. prefetch_relationships (bool, optional): Flag to indicate whether to pre-fetch related node data. parallel (bool, optional): Whether to use parallel processing for the query. @@ -3013,6 +3255,11 @@ def all( Returns: list[InfrahubNodeSync]: List of Nodes + Raises: + FieldSelectionConflictError: `only` is combined with `include` or `exclude`; raised before any request. + FieldNotFoundError: A name in `only` is not a field of the kind; raised before the data query. + TypeError: `only` is a string instead of a list of field names; raised before the data query. + """ if query_name is None: query_name = f"All_{get_kind_as_string(kind=kind)}" @@ -3034,8 +3281,57 @@ def all( include_metadata=include_metadata, query_name=query_name, priority=priority, + only=only, ) + def _build_filters_query( + self, + schema: MainSchemaTypesAPI, + branch: str, + query_name: str, + filters: dict[str, Any], + include: list[str] | None, + exclude: list[str] | None, + fragment: bool, + prefetch_relationships: bool, + partial_match: bool, + property: bool, + order: Order | None, + include_metadata: bool, + only: list[str] | None, + ) -> tuple[Query, OnlyQueryFields | None]: + """Build the query for every page of a filters call, and, with `only`, where it requests each field. + + Raises: + FieldNotFoundError: A name in `only` is not a field of the kind or of any kind implementing it. + + """ + node = InfrahubNodeSync(client=self, schema=schema, branch=branch) + query_args: _QueryBuilderArgs = { + # Pagination is passed as GraphQL variables so the rendered query text stays + # identical across pages and can hit the server-side query cache. + "offset": "$offset", + "limit": "$limit", + "filters": filters, + "prefetch_relationships": prefetch_relationships, + "partial_match": partial_match, + "property": property, + "order": order, + "include_metadata": include_metadata, + } + if only is None: + only_fields = None + query_data = node.generate_query_data(include=include, exclude=exclude, fragment=fragment, **query_args) + else: + implementing_schemas = ( + [self.schema.get(kind=implementing_kind, branch=branch) for implementing_kind in schema.used_by] + if isinstance(schema, GenericSchemaAPI) + else [] + ) + only_fields = only_query_fields(only=only, schema=schema, implementing_schemas=implementing_schemas) + query_data = node._generate_only_query_data(fields=only_fields, **query_args) + return Query(query=query_data, name=query_name, variables={"offset": int, "limit": int}), only_fields + def _process_nodes_and_relationships( self, response: dict[str, Any], @@ -3044,6 +3340,7 @@ def _process_nodes_and_relationships( prefetch_relationships: bool, include: list[str] | None, timeout: int | None = None, + only_fields: OnlyQueryFields | None = None, ) -> ProcessRelationsNodeSync: """Processes InfrahubNodeSync and their Relationships from the GraphQL query response. @@ -3053,6 +3350,9 @@ def _process_nodes_and_relationships( branch (str): The branch name. prefetch_relationships (bool): Flag to indicate whether to pre-fetch relationship data. timeout (int, optional): Overrides default timeout used when querying the GraphQL API. Specified in seconds. + only_fields (OnlyQueryFields, optional): Where the query with `only` requested each field. When given, + every node and every peer built with it records the `only` list, and peers are built only for the + relationships named in `only` when `prefetch_relationships` is set. Returns: ProcessRelationsNodeSync: A TypedDict containing two lists: @@ -3062,12 +3362,27 @@ def _process_nodes_and_relationships( """ nodes: list[InfrahubNodeSync] = [] related_nodes: list[InfrahubNodeSync] = [] + only_plans: dict[str, OnlyPeerRelationships] = {} for item in response.get(schema_kind, {}).get("edges", []): node = InfrahubNodeSync.from_graphql(client=self, branch=branch, data=item, timeout=timeout) nodes.append(node) - if prefetch_relationships or (include and any(rel in include for rel in node._relationships)): + if only_fields is not None: + kind = node.get_kind() + if kind not in only_plans: + only_plans[kind] = only_peer_relationships( + fields=only_fields, kind=kind, prefetch_relationships=prefetch_relationships + ) + peers: dict[str, list[InfrahubNodeSync]] = {} + for rel_name in only_plans[kind].peer_markers: + peers[rel_name] = [] + node._process_relationships( + node_data=item, branch=branch, related_nodes=peers[rel_name], timeout=timeout, names=[rel_name] + ) + node._mark_from_only(plan=only_plans[kind], peers=peers) + related_nodes.extend(peer for rel_peers in peers.values() for peer in rel_peers) + elif prefetch_relationships or (include and any(rel in include for rel in node._relationships)): node._process_relationships( node_data=item, branch=branch, @@ -3098,6 +3413,7 @@ def filters( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> list[SchemaTypeSync]: ... @@ -3122,6 +3438,7 @@ def filters( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> list[InfrahubNodeSync]: ... @@ -3145,6 +3462,7 @@ def filters( # noqa: C901 include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, + only: list[str] | None = None, **kwargs: Any, ) -> list[InfrahubNodeSync] | list[SchemaTypeSync]: """Retrieve nodes of a given kind based on provided filters. @@ -3159,6 +3477,9 @@ def filters( # noqa: C901 limit (int, optional): The limit for pagination. include (list[str], optional): List of attributes or relationships to include in the query. exclude (list[str], optional): List of attributes or relationships to exclude from the query. + only (list[str], optional): Fetch only these attributes and relationships, plus `id`, `display_label` + and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not + fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. fragment (bool, optional): Flag to use GraphQL fragments for generic schemas. prefetch_relationships (bool, optional): Flag to indicate whether to pre-fetch related node data. partial_match (bool, optional): Allow partial match of filter criteria for the query. @@ -3173,7 +3494,13 @@ def filters( # noqa: C901 Returns: list[InfrahubNodeSync]: List of Nodes that match the given filters. + Raises: + FieldSelectionConflictError: `only` is combined with `include` or `exclude`; raised before any request. + FieldNotFoundError: A name in `only` is not a field of the kind; raised before the data query. + TypeError: `only` is a string instead of a list of field names; raised before the data query. + """ + check_only_conflict(only=only, include=include, exclude=exclude) branch = branch or self.default_branch schema = self.schema.get(kind=kind, branch=branch) if query_name is None: @@ -3184,11 +3511,10 @@ 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", + query, only_fields = self._build_filters_query( + schema=schema, + branch=branch, + query_name=query_name, filters=filters, include=include, exclude=exclude, @@ -3198,8 +3524,8 @@ def filters( # noqa: C901 property=property, order=order, include_metadata=include_metadata, + only=only, ) - 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]: @@ -3225,6 +3551,7 @@ def process_page(page_offset: int, page_number: int) -> tuple[dict, ProcessRelat prefetch_relationships=prefetch_relationships, timeout=timeout, include=include, + only_fields=only_fields, ) return response, process_result @@ -3302,6 +3629,7 @@ def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> SchemaTypeSync | None: ... @@ -3324,6 +3652,7 @@ def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> SchemaTypeSync: ... @@ -3346,6 +3675,7 @@ def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> SchemaTypeSync: ... @@ -3368,6 +3698,7 @@ def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> InfrahubNodeSync | None: ... @@ -3390,6 +3721,7 @@ def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> InfrahubNodeSync: ... @@ -3412,6 +3744,7 @@ def get( include_metadata: bool = ..., query_name: str | None = ..., priority: Priority | None = ..., + only: list[str] | None = ..., **kwargs: Any, ) -> InfrahubNodeSync: ... @@ -3433,8 +3766,50 @@ def get( include_metadata: bool = False, query_name: str | None = None, priority: Priority | None = None, + only: list[str] | None = None, **kwargs: Any, ) -> InfrahubNodeSync | SchemaTypeSync | None: + """Retrieve a single node of a given kind, by ID, human-friendly ID or other filters. + + Args: + kind (str): kind of the node to query + raise_when_missing (bool, optional): Raise NodeNotFoundError when no node matches, instead of returning + None. Defaults to True. + at (Timestamp, optional): Time of the query. Defaults to Now. + branch (str, optional): Name of the branch to query from. Defaults to default_branch. + timeout (int, optional): Overrides default timeout used when querying the GraphQL API. Specified in seconds. + id (str, optional): ID of the node. A value that is not a UUID is matched against the kind's default + filter when it has one. + hfid (list[str], optional): Human-friendly ID of the node. + include (list[str], optional): List of attributes or relationships to include in the query. + exclude (list[str], optional): List of attributes or relationships to exclude from the query. + populate_store (bool, optional): Flag to indicate whether to populate the store with the retrieved node. + fragment (bool, optional): Flag to use GraphQL fragments for generic schemas. + prefetch_relationships (bool, optional): Flag to indicate whether to pre-fetch related node data. + property (bool, optional): Flag to include the properties of the attributes and relationships. + 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 Get_ 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. + only (list[str], optional): Fetch only these attributes and relationships, plus `id`, `display_label` + and `__typename`; name `hfid` to fetch the HFID the server stores. Reading a field that was not + fetched raises FieldNotLoadedError. Cannot be combined with `include` or `exclude`. + **kwargs (Any): Additional filter criteria for the query. + + Returns: + InfrahubNodeSync | None: The node, or None when no node matches and `raise_when_missing` is False. + + Raises: + FieldSelectionConflictError: `only` is combined with `include` or `exclude`; raised before any request. + FieldNotFoundError: A name in `only` is not a field of the kind; raised before the data query. + TypeError: `only` is a string instead of a list of field names; raised before the data query. + ValueError: No filter was given, or `hfid` was given for a generic kind or a kind without a + human-friendly ID. + NodeNotFoundError: No node matches and `raise_when_missing` is True. + IndexError: More than one node matches. + + """ + check_only_conflict(only=only, include=include, exclude=exclude) branch = branch or self.default_branch schema = self.schema.get(kind=kind, branch=branch) if query_name is None: @@ -3471,6 +3846,7 @@ def get( include_metadata=include_metadata, query_name=query_name, priority=priority, + only=only, **filters, ) @@ -3896,6 +4272,47 @@ def _get(self, url: str, headers: dict | None = None, timeout: int | None = None timeout=timeout or self.default_timeout, ) + @handle_relogin_sync + def _put(self, url: str, payload: dict, headers: dict | None = None, timeout: int | None = None) -> httpx.Response: + """Execute a HTTP PUT with HTTPX. + + Raises: + ServerNotReachableError: If we are not able to connect to the server. + ServerNotResponsiveError: If the server didn't respond before the timeout expired. + + """ + self.login() + + headers = self._merge_request_headers(headers) + + return self._request( + url=url, + method=HTTPMethod.PUT, + headers=headers, + timeout=timeout or self.default_timeout, + payload=payload, + ) + + @handle_relogin_sync + def _delete(self, url: str, headers: dict | None = None, timeout: int | None = None) -> httpx.Response: + """Execute a HTTP DELETE with HTTPX. + + Raises: + ServerNotReachableError: If we are not able to connect to the server. + ServerNotResponsiveError: If the server didn't respond before the timeout expired. + + """ + self.login() + + headers = self._merge_request_headers(headers) + + return self._request( + url=url, + method=HTTPMethod.DELETE, + headers=headers, + timeout=timeout or self.default_timeout, + ) + @contextmanager def _get_streaming( self, @@ -4088,10 +4505,7 @@ def login(self, refresh: bool = False) -> None: # If we got a 401 while trying to refresh a token we must restart the authentication process # Other status codes indicate other errors if exc.response.status_code != 401: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc url = f"{self.address}/api/auth/login" response = self._request( @@ -4120,10 +4534,13 @@ def __exit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - if exc_type is None and self.mode == InfrahubClientMode.TRACKING: - self.group_context.update_group() - - self.mode = InfrahubClientMode.DEFAULT + try: + if exc_type is None and self.mode == InfrahubClientMode.TRACKING: + self.group_context.update_group() + finally: + # update_group() can raise, and leaving the client in tracking mode would + # silently enroll every later save into the stale context. + self.mode = InfrahubClientMode.DEFAULT def convert_object_type( self, diff --git a/infrahub_sdk/constants.py b/infrahub_sdk/constants.py index 785445d9a..03e6302ac 100644 --- a/infrahub_sdk/constants.py +++ b/infrahub_sdk/constants.py @@ -9,6 +9,13 @@ class InfrahubClientMode(str, enum.Enum): # IDEMPOTENT = "idempotent" +class PoolAllocationProvenance(str, enum.Enum): + """Whether a number pool allocated a number or a user provided it and attached it to the pool.""" + + ALLOCATED = "allocated" + PROVIDED = "provided" + + class Priority(str, enum.Enum): """Request priority emitted as the ``X-Priority`` header. diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index 9d947ff1f..94c1f4a80 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -38,14 +38,22 @@ from ..ctl.task import app as task_app from ..ctl.telemetry import app as telemetry_app from ..ctl.transform import list_transforms +from ..ctl.troubleshooting import app as troubleshooting_app from ..ctl.utils import ( catch_exception, execute_graphql_query, load_yamlfile_from_disk_and_exit, parse_cli_vars, + print_graphql_query_errors, ) from ..ctl.validate import app as validate_app -from ..exceptions import GraphQLError, ModuleImportError +from ..exceptions import ( + BranchNotFoundError, + GraphQLError, + ModuleImportError, + NodeNotFoundError, + SchemaNotFoundError, +) from ..graphql.query_renderer import render_query from ..node import InfrahubNode from ..protocols_generator.generator import CodeGenerator @@ -74,6 +82,7 @@ app.add_typer(task_app, name="task") app.add_typer(marketplace_app, name="marketplace") app.add_typer(telemetry_app, name="telemetry") +app.add_typer(troubleshooting_app, name="troubleshooting") app.command(name="dump")(dump) app.command(name="load")(load) @@ -234,16 +243,13 @@ async def _run_transform( except QueryNotFoundError as exc: console.print(f"[red]Unable to find query : {exc}") raise typer.Exit(1) from exc + except (NodeNotFoundError, BranchNotFoundError, SchemaNotFoundError): + # A lookup miss descends from GraphQLError but carries no server error list, so the clause + # below would render an empty one in place of its message. The shared handler renders these. + raise except GraphQLError as exc: - console.print(f"[red]{len(exc.errors)} error(s) occurred while executing the query") - for error in exc.errors: - if isinstance(error, dict) and "message" in error and "locations" in error: - console.print(f"[yellow] - Message: {error['message']}") # type: ignore[typeddict-item] - console.print(f"[yellow] Location: {error['locations']}") # type: ignore[typeddict-item] - elif isinstance(error, str) and "Branch:" in error: - console.print(f"[yellow] - {error}") - console.print("[yellow] you can specify a different branch with --branch") - raise typer.Abort from None + print_graphql_query_errors(console=console, exc=exc) + raise typer.Exit(1) from None if inspect.iscoroutinefunction(transform_func): output = await transform_func(response) diff --git a/infrahub_sdk/ctl/object/create.py b/infrahub_sdk/ctl/object/create.py index 296b4d000..cf74fbda3 100644 --- a/infrahub_sdk/ctl/object/create.py +++ b/infrahub_sdk/ctl/object/create.py @@ -12,6 +12,7 @@ from rich.console import Console from infrahub_sdk.ctl.client import initialize_client +from infrahub_sdk.ctl.object.inline_peers import raise_if_set_names_inline_peers_by_reference from infrahub_sdk.ctl.object.utils import prepare_relationship_data from infrahub_sdk.ctl.parameters import CONFIG_PARAM from infrahub_sdk.ctl.parsers import parse_set_args, validate_set_fields @@ -68,6 +69,7 @@ async def create_command( data = parse_set_args(set_args) schema = await client.schema.get(kind=kind, branch=branch) validate_set_fields(data, schema.attribute_names, schema.relationship_names) + raise_if_set_names_inline_peers_by_reference(data, schema) data = prepare_relationship_data(data, schema) node = await client.create(kind=kind, data=data, branch=branch) diff --git a/infrahub_sdk/ctl/object/inline_peers.py b/infrahub_sdk/ctl/object/inline_peers.py new file mode 100644 index 000000000..9c7a41dd9 --- /dev/null +++ b/infrahub_sdk/ctl/object/inline_peers.py @@ -0,0 +1,78 @@ +"""Refusals for a relationship whose mutation takes the fields of its peers, for ``--set``.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from infrahub_sdk.exceptions import FeatureNotSupportedError +from infrahub_sdk.node.inline_peers import INLINE_INPUT_REASON, PEER_REFERENCE_KEYS, peers_are_inline_input + +if TYPE_CHECKING: + from infrahub_sdk.schema import MainSchemaTypesAPI + + +def _names_peers_by_reference(value: Any) -> bool: + """Return whether a ``--set`` value points at existing peers instead of carrying their fields. + + ``--set`` reads a value between brackets as JSON, so a list of objects carries the fields of + each peer. Every other shape names peers: a UUID, an HFID, a list of either, or an object + holding one of the keys a peer is named by. + """ + if not isinstance(value, list): + return True + return any(not isinstance(item, dict) or item.keys() & PEER_REFERENCE_KEYS for item in value) + + +def _inline_peer_relationships(data: dict[str, Any], schema: MainSchemaTypesAPI) -> list[tuple[str, Any, str]]: + """Return the key, the value and the peer kind of every relationship whose mutation takes the peers' fields.""" + found = [] + for key, value in data.items(): + rel_schema = schema.get_relationship_or_none(key) + if rel_schema and peers_are_inline_input(rel_schema): + found.append((key, value, rel_schema.peer)) + return found + + +def raise_if_set_names_inline_peers_by_reference(data: dict[str, Any], schema: MainSchemaTypesAPI) -> None: + """Refuse a reference to a peer whose fields the mutation takes, before the node is created. + + The fields themselves are what the mutation takes, so a list of objects goes through. + + Args: + data: Parsed data from ``--set`` arguments. + schema: Schema for the kind being created. + + Raises: + FeatureNotSupportedError: If such a relationship is given a reference. + + """ + for key, value, peer in _inline_peer_relationships(data, schema): + if _names_peers_by_reference(value): + raise FeatureNotSupportedError( + f"'{key}' cannot be set with --set to a reference, because " + f"{INLINE_INPUT_REASON.format(peer=peer)}. Pass the fields of every {peer} instead, " + f"as a JSON list of objects." + ) + + +def raise_if_set_edits_inline_peers(data: dict[str, Any], schema: MainSchemaTypesAPI) -> None: + """Refuse every value for a relationship whose mutation takes the peers' fields, before any change is applied. + + An update sends the peers the node was fetched with, so a value given here never reaches Infrahub, + whether it names the peers or carries their fields. + + Args: + data: Parsed data from ``--set`` arguments. + schema: Schema for the kind being updated. + + Raises: + FeatureNotSupportedError: If a key names such a relationship. + + """ + for key, _value, peer in _inline_peer_relationships(data, schema): + raise FeatureNotSupportedError( + f"'{key}' cannot be changed with --set, because {INLINE_INPUT_REASON.format(peer=peer)}. " + f"To set the complete list, run infrahubctl object create with it, which upserts the node. " + f"To add, change or delete one, run infrahubctl object create, update or delete on the " + f"{peer} node itself." + ) diff --git a/infrahub_sdk/ctl/object/update.py b/infrahub_sdk/ctl/object/update.py index cb4c79d7f..10255f622 100644 --- a/infrahub_sdk/ctl/object/update.py +++ b/infrahub_sdk/ctl/object/update.py @@ -13,6 +13,7 @@ from rich.console import Console from infrahub_sdk.ctl.client import initialize_client +from infrahub_sdk.ctl.object.inline_peers import raise_if_set_edits_inline_peers from infrahub_sdk.ctl.object.utils import prepare_relationship_data, resolve_node from infrahub_sdk.ctl.parameters import CONFIG_PARAM from infrahub_sdk.ctl.parsers import parse_set_args, validate_set_fields @@ -24,6 +25,7 @@ if TYPE_CHECKING: from infrahub_sdk import InfrahubClient from infrahub_sdk.node import InfrahubNode + from infrahub_sdk.node.relationship import RelationshipManager from infrahub_sdk.schema import MainSchemaTypesAPI console = Console() @@ -104,6 +106,7 @@ async def _update_with_set_args( attr_names = schema.attribute_names rel_names = schema.relationship_names validate_set_fields(data, attr_names, rel_names) + raise_if_set_edits_inline_peers(data, schema) node = await resolve_node(client, kind, identifier, schema=schema, branch=branch) @@ -205,7 +208,7 @@ def _apply_relationship( return # Cardinality many: access the RelationshipManager from internal storage - many_data: dict[str, object] = getattr(node, "_relationship_cardinality_many_data", {}) + many_data: dict[str, RelationshipManager] = getattr(node, "_relationship_cardinality_many_data", {}) rel_manager = many_data.get(key) if rel_manager is None or not hasattr(rel_manager, "peers"): return diff --git a/infrahub_sdk/ctl/troubleshooting.py b/infrahub_sdk/ctl/troubleshooting.py new file mode 100644 index 000000000..ad54b4caa --- /dev/null +++ b/infrahub_sdk/ctl/troubleshooting.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +import typer +from rich.console import Console +from rich.markup import escape +from rich.table import Table + +from ..async_typer import AsyncTyper +from .client import initialize_client +from .parameters import CONFIG_PARAM +from .utils import catch_exception + +if TYPE_CHECKING: + import httpx + +app = AsyncTyper() +trace_sample_ratio_app = AsyncTyper(help="Read, set or clear the temporary trace sampling ratio.") +app.add_typer(trace_sample_ratio_app, name="trace-sample-ratio") + +console = Console() + +TRACE_SAMPLE_RATIO_PATH = "/api/troubleshooting/trace-sample-ratio" +MAX_DURATION_SECONDS = 86400 + +STATE_FIELDS = { + "enabled": "Tracing enabled", + "default_ratio": "Default ratio", + "temporary_ratio": "Temporary ratio", + "expires_at": "Expires at (UTC)", + "ratio_in_force": "Ratio in force", +} + + +def _require_finite_ratio(value: float) -> float: + # A range check passes `nan`, because every comparison with it is false. + if not math.isfinite(value): + raise typer.BadParameter("must be a number from 0 to 1") + return value + + +def _print_state(response: httpx.Response) -> None: + if response.is_error: + _print_server_errors(response) + raise typer.Exit(code=1) + + state: dict[str, Any] = response.json() + table = Table(show_header=False) + for field, label in STATE_FIELDS.items(): + value = state.get(field) + table.add_row(label, "" if value is None else escape(str(value))) + console.print(table) + + +def _print_server_errors(response: httpx.Response) -> None: + try: + errors = response.json().get("errors") or [] + except ValueError: + errors = [] + messages = [error["message"] for error in errors if isinstance(error, dict) and "message" in error] + if not messages: + response.raise_for_status() + for message in messages: + console.print(f"[red]{escape(message)}") + + +@trace_sample_ratio_app.command(name="get") +@catch_exception(console=console) +async def get_trace_sample_ratio(_: str = CONFIG_PARAM) -> None: + """Show the trace sampling ratio of the API server that answers.""" + client = initialize_client() + response = await client._get(url=f"{client.address}{TRACE_SAMPLE_RATIO_PATH}") + _print_state(response) + + +@trace_sample_ratio_app.command(name="set") +@catch_exception(console=console) +async def set_trace_sample_ratio( + ratio: float = typer.Option( + ..., min=0, max=1, callback=_require_finite_ratio, help="Share of new root traces to record" + ), + duration: int = typer.Option(..., min=1, max=MAX_DURATION_SECONDS, help="Number of seconds the ratio applies"), + _: str = CONFIG_PARAM, +) -> None: + """Set a temporary trace sampling ratio on every API server and task worker.""" + client = initialize_client() + response = await client._put( + url=f"{client.address}{TRACE_SAMPLE_RATIO_PATH}", + payload={"ratio": ratio, "duration_seconds": duration}, + ) + _print_state(response) + + +@trace_sample_ratio_app.command(name="clear") +@catch_exception(console=console) +async def clear_trace_sample_ratio(_: str = CONFIG_PARAM) -> None: + """Clear the temporary trace sampling ratio, so every process uses its default ratio again.""" + client = initialize_client() + response = await client._delete(url=f"{client.address}{TRACE_SAMPLE_RATIO_PATH}") + _print_state(response) diff --git a/infrahub_sdk/ctl/utils.py b/infrahub_sdk/ctl/utils.py index 3898b7d90..953f62a80 100644 --- a/infrahub_sdk/ctl/utils.py +++ b/infrahub_sdk/ctl/utils.py @@ -3,7 +3,7 @@ import inspect import logging import traceback -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Sequence from functools import wraps from pathlib import Path from typing import TYPE_CHECKING, Any, NoReturn, TypeVar @@ -13,9 +13,12 @@ from rich.console import Console from rich.logging import RichHandler from rich.markup import escape +from rich.table import Table from ..exceptions import ( + ApiError, AuthenticationError, + BranchNotFoundError, Error, FileNotValidError, GraphQLError, @@ -25,7 +28,9 @@ SchemaNotFoundError, ServerNotReachableError, ServerNotResponsiveError, + TrackingGroupCleanupError, ValidationError, + code_names_the_failure, ) from ..graphql.query_renderer import render_query from ..yaml import YamlFile @@ -52,27 +57,61 @@ def init_logging(debug: bool = False) -> None: def handle_exception(exc: Exception, console: Console, exit_code: int) -> NoReturn: - """Handle exception in a different fashion based on its type.""" + """Handle exception in a different fashion based on its type. + + Two orderings are load-bearing, and both are here rather than in the branches that depend on + them. The described-code branch comes first because it is the only one that can name the failure + the way the server did, and every class-keyed branch below would either mislabel it or drop the + code. The lookup misses come ahead of `GraphQLError`, which they now descend from and which would + otherwise claim them and render an empty server error list in place of their message. + + Every message reaching the console is escaped: a server's own words routinely contain + brackets (a branch name, an identifier) and rich would read those as a style tag and delete them. + """ if isinstance(exc, typer.Exit): raise exc + if isinstance(exc, ApiError) and code_names_the_failure(exc.code): + if exc.errors: + # The server's errors carry the path naming the operation that failed, which the coded + # line has nowhere to put, so they render the detail and the code just names the failure. + console.print(f"[red]{escape(exc.code)}") + print_graphql_errors(console=console, errors=exc.errors, raw_entry_without_path=False) + else: + console.print(f"[red]{escape(str(exc))}") + raise typer.Exit(code=exit_code) if isinstance(exc, AuthenticationError): - console.print(f"[red]Authentication failure: {exc!s}") + console.print(f"[red]Authentication failure: {escape(str(exc))}") raise typer.Exit(code=exit_code) if isinstance(exc, (ServerNotReachableError, ServerNotResponsiveError)): - console.print(f"[red]{exc!s}") + console.print(f"[red]{escape(str(exc))}") raise typer.Exit(code=exit_code) if isinstance(exc, HTTPError): - console.print(f"[red]HTTP communication failure: {exc!s} on {exc.request.method} to {exc.request.url}") + console.print( + f"[red]HTTP communication failure: {escape(str(exc))} " + f"on {escape(str(exc.request.method))} to {escape(str(exc.request.url))}" + ) + raise typer.Exit(code=exit_code) + if isinstance( + exc, + ( + SchemaNotFoundError, + NodeNotFoundError, + BranchNotFoundError, + ResourceNotDefinedError, + GraphQLQueryError, + ), + ): + console.print(f"[red]Error: {escape(str(exc))}") raise typer.Exit(code=exit_code) if isinstance(exc, GraphQLError): - print_graphql_errors(console=console, errors=exc.errors) + print_graphql_errors(console=console, errors=exc.errors, fallback=str(exc)) raise typer.Exit(code=exit_code) - if isinstance(exc, (SchemaNotFoundError, NodeNotFoundError, ResourceNotDefinedError, GraphQLQueryError)): - console.print(f"[red]Error: {exc!s}") + if isinstance(exc, TrackingGroupCleanupError): + print_tracking_group_failures(console=console, failures=exc.failures) raise typer.Exit(code=exit_code) - console.print(f"[red]Error: {exc!s}") - console.print(traceback.format_exc()) + console.print(f"[red]Error: {escape(str(exc))}") + console.print(escape(traceback.format_exc())) raise typer.Exit(code=exit_code) @@ -137,15 +176,68 @@ def execute_graphql_query( return response -def print_graphql_errors(console: Console, errors: list) -> None: - if not isinstance(errors, list): - console.print(f"[red]{escape(str(errors))}") +def print_graphql_errors( + console: Console, + errors: Sequence[dict[str, Any]], + fallback: str | None = None, + raw_entry_without_path: bool = True, +) -> None: + """Render the server's errors, degrading to `fallback` when there is nothing to render. + + An envelope whose entries did not match the declared shape leaves `errors` empty, and exiting + non-zero with no output at all would tell the user nothing. + + `raw_entry_without_path` decides what an entry carrying no path renders as. By default the whole + decoded entry prints, because a validation error carries `locations` instead of a path and those + coordinates are the useful part. A caller that has already printed a line naming the failure + passes `False`, so the rest read as the server's sentences rather than as decoded dicts. + """ + if not errors: + if fallback: + console.print(f"[red]{escape(fallback)}") + return for error in errors: - if isinstance(error, dict) and "message" in error and "path" in error: - console.print(f"[red]{escape(str(error['path']))} {escape(str(error['message']))}") - else: + # An explicit null path is as good as an absent one; keying on the key alone renders "None" + # in front of the message. + path = error.get("path") if isinstance(error, dict) else None + if isinstance(error, dict) and "message" in error and path is not None: + console.print(f"[red]{escape(str(path))} {escape(str(error['message']))}") + elif raw_entry_without_path or not isinstance(error, dict): console.print(f"[red]{escape(str(error))}") + else: + console.print(f"[red]{escape(str(error.get('message', error)))}") + + +def print_graphql_query_errors(console: Console, exc: GraphQLError) -> None: + """Render the failure of a GraphQL query the CLI ran on the user's behalf. + + The branch hint is keyed on the server's message rather than on the entry's Python type, because + the exception's `errors` are dicts by construction and a bare string never reaches here. + """ + if not exc.errors: + console.print(f"[red]{escape(str(exc))}") + return + + console.print(f"[red]{len(exc.errors)} error(s) occurred while executing the query") + for error in exc.errors: + message = str(error.get("message", error)) + console.print(f"[yellow] - Message: {escape(message)}") + if "locations" in error: + console.print(f"[yellow] Location: {escape(str(error['locations']))}") + if "Branch:" in message: + console.print("[yellow] you can specify a different branch with --branch") + + +def print_tracking_group_failures(console: Console, failures: dict[str, str]) -> None: + table = Table(title="Unused tracking group members that could not be deleted") + table.add_column("Node ID") + table.add_column("Reason") + for node_id, reason in failures.items(): + table.add_row(escape(node_id), escape(reason)) + + console.print(table) + console.print("[yellow]These nodes remain members of the tracking group and will be retried on the next run.") def parse_cli_vars(variables: list[str] | None) -> dict[str, str]: @@ -218,11 +310,11 @@ def display_object_validate_format_success(file: ObjectFile, console: Console) - def display_object_validate_format_error(file: ObjectFile, error: ValidationError, console: Console) -> None: if file.multiple_documents: - console.print(f"[red] File '{file.location}' [{file.document_position}] is not valid!") + console.print(f"[red] File '{escape(str(file.location))}' [{file.document_position}] is not valid!") else: - console.print(f"[red] File '{file.location}' is not valid!") + console.print(f"[red] File '{escape(str(file.location))}' is not valid!") if error.messages: for message in error.messages: - console.print(f"[red] {message}") + console.print(f"[red] {escape(message)}") else: - console.print(f"[red] {error.message}") + console.print(f"[red] {escape(error.message or '')}") diff --git a/infrahub_sdk/ctl/validate.py b/infrahub_sdk/ctl/validate.py index 8b03b332e..8ea37822b 100644 --- a/infrahub_sdk/ctl/validate.py +++ b/infrahub_sdk/ctl/validate.py @@ -11,7 +11,7 @@ from ..async_typer import AsyncTyper 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 ..ctl.utils import catch_exception, find_graphql_query, parse_cli_vars, print_graphql_query_errors from ..exceptions import GraphQLError from ..schema import validate_schema as validate_schema_offline from ..utils import write_to_file @@ -86,14 +86,7 @@ def validate_graphql( variables=variables_dict, ) except GraphQLError as exc: - console.print(f"[red]{len(exc.errors)} error(s) occurred while executing the query") - for error in exc.errors: - if isinstance(error, dict) and "message" in error and "locations" in error: - console.print(f"[yellow] - Message: {error['message']}") - console.print(f"[yellow] Location: {error['locations']}") - elif isinstance(error, str) and "Branch:" in error: - console.print(f"[yellow] - {error}") - console.print("[yellow] you can specify a different branch with --branch") + print_graphql_query_errors(console=console, exc=exc) sys.exit(1) console.print("[green] Query executed successfully.") diff --git a/infrahub_sdk/exceptions.py b/infrahub_sdk/exceptions.py deleted file mode 100644 index 02111b9ac..000000000 --- a/infrahub_sdk/exceptions.py +++ /dev/null @@ -1,264 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - - -class Error(Exception): - def __init__(self, message: str | None = None) -> None: - self.message = message - super().__init__(self.message) - - -class JsonDecodeError(Error): - def __init__(self, message: str | None = None, content: str | None = None, url: str | None = None) -> None: - self.message = message - self.content = content - self.url = url - if not self.message and self.url: - self.message = f"Unable to decode response as JSON data from {self.url}" - if self.content: - self.message += f". Server response: {self.content}" - super().__init__(self.message) - - -class RateLimitError(Error): - """Raised when a request keeps receiving HTTP 429 past the configured retry budget.""" - - def __init__( - self, - url: str, - attempts: int, - retry_after: float | None = None, - message: str | None = None, - ) -> None: - self.url = url - self.attempts = attempts - self.retry_after = retry_after - if message is None: - message = f"Request to {url} was rate-limited (HTTP 429) after {attempts} attempt(s)." - super().__init__(message) - - -class ServerNotReachableError(Error): - def __init__(self, address: str, message: str | None = None) -> None: - self.address = address - self.message = message or f"Unable to connect to '{address}'." - super().__init__(self.message) - - -class ServerNotResponsiveError(Error): - def __init__(self, url: str, timeout: int | None = None, message: str | None = None) -> None: - self.url = url - self.timeout = timeout - self.message = message or f"Unable to read from '{url}'." - if timeout: - self.message += f" (timeout: {timeout} sec)" - super().__init__(self.message) - - -class GraphQLError(Error): - def __init__(self, errors: list[dict[str, Any]], query: str | None = None, variables: dict | None = None) -> None: - self.query = query - self.variables = variables - self.errors = errors - self.message = f"An error occurred while executing the GraphQL Query {self.query}, {self.errors}" - super().__init__(self.message) - - -class VersionNotSupportedError(Error): - """Raised when a feature is used against an Infrahub server version that does not support it.""" - - def __init__(self, feature: str, required_version: str) -> None: - self.feature = feature - self.required_version = required_version - self.message = f"{feature} requires Infrahub {required_version} or later." - super().__init__(self.message) - - -class BranchNotFoundError(Error): - def __init__(self, identifier: str, message: str | None = None) -> None: - self.identifier = identifier - self.message = message or f"Unable to find the branch '{identifier}' in the Database." - super().__init__(self.message) - - -class SchemaNotFoundError(Error): - def __init__(self, identifier: str, message: str | None = None) -> None: - self.identifier = identifier - self.message = message or f"Unable to find the schema '{identifier}'." - super().__init__(self.message) - - -class ModuleImportError(Error): - def __init__(self, message: str | None = None) -> None: - self.message = message or "Unable to import the module" - super().__init__(self.message) - - -class NodeNotFoundError(Error): - def __init__( - self, - identifier: Mapping[str, list[str]], - message: str = "Unable to find the node in the database.", - branch_name: str | None = None, - node_type: str | None = None, - ) -> None: - self.node_type = node_type or "unknown" - self.identifier = identifier - self.branch_name = branch_name - - self.message = message - super().__init__(self.message) - - def __str__(self) -> str: - return f""" - {self.message} - {self.branch_name} | {self.node_type} | {self.identifier} - """ - - -class NodeInvalidError(NodeNotFoundError): - pass - - -class NodeNotSavedError(Error): - """Raised when an operation requires a node that has been saved (has an id) but it has not.""" - - def __init__(self, message: str | None = None) -> None: - self.message = message or "The node has not been saved yet and does not have an id." - super().__init__(self.message) - - -class ResourceNotDefinedError(Error): - """Raised when trying to access a resource that hasn't been defined.""" - - def __init__(self, message: str | None = None) -> None: - self.message = message or "The requested resource was not found" - super().__init__(self.message) - - -class InfrahubCheckNotFoundError(Error): - def __init__(self, name: str, message: str | None = None) -> None: - self.message = message or f"The requested InfrahubCheck '{name}' was not found." - super().__init__(self.message) - - -class InfrahubTransformNotFoundError(Error): - def __init__(self, name: str, message: str | None = None) -> None: - self.message = message or f"The requested InfrahubTransform '{name}' was not found." - super().__init__(self.message) - - -class ValidationError(Error): - def __init__(self, identifier: str, message: str | None = None, messages: list[str] | None = None) -> None: - self.identifier = identifier - self.message = message - self.messages = messages - if not messages and not message: - self.message = f"Validation Error for {self.identifier}" - super().__init__(self.message) - - def __str__(self) -> str: - if self.messages: - return f"{self.identifier}: {', '.join(self.messages)}" - return f"{self.identifier}: {self.message}" - - -class ObjectValidationError(Error): - def __init__(self, position: list[int | str], message: str) -> None: - self.position = position - self.message = message - super().__init__(self.message) - - def __str__(self) -> str: - return f"{'.'.join(str(p) for p in self.position)}: {self.message}" - - -class AuthenticationError(Error): - def __init__(self, message: str | None = None) -> None: - self.message = message or "Authentication Error, unable to execute the query." - super().__init__(self.message) - - -class URLNotFoundError(Error): - def __init__(self, url: str) -> None: - self.message = f"`{url}` not found." - super().__init__(self.message) - - -class FeatureNotSupportedError(Error): - """Raised when trying to use a method on a node that doesn't support it.""" - - -class UninitializedError(Error): - """Raised when an object requires an initialization step before use.""" - - -class InvalidResponseError(Error): - """Raised when an object requires an initialization step before use.""" - - -class RepositoryFileNotFoundError(Error): - def __init__(self, file_path: str, message: str | None = None) -> None: - self.file_path = file_path - self.message = message or f"File '{file_path}' does not exist." - super().__init__(self.message) - - -class FileNotValidError(Error): - def __init__(self, name: str, message: str = "") -> None: - self.message = message or f"Cannot parse '{name}' content." - super().__init__(self.message) - - -class TimestampFormatError(Error): - def __init__(self, message: str | None = None) -> None: - self.message = message or "Invalid timestamp format" - super().__init__(self.message) - - -class GraphQLQueryError(Error): - """Base class for errors raised during GraphQL query rendering (fragment resolution).""" - - -class QuerySyntaxError(GraphQLQueryError): - def __init__(self, syntax_error: str) -> None: - self.message = f"GraphQL syntax error: {syntax_error}" - super().__init__(self.message) - - -class FragmentNotFoundError(GraphQLQueryError): - def __init__(self, fragment_name: str, query_file: str | None = None, message: str | None = None) -> None: - self.fragment_name = fragment_name - self.query_file = query_file - if message: - self.message = message - elif query_file: - self.message = f"Fragment '{fragment_name}' not found (referenced in '{query_file}')." - else: - self.message = f"Fragment '{fragment_name}' not found." - super().__init__(self.message) - - -class DuplicateFragmentError(GraphQLQueryError): - def __init__(self, fragment_name: str, message: str | None = None) -> None: - self.fragment_name = fragment_name - self.message = ( - message or f"Fragment '{fragment_name}' is defined more than once across declared fragment files." - ) - super().__init__(self.message) - - -class CircularFragmentError(GraphQLQueryError): - def __init__(self, cycle: list[str], message: str | None = None) -> None: - self.cycle = cycle - self.message = message or f"Circular fragment dependency detected: {' -> '.join(cycle)}." - super().__init__(self.message) - - -class FragmentFileNotFoundError(GraphQLQueryError): - def __init__(self, file_path: str, message: str | None = None) -> None: - self.file_path = file_path - self.message = message or f"Fragment file '{file_path}' declared in graphql_fragments does not exist." - super().__init__(self.message) diff --git a/infrahub_sdk/exceptions/__init__.py b/infrahub_sdk/exceptions/__init__.py new file mode 100644 index 000000000..3e84e8254 --- /dev/null +++ b/infrahub_sdk/exceptions/__init__.py @@ -0,0 +1,121 @@ +"""The supported import path for every SDK exception. + +The class lists below are written out rather than star-imported, so reading this file tells you what +the package exports. They have to be kept in step by hand with `base.__all__` and with the exception +classes `catalogue` generates; the tests in `tests/unit/sdk/test_exceptions_public_names.py` fail if +they drift apart, or if a class defined in `base` is left out of either. + +Only `catalogue`'s exception classes are re-exported. Its payload models, its lookup maps and its +dispatch helper are internal to the package and stay importable from the module itself. + +`__all__` is what `import *` hands a caller: the exception classes and nothing else. Without it the +wildcard also carries the `base` and `factory` submodule names, which are an artefact of the layout. +The raise-time factories and `code_names_the_failure` stay importable by name, since what an end user +catches is the classes. Nothing else is re-exported: a name here is a stability promise, so it earns +its place by having a caller rather than by being plausibly useful one day. +""" + +from .base import ( + ApiError, + AuthenticationError, + BranchNotFoundError, + CircularFragmentError, + DuplicateFragmentError, + Error, + FeatureNotSupportedError, + FieldNotFoundError, + FieldNotLoadedError, + FieldSelectionConflictError, + FileNotValidError, + FragmentFileNotFoundError, + FragmentNotFoundError, + GraphQLError, + GraphQLQueryError, + InfrahubCheckNotFoundError, + InfrahubTransformNotFoundError, + InvalidResponseError, + JsonDecodeError, + ModuleImportError, + NodeInvalidError, + NodeNotFoundError, + NodeNotSavedError, + ObjectValidationError, + QuerySyntaxError, + RateLimitError, + RepositoryFileNotFoundError, + ResourceNotDefinedError, + SchemaNotFoundError, + ServerNotReachableError, + ServerNotResponsiveError, + TimestampFormatError, + TrackingGroupCleanupError, + UninitializedError, + URLNotFoundError, + ValidationError, + VersionNotSupportedError, +) +from .base import code_names_the_failure as code_names_the_failure +from .catalogue import ( + AttributeConstraintViolationError, + AttributeInvalidTypeError, + AttributeRequiredError, + BranchAlreadyMergedError, + BranchNeedsRebaseError, + MergeInProgressError, + MergeRecoveryRequiredError, + UndefinedError, + UniquenessViolationError, + WorkerTimeoutError, +) +from .factory import authentication_error_from_response as authentication_error_from_response +from .factory import graphql_error_from_response as graphql_error_from_response + +__all__ = [ + "ApiError", + "AttributeConstraintViolationError", + "AttributeInvalidTypeError", + "AttributeRequiredError", + "AuthenticationError", + "BranchAlreadyMergedError", + "BranchNeedsRebaseError", + "BranchNotFoundError", + "CircularFragmentError", + "DuplicateFragmentError", + "Error", + "FeatureNotSupportedError", + "FieldNotFoundError", + "FieldNotLoadedError", + "FieldSelectionConflictError", + "FileNotValidError", + "FragmentFileNotFoundError", + "FragmentNotFoundError", + "GraphQLError", + "GraphQLQueryError", + "InfrahubCheckNotFoundError", + "InfrahubTransformNotFoundError", + "InvalidResponseError", + "JsonDecodeError", + "MergeInProgressError", + "MergeRecoveryRequiredError", + "ModuleImportError", + "NodeInvalidError", + "NodeNotFoundError", + "NodeNotSavedError", + "ObjectValidationError", + "QuerySyntaxError", + "RateLimitError", + "RepositoryFileNotFoundError", + "ResourceNotDefinedError", + "SchemaNotFoundError", + "ServerNotReachableError", + "ServerNotResponsiveError", + "TimestampFormatError", + "TrackingGroupCleanupError", + "URLNotFoundError", + "UndefinedError", + "UninitializedError", + "UniquenessViolationError", + "ValidationError", + "VersionNotSupportedError", + "WorkerTimeoutError", +] diff --git a/infrahub_sdk/exceptions/base.py b/infrahub_sdk/exceptions/base.py new file mode 100644 index 000000000..656cd069e --- /dev/null +++ b/infrahub_sdk/exceptions/base.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, ClassVar, Protocol, TypeGuard + +from typing_extensions import Self + +# The code the server reports where its own catalogue has no entry for the failure. +UNDEFINED_ERROR_CODE = "UNDEFINED_ERROR" + +__all__ = [ + "ApiError", + "AuthenticationError", + "BranchNotFoundError", + "CircularFragmentError", + "DuplicateFragmentError", + "Error", + "FeatureNotSupportedError", + "FieldNotFoundError", + "FieldNotLoadedError", + "FieldSelectionConflictError", + "FileNotValidError", + "FragmentFileNotFoundError", + "FragmentNotFoundError", + "GraphQLError", + "GraphQLQueryError", + "InfrahubCheckNotFoundError", + "InfrahubTransformNotFoundError", + "InvalidResponseError", + "JsonDecodeError", + "ModuleImportError", + "NodeInvalidError", + "NodeNotFoundError", + "NodeNotSavedError", + "ObjectValidationError", + "QuerySyntaxError", + "RateLimitError", + "RepositoryFileNotFoundError", + "ResourceNotDefinedError", + "SchemaNotFoundError", + "ServerNotReachableError", + "ServerNotResponsiveError", + "TimestampFormatError", + "TrackingGroupCleanupError", + "URLNotFoundError", + "UninitializedError", + "ValidationError", + "VersionNotSupportedError", +] + + +def code_names_the_failure(code: str | None) -> TypeGuard[str]: + """Whether a catalogue code tells the reader something the server's message does not. + + The server codes every error it reports, falling back to `UNDEFINED_ERROR` wherever its own + catalogue has no entry, so "carries a code" is not the same question as "was described". That + fallback names nothing, and letting it displace the query text and the later errors the way a + described code does would lose detail and gain none. It stays readable on `exc.code` either way. + """ + return code is not None and code != UNDEFINED_ERROR_CODE + + +class Error(Exception): + def __init__(self, message: str | None = None) -> None: + self.message = message + super().__init__(self.message) + + +class JsonDecodeError(Error): + def __init__(self, message: str | None = None, content: str | None = None, url: str | None = None) -> None: + self.message = message + self.content = content + self.url = url + if not self.message and self.url: + self.message = f"Unable to decode response as JSON data from {self.url}" + if self.content: + self.message += f". Server response: {self.content}" + super().__init__(self.message) + + +class RateLimitError(Error): + """Raised when a request keeps receiving HTTP 429 past the configured retry budget.""" + + def __init__( + self, + url: str, + attempts: int, + retry_after: float | None = None, + message: str | None = None, + ) -> None: + self.url = url + self.attempts = attempts + self.retry_after = retry_after + if message is None: + message = f"Request to {url} was rate-limited (HTTP 429) after {attempts} attempt(s)." + super().__init__(message) + + +class ServerNotReachableError(Error): + def __init__(self, address: str, message: str | None = None) -> None: + self.address = address + self.message = message or f"Unable to connect to '{address}'." + super().__init__(self.message) + + +class ServerNotResponsiveError(Error): + def __init__(self, url: str, timeout: int | None = None, message: str | None = None) -> None: + self.url = url + self.timeout = timeout + self.message = message or f"Unable to read from '{url}'." + if timeout: + self.message += f" (timeout: {timeout} sec)" + super().__init__(self.message) + + +def as_error_list(errors: Any) -> list[dict[str, Any]]: + """The subset of a server's `errors` payload that matches the declared shape. + + Anything else keeps its text in the exception message, so the attribute can stay a list of dicts + and a caller iterating it never has to guard. + """ + if not isinstance(errors, list): + return [] + return [error for error in errors if isinstance(error, dict)] + + +class ApiError(Error): + """Base for a server-reported failure carrying the parsed response envelope. + + Not every failure the server reports is an ApiError. The ones raised from a status code alone, + such as URLNotFoundError and RateLimitError, have no envelope to carry and stay under Error. + + The defaults guarantee the attributes exist even on an instance no factory ever touched. + """ + + # The catalogue code this class represents, for the classes that adopted one. Optional so that a + # subclass of an adopted class can clear it rather than inherit a code it does not represent. + CODE: ClassVar[str | None] = None + + code: str | None = None + http_status: int | None = None + extensions: dict[str, Any] | None = None + errors: Sequence[dict[str, Any]] = () + # `None` means the request that failed was not recorded on the exception, not that there was no + # query: an authentication failure is observed at the transport, where the class has neither. + query: str | None = None + variables: dict | None = None + + +def _rebuild_graphql_error(cls: type[GraphQLError], args: tuple[Any, ...], state: dict[str, Any]) -> GraphQLError: + """Reconstruct a GraphQL-path exception without replaying its constructor. + + `BaseException.__reduce__` rebuilds by calling `cls(*args)`, where `args` is the message alone. + This class takes the server's error list first, so that call files the message under `errors` and + the message a caller reads comes back as the placeholder built from it. `args` is carried + separately from the rest of the state because it lives on the exception itself rather than in + `__dict__`, and `str()` reads it. + """ + exc = cls.__new__(cls) + exc.args = args + exc.__dict__.update(state) + return exc + + +def graphql_default_message(query: str | None, errors: Any) -> str: + """The message the GraphQL path produces where the server described nothing better.""" + return f"An error occurred while executing the GraphQL Query {query}, {errors}" + + +class GraphQLError(ApiError): + def __init__( + self, + errors: list[dict[str, Any]], + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.query = query + self.variables = variables + # `is not None` rather than `or`: an empty message is a deliberate one, not a request for + # the default. + default = graphql_default_message(query=query, errors=errors) + self.message = message if message is not None else default + self.errors = as_error_list(errors) + super().__init__(self.message) + + def __reduce__(self) -> tuple[Any, ...]: + return (_rebuild_graphql_error, (type(self), self.args, self.__dict__)) + + +class TrackingGroupCleanupError(Error): + """Raised when unused members of a tracking group could not be deleted. + + Every unused member is attempted before this is raised, and the ones the server refused + are kept in the tracking group so a later run retries them. + """ + + def __init__(self, failures: dict[str, str]) -> None: + self.failures = failures + details = "; ".join(f"{node_id} ({reason})" for node_id, reason in failures.items()) + super().__init__(f"Unable to delete {len(failures)} unused member(s) of the tracking group: {details}") + + def __reduce__(self) -> tuple[type[TrackingGroupCleanupError], tuple[dict[str, str]]]: + # Rebuild from the failures rather than the formatted message, so the exception + # survives the serialization that a task orchestrator applies to a failed run. + return (self.__class__, (self.failures,)) + + +class VersionNotSupportedError(Error): + """Raised when a feature is used against an Infrahub server version that does not support it.""" + + def __init__(self, feature: str, required_version: str) -> None: + self.feature = feature + self.required_version = required_version + self.message = f"{feature} requires Infrahub {required_version} or later." + super().__init__(self.message) + + +class BranchNotFoundPayload(Protocol): + """The fields a server-reported BRANCH_NOT_FOUND carries.""" + + branch_name: str + + +class BranchNotFoundError(GraphQLError): + CODE: ClassVar[str | None] = "BRANCH_NOT_FOUND" + + def __init__(self, identifier: str, message: str | None = None) -> None: + self.identifier = identifier + super().__init__( + errors=[], + query=None, + variables=None, + message=message or f"Unable to find the branch '{identifier}' in the Database.", + ) + + @classmethod + def from_payload(cls, payload: BranchNotFoundPayload) -> Self: + """Build the exception from the payload a server-reported failure carries. + + The mapping onto `identifier` is hand-written because the attribute predates the catalogue + and keeps its own name. + """ + return cls(identifier=payload.branch_name) + + +class SchemaNotFoundPayload(Protocol): + """The fields a server-reported SCHEMA_NOT_FOUND carries.""" + + kind: str + + +class SchemaNotFoundError(GraphQLError): + CODE: ClassVar[str | None] = "SCHEMA_NOT_FOUND" + + def __init__(self, identifier: str, message: str | None = None) -> None: + self.identifier = identifier + super().__init__( + errors=[], + query=None, + variables=None, + message=message or f"Unable to find the schema '{identifier}'.", + ) + + @classmethod + def from_payload(cls, payload: SchemaNotFoundPayload) -> Self: + return cls(identifier=payload.kind) + + +class ModuleImportError(Error): + def __init__(self, message: str | None = None) -> None: + self.message = message or "Unable to import the module" + super().__init__(self.message) + + +class NodeNotFoundPayload(Protocol): + """The fields a server-reported NODE_NOT_FOUND carries.""" + + node_kind: str + identifier: str + + +class NodeNotFoundError(GraphQLError): + CODE: ClassVar[str | None] = "NODE_NOT_FOUND" + + def __init__( + self, + # A plain string is admitted because the file handler names the missing file that way, and + # the identifier is only ever interpolated into the message. + identifier: Mapping[str, list[str]] | str, + message: str = "Unable to find the node in the database.", + branch_name: str | None = None, + node_type: str | None = None, + ) -> None: + self.node_type = node_type or "unknown" + self.identifier = identifier + self.branch_name = branch_name + + super().__init__(errors=[], query=None, variables=None, message=message) + + def __str__(self) -> str: + return f""" + {self.message} + {self.branch_name} | {self.node_type} | {self.identifier} + """ + + @classmethod + def from_payload(cls, payload: NodeNotFoundPayload) -> Self: + """`node_kind` lands on `node_type`, the name this class has always used for it.""" + return cls(identifier=payload.identifier, node_type=payload.node_kind) + + +class NodeInvalidError(NodeNotFoundError): + """Raised when a node was found but is not of the kind that was asked for. + + That is not a lookup miss, so it claims no catalogue code and clears the one it would otherwise + inherit. `from_payload` is inherited and unused: nothing dispatches a payload to a class that + represents no code. + """ + + CODE: ClassVar[str | None] = None + + +class NodeNotSavedError(Error): + """Raised when an operation requires a node that has been saved (has an id) but it has not.""" + + def __init__(self, message: str | None = None) -> None: + self.message = message or "The node has not been saved yet and does not have an id." + super().__init__(self.message) + + +class ResourceNotDefinedError(Error): + """Raised when trying to access a resource that hasn't been defined.""" + + def __init__(self, message: str | None = None) -> None: + self.message = message or "The requested resource was not found" + super().__init__(self.message) + + +class InfrahubCheckNotFoundError(Error): + def __init__(self, name: str, message: str | None = None) -> None: + self.message = message or f"The requested InfrahubCheck '{name}' was not found." + super().__init__(self.message) + + +class InfrahubTransformNotFoundError(Error): + def __init__(self, name: str, message: str | None = None) -> None: + self.message = message or f"The requested InfrahubTransform '{name}' was not found." + super().__init__(self.message) + + +class ValidationError(Error): + def __init__(self, identifier: str, message: str | None = None, messages: list[str] | None = None) -> None: + self.identifier = identifier + self.message = message + self.messages = messages + if not messages and not message: + self.message = f"Validation Error for {self.identifier}" + super().__init__(self.message) + + def __str__(self) -> str: + if self.messages: + return f"{self.identifier}: {', '.join(self.messages)}" + return f"{self.identifier}: {self.message}" + + +class ObjectValidationError(Error): + def __init__(self, position: list[int | str], message: str) -> None: + self.position = position + self.message = message + super().__init__(self.message) + + def __str__(self) -> str: + return f"{'.'.join(str(p) for p in self.position)}: {self.message}" + + +class AuthenticationError(ApiError): + def __init__(self, message: str | None = None) -> None: + self.message = message or "Authentication Error, unable to execute the query." + super().__init__(self.message) + + +class URLNotFoundError(Error): + def __init__(self, url: str) -> None: + self.message = f"`{url}` not found." + super().__init__(self.message) + + +class FeatureNotSupportedError(Error): + """Raised when trying to use a method on a node that doesn't support it.""" + + +def _rebuild_error(cls: type[Error], args: tuple[Any, ...], state: dict[str, Any]) -> Error: + """Reconstruct an exception whose constructor does not take its message, without calling the constructor. + + `BaseException.__reduce__` rebuilds by calling `cls(*args)`, where `args` is the message alone. + """ + exc = cls.__new__(cls) + exc.args = args + exc.__dict__.update(state) + return exc + + +class FieldNotLoadedError(Error): + """Raised when code reads a field that a node fetched with `only` does not carry.""" + + def __init__(self, kind: str, field: str, only: Sequence[str], peer_of: str | None = None) -> None: + self.kind = kind + self.field = field + self.only = list(only) + self.peer_of = peer_of + if peer_of: + self.message = ( + f"{kind}.{field} was not fetched: the node is a peer of {peer_of}, queried with only={self.only!r}. " + "Fetch the peer again with every field you read, or check is_loaded before reading it." + ) + else: + self.message = ( + f"{kind}.{field} was not fetched: the node was queried with only={self.only!r}. " + f"Add {field!r} to only, or check is_loaded before reading it." + ) + super().__init__(self.message) + + def __reduce__(self) -> tuple[Any, ...]: + return (_rebuild_error, (type(self), self.args, self.__dict__)) + + +class FieldNotFoundError(Error): + """Raised when a name in `only` is not a field of the kind being queried.""" + + def __init__(self, kind: str, field: str) -> None: + self.kind = kind + self.field = field + self.message = f"{field!r} is not an attribute or relationship of {kind}." + super().__init__(self.message) + + def __reduce__(self) -> tuple[Any, ...]: + return (_rebuild_error, (type(self), self.args, self.__dict__)) + + +class FieldSelectionConflictError(Error): + """Raised when `only` is combined with `include` or `exclude`.""" + + def __init__(self) -> None: + self.message = "only cannot be combined with include or exclude." + super().__init__(self.message) + + def __reduce__(self) -> tuple[Any, ...]: + return (_rebuild_error, (type(self), self.args, self.__dict__)) + + +class UninitializedError(Error): + """Raised when an object requires an initialization step before use.""" + + +class InvalidResponseError(Error): + """Raised when an object requires an initialization step before use.""" + + +class RepositoryFileNotFoundError(Error): + def __init__(self, file_path: str, message: str | None = None) -> None: + self.file_path = file_path + self.message = message or f"File '{file_path}' does not exist." + super().__init__(self.message) + + +class FileNotValidError(Error): + def __init__(self, name: str, message: str = "") -> None: + self.message = message or f"Cannot parse '{name}' content." + super().__init__(self.message) + + +class TimestampFormatError(Error): + def __init__(self, message: str | None = None) -> None: + self.message = message or "Invalid timestamp format" + super().__init__(self.message) + + +class GraphQLQueryError(Error): + """Base class for errors raised during GraphQL query rendering (fragment resolution).""" + + +class QuerySyntaxError(GraphQLQueryError): + def __init__(self, syntax_error: str) -> None: + self.message = f"GraphQL syntax error: {syntax_error}" + super().__init__(self.message) + + +class FragmentNotFoundError(GraphQLQueryError): + def __init__(self, fragment_name: str, query_file: str | None = None, message: str | None = None) -> None: + self.fragment_name = fragment_name + self.query_file = query_file + if message: + self.message = message + elif query_file: + self.message = f"Fragment '{fragment_name}' not found (referenced in '{query_file}')." + else: + self.message = f"Fragment '{fragment_name}' not found." + super().__init__(self.message) + + +class DuplicateFragmentError(GraphQLQueryError): + def __init__(self, fragment_name: str, message: str | None = None) -> None: + self.fragment_name = fragment_name + self.message = ( + message or f"Fragment '{fragment_name}' is defined more than once across declared fragment files." + ) + super().__init__(self.message) + + +class CircularFragmentError(GraphQLQueryError): + def __init__(self, cycle: list[str], message: str | None = None) -> None: + self.cycle = cycle + self.message = message or f"Circular fragment dependency detected: {' -> '.join(cycle)}." + super().__init__(self.message) + + +class FragmentFileNotFoundError(GraphQLQueryError): + def __init__(self, file_path: str, message: str | None = None) -> None: + self.file_path = file_path + self.message = message or f"Fragment file '{file_path}' declared in graphql_fragments does not exist." + super().__init__(self.message) diff --git a/infrahub_sdk/exceptions/catalogue.py b/infrahub_sdk/exceptions/catalogue.py new file mode 100644 index 000000000..e9e9e943d --- /dev/null +++ b/infrahub_sdk/exceptions/catalogue.py @@ -0,0 +1,702 @@ +# Generated from schema/error-catalogue.json in the opsmill/infrahub repository - DO NOT EDIT. +# Catalogue version: 1 +# Regenerate there with: uv run invoke backend.generate +# +# Stability: a "stable" code keeps its payload shape; an "evolving" code may still gain fields. +from __future__ import annotations + +from collections.abc import Callable, Mapping +from datetime import datetime +from typing import Any, ClassVar + +from pydantic import BaseModel, ConfigDict +from typing_extensions import Self + +from .base import BranchNotFoundError, GraphQLError, NodeNotFoundError, SchemaNotFoundError + +__all__ = [ + "CODE_TO_DATA_MODEL", + "CODE_TO_EXCEPTION", + "AttributeConstraintViolationData", + "AttributeConstraintViolationError", + "AttributeInvalidTypeData", + "AttributeInvalidTypeError", + "AttributeRequiredData", + "AttributeRequiredError", + "AuthenticationRequiredData", + "BranchAlreadyMergedData", + "BranchAlreadyMergedError", + "BranchNeedsRebaseData", + "BranchNeedsRebaseError", + "BranchNotFoundData", + "MergeInProgressData", + "MergeInProgressError", + "MergeRecoveryRequiredData", + "MergeRecoveryRequiredError", + "NodeNotFoundData", + "PermissionDeniedData", + "SchemaNotFoundData", + "TokenExpiredData", + "UndefinedError", + "UndefinedErrorData", + "UniquenessViolationData", + "UniquenessViolationError", + "WorkerTimeoutData", + "WorkerTimeoutError", + "exception_from_payload", +] + + +class AttributeConstraintViolationData(BaseModel): + """Payload a server-reported ATTRIBUTE_CONSTRAINT_VIOLATION carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + node_kind: str + field_name: str + constraint: str + detail: str | None = None + + +class AttributeInvalidTypeData(BaseModel): + """Payload a server-reported ATTRIBUTE_INVALID_TYPE carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + node_kind: str + field_name: str + expected_type: str + received_type: str + + +class AttributeRequiredData(BaseModel): + """Payload a server-reported ATTRIBUTE_REQUIRED carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + node_kind: str + field_name: str + + +class AuthenticationRequiredData(BaseModel): + """Payload a server-reported AUTHENTICATION_REQUIRED carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + +class BranchAlreadyMergedData(BaseModel): + """Payload a server-reported BRANCH_ALREADY_MERGED carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + branch_name: str + + +class BranchNeedsRebaseData(BaseModel): + """Payload a server-reported BRANCH_NEEDS_REBASE carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + branch_name: str + + +class BranchNotFoundData(BaseModel): + """Payload a server-reported BRANCH_NOT_FOUND carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + branch_name: str + + +class MergeInProgressData(BaseModel): + """Payload a server-reported MERGE_IN_PROGRESS carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + branch_name: str + merging_branch: str + + +class MergeRecoveryRequiredData(BaseModel): + """Payload a server-reported MERGE_RECOVERY_REQUIRED carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + branch_name: str + merging_branch: str + + +class NodeNotFoundData(BaseModel): + """Payload a server-reported NODE_NOT_FOUND carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + node_kind: str + identifier: str + + +class PermissionDeniedData(BaseModel): + """Payload a server-reported PERMISSION_DENIED carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + action: str | None = None + resource_kind: str | None = None + + +class SchemaNotFoundData(BaseModel): + """Payload a server-reported SCHEMA_NOT_FOUND carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + kind: str + + +class TokenExpiredData(BaseModel): + """Payload a server-reported TOKEN_EXPIRED carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + expired_at: datetime | None = None + + +class UndefinedErrorData(BaseModel): + """Payload a server-reported UNDEFINED_ERROR carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + +class UniquenessViolationData(BaseModel): + """Payload a server-reported UNIQUENESS_VIOLATION carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + node_kind: str + fields: list[str] + + +class WorkerTimeoutData(BaseModel): + """Payload a server-reported WORKER_TIMEOUT carries.""" + + # A newer server may add fields this SDK has never heard of; ignoring them keeps the code + # resolvable rather than failing validation. + model_config = ConfigDict(extra="ignore") + + operation: str + timeout_seconds: int + retry_after_seconds: int + + +class AttributeConstraintViolationError(GraphQLError): + """Raised when the server reports ATTRIBUTE_CONSTRAINT_VIOLATION. + + A node attribute value failed a schema-defined constraint (e.g. regex, length, range). + + Stability: evolving. + """ + + CODE: ClassVar[str | None] = "ATTRIBUTE_CONSTRAINT_VIOLATION" + DATA_MODEL: ClassVar[type[BaseModel]] = AttributeConstraintViolationData + + code: str | None = "ATTRIBUTE_CONSTRAINT_VIOLATION" + http_status: int | None = 422 + + def __init__( + self, + *, + node_kind: str, + field_name: str, + constraint: str, + detail: str | None = None, + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.node_kind = node_kind + self.field_name = field_name + self.constraint = constraint + self.detail = detail + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: AttributeConstraintViolationData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + return cls( + node_kind=payload.node_kind, + field_name=payload.field_name, + constraint=payload.constraint, + detail=payload.detail, + ) + + +class AttributeInvalidTypeError(GraphQLError): + """Raised when the server reports ATTRIBUTE_INVALID_TYPE. + + A node attribute received a value that does not match its declared type. + + Stability: stable. + """ + + CODE: ClassVar[str | None] = "ATTRIBUTE_INVALID_TYPE" + DATA_MODEL: ClassVar[type[BaseModel]] = AttributeInvalidTypeData + + code: str | None = "ATTRIBUTE_INVALID_TYPE" + http_status: int | None = 422 + + def __init__( + self, + *, + node_kind: str, + field_name: str, + expected_type: str, + received_type: str, + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.node_kind = node_kind + self.field_name = field_name + self.expected_type = expected_type + self.received_type = received_type + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: AttributeInvalidTypeData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + return cls( + node_kind=payload.node_kind, + field_name=payload.field_name, + expected_type=payload.expected_type, + received_type=payload.received_type, + ) + + +class AttributeRequiredError(GraphQLError): + """Raised when the server reports ATTRIBUTE_REQUIRED. + + A mandatory node attribute was not provided. + + Stability: stable. + """ + + CODE: ClassVar[str | None] = "ATTRIBUTE_REQUIRED" + DATA_MODEL: ClassVar[type[BaseModel]] = AttributeRequiredData + + code: str | None = "ATTRIBUTE_REQUIRED" + http_status: int | None = 422 + + def __init__( + self, + *, + node_kind: str, + field_name: str, + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.node_kind = node_kind + self.field_name = field_name + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: AttributeRequiredData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + return cls(node_kind=payload.node_kind, field_name=payload.field_name) + + +class BranchAlreadyMergedError(GraphQLError): + """Raised when the server reports BRANCH_ALREADY_MERGED. + + The target branch has been merged and is permanently read-only. + + Stability: stable. + """ + + CODE: ClassVar[str | None] = "BRANCH_ALREADY_MERGED" + DATA_MODEL: ClassVar[type[BaseModel]] = BranchAlreadyMergedData + + code: str | None = "BRANCH_ALREADY_MERGED" + http_status: int | None = 400 + + def __init__( + self, + *, + branch_name: str, + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.branch_name = branch_name + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: BranchAlreadyMergedData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + return cls(branch_name=payload.branch_name) + + +class BranchNeedsRebaseError(GraphQLError): + """Raised when the server reports BRANCH_NEEDS_REBASE. + + The target branch must be rebased before it accepts new changes. + + Stability: stable. + """ + + CODE: ClassVar[str | None] = "BRANCH_NEEDS_REBASE" + DATA_MODEL: ClassVar[type[BaseModel]] = BranchNeedsRebaseData + + code: str | None = "BRANCH_NEEDS_REBASE" + http_status: int | None = 400 + + def __init__( + self, + *, + branch_name: str, + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.branch_name = branch_name + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: BranchNeedsRebaseData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + return cls(branch_name=payload.branch_name) + + +class MergeInProgressError(GraphQLError): + """Raised when the server reports MERGE_IN_PROGRESS. + + The write was rejected because a branch merge is in progress. The block is transient; retry once the + merge completes. + + Stability: evolving. + """ + + CODE: ClassVar[str | None] = "MERGE_IN_PROGRESS" + DATA_MODEL: ClassVar[type[BaseModel]] = MergeInProgressData + + code: str | None = "MERGE_IN_PROGRESS" + http_status: int | None = 423 + + def __init__( + self, + *, + branch_name: str, + merging_branch: str, + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.branch_name = branch_name + self.merging_branch = merging_branch + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: MergeInProgressData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + return cls(branch_name=payload.branch_name, merging_branch=payload.merging_branch) + + +class MergeRecoveryRequiredError(GraphQLError): + """Raised when the server reports MERGE_RECOVERY_REQUIRED. + + The write was rejected because a previous branch merge failed and left the default branch protected. + Recovery is required: an administrator must run `infrahub recover merge`. Unlike MERGE_IN_PROGRESS + this is not retryable. + + Stability: evolving. + """ + + CODE: ClassVar[str | None] = "MERGE_RECOVERY_REQUIRED" + DATA_MODEL: ClassVar[type[BaseModel]] = MergeRecoveryRequiredData + + code: str | None = "MERGE_RECOVERY_REQUIRED" + http_status: int | None = 423 + + def __init__( + self, + *, + branch_name: str, + merging_branch: str, + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.branch_name = branch_name + self.merging_branch = merging_branch + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: MergeRecoveryRequiredData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + return cls(branch_name=payload.branch_name, merging_branch=payload.merging_branch) + + +class UndefinedError(GraphQLError): + """Raised when the server reports UNDEFINED_ERROR. + + An error not yet covered by the catalogue. Its occurrence indicates a catalogue gap and should be + triaged. + + Stability: stable. + """ + + CODE: ClassVar[str | None] = "UNDEFINED_ERROR" + DATA_MODEL: ClassVar[type[BaseModel]] = UndefinedErrorData + + code: str | None = "UNDEFINED_ERROR" + http_status: int | None = 500 + + def __init__( + self, + *, + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: UndefinedErrorData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + del payload + return cls() + + +class UniquenessViolationError(GraphQLError): + """Raised when the server reports UNIQUENESS_VIOLATION. + + The submitted values collide with an existing object on a uniqueness constraint. The constraint members + may be relationships as well as attributes. + + Stability: evolving. + """ + + CODE: ClassVar[str | None] = "UNIQUENESS_VIOLATION" + DATA_MODEL: ClassVar[type[BaseModel]] = UniquenessViolationData + + code: str | None = "UNIQUENESS_VIOLATION" + http_status: int | None = 422 + + def __init__( + self, + *, + node_kind: str, + fields: list[str], + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.node_kind = node_kind + self.fields = fields + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: UniquenessViolationData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + return cls(node_kind=payload.node_kind, fields=payload.fields) + + +class WorkerTimeoutError(GraphQLError): + """Raised when the server reports WORKER_TIMEOUT. + + No worker answered the request within the time allowed. The failure is transient; retry after the number + of seconds the payload reports. + + Stability: evolving. + """ + + CODE: ClassVar[str | None] = "WORKER_TIMEOUT" + DATA_MODEL: ClassVar[type[BaseModel]] = WorkerTimeoutData + + code: str | None = "WORKER_TIMEOUT" + http_status: int | None = 504 + + def __init__( + self, + *, + operation: str, + timeout_seconds: int, + retry_after_seconds: int, + errors: list[dict[str, Any]] | None = None, + query: str | None = None, + variables: dict | None = None, + message: str | None = None, + ) -> None: + self.operation = operation + self.timeout_seconds = timeout_seconds + self.retry_after_seconds = retry_after_seconds + super().__init__(errors=errors or [], query=query, variables=variables, message=message) + + @classmethod + def from_payload(cls, payload: WorkerTimeoutData) -> Self: + """Build the exception from the validated payload of a server-reported failure.""" + return cls( + operation=payload.operation, + timeout_seconds=payload.timeout_seconds, + retry_after_seconds=payload.retry_after_seconds, + ) + + +# Codes that map to a dedicated exception class. Authentication and permission codes are absent: the +# SDK raises a generic class for those and carries the code on the instance. +CODE_TO_EXCEPTION: dict[str, type[GraphQLError]] = { + "ATTRIBUTE_CONSTRAINT_VIOLATION": AttributeConstraintViolationError, + "ATTRIBUTE_INVALID_TYPE": AttributeInvalidTypeError, + "ATTRIBUTE_REQUIRED": AttributeRequiredError, + "BRANCH_ALREADY_MERGED": BranchAlreadyMergedError, + "BRANCH_NEEDS_REBASE": BranchNeedsRebaseError, + "BRANCH_NOT_FOUND": BranchNotFoundError, + "MERGE_IN_PROGRESS": MergeInProgressError, + "MERGE_RECOVERY_REQUIRED": MergeRecoveryRequiredError, + "NODE_NOT_FOUND": NodeNotFoundError, + "SCHEMA_NOT_FOUND": SchemaNotFoundError, + "UNDEFINED_ERROR": UndefinedError, + "UNIQUENESS_VIOLATION": UniquenessViolationError, + "WORKER_TIMEOUT": WorkerTimeoutError, +} + +# Every code's payload model, including the codes that get no class, so a caller that observes one +# can still validate what it carries. +CODE_TO_DATA_MODEL: dict[str, type[BaseModel]] = { + "ATTRIBUTE_CONSTRAINT_VIOLATION": AttributeConstraintViolationData, + "ATTRIBUTE_INVALID_TYPE": AttributeInvalidTypeData, + "ATTRIBUTE_REQUIRED": AttributeRequiredData, + "AUTHENTICATION_REQUIRED": AuthenticationRequiredData, + "BRANCH_ALREADY_MERGED": BranchAlreadyMergedData, + "BRANCH_NEEDS_REBASE": BranchNeedsRebaseData, + "BRANCH_NOT_FOUND": BranchNotFoundData, + "MERGE_IN_PROGRESS": MergeInProgressData, + "MERGE_RECOVERY_REQUIRED": MergeRecoveryRequiredData, + "NODE_NOT_FOUND": NodeNotFoundData, + "PERMISSION_DENIED": PermissionDeniedData, + "SCHEMA_NOT_FOUND": SchemaNotFoundData, + "TOKEN_EXPIRED": TokenExpiredData, + "UNDEFINED_ERROR": UndefinedErrorData, + "UNIQUENESS_VIOLATION": UniquenessViolationData, + "WORKER_TIMEOUT": WorkerTimeoutData, +} + + +def _build_attribute_constraint_violation(data: Mapping[str, Any]) -> GraphQLError: + return AttributeConstraintViolationError.from_payload(AttributeConstraintViolationData.model_validate(data)) + + +def _build_attribute_invalid_type(data: Mapping[str, Any]) -> GraphQLError: + return AttributeInvalidTypeError.from_payload(AttributeInvalidTypeData.model_validate(data)) + + +def _build_attribute_required(data: Mapping[str, Any]) -> GraphQLError: + return AttributeRequiredError.from_payload(AttributeRequiredData.model_validate(data)) + + +def _build_branch_already_merged(data: Mapping[str, Any]) -> GraphQLError: + return BranchAlreadyMergedError.from_payload(BranchAlreadyMergedData.model_validate(data)) + + +def _build_branch_needs_rebase(data: Mapping[str, Any]) -> GraphQLError: + return BranchNeedsRebaseError.from_payload(BranchNeedsRebaseData.model_validate(data)) + + +def _build_branch_not_found(data: Mapping[str, Any]) -> GraphQLError: + return BranchNotFoundError.from_payload(BranchNotFoundData.model_validate(data)) + + +def _build_merge_in_progress(data: Mapping[str, Any]) -> GraphQLError: + return MergeInProgressError.from_payload(MergeInProgressData.model_validate(data)) + + +def _build_merge_recovery_required(data: Mapping[str, Any]) -> GraphQLError: + return MergeRecoveryRequiredError.from_payload(MergeRecoveryRequiredData.model_validate(data)) + + +def _build_node_not_found(data: Mapping[str, Any]) -> GraphQLError: + return NodeNotFoundError.from_payload(NodeNotFoundData.model_validate(data)) + + +def _build_schema_not_found(data: Mapping[str, Any]) -> GraphQLError: + return SchemaNotFoundError.from_payload(SchemaNotFoundData.model_validate(data)) + + +def _build_undefined_error(data: Mapping[str, Any]) -> GraphQLError: + return UndefinedError.from_payload(UndefinedErrorData.model_validate(data)) + + +def _build_uniqueness_violation(data: Mapping[str, Any]) -> GraphQLError: + return UniquenessViolationError.from_payload(UniquenessViolationData.model_validate(data)) + + +def _build_worker_timeout(data: Mapping[str, Any]) -> GraphQLError: + return WorkerTimeoutError.from_payload(WorkerTimeoutData.model_validate(data)) + + +# Each builder validates against its own code's model, so the payload type is never widened on the +# way to the constructor that consumes it. +_CODE_TO_BUILDER: dict[str, Callable[[Mapping[str, Any]], GraphQLError]] = { + "ATTRIBUTE_CONSTRAINT_VIOLATION": _build_attribute_constraint_violation, + "ATTRIBUTE_INVALID_TYPE": _build_attribute_invalid_type, + "ATTRIBUTE_REQUIRED": _build_attribute_required, + "BRANCH_ALREADY_MERGED": _build_branch_already_merged, + "BRANCH_NEEDS_REBASE": _build_branch_needs_rebase, + "BRANCH_NOT_FOUND": _build_branch_not_found, + "MERGE_IN_PROGRESS": _build_merge_in_progress, + "MERGE_RECOVERY_REQUIRED": _build_merge_recovery_required, + "NODE_NOT_FOUND": _build_node_not_found, + "SCHEMA_NOT_FOUND": _build_schema_not_found, + "UNDEFINED_ERROR": _build_undefined_error, + "UNIQUENESS_VIOLATION": _build_uniqueness_violation, + "WORKER_TIMEOUT": _build_worker_timeout, +} + + +def exception_from_payload(code: str, data: Mapping[str, Any]) -> GraphQLError | None: + """Build the exception a catalogued code names, or None where the SDK has no class for it. + + Raises: + pydantic.ValidationError: when the payload does not match what the code declares. + + """ + builder = _CODE_TO_BUILDER.get(code) + return None if builder is None else builder(data) diff --git a/infrahub_sdk/exceptions/factory.py b/infrahub_sdk/exceptions/factory.py new file mode 100644 index 000000000..183d7aa59 --- /dev/null +++ b/infrahub_sdk/exceptions/factory.py @@ -0,0 +1,282 @@ +"""Raise-time construction of an exception from a server response envelope. + +Every authentication and GraphQL raise site that has a server response behind it funnels through +here, so envelope parsing lives in one place rather than being repeated per call site. Both factories +are total: a shape the parser does not recognise degrades to the generic exception that call site +raises anyway, never to a decode error or a TypeError originating in the SDK. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from pydantic import ValidationError as PayloadValidationError + +from .base import ( + AuthenticationError, + Error, + GraphQLError, + as_error_list, + code_names_the_failure, + graphql_default_message, +) +from .catalogue import exception_from_payload + +if TYPE_CHECKING: + import httpx + +LOGGER = logging.getLogger("infrahub_sdk") + +# The names the package façade re-exports. `token_expired_in` is deliberately absent: it is public to +# the SDK, which imports it from this module, but it is not part of the published exception surface. +__all__ = ["authentication_error_from_response", "graphql_error_from_response"] + + +def _extensions_of(error: Any) -> dict[str, Any] | None: + if not isinstance(error, dict): + return None + extensions = error.get("extensions") + return extensions if isinstance(extensions, dict) else None + + +def _first_extensions(errors: Any) -> dict[str, Any] | None: + """Extensions of the governing error: the first one, whether or not it carries a code.""" + if not isinstance(errors, list) or not errors: + return None + return _extensions_of(errors[0]) + + +def _catalogue_code(extensions: dict[str, Any] | None) -> str | None: + """Only a string is a catalogue code. + + A pre-catalogue envelope puts an integer here mirroring the HTTP status, which must never be + mistaken for one. + """ + if extensions is None: + return None + code = extensions.get("code") + return code if isinstance(code, str) else None + + +def _declared_http_status(extensions: dict[str, Any] | None) -> int | None: + if extensions is None: + return None + status = extensions.get("http_status") + # bool is an int subclass, and a JSON `true` here is not a status. + return status if isinstance(status, int) and not isinstance(status, bool) else None + + +def _server_messages(errors: Any) -> list[str]: + if not isinstance(errors, list): + return [] + return [error["message"] for error in errors if isinstance(error, dict) and isinstance(error.get("message"), str)] + + +def _detail_message(body: dict[str, Any]) -> str | None: + """The REST API rejects a request with a bare `detail` string rather than an `errors` array.""" + detail = body.get("detail") + return detail if isinstance(detail, str) and detail else None + + +def _governing_message(errors: Any) -> str: + """The message of the error the code came from, which is the first one. + + Only this error's message may be named beside the code. Joining the whole list would file every + later error under a code that is not theirs; the complete list stays on `exc.errors`. + """ + if not isinstance(errors, list) or not errors: + return "" + first = errors[0] + message = first.get("message") if isinstance(first, dict) else None + return message if isinstance(message, str) else "" + + +def _named_by_code(code: str, message: str) -> str: + """The message for a described failure: the code and the server's own words, and no query text. + + Callers apply this only where the governing error carried a message, since a bare code is a + worse headline than what the transport would otherwise produce. The query stays on `exc.query`. + """ + return f"{code}: {message}" + + +def _replace_message(exc: Error, message: str) -> None: + """Swap the message of an exception that is already built. + + `args` is reassigned alongside it because that, not `message`, is what `str()` reads on a class + that does not override `__str__`. + """ + exc.message = message + exc.args = (message,) + + +def _payload_of(extensions: dict[str, Any] | None) -> Mapping[str, Any]: + """The governing error's payload, or an empty one where the envelope carried none. + + An absent payload is not the same as a malformed one: a code whose fields are all optional still + resolves to its class, while one with required fields fails the validation below and falls back. + """ + data = extensions.get("data") if extensions is not None else None + return data if isinstance(data, Mapping) else {} + + +def _catalogued_exception(code: str | None, extensions: dict[str, Any] | None) -> GraphQLError | None: + """The class the catalogue binds `code` to, built from the payload the envelope carries. + + Resolution and validation both belong to the generated bindings: each code's payload is validated + against its own model and handed to that class's `from_payload`, so the factory never assembles + an attribute itself and never has to widen a payload type to reach a constructor. + + `None` means no class was resolved - the code has none, or its payload violates what the + catalogue declares for it - and the caller then raises the generic class for the transport it + observed, with the code still readable. + """ + if code is None: + return None + try: + exception = exception_from_payload(code=code, data=_payload_of(extensions)) + except PayloadValidationError: + # The caller is already failing, so a validation error from inside the SDK would replace the + # server's reason with one of the SDK's own. + LOGGER.debug("Payload for %s does not match what the catalogue declares: %r", code, extensions, exc_info=True) + return None + if exception is None: + LOGGER.debug("These bindings have no class for the catalogue code %s: %r", code, extensions) + return exception + + +def _has_no_sentence_of_its_own(exc: GraphQLError) -> bool: + """Whether the class supplied no message of its own when it built itself from its payload. + + A class that predates the catalogue writes its own sentence; one bound to a code takes the + GraphQL default, which names the query and errors it was given - and `from_payload` gives it + neither. Asking the instance rather than a fixed string keeps that true if a class ever starts + passing them. + """ + return exc.message == graphql_default_message(query=exc.query, errors=exc.errors) + + +def _log_unresolved_code(code: str | None, extensions: dict[str, Any] | None, source: str) -> None: + """Record a response the SDK read no catalogue code from, whatever the reason. + + The whole `extensions` mapping is logged rather than its `code` alone, since the commonest shapes + here carry no `code` at all and the rest of the mapping is what identifies the server. + """ + if code is None: + LOGGER.debug("No catalogue code resolved from %s error extensions: %r", source, extensions) + + +def token_expired_in(errors: Any) -> bool: + """Whether a decoded `errors` array reports the caller's token as expired. + + Lives here so the client's silent-refresh decision reads the envelope through the same parser as + everything else. Every error is scanned rather than only the first: a stale token is a fact about + the request, not about which error happens to lead. The legacy message check is the fallback for + servers that predate the catalogue; `query_groups` keeps the only other one, for the same reason. + """ + if not isinstance(errors, list): + return False + if any(_catalogue_code(_extensions_of(error)) == "TOKEN_EXPIRED" for error in errors): + return True + return "Expired Signature" in _server_messages(errors) + + +def graphql_error_from_response( + errors: Any, + query: str | None = None, + variables: dict | None = None, +) -> GraphQLError: + """Build the exception for an `errors` array returned on the GraphQL path. + + `errors` is raw decoded JSON, so it is read defensively. The complete list is retained + unreordered. A code the catalogue binds to a class raises that class; every other failure raises + `GraphQLError`, and one the server's catalogue could not describe keeps the message this call site + has always produced, query text included. + + This is the GraphQL branch, so its fallback is `GraphQLError` whatever status the code declares. + A code that reaches an `errors` array was read off this transport, and a declared 401 does not + make it an authentication failure the SDK observed. + """ + extensions = _first_extensions(errors) + code = _catalogue_code(extensions) + governing = _governing_message(errors) + message = _named_by_code(code, governing) if code_names_the_failure(code) and governing else None + + catalogued = _catalogued_exception(code=code, extensions=extensions) + if catalogued is not None: + exc: GraphQLError = catalogued + # An undescribed failure leaves a class with its own sentence holding it, and one without + # holding a placeholder that names neither the query nor the errors it was never given. + if message is None and _has_no_sentence_of_its_own(exc): + message = graphql_default_message(query=query, errors=errors) + if message is not None: + _replace_message(exc, message) + exc.query = query + exc.variables = variables + else: + exc = GraphQLError(errors=errors, query=query, variables=variables, message=message) + + exc.errors = as_error_list(errors) + exc.code = code + declared_status = _declared_http_status(extensions) + if declared_status is not None: + # Conditional so a class that declares the catalogue's status keeps it where the envelope + # omitted one. + exc.http_status = declared_status + exc.extensions = extensions + _log_unresolved_code(code=code, extensions=extensions, source="GraphQL") + return exc + + +def authentication_error_from_response(response: httpx.Response) -> AuthenticationError: + """Build the exception for a response the SDK rejected as an authentication failure. + + Most call sites reach here on a 401 or 403, but the two that handle a failed token refresh reach + it on any other status, so the status itself is not assumed. The message joins the server's + messages with `" | "`, as every call site this replaces did, falling back to the REST API's bare + `detail` string and then to the plain status. A body the SDK cannot read as an envelope therefore + still names the status rather than surfacing a decode error in place of the authentication failure. + + A described failure names its code and the governing error's message instead, on the same rule as + the GraphQL path: only the error the code came from may be named beside it, and the complete list + stays on `exc.errors`. + + No code is resolved to a class here. Every catalogued class descends from `GraphQLError`, so + raising one for a response the SDK observed as an authentication failure would put it out of + reach of `except AuthenticationError`. + """ + errors: Any = [] + message = f"HTTP {response.status_code}" + + # Read raw rather than through utils.decode_json, which raises JsonDecodeError on a body that is + # not JSON. Tolerating that body is the whole point here, so the wrapping would be built and then + # discarded. The catch stays broad because this runs while the caller is already failing: whatever + # a proxy answered with, the authentication failure is what has to reach them. + try: + body = response.json() + except Exception: + LOGGER.debug("Authentication response body could not be parsed; using the plain status", exc_info=True) + else: + if isinstance(body, dict): + errors = body.get("errors", []) + # Each fallback only applies when the one before it yielded nothing, so a body carrying + # no readable reason keeps the status rather than losing it to an empty join. + message = " | ".join(_server_messages(errors)) or _detail_message(body) or message + else: + LOGGER.debug("Authentication response body is not an envelope object; using the plain status: %r", body) + + extensions = _first_extensions(errors) + code = _catalogue_code(extensions) + governing = _governing_message(errors) + if code_names_the_failure(code) and governing: + message = _named_by_code(code, governing) + + exc = AuthenticationError(message) + exc.code = code + exc.http_status = _declared_http_status(extensions) + exc.extensions = extensions + exc.errors = as_error_list(errors) + _log_unresolved_code(code=code, extensions=extensions, source="authentication") + return exc diff --git a/infrahub_sdk/file_handler.py b/infrahub_sdk/file_handler.py index fa7cdede2..9946cb203 100644 --- a/infrahub_sdk/file_handler.py +++ b/infrahub_sdk/file_handler.py @@ -10,7 +10,12 @@ import anyio import httpx -from .exceptions import AuthenticationError, NodeNotFoundError, ServerNotReachableError, ServerNotResponsiveError +from .exceptions import ( + NodeNotFoundError, + ServerNotReachableError, + ServerNotResponsiveError, + authentication_error_from_response, +) if TYPE_CHECKING: from .client import InfrahubClient, InfrahubClientSync @@ -158,16 +163,18 @@ def handle_error_response(exc: httpx.HTTPStatusError) -> None: NodeNotFoundError: If the file/node is not found (404). httpx.HTTPStatusError: For other HTTP errors. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors", []) - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc if exc.response.status_code == 404: - response = exc.response.json() - detail = response.get("detail", "File not found") - raise NodeNotFoundError(node_type="FileObject", identifier=detail) from exc + # The body is whatever the server or an intermediary sent, so a shape carrying no detail + # must degrade to the generic reason rather than raising out of the error handler. + detail: str | None = None + with suppress(ValueError): + body = exc.response.json() + if isinstance(body, dict) and isinstance(body.get("detail"), str): + detail = body["detail"] + raise NodeNotFoundError(node_type="FileObject", identifier=detail or "File not found") from exc raise exc @staticmethod diff --git a/infrahub_sdk/graph_traversal/query.py b/infrahub_sdk/graph_traversal/query.py index 9813e4dd8..302b432b1 100644 --- a/infrahub_sdk/graph_traversal/query.py +++ b/infrahub_sdk/graph_traversal/query.py @@ -8,6 +8,7 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Any # Selection set shared by both queries. @@ -39,7 +40,7 @@ }}""" -def is_unknown_field_error(errors: list[dict[str, Any]], field_name: str) -> bool: +def is_unknown_field_error(errors: Sequence[dict[str, Any]], field_name: str) -> bool: """Return True if the GraphQL errors indicate ``field_name`` is an unknown query field. Used to detect a pre-1.10 server that lacks the traversal queries, so the SDK can diff --git a/infrahub_sdk/node/__init__.py b/infrahub_sdk/node/__init__.py index eb55bfe36..46664defb 100644 --- a/infrahub_sdk/node/__init__.py +++ b/infrahub_sdk/node/__init__.py @@ -14,7 +14,7 @@ SAFE_VALUE, UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE, ) -from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync, UploadResult +from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync, PoolAllocation, UploadResult from .parsers import parse_human_friendly_id from .property import NodeProperty from .related_node import ( @@ -43,6 +43,7 @@ "InfrahubNodeBase", "InfrahubNodeSync", "NodeProperty", + "PoolAllocation", "RelatedNode", "RelatedNodeBase", "RelatedNodeSync", diff --git a/infrahub_sdk/node/_only.py b/infrahub_sdk/node/_only.py new file mode 100644 index 000000000..135a0511a --- /dev/null +++ b/infrahub_sdk/node/_only.py @@ -0,0 +1,235 @@ +"""Checks and field lists shared by every query that names its fields with `only`. + +The helpers take schemas the caller has already looked up and do no I/O, so the async and the sync +code paths reach the same decisions. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ..exceptions import FieldNotFoundError, FieldNotLoadedError, FieldSelectionConflictError +from ..schema import GenericSchemaAPI, NodeSchemaAPI + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + + from ..schema import MainSchemaTypesAPI + +HIERARCHY_FIELD_NAMES: tuple[str, ...] = ("parent", "children", "ancestors", "descendants") + +# Every node is requested with its id and display label; "hfid" requests the HFID the server stores. +_ALWAYS_ACCEPTED_NAMES = frozenset({"id", "display_label", "hfid"}) + +# Like the default prefetch, only the hierarchy fields that are schema relationships get peer nodes. +_HIERARCHY_PEER_NODE_NAMES = frozenset({"parent", "children"}) + + +@dataclass(frozen=True) +class OnlyMarker: + """Marks a node built from a query with `only`, so reading a field it does not carry raises. + + `peer_of` is `None` for the nodes the query returned, and `"Kind.relationship"` for a peer + returned with them. + """ + + only: tuple[str, ...] + peer_of: str | None = None + + def not_loaded_error(self, kind: str, field: str) -> FieldNotLoadedError: + """Build the error for reading `field` on a node of `kind` that carries this marker.""" + return FieldNotLoadedError(kind=kind, field=field, only=self.only, peer_of=self.peer_of) + + +@dataclass(frozen=True) +class KindFields: + """The fields a query with `only` requests on one kind. + + `schema` is the schema of that kind. `hierarchy_kind` is the kind of the peers of the hierarchy fields, + and is set whenever `hierarchy_fields` is not empty. Every field is listed in a fixed order, so the query + does not depend on the order of `only`. + """ + + schema: MainSchemaTypesAPI + attributes: tuple[str, ...] = () + relationships: tuple[str, ...] = () + hierarchy_fields: tuple[str, ...] = () + hierarchy_kind: str | None = None + + @property + def is_empty(self) -> bool: + return not (self.attributes or self.relationships or self.hierarchy_fields) + + +@dataclass(frozen=True) +class OnlyQueryFields: + """Where a query with `only` requests each field. + + `only` is the list the query was built from, as given. `node` holds the fields requested on the + queried kind. For a generic kind, `implementing_kinds` maps each implementing kind that needs fields + the generic does not have to the fields requested in its `...on Kind` block, in the order the + implementing schemas were given. `hfid` is true when the queried node requests the HFID the server + stores. + """ + + only: tuple[str, ...] + node: KindFields + implementing_kinds: Mapping[str, KindFields] + hfid: bool + + +@dataclass(frozen=True) +class OnlyPeerRelationships: + """How the nodes of one kind returned by a query with `only` are marked, and which of their peers are built. + + `marker` goes on each node. `peer_markers` maps each relationship whose peers are built as nodes, in + the order the query requests them, to the marker of those peers. + """ + + marker: OnlyMarker + peer_markers: Mapping[str, OnlyMarker] + + +def check_only_conflict( + only: Sequence[str] | None, include: Sequence[str] | None, exclude: Sequence[str] | None +) -> None: + """Reject `only` combined with `include` or `exclude`, even when either is an empty list. + + Raises: + FieldSelectionConflictError: `only` is not `None` and `include` or `exclude` is not `None`. + """ + if only is not None and (include is not None or exclude is not None): + raise FieldSelectionConflictError + + +def field_names(schema: MainSchemaTypesAPI) -> frozenset[str]: + """The fields `only` can name on one kind, not counting the fields of its implementing kinds. + + These are the kind's attributes and relationships, plus the four hierarchy fields when the kind + is a node with a hierarchy or a hierarchical generic. + """ + names = {*schema.attribute_names, *schema.relationship_names} + if _is_hierarchical(schema): + names.update(HIERARCHY_FIELD_NAMES) + return frozenset(names) + + +def check_only_names( + only: Sequence[str], schema: MainSchemaTypesAPI, implementing_schemas: Iterable[MainSchemaTypesAPI] = () +) -> None: + """Reject the first name in `only` that is neither an identifier nor a field of the kind. + + For a generic kind, pass the schemas of the kinds that implement it: a name is accepted when the + generic or any of those kinds has it. + + Raises: + TypeError: `only` is a string instead of a list of names. + FieldNotFoundError: a name is not accepted; it names that field and `schema.kind`. + """ + if isinstance(only, str): + raise TypeError("only must be a list of field names, not a string") + accepted = set(field_names(schema)) + for implementing_schema in implementing_schemas: + accepted.update(field_names(implementing_schema)) + for name in only: + if name not in accepted and name not in _ALWAYS_ACCEPTED_NAMES: + raise FieldNotFoundError(kind=schema.kind, field=name) + + +def check_only_names_on_every_kind(only: Sequence[str], schemas: Iterable[MainSchemaTypesAPI]) -> None: + """Reject `only` unless every kind in `schemas` has every name in it. + + Raises: + FieldNotFoundError: a name is missing on one of the kinds; it names that field and that kind. + """ + for schema in schemas: + check_only_names(only=only, schema=schema) + + +def only_query_fields( + only: Sequence[str], schema: MainSchemaTypesAPI, implementing_schemas: Iterable[MainSchemaTypesAPI] = () +) -> OnlyQueryFields: + """Decide which fields a query with `only` requests, and on which kind. + + `id` and `display_label` add nothing. `hfid` requests the HFID the server stores on the queried node, when + the kind, or for a generic kind the generic or one of `implementing_schemas`, has an HFID. A field the + generic has is requested on the generic; any other field goes to the block of each implementing kind + that has it. On a hierarchical kind, `parent`, `children`, `ancestors` and `descendants` are hierarchy + fields. + + Raises: + TypeError: `only` is a string instead of a list of names. + FieldNotFoundError: a name is not a field of the kind or of any of `implementing_schemas`. + """ + implementing_schemas = tuple(implementing_schemas) + check_only_names(only=only, schema=schema, implementing_schemas=implementing_schemas) + + names = [name for name in dict.fromkeys(only) if name not in _ALWAYS_ACCEPTED_NAMES] + node_names = field_names(schema) + implementing_kinds: dict[str, KindFields] = {} + for implementing_schema in implementing_schemas: + block_names = field_names(implementing_schema) + kind_fields = _kind_fields( + implementing_schema, {name for name in names if name not in node_names and name in block_names} + ) + if not kind_fields.is_empty: + implementing_kinds[implementing_schema.kind] = kind_fields + + return OnlyQueryFields( + only=tuple(only), + node=_kind_fields(schema, {name for name in names if name in node_names}), + implementing_kinds=implementing_kinds, + hfid="hfid" in only and any(hfid_schema.human_friendly_id for hfid_schema in (schema, *implementing_schemas)), + ) + + +def only_peer_relationships(fields: OnlyQueryFields, kind: str, prefetch_relationships: bool) -> OnlyPeerRelationships: + """Decide how the nodes of `kind` returned by the query `fields` describes are marked, and which peers are built. + + With `prefetch_relationships`, the peers of each named relationship are built as nodes; of the hierarchy + fields, only `parent` and `children` qualify. A node of an implementing kind also gets the relationships + of that kind's `...on Kind` block. + """ + names: dict[str, None] = {} + if prefetch_relationships: + for kind_fields in (fields.node, fields.implementing_kinds.get(kind)): + if kind_fields is None: + continue + for name in kind_fields.relationships: + names[name] = None + for name in kind_fields.hierarchy_fields: + if name in _HIERARCHY_PEER_NODE_NAMES: + names[name] = None + return OnlyPeerRelationships( + marker=OnlyMarker(only=fields.only), + peer_markers={name: OnlyMarker(only=fields.only, peer_of=f"{kind}.{name}") for name in names}, + ) + + +def _kind_fields(schema: MainSchemaTypesAPI, names: set[str]) -> KindFields: + hierarchy_kind = _hierarchy_kind(schema) + hierarchy_names = names.intersection(HIERARCHY_FIELD_NAMES) if hierarchy_kind is not None else set() + hierarchy_fields = tuple(name for name in HIERARCHY_FIELD_NAMES if name in hierarchy_names) + return KindFields( + schema=schema, + attributes=tuple(name for name in schema.attribute_names if name in names and name not in hierarchy_names), + relationships=tuple( + name for name in schema.relationship_names if name in names and name not in hierarchy_names + ), + hierarchy_fields=hierarchy_fields, + hierarchy_kind=hierarchy_kind if hierarchy_fields else None, + ) + + +def _hierarchy_kind(schema: MainSchemaTypesAPI) -> str | None: + """The hierarchical generic the kind's hierarchy is defined on; a hierarchical generic is its own.""" + if isinstance(schema, NodeSchemaAPI): + return schema.hierarchy + if isinstance(schema, GenericSchemaAPI) and schema.hierarchical: + return schema.kind + return None + + +def _is_hierarchical(schema: MainSchemaTypesAPI) -> bool: + return _hierarchy_kind(schema) is not None diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index 4ada6b9de..bd72595ad 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -16,7 +16,9 @@ from .property import NodeProperty if TYPE_CHECKING: + from ..protocols_base import CoreNodeBase from ..schema import AttributeSchemaAPI + from .node import InfrahubNodeBase class _GraphQLPayloadAttribute(NamedTuple): @@ -60,6 +62,8 @@ class Attribute: id (str | None): The unique identifier of the attribute, when known. value (Any): The current attribute value. Setting this marks the attribute as mutated. value_has_been_mutated (bool): True when ``value`` has been assigned after construction. + is_loaded (bool): True when the node's data contained the attribute, a value was assigned since, + the attribute does not belong to a node, or the node has no ``id`` yet. is_default (bool | None): True when the value comes from the schema default. is_from_profile (bool | None): True when the value is inherited from a profile. is_inherited (bool | None): True when the attribute is inherited from a generic. @@ -83,14 +87,19 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N data (Any | dict): The data for the attribute. Either a scalar value, a dict with a ``value`` key (and optional metadata properties), or a dict with a ``from_pool`` key to allocate the value from a resource pool when saving. + A dict with both ``value`` and ``from_pool`` attaches that value to the pool. """ self.name = name self._schema = schema self._from_pool: dict[str, Any] | None = None + self._pool_write_pending = False + self._node: InfrahubNodeBase | None = None + self._has_data = False if isinstance(data, dict) and "from_pool" in data: self._from_pool = data.pop("from_pool") + self._pool_write_pending = self._from_pool is not None data.setdefault("value", None) elif not isinstance(data, dict) or "value" not in data: data = {"value": data} @@ -103,7 +112,7 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N self.id: str | None = data.get("id") - self._value: Any | None = data.get("value") + self._value: Any = data.get("value") self.value_has_been_mutated = False self.is_default: bool | None = data.get("is_default") self.is_from_profile: bool | None = data.get("is_from_profile") @@ -136,45 +145,141 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N @property def value(self) -> Any: + """Return the attribute value. + + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the attribute was not fetched. + + """ + self._raise_if_not_loaded() return self._value @value.setter def value(self, value: Any) -> None: self._value = value self.value_has_been_mutated = True + self._has_data = True + + @property + def is_loaded(self) -> bool: + """Return whether this attribute holds a value from its node's data or from an assignment. + + Never raises and sends no request. + + Returns: + bool: ``True`` when the data the node was built from contained the attribute, when a value + was assigned since, when the attribute does not belong to a node, or when its node has no + ``id`` yet. ``False`` otherwise. + + """ + return self._has_data or self._node is None or not self._node.id + + def _bind(self, node: InfrahubNodeBase, present: bool) -> None: + self._node = node + self._has_data = present + + def _raise_if_not_loaded(self) -> None: + if not self.is_loaded and self._node is not None and self._node._only_marker is not None: + raise self._node._only_marker.not_loaded_error(kind=self._node.get_kind(), field=self.name) + + def attach_to_pool(self, pool: CoreNodeBase | str) -> None: + """Attach the number this attribute holds to a number pool when the node is next saved. + + The pool then tracks the number as one a user provided, without allocating a new one. + + Args: + pool (CoreNodeBase | str): The number pool node, or its ID. + + Raises: + ValueError: If the attribute holds no number, or if ``pool`` is a node that is not a resource pool or + has not been saved. + + """ + if self.value is None or self.is_unresolved_pool_attribute(): + raise ValueError( + f"Attribute '{self.name}' holds no number to attach; " + "assign the pool to the attribute instead to allocate a number from it." + ) + if isinstance(pool, str): + pool_id = pool + elif not pool.is_resource_pool(): + raise ValueError(f"{pool.get_kind()} is not a resource pool.") + elif not pool.id: + raise ValueError(f"Save the {pool.get_kind()} before attaching a number to it.") + else: + pool_id = pool.id + self._from_pool = {"id": pool_id} + self._pool_write_pending = True + self._has_data = True + + def detach_from_pool(self) -> None: + """Detach the number this attribute holds from its number pool when the node is next saved. + + The attribute keeps its number, and the pool stops tracking it. + """ + self._from_pool = None + self._pool_write_pending = True + self._has_data = True + + def _mark_pool_write_sent(self) -> None: + self._pool_write_pending = False def _initialize_graphql_payload(self) -> _GraphQLPayloadAttribute: """Resolve the attribute value into a GraphQL mutation payload object.""" - # Pool-based allocation (dict data or resource-pool node) - if self._from_pool is not None: - return _GraphQLPayloadAttribute(payload={"from_pool": self._from_pool}, variables={}, needs_metadata=True) - if hasattr(self.value, "is_resource_pool") and self.value.is_resource_pool(): + if self._pool_write_pending: + return self._pool_write_payload() + if hasattr(self._value, "is_resource_pool") and self._value.is_resource_pool(): + return _GraphQLPayloadAttribute( + payload={"from_pool": {"id": self._value.id}}, variables={}, needs_metadata=True + ) + return self._value_payload() + + def _pool_write_payload(self) -> _GraphQLPayloadAttribute: + # A pending write that names no pool is a detach. + if self._from_pool is None: + if not self.value_has_been_mutated and self._node is not None and self._node.id: + return _GraphQLPayloadAttribute(payload={"from_pool": None}, variables={}, needs_metadata=True) + # A changed number, or the number of a node that is not saved yet, must still be sent. + value_payload = self._value_payload() return _GraphQLPayloadAttribute( - payload={"from_pool": {"id": self.value.id}}, variables={}, needs_metadata=True + payload={**value_payload.payload, "from_pool": None}, + variables=value_payload.variables, + needs_metadata=True, ) + if self._value is None: + return _GraphQLPayloadAttribute(payload={"from_pool": self._from_pool}, variables={}, needs_metadata=True) + + value_payload = self._value_payload() + return _GraphQLPayloadAttribute( + payload={**value_payload.payload, "from_pool": self._from_pool}, + variables=value_payload.variables, + needs_metadata=True, + ) + + def _value_payload(self) -> _GraphQLPayloadAttribute: # Null value - if self.value is None: + if self._value is None: data = {"value": None} if (self._schema.optional and self.value_has_been_mutated) else {} return _GraphQLPayloadAttribute(payload=data, variables={}, needs_metadata=False) # Unsafe strings need a variable binding to avoid injection - if isinstance(self.value, str) and not SAFE_VALUE.match(self.value): + if isinstance(self._value, str) and not SAFE_VALUE.match(self._value): var_name = f"value_{UUIDT.new().hex}" return _GraphQLPayloadAttribute( payload={"value": f"${var_name}"}, - variables={var_name: self.value}, + variables={var_name: self._value}, needs_metadata=True, ) # Safe strings, IP types, and everything else - if isinstance(self.value, get_args(IP_TYPES)): - value = self.value.with_prefixlen - elif isinstance(self.value, get_args(IP_ADDRESS_TYPES)): + 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) + value = str(self._value) else: - value = self.value + value = self._value return _GraphQLPayloadAttribute(payload={"value": value}, variables={}, needs_metadata=True) def _generate_input_data(self) -> _GraphQLPayloadAttribute: @@ -225,12 +330,15 @@ def _generate_mutation_query(self) -> dict[str, Any]: def is_from_pool_attribute(self) -> bool: """Check whether this attribute's value is sourced from a resource pool. + This reflects only the pool requests made through this object. For a node fetched from the server, query it + with ``property=True`` and read the attribute's ``source``, which is the number pool tracking the value. + Returns: True if the attribute value is a resource pool node or was explicitly allocated from a pool. """ return ( - hasattr(self.value, "is_resource_pool") and self.value.is_resource_pool() + hasattr(self._value, "is_resource_pool") and self._value.is_resource_pool() ) or self._from_pool is not None def is_unresolved_pool_attribute(self) -> bool: @@ -240,9 +348,9 @@ def is_unresolved_pool_attribute(self) -> bool: - its value is a pool node object (the pool reference itself, not an allocated scalar), or - its value is None and the from_pool allocation dict is set. - An attribute whose _from_pool dict is set but whose value has already been populated - with the allocated scalar (e.g. after a prior save) is considered resolved. + An attribute whose _from_pool dict is set but which holds a number, either the one allocated by a + prior save or one given to attach to the pool, is considered resolved. """ - if hasattr(self.value, "is_resource_pool") and self.value.is_resource_pool(): + if hasattr(self._value, "is_resource_pool") and self._value.is_resource_pool(): return True - return self._from_pool is not None and self.value is None + return self._from_pool is not None and self._value is None diff --git a/infrahub_sdk/node/constants.py b/infrahub_sdk/node/constants.py index 192bbf6de..cd9b04d11 100644 --- a/infrahub_sdk/node/constants.py +++ b/infrahub_sdk/node/constants.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import ipaddress import re @@ -42,5 +44,9 @@ HIERARCHY_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE = "Hierarchical fields are not supported for this node." +POOL_ALLOCATED_QUERY_NAME = "InfrahubResourcePoolAllocated" +POOL_ALLOCATION_FIELDS = ["id", "kind", "branch", "display_label", "identifier", "provenance"] +POOL_ALLOCATED_RESOURCE_FIELDS = ["id", "kind", "branch", "identifier"] + HFID_STR_SEPARATOR = "__" PROFILE_KIND_PREFIX = "Profile" diff --git a/infrahub_sdk/node/inline_peers.py b/infrahub_sdk/node/inline_peers.py new file mode 100644 index 000000000..902f6536e --- /dev/null +++ b/infrahub_sdk/node/inline_peers.py @@ -0,0 +1,28 @@ +"""Relationships whose mutation takes the fields of their peers instead of a reference to one.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..schema import RelationshipSchemaAPI + +# The reason both the SDK and the CLI give for refusing an edit; each adds the way out of its own. +INLINE_INPUT_REASON = "the mutation takes the fields of a {peer} and not a reference to one" + +INLINE_INPUT_PEERS_FEATURE_NOT_SUPPORTED_MESSAGE = ( + "The peers of '{name}' cannot be edited one at a time, because " + INLINE_INPUT_REASON + ". " + "To set them, pass the complete list of '{name}' to client.create() and save the node with " + "allow_upsert=True, or create, update or delete the {peer} node itself." +) + +# Keys that name an existing peer rather than carry its fields. +PEER_REFERENCE_KEYS = frozenset({"id", "hfid", "node"}) + +# The relationships concerned, by peer kind. +INLINE_INPUT_RELATIONSHIPS: dict[str, frozenset[str]] = {"CoreNumberPoolRange": frozenset({"ranges"})} + + +def peers_are_inline_input(schema: RelationshipSchemaAPI) -> bool: + """Return whether a mutation takes this relationship's peers as fields instead of as references.""" + return schema.name in INLINE_INPUT_RELATIONSHIPS.get(schema.peer, frozenset()) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 561c01020..7be571a7f 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -1,12 +1,12 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping, Sequence from copy import copy, deepcopy from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, BinaryIO, overload -from ..constants import InfrahubClientMode, Priority +from ..constants import InfrahubClientMode, PoolAllocationProvenance, Priority from ..exceptions import ( FeatureNotSupportedError, NodeNotFoundError, @@ -23,6 +23,7 @@ RelationshipSchemaAPI, ) from ..utils import compare_lists, generate_short_id +from ._only import HIERARCHY_FIELD_NAMES from .attribute import Attribute from .constants import ( ARTIFACT_DEFINITION_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, @@ -30,6 +31,9 @@ ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE, MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE, + POOL_ALLOCATED_QUERY_NAME, + POOL_ALLOCATED_RESOURCE_FIELDS, + POOL_ALLOCATION_FIELDS, PROPERTIES_OBJECT, UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE, ) @@ -44,6 +48,7 @@ from ..context import RequestContext from ..schema import MainSchemaTypesAPI from ..types import Order + from ._only import KindFields, OnlyMarker, OnlyPeerRelationships, OnlyQueryFields @dataclass(frozen=True) @@ -72,6 +77,32 @@ class UploadResult: checksum: str | None +@dataclass(frozen=True) +class PoolAllocation: + """One resource a pool holds, as returned by :meth:`InfrahubNode.get_pool_allocations` and its sync twin.""" + + node_id: str + kind: str + branch: str + display_label: str + """The allocated resource; for a number pool, the number itself.""" + identifier: str | None + provenance: PoolAllocationProvenance | None + """``None`` for IP pools, which do not record it.""" + + @classmethod + def from_graphql(cls, data: dict[str, Any]) -> PoolAllocation: + provenance = data.get("provenance") + return cls( + node_id=data["id"], + kind=data["kind"], + branch=data["branch"], + display_label=data["display_label"], + identifier=data.get("identifier"), + provenance=PoolAllocationProvenance[provenance] if provenance else None, + ) + + class InfrahubNodeBase: """Base class for :class:`InfrahubNode` and :class:`InfrahubNodeSync`. @@ -103,6 +134,9 @@ def __init__(self, schema: MainSchemaTypesAPI, branch: str, data: dict | None = self._existing: bool = True self._attribute_data: dict[str, Attribute] = {} self._metadata: NodeMetadata | None = None + self._only_marker: OnlyMarker | None = None + # Set only on a node from `only` whose response contained `hfid`, so default nodes keep calculating theirs. + self._server_hfid: list[Any] | None = None # Generate a unique ID only to be used inside the SDK # The format if this ID is purposely different from the ID used by the API @@ -154,7 +188,11 @@ def get_path_value(self, path: str) -> Any: Returns: Any: The resolved value, or ``None`` when any path component cannot be - resolved (for example, an unfetched related node not present in the store). + resolved (for example, a related node that is not in the store). + + Raises: + FieldNotLoadedError: This node or the related node was queried with ``only`` and a field + the path reads was not fetched. """ path_parts = path.split("__") @@ -193,18 +231,76 @@ def get_path_value(self, path: str) -> Any: return return_value + def _get_path_value_if_loaded(self, path: str) -> Any: + """Resolve a dunder-separated path like the public path lookup, without raising for unfetched fields. + + Used for the HFID of every node. A field that a node from `only` did not fetch counts as missing, + including on a peer from the store; any other field is read as the public lookup reads it. The + peer of a relationship comes from the peer cached on the relationship or the store. + + Returns: + Any: The resolved value, or ``None`` when a path component is missing or was not fetched. + + """ + path_parts = path.split("__") + + if path_parts[0] in self._schema.relationship_names: + peer = self._get_loaded_peer(name=path_parts[0]) + if peer is None: + return None + if len(path_parts) == 1: + return peer.hfid or peer.id + return self._get_loaded_attribute_value(node=peer, path_parts=path_parts[1:]) + + if path_parts[0] in self._schema.attribute_names: + return self._get_loaded_attribute_value(node=self, path_parts=path_parts) + + return None + + def _get_loaded_peer(self, name: str) -> Any: + related_node = getattr(self, name, None) + if not related_node or not related_node.is_loaded: + return None + + try: + return related_node.get() + except (NodeNotFoundError, ValueError): + # The store is not populated yet while nodes are created in a batch. + return None + + @staticmethod + def _get_loaded_attribute_value(node: Any, path_parts: list[str]) -> Any: + attribute = getattr(node, path_parts[0], None) + # Only a node from `only` raises on an unfetched attribute; any other node reads it as before. + if ( + isinstance(attribute, Attribute) + and not attribute.is_loaded + and getattr(node, "_only_marker", None) is not None + ): + return None + if len(path_parts) == 1: + return attribute + if path_parts[1] == "value" and isinstance(attribute, Attribute): + return attribute._value + return getattr(attribute, path_parts[1], None) + def get_human_friendly_id(self) -> list[str] | None: """Compute the human-friendly ID for this node from its schema. The HFID is composed of the values addressed by the schema's ``human_friendly_id`` paths. When any component cannot be resolved, the HFID is - considered invalid and ``None`` is returned. + considered invalid and ``None`` is returned. A node from a query that named ``hfid`` in + ``only`` returns the HFID the server stores instead, or ``None`` when that value is empty, + until a field the HFID is built from is changed on the node. Returns: list[str] | None: The HFID as a list of stringified components, or ``None`` when the schema does not define an HFID or a component is missing. """ + if self._server_hfid is not None and not self._hfid_field_changed(): + return [str(part) for part in self._server_hfid] or None + if not hasattr(self._schema, "human_friendly_id"): return None @@ -212,11 +308,21 @@ def get_human_friendly_id(self) -> list[str] | None: return None # If an HFID component is missing we assume that it is invalid and not usable for this node - hfid_components = [self.get_path_value(path=item) for item in self._schema.human_friendly_id] + hfid_components = [self._get_path_value_if_loaded(path=item) for item in self._schema.human_friendly_id] if None in hfid_components: return None return [str(hfid) for hfid in hfid_components] + def _hfid_field_changed(self) -> bool: + # The stored HFID no longer matches the node once a field it is built from changes locally. + for path in getattr(self._schema, "human_friendly_id", None) or []: + field = getattr(self, path.split("__")[0], None) + if isinstance(field, Attribute) and field.value_has_been_mutated: + return True + if isinstance(field, RelatedNodeBase) and field._peer_has_been_mutated: + return True + return False + def get_human_friendly_id_as_string(self, include_kind: bool = False) -> str | None: """Return the human-friendly ID joined into a single string. @@ -271,9 +377,9 @@ def get_node_metadata(self) -> NodeMetadata | None: def _init_attributes(self, data: dict | None = None) -> None: for attr_schema in self._schema.attributes: attr_data = data.get(attr_schema.name, None) if isinstance(data, dict) else None - self._attribute_data[attr_schema.name] = Attribute( - name=attr_schema.name, schema=attr_schema, data=attr_data - ) + attribute = Attribute(name=attr_schema.name, schema=attr_schema, data=attr_data) + attribute._bind(node=self, present=isinstance(data, dict) and attr_schema.name in data) + self._attribute_data[attr_schema.name] = attribute def __setattr__(self, name: str, value: Any) -> None: """Set values for attributes that exist or revert to normal behaviour.""" @@ -355,6 +461,24 @@ def is_resource_pool(self) -> bool: """ return hasattr(self._schema, "inherit_from") and "CoreResourcePool" in self._schema.inherit_from # type: ignore[union-attr] + def _pool_allocated_query(self, edge_fields: Sequence[str]) -> str: + return Query( + query={ + POOL_ALLOCATED_QUERY_NAME: { + "@filters": { + "pool_id": "$pool_id", + "resource_id": "$resource_id", + "offset": "$offset", + "limit": "$limit", + }, + "count": None, + "edges": {"node": dict.fromkeys(edge_fields)}, + } + }, + name="GetAllocatedResourceForPool", + variables={"pool_id": str, "resource_id": str, "offset": int, "limit": int}, + ).render() + def is_file_object(self) -> bool: """Return whether this node inherits from ``CoreFileObject`` and supports file uploads. @@ -528,6 +652,14 @@ def _generate_input_data( # noqa: C901 "mutation_variables": mutation_variables, } + @staticmethod + def _keeps_unmodified_key(data_item: dict, item_key: str) -> bool: + # Related nodes typically require an ID, so it is only removed when it is the last key in the context. + if item_key == "id": + return len(data_item.keys()) > 1 + # Without the value, naming a pool over a held number asks to replace it, not attach it. + return item_key == "value" and "from_pool" in data_item + @staticmethod def _strip_unmodified_dict(data: dict, original_data: dict, variables: dict, item: str) -> None: data_item = data.get(item) @@ -541,9 +673,7 @@ def _strip_unmodified_dict(data: dict, original_data: dict, variables: dict, ite ): original_data[item][property_name] = original_data[item][property_name]["id"] if item_key in data[item]: - if item_key == "id" and len(data[item].keys()) > 1: - # Related nodes typically require an ID. So the ID is only - # removed if it's the last key in the current context + if InfrahubNodeBase._keeps_unmodified_key(data_item=data[item], item_key=item_key): continue variable_key = None @@ -719,6 +849,68 @@ def generate_query_data_init( return data + def _generate_only_query_data_init( + self, + filters: dict[str, Any] | None, + offset: int | str | None, + limit: int | str | None, + partial_match: bool, + order: Order | None, + include_metadata: bool, + hfid: bool, + ) -> dict[str, Any]: + data = self.generate_query_data_init( + filters=filters, + offset=offset, + limit=limit, + partial_match=partial_match, + order=order, + include_metadata=include_metadata, + ) + if not hfid: + del data["edges"]["node"]["hfid"] + return data + + def _generate_only_attribute_data( + self, names: Iterable[str], property: bool, include_metadata: bool + ) -> dict[str, Any]: + return { + name: self._attribute_data[name]._generate_query_data(property=property, include_metadata=include_metadata) + for name in names + } + + def _mark_from_only(self, plan: OnlyPeerRelationships, peers: Mapping[str, Sequence[InfrahubNodeBase]]) -> None: + """Mark this node and the peers built from its data as coming from a query with `only`. + + `peers` maps each relationship in `plan` to the peers built for it. When the node's data contains + `hfid`, which the query requests only when `only` names it, the node keeps that value as its HFID. + """ + self._only_marker = plan.marker + if isinstance(self._data, dict) and "hfid" in self._data: + self._server_hfid = list(self._data["hfid"] or []) + for rel_name, rel_peers in peers.items(): + for peer in rel_peers: + peer._only_marker = plan.peer_markers[rel_name] + + def _hierarchy_peer_kind(self, name: str, hierarchy_kind: str) -> str: + """The kind of the peers of a hierarchy field: the declared peer when the kind has the field as a relationship.""" + if name in self._schema.relationship_names: + return self._schema.get_relationship(name=name).peer + return hierarchy_kind + + @staticmethod + def _build_hierarchy_query_data( + name: str, peer_kind: str, peer_data: dict[str, Any], property: bool + ) -> dict[str, Any]: + # `parent` is cardinality one, the other hierarchy fields are cardinality many. + if name == "parent": + rel_data = RelatedNodeBase._generate_query_data(peer_data=peer_data, property=property) + rel_data["node"] = {f"...on {peer_kind}": rel_data["node"]} + else: + rel_data = RelationshipManagerBase._generate_query_data(peer_data=peer_data, property=property) + rel_data["edges"]["node"] = {f"...on {peer_kind}": rel_data["edges"]["node"]} + return rel_data + def __hash__(self) -> int: return hash(self.id) @@ -901,11 +1093,14 @@ async def from_graphql( def _init_relationships(self, data: dict | RelatedNode | None = None) -> None: for rel_schema in self._schema.relationships: rel_data = data.get(rel_schema.name, None) if isinstance(data, dict) else None + present = isinstance(data, dict) and rel_schema.name in data if rel_schema.cardinality == RelationshipCardinality.ONE: - self._relationship_cardinality_one_data[rel_schema.name] = RelatedNode( + related_node = RelatedNode( name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=rel_data ) + related_node._bind(node=self, present=present) + self._relationship_cardinality_one_data[rel_schema.name] = related_node else: self._relationship_cardinality_many_data[rel_schema.name] = RelationshipManager( name=rel_schema.name, @@ -919,13 +1114,15 @@ def _init_relationships(self, data: dict | RelatedNode | None = None) -> None: for rel_schema in self._schema.hierarchical_relationship_schemas: rel_data = data.get(rel_schema.name, None) if isinstance(data, dict) else None if rel_schema.cardinality_is_one: - self._hierarchical_data[rel_schema.name] = RelatedNode( + hierarchy_node = RelatedNode( name=rel_schema.name, client=self._client, branch=self._branch, schema=rel_schema, data=rel_data, ) + hierarchy_node._bind(node=self, present=isinstance(data, dict) and rel_schema.name in data) + self._hierarchical_data[rel_schema.name] = hierarchy_node else: self._hierarchical_data[rel_schema.name] = RelationshipManager( name=rel_schema.name, @@ -967,6 +1164,7 @@ def __setattr__(self, name: str, value: Any) -> None: name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=value ) new_rel._peer_has_been_mutated = True + new_rel._bind(node=self, present=True) self._relationship_cardinality_one_data[name] = new_rel return @@ -1069,6 +1267,8 @@ async def download_file( FeatureNotSupportedError: If this node doesn't inherit from CoreFileObject. ValueError: If the node hasn't been saved yet, file not found, or ``skip_if_unchanged=True`` was passed without a ``dest``. + FieldNotLoadedError: ``skip_if_unchanged=True``, ``dest`` is an existing file, and the node + was queried with ``only`` without fetching ``checksum``. AuthenticationError: If authentication fails. Examples: @@ -1125,6 +1325,7 @@ async def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: FeatureNotSupportedError: Node is not a ``CoreFileObject``. ValueError: Node has no server-side checksum yet (unsaved or file never attached). + FieldNotLoadedError: The node was queried with ``only`` without fetching ``checksum``. """ self._validate_file_object_support(message=MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE) @@ -1173,6 +1374,7 @@ async def upload_if_changed( FeatureNotSupportedError: Node is not a ``CoreFileObject``. ValueError: ``source`` is ``bytes`` or ``BinaryIO`` and no ``name`` was supplied. + FieldNotLoadedError: The node was queried with ``only`` without fetching ``checksum``. """ self._validate_file_object_support(message=UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE) @@ -1323,21 +1525,12 @@ async def _process_hierarchical_fields( property=property, ) - # Parent is cardinality one, others are cardinality many - if hierarchical_name == "parent": - hierarchical_data = RelatedNode._generate_query_data(peer_data=peer_data, property=property) - # Use fragment for hierarchical fields similar to hierarchy relationships - data_node = hierarchical_data["node"] - hierarchical_data["node"] = {} - hierarchical_data["node"][f"...on {self._schema.hierarchy}"] = data_node # type: ignore[union-attr] - else: - hierarchical_data = RelationshipManager._generate_query_data(peer_data=peer_data, property=property) - # Use fragment for hierarchical fields similar to hierarchy relationships - data_node = hierarchical_data["edges"]["node"] - hierarchical_data["edges"]["node"] = {} - hierarchical_data["edges"]["node"][f"...on {self._schema.hierarchy}"] = data_node # type: ignore[union-attr] - - data[hierarchical_name] = hierarchical_data + data[hierarchical_name] = self._build_hierarchy_query_data( + name=hierarchical_name, + peer_kind=self._schema.hierarchy, # type: ignore[union-attr, arg-type] + peer_data=peer_data, + property=property, + ) if insert_alias: data[hierarchical_name]["@alias"] = f"__alias__{self._schema.kind}__{hierarchical_name}" @@ -1528,6 +1721,117 @@ async def generate_query_data_node( return data + async def _generate_only_query_data( + self, + fields: OnlyQueryFields, + filters: dict[str, Any] | None = None, + offset: int | str | None = None, + limit: int | str | None = None, + 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 GraphQL query payload that requests the fields in `fields` and nothing else. + + The node gets `id`, `display_label` and `__typename`, `hfid` when `fields.hfid` is set, plus the fields + of `fields.node`. Named relationships are references, and their peers get their default fields with + `prefetch_relationships`. For a generic kind, the fields of each kind in `fields.implementing_kinds` + are requested in an aliased `...on Kind` block. + """ + data = self._generate_only_query_data_init( + filters=filters, + offset=offset, + limit=limit, + partial_match=partial_match, + order=order, + include_metadata=include_metadata, + hfid=fields.hfid, + ) + node_data = data["edges"]["node"] + node_data.update( + await self._generate_only_query_data_node( + fields=fields.node, + prefetch_relationships=prefetch_relationships, + property=property, + include_metadata=include_metadata, + ) + ) + for kind_fields in fields.implementing_kinds.values(): + kind_node = InfrahubNode(client=self._client, schema=kind_fields.schema, branch=self._branch) + node_data[f"...on {kind_fields.schema.kind}"] = await kind_node._generate_only_query_data_node( + fields=kind_fields, + prefetch_relationships=prefetch_relationships, + property=property, + include_metadata=include_metadata, + insert_alias=True, + ) + + return {self._schema.kind: data} + + async def _generate_only_query_data_node( + self, + fields: KindFields, + prefetch_relationships: bool, + property: bool, + include_metadata: bool, + insert_alias: bool = False, + ) -> dict[str, Any]: + data = self._generate_only_attribute_data( + names=fields.attributes, property=property, include_metadata=include_metadata + ) + + for rel_name in fields.relationships: + rel_schema = self._schema.get_relationship(name=rel_name) + peer_data = await self._generate_only_peer_data( + peer_kind=rel_schema.peer, + prefetch_relationships=prefetch_relationships, + property=property, + include_metadata=include_metadata, + ) + rel_data = self._build_rel_query_data(rel_schema, peer_data, property, include_metadata) + if rel_data is not None: + data[rel_name] = rel_data + + if fields.hierarchy_kind is not None: + for hierarchical_name in fields.hierarchy_fields: + peer_kind = self._hierarchy_peer_kind(name=hierarchical_name, hierarchy_kind=fields.hierarchy_kind) + # With prefetch, hierarchy peers get their default fields except their own hierarchy fields. + peer_data = await self._generate_only_peer_data( + peer_kind=peer_kind, + prefetch_relationships=prefetch_relationships, + property=property, + include_metadata=False, + exclude=list(HIERARCHY_FIELD_NAMES), + ) + data[hierarchical_name] = self._build_hierarchy_query_data( + name=hierarchical_name, peer_kind=peer_kind, peer_data=peer_data, property=property + ) + + if insert_alias: + for name, field_data in data.items(): + field_data["@alias"] = f"__alias__{self._schema.kind}__{name}" + + return data + + async def _generate_only_peer_data( + self, + peer_kind: str, + prefetch_relationships: bool, + property: bool, + include_metadata: bool, + exclude: list[str] | None = None, + ) -> dict[str, Any]: + if not prefetch_relationships: + return {} + + peer_schema = await self._client.schema.get(kind=peer_kind, branch=self._branch) + peer_node = InfrahubNode(client=self._client, schema=peer_schema, branch=self._branch) + return await peer_node.generate_query_data_node( + exclude=exclude, property=property, include_metadata=include_metadata + ) + async def add_relationships(self, relation_to_update: str, related_nodes: list[str]) -> None: """Add peers to a cardinality-many relationship through a dedicated mutation. @@ -1591,6 +1895,7 @@ async def _process_mutation_result( for attr_name in self._attributes: attr = getattr(self, attr_name) + attr._mark_pool_write_sent() if attr_name not in object_response or not attr.is_from_pool_attribute(): continue @@ -1776,6 +2081,7 @@ async def _process_relationships( related_nodes: list[InfrahubNode], timeout: int | None = None, recursive: bool = False, + names: Iterable[str] | None = None, ) -> None: """Processes the Relationships of a InfrahubNode and add Related Nodes to a list. @@ -1785,12 +2091,21 @@ async def _process_relationships( related_nodes (list[InfrahubNode]): The list to which related nodes will be appended. timeout (int, optional): Overrides default timeout used when querying the graphql API. Specified in seconds. recursive:(bool): Whether to recursively process relationships of related nodes. + names (Iterable[str], optional): Process only these relationships, reading their peers from the node's + own data, where the fields that only an implementing kind has carry their plain names. """ - for rel_name in self._relationships: + if names is None: + relationship_names: Iterable[str] = self._relationships + data: dict[str, Any] = node_data["node"] + else: + relationship_names = names + data = self._data or {} + + for rel_name in relationship_names: rel = getattr(self, rel_name) if rel and isinstance(rel, RelatedNode): - relation = node_data["node"].get(rel_name, None) + relation = data.get(rel_name, None) if relation and relation.get("node", None): related_node = await InfrahubNode.from_graphql( client=self._client, @@ -1807,7 +2122,7 @@ async def _process_relationships( recursive=recursive, ) elif rel and isinstance(rel, RelationshipManager): - peers = node_data["node"].get(rel_name, None) + peers = data.get(rel_name, None) if peers and peers["edges"]: for peer in peers["edges"]: related_node = await InfrahubNode.from_graphql( @@ -1841,32 +2156,52 @@ async def get_pool_allocated_resources(self, resource: InfrahubNode) -> list[Inf if not self.is_resource_pool(): raise ValueError("Allocated resources can only be fetched from resource pool nodes.") - graphql_query_name = "InfrahubResourcePoolAllocated" + rows = await self._get_pool_allocated_rows( + resource=resource, edge_fields=POOL_ALLOCATED_RESOURCE_FIELDS, tracker="get-allocated-resources" + ) node_ids_per_kind: dict[str, list[str]] = {} + for row in rows: + node_ids_per_kind.setdefault(row["kind"], []).append(row["id"]) - 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}, + nodes: list[InfrahubNode] = [] + for kind, node_ids in node_ids_per_kind.items(): + nodes.extend(await self._client.filters(kind=kind, branch=self._branch, ids=node_ids)) + + return nodes + + async def get_pool_allocations(self, resource: InfrahubNode) -> list[PoolAllocation]: + """Fetch the resources the pool holds for a given resource, with how each one came to be held. + + Requires Infrahub 1.12 or later. Unlike :meth:`get_pool_allocated_resources`, it does not fetch the + allocated nodes. + + Args: + resource (InfrahubNode): The resource from which the nodes were allocated: the pool itself, or one of + its ranges or prefixes. + + Returns: + list[PoolAllocation]: One entry per resource the pool holds. + + Raises: + ValueError: If the node is not a resource pool. + + """ + if not self.is_resource_pool(): + raise ValueError("Pool allocations can only be fetched from resource pool nodes.") + + rows = await self._get_pool_allocated_rows( + resource=resource, edge_fields=POOL_ALLOCATION_FIELDS, tracker="get-pool-allocations" ) - query_str = query.render() + return [PoolAllocation.from_graphql(row) for row in rows] - has_remaining_items = True + async def _get_pool_allocated_rows( + self, resource: InfrahubNode, edge_fields: Sequence[str], tracker: str + ) -> list[dict[str, Any]]: + query_str = self._pool_allocated_query(edge_fields=edge_fields) + rows: list[dict[str, Any]] = [] page_number = 1 - while has_remaining_items: + while True: page_offset = (page_number - 1) * self._client.pagination_size - response = await self._client.execute_graphql( query=query_str, variables={ @@ -1876,27 +2211,13 @@ async def get_pool_allocated_resources(self, resource: InfrahubNode) -> list[Inf "limit": self._client.pagination_size, }, branch_name=self._branch, - tracker=f"get-allocated-resources-page{page_number}", - ) - - for edge in response[graphql_query_name]["edges"]: - node = edge["node"] - node_ids_per_kind.setdefault(node["kind"], []).append(node["id"]) - - remaining_items = response[graphql_query_name].get("count", 0) - ( - page_offset + self._client.pagination_size + tracker=f"{tracker}-page{page_number}", ) - if remaining_items <= 0: - has_remaining_items = False - + rows.extend(edge["node"] for edge in response[POOL_ALLOCATED_QUERY_NAME]["edges"]) + if response[POOL_ALLOCATED_QUERY_NAME].get("count", 0) <= page_offset + self._client.pagination_size: + return rows page_number += 1 - nodes: list[InfrahubNode] = [] - for kind, node_ids in node_ids_per_kind.items(): - nodes.extend(await self._client.filters(kind=kind, branch=self._branch, ids=node_ids)) - - return nodes - async def get_pool_resources_utilization(self) -> list[dict[str, Any]]: """Fetch the utilization of each resource for the pool. @@ -1973,6 +2294,8 @@ async def get_flat_value(self, key: str, separator: str = "__") -> Any: Raises: ValueError: If a component does not match an attribute or relationship, or if a relationship hop targets a non cardinality-one relationship. + FieldNotLoadedError: This node or a related node was queried with ``only`` and a field + the key reads was not fetched. Examples: name__value @@ -2124,11 +2447,14 @@ def from_graphql( def _init_relationships(self, data: dict | RelatedNodeSync | None = None) -> None: for rel_schema in self._schema.relationships: rel_data = data.get(rel_schema.name, None) if isinstance(data, dict) else None + present = isinstance(data, dict) and rel_schema.name in data if rel_schema.cardinality == RelationshipCardinality.ONE: - self._relationship_cardinality_one_data[rel_schema.name] = RelatedNodeSync( + related_node = RelatedNodeSync( name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=rel_data ) + related_node._bind(node=self, present=present) + self._relationship_cardinality_one_data[rel_schema.name] = related_node else: self._relationship_cardinality_many_data[rel_schema.name] = RelationshipManagerSync( name=rel_schema.name, @@ -2143,13 +2469,15 @@ def _init_relationships(self, data: dict | RelatedNodeSync | None = None) -> Non for rel_schema in self._schema.hierarchical_relationship_schemas: rel_data = data.get(rel_schema.name, None) if isinstance(data, dict) else None if rel_schema.cardinality_is_one: - self._hierarchical_data[rel_schema.name] = RelatedNodeSync( + hierarchy_node = RelatedNodeSync( name=rel_schema.name, client=self._client, branch=self._branch, schema=rel_schema, data=rel_data, ) + hierarchy_node._bind(node=self, present=isinstance(data, dict) and rel_schema.name in data) + self._hierarchical_data[rel_schema.name] = hierarchy_node else: self._hierarchical_data[rel_schema.name] = RelationshipManagerSync( name=rel_schema.name, @@ -2191,6 +2519,7 @@ def __setattr__(self, name: str, value: Any) -> None: name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=value ) new_rel._peer_has_been_mutated = True + new_rel._bind(node=self, present=True) self._relationship_cardinality_one_data[name] = new_rel return @@ -2290,6 +2619,8 @@ def download_file( FeatureNotSupportedError: If this node doesn't inherit from CoreFileObject. ValueError: If the node hasn't been saved yet, file not found, or ``skip_if_unchanged=True`` was passed without a ``dest``. + FieldNotLoadedError: ``skip_if_unchanged=True``, ``dest`` is an existing file, and the node + was queried with ``only`` without fetching ``checksum``. AuthenticationError: If authentication fails. Examples: @@ -2346,6 +2677,7 @@ def matches_local_checksum(self, source: bytes | Path | BinaryIO) -> bool: FeatureNotSupportedError: Node is not a ``CoreFileObject``. ValueError: Node has no server-side checksum yet (unsaved or file never attached). + FieldNotLoadedError: The node was queried with ``only`` without fetching ``checksum``. """ self._validate_file_object_support(message=MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE) @@ -2394,6 +2726,7 @@ def upload_if_changed( FeatureNotSupportedError: Node is not a ``CoreFileObject``. ValueError: ``source`` is ``bytes`` or ``BinaryIO`` and no ``name`` was supplied. + FieldNotLoadedError: The node was queried with ``only`` without fetching ``checksum``. """ self._validate_file_object_support(message=UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE) @@ -2538,21 +2871,12 @@ def _process_hierarchical_fields( property=property, ) - # Parent is cardinality one, others are cardinality many - if hierarchical_name == "parent": - hierarchical_data = RelatedNodeSync._generate_query_data(peer_data=peer_data, property=property) - # Use fragment for hierarchical fields similar to hierarchy relationships - data_node = hierarchical_data["node"] - hierarchical_data["node"] = {} - hierarchical_data["node"][f"...on {self._schema.hierarchy}"] = data_node # type: ignore[union-attr] - else: - hierarchical_data = RelationshipManagerSync._generate_query_data(peer_data=peer_data, property=property) - # Use fragment for hierarchical fields similar to hierarchy relationships - data_node = hierarchical_data["edges"]["node"] - hierarchical_data["edges"]["node"] = {} - hierarchical_data["edges"]["node"][f"...on {self._schema.hierarchy}"] = data_node # type: ignore[union-attr] - - data[hierarchical_name] = hierarchical_data + data[hierarchical_name] = self._build_hierarchy_query_data( + name=hierarchical_name, + peer_kind=self._schema.hierarchy, # type: ignore[union-attr, arg-type] + peer_data=peer_data, + property=property, + ) if insert_alias: data[hierarchical_name]["@alias"] = f"__alias__{self._schema.kind}__{hierarchical_name}" @@ -2742,6 +3066,115 @@ def generate_query_data_node( return data + def _generate_only_query_data( + self, + fields: OnlyQueryFields, + filters: dict[str, Any] | None = None, + offset: int | str | None = None, + limit: int | str | None = None, + 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 GraphQL query payload that requests the fields in `fields` and nothing else. + + The node gets `id`, `display_label` and `__typename`, `hfid` when `fields.hfid` is set, plus the fields + of `fields.node`. Named relationships are references, and their peers get their default fields with + `prefetch_relationships`. For a generic kind, the fields of each kind in `fields.implementing_kinds` + are requested in an aliased `...on Kind` block. + """ + data = self._generate_only_query_data_init( + filters=filters, + offset=offset, + limit=limit, + partial_match=partial_match, + order=order, + include_metadata=include_metadata, + hfid=fields.hfid, + ) + node_data = data["edges"]["node"] + node_data.update( + self._generate_only_query_data_node( + fields=fields.node, + prefetch_relationships=prefetch_relationships, + property=property, + include_metadata=include_metadata, + ) + ) + for kind_fields in fields.implementing_kinds.values(): + kind_node = InfrahubNodeSync(client=self._client, schema=kind_fields.schema, branch=self._branch) + node_data[f"...on {kind_fields.schema.kind}"] = kind_node._generate_only_query_data_node( + fields=kind_fields, + prefetch_relationships=prefetch_relationships, + property=property, + include_metadata=include_metadata, + insert_alias=True, + ) + + return {self._schema.kind: data} + + def _generate_only_query_data_node( + self, + fields: KindFields, + prefetch_relationships: bool, + property: bool, + include_metadata: bool, + insert_alias: bool = False, + ) -> dict[str, Any]: + data = self._generate_only_attribute_data( + names=fields.attributes, property=property, include_metadata=include_metadata + ) + + for rel_name in fields.relationships: + rel_schema = self._schema.get_relationship(name=rel_name) + peer_data = self._generate_only_peer_data( + peer_kind=rel_schema.peer, + prefetch_relationships=prefetch_relationships, + property=property, + include_metadata=include_metadata, + ) + rel_data = self._build_rel_query_data(rel_schema, peer_data, property, include_metadata) + if rel_data is not None: + data[rel_name] = rel_data + + if fields.hierarchy_kind is not None: + for hierarchical_name in fields.hierarchy_fields: + peer_kind = self._hierarchy_peer_kind(name=hierarchical_name, hierarchy_kind=fields.hierarchy_kind) + # With prefetch, hierarchy peers get their default fields except their own hierarchy fields. + peer_data = self._generate_only_peer_data( + peer_kind=peer_kind, + prefetch_relationships=prefetch_relationships, + property=property, + include_metadata=False, + exclude=list(HIERARCHY_FIELD_NAMES), + ) + data[hierarchical_name] = self._build_hierarchy_query_data( + name=hierarchical_name, peer_kind=peer_kind, peer_data=peer_data, property=property + ) + + if insert_alias: + for name, field_data in data.items(): + field_data["@alias"] = f"__alias__{self._schema.kind}__{name}" + + return data + + def _generate_only_peer_data( + self, + peer_kind: str, + prefetch_relationships: bool, + property: bool, + include_metadata: bool, + exclude: list[str] | None = None, + ) -> dict[str, Any]: + if not prefetch_relationships: + return {} + + peer_schema = self._client.schema.get(kind=peer_kind, branch=self._branch) + peer_node = InfrahubNodeSync(client=self._client, schema=peer_schema, branch=self._branch) + return peer_node.generate_query_data_node(exclude=exclude, property=property, include_metadata=include_metadata) + def add_relationships( self, relation_to_update: str, @@ -2809,6 +3242,7 @@ def _process_mutation_result( for attr_name in self._attributes: attr = getattr(self, attr_name) + attr._mark_pool_write_sent() if attr_name not in object_response or not attr.is_from_pool_attribute(): continue @@ -2994,6 +3428,7 @@ def _process_relationships( related_nodes: list[InfrahubNodeSync], timeout: int | None = None, recursive: bool = False, + names: Iterable[str] | None = None, ) -> None: """Processes the Relationships of a InfrahubNodeSync and add Related Nodes to a list. @@ -3003,12 +3438,21 @@ def _process_relationships( related_nodes (list[InfrahubNodeSync]): The list to which related nodes will be appended. timeout (int, optional): Overrides default timeout used when querying the graphql API. Specified in seconds. recursive:(bool): Whether to recursively process relationships of related nodes. + names (Iterable[str], optional): Process only these relationships, reading their peers from the node's + own data, where the fields that only an implementing kind has carry their plain names. """ - for rel_name in self._relationships: + if names is None: + relationship_names: Iterable[str] = self._relationships + data: dict[str, Any] = node_data["node"] + else: + relationship_names = names + data = self._data or {} + + for rel_name in relationship_names: rel = getattr(self, rel_name) if rel and isinstance(rel, RelatedNodeSync): - relation = node_data["node"].get(rel_name, None) + relation = data.get(rel_name, None) if relation and relation.get("node", None): related_node = InfrahubNodeSync.from_graphql( client=self._client, @@ -3025,7 +3469,7 @@ def _process_relationships( recursive=recursive, ) elif rel and isinstance(rel, RelationshipManagerSync): - peers = node_data["node"].get(rel_name, None) + peers = data.get(rel_name, None) if peers and peers["edges"]: for peer in peers["edges"]: related_node = InfrahubNodeSync.from_graphql( @@ -3059,32 +3503,52 @@ def get_pool_allocated_resources(self, resource: InfrahubNodeSync) -> list[Infra if not self.is_resource_pool(): raise ValueError("Allocated resources can only be fetched from resource pool nodes.") - graphql_query_name = "InfrahubResourcePoolAllocated" + rows = self._get_pool_allocated_rows( + resource=resource, edge_fields=POOL_ALLOCATED_RESOURCE_FIELDS, tracker="get-allocated-resources" + ) node_ids_per_kind: dict[str, list[str]] = {} + for row in rows: + node_ids_per_kind.setdefault(row["kind"], []).append(row["id"]) - 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}, + nodes: list[InfrahubNodeSync] = [] + for kind, node_ids in node_ids_per_kind.items(): + nodes.extend(self._client.filters(kind=kind, branch=self._branch, ids=node_ids)) + + return nodes + + def get_pool_allocations(self, resource: InfrahubNodeSync) -> list[PoolAllocation]: + """Fetch the resources the pool holds for a given resource, with how each one came to be held. + + Requires Infrahub 1.12 or later. Unlike :meth:`get_pool_allocated_resources`, it does not fetch the + allocated nodes. + + Args: + resource (InfrahubNodeSync): The resource from which the nodes were allocated: the pool itself, or one + of its ranges or prefixes. + + Returns: + list[PoolAllocation]: One entry per resource the pool holds. + + Raises: + ValueError: If the node is not a resource pool. + + """ + if not self.is_resource_pool(): + raise ValueError("Pool allocations can only be fetched from resource pool nodes.") + + rows = self._get_pool_allocated_rows( + resource=resource, edge_fields=POOL_ALLOCATION_FIELDS, tracker="get-pool-allocations" ) - query_str = query.render() + return [PoolAllocation.from_graphql(row) for row in rows] - has_remaining_items = True + def _get_pool_allocated_rows( + self, resource: InfrahubNodeSync, edge_fields: Sequence[str], tracker: str + ) -> list[dict[str, Any]]: + query_str = self._pool_allocated_query(edge_fields=edge_fields) + rows: list[dict[str, Any]] = [] page_number = 1 - while has_remaining_items: + while True: page_offset = (page_number - 1) * self._client.pagination_size - response = self._client.execute_graphql( query=query_str, variables={ @@ -3094,27 +3558,13 @@ def get_pool_allocated_resources(self, resource: InfrahubNodeSync) -> list[Infra "limit": self._client.pagination_size, }, branch_name=self._branch, - tracker=f"get-allocated-resources-page{page_number}", - ) - - for edge in response[graphql_query_name]["edges"]: - node = edge["node"] - node_ids_per_kind.setdefault(node["kind"], []).append(node["id"]) - - remaining_items = response[graphql_query_name].get("count", 0) - ( - page_offset + self._client.pagination_size + tracker=f"{tracker}-page{page_number}", ) - if remaining_items <= 0: - has_remaining_items = False - + rows.extend(edge["node"] for edge in response[POOL_ALLOCATED_QUERY_NAME]["edges"]) + if response[POOL_ALLOCATED_QUERY_NAME].get("count", 0) <= page_offset + self._client.pagination_size: + return rows page_number += 1 - nodes: list[InfrahubNodeSync] = [] - for kind, node_ids in node_ids_per_kind.items(): - nodes.extend(self._client.filters(kind=kind, branch=self._branch, ids=node_ids)) - - return nodes - def get_pool_resources_utilization(self) -> list[dict[str, Any]]: """Fetch the utilization of each resource for the pool. @@ -3191,6 +3641,8 @@ def get_flat_value(self, key: str, separator: str = "__") -> Any: Raises: ValueError: If a component does not match an attribute or relationship, or if a relationship hop targets a non cardinality-one relationship. + FieldNotLoadedError: This node or a related node was queried with ``only`` and a field + the key reads was not fetched. Examples: name__value diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index e61322bdb..4589e2355 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -7,7 +7,12 @@ from ..exceptions import Error from ..protocols_base import CoreNodeBase -from .constants import PROFILE_KIND_PREFIX, PROPERTIES_FLAG, PROPERTIES_OBJECT +from .constants import ( + PROFILE_KIND_PREFIX, + PROPERTIES_FLAG, + PROPERTIES_OBJECT, +) +from .inline_peers import PEER_REFERENCE_KEYS, peers_are_inline_input from .metadata import NodeMetadata, RelationshipMetadata if TYPE_CHECKING: @@ -24,19 +29,56 @@ PeerTSync = TypeVar("PeerTSync", default="InfrahubNodeSync") +def _is_peer_node(data: Any) -> bool: + """True when the data is a peer node instance rather than raw relationship data.""" + # Imported lazily to avoid a circular import: node.py imports this module at load time. + from .node import InfrahubNodeBase # noqa: PLC0415 + + return isinstance(data, (CoreNodeBase, InfrahubNodeBase)) + + +def _inline_input(schema: RelationshipSchemaAPI, data: Any) -> dict[str, Any] | None: + """Return the peer's fields when its relationship takes them as mutation input instead of a reference.""" + if peers_are_inline_input(schema) and isinstance(data, dict) and not data.keys() & PEER_REFERENCE_KEYS: + return dict(data) + return None + + +def _peer_input(data: Any) -> Any: + """Narrow another related node to its peer identifiers; pass anything else through. + + The edge properties of the source relationship do not carry over to the new one. + """ + if not isinstance(data, RelatedNodeBase): + return data + return { + key: value + for key, value in ( + ("id", data.id), + ("hfid", data.hfid), + ("__typename", data.typename), + ("kind", data.kind), + ("display_label", data.display_label), + ) + if value is not None + } + + class RelatedNodeBase: """Base class for representing a related node in a relationship. A ``RelatedNodeBase`` is the peer end of a cardinality-one relationship. It carries the lightweight identification of the peer (``id``, ``hfid``, ``typename``, ...) along with the relationship-edge properties (``source``, ``owner``, ``is_protected``, ...). - The full peer node is fetched lazily through :meth:`RelatedNode.fetch` / + The peer node is fetched lazily through :meth:`RelatedNode.fetch` / :meth:`RelatedNodeSync.fetch`. Attributes: schema (RelationshipSchemaAPI): The schema describing the relationship. name (str | None): The name of the relationship slot on the parent node. updated_at (str | None): ISO-8601 timestamp of the most recent edge update. + is_loaded (bool): True when the node's data contained the relationship, it was assigned since, + it does not belong to a node, or the node has no ``id`` yet. """ @@ -57,6 +99,8 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self.name = name self._branch = branch + self._node: InfrahubNodeBase | None = None + self._has_data = False self._properties_flag = PROPERTIES_FLAG self._properties_object = PROPERTIES_OBJECT @@ -75,29 +119,13 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, # so we don't silently null-clear unfetched relationships on save. self._peer_has_been_mutated: bool = False - # Copy only the peer identifiers: the edge properties belong to the source relationship. - if isinstance(data, RelatedNodeBase): - data = { - key: value - for key, value in ( - ("id", data.id), - ("hfid", data.hfid), - ("__typename", data.typename), - ("kind", data.kind), - ("display_label", data.display_label), - ) - if value is not None - } - - # Detect node instances. InfrahubNodeBase is imported lazily here to avoid a - # circular import (node.py imports this module at load time). - from .node import InfrahubNodeBase as _InfrahubNodeBase # noqa: PLC0415 - - if isinstance(data, (CoreNodeBase, _InfrahubNodeBase)): + data = _peer_input(data) + self._inline_input = _inline_input(schema=schema, data=data) + + if _is_peer_node(data): self._peer = cast("InfrahubNodeBase | CoreNodeBase", data) for prop in self._properties: setattr(self, prop, None) - self._relationship_metadata = None elif isinstance(data, list): data = {"hfid": data} @@ -149,7 +177,11 @@ def id(self) -> str | None: Returns: str | None: The peer node ID, or ``None`` when neither the peer nor an ID is set. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ + self._raise_if_not_loaded() if self._peer: return self._peer.id return self._id @@ -158,12 +190,23 @@ def id(self) -> str | None: def hfid(self) -> list[Any] | None: """Return the human-friendly ID of the related node. + When the cached peer was fetched with ``only`` and has no HFID, because the response did not + contain one and the fields it carries do not give one, the HFID the relationship was loaded with + is returned instead. + Returns: list[Any] | None: The peer HFID as a list of components, or ``None`` when not set. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ + self._raise_if_not_loaded() if self._peer: - return self._peer.hfid + peer_hfid = self._peer.hfid + if peer_hfid is None and self._peer_is_from_only: + return self._hfid + return peer_hfid return self._hfid @property @@ -171,17 +214,32 @@ def hfid_str(self) -> str | None: """Return the human-friendly ID of the related node as a separator-joined string. The returned string includes the kind prefix and is therefore suitable as a key - for the client store. + for the client store. When the cached peer was fetched with ``only`` and has no HFID, + because the response did not contain one and the fields it carries do not give one, the + string is built from the HFID the relationship was loaded with. Returns: str | None: The peer HFID joined with the HFID separator, or ``None`` when unavailable (no resolved peer or missing HFID). + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ - if self._peer and self.hfid: + self._raise_if_not_loaded() + if not self._peer: + return None + if self._peer.hfid: return self._peer.get_human_friendly_id_as_string(include_kind=True) + if self._peer_is_from_only and self._hfid: + return "__".join([self._peer.get_kind(), *(str(part) for part in self._hfid)]) return None + @property + def _peer_is_from_only(self) -> bool: + # The cached peer can be typed as a protocol, which does not declare the marker. + return getattr(self._peer, "_only_marker", None) is not None + @property def is_resource_pool(self) -> bool: """Return whether the related node is a resource pool. @@ -202,7 +260,32 @@ def initialized(self) -> bool: bool: ``True`` when an ID or HFID is known and the relationship can be referenced. """ - return bool(self.id) or bool(self.hfid) + if self._peer: + return bool(self._peer.id) or bool(self._peer.hfid) + return bool(self._id) or bool(self._hfid) + + @property + def is_loaded(self) -> bool: + """Return whether this relationship holds the peer from its node's data or from an assignment. + + Never raises and sends no request. + + Returns: + bool: ``True`` when the data the node was built from contained the relationship, even + without a peer, when the relationship was assigned since, when it does not belong to a + node (such as a peer inside a cardinality-many relationship), or when its node has no + ``id`` yet. ``False`` otherwise. + + """ + return self._has_data or self._node is None or not self._node.id + + def _bind(self, node: InfrahubNodeBase, present: bool) -> None: + self._node = node + self._has_data = present + + def _raise_if_not_loaded(self) -> None: + if not self.is_loaded and self._node is not None and self._node._only_marker is not None: + raise self._node._only_marker.not_loaded_error(kind=self._node.get_kind(), field=self.schema.name) @property def display_label(self) -> str | None: @@ -211,7 +294,11 @@ def display_label(self) -> str | None: Returns: str | None: The peer display label, or ``None`` when not provided. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ + self._raise_if_not_loaded() if self._peer: return self._peer.display_label return self._display_label @@ -223,7 +310,11 @@ def typename(self) -> str | None: Returns: str | None: The peer typename, or ``None`` when not provided. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ + self._raise_if_not_loaded() if self._peer: return self._peer.typename return self._typename @@ -235,7 +326,11 @@ def kind(self) -> str | None: Returns: str | None: The peer schema kind, or ``None`` when not provided. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ + self._raise_if_not_loaded() if self._peer: return self._peer.get_kind() return self._kind @@ -268,6 +363,9 @@ def get_relationship_metadata(self) -> RelationshipMetadata | None: return self._relationship_metadata def _generate_input_data(self, allocate_from_pool: bool = False) -> dict[str, Any]: + if self._inline_input is not None: + return dict(self._inline_input) + data: dict[str, Any] = {} if self.is_resource_pool and allocate_from_pool: @@ -363,8 +461,10 @@ def __init__( self._client = client super().__init__(branch=branch, schema=schema, data=data, name=name) - 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. + async def fetch( + self, timeout: int | None = None, priority: Priority | None = None, only: list[str] | None = None + ) -> None: + """Fetch the peer with its default fields, or only the fields named in ``only``, and cache it on this object. After ``fetch()`` completes, attribute and relationship access on the peer is available via :attr:`peer` or :meth:`get`. @@ -373,16 +473,32 @@ async def fetch(self, timeout: int | None = None, priority: Priority | None = No 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. + only (list[str], optional): The only attributes and relationships to fetch on the peer, which also gets + its ``id``, ``display_label`` and ``__typename``. Reading a field of the peer that was not fetched + raises FieldNotLoadedError. Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + FieldNotFoundError: A name in ``only`` is not a field of the peer's kind; raised before the peer is + queried. + TypeError: ``only`` is a string instead of a list of field names; raised before the peer is queried. Error: If neither ``id`` nor ``typename`` is set on this related node. """ + self._raise_if_not_loaded() if not self.id or not self.typename: raise Error("Unable to fetch the peer, id and/or typename are not defined") + # Pass `only` only when set, so client overrides written before it keep working. + only_kwargs: dict[str, Any] = {} if only is None else {"only": only} self._peer = await self._client.get( - kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout, priority=priority + kind=self.typename, + id=self.id, + populate_store=True, + branch=self._branch, + timeout=timeout, + priority=priority, + **only_kwargs, ) @property @@ -395,6 +511,9 @@ def peer(self) -> PeerT: Returns: PeerT: The resolved peer node. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ return self.get() @@ -415,9 +534,11 @@ def get(self) -> PeerT: PeerT: The resolved peer node. Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. ValueError: If neither an ID nor an HFID is available to look up the peer. """ + self._raise_if_not_loaded() if self._peer: return cast("PeerT", self._peer) @@ -460,8 +581,10 @@ def __init__( self._client = client super().__init__(branch=branch, schema=schema, data=data, name=name) - 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. + def fetch( + self, timeout: int | None = None, priority: Priority | None = None, only: list[str] | None = None + ) -> None: + """Fetch the peer with its default fields, or only the fields named in ``only``, and cache it on this object. After ``fetch()`` completes, attribute and relationship access on the peer is available via :attr:`peer` or :meth:`get`. @@ -470,16 +593,32 @@ def fetch(self, timeout: int | None = None, priority: Priority | None = None) -> 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. + only (list[str], optional): The only attributes and relationships to fetch on the peer, which also gets + its ``id``, ``display_label`` and ``__typename``. Reading a field of the peer that was not fetched + raises FieldNotLoadedError. Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + FieldNotFoundError: A name in ``only`` is not a field of the peer's kind; raised before the peer is + queried. + TypeError: ``only`` is a string instead of a list of field names; raised before the peer is queried. Error: If neither ``id`` nor ``typename`` is set on this related node. """ + self._raise_if_not_loaded() if not self.id or not self.typename: raise Error("Unable to fetch the peer, id and/or typename are not defined") + # Pass `only` only when set, so client overrides written before it keep working. + only_kwargs: dict[str, Any] = {} if only is None else {"only": only} self._peer = self._client.get( - kind=self.typename, id=self.id, populate_store=True, branch=self._branch, timeout=timeout, priority=priority + kind=self.typename, + id=self.id, + populate_store=True, + branch=self._branch, + timeout=timeout, + priority=priority, + **only_kwargs, ) @property @@ -492,6 +631,9 @@ def peer(self) -> PeerTSync: Returns: PeerTSync: The resolved peer node. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ return self.get() @@ -512,9 +654,11 @@ def get(self) -> PeerTSync: PeerTSync: The resolved peer node. Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. ValueError: If neither an ID nor an HFID is available to look up the peer. """ + self._raise_if_not_loaded() if self._peer: return cast("PeerTSync", self._peer) diff --git a/infrahub_sdk/node/relationship.py b/infrahub_sdk/node/relationship.py index 4e47d4ff1..23ea291dc 100644 --- a/infrahub_sdk/node/relationship.py +++ b/infrahub_sdk/node/relationship.py @@ -6,16 +6,23 @@ from ..exceptions import ( Error, + FeatureNotSupportedError, UninitializedError, ) +from ..schema import GenericSchemaAPI from ..types import Order -from .constants import PROPERTIES_FLAG, PROPERTIES_OBJECT +from ._only import check_only_names, check_only_names_on_every_kind +from .constants import ( + PROPERTIES_FLAG, + PROPERTIES_OBJECT, +) +from .inline_peers import INLINE_INPUT_PEERS_FEATURE_NOT_SUPPORTED_MESSAGE, peers_are_inline_input from .metadata import NodeMetadata, RelationshipMetadata from .related_node import PeerT, PeerTSync, RelatedNode, RelatedNodeSync if TYPE_CHECKING: from ..client import InfrahubClient, InfrahubClientSync - from ..schema import RelationshipSchemaAPI + from ..schema import MainSchemaTypesAPI, RelationshipSchemaAPI from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync @@ -31,8 +38,11 @@ class RelationshipManagerBase(Generic[PeerT]): name (str): The name of the relationship slot on the parent node. schema (RelationshipSchemaAPI): The schema describing the relationship. branch (str): The branch the relationship is bound to. - peers (list[RelatedNode | RelatedNodeSync]): The current peer set. + peers (list[RelatedNode | RelatedNodeSync]): The current peer set. Assigning a list marks the + relationship as loaded. initialized (bool): ``True`` once the manager has been populated with data. + is_loaded (bool): ``True`` once the manager has been populated with data or its peers were + assigned, when it does not belong to a node, or when the node has no ``id`` yet. """ @@ -58,7 +68,58 @@ def __init__(self, name: str, branch: str, schema: RelationshipSchemaAPI) -> Non self._properties_object = PROPERTIES_OBJECT self._properties = self._properties_flag + self._properties_object - self.peers: list[RelatedNode[PeerT] | RelatedNodeSync[PeerT]] = [] + self._peers: list[RelatedNode[PeerT] | RelatedNodeSync[PeerT]] = [] + self._peers_assigned = False + + @property + def peers(self) -> list[RelatedNode[PeerT] | RelatedNodeSync[PeerT]]: + """Return the peer set. + + The list returned is the one the relationship holds, so changing it in place changes the + relationship. Editing it is not supported for a relationship that takes its peers as fields + instead of references: declare the complete list on the node instead. + + Returns: + list[RelatedNode | RelatedNodeSync]: The peers, in insertion order. + + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + + """ + self._raise_if_not_loaded() + return self._peers + + @peers.setter + def peers(self, value: list[RelatedNode[PeerT] | RelatedNodeSync[PeerT]]) -> None: + self._peers = value + self._peers_assigned = True + + @property + def is_loaded(self) -> bool: + """Return whether this relationship holds the peers from its node's data or from an assignment. + + Never raises and sends no request. + + Returns: + bool: ``True`` when the relationship is initialized (the data the node was built from + contained it, or :meth:`fetch` ran), when a list was assigned to ``peers``, when the + relationship does not belong to a node, or when its node has no ``id`` yet. ``False`` + otherwise. + + """ + return self.initialized or self._peers_assigned or self.node is None or not self.node.id + + @property + def _inline_input_message(self) -> str: + return INLINE_INPUT_PEERS_FEATURE_NOT_SUPPORTED_MESSAGE.format(name=self.name, peer=self.schema.peer) + + def _raise_if_peers_are_inline_input(self) -> None: + if peers_are_inline_input(self.schema): + raise FeatureNotSupportedError(self._inline_input_message) + + def _raise_if_not_loaded(self) -> None: + if not self.is_loaded and self.node is not None and self.node._only_marker is not None: + raise self.node._only_marker.not_loaded_error(kind=self.node.get_kind(), field=self.name) @property def peer_ids(self) -> list[str]: @@ -67,6 +128,9 @@ def peer_ids(self) -> list[str]: Returns: list[str]: The IDs of the peers, in insertion order. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ return [peer.id for peer in self.peers if peer.id] @@ -77,6 +141,9 @@ def peer_hfids(self) -> list[list[Any]]: Returns: list[list[Any]]: The HFIDs of the peers as lists of components, in insertion order. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ return [peer.hfid for peer in self.peers if peer.hfid] @@ -87,6 +154,9 @@ def peer_hfids_str(self) -> list[str]: Returns: list[str]: The HFIDs of the peers as ``Kind__part1__part2`` strings. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ return [peer.hfid_str for peer in self.peers if peer.hfid_str] @@ -110,14 +180,26 @@ def is_from_profile(self) -> bool: Returns: bool: ``True`` when at least one peer exists and all peers are from a profile. + Raises: + FieldNotLoadedError: The node was queried with ``only`` and the relationship was not fetched. + """ - if not self.peers: + self._raise_if_not_loaded() + if not self._peers: return False - all_profiles = [p.is_from_profile for p in self.peers] + all_profiles = [p.is_from_profile for p in self._peers] return bool(all_profiles) and all(all_profiles) def _generate_input_data(self, allocate_from_pool: bool = False) -> list[dict]: - return [peer._generate_input_data(allocate_from_pool=allocate_from_pool) for peer in self.peers] + return [peer._generate_input_data(allocate_from_pool=allocate_from_pool) for peer in self._peers] + + def _peer_ids_per_kind(self) -> dict[str, list[str]]: + ids_per_kind_map: dict[str, list[str]] = defaultdict(list) + for peer in self._peers: + if not peer.id or not peer.typename: + raise Error("Unable to fetch the peer, id and/or typename are not defined") + ids_per_kind_map[peer.typename].append(peer.id) + return ids_per_kind_map def _reload_kwargs(self) -> dict[str, Any]: """The arguments of the `get` that reloads `self.node` with this relationship only. @@ -128,6 +210,9 @@ def _reload_kwargs(self) -> dict[str, Any]: """ if self.node is None or not self.node.id: raise UninitializedError(f"Unable to fetch '{self.schema.name}' on a node that has not been saved") + if self.node._only_marker is not None: + # Keep the node from `only` in the store instead of replacing it with this narrower copy. + return {"only": [self.schema.name], "populate_store": False} schema = self.node._schema # `ancestors` and `descendants` are not schema relationships, so they may be missing from these names. exclude = [name for name in schema.relationship_names + schema.attribute_names if name != self.schema.name] @@ -226,7 +311,7 @@ def __init__( if isinstance(data, list): for item in data: - self.peers.append( + self._peers.append( cast( "RelatedNode[PeerT]", RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item), @@ -234,7 +319,7 @@ def __init__( ) elif isinstance(data, dict) and "edges" in data: for item in data["edges"]: - self.peers.append( + self._peers.append( cast( "RelatedNode[PeerT]", RelatedNode(name=name, client=self.client, branch=self.branch, schema=schema, data=item), @@ -248,20 +333,37 @@ def __init__( ) def __getitem__(self, item: int) -> RelatedNode[PeerT]: - return cast("RelatedNode[PeerT]", self.peers[item]) + self._raise_if_not_loaded() + return cast("RelatedNode[PeerT]", self._peers[item]) - async def fetch(self) -> None: - """Populate the peer set and resolve every peer to a full node. + async def fetch(self, only: list[str] | None = None) -> None: + """Populate the peer set and fetch every peer with its default fields, or only the fields named in ``only``. When the manager is not yet initialized, the parent node is re-queried with this - relationship included so the peer list can be populated. The peers are then - fetched in a parallel batch grouped by kind and stored in the client store. + relationship included so the peer list can be populated. When the parent node was + queried with ``only``, the re-query requests this relationship alone and does not + replace the parent node in the client store. The peers are then fetched in a + parallel batch grouped by kind and stored in the client store. + + Args: + only (list[str], optional): The only attributes and relationships to fetch on each peer, which also + gets its ``id``, ``display_label`` and ``__typename``. Every name must be a field of every peer's + kind. Reading a field of a peer that was not fetched raises FieldNotLoadedError. Raises: + FieldNotFoundError: A name in ``only`` is not a field of the relationship's peer kind, raised before + any request, or is missing on the kind of one of the peers, raised before any peer is queried. + TypeError: ``only`` is a string instead of a list of field names; raised before any request. UninitializedError: If the manager is not initialized and the parent node has no ``id``. Error: If any peer is missing an ``id`` or ``typename`` and cannot be resolved. """ + if only is not None: + peer_schema = await self.client.schema.get(kind=self.schema.peer, branch=self.branch) + check_only_names( + only=only, schema=peer_schema, implementing_schemas=await self._implementing_schemas(peer_schema) + ) + if not self.initialized: reload_kwargs = self._reload_kwargs() node = await self.client.get( @@ -271,15 +373,18 @@ async def fetch(self) -> None: **reload_kwargs, ) rm = getattr(node, self.schema.name) - self.peers = rm.peers + self._peers = rm._peers self.initialized = True - ids_per_kind_map = defaultdict(list) - for peer in self.peers: - if not peer.id or not peer.typename: - raise Error("Unable to fetch the peer, id and/or typename are not defined") - ids_per_kind_map[peer.typename].append(peer.id) + ids_per_kind_map = self._peer_ids_per_kind() + if only is not None: + check_only_names_on_every_kind( + only=only, + schemas=[await self.client.schema.get(kind=kind, branch=self.branch) for kind in ids_per_kind_map], + ) + # Pass `only` only when set, so client overrides written before it keep working. + only_kwargs: dict[str, Any] = {} if only is None else {"only": only} batch = await self.client.create_batch() for kind, ids in ids_per_kind_map.items(): batch.add( @@ -290,11 +395,17 @@ async def fetch(self) -> None: branch=self.branch, parallel=True, order=Order(disable=True), + **only_kwargs, ) async for _ in batch.execute(): pass + async def _implementing_schemas(self, schema: MainSchemaTypesAPI) -> list[MainSchemaTypesAPI]: + if not isinstance(schema, GenericSchemaAPI): + return [] + return [await self.client.schema.get(kind=kind, branch=self.branch) for kind in schema.used_by] + def add(self, data: str | RelatedNode | dict) -> None: """Add a new peer to this relationship. @@ -307,9 +418,11 @@ def add(self, data: str | RelatedNode | dict) -> None: or ``hfid`` keys, plus optional relationship properties). Raises: + FeatureNotSupportedError: If the relationship takes its peers as fields instead of references. UninitializedError: If :meth:`fetch` has not been called on this manager yet. """ + self._raise_if_peers_are_inline_input() if not self.initialized: raise UninitializedError("Must call fetch() on RelationshipManager before editing members") new_node = cast( @@ -319,7 +432,7 @@ def add(self, data: str | RelatedNode | dict) -> None: if (new_node.id and new_node.id not in self.peer_ids) or ( new_node.hfid and new_node.hfid not in self.peer_hfids ): - self.peers.append(new_node) + self._peers.append(new_node) self._has_update = True def extend(self, data: Iterable[str | RelatedNode | dict]) -> None: @@ -333,9 +446,11 @@ def extend(self, data: Iterable[str | RelatedNode | dict]) -> None: formats accepted by :meth:`add`. Raises: + FeatureNotSupportedError: If the relationship takes its peers as fields instead of references. UninitializedError: If :meth:`fetch` has not been called on this manager yet. """ + self._raise_if_peers_are_inline_input() for d in data: self.add(d) @@ -350,28 +465,30 @@ def remove(self, data: str | RelatedNode | dict) -> None: existing :class:`RelatedNode`, or a dict describing the peer. Raises: + FeatureNotSupportedError: If the relationship takes its peers as fields instead of references. UninitializedError: If :meth:`fetch` has not been called on this manager yet. IndexError: If the internal peer index is inconsistent with the lookup result. """ + self._raise_if_peers_are_inline_input() if not self.initialized: raise UninitializedError("Must call fetch() on RelationshipManager before editing members") node_to_remove = RelatedNode(schema=self.schema, client=self.client, branch=self.branch, data=data) if node_to_remove.id and node_to_remove.id in self.peer_ids: idx = self.peer_ids.index(node_to_remove.id) - if self.peers[idx].id != node_to_remove.id: + if self._peers[idx].id != node_to_remove.id: raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.id}") - self.peers.pop(idx) + self._peers.pop(idx) self._has_update = True elif node_to_remove.hfid and node_to_remove.hfid in self.peer_hfids: idx = self.peer_hfids.index(node_to_remove.hfid) - if self.peers[idx].hfid != node_to_remove.hfid: + if self._peers[idx].hfid != node_to_remove.hfid: raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.hfid}") - self.peers.pop(idx) + self._peers.pop(idx) self._has_update = True @@ -424,7 +541,7 @@ def __init__( if isinstance(data, list): for item in data: - self.peers.append( + self._peers.append( cast( "RelatedNodeSync[PeerTSync]", RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item), @@ -432,7 +549,7 @@ def __init__( ) elif isinstance(data, dict) and "edges" in data: for item in data["edges"]: - self.peers.append( + self._peers.append( cast( "RelatedNodeSync[PeerTSync]", RelatedNodeSync(name=name, client=self.client, branch=self.branch, schema=schema, data=item), @@ -446,20 +563,37 @@ def __init__( ) def __getitem__(self, item: int) -> RelatedNodeSync[PeerTSync]: - return cast("RelatedNodeSync[PeerTSync]", self.peers[item]) + self._raise_if_not_loaded() + return cast("RelatedNodeSync[PeerTSync]", self._peers[item]) - def fetch(self) -> None: - """Populate the peer set and resolve every peer to a full node. + def fetch(self, only: list[str] | None = None) -> None: + """Populate the peer set and fetch every peer with its default fields, or only the fields named in ``only``. When the manager is not yet initialized, the parent node is re-queried with this - relationship included so the peer list can be populated. The peers are then - fetched in a parallel batch grouped by kind and stored in the client store. + relationship included so the peer list can be populated. When the parent node was + queried with ``only``, the re-query requests this relationship alone and does not + replace the parent node in the client store. The peers are then fetched in a + parallel batch grouped by kind and stored in the client store. + + Args: + only (list[str], optional): The only attributes and relationships to fetch on each peer, which also + gets its ``id``, ``display_label`` and ``__typename``. Every name must be a field of every peer's + kind. Reading a field of a peer that was not fetched raises FieldNotLoadedError. Raises: + FieldNotFoundError: A name in ``only`` is not a field of the relationship's peer kind, raised before + any request, or is missing on the kind of one of the peers, raised before any peer is queried. + TypeError: ``only`` is a string instead of a list of field names; raised before any request. UninitializedError: If the manager is not initialized and the parent node has no ``id``. Error: If any peer is missing an ``id`` or ``typename`` and cannot be resolved. """ + if only is not None: + peer_schema = self.client.schema.get(kind=self.schema.peer, branch=self.branch) + check_only_names( + only=only, schema=peer_schema, implementing_schemas=self._implementing_schemas(peer_schema) + ) + if not self.initialized: reload_kwargs = self._reload_kwargs() node = self.client.get( @@ -469,15 +603,18 @@ def fetch(self) -> None: **reload_kwargs, ) rm = getattr(node, self.schema.name) - self.peers = rm.peers + self._peers = rm._peers self.initialized = True - ids_per_kind_map = defaultdict(list) - for peer in self.peers: - if not peer.id or not peer.typename: - raise Error("Unable to fetch the peer, id and/or typename are not defined") - ids_per_kind_map[peer.typename].append(peer.id) + ids_per_kind_map = self._peer_ids_per_kind() + if only is not None: + check_only_names_on_every_kind( + only=only, + schemas=[self.client.schema.get(kind=kind, branch=self.branch) for kind in ids_per_kind_map], + ) + # Pass `only` only when set, so client overrides written before it keep working. + only_kwargs: dict[str, Any] = {} if only is None else {"only": only} batch = self.client.create_batch() for kind, ids in ids_per_kind_map.items(): batch.add( @@ -488,11 +625,17 @@ def fetch(self) -> None: branch=self.branch, parallel=True, order=Order(disable=True), + **only_kwargs, ) for _ in batch.execute(): pass + def _implementing_schemas(self, schema: MainSchemaTypesAPI) -> list[MainSchemaTypesAPI]: + if not isinstance(schema, GenericSchemaAPI): + return [] + return [self.client.schema.get(kind=kind, branch=self.branch) for kind in schema.used_by] + def add(self, data: str | RelatedNodeSync | dict) -> None: """Add a new peer to this relationship. @@ -505,9 +648,11 @@ def add(self, data: str | RelatedNodeSync | dict) -> None: ``id`` or ``hfid`` keys, plus optional relationship properties). Raises: + FeatureNotSupportedError: If the relationship takes its peers as fields instead of references. UninitializedError: If :meth:`fetch` has not been called on this manager yet. """ + self._raise_if_peers_are_inline_input() if not self.initialized: raise UninitializedError("Must call fetch() on RelationshipManager before editing members") new_node = cast( @@ -518,7 +663,7 @@ def add(self, data: str | RelatedNodeSync | dict) -> None: if (new_node.id and new_node.id not in self.peer_ids) or ( new_node.hfid and new_node.hfid not in self.peer_hfids ): - self.peers.append(new_node) + self._peers.append(new_node) self._has_update = True def extend(self, data: Iterable[str | RelatedNodeSync | dict]) -> None: @@ -532,9 +677,11 @@ def extend(self, data: Iterable[str | RelatedNodeSync | dict]) -> None: formats accepted by :meth:`add`. Raises: + FeatureNotSupportedError: If the relationship takes its peers as fields instead of references. UninitializedError: If :meth:`fetch` has not been called on this manager yet. """ + self._raise_if_peers_are_inline_input() for d in data: self.add(d) @@ -549,25 +696,27 @@ def remove(self, data: str | RelatedNodeSync | dict) -> None: an existing :class:`RelatedNodeSync`, or a dict describing the peer. Raises: + FeatureNotSupportedError: If the relationship takes its peers as fields instead of references. UninitializedError: If :meth:`fetch` has not been called on this manager yet. IndexError: If the internal peer index is inconsistent with the lookup result. """ + self._raise_if_peers_are_inline_input() if not self.initialized: raise UninitializedError("Must call fetch() on RelationshipManager before editing members") node_to_remove = RelatedNodeSync(schema=self.schema, client=self.client, branch=self.branch, data=data) if node_to_remove.id and node_to_remove.id in self.peer_ids: idx = self.peer_ids.index(node_to_remove.id) - if self.peers[idx].id != node_to_remove.id: + if self._peers[idx].id != node_to_remove.id: raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.id}") - self.peers.pop(idx) + self._peers.pop(idx) self._has_update = True elif node_to_remove.hfid and node_to_remove.hfid in self.peer_hfids: idx = self.peer_hfids.index(node_to_remove.hfid) - if self.peers[idx].hfid != node_to_remove.hfid: + if self._peers[idx].hfid != node_to_remove.hfid: raise IndexError(f"Unexpected situation, the node with the index {idx} should be {node_to_remove.hfid}") - self.peers.pop(idx) + self._peers.pop(idx) self._has_update = True diff --git a/infrahub_sdk/object_store.py b/infrahub_sdk/object_store.py index c770d016a..550716b90 100644 --- a/infrahub_sdk/object_store.py +++ b/infrahub_sdk/object_store.py @@ -4,7 +4,7 @@ import httpx -from .exceptions import AuthenticationError, ServerNotReachableError +from .exceptions import ServerNotReachableError, authentication_error_from_response if TYPE_CHECKING: from .client import InfrahubClient, InfrahubClientSync @@ -52,10 +52,7 @@ async def get(self, identifier: str, tracker: str | None = None) -> str: raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return resp.text @@ -72,10 +69,7 @@ async def upload(self, content: str, tracker: str | None = None) -> dict[str, st raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return resp.json() @@ -88,7 +82,7 @@ async def _get_file(self, url: str, identifier: str, tracker: str | None = None) AuthenticationError: If the server returns a 401 or 403 response. HTTPStatusError: For other non-2xx HTTP responses. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's headers = self.client._request_headers(tracker=tracker) try: @@ -99,10 +93,7 @@ async def _get_file(self, url: str, identifier: str, tracker: str | None = None) raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return self._validate_text_content(response=resp, identifier=identifier) @@ -141,10 +132,7 @@ def get(self, identifier: str, tracker: str | None = None) -> str: raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return resp.text @@ -161,10 +149,7 @@ def upload(self, content: str, tracker: str | None = None) -> dict[str, str]: raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return resp.json() @@ -177,7 +162,7 @@ def _get_file(self, url: str, identifier: str, tracker: str | None = None) -> st AuthenticationError: If the server returns a 401 or 403 response. HTTPStatusError: For other non-2xx HTTP responses. - """ + """ # noqa: DOC501 # raises via a factory, whose name ruff reads as the exception's headers = self.client._request_headers(tracker=tracker) try: @@ -188,10 +173,7 @@ def _get_file(self, url: str, identifier: str, tracker: str | None = None) -> st raise except httpx.HTTPStatusError as exc: if exc.response.status_code in {401, 403}: - response = exc.response.json() - errors = response.get("errors") - messages = [error.get("message") for error in errors] - raise AuthenticationError(" | ".join(messages)) from exc + raise authentication_error_from_response(response=exc.response) from exc raise return self._validate_text_content(resp, identifier) diff --git a/infrahub_sdk/protocols.py b/infrahub_sdk/protocols.py index 7b38f49a3..65f33a534 100644 --- a/infrahub_sdk/protocols.py +++ b/infrahub_sdk/protocols.py @@ -7,7 +7,12 @@ from .protocols_base import CoreNode, CoreNodeSync if TYPE_CHECKING: - from infrahub_sdk.node import RelatedNode, RelatedNodeSync, RelationshipManager, RelationshipManagerSync + from infrahub_sdk.node import ( + RelationshipAttribute, + RelationshipAttributeSync, + RelationshipManager, + RelationshipManagerSync, + ) from .protocols_base import ( URL, @@ -16,12 +21,14 @@ DateTime, DateTimeOptional, Dropdown, - Enum, + DropdownOptional, HashedPassword, Integer, IntegerOptional, IPHost, + IPHostOptional, IPNetwork, + IPNetworkOptional, JSONAttribute, JSONAttributeOptional, ListAttributeOptional, @@ -30,234 +37,286 @@ ) -# --------------------------------------------- -# ASYNC -# --------------------------------------------- - - class BuiltinIPAddress(CoreNode): address: IPHost description: StringOptional - ip_namespace: RelatedNode - ip_prefix: RelatedNode + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + ip_prefix: RelationshipAttribute[BuiltinIPPrefix] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] class BuiltinIPNamespace(CoreNode): - name: String description: StringOptional - ip_prefixes: RelationshipManager - ip_addresses: RelationshipManager + name: String + ip_addresses: RelationshipManager[BuiltinIPAddress] + ip_prefixes: RelationshipManager[BuiltinIPPrefix] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] class BuiltinIPPrefix(CoreNode): - prefix: IPNetwork + broadcast_address: StringOptional description: StringOptional - member_type: Dropdown + hostmask: StringOptional is_pool: Boolean is_top_level: BooleanOptional - utilization: IntegerOptional + member_type: Dropdown netmask: StringOptional - hostmask: StringOptional network_address: StringOptional - broadcast_address: StringOptional - ip_namespace: RelatedNode - ip_addresses: RelationshipManager - resource_pool: RelationshipManager - parent: RelatedNode - children: RelationshipManager + prefix: IPNetwork + utilization: IntegerOptional + children: RelationshipManager[BuiltinIPPrefix] + ip_addresses: RelationshipManager[BuiltinIPAddress] + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[BuiltinIPPrefix] + profiles: RelationshipManager[CoreProfile] + resource_pool: RelationshipManager[CoreIPPool] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreAction(CoreNode): - name: String description: StringOptional - triggers: RelationshipManager + name: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + triggers: RelationshipManager[CoreTriggerRule] class CoreArtifactTarget(CoreNode): - artifacts: RelationshipManager + artifacts: RelationshipManager[CoreArtifact] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreBasePermission(CoreNode): description: StringOptional identifier: StringOptional - roles: RelationshipManager + member_of_groups: RelationshipManager[CoreGroup] + roles: RelationshipManager[CoreAccountRole] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreCheck(CoreNode): - name: StringOptional - label: StringOptional - origin: String + conclusion: String + created_at: DateTimeOptional kind: String + label: StringOptional message: StringOptional - conclusion: Enum - severity: Enum - created_at: DateTimeOptional - validator: RelatedNode + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + validator: RelationshipAttribute[CoreValidator] class CoreComment(CoreNode): text: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreCredential(CoreNode): - name: String - label: StringOptional description: StringOptional + label: StringOptional + name: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreFileObject(CoreNode): - file_name: String checksum: String + file_name: String file_size: Integer file_type: String storage_id: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreGenericAccount(CoreNode): + account_type: String + description: StringOptional + label: StringOptional name: String password: HashedPassword - label: StringOptional - description: StringOptional - account_type: Enum status: Dropdown - tokens: RelationshipManager - refresh_tokens: RelationshipManager - external_identities: RelationshipManager + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreGenericRepository(CoreNode): - name: String + commit: StringOptional description: StringOptional - location: String internal_status: Dropdown + last_rewrite_at: DateTimeOptional + last_rewrite_commit: StringOptional + last_rewrite_previous_commit: StringOptional + location: String + name: String operational_status: Dropdown + rewrite_count: IntegerOptional sync_status: Dropdown - credential: RelatedNode - tags: RelationshipManager - transformations: RelationshipManager - queries: RelationshipManager - checks: RelationshipManager - generators: RelationshipManager - groups_objects: RelationshipManager + checks: RelationshipManager[CoreCheckDefinition] + credential: RelationshipAttribute[CoreCredential] + generators: RelationshipManager[CoreGeneratorDefinition] + groups_objects: RelationshipManager[CoreRepositoryGroup] + member_of_groups: RelationshipManager[CoreGroup] + queries: RelationshipManager[CoreGraphQLQuery] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + transformations: RelationshipManager[CoreTransformation] class CoreGroup(CoreNode): - name: String - label: StringOptional description: StringOptional - group_type: Enum - members: RelationshipManager - subscribers: RelationshipManager - parent: RelatedNode - children: RelationshipManager + group_type: String + label: StringOptional + name: String + children: RelationshipManager[CoreGroup] + members: RelationshipManager[CoreNode] + parent: RelationshipAttribute[CoreGroup] + subscribers: RelationshipManager[CoreNode] class CoreIPPool(CoreNode): - pass + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreKeyValue(CoreNode): - name: String - key: String description: StringOptional + key: String + name: String value: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreMenu(CoreNode): - namespace: String - name: String - label: StringOptional - kind: StringOptional - path: StringOptional description: StringOptional icon: StringOptional - protected: Boolean + kind: StringOptional + label: StringOptional + name: String + namespace: String order_weight: Integer + path: StringOptional + protected: Boolean required_permissions: ListAttributeOptional - section: Enum - parent: RelatedNode - children: RelationshipManager + section: String + children: RelationshipManager[CoreMenu] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[CoreMenu] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreNodeTriggerMatch(CoreNode): - trigger: RelatedNode + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + trigger: RelationshipAttribute[CoreNodeTriggerRule] class CoreObjectComponentTemplate(CoreNode): template_name: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreObjectTemplate(CoreNode): template_name: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreProfile(CoreNode): profile_name: String - profile_priority: IntegerOptional + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreResourcePool(CoreNode): - name: String description: StringOptional + name: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreTaskTarget(CoreNode): - pass + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreThread(CoreNode): label: StringOptional resolved: Boolean - change: RelatedNode - comments: RelationshipManager + change: RelationshipAttribute[CoreProposedChange] + comments: RelationshipManager[CoreThreadComment] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreTransformation(CoreNode): - name: String - label: StringOptional - description: StringOptional - timeout: Integer - fingerprint: StringOptional dependencies: ListAttributeOptional dependencies_complete: BooleanOptional - query: RelatedNode - repository: RelatedNode - tags: RelationshipManager - artifact_definitions: RelationshipManager + description: StringOptional + fingerprint: StringOptional + label: StringOptional + name: String + timeout: Integer + artifact_definitions: RelationshipManager[CoreArtifactDefinition] + member_of_groups: RelationshipManager[CoreGroup] + query: RelationshipAttribute[CoreGraphQLQuery] + repository: RelationshipAttribute[CoreGenericRepository] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] class CoreTriggerRule(CoreNode): - name: String - description: StringOptional active: Boolean branch_scope: Dropdown - action: RelatedNode + description: StringOptional + name: String + action: RelationshipAttribute[CoreAction] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreValidator(CoreNode): - label: StringOptional - state: Enum - conclusion: Enum completed_at: DateTimeOptional + conclusion: String + label: StringOptional started_at: DateTimeOptional - proposed_change: RelatedNode - checks: RelationshipManager + state: String + checks: RelationshipManager[CoreCheck] + member_of_groups: RelationshipManager[CoreGroup] + proposed_change: RelationshipAttribute[CoreProposedChange] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreWebhook(CoreNode): - name: String - event_type: Enum active: Boolean branch_scope: Dropdown - node_kind: StringOptional description: StringOptional + event_type: String + name: String + node_kind: StringOptional url: URL validate_certificates: BooleanOptional - headers: RelationshipManager + headers: RelationshipManager[CoreKeyValue] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreWeightedPoolResource(CoreNode): allocation_weight: IntegerOptional + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class LineageOwner(CoreNode): @@ -269,601 +328,1138 @@ class LineageSource(CoreNode): class BuiltinTag(CoreNode): - name: String description: StringOptional + name: String + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreAccount(LineageOwner, LineageSource, CoreGenericAccount): - pass + account_type: String + description: StringOptional + label: StringOptional + name: String + password: HashedPassword + status: Dropdown + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreAccountGroup(LineageOwner, LineageSource, CoreGroup): + description: StringOptional + group_type: String + label: StringOptional + name: String origin: StringOptional - roles: RelationshipManager + children: RelationshipManager[CoreGroup] + members: RelationshipManager[CoreNode] + parent: RelationshipAttribute[CoreGroup] + roles: RelationshipManager[CoreAccountRole] + subscribers: RelationshipManager[CoreNode] class CoreAccountRole(CoreNode): name: String - groups: RelationshipManager - permissions: RelationshipManager + groups: RelationshipManager[CoreAccountGroup] + member_of_groups: RelationshipManager[CoreGroup] + permissions: RelationshipManager[CoreBasePermission] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreArtifact(CoreTaskTarget): - name: String - status: Enum - content_type: Enum checksum: StringOptional - storage_id: StringOptional + content_type: String + name: String parameters: JSONAttributeOptional - object: RelatedNode - definition: RelatedNode + status: String + storage_id: StringOptional + definition: RelationshipAttribute[CoreArtifactDefinition] + member_of_groups: RelationshipManager[CoreGroup] + object: RelationshipAttribute[CoreArtifactTarget] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreArtifactCheck(CoreCheck): + artifact_id: StringOptional changed: BooleanOptional checksum: StringOptional - artifact_id: StringOptional - storage_id: StringOptional + conclusion: String + created_at: DateTimeOptional + kind: String + label: StringOptional line_number: IntegerOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + storage_id: StringOptional + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + validator: RelationshipAttribute[CoreValidator] class CoreArtifactDefinition(CoreTaskTarget): - name: String artifact_name: String + content_type: String description: StringOptional - parameters: JSONAttribute - content_type: Enum fingerprint: StringOptional - targets: RelatedNode - transformation: RelatedNode - artifacts: RelationshipManager - validators: RelationshipManager + name: String + parameters: JSONAttribute + artifacts: RelationshipManager[CoreArtifact] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + targets: RelationshipAttribute[CoreGroup] + transformation: RelationshipAttribute[CoreTransformation] + validators: RelationshipManager[CoreArtifactValidator] class CoreArtifactThread(CoreThread): artifact_id: StringOptional - storage_id: StringOptional + label: StringOptional line_number: IntegerOptional + resolved: Boolean + storage_id: StringOptional + change: RelationshipAttribute[CoreProposedChange] + comments: RelationshipManager[CoreThreadComment] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreArtifactValidator(CoreValidator): - definition: RelatedNode + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManager[CoreCheck] + definition: RelationshipAttribute[CoreArtifactDefinition] + member_of_groups: RelationshipManager[CoreGroup] + proposed_change: RelationshipAttribute[CoreProposedChange] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreChangeComment(CoreComment): - change: RelatedNode + text: String + change: RelationshipAttribute[CoreProposedChange] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreChangeThread(CoreThread): - pass + label: StringOptional + resolved: Boolean + change: RelationshipAttribute[CoreProposedChange] + comments: RelationshipManager[CoreThreadComment] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreCheckDefinition(CoreTaskTarget): - name: String + class_name: String description: StringOptional file_path: String - class_name: String - timeout: Integer + name: String parameters: JSONAttributeOptional - repository: RelatedNode - query: RelatedNode - targets: RelatedNode - tags: RelationshipManager - validators: RelationshipManager + timeout: Integer + member_of_groups: RelationshipManager[CoreGroup] + query: RelationshipAttribute[CoreGraphQLQuery] + repository: RelationshipAttribute[CoreGenericRepository] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + targets: RelationshipAttribute[CoreGroup] + validators: RelationshipManager[CoreUserValidator] class CoreCustomWebhook(CoreWebhook, CoreTaskTarget): + active: Boolean + branch_scope: Dropdown + description: StringOptional + event_type: String + name: String + node_kind: StringOptional shared_key: StringOptional - transformation: RelatedNode + url: URL + validate_certificates: BooleanOptional + headers: RelationshipManager[CoreKeyValue] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + transformation: RelationshipAttribute[CoreTransformPython] class CoreDataCheck(CoreCheck): + conclusion: String conflicts: JSONAttribute - keep_branch: Enum + created_at: DateTimeOptional enriched_conflict_id: StringOptional + keep_branch: StringOptional + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + validator: RelationshipAttribute[CoreValidator] class CoreDataValidator(CoreValidator): - pass + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManager[CoreCheck] + member_of_groups: RelationshipManager[CoreGroup] + proposed_change: RelationshipAttribute[CoreProposedChange] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreEnvKeyValue(CoreKeyValue): - pass + description: StringOptional + key: String + name: String + value: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreFileCheck(CoreCheck): - files: ListAttributeOptional commit: StringOptional + conclusion: String + created_at: DateTimeOptional + files: ListAttributeOptional + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + validator: RelationshipAttribute[CoreValidator] class CoreFileThread(CoreThread): - file: StringOptional commit: StringOptional + file: StringOptional + label: StringOptional line_number: IntegerOptional - repository: RelatedNode + resolved: Boolean + change: RelationshipAttribute[CoreProposedChange] + comments: RelationshipManager[CoreThreadComment] + member_of_groups: RelationshipManager[CoreGroup] + repository: RelationshipAttribute[CoreRepository] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreGeneratorAction(CoreAction): - generator: RelatedNode + description: StringOptional + name: String + generator: RelationshipAttribute[CoreGeneratorDefinition] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + triggers: RelationshipManager[CoreTriggerRule] class CoreGeneratorAwareGroup(CoreGroup): - pass + description: StringOptional + group_type: String + label: StringOptional + name: String + children: RelationshipManager[CoreGroup] + members: RelationshipManager[CoreNode] + parent: RelationshipAttribute[CoreGroup] + subscribers: RelationshipManager[CoreNode] class CoreGeneratorCheck(CoreCheck): + conclusion: String + created_at: DateTimeOptional instance: String + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + validator: RelationshipAttribute[CoreValidator] class CoreGeneratorDefinition(CoreTaskTarget): - name: String - description: StringOptional - parameters: JSONAttribute - file_path: String class_name: String - convert_query_response: BooleanOptional - execute_in_proposed_change: BooleanOptional - execute_after_merge: BooleanOptional - fingerprint: StringOptional + convert_query_response: Boolean dependencies: ListAttributeOptional dependencies_complete: BooleanOptional - query: RelatedNode - repository: RelatedNode - targets: RelatedNode - instances: RelationshipManager - validators: RelationshipManager + description: StringOptional + execute_after_merge: Boolean + execute_in_proposed_change: Boolean + file_path: String + fingerprint: StringOptional + name: String + parameters: JSONAttribute + instances: RelationshipManager[CoreGeneratorInstance] + member_of_groups: RelationshipManager[CoreGroup] + query: RelationshipAttribute[CoreGraphQLQuery] + repository: RelationshipAttribute[CoreGenericRepository] + subscriber_of_groups: RelationshipManager[CoreGroup] + targets: RelationshipAttribute[CoreGroup] + validators: RelationshipManager[CoreGeneratorValidator] class CoreGeneratorGroup(CoreGroup): - pass + description: StringOptional + group_type: String + label: StringOptional + name: String + children: RelationshipManager[CoreGroup] + members: RelationshipManager[CoreNode] + parent: RelationshipAttribute[CoreGroup] + subscribers: RelationshipManager[CoreNode] class CoreGeneratorInstance(CoreTaskTarget): name: String - status: Enum - object: RelatedNode - definition: RelatedNode + status: String + definition: RelationshipAttribute[CoreGeneratorDefinition] + member_of_groups: RelationshipManager[CoreGroup] + object: RelationshipAttribute[CoreNode] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreGeneratorValidator(CoreValidator): - definition: RelatedNode + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManager[CoreCheck] + definition: RelationshipAttribute[CoreGeneratorDefinition] + member_of_groups: RelationshipManager[CoreGroup] + proposed_change: RelationshipAttribute[CoreProposedChange] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreGlobalPermission(CoreBasePermission): action: Dropdown - decision: Enum + decision: Integer + description: StringOptional + identifier: StringOptional + member_of_groups: RelationshipManager[CoreGroup] + roles: RelationshipManager[CoreAccountRole] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreGraphQLQuery(CoreNode): - name: String + depth: IntegerOptional description: StringOptional - query: String fingerprint: StringOptional - variables: JSONAttributeOptional - operations: ListAttributeOptional - models: ListAttributeOptional - depth: IntegerOptional height: IntegerOptional - repository: RelatedNode - tags: RelationshipManager - query_groups: RelationshipManager + models: ListAttributeOptional + name: String + operations: ListAttributeOptional + query: String + variables: JSONAttributeOptional + member_of_groups: RelationshipManager[CoreGroup] + query_groups: RelationshipManager[CoreGraphQLQueryGroup] + repository: RelationshipAttribute[CoreGenericRepository] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] class CoreGraphQLQueryGroup(CoreGroup): + description: StringOptional + group_type: String + label: StringOptional + name: String parameters: JSONAttributeOptional - query: RelatedNode + children: RelationshipManager[CoreGroup] + members: RelationshipManager[CoreNode] + parent: RelationshipAttribute[CoreGroup] + query: RelationshipAttribute[CoreGraphQLQuery] + subscribers: RelationshipManager[CoreNode] class CoreGroupAction(CoreAction): + description: StringOptional member_action: Dropdown - group: RelatedNode + name: String + group: RelationshipAttribute[CoreGroup] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + triggers: RelationshipManager[CoreTriggerRule] class CoreGroupTriggerRule(CoreTriggerRule): + active: Boolean + branch_scope: Dropdown + description: StringOptional member_update: Dropdown - group: RelatedNode + name: String + action: RelationshipAttribute[CoreAction] + group: RelationshipAttribute[CoreGroup] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreIPAddressPool(CoreResourcePool, LineageSource, CoreIPPool): default_address_type: String default_prefix_length: IntegerOptional - resources: RelationshipManager - ip_namespace: RelatedNode + description: StringOptional + name: String + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + member_of_groups: RelationshipManager[CoreGroup] + resources: RelationshipManager[BuiltinIPPrefix] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreIPPrefixPool(CoreResourcePool, LineageSource, CoreIPPool): + default_member_type: String default_prefix_length: IntegerOptional - default_member_type: Enum default_prefix_type: StringOptional - resources: RelationshipManager - ip_namespace: RelatedNode + description: StringOptional + name: String + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + member_of_groups: RelationshipManager[CoreGroup] + resources: RelationshipManager[BuiltinIPPrefix] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreMenuItem(CoreMenu): - pass + description: StringOptional + icon: StringOptional + kind: StringOptional + label: StringOptional + name: String + namespace: String + order_weight: Integer + path: StringOptional + protected: Boolean + required_permissions: ListAttributeOptional + section: String + children: RelationshipManager[CoreMenu] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[CoreMenu] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreNodeTriggerAttributeMatch(CoreNodeTriggerMatch): attribute_name: String value: StringOptional - value_previous: StringOptional value_match: Dropdown + value_previous: StringOptional + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + trigger: RelationshipAttribute[CoreNodeTriggerRule] class CoreNodeTriggerRelationshipMatch(CoreNodeTriggerMatch): - relationship_name: String modification_type: Dropdown peer: StringOptional + relationship_name: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + trigger: RelationshipAttribute[CoreNodeTriggerRule] class CoreNodeTriggerRule(CoreTriggerRule): + active: Boolean + branch_scope: Dropdown + description: StringOptional + mutation_action: String + name: String node_kind: String - mutation_action: Enum - matches: RelationshipManager + action: RelationshipAttribute[CoreAction] + matches: RelationshipManager[CoreNodeTriggerMatch] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreNumberPool(CoreResourcePool, LineageSource): + allocation_scope: ListAttributeOptional + description: StringOptional + end_range: IntegerOptional + name: String node: String node_attribute: String - start_range: Integer - end_range: Integer - pool_type: Enum + pool_type: String + start_range: IntegerOptional + member_of_groups: RelationshipManager[CoreGroup] + ranges: RelationshipManager[CoreNumberPoolRange] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class CoreNumberPoolRange(CoreWeightedPoolResource): + allocation_weight: IntegerOptional + end: IntegerOptional + start: IntegerOptional + member_of_groups: RelationshipManager[CoreGroup] + pool: RelationshipAttribute[CoreNumberPool] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreObjectPermission(CoreBasePermission): - namespace: String + action: String + decision: Integer + description: StringOptional + identifier: StringOptional name: String - action: Enum - decision: Enum + namespace: String + member_of_groups: RelationshipManager[CoreGroup] + roles: RelationshipManager[CoreAccountRole] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreObjectThread(CoreThread): + label: StringOptional object_path: String + resolved: Boolean + change: RelationshipAttribute[CoreProposedChange] + comments: RelationshipManager[CoreThreadComment] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CorePasswordCredential(CoreCredential): - username: StringOptional + description: StringOptional + label: StringOptional + name: String password: StringOptional + username: StringOptional + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreProposedChange(CoreTaskTarget): - name: String description: StringOptional - source_branch: String destination_branch: String - state: Enum is_draft: Boolean + name: String + source_branch: String + state: String total_comments: IntegerOptional - approved_by: RelationshipManager - rejected_by: RelationshipManager - reviewers: RelationshipManager - comments: RelationshipManager - threads: RelationshipManager - validations: RelationshipManager + approved_by: RelationshipManager[CoreGenericAccount] + comments: RelationshipManager[CoreChangeComment] + member_of_groups: RelationshipManager[CoreGroup] + rejected_by: RelationshipManager[CoreGenericAccount] + reviewers: RelationshipManager[CoreGenericAccount] + subscriber_of_groups: RelationshipManager[CoreGroup] + threads: RelationshipManager[CoreThread] + validations: RelationshipManager[CoreValidator] class CoreReadOnlyRepository(LineageOwner, LineageSource, CoreGenericRepository, CoreTaskTarget): - ref: String commit: StringOptional + description: StringOptional + internal_status: Dropdown + last_rewrite_at: DateTimeOptional + last_rewrite_commit: StringOptional + last_rewrite_previous_commit: StringOptional + location: String + name: String + operational_status: Dropdown + ref: String + rewrite_count: IntegerOptional + sync_status: Dropdown + checks: RelationshipManager[CoreCheckDefinition] + credential: RelationshipAttribute[CoreCredential] + generators: RelationshipManager[CoreGeneratorDefinition] + groups_objects: RelationshipManager[CoreRepositoryGroup] + member_of_groups: RelationshipManager[CoreGroup] + queries: RelationshipManager[CoreGraphQLQuery] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + transformations: RelationshipManager[CoreTransformation] class CoreRepository(LineageOwner, LineageSource, CoreGenericRepository, CoreTaskTarget): - default_branch: String commit: StringOptional + default_branch: String + delivery_error: StringOptional + delivery_failure_cause: DropdownOptional + delivery_held_regeneration: JSONAttributeOptional + delivery_last_abandonment: JSONAttributeOptional + delivery_last_delivered_commit: StringOptional + delivery_progress: JSONAttributeOptional + delivery_queue: JSONAttributeOptional + delivery_reverted: JSONAttributeOptional + delivery_status: DropdownOptional + description: StringOptional + internal_status: Dropdown + last_rewrite_at: DateTimeOptional + last_rewrite_commit: StringOptional + last_rewrite_previous_commit: StringOptional + location: String + name: String + operational_status: Dropdown + rewrite_count: IntegerOptional + sync_status: Dropdown + checks: RelationshipManager[CoreCheckDefinition] + credential: RelationshipAttribute[CoreCredential] + generators: RelationshipManager[CoreGeneratorDefinition] + groups_objects: RelationshipManager[CoreRepositoryGroup] + member_of_groups: RelationshipManager[CoreGroup] + queries: RelationshipManager[CoreGraphQLQuery] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + transformations: RelationshipManager[CoreTransformation] class CoreRepositoryGroup(CoreGroup): content: Dropdown - repository: RelatedNode + description: StringOptional + group_type: String + label: StringOptional + name: String + children: RelationshipManager[CoreGroup] + members: RelationshipManager[CoreNode] + parent: RelationshipAttribute[CoreGroup] + repository: RelationshipAttribute[CoreGenericRepository] + subscribers: RelationshipManager[CoreNode] class CoreRepositoryValidator(CoreValidator): - repository: RelatedNode + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManager[CoreCheck] + member_of_groups: RelationshipManager[CoreGroup] + proposed_change: RelationshipAttribute[CoreProposedChange] + repository: RelationshipAttribute[CoreGenericRepository] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreSchemaCheck(CoreCheck): + conclusion: String conflicts: JSONAttribute + created_at: DateTimeOptional enriched_conflict_id: StringOptional + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + validator: RelationshipAttribute[CoreValidator] class CoreSchemaValidator(CoreValidator): - pass + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManager[CoreCheck] + member_of_groups: RelationshipManager[CoreGroup] + proposed_change: RelationshipAttribute[CoreProposedChange] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreStandardCheck(CoreCheck): - pass + conclusion: String + created_at: DateTimeOptional + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + validator: RelationshipAttribute[CoreValidator] class CoreStandardGroup(CoreGroup): - pass + description: StringOptional + group_type: String + label: StringOptional + name: String + children: RelationshipManager[CoreGroup] + members: RelationshipManager[CoreNode] + parent: RelationshipAttribute[CoreGroup] + subscribers: RelationshipManager[CoreNode] class CoreStandardWebhook(CoreWebhook, CoreTaskTarget): + active: Boolean + branch_scope: Dropdown + description: StringOptional + event_type: String + name: String + node_kind: StringOptional shared_key: String + url: URL + validate_certificates: BooleanOptional + headers: RelationshipManager[CoreKeyValue] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreStaticKeyValue(CoreKeyValue): - pass + description: StringOptional + key: String + name: String + value: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class CoreThreadComment(CoreComment): - thread: RelatedNode + text: String + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] + thread: RelationshipAttribute[CoreThread] class CoreTransformJinja2(CoreTransformation): + dependencies: ListAttributeOptional + dependencies_complete: BooleanOptional + description: StringOptional + fingerprint: StringOptional + label: StringOptional + name: String template_path: String + timeout: Integer + artifact_definitions: RelationshipManager[CoreArtifactDefinition] + member_of_groups: RelationshipManager[CoreGroup] + query: RelationshipAttribute[CoreGraphQLQuery] + repository: RelationshipAttribute[CoreGenericRepository] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] class CoreTransformPython(CoreTransformation): - file_path: String class_name: String - convert_query_response: BooleanOptional + convert_query_response: Boolean + dependencies: ListAttributeOptional + dependencies_complete: BooleanOptional + description: StringOptional + file_path: String + fingerprint: StringOptional + label: StringOptional + name: String + timeout: Integer + artifact_definitions: RelationshipManager[CoreArtifactDefinition] + member_of_groups: RelationshipManager[CoreGroup] + query: RelationshipAttribute[CoreGraphQLQuery] + repository: RelationshipAttribute[CoreGenericRepository] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] class CoreUserValidator(CoreValidator): - check_definition: RelatedNode - repository: RelatedNode + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + check_definition: RelationshipAttribute[CoreCheckDefinition] + checks: RelationshipManager[CoreCheck] + member_of_groups: RelationshipManager[CoreGroup] + proposed_change: RelationshipAttribute[CoreProposedChange] + repository: RelationshipAttribute[CoreGenericRepository] + subscriber_of_groups: RelationshipManager[CoreGroup] class InternalAccountToken(CoreNode): + expiration: DateTimeOptional name: StringOptional token: String - expiration: DateTimeOptional - account: RelatedNode + account: RelationshipAttribute[CoreGenericAccount] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class InternalExternalIdentity(CoreNode): - sub: String - provider_name: String protocol: String - account: RelatedNode + provider_name: String + sub: String + account: RelationshipAttribute[CoreGenericAccount] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class InternalIPPrefixAvailable(BuiltinIPPrefix): - pass + broadcast_address: StringOptional + description: StringOptional + hostmask: StringOptional + is_pool: Boolean + is_top_level: BooleanOptional + member_type: Dropdown + netmask: StringOptional + network_address: StringOptional + prefix: IPNetwork + utilization: IntegerOptional + children: RelationshipManager[BuiltinIPPrefix] + ip_addresses: RelationshipManager[BuiltinIPAddress] + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[BuiltinIPPrefix] + profiles: RelationshipManager[CoreProfile] + resource_pool: RelationshipManager[CoreIPPool] + subscriber_of_groups: RelationshipManager[CoreGroup] class InternalIPRangeAvailable(BuiltinIPAddress): + address: IPHost + description: StringOptional last_address: IPHost + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + ip_prefix: RelationshipAttribute[BuiltinIPPrefix] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] class InternalRefreshToken(CoreNode): expiration: DateTime - account: RelatedNode + account: RelationshipAttribute[CoreGenericAccount] + member_of_groups: RelationshipManager[CoreGroup] + subscriber_of_groups: RelationshipManager[CoreGroup] class IpamNamespace(BuiltinIPNamespace): default: BooleanOptional + description: StringOptional + name: String + ip_addresses: RelationshipManager[BuiltinIPAddress] + ip_prefixes: RelationshipManager[BuiltinIPPrefix] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileBuiltinIPAddress(LineageSource, CoreProfile, CoreNode): + address: IPHostOptional + description: StringOptional + profile_name: String + profile_priority: Integer + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[BuiltinIPAddress] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileBuiltinIPPrefix(LineageSource, CoreProfile, CoreNode): + description: StringOptional + is_pool: BooleanOptional + member_type: DropdownOptional + prefix: IPNetworkOptional + profile_name: String + profile_priority: Integer + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[BuiltinIPPrefix] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileBuiltinTag(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[BuiltinTag] + subscriber_of_groups: RelationshipManager[CoreGroup] -# --------------------------------------------- -# SYNC -# --------------------------------------------- +class ProfileIpamNamespace(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + ip_addresses: RelationshipManager[BuiltinIPAddress] + ip_prefixes: RelationshipManager[BuiltinIPPrefix] + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[IpamNamespace] + subscriber_of_groups: RelationshipManager[CoreGroup] class BuiltinIPAddressSync(CoreNodeSync): address: IPHost description: StringOptional - ip_namespace: RelatedNodeSync - ip_prefix: RelatedNodeSync + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + ip_prefix: RelationshipAttributeSync[BuiltinIPPrefixSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class BuiltinIPNamespaceSync(CoreNodeSync): - name: String description: StringOptional - ip_prefixes: RelationshipManagerSync - ip_addresses: RelationshipManagerSync + name: String + ip_addresses: RelationshipManagerSync[BuiltinIPAddressSync] + ip_prefixes: RelationshipManagerSync[BuiltinIPPrefixSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class BuiltinIPPrefixSync(CoreNodeSync): - prefix: IPNetwork + broadcast_address: StringOptional description: StringOptional - member_type: Dropdown + hostmask: StringOptional is_pool: Boolean is_top_level: BooleanOptional - utilization: IntegerOptional + member_type: Dropdown netmask: StringOptional - hostmask: StringOptional network_address: StringOptional - broadcast_address: StringOptional - ip_namespace: RelatedNodeSync - ip_addresses: RelationshipManagerSync - resource_pool: RelationshipManagerSync - parent: RelatedNodeSync - children: RelationshipManagerSync + prefix: IPNetwork + utilization: IntegerOptional + children: RelationshipManagerSync[BuiltinIPPrefixSync] + ip_addresses: RelationshipManagerSync[BuiltinIPAddressSync] + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[BuiltinIPPrefixSync] + profiles: RelationshipManagerSync[CoreProfileSync] + resource_pool: RelationshipManagerSync[CoreIPPoolSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreActionSync(CoreNodeSync): - name: String description: StringOptional - triggers: RelationshipManagerSync + name: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + triggers: RelationshipManagerSync[CoreTriggerRuleSync] class CoreArtifactTargetSync(CoreNodeSync): - artifacts: RelationshipManagerSync + artifacts: RelationshipManagerSync[CoreArtifactSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreBasePermissionSync(CoreNodeSync): description: StringOptional identifier: StringOptional - roles: RelationshipManagerSync + member_of_groups: RelationshipManagerSync[CoreGroupSync] + roles: RelationshipManagerSync[CoreAccountRoleSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreCheckSync(CoreNodeSync): - name: StringOptional - label: StringOptional - origin: String + conclusion: String + created_at: DateTimeOptional kind: String + label: StringOptional message: StringOptional - conclusion: Enum - severity: Enum - created_at: DateTimeOptional - validator: RelatedNodeSync + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + validator: RelationshipAttributeSync[CoreValidatorSync] class CoreCommentSync(CoreNodeSync): text: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreCredentialSync(CoreNodeSync): - name: String - label: StringOptional description: StringOptional + label: StringOptional + name: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreFileObjectSync(CoreNodeSync): - file_name: String checksum: String + file_name: String file_size: Integer file_type: String storage_id: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreGenericAccountSync(CoreNodeSync): + account_type: String + description: StringOptional + label: StringOptional name: String password: HashedPassword - label: StringOptional - description: StringOptional - account_type: Enum status: Dropdown - tokens: RelationshipManagerSync - refresh_tokens: RelationshipManagerSync - external_identities: RelationshipManagerSync + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreGenericRepositorySync(CoreNodeSync): - name: String + commit: StringOptional description: StringOptional - location: String internal_status: Dropdown + last_rewrite_at: DateTimeOptional + last_rewrite_commit: StringOptional + last_rewrite_previous_commit: StringOptional + location: String + name: String operational_status: Dropdown + rewrite_count: IntegerOptional sync_status: Dropdown - credential: RelatedNodeSync - tags: RelationshipManagerSync - transformations: RelationshipManagerSync - queries: RelationshipManagerSync - checks: RelationshipManagerSync - generators: RelationshipManagerSync - groups_objects: RelationshipManagerSync + checks: RelationshipManagerSync[CoreCheckDefinitionSync] + credential: RelationshipAttributeSync[CoreCredentialSync] + generators: RelationshipManagerSync[CoreGeneratorDefinitionSync] + groups_objects: RelationshipManagerSync[CoreRepositoryGroupSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + queries: RelationshipManagerSync[CoreGraphQLQuerySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + transformations: RelationshipManagerSync[CoreTransformationSync] class CoreGroupSync(CoreNodeSync): - name: String - label: StringOptional description: StringOptional - group_type: Enum - members: RelationshipManagerSync - subscribers: RelationshipManagerSync - parent: RelatedNodeSync - children: RelationshipManagerSync + group_type: String + label: StringOptional + name: String + children: RelationshipManagerSync[CoreGroupSync] + members: RelationshipManagerSync[CoreNodeSync] + parent: RelationshipAttributeSync[CoreGroupSync] + subscribers: RelationshipManagerSync[CoreNodeSync] class CoreIPPoolSync(CoreNodeSync): - pass + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreKeyValueSync(CoreNodeSync): - name: String - key: String description: StringOptional + key: String + name: String value: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreMenuSync(CoreNodeSync): - namespace: String - name: String - label: StringOptional - kind: StringOptional - path: StringOptional description: StringOptional icon: StringOptional - protected: Boolean + kind: StringOptional + label: StringOptional + name: String + namespace: String order_weight: Integer + path: StringOptional + protected: Boolean required_permissions: ListAttributeOptional - section: Enum - parent: RelatedNodeSync - children: RelationshipManagerSync + section: String + children: RelationshipManagerSync[CoreMenuSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[CoreMenuSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreNodeTriggerMatchSync(CoreNodeSync): - trigger: RelatedNodeSync + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + trigger: RelationshipAttributeSync[CoreNodeTriggerRuleSync] class CoreObjectComponentTemplateSync(CoreNodeSync): template_name: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreObjectTemplateSync(CoreNodeSync): template_name: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreProfileSync(CoreNodeSync): profile_name: String - profile_priority: IntegerOptional + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreResourcePoolSync(CoreNodeSync): - name: String description: StringOptional + name: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreTaskTargetSync(CoreNodeSync): - pass + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreThreadSync(CoreNodeSync): label: StringOptional resolved: Boolean - change: RelatedNodeSync - comments: RelationshipManagerSync + change: RelationshipAttributeSync[CoreProposedChangeSync] + comments: RelationshipManagerSync[CoreThreadCommentSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreTransformationSync(CoreNodeSync): - name: String - label: StringOptional - description: StringOptional - timeout: Integer - fingerprint: StringOptional dependencies: ListAttributeOptional dependencies_complete: BooleanOptional - query: RelatedNodeSync - repository: RelatedNodeSync - tags: RelationshipManagerSync - artifact_definitions: RelationshipManagerSync + description: StringOptional + fingerprint: StringOptional + label: StringOptional + name: String + timeout: Integer + artifact_definitions: RelationshipManagerSync[CoreArtifactDefinitionSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + query: RelationshipAttributeSync[CoreGraphQLQuerySync] + repository: RelationshipAttributeSync[CoreGenericRepositorySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] class CoreTriggerRuleSync(CoreNodeSync): - name: String - description: StringOptional active: Boolean branch_scope: Dropdown - action: RelatedNodeSync + description: StringOptional + name: String + action: RelationshipAttributeSync[CoreActionSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreValidatorSync(CoreNodeSync): - label: StringOptional - state: Enum - conclusion: Enum completed_at: DateTimeOptional + conclusion: String + label: StringOptional started_at: DateTimeOptional - proposed_change: RelatedNodeSync - checks: RelationshipManagerSync + state: String + checks: RelationshipManagerSync[CoreCheckSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + proposed_change: RelationshipAttributeSync[CoreProposedChangeSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreWebhookSync(CoreNodeSync): - name: String - event_type: Enum active: Boolean branch_scope: Dropdown - node_kind: StringOptional description: StringOptional + event_type: String + name: String + node_kind: StringOptional url: URL validate_certificates: BooleanOptional - headers: RelationshipManagerSync + headers: RelationshipManagerSync[CoreKeyValueSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreWeightedPoolResourceSync(CoreNodeSync): allocation_weight: IntegerOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class LineageOwnerSync(CoreNodeSync): @@ -875,368 +1471,853 @@ class LineageSourceSync(CoreNodeSync): class BuiltinTagSync(CoreNodeSync): - name: String description: StringOptional + name: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreAccountSync(LineageOwnerSync, LineageSourceSync, CoreGenericAccountSync): - pass + account_type: String + description: StringOptional + label: StringOptional + name: String + password: HashedPassword + status: Dropdown + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreAccountGroupSync(LineageOwnerSync, LineageSourceSync, CoreGroupSync): + description: StringOptional + group_type: String + label: StringOptional + name: String origin: StringOptional - roles: RelationshipManagerSync + children: RelationshipManagerSync[CoreGroupSync] + members: RelationshipManagerSync[CoreNodeSync] + parent: RelationshipAttributeSync[CoreGroupSync] + roles: RelationshipManagerSync[CoreAccountRoleSync] + subscribers: RelationshipManagerSync[CoreNodeSync] class CoreAccountRoleSync(CoreNodeSync): name: String - groups: RelationshipManagerSync - permissions: RelationshipManagerSync + groups: RelationshipManagerSync[CoreAccountGroupSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + permissions: RelationshipManagerSync[CoreBasePermissionSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreArtifactSync(CoreTaskTargetSync): - name: String - status: Enum - content_type: Enum checksum: StringOptional - storage_id: StringOptional + content_type: String + name: String parameters: JSONAttributeOptional - object: RelatedNodeSync - definition: RelatedNodeSync + status: String + storage_id: StringOptional + definition: RelationshipAttributeSync[CoreArtifactDefinitionSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + object: RelationshipAttributeSync[CoreArtifactTargetSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreArtifactCheckSync(CoreCheckSync): + artifact_id: StringOptional changed: BooleanOptional checksum: StringOptional - artifact_id: StringOptional - storage_id: StringOptional + conclusion: String + created_at: DateTimeOptional + kind: String + label: StringOptional line_number: IntegerOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + storage_id: StringOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + validator: RelationshipAttributeSync[CoreValidatorSync] class CoreArtifactDefinitionSync(CoreTaskTargetSync): - name: String artifact_name: String + content_type: String description: StringOptional - parameters: JSONAttribute - content_type: Enum fingerprint: StringOptional - targets: RelatedNodeSync - transformation: RelatedNodeSync - artifacts: RelationshipManagerSync - validators: RelationshipManagerSync + name: String + parameters: JSONAttribute + artifacts: RelationshipManagerSync[CoreArtifactSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + targets: RelationshipAttributeSync[CoreGroupSync] + transformation: RelationshipAttributeSync[CoreTransformationSync] + validators: RelationshipManagerSync[CoreArtifactValidatorSync] class CoreArtifactThreadSync(CoreThreadSync): artifact_id: StringOptional - storage_id: StringOptional + label: StringOptional line_number: IntegerOptional + resolved: Boolean + storage_id: StringOptional + change: RelationshipAttributeSync[CoreProposedChangeSync] + comments: RelationshipManagerSync[CoreThreadCommentSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreArtifactValidatorSync(CoreValidatorSync): - definition: RelatedNodeSync + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManagerSync[CoreCheckSync] + definition: RelationshipAttributeSync[CoreArtifactDefinitionSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + proposed_change: RelationshipAttributeSync[CoreProposedChangeSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreChangeCommentSync(CoreCommentSync): - change: RelatedNodeSync + text: String + change: RelationshipAttributeSync[CoreProposedChangeSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreChangeThreadSync(CoreThreadSync): - pass + label: StringOptional + resolved: Boolean + change: RelationshipAttributeSync[CoreProposedChangeSync] + comments: RelationshipManagerSync[CoreThreadCommentSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreCheckDefinitionSync(CoreTaskTargetSync): - name: String + class_name: String description: StringOptional file_path: String - class_name: String - timeout: Integer + name: String parameters: JSONAttributeOptional - repository: RelatedNodeSync - query: RelatedNodeSync - targets: RelatedNodeSync - tags: RelationshipManagerSync - validators: RelationshipManagerSync + timeout: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + query: RelationshipAttributeSync[CoreGraphQLQuerySync] + repository: RelationshipAttributeSync[CoreGenericRepositorySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + targets: RelationshipAttributeSync[CoreGroupSync] + validators: RelationshipManagerSync[CoreUserValidatorSync] class CoreCustomWebhookSync(CoreWebhookSync, CoreTaskTargetSync): + active: Boolean + branch_scope: Dropdown + description: StringOptional + event_type: String + name: String + node_kind: StringOptional shared_key: StringOptional - transformation: RelatedNodeSync + url: URL + validate_certificates: BooleanOptional + headers: RelationshipManagerSync[CoreKeyValueSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + transformation: RelationshipAttributeSync[CoreTransformPythonSync] class CoreDataCheckSync(CoreCheckSync): + conclusion: String conflicts: JSONAttribute - keep_branch: Enum + created_at: DateTimeOptional enriched_conflict_id: StringOptional + keep_branch: StringOptional + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + validator: RelationshipAttributeSync[CoreValidatorSync] class CoreDataValidatorSync(CoreValidatorSync): - pass + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManagerSync[CoreCheckSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + proposed_change: RelationshipAttributeSync[CoreProposedChangeSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreEnvKeyValueSync(CoreKeyValueSync): - pass + description: StringOptional + key: String + name: String + value: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreFileCheckSync(CoreCheckSync): - files: ListAttributeOptional commit: StringOptional + conclusion: String + created_at: DateTimeOptional + files: ListAttributeOptional + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + validator: RelationshipAttributeSync[CoreValidatorSync] class CoreFileThreadSync(CoreThreadSync): - file: StringOptional commit: StringOptional + file: StringOptional + label: StringOptional line_number: IntegerOptional - repository: RelatedNodeSync + resolved: Boolean + change: RelationshipAttributeSync[CoreProposedChangeSync] + comments: RelationshipManagerSync[CoreThreadCommentSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + repository: RelationshipAttributeSync[CoreRepositorySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreGeneratorActionSync(CoreActionSync): - generator: RelatedNodeSync + description: StringOptional + name: String + generator: RelationshipAttributeSync[CoreGeneratorDefinitionSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + triggers: RelationshipManagerSync[CoreTriggerRuleSync] class CoreGeneratorAwareGroupSync(CoreGroupSync): - pass + description: StringOptional + group_type: String + label: StringOptional + name: String + children: RelationshipManagerSync[CoreGroupSync] + members: RelationshipManagerSync[CoreNodeSync] + parent: RelationshipAttributeSync[CoreGroupSync] + subscribers: RelationshipManagerSync[CoreNodeSync] class CoreGeneratorCheckSync(CoreCheckSync): + conclusion: String + created_at: DateTimeOptional instance: String + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + validator: RelationshipAttributeSync[CoreValidatorSync] class CoreGeneratorDefinitionSync(CoreTaskTargetSync): - name: String - description: StringOptional - parameters: JSONAttribute - file_path: String class_name: String - convert_query_response: BooleanOptional - execute_in_proposed_change: BooleanOptional - execute_after_merge: BooleanOptional - fingerprint: StringOptional + convert_query_response: Boolean dependencies: ListAttributeOptional dependencies_complete: BooleanOptional - query: RelatedNodeSync - repository: RelatedNodeSync - targets: RelatedNodeSync - instances: RelationshipManagerSync - validators: RelationshipManagerSync + description: StringOptional + execute_after_merge: Boolean + execute_in_proposed_change: Boolean + file_path: String + fingerprint: StringOptional + name: String + parameters: JSONAttribute + instances: RelationshipManagerSync[CoreGeneratorInstanceSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + query: RelationshipAttributeSync[CoreGraphQLQuerySync] + repository: RelationshipAttributeSync[CoreGenericRepositorySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + targets: RelationshipAttributeSync[CoreGroupSync] + validators: RelationshipManagerSync[CoreGeneratorValidatorSync] class CoreGeneratorGroupSync(CoreGroupSync): - pass + description: StringOptional + group_type: String + label: StringOptional + name: String + children: RelationshipManagerSync[CoreGroupSync] + members: RelationshipManagerSync[CoreNodeSync] + parent: RelationshipAttributeSync[CoreGroupSync] + subscribers: RelationshipManagerSync[CoreNodeSync] class CoreGeneratorInstanceSync(CoreTaskTargetSync): name: String - status: Enum - object: RelatedNodeSync - definition: RelatedNodeSync + status: String + definition: RelationshipAttributeSync[CoreGeneratorDefinitionSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + object: RelationshipAttributeSync[CoreNodeSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreGeneratorValidatorSync(CoreValidatorSync): - definition: RelatedNodeSync + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManagerSync[CoreCheckSync] + definition: RelationshipAttributeSync[CoreGeneratorDefinitionSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + proposed_change: RelationshipAttributeSync[CoreProposedChangeSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreGlobalPermissionSync(CoreBasePermissionSync): action: Dropdown - decision: Enum + decision: Integer + description: StringOptional + identifier: StringOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + roles: RelationshipManagerSync[CoreAccountRoleSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreGraphQLQuerySync(CoreNodeSync): - name: String + depth: IntegerOptional description: StringOptional - query: String fingerprint: StringOptional - variables: JSONAttributeOptional - operations: ListAttributeOptional - models: ListAttributeOptional - depth: IntegerOptional height: IntegerOptional - repository: RelatedNodeSync - tags: RelationshipManagerSync - query_groups: RelationshipManagerSync + models: ListAttributeOptional + name: String + operations: ListAttributeOptional + query: String + variables: JSONAttributeOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + query_groups: RelationshipManagerSync[CoreGraphQLQueryGroupSync] + repository: RelationshipAttributeSync[CoreGenericRepositorySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] class CoreGraphQLQueryGroupSync(CoreGroupSync): + description: StringOptional + group_type: String + label: StringOptional + name: String parameters: JSONAttributeOptional - query: RelatedNodeSync + children: RelationshipManagerSync[CoreGroupSync] + members: RelationshipManagerSync[CoreNodeSync] + parent: RelationshipAttributeSync[CoreGroupSync] + query: RelationshipAttributeSync[CoreGraphQLQuerySync] + subscribers: RelationshipManagerSync[CoreNodeSync] class CoreGroupActionSync(CoreActionSync): + description: StringOptional member_action: Dropdown - group: RelatedNodeSync + name: String + group: RelationshipAttributeSync[CoreGroupSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + triggers: RelationshipManagerSync[CoreTriggerRuleSync] class CoreGroupTriggerRuleSync(CoreTriggerRuleSync): + active: Boolean + branch_scope: Dropdown + description: StringOptional member_update: Dropdown - group: RelatedNodeSync + name: String + action: RelationshipAttributeSync[CoreActionSync] + group: RelationshipAttributeSync[CoreGroupSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreIPAddressPoolSync(CoreResourcePoolSync, LineageSourceSync, CoreIPPoolSync): default_address_type: String default_prefix_length: IntegerOptional - resources: RelationshipManagerSync - ip_namespace: RelatedNodeSync + description: StringOptional + name: String + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + resources: RelationshipManagerSync[BuiltinIPPrefixSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreIPPrefixPoolSync(CoreResourcePoolSync, LineageSourceSync, CoreIPPoolSync): + default_member_type: String default_prefix_length: IntegerOptional - default_member_type: Enum default_prefix_type: StringOptional - resources: RelationshipManagerSync - ip_namespace: RelatedNodeSync + description: StringOptional + name: String + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + resources: RelationshipManagerSync[BuiltinIPPrefixSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreMenuItemSync(CoreMenuSync): - pass + description: StringOptional + icon: StringOptional + kind: StringOptional + label: StringOptional + name: String + namespace: String + order_weight: Integer + path: StringOptional + protected: Boolean + required_permissions: ListAttributeOptional + section: String + children: RelationshipManagerSync[CoreMenuSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[CoreMenuSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreNodeTriggerAttributeMatchSync(CoreNodeTriggerMatchSync): attribute_name: String value: StringOptional - value_previous: StringOptional value_match: Dropdown + value_previous: StringOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + trigger: RelationshipAttributeSync[CoreNodeTriggerRuleSync] class CoreNodeTriggerRelationshipMatchSync(CoreNodeTriggerMatchSync): - relationship_name: String modification_type: Dropdown peer: StringOptional + relationship_name: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + trigger: RelationshipAttributeSync[CoreNodeTriggerRuleSync] class CoreNodeTriggerRuleSync(CoreTriggerRuleSync): + active: Boolean + branch_scope: Dropdown + description: StringOptional + mutation_action: String + name: String node_kind: String - mutation_action: Enum - matches: RelationshipManagerSync + action: RelationshipAttributeSync[CoreActionSync] + matches: RelationshipManagerSync[CoreNodeTriggerMatchSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreNumberPoolSync(CoreResourcePoolSync, LineageSourceSync): + allocation_scope: ListAttributeOptional + description: StringOptional + end_range: IntegerOptional + name: String node: String node_attribute: String - start_range: Integer - end_range: Integer - pool_type: Enum + pool_type: String + start_range: IntegerOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + ranges: RelationshipManagerSync[CoreNumberPoolRangeSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class CoreNumberPoolRangeSync(CoreWeightedPoolResourceSync): + allocation_weight: IntegerOptional + end: IntegerOptional + start: IntegerOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + pool: RelationshipAttributeSync[CoreNumberPoolSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreObjectPermissionSync(CoreBasePermissionSync): - namespace: String + action: String + decision: Integer + description: StringOptional + identifier: StringOptional name: String - action: Enum - decision: Enum + namespace: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + roles: RelationshipManagerSync[CoreAccountRoleSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreObjectThreadSync(CoreThreadSync): + label: StringOptional object_path: String + resolved: Boolean + change: RelationshipAttributeSync[CoreProposedChangeSync] + comments: RelationshipManagerSync[CoreThreadCommentSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CorePasswordCredentialSync(CoreCredentialSync): - username: StringOptional + description: StringOptional + label: StringOptional + name: String password: StringOptional + username: StringOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreProposedChangeSync(CoreTaskTargetSync): - name: String description: StringOptional - source_branch: String destination_branch: String - state: Enum is_draft: Boolean + name: String + source_branch: String + state: String total_comments: IntegerOptional - approved_by: RelationshipManagerSync - rejected_by: RelationshipManagerSync - reviewers: RelationshipManagerSync - comments: RelationshipManagerSync - threads: RelationshipManagerSync - validations: RelationshipManagerSync + approved_by: RelationshipManagerSync[CoreGenericAccountSync] + comments: RelationshipManagerSync[CoreChangeCommentSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + rejected_by: RelationshipManagerSync[CoreGenericAccountSync] + reviewers: RelationshipManagerSync[CoreGenericAccountSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + threads: RelationshipManagerSync[CoreThreadSync] + validations: RelationshipManagerSync[CoreValidatorSync] class CoreReadOnlyRepositorySync(LineageOwnerSync, LineageSourceSync, CoreGenericRepositorySync, CoreTaskTargetSync): - ref: String commit: StringOptional + description: StringOptional + internal_status: Dropdown + last_rewrite_at: DateTimeOptional + last_rewrite_commit: StringOptional + last_rewrite_previous_commit: StringOptional + location: String + name: String + operational_status: Dropdown + ref: String + rewrite_count: IntegerOptional + sync_status: Dropdown + checks: RelationshipManagerSync[CoreCheckDefinitionSync] + credential: RelationshipAttributeSync[CoreCredentialSync] + generators: RelationshipManagerSync[CoreGeneratorDefinitionSync] + groups_objects: RelationshipManagerSync[CoreRepositoryGroupSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + queries: RelationshipManagerSync[CoreGraphQLQuerySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + transformations: RelationshipManagerSync[CoreTransformationSync] class CoreRepositorySync(LineageOwnerSync, LineageSourceSync, CoreGenericRepositorySync, CoreTaskTargetSync): - default_branch: String commit: StringOptional + default_branch: String + delivery_error: StringOptional + delivery_failure_cause: DropdownOptional + delivery_held_regeneration: JSONAttributeOptional + delivery_last_abandonment: JSONAttributeOptional + delivery_last_delivered_commit: StringOptional + delivery_progress: JSONAttributeOptional + delivery_queue: JSONAttributeOptional + delivery_reverted: JSONAttributeOptional + delivery_status: DropdownOptional + description: StringOptional + internal_status: Dropdown + last_rewrite_at: DateTimeOptional + last_rewrite_commit: StringOptional + last_rewrite_previous_commit: StringOptional + location: String + name: String + operational_status: Dropdown + rewrite_count: IntegerOptional + sync_status: Dropdown + checks: RelationshipManagerSync[CoreCheckDefinitionSync] + credential: RelationshipAttributeSync[CoreCredentialSync] + generators: RelationshipManagerSync[CoreGeneratorDefinitionSync] + groups_objects: RelationshipManagerSync[CoreRepositoryGroupSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + queries: RelationshipManagerSync[CoreGraphQLQuerySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + transformations: RelationshipManagerSync[CoreTransformationSync] class CoreRepositoryGroupSync(CoreGroupSync): content: Dropdown - repository: RelatedNodeSync + description: StringOptional + group_type: String + label: StringOptional + name: String + children: RelationshipManagerSync[CoreGroupSync] + members: RelationshipManagerSync[CoreNodeSync] + parent: RelationshipAttributeSync[CoreGroupSync] + repository: RelationshipAttributeSync[CoreGenericRepositorySync] + subscribers: RelationshipManagerSync[CoreNodeSync] class CoreRepositoryValidatorSync(CoreValidatorSync): - repository: RelatedNodeSync + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManagerSync[CoreCheckSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + proposed_change: RelationshipAttributeSync[CoreProposedChangeSync] + repository: RelationshipAttributeSync[CoreGenericRepositorySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreSchemaCheckSync(CoreCheckSync): + conclusion: String conflicts: JSONAttribute + created_at: DateTimeOptional enriched_conflict_id: StringOptional + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + validator: RelationshipAttributeSync[CoreValidatorSync] class CoreSchemaValidatorSync(CoreValidatorSync): - pass + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + checks: RelationshipManagerSync[CoreCheckSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + proposed_change: RelationshipAttributeSync[CoreProposedChangeSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreStandardCheckSync(CoreCheckSync): - pass + conclusion: String + created_at: DateTimeOptional + kind: String + label: StringOptional + message: StringOptional + name: StringOptional + origin: String + severity: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + validator: RelationshipAttributeSync[CoreValidatorSync] class CoreStandardGroupSync(CoreGroupSync): - pass + description: StringOptional + group_type: String + label: StringOptional + name: String + children: RelationshipManagerSync[CoreGroupSync] + members: RelationshipManagerSync[CoreNodeSync] + parent: RelationshipAttributeSync[CoreGroupSync] + subscribers: RelationshipManagerSync[CoreNodeSync] class CoreStandardWebhookSync(CoreWebhookSync, CoreTaskTargetSync): + active: Boolean + branch_scope: Dropdown + description: StringOptional + event_type: String + name: String + node_kind: StringOptional shared_key: String + url: URL + validate_certificates: BooleanOptional + headers: RelationshipManagerSync[CoreKeyValueSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreStaticKeyValueSync(CoreKeyValueSync): - pass + description: StringOptional + key: String + name: String + value: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class CoreThreadCommentSync(CoreCommentSync): - thread: RelatedNodeSync + text: String + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + thread: RelationshipAttributeSync[CoreThreadSync] class CoreTransformJinja2Sync(CoreTransformationSync): + dependencies: ListAttributeOptional + dependencies_complete: BooleanOptional + description: StringOptional + fingerprint: StringOptional + label: StringOptional + name: String template_path: String + timeout: Integer + artifact_definitions: RelationshipManagerSync[CoreArtifactDefinitionSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + query: RelationshipAttributeSync[CoreGraphQLQuerySync] + repository: RelationshipAttributeSync[CoreGenericRepositorySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] class CoreTransformPythonSync(CoreTransformationSync): - file_path: String class_name: String - convert_query_response: BooleanOptional + convert_query_response: Boolean + dependencies: ListAttributeOptional + dependencies_complete: BooleanOptional + description: StringOptional + file_path: String + fingerprint: StringOptional + label: StringOptional + name: String + timeout: Integer + artifact_definitions: RelationshipManagerSync[CoreArtifactDefinitionSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + query: RelationshipAttributeSync[CoreGraphQLQuerySync] + repository: RelationshipAttributeSync[CoreGenericRepositorySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] class CoreUserValidatorSync(CoreValidatorSync): - check_definition: RelatedNodeSync - repository: RelatedNodeSync + completed_at: DateTimeOptional + conclusion: String + label: StringOptional + started_at: DateTimeOptional + state: String + check_definition: RelationshipAttributeSync[CoreCheckDefinitionSync] + checks: RelationshipManagerSync[CoreCheckSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + proposed_change: RelationshipAttributeSync[CoreProposedChangeSync] + repository: RelationshipAttributeSync[CoreGenericRepositorySync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class InternalAccountTokenSync(CoreNodeSync): + expiration: DateTimeOptional name: StringOptional token: String - expiration: DateTimeOptional - account: RelatedNodeSync + account: RelationshipAttributeSync[CoreGenericAccountSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class InternalExternalIdentitySync(CoreNodeSync): - sub: String - provider_name: String protocol: String - account: RelatedNodeSync + provider_name: String + sub: String + account: RelationshipAttributeSync[CoreGenericAccountSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class InternalIPPrefixAvailableSync(BuiltinIPPrefixSync): - pass + broadcast_address: StringOptional + description: StringOptional + hostmask: StringOptional + is_pool: Boolean + is_top_level: BooleanOptional + member_type: Dropdown + netmask: StringOptional + network_address: StringOptional + prefix: IPNetwork + utilization: IntegerOptional + children: RelationshipManagerSync[BuiltinIPPrefixSync] + ip_addresses: RelationshipManagerSync[BuiltinIPAddressSync] + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[BuiltinIPPrefixSync] + profiles: RelationshipManagerSync[CoreProfileSync] + resource_pool: RelationshipManagerSync[CoreIPPoolSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class InternalIPRangeAvailableSync(BuiltinIPAddressSync): + address: IPHost + description: StringOptional last_address: IPHost + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + ip_prefix: RelationshipAttributeSync[BuiltinIPPrefixSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class InternalRefreshTokenSync(CoreNodeSync): expiration: DateTime - account: RelatedNodeSync + account: RelationshipAttributeSync[CoreGenericAccountSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] class IpamNamespaceSync(BuiltinIPNamespaceSync): default: BooleanOptional + description: StringOptional + name: String + ip_addresses: RelationshipManagerSync[BuiltinIPAddressSync] + ip_prefixes: RelationshipManagerSync[BuiltinIPPrefixSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileBuiltinIPAddressSync(LineageSourceSync, CoreProfileSync, CoreNodeSync): + address: IPHostOptional + description: StringOptional + profile_name: String + profile_priority: Integer + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[BuiltinIPAddressSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileBuiltinIPPrefixSync(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + is_pool: BooleanOptional + member_type: DropdownOptional + prefix: IPNetworkOptional + profile_name: String + profile_priority: Integer + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[BuiltinIPPrefixSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileBuiltinTagSync(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[BuiltinTagSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileIpamNamespaceSync(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + ip_addresses: RelationshipManagerSync[BuiltinIPAddressSync] + ip_prefixes: RelationshipManagerSync[BuiltinIPPrefixSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[IpamNamespaceSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index 7040d1af4..83e6e5f33 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -27,6 +27,9 @@ def is_resource_pool(self) -> bool: ... @property def initialized(self) -> bool: ... + @property + def is_loaded(self) -> bool: ... + @property def display_label(self) -> str | None: ... @@ -59,6 +62,9 @@ class Attribute(Protocol): updated_at: str | None is_protected: bool | None + @property + def is_loaded(self) -> bool: ... + class String(Attribute): value: str @@ -119,10 +125,18 @@ class EnumOptional(Attribute): class Integer(Attribute): value: int + def attach_to_pool(self, pool: CoreNodeBase | str) -> None: ... + + def detach_from_pool(self) -> None: ... + class IntegerOptional(Attribute): value: int | None + def attach_to_pool(self, pool: CoreNodeBase | str) -> None: ... + + def detach_from_pool(self) -> None: ... + class IPHost(Attribute): value: ipaddress.IPv4Interface | ipaddress.IPv6Interface diff --git a/infrahub_sdk/protocols_generator/constants.py b/infrahub_sdk/protocols_generator/constants.py index 282217948..2c873645a 100644 --- a/infrahub_sdk/protocols_generator/constants.py +++ b/infrahub_sdk/protocols_generator/constants.py @@ -1,4 +1,5 @@ TEMPLATE_FILE_NAME = "template.j2" +HEADER_FILE_NAME = "header.j2" ATTRIBUTE_KIND_MAP = { "ID": "String", diff --git a/infrahub_sdk/protocols_generator/generator.py b/infrahub_sdk/protocols_generator/generator.py index e0a4c61cc..863e4688e 100644 --- a/infrahub_sdk/protocols_generator/generator.py +++ b/infrahub_sdk/protocols_generator/generator.py @@ -4,6 +4,7 @@ from pathlib import Path import jinja2 +from typing_extensions import assert_never from .. import protocols as sdk_protocols from ..schema import ( @@ -18,11 +19,12 @@ RelationshipSchemaAPI, TemplateSchemaAPI, ) -from .constants import ATTRIBUTE_KIND_MAP, CORE_BASE_CLASS_TO_SYNCIFY, TEMPLATE_FILE_NAME +from .constants import ATTRIBUTE_KIND_MAP, CORE_BASE_CLASS_TO_SYNCIFY, HEADER_FILE_NAME, TEMPLATE_FILE_NAME +from .target import ProtocolTarget -def load_template() -> str: - path = Path(__file__).parent / TEMPLATE_FILE_NAME +def load_template(file_name: str = TEMPLATE_FILE_NAME) -> str: + path = Path(__file__).parent / file_name return path.read_text() @@ -35,7 +37,10 @@ def move_to_end_of_list(lst: list, item: str) -> list: class CodeGenerator: - def __init__(self, schema: dict[str, MainSchemaTypesAll]) -> None: + def __init__( + self, schema: dict[str, MainSchemaTypesAll], target: ProtocolTarget = ProtocolTarget.USER_SCHEMA + ) -> None: + self.target: ProtocolTarget = target self.generics: dict[str, GenericSchemaAPI | GenericSchema] = {} self.nodes: dict[str, NodeSchemaAPI | NodeSchema] = {} self.profiles: dict[str, ProfileSchemaAPI] = {} @@ -51,14 +56,29 @@ def __init__(self, schema: dict[str, MainSchemaTypesAll]) -> None: if isinstance(schema_type, TemplateSchemaAPI): self.templates[name] = schema_type - self.base_protocols = [ - e - for e in dir(sdk_protocols) - if not e.startswith("__") - and not e.endswith("__") - and e - not in {"TYPE_CHECKING", "CoreNode", "Optional", "Protocol", "Union", "annotations", "runtime_checkable"} - ] + match self.target: + case ProtocolTarget.USER_SCHEMA: + self.base_protocols = [ + e + for e in dir(sdk_protocols) + if not e.startswith("__") + and not e.endswith("__") + and e + not in { + "TYPE_CHECKING", + "CoreNode", + "Optional", + "Protocol", + "Union", + "annotations", + "runtime_checkable", + } + ] + case ProtocolTarget.SDK_CORE: + # Nothing can be imported from the module being generated. + self.base_protocols = [] + case _: + assert_never(self.target) self.sorted_generics = self._sort_and_filter_models(self.generics, filters=["CoreNode", *self.base_protocols]) self.sorted_nodes = self._sort_and_filter_models(self.nodes, filters=["CoreNode", *self.base_protocols]) @@ -69,7 +89,26 @@ def __init__(self, schema: dict[str, MainSchemaTypesAll]) -> None: self.templates, filters=["CoreObjectTemplate", *self.base_protocols] ) + # Which referenced names have a Sync counterpart to switch to when rendering sync output. + # Both a peer and an inheritance base take the Sync variant whenever one exists, so a single + # set covers both positions. ``CoreNode`` is always included: it is the implicit base and is + # excluded from ``base_protocols``, so it would otherwise never be switched. + match self.target: + case ProtocolTarget.USER_SCHEMA: + self._sync_names = frozenset( + name for name in self.base_protocols if f"{name}Sync" in self.base_protocols + ) | {"CoreNode"} + case ProtocolTarget.SDK_CORE: + self._sync_names = self._local_class_names() | {"CoreNode"} + case _: + assert_never(self.target) + def render(self, sync: bool = True) -> str: + """Render the protocols module. + + ``sync`` selects which variant to render for ``USER_SCHEMA``. ``SDK_CORE`` renders both + variants into a single module, so it does not apply there. + """ jinja2_env = jinja2.Environment( loader=jinja2.BaseLoader(), trim_blocks=True, @@ -80,23 +119,45 @@ def render(self, sync: bool = True) -> str: jinja2_env.filters["render_relationship"] = self._jinja2_filter_render_relationship jinja2_env.filters["syncify"] = self._jinja2_filter_syncify - template = jinja2_env.from_string(load_template()) - return template.render( + header = jinja2_env.from_string(load_template(HEADER_FILE_NAME)) + body = jinja2_env.from_string(load_template(TEMPLATE_FILE_NAME)) + + match self.target: + case ProtocolTarget.USER_SCHEMA: + return header.render(sync=sync, base_protocols=self.base_protocols, core=False) + self._render_body( + body, sync=sync, suffix="" + ) + case ProtocolTarget.SDK_CORE: + return ( + header.render(sync=False, base_protocols=self.base_protocols, core=True) + + self._render_body(body, sync=False, suffix="") + + self._render_body(body, sync=True, suffix="Sync") + ) + case _: + assert_never(self.target) + + def _render_body(self, body: jinja2.Template, sync: bool, suffix: str) -> str: + return body.render( generics=self.sorted_generics, nodes=self.sorted_nodes, profiles=self.sorted_profiles, templates=self.sorted_templates, - base_protocols=self.base_protocols, core_node_name="CoreNodeSync" if sync else "CoreNode", sync=sync, + suffix=suffix, ) - @staticmethod - def _jinja2_filter_syncify(value: str | list, sync: bool = False) -> str | list: + def _local_class_names(self) -> frozenset[str]: + return frozenset( + f"{model.namespace}{model.name}" + for model in (*self.sorted_generics, *self.sorted_nodes, *self.sorted_profiles, *self.sorted_templates) + ) + + def _jinja2_filter_syncify(self, value: str | list, sync: bool = False) -> str | list: """Filter to help with the convertion to sync. If a string is provided, append Sync to the end of the string - If a list is provided, search for CoreNode and replace it with CoreNodeSync + If a list is provided, append Sync to the items that have a Sync counterpart """ if isinstance(value, list): # Order the list based on the CORE_BASE_CLASS_TO_SYNCIFY list to ensure the base classes are always last @@ -110,7 +171,7 @@ def _jinja2_filter_syncify(value: str | list, sync: bool = False) -> str | list: return f"{value}Sync" if isinstance(value, list): - return [f"{item}Sync" if item in CORE_BASE_CLASS_TO_SYNCIFY else item for item in value] + return [f"{item}Sync" if item in self._sync_names else item for item in value] return value @@ -136,10 +197,9 @@ def _jinja2_filter_render_relationship(self, value: RelationshipSchemaAPI, sync: if sync: type_ += "Sync" - # Core peer protocols expose a dedicated ``*Sync`` variant; reference it in sync - # output. Locally generated node/generic classes keep their name (they already - # inherit the sync base class), so only swap when a ``*Sync`` peer actually exists. - if f"{peer}Sync" in self.base_protocols: + # Peers with a dedicated ``*Sync`` variant are referenced by it in sync output. The + # rest keep their name, because they already are the sync class. + if peer in self._sync_names: peer = f"{peer}Sync" return f"{name}: {type_}[{peer}]" @@ -157,4 +217,7 @@ def _sort_and_filter_models( continue filtered.append(model) - return sorted(filtered, key=lambda k: k.name) + # Sorted on the kind, which is the name each class renders as. Case is ignored so the file + # reads in alphabetical order, with the kind itself breaking ties: a key that can compare + # two kinds equal would leave them ordered by however the caller supplied them. + return sorted(filtered, key=lambda k: (k.kind.lower(), k.kind)) diff --git a/infrahub_sdk/protocols_generator/header.j2 b/infrahub_sdk/protocols_generator/header.j2 new file mode 100644 index 000000000..67eeb2a83 --- /dev/null +++ b/infrahub_sdk/protocols_generator/header.j2 @@ -0,0 +1,92 @@ +{% if core %} +# Generated by "invoke backend.generate", do not edit directly + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .protocols_base import CoreNode, CoreNodeSync + +if TYPE_CHECKING: + from infrahub_sdk.node import ( + RelationshipAttribute, + RelationshipAttributeSync, + RelationshipManager, + RelationshipManagerSync, + ) + from .protocols_base import ( + AnyAttribute, + AnyAttributeOptional, + String, + StringOptional, + Integer, + IntegerOptional, + Boolean, + BooleanOptional, + DateTime, + DateTimeOptional, + Dropdown, + DropdownOptional, + HashedPassword, + HashedPasswordOptional, + MacAddress, + MacAddressOptional, + IPHost, + IPHostOptional, + IPNetwork, + IPNetworkOptional, + IPAddress, + IPAddressOptional, + JSONAttribute, + JSONAttributeOptional, + ListAttribute, + ListAttributeOptional, + URL, + URLOptional, + ) + +{% else %} +# +# Generated by "infrahubctl protocols" +# + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from infrahub_sdk.protocols import {{ "CoreNode" | syncify(sync) }}, {{ base_protocols | join(', ') }} + +if TYPE_CHECKING: + from infrahub_sdk.node import {{ "RelatedNode" | syncify(sync) }}, {{ "RelationshipAttribute" | syncify(sync) }}, {{ "RelationshipManager" | syncify(sync) }} + from infrahub_sdk.protocols_base import ( + AnyAttribute, + AnyAttributeOptional, + String, + StringOptional, + Integer, + IntegerOptional, + Boolean, + BooleanOptional, + DateTime, + DateTimeOptional, + Dropdown, + DropdownOptional, + HashedPassword, + HashedPasswordOptional, + MacAddress, + MacAddressOptional, + IPHost, + IPHostOptional, + IPNetwork, + IPNetworkOptional, + IPAddress, + IPAddressOptional, + JSONAttribute, + JSONAttributeOptional, + ListAttribute, + ListAttributeOptional, + URL, + URLOptional, + ) + +{% endif %} diff --git a/infrahub_sdk/protocols_generator/target.py b/infrahub_sdk/protocols_generator/target.py new file mode 100644 index 000000000..7dfcacadd --- /dev/null +++ b/infrahub_sdk/protocols_generator/target.py @@ -0,0 +1,16 @@ +from enum import Enum + + +class ProtocolTarget(str, Enum): + """Which protocols module is being generated. + + ``USER_SCHEMA`` renders protocols for a user's own schema into a standalone module, one file + per async/sync variant, importing the core kinds it references from ``infrahub_sdk.protocols``. + + ``SDK_CORE`` renders ``infrahub_sdk.protocols`` itself. Every kind is local, so nothing can be + imported from that module, both variants share a single file, and the sync classes carry a + ``Sync`` suffix to keep their names distinct. + """ + + USER_SCHEMA = "user-schema" + SDK_CORE = "sdk-core" diff --git a/infrahub_sdk/protocols_generator/template.j2 b/infrahub_sdk/protocols_generator/template.j2 index a1c9fca53..e5258559c 100644 --- a/infrahub_sdk/protocols_generator/template.j2 +++ b/infrahub_sdk/protocols_generator/template.j2 @@ -1,49 +1,6 @@ -# -# Generated by "infrahubctl protocols" -# - -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -from infrahub_sdk.protocols import {{ "CoreNode" | syncify(sync) }}, {{ base_protocols | join(', ') }} - -if TYPE_CHECKING: - from infrahub_sdk.node import {{ "RelatedNode" | syncify(sync) }}, {{ "RelationshipAttribute" | syncify(sync) }}, {{ "RelationshipManager" | syncify(sync) }} - from infrahub_sdk.protocols_base import ( - AnyAttribute, - AnyAttributeOptional, - String, - StringOptional, - Integer, - IntegerOptional, - Boolean, - BooleanOptional, - DateTime, - DateTimeOptional, - Dropdown, - DropdownOptional, - HashedPassword, - HashedPasswordOptional, - MacAddress, - MacAddressOptional, - IPHost, - IPHostOptional, - IPNetwork, - IPNetworkOptional, - IPAddress, - IPAddressOptional, - JSONAttribute, - JSONAttributeOptional, - ListAttribute, - ListAttributeOptional, - URL, - URLOptional, - ) - {% for generic in generics %} -class {{ generic.namespace + generic.name }}({{core_node_name}}): +class {{ generic.namespace + generic.name }}{{ suffix }}({{core_node_name}}): {% if not generic.attributes|default([]) and not generic.relationships|default([]) %} pass {% endif %} @@ -54,15 +11,20 @@ class {{ generic.namespace + generic.name }}({{core_node_name}}): {{ relationship | render_relationship(sync) }} {% endfor %} {% if generic.hierarchical | default(false) %} - parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ generic.namespace + generic.name }}] - children: {{ "RelationshipManager" | syncify(sync) }}[{{ generic.namespace + generic.name }}] + {% set relationship_names = generic.relationships | default([]) | map(attribute="name") | list %} + {% if "parent" not in relationship_names %} + parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ generic.namespace + generic.name }}{{ suffix }}] + {% endif %} + {% if "children" not in relationship_names %} + children: {{ "RelationshipManager" | syncify(sync) }}[{{ generic.namespace + generic.name }}{{ suffix }}] + {% endif %} {% endif %} {% endfor %} {% for node in nodes %} -class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): +class {{ node.namespace + node.name }}{{ suffix }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): {% if not node.attributes|default([]) and not node.relationships|default([]) %} pass {% endif %} @@ -73,8 +35,13 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {{ relationship | render_relationship(sync) }} {% endfor %} {% if node.hierarchical | default(false) %} - parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}] - children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] + {% set relationship_names = node.relationships | default([]) | map(attribute="name") | list %} + {% if "parent" not in relationship_names %} + parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] + {% endif %} + {% if "children" not in relationship_names %} + children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] + {% endif %} {% endif %} {% endfor %} @@ -82,7 +49,7 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {% for node in profiles %} -class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): +class {{ node.namespace + node.name }}{{ suffix }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): {% if not node.attributes|default([]) and not node.relationships|default([]) %} pass {% endif %} @@ -93,8 +60,13 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {{ relationship | render_relationship(sync) }} {% endfor %} {% if node.hierarchical | default(false) %} - parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}] - children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}] + {% set relationship_names = node.relationships | default([]) | map(attribute="name") | list %} + {% if "parent" not in relationship_names %} + parent: {{ "RelationshipAttribute" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] + {% endif %} + {% if "children" not in relationship_names %} + children: {{ "RelationshipManager" | syncify(sync) }}[{{ node.namespace + node.name }}{{ suffix }}] + {% endif %} {% endif %} {% endfor %} @@ -102,7 +74,7 @@ class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | jo {% for node in templates %} -class {{ node.namespace + node.name }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): +class {{ node.namespace + node.name }}{{ suffix }}({{ node.inherit_from | syncify(sync) | join(", ") or core_node_name }}): {% if not node.attributes|default([]) and not node.relationships|default([]) %} pass {% endif %} diff --git a/infrahub_sdk/query_groups.py b/infrahub_sdk/query_groups.py index cc7faebd1..8f65314cb 100644 --- a/infrahub_sdk/query_groups.py +++ b/infrahub_sdk/query_groups.py @@ -1,10 +1,19 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from .constants import InfrahubClientMode -from .exceptions import GraphQLError, NodeNotFoundError +from .exceptions import ( + ApiError, + GraphQLError, + NodeNotFoundError, + RateLimitError, + TrackingGroupCleanupError, + URLNotFoundError, + code_names_the_failure, +) from .utils import dict_hash if TYPE_CHECKING: @@ -12,6 +21,104 @@ from .node import InfrahubNode, InfrahubNodeSync, RelatedNodeBase from .schema import MainSchemaTypesAPI +# What a server older than the error catalogue reports a missing node with. Goes when those do. +_LEGACY_NODE_NOT_FOUND = "Unable to find the node" + +# Codes that describe the request rather than the member being deleted. Every delete after one of +# these fails the same way, so it stops the reap instead of being recorded against each member. +_REQUEST_FAILURE_CODES = frozenset( + { + "AUTHENTICATION_REQUIRED", + "TOKEN_EXPIRED", + "BRANCH_NOT_FOUND", + "BRANCH_ALREADY_MERGED", + "BRANCH_NEEDS_REBASE", + "MERGE_IN_PROGRESS", + "MERGE_RECOVERY_REQUIRED", + } +) + +# Failures that mean the server answered and did not process the delete, so the member still exists. +# The SDK raises a rate limit and an unknown endpoint outside `ApiError`, from the status code alone. +_REJECTED_BY_THE_SERVER = (ApiError, RateLimitError, URLNotFoundError) + + +def _node_already_deleted(exc: GraphQLError) -> bool: + """Whether a failed delete is reporting a node that another node's cascade already removed. + + A server that codes its errors has already been handled by the caller's `NodeNotFoundError` + clause, so a coded failure reaching here is a different failure and must not be swallowed on the + strength of its wording. + """ + return exc.code is None and exc.message is not None and _LEGACY_NODE_NOT_FOUND in exc.message + + +def _about_the_member(exc: GraphQLError) -> bool: + """Whether a failed delete is a fact about the member rather than about the request. + + The GraphQL path raises every coded failure as a `GraphQLError`, an expired token included, so + the class alone does not separate the two. A class raised by the SDK without a server behind it + carries no instance code, which is why its declared `CODE` is consulted as well. + """ + return (exc.code or exc.CODE) not in _REQUEST_FAILURE_CODES + + +def _record_failure(result: ReapResult, exc: Exception, candidates: list[tuple[str, str]], position: int) -> bool: + """Record the failed delete of `candidates[position]` in `result`, and return whether the reap stops. + + A failure about the member is recorded against it and the reap carries on. Any other failure + stops the reap, and the members after it, which were never attempted, are kept. + + The interrupted member is kept only when the server answered, since a rejected delete removed + nothing. Without an answer the delete may have gone through, and a group write naming a node + that no longer exists is rejected, losing this run's membership. + """ + if not isinstance(exc, GraphQLError) or not _about_the_member(exc): + result.error = exc + first_kept = position if isinstance(exc, _REJECTED_BY_THE_SERVER) else position + 1 + result.unattempted = [candidate_id for _, candidate_id in candidates[first_kept:]] + return True + if not _node_already_deleted(exc): + result.refused[candidates[position][1]] = _failure_reason(exc) + return False + + +def _failure_reason(exc: GraphQLError) -> str: + """The server's reason for refusing a delete, without the mutation that triggered it.""" + if code_names_the_failure(exc.code): + return str(exc) + return "; ".join(str(error.get("message", error)) for error in exc.errors) or str(exc) + + +@dataclass +class ReapResult: + """How far a reap of the unused members got. + + Attributes: + refused: The id of each member the server refused to delete, mapped to its reason. + unattempted: The ids the reap never got to, because something unrelated to any + member stopped it. + error: The failure that stopped the reap, if one did. + + """ + + refused: dict[str, str] = field(default_factory=dict) + unattempted: list[str] = field(default_factory=list) + error: Exception | None = None + + @property + def retained_member_ids(self) -> list[str]: + """The members that must stay in the group so a later run retries them.""" + return list(self.refused) + self.unattempted + + def failure(self) -> Exception | None: + """What to raise once the group is written: the error that stopped the reap, or the refusals.""" + if self.error is not None: + return self.error + if self.refused: + return TrackingGroupCleanupError(failures=self.refused) + return None + class InfrahubGroupContextBase: """Base class for InfrahubGroupContext and InfrahubGroupContextSync.""" @@ -27,6 +134,7 @@ def __init__(self) -> None: self.delete_unused_nodes: bool = False self.group_type: str = "CoreStandardGroup" self.group_params: dict[str, Any] = {} + self.branch: str | None = None def set_properties( self, @@ -51,6 +159,46 @@ def set_properties( self.group_params = group_params or {} self.branch = branch + def _get_members(self) -> list[str]: + """The ids of everything this run tracked.""" + return self.related_group_ids + self.related_node_ids + + def _set_unused_member_ids(self, previous_member_ids: list[str], members: list[str]) -> None: + self.unused_member_ids = list(set(previous_member_ids) - set(members)) + + def _reconcile_with(self, existing_group: InfrahubNode | InfrahubNodeSync | None, members: list[str]) -> list[str]: + """Record which of the existing group's members this run no longer uses, and return them all. + + Without a group there is nothing to reap, so what an earlier lookup recorded on a reused + context is cleared rather than reaped against this run. + """ + if existing_group is None: + self.previous_members = None + self.unused_member_ids = None + return [] + previous_member_ids = existing_group._get_relationship_many(name="members").peer_ids + self._set_unused_member_ids(previous_member_ids=previous_member_ids, members=members) + return previous_member_ids + + @staticmethod + def _needs_group_write(members: list[str], previous_member_ids: list[str]) -> bool: + """A run that tracked nothing and found no members must neither create nor re-save an empty group.""" + return bool(members or previous_member_ids) + + def _reap_candidates(self) -> list[tuple[str, str]]: + """The (kind, id) of every previous member this run no longer uses.""" + if not self.previous_members or not self.unused_member_ids: + return [] + + unused_member_ids = set(self.unused_member_ids) + candidates: list[tuple[str, str]] = [] + for member in self.previous_members: + if member.id is None or member.typename is None or member.id not in unused_member_ids: + continue + candidates.append((member.typename, member.id)) + + return candidates + def _get_params_as_str(self) -> str: """Convert the params in dict format, into a string.""" params_as_str: list[str] = [] @@ -108,17 +256,35 @@ async def get_group(self, store_peers: bool = False) -> InfrahubNode | None: self.previous_members = group._get_relationship_many(name="members").peers return group - async def delete_unused(self) -> None: - if self.previous_members and self.unused_member_ids: - for member in self.previous_members: - if member.id in self.unused_member_ids and member.typename: - try: - await self.client.delete(kind=member.typename, id=member.id) - except GraphQLError as exc: - if not exc.message or "Unable to find the node" not in exc.message: - # If the node already has been deleted, skip the error as it would have been deleted - # by the cascade delete of another node - raise + async def delete_unused(self) -> ReapResult: + """Delete the members that this run no longer uses. + + A refusal by the server is a fact about the member, so it is recorded against it + and the remaining candidates are still attempted. Any other failure is not a fact + about the member being deleted, so it stops the reap and is returned as itself + rather than blamed on every member in turn. + + Nothing is raised here: the caller has to record this run's membership before + reporting the failure, or the nodes it created are left in no group at all. + + Returns: + How far the reap got, and what must stay in the group. + + """ + result = ReapResult() + candidates = self._reap_candidates() + + for position, (kind, member_id) in enumerate(candidates): + try: + await self.client.delete(kind=kind, id=member_id, branch=self.branch) + except NodeNotFoundError: + # Already gone, cascade-deleted along with another node. + continue + except Exception as exc: + if _record_failure(result=result, exc=exc, candidates=candidates, position=position): + break + + return result async def add_related_nodes(self, ids: list[str], update_group_context: bool | None = None) -> None: """Add related Nodes IDs to the context. @@ -147,40 +313,48 @@ async def add_related_groups(self, ids: list[str], update_group_context: bool | self.related_group_ids.extend(ids) async def update_group(self) -> None: - """Create or update (using upsert) a CoreStandardGroup to store all the Nodes and Groups used during an execution.""" - members: list[str] = self.related_group_ids + self.related_node_ids + """Create or update (using upsert) a CoreStandardGroup to store all the Nodes and Groups used during an execution. - if not members: - return + Raises: + TrackingGroupCleanupError: When the server refused to delete one or more unused members. + Exception: Whatever stopped the reap, when it was stopped by something other than a refusal. - group_name = self._generate_group_name() - schema = await self.client.schema.get(kind=self.group_type) - description = self._generate_group_description(schema=schema) + """ + members = self._get_members() existing_group = None if self.delete_unused_nodes: existing_group = await self.get_group(store_peers=True) - group = await self.client.create( - kind=self.group_type, - name=group_name, - description=description, - members=members, - branch=self.branch, - **self.group_params, - ) - await group.save(allow_upsert=True, update_group_context=False) - - if not existing_group: - return - - # Calculate how many nodes should be deleted - self.unused_member_ids = list(set(existing_group.members.peer_ids) - set(members)) # type: ignore[union-attr] + previous_member_ids = self._reconcile_with(existing_group=existing_group, members=members) + reap = await self.delete_unused() - if not self.delete_unused_nodes: + if not self._needs_group_write(members=members, previous_member_ids=previous_member_ids): return - await self.delete_unused() + # The group write is attempted before any failure is reported: whatever went wrong, the + # nodes this run created are only reachable later once they are recorded here. + try: + group_name = self._generate_group_name() + schema = await self.client.schema.get(kind=self.group_type) + description = self._generate_group_description(schema=schema) + group = await self.client.create( + kind=self.group_type, + name=group_name, + description=description, + members=members + reap.retained_member_ids, + branch=self.branch, + **self.group_params, + ) + await group.save(allow_upsert=True, update_group_context=False) + except Exception as exc: + if reap.error is None: + raise + # The write usually fails for the reason the reap stopped; report that reason. + raise reap.error from exc + + if (failure := reap.failure()) is not None: + raise failure # TODO : create anoter "read" group. Could be based of the store items # Need to filters the store items inherited from CoreGroup to add them as children # Need to validate that it's UUIDas "key" if we want to implement other methods to store item @@ -196,7 +370,9 @@ def __init__(self, client: InfrahubClientSync) -> None: def get_group(self, store_peers: bool = False) -> InfrahubNodeSync | None: group_name = self._generate_group_name() try: - group = self.client.get(kind=self.group_type, name__value=group_name, include=["members"]) + group = self.client.get( + kind=self.group_type, name__value=group_name, include=["members"], branch=self.branch + ) except NodeNotFoundError: return None @@ -206,11 +382,35 @@ def get_group(self, store_peers: bool = False) -> InfrahubNodeSync | None: self.previous_members = group._get_relationship_many(name="members").peers return group - def delete_unused(self) -> None: - if self.previous_members and self.unused_member_ids: - for member in self.previous_members: - if member.id in self.unused_member_ids and member.typename: - self.client.delete(kind=member.typename, id=member.id) + def delete_unused(self) -> ReapResult: + """Delete the members that this run no longer uses. + + A refusal by the server is a fact about the member, so it is recorded against it + and the remaining candidates are still attempted. Any other failure is not a fact + about the member being deleted, so it stops the reap and is returned as itself + rather than blamed on every member in turn. + + Nothing is raised here: the caller has to record this run's membership before + reporting the failure, or the nodes it created are left in no group at all. + + Returns: + How far the reap got, and what must stay in the group. + + """ + result = ReapResult() + candidates = self._reap_candidates() + + for position, (kind, member_id) in enumerate(candidates): + try: + self.client.delete(kind=kind, id=member_id, branch=self.branch) + except NodeNotFoundError: + # Already gone, cascade-deleted along with another node. + continue + except Exception as exc: + if _record_failure(result=result, exc=exc, candidates=candidates, position=position): + break + + return result def add_related_nodes(self, ids: list[str], update_group_context: bool | None = None) -> None: """Add related Nodes IDs to the context. @@ -239,40 +439,48 @@ def add_related_groups(self, ids: list[str], update_group_context: bool | None = self.related_group_ids.extend(ids) def update_group(self) -> None: - """Create or update (using upsert) a CoreStandardGroup to store all the Nodes and Groups used during an execution.""" - members: list[str] = self.related_node_ids + self.related_group_ids + """Create or update (using upsert) a CoreStandardGroup to store all the Nodes and Groups used during an execution. - if not members: - return + Raises: + TrackingGroupCleanupError: When the server refused to delete one or more unused members. + Exception: Whatever stopped the reap, when it was stopped by something other than a refusal. - group_name = self._generate_group_name() - schema = self.client.schema.get(kind=self.group_type) - description = self._generate_group_description(schema=schema) + """ + members = self._get_members() existing_group = None if self.delete_unused_nodes: existing_group = self.get_group(store_peers=True) - group = self.client.create( - kind=self.group_type, - name=group_name, - description=description, - members=members, - branch=self.branch, - **self.group_params, - ) - group.save(allow_upsert=True, update_group_context=False) - - if not existing_group: - return - - # Calculate how many nodes should be deleted - self.unused_member_ids = list(set(existing_group.members.peer_ids) - set(members)) # type: ignore[union-attr] + previous_member_ids = self._reconcile_with(existing_group=existing_group, members=members) + reap = self.delete_unused() - if not self.delete_unused_nodes: + if not self._needs_group_write(members=members, previous_member_ids=previous_member_ids): return - self.delete_unused() + # The group write is attempted before any failure is reported: whatever went wrong, the + # nodes this run created are only reachable later once they are recorded here. + try: + group_name = self._generate_group_name() + schema = self.client.schema.get(kind=self.group_type) + description = self._generate_group_description(schema=schema) + group = self.client.create( + kind=self.group_type, + name=group_name, + description=description, + members=members + reap.retained_member_ids, + branch=self.branch, + **self.group_params, + ) + group.save(allow_upsert=True, update_group_context=False) + except Exception as exc: + if reap.error is None: + raise + # The write usually fails for the reason the reap stopped; report that reason. + raise reap.error from exc + + if (failure := reap.failure()) is not None: + raise failure # TODO : create anoter "read" group. Could be based of the store items # Need to filters the store items inherited from CoreGroup to add them as children diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 37658da37..fb2d5cddf 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -506,7 +506,7 @@ async def _mutate_dropdown_attribute( if schema_attr.kind != "Dropdown": raise ValueError(f"Attribute '{schema_attr.name}' is not of kind Dropdown") - input_data: dict[str, Any] = { + input_data: dict[str, dict[str, Any]] = { "data": { "kind": node_kind, "attribute": schema_attr.name, @@ -783,7 +783,7 @@ def _mutate_dropdown_attribute( if schema_attr.kind != "Dropdown": raise ValueError(f"Attribute '{schema_attr.name}' is not of kind Dropdown") - input_data: dict[str, Any] = { + input_data: dict[str, dict[str, Any]] = { "data": { "kind": node_kind, "attribute": schema_attr.name, diff --git a/infrahub_sdk/schema/generated/contract.py b/infrahub_sdk/schema/generated/contract.py index c2c5754f3..efa23b612 100644 --- a/infrahub_sdk/schema/generated/contract.py +++ b/infrahub_sdk/schema/generated/contract.py @@ -27,7 +27,8 @@ "NumberAttributeParametersWrite": frozenset({"id", "state"}), "NumberAttributeWrite": frozenset({"inherited"}), "NumberPoolAttributeWrite": frozenset({"inherited"}), - "NumberPoolParametersWrite": frozenset({"id", "state"}), + "NumberPoolParametersWrite": frozenset({"id", "number_pool_id", "state"}), + "NumberPoolRangeWrite": frozenset({"id", "state"}), "RelationshipSchemaWrite": frozenset({"hierarchical", "inherited"}), "SchemaExtensionWrite": frozenset({"id", "state"}), "TextAttributeParametersWrite": frozenset({"id", "state"}), diff --git a/infrahub_sdk/schema/generated/read.py b/infrahub_sdk/schema/generated/read.py index ecc931430..f6af52ae8 100644 --- a/infrahub_sdk/schema/generated/read.py +++ b/infrahub_sdk/schema/generated/read.py @@ -65,20 +65,44 @@ class NumberAttributeParametersRead(AttributeParametersRead): ) +class NumberPoolRangeRead(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + start: int | None = Field( + default=None, + description="First number of the range, 1 when unset", + ) + end: int | None = Field( + default=None, + description="Last number of the range, the largest supported number when unset", + ) + weight: int | None = Field( + default=None, + description="Ranges with a higher weight are allocated from first", + ) + + 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", + end_range: int | None = Field( + default=None, + description="Deprecated, use ranges instead. End of the single range, defaults to the largest supported number when only start_range is set", ) - start_range: int = Field( - default=1, - description="Start range for numbers for the associated NumberPool", + start_range: int | None = Field( + default=None, + description="Deprecated, use ranges instead. Start of the single range, defaults to 1 when only end_range is set", + ) + ranges: list[NumberPoolRangeRead] | None = Field( + default=None, + description="Ranges the NumberPool allocates from, without overlap. Unset means one range with no start or end; an empty list means no range", ) 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.", ) + allocation_scope: list[str] | None = Field( + default=None, + description="Fields of the kind that divide the pool's space; allocation returns the lowest free number within the writer's division", + ) class DropdownChoiceRead(BaseModel): diff --git a/infrahub_sdk/schema/generated/write.py b/infrahub_sdk/schema/generated/write.py index a834ea442..31fccda96 100644 --- a/infrahub_sdk/schema/generated/write.py +++ b/infrahub_sdk/schema/generated/write.py @@ -65,19 +65,39 @@ class NumberAttributeParametersWrite(AttributeParametersWrite): ) +class NumberPoolRangeWrite(BaseModel): + model_config = ConfigDict(extra="ignore", use_enum_values=True) + start: int | None = Field( + default=None, + description="First number of the range, 1 when unset", + ) + end: int | None = Field( + default=None, + description="Last number of the range, the largest supported number when unset", + ) + weight: int | None = Field( + default=None, + description="Ranges with a higher weight are allocated from first", + ) + + 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", + end_range: int | None = Field( + default=None, + description="Deprecated, use ranges instead. End of the single range, defaults to the largest supported number when only start_range is set", ) - start_range: int = Field( - default=1, - description="Start range for numbers for the associated NumberPool", + start_range: int | None = Field( + default=None, + description="Deprecated, use ranges instead. Start of the single range, defaults to 1 when only end_range is set", + ) + ranges: list[NumberPoolRangeWrite] | None = Field( + default=None, + description="Ranges the NumberPool allocates from, without overlap. Unset means one range with no start or end; an empty list means no range", ) - number_pool_id: str | None = Field( + allocation_scope: list[str] | None = Field( default=None, - description="The ID of the numberpool associated with this attribute. Only set after the number pool has been provisioned.", + description="Fields of the kind that divide the pool's space; allocation returns the lowest free number within the writer's division", ) diff --git a/infrahub_sdk/spec/menu.py b/infrahub_sdk/spec/menu.py index d49985f0d..a09f8e570 100644 --- a/infrahub_sdk/spec/menu.py +++ b/infrahub_sdk/spec/menu.py @@ -1,5 +1,8 @@ from __future__ import annotations +from pydantic import ValidationError as PydanticValidationError + +from ..exceptions import ValidationError from ..yaml import InfrahubFile, InfrahubFileKind from .object import InfrahubObjectFileData, ObjectFile @@ -24,11 +27,17 @@ class MenuFile(ObjectFile): @property def spec(self) -> InfrahubMenuFileData: if not self._spec: - self._spec = InfrahubMenuFileData(**self.data.spec) + try: + self._spec = InfrahubMenuFileData.model_validate(self.data.spec) + except PydanticValidationError as exc: + raise self._content_error(exc) from exc return self._spec def validate_content(self) -> None: InfrahubFile.validate_content(self) if self.kind != InfrahubFileKind.MENU: - raise ValueError("File is not an Infrahub Menu file") - self._spec = InfrahubMenuFileData(**self.data.spec) + raise ValidationError(identifier=str(self.location), message="File is not an Infrahub Menu file") + try: + self._spec = InfrahubMenuFileData.model_validate(self.data.spec) + except PydanticValidationError as exc: + raise self._content_error(exc) from exc diff --git a/infrahub_sdk/spec/object.py b/infrahub_sdk/spec/object.py index b2c86a6c3..fe78e594b 100644 --- a/infrahub_sdk/spec/object.py +++ b/infrahub_sdk/spec/object.py @@ -5,8 +5,10 @@ from typing import TYPE_CHECKING, Any, TypeGuard from pydantic import BaseModel, Field +from pydantic import ValidationError as PydanticValidationError from ..exceptions import ObjectValidationError, ValidationError +from ..node.inline_peers import peers_are_inline_input from ..schema import GenericSchemaAPI, NodeSchemaAPI, RelationshipCardinality, RelationshipKind, RelationshipSchemaAPI from ..utils import is_valid_uuid from ..yaml import InfrahubFile, InfrahubFileKind @@ -121,6 +123,30 @@ def is_valid(self) -> bool: def is_reference(self) -> bool: return self.format in {RelationshipDataFormat.ONE_REF, RelationshipDataFormat.MANY_REF} + @property + def peers_are_inline_fields(self) -> bool: + """Indicate whether the mutation takes the peers' fields, which this value declares as objects.""" + return peers_are_inline_input(self.rel_schema) and self.format in { + RelationshipDataFormat.MANY_OBJ_DICT_LIST, + RelationshipDataFormat.MANY_OBJ_LIST_DICT, + } + + async def inline_peer_fields( + self, value: Any, parameters: InfrahubObjectParameters | None = None + ) -> list[dict[str, Any]]: + """Return the fields of every peer, from either form that declares them as objects. + + A list under ``data`` goes through the processors the validation pass runs over it, so both + passes read the same peers. + """ + if self.format == RelationshipDataFormat.MANY_OBJ_DICT_LIST: + return await DataProcessorFactory.process_data( + kind=value.get("kind") or self.peer_kind, + data=list(value["data"]), + parameters=parameters or InfrahubObjectParameters(), + ) + return [item["data"] for item in value] + def get_context(self, value: Any) -> dict: """Return a dict to insert to the context if the relationship is mandatory.""" if self.peer_rel and self.is_mandatory and self.peer_rel.cardinality == RelationshipCardinality.ONE: @@ -624,6 +650,16 @@ async def create_node( ] elif rel_info.format == RelationshipDataFormat.ONE_REF: clean_data[key] = await cls._clean_reference(client, rel_info, value, [*position, key], branch) + elif rel_info.peers_are_inline_fields: + # Infrahub refuses these peers when they are created afterwards as nodes of + # their own, against the ones the object already holds. + clean_data[key] = await rel_info.inline_peer_fields( + value, + # The validation pass falls back to the file's parameters, so this one must too. + parameters=InfrahubObjectParameters(**value["parameters"]) + if isinstance(value, dict) and "parameters" in value + else parameters, + ) elif not rel_info.is_reference and rel_info.is_bidirectional and rel_info.is_mandatory: remaining_rels.append(key) elif not rel_info.is_reference and not rel_info.is_mandatory: @@ -817,19 +853,19 @@ class ObjectFile(InfrahubFile): def spec(self) -> InfrahubObjectFileData: if not self._spec: try: - self._spec = InfrahubObjectFileData(**self.data.spec) - except Exception as exc: - raise ValidationError(identifier=str(self.location), message=str(exc)) from exc + self._spec = InfrahubObjectFileData.model_validate(self.data.spec) + except PydanticValidationError as exc: + raise self._content_error(exc) from exc return self._spec def validate_content(self) -> None: super().validate_content() if self.kind != InfrahubFileKind.OBJECT: - raise ValueError("File is not an Infrahub Object file") + raise ValidationError(identifier=str(self.location), message="File is not an Infrahub Object file") try: - self._spec = InfrahubObjectFileData(**self.data.spec) - except Exception as exc: - raise ValidationError(identifier=str(self.location), message=str(exc)) from exc + self._spec = InfrahubObjectFileData.model_validate(self.data.spec) + except PydanticValidationError as exc: + raise self._content_error(exc) from exc async def validate_format(self, client: InfrahubClient, branch: str | None = None) -> None: self.validate_content() diff --git a/infrahub_sdk/task/manager.py b/infrahub_sdk/task/manager.py index 59f17f869..feabf2c2c 100644 --- a/infrahub_sdk/task/manager.py +++ b/infrahub_sdk/task/manager.py @@ -28,7 +28,7 @@ def _generate_query( limit: int | None = None, count: bool = False, ) -> Query: - query: dict[str, Any] = { + query: dict[str, dict[str, Any]] = { "InfrahubTask": { "edges": { "node": { @@ -92,7 +92,7 @@ def _generate_query( @classmethod def _generate_count_query(cls, filters: TaskFilter | None = None) -> Query: - query: dict[str, Any] = { + query: dict[str, dict[str, Any]] = { "InfrahubTask": { "count": None, } diff --git a/infrahub_sdk/testing/repository.py b/infrahub_sdk/testing/repository.py index 941f118ee..0bd67e711 100644 --- a/infrahub_sdk/testing/repository.py +++ b/infrahub_sdk/testing/repository.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os import shutil from dataclasses import dataclass, field from enum import Enum @@ -43,6 +44,8 @@ class GitRepo: directories_to_ignore: list[str] = field(default_factory=list) remote_directory_name: str = "/remote" _branches: list[str] = field(default_factory=list) + shared: bool = False + """Give every user read and write access to the repository, for Infrahub containers that run as another user.""" @property def repo(self) -> GitRepoManager: @@ -75,6 +78,33 @@ def init(self) -> None: porcelain.commit(repo=self._repo.git.path, message="First commit") porcelain.checkout(repo=self._repo.git, target=self.initial_branch) + if self.shared: + self._share() + + def _share(self) -> None: + """Give every user read and write access to the repository, now and to the files Git adds later. + + `core.sharedRepository` makes Git create the files of a later commit or push with the same access. + The files that exist already, including those copied from `src_directory`, get it here. + """ + config = self.repo.git.get_config() + config.set((b"core",), b"sharedRepository", b"0666") + config.write_to_path() + + root_directory = self.dst_directory / self.name + root_directory.chmod(0o777) + for root, directories, files in os.walk(root_directory): + for directory in directories: + directory_path = Path(root) / directory + if not directory_path.is_symlink(): + directory_path.chmod(0o777) + for file in files: + file_path = Path(root) / file + if file_path.is_symlink(): + continue + mode = file_path.stat().st_mode + file_path.chmod(mode | 0o666 | (0o111 if mode & 0o111 else 0)) + async def add_to_infrahub(self, client: InfrahubClient, branch: str | None = None) -> dict: input_data = { "data": { diff --git a/infrahub_sdk/trace_context.py b/infrahub_sdk/trace_context.py new file mode 100644 index 000000000..c2100fbc3 --- /dev/null +++ b/infrahub_sdk/trace_context.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import logging +import re +from functools import cache +from importlib.metadata import PackageNotFoundError, version + +LOGGER = logging.getLogger("infrahub_sdk") + +MINIMUM_OPENTELEMETRY_API = (1, 33) + + +def trace_context_headers() -> dict[str, str]: + """Return the W3C trace context headers of the active span, or no header when a supported OpenTelemetry is not installed.""" + if not _supported_opentelemetry_is_installed(): + return {} + + try: + from opentelemetry import propagate # noqa: PLC0415 + except ImportError: + return {} + except ValueError as exc: + # OpenTelemetry validates OTEL_PROPAGATORS on import, and a value set for another tool must not fail requests. + _warn_invalid_propagators(str(exc)) + return {} + + carrier: dict[str, str] = {} + propagate.inject(carrier) + return carrier + + +@cache +def _supported_opentelemetry_is_installed() -> bool: + # Older releases can raise unexpected errors on import, so they are never imported; the lookup is cached + # because reading package metadata costs far more than the rest of the request headers. + try: + installed = version("opentelemetry-api") + except PackageNotFoundError: + return False + + release = re.match(r"(\d+)\.(\d+)", installed) + if release is None or (int(release[1]), int(release[2])) < MINIMUM_OPENTELEMETRY_API: + LOGGER.warning( + "Not sending the trace context: opentelemetry-api %s is installed, %s or later is required", + installed, + ".".join(str(part) for part in MINIMUM_OPENTELEMETRY_API), + ) + return False + return True + + +@cache +def _warn_invalid_propagators(reason: str) -> None: + LOGGER.warning("Not sending the trace context: OpenTelemetry could not load its propagators (%s)", reason) diff --git a/infrahub_sdk/transfer/importer/json.py b/infrahub_sdk/transfer/importer/json.py index 8b0c1e730..4e24f0590 100644 --- a/infrahub_sdk/transfer/importer/json.py +++ b/infrahub_sdk/transfer/importer/json.py @@ -194,9 +194,13 @@ async def execute_batches( if self.console: progress.stop() raise result - if isinstance(result, GraphQLError): + # Keyed on there being server errors to render rather than on the class: a + # lookup miss is a GraphQLError too, and carries its reason in its message. + if isinstance(result, GraphQLError) and result.errors: error_name = type(result).__name__ - error_msgs = [err["message"] for err in result.errors] + # `.get` rather than indexing, so an entry with no message cannot raise out + # of the branch whose whole job is to keep going. + error_msgs = [err.get("message", err) for err in result.errors] error_str = f"{error_name}: {error_msgs}" else: error_str = str(result) diff --git a/infrahub_sdk/types.py b/infrahub_sdk/types.py index 35f5d0e94..305e61185 100644 --- a/infrahub_sdk/types.py +++ b/infrahub_sdk/types.py @@ -15,6 +15,8 @@ class HTTPMethod(str, enum.Enum): GET = "get" POST = "post" + PUT = "put" + DELETE = "delete" class RequesterTransport(str, enum.Enum): diff --git a/infrahub_sdk/yaml.py b/infrahub_sdk/yaml.py index dd5a07541..ec8ce9d5a 100644 --- a/infrahub_sdk/yaml.py +++ b/infrahub_sdk/yaml.py @@ -6,10 +6,11 @@ import yaml from pydantic import BaseModel, Field +from pydantic import ValidationError as PydanticValidationError from typing_extensions import Self from yaml.parser import ParserError -from .exceptions import FileNotValidError +from .exceptions import FileNotValidError, ValidationError from .utils import read_file @@ -160,7 +161,23 @@ def kind(self) -> InfrahubFileKind: def validate_content(self) -> None: if not self.content: raise ValueError("Content hasn't been loaded yet") - self._data = InfrahubFileData(**self.content) + try: + self._data = InfrahubFileData.model_validate(self.content) + except PydanticValidationError as exc: + raise self._content_error(exc) from exc + + def _content_error(self, exc: PydanticValidationError) -> ValidationError: + """Describe each pydantic error on one line, as `path | reason, received value (type)`.""" + messages = [] + for error in exc.errors(): + message = error["msg"] + if error["type"] != "missing" and isinstance(error.get("input"), (str, int, float, bool)): + message += f", received {error['input']!r}" + message += f" ({error['type']})" + if error["loc"]: + message = f"{'/'.join(str(item) for item in error['loc'])} | {message}" + messages.append(message) + return ValidationError(identifier=str(self.location), messages=messages) class SchemaFile(YamlFile): diff --git a/pyproject.toml b/pyproject.toml index 6fbb79a93..6f373d3f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,10 +19,19 @@ classifiers = [ "Programming Language :: Python :: 3.15", ] dependencies = [ - "pydantic>=2.0.0,!=2.0.1,!=2.1.0,<3.0.0", + # 2.0 and 2.0.2 cannot build the generated schema models: their regex engine rejects the + # `\_` escape in the model field patterns, so `import infrahub_sdk` fails outright. + "pydantic>=2.0.3,!=2.1.0,<3.0.0", "pydantic-settings>=2.0", "graphql-core>=3.2.12,<3.3", "httpx>=0.20", + "anyio>=3.3.0", # 3.3.0 introduced `anyio.Path`, used by the async file handler + "typing-extensions>=4.4.0", # 4.4.0 introduced PEP 696 `TypeVar(default=...)` + # 3.1.5 for the error positions the template error reporting relies on; 3.1.4 reports + # the wrong template for a nested undefined and omits the source path on a missing file. + "Jinja2>=3.1.5", # template rendering in `template/` and `protocols_generator/` + "pyyaml>=6", # YAML parsing in `yaml.py`, `spec/`, `template/infrahub_filters.py` and the pytest plugin + "rich>=12.6", # `Traceback`/`Frame`/`Syntax` in the Jinja error model, and transfer progress bars "ujson>=5", "dulwich>=0.24.7", "whenever>=0.9.3,<0.10.0", @@ -43,31 +52,40 @@ infrahubctl = "infrahub_sdk.ctl.cli:app" [project.optional-dependencies] ctl = [ - "Jinja2>=3", - "numpy>=1.24.2; python_version<'3.12'", - "numpy>=1.26.2; python_version>='3.12'", "pyarrow>=14", - "pyyaml>=6", - "ruamel.yaml>=0.18", - "rich>=12.6,<14", - "typer>=0.15.0", + "ruamel.yaml>=0.18", # round-trip mode preserves comments when `schema format` rewrites a file + # 0.16 is the first release that calls click's `Parameter.make_metavar()` with the `ctx` + # argument click 8.3 made mandatory. Below it, any `--help` raises TypeError against the + # `click` floor below. Widening this means lowering that floor, not this one. + "typer>=0.16.0", + # Not imported directly; constrains the `click` that `typer` resolves to. "click>=8.3,<9", "ariadne-codegen==0.18.0", - "mdxify>=0.2.23; python_version>='3.10'", ] +# The bundled `pytest-infrahub` plugin and the schemas under `infrahub_sdk.testing`, for writing +# tests against your own Transforms, Queries and Checks. +testing = [ + "pytest>=7.0", +] + +# `infrahub_sdk.testing.docker`, which starts a real Infrahub in containers. Kept apart from +# `testing` because it is the only module that needs it and it is a far heavier install. +testcontainers = [ + "infrahub-sdk[testing]", + "infrahub-testcontainers>=1.7.3", + "packaging>=21.0", +] + +# Sends the W3C trace context with each request when the caller's process traces with OpenTelemetry. +# 1.33 is the first release that accepts `OTEL_PROPAGATORS=none`; older releases raise on it. +tracing = [ + "opentelemetry-api>=1.33", +] + +# Self-referential so it cannot drift out of sync with the extras it aggregates. all = [ - "Jinja2>=3", - "numpy>=1.24.2; python_version<'3.12'", - "numpy>=1.26.2; python_version>='3.12'", - "pyarrow>=14", - "pytest", - "pyyaml>=6", - "ruamel.yaml>=0.18", - "rich>=12.6,<14", - "typer>=0.15.0", - "click>=8.3,<9", - "ariadne-codegen==0.18.0", + "infrahub-sdk[ctl,testing,testcontainers,tracing]", ] [dependency-groups] @@ -75,6 +93,7 @@ all = [ tests = [ "infrahub-testcontainers>=1.7.3", "jsonschema>=4.25.1", + "opentelemetry-api>=1.33", "pytest>=9.0,<9.1", "pytest-asyncio>=1.3,<1.4", "pytest-clarity>=1.0.1", @@ -83,26 +102,30 @@ tests = [ "pytest-xdist>=3.3.1", ] lint = [ - "yamllint", + "yamllint>=1.35", "mypy==2.3.1", "ruff==0.15.12", "astroid>=3.1,<4.0", - "ty==0.0.14", + "ty==0.0.74", "rumdl==0.2.28", ] types = [ - "types-ujson", - "types-pyyaml", + "types-ujson>=5.10", + "types-pyyaml>=6.0.12", "types-python-slugify>=8.0.0.3", ] +docs = [ + "mdxify>=0.2.23", +] dev = [ {include-group = "tests"}, {include-group = "lint"}, {include-group = "types"}, - "ipython", + {include-group = "docs"}, + "ipython>=8.18", "requests>=2.33.0", "prek>=0.3.0", - "codecov", + "codecov>=2.1", "invoke>=2.2.1", "towncrier>=24.8.0", ] @@ -121,6 +144,12 @@ filterwarnings = [ "ignore:Module already imported so cannot be rewritten", "ignore:Deprecated call to", ] +markers = [ + "catalogue: Drives a catalogued failure through a real server, proving the envelope shape", + "crossversion: Parses an envelope from a server whose version differs from the SDK's", + "malformed: Parses an envelope that violates the shape the SDK expects", + "message: Pins an exception's message text, in either the catalogued or the uncatalogued direction", +] addopts = "-vs --strict-markers --cov-report term-missing --cov-report xml --dist loadscope" [tool.ty] @@ -165,13 +194,50 @@ include = ["infrahub_sdk/node/node.py"] [tool.ty.overrides.rules] invalid-argument-type = "ignore" # 8 violations +unresolved-attribute = "ignore" # 22 violations - `_schema` is a union and only some members carry the attribute +unsupported-operator = "ignore" # 1 violation +[[tool.ty.overrides]] +include = ["infrahub_sdk/pytest_plugin/**"] + +[tool.ty.overrides.rules] +unresolved-attribute = "ignore" # 35 violations - resource_config is a union of repository config element types + +[[tool.ty.overrides]] +include = ["infrahub_sdk/node/attribute.py"] + +[tool.ty.overrides.rules] +invalid-argument-type = "ignore" # 2 violations [[tool.ty.overrides]] -include = ["infrahub_sdk/ctl/config.py"] +include = ["infrahub_sdk/config.py"] [tool.ty.overrides.rules] -unresolved-import = "ignore" # import tomli as tomllib when running on later versions of Python +invalid-return-type = "ignore" # 1 violation + +[[tool.ty.overrides]] +# Mirrors the module-level mypy `return-value` override for the same code. +include = ["infrahub_sdk/utils.py"] + +[tool.ty.overrides.rules] +invalid-return-type = "ignore" # 1 violation + +[[tool.ty.overrides]] +# Mirrors the module-level mypy `call-overload` override for the same code. +include = ["infrahub_sdk/ctl/check.py"] + +[tool.ty.overrides.rules] +no-matching-overload = "ignore" # 1 violation - `**filters` cannot be matched against `get()` overloads + + +[[tool.ty.overrides]] +# Both files fall back to `import tomli as tomllib` below Python 3.11. The `tomli` dependency is +# marked `python_version<'3.11'`, so on a newer interpreter it is absent from site-packages and ty +# cannot resolve the import. Retire alongside Python 3.10 support. +include = ["infrahub_sdk/ctl/config.py", "tests/unit/test_packaging_metadata.py"] + +[tool.ty.overrides.rules] +unresolved-import = "ignore" [[tool.ty.overrides]] @@ -185,6 +251,7 @@ include = ["tests/fixtures/**"] [tool.ty.overrides.rules] possibly-missing-attribute = "ignore" # Test fixtures use dynamic attributes +unresolved-attribute = "ignore" # 5 violations - fixtures use dynamic node attributes # Test-specific overrides - tests have more lenient type checking # Fix these incrementally, starting with files that have fewer violations @@ -195,6 +262,12 @@ include = ["tests/unit/sdk/conftest.py"] [tool.ty.overrides.rules] invalid-argument-type = "ignore" # 434 violations - test fixtures with dynamic types +[[tool.ty.overrides]] +include = ["tests/unit/ctl/test_utils.py"] + +[tool.ty.overrides.rules] +unresolved-attribute = "ignore" # 1 violation - a narrowed Console file + [[tool.ty.overrides]] include = [ "tests/unit/sdk/test_node.py", @@ -222,6 +295,7 @@ invalid-argument-type = "ignore" # ~120 violations across integration tests invalid-assignment = "ignore" no-matching-overload = "ignore" possibly-missing-attribute = "ignore" # Tests use dynamic node attributes +unresolved-attribute = "ignore" # ~205 violations - `node..value` resolves to a union of attribute/relationship types [[tool.ty.overrides]] include = [ @@ -234,6 +308,7 @@ include = [ invalid-argument-type = "ignore" # 25 violations invalid-assignment = "ignore" possibly-missing-attribute = "ignore" +unresolved-attribute = "ignore" # 1 violation in test_client.py [[tool.ty.overrides]] include = [ @@ -246,6 +321,11 @@ include = [ "tests/unit/sdk/test_schema_sorter.py", "tests/unit/sdk/test_topological_sort.py", "tests/unit/sdk/test_schema_export.py", + "tests/unit/ctl/formatters/test_init.py", + "tests/unit/ctl/test_marketplace_app.py", + "tests/unit/sdk/test_file_handler.py", + "tests/unit/sdk/test_graph_traversal.py", + "tests/unit/sdk/test_store.py", ] [tool.ty.overrides.rules] @@ -253,6 +333,27 @@ invalid-argument-type = "ignore" # Remaining files with 1-5 violations each invalid-method-override = "ignore" no-matching-overload = "ignore" +[[tool.ty.overrides]] +include = ["tests/__init__.py", "tests/unit/__init__.py", "tests/unit/sdk/pool/test_allocate.py"] + +[tool.ty.overrides.rules] +unresolved-attribute = "ignore" # 5 violations - `builtins` is patched with rich helpers for tests + +[[tool.ty.overrides]] +include = ["tests/conftest.py", "tests/unit/sdk/test_file_object.py"] + +[tool.ty.overrides.rules] +invalid-assignment = "ignore" # 21 violations - assigning to `node..value` through a union + +[[tool.ty.overrides]] +include = ["tests/unit/sdk/test_priority.py"] + +[tool.ty.overrides.rules] +invalid-argument-type = "ignore" # 22 violations total +invalid-await = "ignore" +unknown-argument = "ignore" +unresolved-attribute = "ignore" + [[tool.ty.overrides]] include = ["docs/**"] diff --git a/tasks.py b/tasks.py index 35cd6dba0..6e2c62e36 100644 --- a/tasks.py +++ b/tasks.py @@ -200,6 +200,7 @@ def get_modules_to_document() -> list[str]: # Packages explicitly ignored for API doc generation. packages_to_ignore = [ "ctl", + "exceptions", "graphql", "protocols_generator", "pytest_plugin", diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 94f7b61d8..1a50ecc7f 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -90,8 +90,39 @@ def test_cli_command(): - Use `httpx_mock` fixture for HTTP mocking - Clean up resources in integration tests +- Let `tests/conftest.py` own Rich's rendering environment (see below) instead of pinning + colour or width per test 🚫 **Never** - Add `@pytest.mark.asyncio` (globally enabled) - Make unit tests depend on external services +- Set `TERM=dumb` to disable colour — it pins Rich's width to 80 and ignores `COLUMNS`, which + truncates the wide tables the CLI-output fixtures record + +## CLI output and Rich + +Tests that assert on CLI text compare against output whose colour and width Rich decides. Both +are pinned centrally by `pytest_configure` in `tests/conftest.py`, which unsets `FORCE_COLOR` +and sets `NO_COLOR=1` and `COLUMNS=200` before any test module is imported. + +The timing matters. Rich snapshots `no_color` when a `Console` is constructed, and treats a set +`FORCE_COLOR` as proof it is writing to a terminal — on Rich 12.6 through 13 *any* value, the +empty string included; from Rich 14 any non-empty value. Removing the variable is the only strategy correct on +both, which is why the hook removes it rather than overriding it. Many `infrahub_sdk.ctl` modules +build a module-level `Console()`, which runs during collection, so a fixture or an env override +passed to `CliRunner.invoke()` is already too late for those consoles. + +Practical consequences: + +- A plain `CliRunner()` is fine; it inherits the pinned environment. Pass `env=` only to widen + `COLUMNS` beyond 200 for a specific test. +- When a test builds its own `Console`, make it explicit — + `Console(file=StringIO(), width=1000, no_color=True, force_terminal=False)` — so it does not + depend on the ambient environment at all. Prefer wrapping that in a fixture that patches the + module-level console and yields it, as `schema_console` in `tests/unit/sdk/test_schema.py` + does, rather than repeating the `mock.patch` block per test. +- Tests that cover the test infrastructure itself, rather than any SDK behaviour, live in + `tests/unit/meta/`. +- Prefer fixing the environment over loosening an assertion, so exact-output tests keep their + value. diff --git a/tests/conftest.py b/tests/conftest.py index 9098d3732..7e1e3ac9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,33 @@ ENV_VARS_TO_CLEAN = ["INFRAHUB_ADDRESS", "INFRAHUB_TOKEN", "INFRAHUB_BRANCH", "INFRAHUB_USERNAME", "INFRAHUB_PASSWORD"] +# Rendering environment for every test in this suite. +# +# Rich snapshots ``no_color`` when a ``Console`` is constructed, and a ``Console`` reports +# itself as a terminal if ``FORCE_COLOR`` is set -- on Rich 12.6 through 13 to *any* value, empty +# string included; from Rich 14 to any non-empty value. Removing the variable is the one strategy that +# is correct on both, so it is removed rather than overridden. Many ``infrahub_sdk.ctl`` modules +# build a module-level ``Console()``, and that happens while pytest +# imports the test modules -- before any fixture can run. So the environment has to be pinned +# from a hook that runs ahead of collection, which is what ``pytest_configure`` does. +# +# Without this, a developer whose shell exports ``FORCE_COLOR`` (or whose terminal is narrower +# than the CLI-output fixtures) gets ANSI escapes and truncated Rich tables in captured output, +# and every test that asserts on CLI text fails locally while staying green in CI. +# +# ``TERM`` is deliberately left alone: ``TERM=dumb`` puts Rich in its dumb-terminal path, which +# pins the width to 80 and ignores ``COLUMNS``, truncating the wide tables these fixtures record. +RENDER_ENV = {"NO_COLOR": "1", "COLUMNS": "200"} +RENDER_ENV_TO_CLEAN = ["FORCE_COLOR"] + + +def pytest_configure(config: pytest.Config) -> None: + """Pin Rich's colour and width before any test module is imported.""" + for name in RENDER_ENV_TO_CLEAN: + os.environ.pop(name, None) + os.environ.update(RENDER_ENV) + + def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: pytest_asyncio_tests = (item for item in items if pytest_asyncio.is_async_test(item)) session_scope_marker = pytest.mark.asyncio(loop_scope="session") diff --git a/tests/constants.py b/tests/constants.py index c41bd318a..74d17b00d 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -7,3 +7,7 @@ TEST_DIR = Path(__file__).parent FIXTURES_DIR = TEST_DIR / "fixtures" FIXTURE_REPOS_DIR = FIXTURES_DIR / "repos" + +REPO_ROOT = TEST_DIR.parent +PACKAGE_DIR = REPO_ROOT / "infrahub_sdk" +PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" diff --git a/tests/fixtures/error_catalogue/README.md b/tests/fixtures/error_catalogue/README.md new file mode 100644 index 000000000..54f8038d7 --- /dev/null +++ b/tests/fixtures/error_catalogue/README.md @@ -0,0 +1,35 @@ +# Error catalogue fixtures + +Two transports appear here: + +- **GraphQL** (`/graphql`). Data failures arrive as HTTP 200 with an `errors` array; authentication + failures arrive as a real 401 or 403. +- **REST** (`/api/...`), which carries no `data`. + +Cutting across both is what `extensions.code` holds. A catalogue-aware server sends a **string**, the +catalogue code. A pre-catalogue server sends an **integer** mirroring the HTTP status, which is not a +catalogue code and which the parser resolves to `code=None`. The integer shape is the norm on REST +(`rest_legacy_401.json`) but also reaches the GraphQL path from an older server +(`graphql_integer_code.json`), so the two axes are independent: do not read a `graphql_*` filename as a +promise that the code is a string. + +The `graphql_*`, `auth_*`, and `rest_*` files are captured responses, kept in the shape the server +sends rather than hand-shaped to suit the parser. A fixture authored against the parser can only +prove the parser agrees with itself. + +The `malformed_*` files are the exception: they are constructed, because a correct server does not +produce them. They stand in for a proxy, a gateway, or a future server that answers in a shape the +SDK does not expect, and they exist to pin that such a shape degrades rather than raising. + +`codes/` holds one envelope per catalogue code, named after the code in lower case, each carrying the +payload that code declares. They are the exhaustive set: a code without a file here is a code nothing +proves the SDK can resolve, which is why the test that reads them addresses each file by code rather +than listing the directory, and separately pins its case list equal to the bindings' own code map. +All of them are shaped as a GraphQL response, including the three +authentication codes, which reach that transport whenever a resolver rather than the request pipeline +raised them. + +`public_names.json` is not an envelope. It is the committed snapshot of every exception class +importable from `infrahub_sdk.exceptions`, which pins that restructuring the module into a package +stays invisible from outside. It lists exception classes only; incidental typing imports that the +flat module used to re-export are not part of that surface and are no longer importable. diff --git a/tests/fixtures/error_catalogue/auth_token_expired.json b/tests/fixtures/error_catalogue/auth_token_expired.json new file mode 100644 index 000000000..62b4f7ec1 --- /dev/null +++ b/tests/fixtures/error_catalogue/auth_token_expired.json @@ -0,0 +1,12 @@ +{ + "errors": [ + { + "message": "Expired Signature", + "extensions": { + "code": "TOKEN_EXPIRED", + "http_status": 401, + "data": {} + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/auth_two_messages.json b/tests/fixtures/error_catalogue/auth_two_messages.json new file mode 100644 index 000000000..3075fab09 --- /dev/null +++ b/tests/fixtures/error_catalogue/auth_two_messages.json @@ -0,0 +1,14 @@ +{ + "errors": [ + { + "message": "first problem", + "extensions": { + "code": "AUTHENTICATION_REQUIRED", + "http_status": 401 + } + }, + { + "message": "second problem" + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/attribute_constraint_violation.json b/tests/fixtures/error_catalogue/codes/attribute_constraint_violation.json new file mode 100644 index 000000000..c8eb60a4a --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/attribute_constraint_violation.json @@ -0,0 +1,27 @@ +{ + "data": null, + "errors": [ + { + "message": "Value for TestPerson.name does not conform to the regex '^[A-Z]'", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "ATTRIBUTE_CONSTRAINT_VIOLATION", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "field_name": "name", + "constraint": "regex", + "detail": "^[A-Z]" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/attribute_invalid_type.json b/tests/fixtures/error_catalogue/codes/attribute_invalid_type.json new file mode 100644 index 000000000..ffe04011e --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/attribute_invalid_type.json @@ -0,0 +1,27 @@ +{ + "data": null, + "errors": [ + { + "message": "Value for TestPerson.height must be of type Integer, received String", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "ATTRIBUTE_INVALID_TYPE", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "field_name": "height", + "expected_type": "Integer", + "received_type": "String" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/attribute_required.json b/tests/fixtures/error_catalogue/codes/attribute_required.json new file mode 100644 index 000000000..20c5e005c --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/attribute_required.json @@ -0,0 +1,25 @@ +{ + "data": null, + "errors": [ + { + "message": "A value is required for TestPerson.name", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "ATTRIBUTE_REQUIRED", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "field_name": "name" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/authentication_required.json b/tests/fixtures/error_catalogue/codes/authentication_required.json new file mode 100644 index 000000000..c49b79209 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/authentication_required.json @@ -0,0 +1,22 @@ +{ + "data": null, + "errors": [ + { + "message": "Authentication is required to perform this operation", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "AUTHENTICATION_REQUIRED", + "http_status": 401, + "data": {} + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/branch_already_merged.json b/tests/fixtures/error_catalogue/codes/branch_already_merged.json new file mode 100644 index 000000000..11a4ba7fa --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/branch_already_merged.json @@ -0,0 +1,24 @@ +{ + "data": null, + "errors": [ + { + "message": "Branch: feature-a has already been merged and is read-only.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "BRANCH_ALREADY_MERGED", + "http_status": 400, + "data": { + "branch_name": "feature-a" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/branch_needs_rebase.json b/tests/fixtures/error_catalogue/codes/branch_needs_rebase.json new file mode 100644 index 000000000..7b1268ba2 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/branch_needs_rebase.json @@ -0,0 +1,24 @@ +{ + "data": null, + "errors": [ + { + "message": "Branch: feature-a must be rebased before it accepts new changes.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "BRANCH_NEEDS_REBASE", + "http_status": 400, + "data": { + "branch_name": "feature-a" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/branch_not_found.json b/tests/fixtures/error_catalogue/codes/branch_not_found.json new file mode 100644 index 000000000..c88dfbab8 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/branch_not_found.json @@ -0,0 +1,21 @@ +{ + "data": null, + "errors": [ + { + "message": "Branch: does-not-exist not found.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "extensions": { + "code": "BRANCH_NOT_FOUND", + "http_status": 400, + "data": { + "branch_name": "does-not-exist" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/merge_in_progress.json b/tests/fixtures/error_catalogue/codes/merge_in_progress.json new file mode 100644 index 000000000..3494578d6 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/merge_in_progress.json @@ -0,0 +1,25 @@ +{ + "data": null, + "errors": [ + { + "message": "Branch main is locked while feature-a is being merged into it.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "MERGE_IN_PROGRESS", + "http_status": 423, + "data": { + "branch_name": "main", + "merging_branch": "feature-a" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/merge_recovery_required.json b/tests/fixtures/error_catalogue/codes/merge_recovery_required.json new file mode 100644 index 000000000..b7bd7e704 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/merge_recovery_required.json @@ -0,0 +1,25 @@ +{ + "data": null, + "errors": [ + { + "message": "Branch main is protected after a failed merge of feature-a. Run `infrahub recover merge`.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "MERGE_RECOVERY_REQUIRED", + "http_status": 423, + "data": { + "branch_name": "main", + "merging_branch": "feature-a" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/node_not_found.json b/tests/fixtures/error_catalogue/codes/node_not_found.json new file mode 100644 index 000000000..b606ed061 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/node_not_found.json @@ -0,0 +1,25 @@ +{ + "data": null, + "errors": [ + { + "message": "Unable to find the node john / TestPerson in the database.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonDelete" + ], + "extensions": { + "code": "NODE_NOT_FOUND", + "http_status": 404, + "data": { + "node_kind": "TestPerson", + "identifier": "john" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/permission_denied.json b/tests/fixtures/error_catalogue/codes/permission_denied.json new file mode 100644 index 000000000..b6c1ad7c7 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/permission_denied.json @@ -0,0 +1,25 @@ +{ + "data": null, + "errors": [ + { + "message": "The requested operation was not authorized", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonUpdate" + ], + "extensions": { + "code": "PERMISSION_DENIED", + "http_status": 403, + "data": { + "action": "update", + "resource_kind": "TestPerson" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/schema_not_found.json b/tests/fixtures/error_catalogue/codes/schema_not_found.json new file mode 100644 index 000000000..9f265b84f --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/schema_not_found.json @@ -0,0 +1,21 @@ +{ + "data": null, + "errors": [ + { + "message": "Unable to find the schema TestWidget in the database.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "extensions": { + "code": "SCHEMA_NOT_FOUND", + "http_status": 422, + "data": { + "kind": "TestWidget" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/token_expired.json b/tests/fixtures/error_catalogue/codes/token_expired.json new file mode 100644 index 000000000..7fcddb596 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/token_expired.json @@ -0,0 +1,21 @@ +{ + "data": null, + "errors": [ + { + "message": "Expired Signature", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "extensions": { + "code": "TOKEN_EXPIRED", + "http_status": 401, + "data": { + "expired_at": "2026-09-18T09:30:00+00:00" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/undefined_error.json b/tests/fixtures/error_catalogue/codes/undefined_error.json new file mode 100644 index 000000000..930f532fb --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/undefined_error.json @@ -0,0 +1,19 @@ +{ + "data": null, + "errors": [ + { + "message": "Cannot query field 'nope' on type 'Query'.", + "locations": [ + { + "line": 1, + "column": 9 + } + ], + "extensions": { + "code": "UNDEFINED_ERROR", + "http_status": 500, + "data": {} + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/uniqueness_violation.json b/tests/fixtures/error_catalogue/codes/uniqueness_violation.json new file mode 100644 index 000000000..acc470d08 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/uniqueness_violation.json @@ -0,0 +1,27 @@ +{ + "data": null, + "errors": [ + { + "message": "Node of kind TestPerson already has name 'John'", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "UNIQUENESS_VIOLATION", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "fields": [ + "name" + ] + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/codes/worker_timeout.json b/tests/fixtures/error_catalogue/codes/worker_timeout.json new file mode 100644 index 000000000..6b0356470 --- /dev/null +++ b/tests/fixtures/error_catalogue/codes/worker_timeout.json @@ -0,0 +1,26 @@ +{ + "data": null, + "errors": [ + { + "message": "No worker answered git.repository.connectivity within 30 seconds", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "CoreRepositoryCreate" + ], + "extensions": { + "code": "WORKER_TIMEOUT", + "http_status": 504, + "data": { + "operation": "git.repository.connectivity", + "timeout_seconds": 30, + "retry_after_seconds": 30 + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/graphql_extra_payload_field.json b/tests/fixtures/error_catalogue/graphql_extra_payload_field.json new file mode 100644 index 000000000..f5889d7f2 --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_extra_payload_field.json @@ -0,0 +1,19 @@ +{ + "errors": [ + { + "message": "Node of kind TestPerson already has name 'John'", + "extensions": { + "code": "UNIQUENESS_VIOLATION", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "fields": [ + "name" + ], + "introduced_in_a_later_version": "ignored" + } + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_integer_code.json b/tests/fixtures/error_catalogue/graphql_integer_code.json new file mode 100644 index 000000000..f8b1c1d7f --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_integer_code.json @@ -0,0 +1,11 @@ +{ + "errors": [ + { + "message": "Something went wrong", + "extensions": { + "code": 500 + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_multiple_errors.json b/tests/fixtures/error_catalogue/graphql_multiple_errors.json new file mode 100644 index 000000000..0dfe69b5a --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_multiple_errors.json @@ -0,0 +1,36 @@ +{ + "errors": [ + { + "message": "first failure", + "extensions": { + "code": "SCHEMA_NOT_FOUND", + "http_status": 422, + "data": { + "kind": "TestWidget" + } + } + }, + { + "message": "second failure", + "extensions": { + "code": "UNIQUENESS_VIOLATION", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "fields": [ + "name" + ] + } + } + }, + { + "message": "third failure", + "extensions": { + "code": "UNDEFINED_ERROR", + "http_status": 500, + "data": {} + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_no_extensions.json b/tests/fixtures/error_catalogue/graphql_no_extensions.json new file mode 100644 index 000000000..c562b88a7 --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_no_extensions.json @@ -0,0 +1,11 @@ +{ + "errors": [ + { + "message": "Something went wrong", + "path": [ + "TestPersonCreate" + ] + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_permission_denied.json b/tests/fixtures/error_catalogue/graphql_permission_denied.json new file mode 100644 index 000000000..23bd5cfad --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_permission_denied.json @@ -0,0 +1,19 @@ +{ + "errors": [ + { + "message": "You do not have the permission to update TestPerson", + "path": [ + "TestPersonUpdate" + ], + "extensions": { + "code": "PERMISSION_DENIED", + "http_status": 403, + "data": { + "action": "update", + "resource_kind": "TestPerson" + } + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_uniqueness_violation.json b/tests/fixtures/error_catalogue/graphql_uniqueness_violation.json new file mode 100644 index 000000000..e794338e9 --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_uniqueness_violation.json @@ -0,0 +1,21 @@ +{ + "errors": [ + { + "message": "Node of kind TestPerson already has name 'John'", + "path": [ + "TestPersonCreate" + ], + "extensions": { + "code": "UNIQUENESS_VIOLATION", + "http_status": 422, + "data": { + "node_kind": "TestPerson", + "fields": [ + "name" + ] + } + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/graphql_unknown_code.json b/tests/fixtures/error_catalogue/graphql_unknown_code.json new file mode 100644 index 000000000..871a02869 --- /dev/null +++ b/tests/fixtures/error_catalogue/graphql_unknown_code.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "message": "A failure this SDK has never heard of", + "extensions": { + "code": "SOMETHING_WE_HAVE_NEVER_HEARD_OF", + "http_status": 418, + "data": { + "whatever": "the newer server sent" + } + } + } + ], + "data": null +} diff --git a/tests/fixtures/error_catalogue/malformed_code_as_object.json b/tests/fixtures/error_catalogue/malformed_code_as_object.json new file mode 100644 index 000000000..eb841978b --- /dev/null +++ b/tests/fixtures/error_catalogue/malformed_code_as_object.json @@ -0,0 +1,12 @@ +{ + "errors": [ + { + "message": "Something went wrong", + "extensions": { + "code": { + "nested": "object" + } + } + } + ] +} diff --git a/tests/fixtures/error_catalogue/malformed_errors_as_string.json b/tests/fixtures/error_catalogue/malformed_errors_as_string.json new file mode 100644 index 000000000..d344c4016 --- /dev/null +++ b/tests/fixtures/error_catalogue/malformed_errors_as_string.json @@ -0,0 +1,3 @@ +{ + "errors": "a bare string where an array belongs" +} diff --git a/tests/fixtures/error_catalogue/malformed_extensions_as_list.json b/tests/fixtures/error_catalogue/malformed_extensions_as_list.json new file mode 100644 index 000000000..b0a70d9d5 --- /dev/null +++ b/tests/fixtures/error_catalogue/malformed_extensions_as_list.json @@ -0,0 +1,12 @@ +{ + "errors": [ + { + "message": "Something went wrong", + "extensions": [ + "not", + "a", + "mapping" + ] + } + ] +} diff --git a/tests/fixtures/error_catalogue/public_names.json b/tests/fixtures/error_catalogue/public_names.json new file mode 100644 index 000000000..ac5a2d102 --- /dev/null +++ b/tests/fixtures/error_catalogue/public_names.json @@ -0,0 +1,49 @@ +[ + "ApiError", + "AttributeConstraintViolationError", + "AttributeInvalidTypeError", + "AttributeRequiredError", + "AuthenticationError", + "BranchAlreadyMergedError", + "BranchNeedsRebaseError", + "BranchNotFoundError", + "CircularFragmentError", + "DuplicateFragmentError", + "Error", + "FeatureNotSupportedError", + "FieldNotFoundError", + "FieldNotLoadedError", + "FieldSelectionConflictError", + "FileNotValidError", + "FragmentFileNotFoundError", + "FragmentNotFoundError", + "GraphQLError", + "GraphQLQueryError", + "InfrahubCheckNotFoundError", + "InfrahubTransformNotFoundError", + "InvalidResponseError", + "JsonDecodeError", + "MergeInProgressError", + "MergeRecoveryRequiredError", + "ModuleImportError", + "NodeInvalidError", + "NodeNotFoundError", + "NodeNotSavedError", + "ObjectValidationError", + "QuerySyntaxError", + "RateLimitError", + "RepositoryFileNotFoundError", + "ResourceNotDefinedError", + "SchemaNotFoundError", + "ServerNotReachableError", + "ServerNotResponsiveError", + "TimestampFormatError", + "TrackingGroupCleanupError", + "URLNotFoundError", + "UndefinedError", + "UninitializedError", + "UniquenessViolationError", + "ValidationError", + "VersionNotSupportedError", + "WorkerTimeoutError" +] diff --git a/tests/fixtures/error_catalogue/rest_legacy_401.json b/tests/fixtures/error_catalogue/rest_legacy_401.json new file mode 100644 index 000000000..53aeafce4 --- /dev/null +++ b/tests/fixtures/error_catalogue/rest_legacy_401.json @@ -0,0 +1,10 @@ +{ + "errors": [ + { + "message": "Authentication is required", + "extensions": { + "code": 401 + } + } + ] +} diff --git a/tests/fixtures/protocols_generator/user_schema_async.txt b/tests/fixtures/protocols_generator/user_schema_async.txt new file mode 100644 index 000000000..efe608bcb --- /dev/null +++ b/tests/fixtures/protocols_generator/user_schema_async.txt @@ -0,0 +1,320 @@ +# +# Generated by "infrahubctl protocols" +# + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from infrahub_sdk.protocols import CoreNode, BuiltinIPAddress, BuiltinIPAddressSync, BuiltinIPNamespace, BuiltinIPNamespaceSync, BuiltinIPPrefix, BuiltinIPPrefixSync, BuiltinTag, BuiltinTagSync, CoreAccount, CoreAccountGroup, CoreAccountGroupSync, CoreAccountRole, CoreAccountRoleSync, CoreAccountSync, CoreAction, CoreActionSync, CoreArtifact, CoreArtifactCheck, CoreArtifactCheckSync, CoreArtifactDefinition, CoreArtifactDefinitionSync, CoreArtifactSync, CoreArtifactTarget, CoreArtifactTargetSync, CoreArtifactThread, CoreArtifactThreadSync, CoreArtifactValidator, CoreArtifactValidatorSync, CoreBasePermission, CoreBasePermissionSync, CoreChangeComment, CoreChangeCommentSync, CoreChangeThread, CoreChangeThreadSync, CoreCheck, CoreCheckDefinition, CoreCheckDefinitionSync, CoreCheckSync, CoreComment, CoreCommentSync, CoreCredential, CoreCredentialSync, CoreCustomWebhook, CoreCustomWebhookSync, CoreDataCheck, CoreDataCheckSync, CoreDataValidator, CoreDataValidatorSync, CoreEnvKeyValue, CoreEnvKeyValueSync, CoreFileCheck, CoreFileCheckSync, CoreFileObject, CoreFileObjectSync, CoreFileThread, CoreFileThreadSync, CoreGeneratorAction, CoreGeneratorActionSync, CoreGeneratorAwareGroup, CoreGeneratorAwareGroupSync, CoreGeneratorCheck, CoreGeneratorCheckSync, CoreGeneratorDefinition, CoreGeneratorDefinitionSync, CoreGeneratorGroup, CoreGeneratorGroupSync, CoreGeneratorInstance, CoreGeneratorInstanceSync, CoreGeneratorValidator, CoreGeneratorValidatorSync, CoreGenericAccount, CoreGenericAccountSync, CoreGenericRepository, CoreGenericRepositorySync, CoreGlobalPermission, CoreGlobalPermissionSync, CoreGraphQLQuery, CoreGraphQLQueryGroup, CoreGraphQLQueryGroupSync, CoreGraphQLQuerySync, CoreGroup, CoreGroupAction, CoreGroupActionSync, CoreGroupSync, CoreGroupTriggerRule, CoreGroupTriggerRuleSync, CoreIPAddressPool, CoreIPAddressPoolSync, CoreIPPool, CoreIPPoolSync, CoreIPPrefixPool, CoreIPPrefixPoolSync, CoreKeyValue, CoreKeyValueSync, CoreMenu, CoreMenuItem, CoreMenuItemSync, CoreMenuSync, CoreNodeSync, CoreNodeTriggerAttributeMatch, CoreNodeTriggerAttributeMatchSync, CoreNodeTriggerMatch, CoreNodeTriggerMatchSync, CoreNodeTriggerRelationshipMatch, CoreNodeTriggerRelationshipMatchSync, CoreNodeTriggerRule, CoreNodeTriggerRuleSync, CoreNumberPool, CoreNumberPoolRange, CoreNumberPoolRangeSync, CoreNumberPoolSync, CoreObjectComponentTemplate, CoreObjectComponentTemplateSync, CoreObjectPermission, CoreObjectPermissionSync, CoreObjectTemplate, CoreObjectTemplateSync, CoreObjectThread, CoreObjectThreadSync, CorePasswordCredential, CorePasswordCredentialSync, CoreProfile, CoreProfileSync, CoreProposedChange, CoreProposedChangeSync, CoreReadOnlyRepository, CoreReadOnlyRepositorySync, CoreRepository, CoreRepositoryGroup, CoreRepositoryGroupSync, CoreRepositorySync, CoreRepositoryValidator, CoreRepositoryValidatorSync, CoreResourcePool, CoreResourcePoolSync, CoreSchemaCheck, CoreSchemaCheckSync, CoreSchemaValidator, CoreSchemaValidatorSync, CoreStandardCheck, CoreStandardCheckSync, CoreStandardGroup, CoreStandardGroupSync, CoreStandardWebhook, CoreStandardWebhookSync, CoreStaticKeyValue, CoreStaticKeyValueSync, CoreTaskTarget, CoreTaskTargetSync, CoreThread, CoreThreadComment, CoreThreadCommentSync, CoreThreadSync, CoreTransformJinja2, CoreTransformJinja2Sync, CoreTransformPython, CoreTransformPythonSync, CoreTransformation, CoreTransformationSync, CoreTriggerRule, CoreTriggerRuleSync, CoreUserValidator, CoreUserValidatorSync, CoreValidator, CoreValidatorSync, CoreWebhook, CoreWebhookSync, CoreWeightedPoolResource, CoreWeightedPoolResourceSync, InternalAccountToken, InternalAccountTokenSync, InternalExternalIdentity, InternalExternalIdentitySync, InternalIPPrefixAvailable, InternalIPPrefixAvailableSync, InternalIPRangeAvailable, InternalIPRangeAvailableSync, InternalRefreshToken, InternalRefreshTokenSync, IpamNamespace, IpamNamespaceSync, LineageOwner, LineageOwnerSync, LineageSource, LineageSourceSync, ProfileBuiltinIPAddress, ProfileBuiltinIPAddressSync, ProfileBuiltinIPPrefix, ProfileBuiltinIPPrefixSync, ProfileBuiltinTag, ProfileBuiltinTagSync, ProfileIpamNamespace, ProfileIpamNamespaceSync + +if TYPE_CHECKING: + from infrahub_sdk.node import RelatedNode, RelationshipAttribute, RelationshipManager + from infrahub_sdk.protocols_base import ( + AnyAttribute, + AnyAttributeOptional, + String, + StringOptional, + Integer, + IntegerOptional, + Boolean, + BooleanOptional, + DateTime, + DateTimeOptional, + Dropdown, + DropdownOptional, + HashedPassword, + HashedPasswordOptional, + MacAddress, + MacAddressOptional, + IPHost, + IPHostOptional, + IPNetwork, + IPNetworkOptional, + IPAddress, + IPAddressOptional, + JSONAttribute, + JSONAttributeOptional, + ListAttribute, + ListAttributeOptional, + URL, + URLOptional, + ) + + +class LocationGeneric(CoreNode): + description: StringOptional + name: String + shortname: String + children: RelationshipManager[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[LocationGeneric] + profiles: RelationshipManager[CoreProfile] + servers: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + +class NetworkManagementServer(CoreNode): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManager[IpamIPAddress] + location: RelationshipAttribute[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + + +class InfraDevice(CoreNode): + description: StringOptional + name: String + role: Dropdown + role_id: Integer + status: Dropdown + location: RelationshipAttribute[LocationSite] + member_of_groups: RelationshipManager[CoreGroup] + object_template: RelationshipAttribute[TemplateInfraDevice] + primary_ip: RelationshipAttribute[IpamIPAddress] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class IpamIPAddress(BuiltinIPAddress): + address: IPHost + description: StringOptional + fqdn: StringOptional + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + ip_prefix: RelationshipAttribute[BuiltinIPPrefix] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class IpamPrefix(BuiltinIPPrefix): + broadcast_address: StringOptional + description: StringOptional + hostmask: StringOptional + is_pool: Boolean + is_top_level: BooleanOptional + member_type: Dropdown + netmask: StringOptional + network_address: StringOptional + prefix: IPNetwork + role: DropdownOptional + status: Dropdown + utilization: IntegerOptional + children: RelationshipManager[BuiltinIPPrefix] + ip_addresses: RelationshipManager[BuiltinIPAddress] + ip_namespace: RelationshipAttribute[BuiltinIPNamespace] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[BuiltinIPPrefix] + profiles: RelationshipManager[CoreProfile] + resource_pool: RelationshipManager[CoreIPAddressPool] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class LocationCountry(LocationGeneric): + description: StringOptional + name: String + shortname: String + timezone: StringOptional + children: RelationshipManager[LocationSite] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[LocationGeneric] + profiles: RelationshipManager[CoreProfile] + servers: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + + +class LocationRack(LocationGeneric): + description: StringOptional + facility_id: StringOptional + height: Integer + name: String + shortname: String + children: RelationshipManager[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[LocationSite] + profiles: RelationshipManager[CoreProfile] + servers: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + + +class LocationSite(LocationGeneric): + description: StringOptional + facility_id: StringOptional + name: String + physical_address: StringOptional + shortname: String + children: RelationshipManager[LocationRack] + member_of_groups: RelationshipManager[CoreGroup] + parent: RelationshipAttribute[LocationCountry] + profiles: RelationshipManager[CoreProfile] + servers: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + tags: RelationshipManager[BuiltinTag] + + +class NetworkDhcpServer(NetworkManagementServer): + description: StringOptional + lease_time: String + name: String + status: Dropdown + ip_addresses: RelationshipManager[IpamIPAddress] + location: RelationshipAttribute[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class NetworkNameServer(NetworkManagementServer): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManager[IpamIPAddress] + location: RelationshipAttribute[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class NetworkNTPServer(NetworkManagementServer): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManager[IpamIPAddress] + location: RelationshipAttribute[LocationGeneric] + member_of_groups: RelationshipManager[CoreGroup] + profiles: RelationshipManager[CoreProfile] + subscriber_of_groups: RelationshipManager[CoreGroup] + + + + +class ProfileInfraDevice(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + role: DropdownOptional + status: DropdownOptional + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[InfraDevice] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileIpamIPAddress(LineageSource, CoreProfile, CoreNode): + description: StringOptional + fqdn: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[IpamIPAddress] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileIpamPrefix(LineageSource, CoreProfile, CoreNode): + description: StringOptional + is_pool: BooleanOptional + member_type: DropdownOptional + profile_name: String + profile_priority: Integer + role: DropdownOptional + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[IpamPrefix] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileLocationCountry(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + timezone: StringOptional + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[LocationCountry] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileLocationGeneric(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[LocationGeneric] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileLocationRack(LineageSource, CoreProfile, CoreNode): + description: StringOptional + facility_id: StringOptional + height: IntegerOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[LocationRack] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileLocationSite(LineageSource, CoreProfile, CoreNode): + description: StringOptional + facility_id: StringOptional + physical_address: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[LocationSite] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileNetworkDhcpServer(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[NetworkDhcpServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileNetworkManagementServer(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[NetworkManagementServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileNetworkNameServer(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[NetworkNameServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + + +class ProfileNetworkNTPServer(LineageSource, CoreProfile, CoreNode): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManager[CoreGroup] + related_nodes: RelationshipManager[NetworkNTPServer] + subscriber_of_groups: RelationshipManager[CoreGroup] + + + + +class TemplateInfraDevice(LineageSource, CoreObjectTemplate, CoreNode): + description: StringOptional + role: Dropdown + role_id: IntegerOptional + status: Dropdown + template_name: String + location: RelationshipAttribute[LocationSite] + member_of_groups: RelationshipManager[CoreGroup] + primary_ip: RelationshipAttribute[IpamIPAddress] + related_nodes: RelationshipManager[InfraDevice] + subscriber_of_groups: RelationshipManager[CoreGroup] + diff --git a/tests/fixtures/protocols_generator/user_schema_sync.txt b/tests/fixtures/protocols_generator/user_schema_sync.txt new file mode 100644 index 000000000..410041211 --- /dev/null +++ b/tests/fixtures/protocols_generator/user_schema_sync.txt @@ -0,0 +1,320 @@ +# +# Generated by "infrahubctl protocols" +# + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from infrahub_sdk.protocols import CoreNodeSync, BuiltinIPAddress, BuiltinIPAddressSync, BuiltinIPNamespace, BuiltinIPNamespaceSync, BuiltinIPPrefix, BuiltinIPPrefixSync, BuiltinTag, BuiltinTagSync, CoreAccount, CoreAccountGroup, CoreAccountGroupSync, CoreAccountRole, CoreAccountRoleSync, CoreAccountSync, CoreAction, CoreActionSync, CoreArtifact, CoreArtifactCheck, CoreArtifactCheckSync, CoreArtifactDefinition, CoreArtifactDefinitionSync, CoreArtifactSync, CoreArtifactTarget, CoreArtifactTargetSync, CoreArtifactThread, CoreArtifactThreadSync, CoreArtifactValidator, CoreArtifactValidatorSync, CoreBasePermission, CoreBasePermissionSync, CoreChangeComment, CoreChangeCommentSync, CoreChangeThread, CoreChangeThreadSync, CoreCheck, CoreCheckDefinition, CoreCheckDefinitionSync, CoreCheckSync, CoreComment, CoreCommentSync, CoreCredential, CoreCredentialSync, CoreCustomWebhook, CoreCustomWebhookSync, CoreDataCheck, CoreDataCheckSync, CoreDataValidator, CoreDataValidatorSync, CoreEnvKeyValue, CoreEnvKeyValueSync, CoreFileCheck, CoreFileCheckSync, CoreFileObject, CoreFileObjectSync, CoreFileThread, CoreFileThreadSync, CoreGeneratorAction, CoreGeneratorActionSync, CoreGeneratorAwareGroup, CoreGeneratorAwareGroupSync, CoreGeneratorCheck, CoreGeneratorCheckSync, CoreGeneratorDefinition, CoreGeneratorDefinitionSync, CoreGeneratorGroup, CoreGeneratorGroupSync, CoreGeneratorInstance, CoreGeneratorInstanceSync, CoreGeneratorValidator, CoreGeneratorValidatorSync, CoreGenericAccount, CoreGenericAccountSync, CoreGenericRepository, CoreGenericRepositorySync, CoreGlobalPermission, CoreGlobalPermissionSync, CoreGraphQLQuery, CoreGraphQLQueryGroup, CoreGraphQLQueryGroupSync, CoreGraphQLQuerySync, CoreGroup, CoreGroupAction, CoreGroupActionSync, CoreGroupSync, CoreGroupTriggerRule, CoreGroupTriggerRuleSync, CoreIPAddressPool, CoreIPAddressPoolSync, CoreIPPool, CoreIPPoolSync, CoreIPPrefixPool, CoreIPPrefixPoolSync, CoreKeyValue, CoreKeyValueSync, CoreMenu, CoreMenuItem, CoreMenuItemSync, CoreMenuSync, CoreNodeSync, CoreNodeTriggerAttributeMatch, CoreNodeTriggerAttributeMatchSync, CoreNodeTriggerMatch, CoreNodeTriggerMatchSync, CoreNodeTriggerRelationshipMatch, CoreNodeTriggerRelationshipMatchSync, CoreNodeTriggerRule, CoreNodeTriggerRuleSync, CoreNumberPool, CoreNumberPoolRange, CoreNumberPoolRangeSync, CoreNumberPoolSync, CoreObjectComponentTemplate, CoreObjectComponentTemplateSync, CoreObjectPermission, CoreObjectPermissionSync, CoreObjectTemplate, CoreObjectTemplateSync, CoreObjectThread, CoreObjectThreadSync, CorePasswordCredential, CorePasswordCredentialSync, CoreProfile, CoreProfileSync, CoreProposedChange, CoreProposedChangeSync, CoreReadOnlyRepository, CoreReadOnlyRepositorySync, CoreRepository, CoreRepositoryGroup, CoreRepositoryGroupSync, CoreRepositorySync, CoreRepositoryValidator, CoreRepositoryValidatorSync, CoreResourcePool, CoreResourcePoolSync, CoreSchemaCheck, CoreSchemaCheckSync, CoreSchemaValidator, CoreSchemaValidatorSync, CoreStandardCheck, CoreStandardCheckSync, CoreStandardGroup, CoreStandardGroupSync, CoreStandardWebhook, CoreStandardWebhookSync, CoreStaticKeyValue, CoreStaticKeyValueSync, CoreTaskTarget, CoreTaskTargetSync, CoreThread, CoreThreadComment, CoreThreadCommentSync, CoreThreadSync, CoreTransformJinja2, CoreTransformJinja2Sync, CoreTransformPython, CoreTransformPythonSync, CoreTransformation, CoreTransformationSync, CoreTriggerRule, CoreTriggerRuleSync, CoreUserValidator, CoreUserValidatorSync, CoreValidator, CoreValidatorSync, CoreWebhook, CoreWebhookSync, CoreWeightedPoolResource, CoreWeightedPoolResourceSync, InternalAccountToken, InternalAccountTokenSync, InternalExternalIdentity, InternalExternalIdentitySync, InternalIPPrefixAvailable, InternalIPPrefixAvailableSync, InternalIPRangeAvailable, InternalIPRangeAvailableSync, InternalRefreshToken, InternalRefreshTokenSync, IpamNamespace, IpamNamespaceSync, LineageOwner, LineageOwnerSync, LineageSource, LineageSourceSync, ProfileBuiltinIPAddress, ProfileBuiltinIPAddressSync, ProfileBuiltinIPPrefix, ProfileBuiltinIPPrefixSync, ProfileBuiltinTag, ProfileBuiltinTagSync, ProfileIpamNamespace, ProfileIpamNamespaceSync + +if TYPE_CHECKING: + from infrahub_sdk.node import RelatedNodeSync, RelationshipAttributeSync, RelationshipManagerSync + from infrahub_sdk.protocols_base import ( + AnyAttribute, + AnyAttributeOptional, + String, + StringOptional, + Integer, + IntegerOptional, + Boolean, + BooleanOptional, + DateTime, + DateTimeOptional, + Dropdown, + DropdownOptional, + HashedPassword, + HashedPasswordOptional, + MacAddress, + MacAddressOptional, + IPHost, + IPHostOptional, + IPNetwork, + IPNetworkOptional, + IPAddress, + IPAddressOptional, + JSONAttribute, + JSONAttributeOptional, + ListAttribute, + ListAttributeOptional, + URL, + URLOptional, + ) + + +class LocationGeneric(CoreNodeSync): + description: StringOptional + name: String + shortname: String + children: RelationshipManagerSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[LocationGeneric] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + +class NetworkManagementServer(CoreNodeSync): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManagerSync[IpamIPAddress] + location: RelationshipAttributeSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + + +class InfraDevice(CoreNodeSync): + description: StringOptional + name: String + role: Dropdown + role_id: Integer + status: Dropdown + location: RelationshipAttributeSync[LocationSite] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + object_template: RelationshipAttributeSync[TemplateInfraDevice] + primary_ip: RelationshipAttributeSync[IpamIPAddress] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class IpamIPAddress(BuiltinIPAddressSync): + address: IPHost + description: StringOptional + fqdn: StringOptional + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + ip_prefix: RelationshipAttributeSync[BuiltinIPPrefixSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class IpamPrefix(BuiltinIPPrefixSync): + broadcast_address: StringOptional + description: StringOptional + hostmask: StringOptional + is_pool: Boolean + is_top_level: BooleanOptional + member_type: Dropdown + netmask: StringOptional + network_address: StringOptional + prefix: IPNetwork + role: DropdownOptional + status: Dropdown + utilization: IntegerOptional + children: RelationshipManagerSync[BuiltinIPPrefixSync] + ip_addresses: RelationshipManagerSync[BuiltinIPAddressSync] + ip_namespace: RelationshipAttributeSync[BuiltinIPNamespaceSync] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[BuiltinIPPrefixSync] + profiles: RelationshipManagerSync[CoreProfileSync] + resource_pool: RelationshipManagerSync[CoreIPAddressPoolSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class LocationCountry(LocationGeneric): + description: StringOptional + name: String + shortname: String + timezone: StringOptional + children: RelationshipManagerSync[LocationSite] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[LocationGeneric] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + + +class LocationRack(LocationGeneric): + description: StringOptional + facility_id: StringOptional + height: Integer + name: String + shortname: String + children: RelationshipManagerSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[LocationSite] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + + +class LocationSite(LocationGeneric): + description: StringOptional + facility_id: StringOptional + name: String + physical_address: StringOptional + shortname: String + children: RelationshipManagerSync[LocationRack] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + parent: RelationshipAttributeSync[LocationCountry] + profiles: RelationshipManagerSync[CoreProfileSync] + servers: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + tags: RelationshipManagerSync[BuiltinTagSync] + + +class NetworkDhcpServer(NetworkManagementServer): + description: StringOptional + lease_time: String + name: String + status: Dropdown + ip_addresses: RelationshipManagerSync[IpamIPAddress] + location: RelationshipAttributeSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class NetworkNameServer(NetworkManagementServer): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManagerSync[IpamIPAddress] + location: RelationshipAttributeSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class NetworkNTPServer(NetworkManagementServer): + description: StringOptional + name: String + status: Dropdown + ip_addresses: RelationshipManagerSync[IpamIPAddress] + location: RelationshipAttributeSync[LocationGeneric] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + profiles: RelationshipManagerSync[CoreProfileSync] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + + + +class ProfileInfraDevice(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + role: DropdownOptional + status: DropdownOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[InfraDevice] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileIpamIPAddress(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + fqdn: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[IpamIPAddress] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileIpamPrefix(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + is_pool: BooleanOptional + member_type: DropdownOptional + profile_name: String + profile_priority: Integer + role: DropdownOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[IpamPrefix] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileLocationCountry(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + timezone: StringOptional + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[LocationCountry] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileLocationGeneric(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[LocationGeneric] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileLocationRack(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + facility_id: StringOptional + height: IntegerOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[LocationRack] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileLocationSite(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + facility_id: StringOptional + physical_address: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[LocationSite] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileNetworkDhcpServer(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[NetworkDhcpServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileNetworkManagementServer(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[NetworkManagementServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileNetworkNameServer(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[NetworkNameServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + +class ProfileNetworkNTPServer(LineageSourceSync, CoreProfileSync, CoreNodeSync): + description: StringOptional + profile_name: String + profile_priority: Integer + member_of_groups: RelationshipManagerSync[CoreGroupSync] + related_nodes: RelationshipManagerSync[NetworkNTPServer] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + + + + +class TemplateInfraDevice(LineageSourceSync, CoreObjectTemplateSync, CoreNodeSync): + description: StringOptional + role: Dropdown + role_id: IntegerOptional + status: Dropdown + template_name: String + location: RelationshipAttributeSync[LocationSite] + member_of_groups: RelationshipManagerSync[CoreGroupSync] + primary_ip: RelationshipAttributeSync[IpamIPAddress] + related_nodes: RelationshipManagerSync[InfraDevice] + subscriber_of_groups: RelationshipManagerSync[CoreGroupSync] + diff --git a/tests/helpers/only_data.py b/tests/helpers/only_data.py new file mode 100644 index 000000000..5ad8c1ac1 --- /dev/null +++ b/tests/helpers/only_data.py @@ -0,0 +1,209 @@ +"""Schema, mocked responses and client helpers shared by the unit tests of queries with `only`.""" + +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING, Any + +from infrahub_sdk.graphql import Query + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk import InfrahubClient, InfrahubClientSync + from tests.unit.sdk.conftest import BothClients + +DEVICE_ID = "a5f1d5a0-5f5c-4a2b-9a4c-3e1c4c1f0d01" +SITE_ID = "b6e2c6b1-6a6d-4b3c-8b5d-4f2d5d2e1e01" +TAG_ID = "c7d3b7c2-7b7e-4c4d-9c6e-5a3e6e3f2f01" +TAG_2_ID = "c7d3b7c2-7b7e-4c4d-9c6e-5a3e6e3f2f02" +CAT_ID = "d8c4a8d3-8c8f-4d5e-8d7f-6b4f7f4a3a01" +DOG_ID = "d8c4a8d3-8c8f-4d5e-8d7f-6b4f7f4a3a02" +REGION_ID = "f1a6e1f5-1e1b-4f7a-8f9b-8d6b9b6c5c01" +PARENT_ID = "f1a6e1f5-1e1b-4f7a-8f9b-8d6b9b6c5c02" + +HIERARCHY_RELATIONSHIPS = [ + {"name": "parent", "peer": "LocationGeneric", "cardinality": "one", "kind": "Hierarchy", "optional": True}, + {"name": "children", "peer": "LocationGeneric", "cardinality": "many", "kind": "Hierarchy", "optional": True}, +] +OWNER_RELATIONSHIP = {"name": "owner", "peer": "TestingPerson", "cardinality": "one", "optional": True} + +SCHEMA: dict[str, Any] = { + "version": "1.0", + "generics": [ + { + "name": "Animal", + "namespace": "Testing", + "human_friendly_id": ["owner__name__value", "name__value"], + "used_by": ["TestingCat", "TestingDog"], + "attributes": [{"name": "name", "kind": "Text"}], + "relationships": [OWNER_RELATIONSHIP], + }, + { + "name": "Generic", + "namespace": "Location", + "hierarchical": True, + "used_by": ["LocationRegion"], + "attributes": [{"name": "name", "kind": "Text"}], + "relationships": HIERARCHY_RELATIONSHIPS, + }, + ], + "nodes": [ + { + "name": "Site", + "namespace": "Infra", + "human_friendly_id": ["name__value"], + "attributes": [ + {"name": "name", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, + ], + }, + { + "name": "Tag", + "namespace": "Builtin", + "human_friendly_id": ["name__value"], + "attributes": [{"name": "name", "kind": "Text", "unique": True}], + }, + { + "name": "Device", + "namespace": "Infra", + "human_friendly_id": ["name__value", "site__name__value"], + "attributes": [ + {"name": "name", "kind": "Text"}, + {"name": "description", "kind": "Text", "optional": True}, + ], + "relationships": [ + {"name": "site", "peer": "InfraSite", "cardinality": "one"}, + {"name": "tags", "peer": "BuiltinTag", "cardinality": "many", "optional": True}, + ], + }, + { + "name": "Person", + "namespace": "Testing", + "human_friendly_id": ["name__value"], + "attributes": [{"name": "name", "kind": "Text"}], + }, + { + "name": "Cat", + "namespace": "Testing", + "inherit_from": ["TestingAnimal"], + "human_friendly_id": ["home__name__value", "name__value"], + "attributes": [{"name": "name", "kind": "Text"}], + "relationships": [ + OWNER_RELATIONSHIP, + {"name": "home", "peer": "InfraSite", "cardinality": "one", "optional": True}, + ], + }, + { + "name": "Dog", + "namespace": "Testing", + "inherit_from": ["TestingAnimal"], + "human_friendly_id": ["owner__name__value", "name__value"], + "attributes": [{"name": "name", "kind": "Text"}], + "relationships": [ + OWNER_RELATIONSHIP, + {"name": "walker", "peer": "TestingPerson", "cardinality": "one", "optional": True}, + ], + }, + { + "name": "Region", + "namespace": "Location", + "inherit_from": ["LocationGeneric"], + "hierarchy": "LocationGeneric", + "attributes": [{"name": "name", "kind": "Text"}], + "relationships": HIERARCHY_RELATIONSHIPS, + }, + { + "name": "Shelter", + "namespace": "Testing", + "attributes": [{"name": "name", "kind": "Text"}], + "relationships": [{"name": "animals", "peer": "TestingAnimal", "cardinality": "many", "optional": True}], + }, + { + "name": "Contract", + "namespace": "Network", + "inherit_from": ["CoreFileObject"], + "attributes": [ + {"name": "file_name", "kind": "Text", "read_only": True}, + {"name": "checksum", "kind": "Text", "read_only": True}, + ], + }, + ], +} + +REFERENCE: dict[str, Any] = {"id": None, "hfid": None, "display_label": None, "__typename": None} +VALUE: dict[str, Any] = {"value": None} +PAGINATION = {"offset": "$offset", "limit": "$limit"} + + +def expected_body( + kind: str, + query_name: str, + node_fields: dict[str, Any], + filters: dict[str, Any] | None = None, + hfid: bool = False, +) -> dict[str, Any]: + """The JSON body of the first page request: the node always gets `id`, `display_label` and `__typename`.""" + identifiers: dict[str, Any] = {"id": None, "hfid": None} if hfid else {"id": None} + query = { + kind: { + "count": None, + "edges": {"node": {**identifiers, "display_label": None, "__typename": None, **node_fields}}, + "@filters": {**(filters or {}), **PAGINATION}, + } + } + return { + "query": Query(query=query, name=query_name, variables={"offset": int, "limit": int}).render(), + "variables": {"offset": 0, "limit": 3}, + "operationName": query_name, + } + + +def peer(id: str, typename: str, **fields: dict[str, Any]) -> dict[str, Any]: + return {"node": {"id": id, "hfid": None, "display_label": None, "__typename": typename, **fields}} + + +def peers(*nodes: dict[str, Any]) -> dict[str, Any]: + return {"edges": list(nodes)} + + +def node_data(id: str, typename: str, **fields: object) -> dict[str, Any]: + return {"node": {"id": id, "display_label": None, "__typename": typename, **fields}} + + +DEVICE_WITH_HFID = node_data(DEVICE_ID, "InfraDevice", hfid=["dev1", "Paris"]) +DEVICE_WITH_NAME_AND_SITE = node_data( + DEVICE_ID, "InfraDevice", name={"value": "dev1"}, site=peer(SITE_ID, "InfraSite", name={"value": "Paris"}) +) + + +def add_page(httpx_mock: HTTPXMock, kind: str, edges: list[dict[str, Any]]) -> None: + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json={"data": {kind: {"count": len(edges), "edges": edges}}}, + match_headers={"X-Infrahub-Tracker": f"query-{kind.lower()}-page1"}, + ) + + +def client_of(clients: BothClients, client_type: str) -> InfrahubClient | InfrahubClientSync: + return clients.standard if client_type == "standard" else clients.sync + + +async def run(clients: BothClients, client_type: str, method: str, **kwargs: object) -> object: + if client_type == "standard": + return await getattr(clients.standard, method)(**kwargs) + return getattr(clients.sync, method)(**kwargs) + + +async def query_nodes(clients: BothClients, client_type: str, method: str, **kwargs: object) -> list[Any]: + """Run a query method on one of the clients and return the nodes it returned as a list.""" + result = await run(clients, client_type, method, **kwargs) + return result if isinstance(result, list) else [result] + + +async def resolve(result: object) -> object: + """Await what a call on the async client returns; a call on the sync client has already run.""" + if inspect.isawaitable(result): + return await result + return result diff --git a/tests/integration/test_infrahub_client.py b/tests/integration/test_infrahub_client.py index fefb87370..79b274bd4 100644 --- a/tests/integration/test_infrahub_client.py +++ b/tests/integration/test_infrahub_client.py @@ -9,7 +9,14 @@ from infrahub_sdk import Config, InfrahubClient from infrahub_sdk.branch import BranchData from infrahub_sdk.constants import InfrahubClientMode -from infrahub_sdk.exceptions import BranchNotFoundError, URLNotFoundError +from infrahub_sdk.exceptions import ( + BranchNotFoundError, + GraphQLError, + NodeNotFoundError, + UniquenessViolationError, + URLNotFoundError, + code_names_the_failure, +) from infrahub_sdk.node import InfrahubNode from infrahub_sdk.node.metadata import NodeMetadata, RelationshipMetadata from infrahub_sdk.playback import JSONPlayback @@ -202,6 +209,45 @@ async def test_query_unexisting_branch(self, client: InfrahubClient) -> None: with pytest.raises(URLNotFoundError, match=r"/graphql/unexisting` not found."): await client.execute_graphql(query="unused", branch_name="unexisting") + @pytest.mark.catalogue + async def test_a_unique_attribute_collision_raises_its_own_class( + self, client: InfrahubClient, base_dataset: None + ) -> None: + """Against a real server, so the payload is the one Infrahub sends rather than a fixture.""" + duplicate = await client.create(kind=TESTING_PERSON, name="Liam Walker", height=180) + + # The two message shapes a supported server can produce here: the code named, or the text + # the call site falls back to when the server described nothing. A server that described the + # failure as some other code matches neither and fails, which is the point. + with pytest.raises(GraphQLError, match=r"UNIQUENESS_VIOLATION|An error occurred while") as exc_info: + await duplicate.save() + + # A server that did not describe the failure is one whose catalogue has no entry for it yet, + # which is the only tolerable reason to stop here. + if not code_names_the_failure(exc_info.value.code): + pytest.skip("this server's catalogue has no entry for a uniqueness violation") + + assert isinstance(exc_info.value, UniquenessViolationError) + assert exc_info.value.code == "UNIQUENESS_VIOLATION" + assert exc_info.value.node_kind == TESTING_PERSON + assert exc_info.value.fields == ["name"] + + @pytest.mark.catalogue + async def test_deleting_a_missing_node_raises_its_own_class( + self, client: InfrahubClient, base_dataset: None + ) -> None: + obj = await client.create(kind=TESTING_PERSON, name="Gone Walker", height=170) + await obj.save() + node_id = obj.id + await obj.delete() + + with pytest.raises(NodeNotFoundError, match="NODE_NOT_FOUND") as exc_info: + await obj.delete() + + assert exc_info.value.code == "NODE_NOT_FOUND" + assert exc_info.value.node_type == TESTING_PERSON + assert exc_info.value.identifier == node_id + async def test_create_generic_rel_with_hfid( self, client: InfrahubClient, diff --git a/tests/integration/test_infrahub_client_sync.py b/tests/integration/test_infrahub_client_sync.py index f298e2b72..cb7058233 100644 --- a/tests/integration/test_infrahub_client_sync.py +++ b/tests/integration/test_infrahub_client_sync.py @@ -8,7 +8,14 @@ from infrahub_sdk import Config, InfrahubClientSync from infrahub_sdk.branch import BranchData from infrahub_sdk.constants import InfrahubClientMode -from infrahub_sdk.exceptions import BranchNotFoundError, URLNotFoundError +from infrahub_sdk.exceptions import ( + BranchNotFoundError, + GraphQLError, + NodeNotFoundError, + UniquenessViolationError, + URLNotFoundError, + code_names_the_failure, +) from infrahub_sdk.node import InfrahubNodeSync from infrahub_sdk.node.metadata import NodeMetadata, RelationshipMetadata from infrahub_sdk.playback import JSONPlayback @@ -203,6 +210,45 @@ def test_query_unexisting_branch(self, client_sync: InfrahubClientSync) -> None: with pytest.raises(URLNotFoundError, match=r"/graphql/unexisting` not found."): client_sync.execute_graphql(query="unused", branch_name="unexisting") + @pytest.mark.catalogue + def test_a_unique_attribute_collision_raises_its_own_class( + self, client_sync: InfrahubClientSync, base_dataset: None + ) -> None: + """Against a real server, so the payload is the one Infrahub sends rather than a fixture.""" + duplicate = client_sync.create(kind=TESTING_PERSON, name="Liam Walker", height=180) + + # The two message shapes a supported server can produce here: the code named, or the text + # the call site falls back to when the server described nothing. A server that described the + # failure as some other code matches neither and fails, which is the point. + with pytest.raises(GraphQLError, match=r"UNIQUENESS_VIOLATION|An error occurred while") as exc_info: + duplicate.save() + + # A server that did not describe the failure is one whose catalogue has no entry for it yet, + # which is the only tolerable reason to stop here. + if not code_names_the_failure(exc_info.value.code): + pytest.skip("this server's catalogue has no entry for a uniqueness violation") + + assert isinstance(exc_info.value, UniquenessViolationError) + assert exc_info.value.code == "UNIQUENESS_VIOLATION" + assert exc_info.value.node_kind == TESTING_PERSON + assert exc_info.value.fields == ["name"] + + @pytest.mark.catalogue + def test_deleting_a_missing_node_raises_its_own_class( + self, client_sync: InfrahubClientSync, base_dataset: None + ) -> None: + obj = client_sync.create(kind=TESTING_PERSON, name="Vanished Walker", height=170) + obj.save() + node_id = obj.id + obj.delete() + + with pytest.raises(NodeNotFoundError, match="NODE_NOT_FOUND") as exc_info: + obj.delete() + + assert exc_info.value.code == "NODE_NOT_FOUND" + assert exc_info.value.node_type == TESTING_PERSON + assert exc_info.value.identifier == node_id + def test_create_generic_rel_with_hfid( self, client_sync: InfrahubClientSync, diff --git a/tests/integration/test_node_only.py b/tests/integration/test_node_only.py new file mode 100644 index 000000000..1ae2ed77b --- /dev/null +++ b/tests/integration/test_node_only.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk.exceptions import FieldNotLoadedError +from infrahub_sdk.testing.docker import TestInfrahubDockerClient +from infrahub_sdk.testing.schemas.animal import ( + TESTING_ANIMAL, + TESTING_CAT, + TESTING_DOG, + TESTING_PERSON, + SchemaAnimal, +) + +if TYPE_CHECKING: + from infrahub_sdk import InfrahubClient, InfrahubClientSync + from infrahub_sdk.node import InfrahubNode + +BREED_NOT_FETCHED = re.escape("TestingDog.breed was not fetched: the node was queried with only=['name', 'owner'].") +HEIGHT_NOT_FETCHED = re.escape("TestingPerson.height was not fetched: the node was queried with only=['name'].") +BREED_BY_ANIMAL = { + "Bella": (TESTING_CAT, "Bengal"), + "Luna": (TESTING_CAT, "Siamese"), + "Daisy": (TESTING_DOG, "French Bulldog"), + "Rocky": (TESTING_DOG, "German Shepherd"), +} +BELLA_HFID = ["Ethan Carter", "Bella", "#123456"] +ROCKY_WEIGHT = 32 +KIND_OF_ETHAN_ANIMALS = {"Bella": TESTING_CAT, "Luna": TESTING_CAT, "Daisy": TESTING_DOG} +DAISY_OWNER_NAME_AND_HEIGHT = ("Ethan Carter", 185) +ROCKY_OWNER_NAME_AND_HEIGHT = ("Sophia Walker", 168) + + +def not_fetched(kind: str, field: str, only: list[str]) -> str: + return re.escape(f"{kind}.{field} was not fetched: the node was queried with only={only!r}.") + + +class TestNodeOnly(TestInfrahubDockerClient, SchemaAnimal): + @pytest.fixture(scope="class") + def base_dataset( + self, + load_schema: None, + person_ethan: InfrahubNode, + person_sophia: InfrahubNode, + cat_bella: InfrahubNode, + cat_luna: InfrahubNode, + dog_daisy: InfrahubNode, + dog_rocky: InfrahubNode, + ) -> None: + pass + + async def test_all_with_only_reads_the_named_fields( + self, + client: InfrahubClient, + base_dataset: None, + person_ethan: InfrahubNode, + person_sophia: InfrahubNode, + ) -> None: + dogs = await client.all(kind=TESTING_DOG, only=["name", "owner"]) + + assert {dog.name.value: dog.owner.id for dog in dogs} == { + "Daisy": person_ethan.id, + "Rocky": person_sophia.id, + } + for dog in dogs: + with pytest.raises(FieldNotLoadedError, match=BREED_NOT_FETCHED): + _ = dog.breed.value + + async def test_all_on_generic_with_only_reads_fields_of_each_kind( + self, client: InfrahubClient, base_dataset: None + ) -> None: + animals = await client.all(kind=TESTING_ANIMAL, only=["name", "breed"]) + + assert {animal.name.value: (animal.get_kind(), animal.breed.value) for animal in animals} == BREED_BY_ANIMAL + + async def test_hfid_in_only_gives_the_hfid_the_server_stores( + self, client: InfrahubClient, base_dataset: None, cat_bella: InfrahubNode + ) -> None: + cat = await client.get(kind=TESTING_CAT, id=cat_bella.id, only=["hfid"]) + + assert [cat.hfid, cat.name.is_loaded, cat.owner.is_loaded, cat.color.is_loaded] == [ + BELLA_HFID, + False, + False, + False, + ] + + async def test_fetch_with_only_on_owner_reads_its_name( + self, client: InfrahubClient, base_dataset: None, dog_rocky: InfrahubNode + ) -> None: + dog = await client.get(kind=TESTING_DOG, id=dog_rocky.id, only=["name", "owner"]) + + await dog.owner.fetch(only=["name"]) + + owner = dog.owner.peer + assert owner.name.value == "Sophia Walker" + with pytest.raises(FieldNotLoadedError, match=HEIGHT_NOT_FETCHED): + _ = owner.height.value + + async def test_manager_fetch_with_only_reads_the_named_fields_of_each_peer_kind( + self, client: InfrahubClient, base_dataset: None, person_ethan: InfrahubNode + ) -> None: + person = await client.get(kind=TESTING_PERSON, id=person_ethan.id, only=["name"]) + + await person.animals.fetch(only=["name"]) + + animals = [related_node.peer for related_node in person.animals.peers] + assert {animal.name.value: animal.get_kind() for animal in animals} == KIND_OF_ETHAN_ANIMALS + for animal in animals: + with pytest.raises(FieldNotLoadedError, match=not_fetched(animal.get_kind(), "breed", ["name"])): + _ = animal.breed.value + + async def test_all_with_only_and_prefetch_gives_the_peer_its_default_fields( + self, client: InfrahubClient, base_dataset: None, dog_daisy: InfrahubNode, dog_rocky: InfrahubNode + ) -> None: + dogs = await client.all(kind=TESTING_DOG, only=["owner"], prefetch_relationships=True) + + owners = {dog.id: dog.owner.peer for dog in dogs} + assert {dog_id: (owner.name.value, owner.height.value) for dog_id, owner in owners.items()} == { + dog_daisy.id: DAISY_OWNER_NAME_AND_HEIGHT, + dog_rocky.id: ROCKY_OWNER_NAME_AND_HEIGHT, + } + for dog in dogs: + with pytest.raises(FieldNotLoadedError, match=not_fetched(TESTING_DOG, "name", ["owner"])): + _ = dog.name.value + + async def test_save_of_node_from_only_keeps_its_other_fields( + self, client: InfrahubClient, base_dataset: None, dog_rocky: InfrahubNode, person_sophia: InfrahubNode + ) -> None: + dog = await client.get(kind=TESTING_DOG, id=dog_rocky.id, only=["weight"]) + dog.weight.value = ROCKY_WEIGHT + + await dog.save() + + saved = await client.get(kind=TESTING_DOG, id=dog_rocky.id) + assert [saved.name.value, saved.breed.value, saved.color.value, saved.owner.id, saved.weight.value] == [ + "Rocky", + "German Shepherd", + "#784212", + person_sophia.id, + ROCKY_WEIGHT, + ] + + +class TestNodeOnlySync(TestInfrahubDockerClient, SchemaAnimal): + @pytest.fixture(scope="class") + def base_dataset( + self, + load_schema: None, + person_ethan: InfrahubNode, + person_sophia: InfrahubNode, + cat_bella: InfrahubNode, + cat_luna: InfrahubNode, + dog_daisy: InfrahubNode, + dog_rocky: InfrahubNode, + ) -> None: + pass + + def test_all_with_only_reads_the_named_fields( + self, + client_sync: InfrahubClientSync, + base_dataset: None, + person_ethan: InfrahubNode, + person_sophia: InfrahubNode, + ) -> None: + dogs = client_sync.all(kind=TESTING_DOG, only=["name", "owner"]) + + assert {dog.name.value: dog.owner.id for dog in dogs} == { + "Daisy": person_ethan.id, + "Rocky": person_sophia.id, + } + for dog in dogs: + with pytest.raises(FieldNotLoadedError, match=BREED_NOT_FETCHED): + _ = dog.breed.value + + def test_all_on_generic_with_only_reads_fields_of_each_kind( + self, client_sync: InfrahubClientSync, base_dataset: None + ) -> None: + animals = client_sync.all(kind=TESTING_ANIMAL, only=["name", "breed"]) + + assert {animal.name.value: (animal.get_kind(), animal.breed.value) for animal in animals} == BREED_BY_ANIMAL + + def test_hfid_in_only_gives_the_hfid_the_server_stores( + self, client_sync: InfrahubClientSync, base_dataset: None, cat_bella: InfrahubNode + ) -> None: + cat = client_sync.get(kind=TESTING_CAT, id=cat_bella.id, only=["hfid"]) + + assert [cat.hfid, cat.name.is_loaded, cat.owner.is_loaded, cat.color.is_loaded] == [ + BELLA_HFID, + False, + False, + False, + ] + + def test_fetch_with_only_on_owner_reads_its_name( + self, client_sync: InfrahubClientSync, base_dataset: None, dog_rocky: InfrahubNode + ) -> None: + dog = client_sync.get(kind=TESTING_DOG, id=dog_rocky.id, only=["name", "owner"]) + + dog.owner.fetch(only=["name"]) + + owner = dog.owner.peer + assert owner.name.value == "Sophia Walker" + with pytest.raises(FieldNotLoadedError, match=HEIGHT_NOT_FETCHED): + _ = owner.height.value + + def test_manager_fetch_with_only_reads_the_named_fields_of_each_peer_kind( + self, client_sync: InfrahubClientSync, base_dataset: None, person_ethan: InfrahubNode + ) -> None: + person = client_sync.get(kind=TESTING_PERSON, id=person_ethan.id, only=["name"]) + + person.animals.fetch(only=["name"]) + + animals = [related_node.peer for related_node in person.animals.peers] + assert {animal.name.value: animal.get_kind() for animal in animals} == KIND_OF_ETHAN_ANIMALS + for animal in animals: + with pytest.raises(FieldNotLoadedError, match=not_fetched(animal.get_kind(), "breed", ["name"])): + _ = animal.breed.value + + def test_all_with_only_and_prefetch_gives_the_peer_its_default_fields( + self, + client_sync: InfrahubClientSync, + base_dataset: None, + dog_daisy: InfrahubNode, + dog_rocky: InfrahubNode, + ) -> None: + dogs = client_sync.all(kind=TESTING_DOG, only=["owner"], prefetch_relationships=True) + + owners = {dog.id: dog.owner.peer for dog in dogs} + assert {dog_id: (owner.name.value, owner.height.value) for dog_id, owner in owners.items()} == { + dog_daisy.id: DAISY_OWNER_NAME_AND_HEIGHT, + dog_rocky.id: ROCKY_OWNER_NAME_AND_HEIGHT, + } + for dog in dogs: + with pytest.raises(FieldNotLoadedError, match=not_fetched(TESTING_DOG, "name", ["owner"])): + _ = dog.name.value + + def test_save_of_node_from_only_keeps_its_other_fields( + self, + client_sync: InfrahubClientSync, + base_dataset: None, + dog_rocky: InfrahubNode, + person_sophia: InfrahubNode, + ) -> None: + dog = client_sync.get(kind=TESTING_DOG, id=dog_rocky.id, only=["weight"]) + dog.weight.value = ROCKY_WEIGHT + + dog.save() + + saved = client_sync.get(kind=TESTING_DOG, id=dog_rocky.id) + assert [saved.name.value, saved.breed.value, saved.color.value, saved.owner.id, saved.weight.value] == [ + "Rocky", + "German Shepherd", + "#784212", + person_sophia.id, + ROCKY_WEIGHT, + ] diff --git a/tests/integration/test_number_pool_attach_ranges.py b/tests/integration/test_number_pool_attach_ranges.py new file mode 100644 index 000000000..c927e4956 --- /dev/null +++ b/tests/integration/test_number_pool_attach_ranges.py @@ -0,0 +1,241 @@ +"""Ranges declared on create, on upsert and in an object file; numbers attached, detached and their provenance.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk.constants import PoolAllocationProvenance +from infrahub_sdk.exceptions import FeatureNotSupportedError +from infrahub_sdk.spec.object import ObjectFile +from infrahub_sdk.testing.docker import TestInfrahubDockerClient, skip_version + +if TYPE_CHECKING: + from infrahub_sdk import InfrahubClient, InfrahubClientSync + from infrahub_sdk.node import InfrahubNode + +VLAN_KIND = "TestingPoolVlan" +REFUSAL_MESSAGE = re.escape( + "The peers of 'ranges' cannot be edited one at a time, because the mutation takes the fields of a " + "CoreNumberPoolRange and not a reference to one. To set them, pass the complete list of 'ranges' to " + "client.create() and save the node with allow_upsert=True, or create, update or delete the " + "CoreNumberPoolRange node itself." +) +VLAN_SCHEMA: dict[str, Any] = { + "version": "1.0", + "nodes": [ + { + "name": "PoolVlan", + "namespace": "Testing", + "human_friendly_id": ["name__value"], + "display_label": "{{ name__value }}", + "attributes": [ + {"name": "name", "kind": "Text", "unique": True}, + {"name": "vlan_id", "kind": "Number", "optional": True}, + ], + } + ], +} + + +def _bounds(range_nodes: list[Any]) -> list[tuple[int | None, int | None, int | None]]: + return sorted((r.start.value, r.end.value, r.allocation_weight.value) for r in range_nodes) + + +def _object_file(kind: str, data: list[dict[str, Any]]) -> ObjectFile: + return ObjectFile( + location=Path("inline"), + content={"apiVersion": "infrahub.app/v1", "kind": "Object", "spec": {"kind": kind, "data": data}}, + ) + + +@pytest.mark.skipif(skip_version(min_infrahub_version="1.12"), reason="Requires Infrahub 1.12 or later") +class TestNumberPoolAttachRanges(TestInfrahubDockerClient): + @pytest.fixture(scope="class") + async def load_vlan_schema(self, default_branch: str, client: InfrahubClient) -> None: + await client.schema.wait_until_converged(branch=default_branch) + resp = await client.schema.load(schemas=[VLAN_SCHEMA], branch=default_branch, wait_until_converged=True) + assert resp.errors == {} + + @pytest.fixture(scope="class") + async def vlan_pool(self, client: InfrahubClient, load_vlan_schema: None) -> InfrahubNode: + pool = await client.create( + kind="CoreNumberPool", + name="VLAN ID Pool", + node=VLAN_KIND, + node_attribute="vlan_id", + ranges=[{"start": 100, "end": 199, "allocation_weight": 10}, {"start": 200, "end": 299}], + ) + await pool.save() + return pool + + async def test_create_stores_declared_ranges(self, client: InfrahubClient, vlan_pool: InfrahubNode) -> None: + ranges = await client.filters(kind="CoreNumberPoolRange", pool__ids=[vlan_pool.id]) + + assert _bounds(ranges) == [(100, 199, 10), (200, 299, None)] + + async def test_upsert_sets_declared_ranges(self, client: InfrahubClient, load_vlan_schema: None) -> None: + pool = await client.create( + kind="CoreNumberPool", + name="Upserted Pool", + node=VLAN_KIND, + node_attribute="vlan_id", + ranges=[{"start": 1000, "end": 1099, "allocation_weight": 10}, {"start": 1100, "end": 1199}], + ) + await pool.save() + upsert = await client.create( + kind="CoreNumberPool", + name="Upserted Pool", + node=VLAN_KIND, + node_attribute="vlan_id", + ranges=[{"start": 1000, "end": 1099, "allocation_weight": 20}, {"start": 1200}], + ) + + await upsert.save(allow_upsert=True) + + ranges = await client.filters(kind="CoreNumberPoolRange", pool__ids=[pool.id]) + assert _bounds(ranges) == [(1000, 1099, 20), (1200, None, None)] + + async def test_the_way_out_named_by_the_refusal_adds_the_range( + self, client: InfrahubClient, load_vlan_schema: None + ) -> None: + """After the refusal, saving the pool again with the complete list adds the range.""" + pool = await client.create( + kind="CoreNumberPool", + name="Recovered Pool", + node=VLAN_KIND, + node_attribute="vlan_id", + ranges=[{"start": 2000, "end": 2099}], + ) + await pool.save() + fetched = await client.get(kind="CoreNumberPool", id=pool.id) + with pytest.raises(FeatureNotSupportedError, match=REFUSAL_MESSAGE): + fetched.ranges.add({"start": 2100, "end": 2199}) + + recovered = await client.create( + kind="CoreNumberPool", + name="Recovered Pool", + node=VLAN_KIND, + node_attribute="vlan_id", + ranges=[{"start": 2000, "end": 2099}, {"start": 2100, "end": 2199}], + ) + await recovered.save(allow_upsert=True) + + ranges = await client.filters(kind="CoreNumberPoolRange", pool__ids=[pool.id]) + assert _bounds(ranges) == [(2000, 2099, None), (2100, 2199, None)] + + async def test_a_range_node_is_added_changed_and_deleted_on_its_own( + self, client: InfrahubClient, load_vlan_schema: None + ) -> None: + """The other way out the refusal names: work with the CoreNumberPoolRange node itself.""" + pool = await client.create( + kind="CoreNumberPool", + name="Range Node Pool", + node=VLAN_KIND, + node_attribute="vlan_id", + ranges=[{"start": 4000, "end": 4099}], + ) + await pool.save() + + added = await client.create(kind="CoreNumberPoolRange", start=4100, end=4199, pool=pool.id) + await added.save() + + ranges = await client.filters(kind="CoreNumberPoolRange", pool__ids=[pool.id]) + assert _bounds(ranges) == [(4000, 4099, None), (4100, 4199, None)] + + fetched = await client.get(kind="CoreNumberPoolRange", id=added.id) + fetched.end.value = 4149 + await fetched.save() + + ranges = await client.filters(kind="CoreNumberPoolRange", pool__ids=[pool.id]) + assert _bounds(ranges) == [(4000, 4099, None), (4100, 4149, None)] + + await fetched.delete() + + ranges = await client.filters(kind="CoreNumberPoolRange", pool__ids=[pool.id]) + assert _bounds(ranges) == [(4000, 4099, None)] + + async def test_an_object_file_sets_the_ranges_of_a_new_and_of_an_existing_pool( + self, client: InfrahubClient, load_vlan_schema: None + ) -> None: + """Ranges declared in an object file travel with the pool, on a new pool and on one that exists.""" + obj = _object_file( + kind="CoreNumberPool", + data=[ + { + "name": "Object File Pool", + "node": VLAN_KIND, + "node_attribute": "vlan_id", + "ranges": {"data": [{"start": 3000, "end": 3099}]}, + } + ], + ) + await obj.validate_format(client=client) + + # Once against a pool that does not exist, once against the pool the first pass created. + await obj.process(client=client) + await obj.process(client=client) + + pool = await client.get(kind="CoreNumberPool", name__value="Object File Pool") + ranges = await client.filters(kind="CoreNumberPoolRange", pool__ids=[pool.id]) + assert _bounds(ranges) == [(3000, 3099, None)] + + async def test_attach_detach_and_provenance(self, client: InfrahubClient, vlan_pool: InfrahubNode) -> None: + allocated = await client.create(kind=VLAN_KIND, name="allocated", vlan_id={"from_pool": {"id": vlan_pool.id}}) + await allocated.save() + provided = await client.create( + kind=VLAN_KIND, name="provided", vlan_id={"value": 150, "from_pool": {"id": vlan_pool.id}} + ) + await provided.save() + plain = await client.create(kind=VLAN_KIND, name="plain", vlan_id=160) + await plain.save() + fetched_plain = await client.get(kind=VLAN_KIND, id=plain.id) + fetched_plain.vlan_id.attach_to_pool(vlan_pool) + await fetched_plain.save() + + allocations = {a.node_id: a for a in await vlan_pool.get_pool_allocations(resource=vlan_pool)} + assert allocations[allocated.id].provenance == PoolAllocationProvenance.ALLOCATED + assert allocations[allocated.id].display_label == str(allocated.vlan_id.value) + assert allocations[provided.id].provenance == PoolAllocationProvenance.PROVIDED + assert allocations[plain.id].provenance == PoolAllocationProvenance.PROVIDED + with_properties = await client.get(kind=VLAN_KIND, id=provided.id, property=True) + assert with_properties.vlan_id.source is not None + assert with_properties.vlan_id.source.id == vlan_pool.id + + to_detach = await client.get(kind=VLAN_KIND, id=provided.id) + to_detach.vlan_id.detach_from_pool() + await to_detach.save() + + allocations = {a.node_id: a for a in await vlan_pool.get_pool_allocations(resource=vlan_pool)} + assert provided.id not in allocations + assert (await client.get(kind=VLAN_KIND, id=provided.id)).vlan_id.value == 150 + + async def test_detach_before_first_save_keeps_the_number( + self, client: InfrahubClient, vlan_pool: InfrahubNode + ) -> None: + vlan = await client.create( + kind=VLAN_KIND, name="detached-before-save", vlan_id={"value": 180, "from_pool": {"id": vlan_pool.id}} + ) + vlan.vlan_id.detach_from_pool() + + await vlan.save() + + assert (await client.get(kind=VLAN_KIND, id=vlan.id)).vlan_id.value == 180 + assert vlan.id not in {a.node_id for a in await vlan_pool.get_pool_allocations(resource=vlan_pool)} + + def test_attach_detach_sync(self, client_sync: InfrahubClientSync, vlan_pool: InfrahubNode) -> None: + vlan = client_sync.create(kind=VLAN_KIND, name="sync", vlan_id=170) + vlan.save() + vlan.vlan_id.attach_to_pool(vlan_pool.id) + vlan.save() + pool = client_sync.get(kind="CoreNumberPool", id=vlan_pool.id) + + assert vlan.id in {a.node_id for a in pool.get_pool_allocations(resource=pool)} + + vlan.vlan_id.detach_from_pool() + vlan.save() + + assert vlan.id not in {a.node_id for a in pool.get_pool_allocations(resource=pool)} diff --git a/tests/integration/test_tracking_zero_members.py b/tests/integration/test_tracking_zero_members.py new file mode 100644 index 000000000..53007fe59 --- /dev/null +++ b/tests/integration/test_tracking_zero_members.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk.constants import InfrahubClientMode +from infrahub_sdk.exceptions import NodeNotFoundError, TrackingGroupCleanupError +from infrahub_sdk.testing.docker import TestInfrahubDockerClient +from infrahub_sdk.testing.schemas.animal import TESTING_CAT, TESTING_PERSON, SchemaAnimal + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from infrahub_sdk import InfrahubClient, InfrahubClientSync + +# Cats are deleted before persons: an animal holds a mandatory relationship to its owner. +TRACKED_KINDS = (TESTING_CAT, TESTING_PERSON, "BuiltinTag", "CoreStandardGroup") + + +async def _delete_tracked_nodes(client: InfrahubClient, prefixes: tuple[str, ...]) -> None: + """Delete the nodes and tracking groups a test class left on main.""" + for kind in TRACKED_KINDS: + for node in await client.filters(kind=kind): + if str(node.name.value or "").startswith(prefixes): + await node.delete() + + +class TestTracking(TestInfrahubDockerClient, SchemaAnimal): + BRANCH = "tracking-branch01" + # Naming the tracking identifier keeps the groups these tests create identifiable at teardown, + # where the client default would bury them among every other run's python-sdk- groups. + IDENTIFIER = "tracking-zero-members" + NAME_PREFIXES = ("Tracking", "tracking-", "BranchTracking", "branch-tracking-", IDENTIFIER) + + @pytest.fixture(scope="class") + async def base_dataset(self, client: InfrahubClient, load_schema: None) -> AsyncGenerator[None, None]: + await client.branch.create(branch_name=self.BRANCH) + yield + # The branch goes first: what these tests created on it goes with it, so main needs one pass. + await client.branch.delete(branch_name=self.BRANCH) + await _delete_tracked_nodes(client=client, prefixes=self.NAME_PREFIXES) + + async def test_zero_member_run_prunes_previous_members(self, client: InfrahubClient, base_dataset: None) -> None: + person_name = "TrackingZeroMemberPerson" + tag_name = "tracking-zero-TAG" + params = {"person_name": person_name} + + async with client.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True) as clt: + tag = await clt.create(kind="BuiltinTag", name=tag_name) + await tag.save(allow_upsert=True) + person = await clt.create(kind=TESTING_PERSON, name=person_name, tags=[tag]) + await person.save(allow_upsert=True) + + group_name = client.group_context._generate_group_name() + group = await client.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert len(group.members.peers) == 2 + + # A run that saves nothing must still prune everything the previous run tracked. + async with client.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True): + pass + + group = await client.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert len(group.members.peers) == 0 + + with pytest.raises(NodeNotFoundError, match=tag_name): + await client.get(kind="BuiltinTag", name__value=tag_name) + with pytest.raises(NodeNotFoundError, match=person_name): + await client.get(kind=TESTING_PERSON, name__value=person_name) + + async def test_zero_member_run_without_existing_group_creates_nothing( + self, client: InfrahubClient, base_dataset: None + ) -> None: + params = {"person_name": "TrackingNeverAnyMembers"} + + async with client.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True): + pass + + group_name = client.group_context._generate_group_name() + with pytest.raises(NodeNotFoundError, match=group_name): + await client.get(kind="CoreStandardGroup", name__value=group_name) + + async def test_refused_delete_does_not_abort_remaining_reaps( + self, client: InfrahubClient, base_dataset: None + ) -> None: + person_name = "TrackingRefusedPerson" + doomed_tag_name = "tracking-refused-DOOMED" + keeper_tag_name = "tracking-refused-KEEPER" + params = {"person_name": person_name} + + async with client.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True) as clt: + person = await clt.create(kind=TESTING_PERSON, name=person_name) + await person.save(allow_upsert=True) + doomed_tag = await clt.create(kind="BuiltinTag", name=doomed_tag_name) + await doomed_tag.save(allow_upsert=True) + + group_name = client.group_context._generate_group_name() + group = await client.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert len(group.members.peers) == 2 + + # An animal outside the tracking group makes its owner undeletable, + # because Animal.owner is a mandatory relationship. + cat = await client.create(kind=TESTING_CAT, name="TrackingRefusedCat", breed="Bengal", owner=person) + await cat.save() + + # Second run saves only a new tag, so the person and the first tag both + # become reap candidates. The person's delete is refused by the server. + with pytest.raises(TrackingGroupCleanupError, match="Unable to delete 1 unused member") as exc_info: + async with client.start_tracking( + identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True + ) as clt: + keeper_tag = await clt.create(kind="BuiltinTag", name=keeper_tag_name) + await keeper_tag.save(allow_upsert=True) + + assert list(exc_info.value.failures) == [person.id] + + # The refused delete must not prevent the other unused member from being reaped. + with pytest.raises(NodeNotFoundError, match=doomed_tag_name): + await client.get(kind="BuiltinTag", name__value=doomed_tag_name) + + # The person survived, and must still be a group member so a later run can retry it. + await client.get(kind=TESTING_PERSON, name__value=person_name) + group = await client.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert sorted(group.members.peer_ids) == sorted([person.id, keeper_tag.id]) + + async def test_zero_member_run_keeps_undeletable_member(self, client: InfrahubClient, base_dataset: None) -> None: + person_name = "TrackingRetryPerson" + params = {"person_name": person_name} + + async with client.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True) as clt: + person = await clt.create(kind=TESTING_PERSON, name=person_name) + await person.save(allow_upsert=True) + + cat = await client.create(kind=TESTING_CAT, name="TrackingRetryCat", breed="Bengal", owner=person) + await cat.save() + + # A zero-member run now attempts the reap; the person's delete is refused. + with pytest.raises(TrackingGroupCleanupError, match="Unable to delete 1 unused member"): + async with client.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True): + pass + + assert client.mode == InfrahubClientMode.DEFAULT + + group_name = client.group_context._generate_group_name() + group = await client.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert group.members.peer_ids == [person.id] + + # Once the blocking node is gone, the next zero-member run reaps the person. + await client.delete(kind=TESTING_CAT, id=cat.id) + async with client.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True): + pass + + group = await client.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert len(group.members.peers) == 0 + with pytest.raises(NodeNotFoundError, match=person_name): + await client.get(kind=TESTING_PERSON, name__value=person_name) + + async def test_zero_member_run_prunes_on_the_tracked_branch( + self, client: InfrahubClient, base_dataset: None + ) -> None: + person_name = "BranchTrackingPerson" + tag_name = "branch-tracking-TAG" + params = {"person_name": person_name} + + async with client.start_tracking( + identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True, branch=self.BRANCH + ) as clt: + tag = await clt.create(kind="BuiltinTag", name=tag_name, branch=self.BRANCH) + await tag.save(allow_upsert=True) + person = await clt.create(kind=TESTING_PERSON, name=person_name, tags=[tag], branch=self.BRANCH) + await person.save(allow_upsert=True) + + group_name = client.group_context._generate_group_name() + group = await client.get( + kind="CoreStandardGroup", name__value=group_name, include=["members"], branch=self.BRANCH + ) + assert len(group.members.peers) == 2 + + # The group belongs to the branch, not to main. + with pytest.raises(NodeNotFoundError, match=group_name): + await client.get(kind="CoreStandardGroup", name__value=group_name, branch="main") + + # A zero-member run on the branch must delete the branch's nodes. Deleting on the + # client default branch instead would not find them and would silently succeed. + async with client.start_tracking( + identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True, branch=self.BRANCH + ): + pass + + group = await client.get( + kind="CoreStandardGroup", name__value=group_name, include=["members"], branch=self.BRANCH + ) + assert len(group.members.peers) == 0 + + with pytest.raises(NodeNotFoundError, match=tag_name): + await client.get(kind="BuiltinTag", name__value=tag_name, branch=self.BRANCH) + with pytest.raises(NodeNotFoundError, match=person_name): + await client.get(kind=TESTING_PERSON, name__value=person_name, branch=self.BRANCH) + + +class TestTrackingSync(TestInfrahubDockerClient, SchemaAnimal): + BRANCH = "sync-tracking-branch01" + IDENTIFIER = "sync-tracking-zero-members" + NAME_PREFIXES = ("SyncTracking", "sync-tracking-", "SyncBranchTracking", "sync-branch-tracking-", IDENTIFIER) + + @pytest.fixture(scope="class") + async def base_dataset(self, client: InfrahubClient, load_schema: None) -> AsyncGenerator[None, None]: + await client.branch.create(branch_name=self.BRANCH) + yield + # The branch goes first: what these tests created on it goes with it, so main needs one pass. + await client.branch.delete(branch_name=self.BRANCH) + await _delete_tracked_nodes(client=client, prefixes=self.NAME_PREFIXES) + + def test_zero_member_run_prunes_previous_members(self, client_sync: InfrahubClientSync, base_dataset: None) -> None: + person_name = "SyncTrackingZeroMemberPerson" + tag_name = "sync-tracking-zero-TAG" + params = {"person_name": person_name} + + with client_sync.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True) as clt: + tag = clt.create(kind="BuiltinTag", name=tag_name) + tag.save(allow_upsert=True) + person = clt.create(kind=TESTING_PERSON, name=person_name, tags=[tag]) + person.save(allow_upsert=True) + + group_name = client_sync.group_context._generate_group_name() + group = client_sync.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert len(group.members.peers) == 2 + + # A run that saves nothing must still prune everything the previous run tracked. + with client_sync.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True): + pass + + group = client_sync.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert len(group.members.peers) == 0 + + with pytest.raises(NodeNotFoundError, match=tag_name): + client_sync.get(kind="BuiltinTag", name__value=tag_name) + with pytest.raises(NodeNotFoundError, match=person_name): + client_sync.get(kind=TESTING_PERSON, name__value=person_name) + + def test_zero_member_run_without_existing_group_creates_nothing( + self, client_sync: InfrahubClientSync, base_dataset: None + ) -> None: + params = {"person_name": "SyncTrackingNeverAnyMembers"} + + with client_sync.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True): + pass + + group_name = client_sync.group_context._generate_group_name() + with pytest.raises(NodeNotFoundError, match=group_name): + client_sync.get(kind="CoreStandardGroup", name__value=group_name) + + def test_refused_delete_does_not_abort_remaining_reaps( + self, client_sync: InfrahubClientSync, base_dataset: None + ) -> None: + person_name = "SyncTrackingRefusedPerson" + doomed_tag_name = "sync-tracking-refused-DOOMED" + keeper_tag_name = "sync-tracking-refused-KEEPER" + params = {"person_name": person_name} + + with client_sync.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True) as clt: + person = clt.create(kind=TESTING_PERSON, name=person_name) + person.save(allow_upsert=True) + doomed_tag = clt.create(kind="BuiltinTag", name=doomed_tag_name) + doomed_tag.save(allow_upsert=True) + + group_name = client_sync.group_context._generate_group_name() + group = client_sync.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert len(group.members.peers) == 2 + + # An animal outside the tracking group makes its owner undeletable, + # because Animal.owner is a mandatory relationship. + cat = client_sync.create(kind=TESTING_CAT, name="SyncTrackingRefusedCat", breed="Bengal", owner=person) + cat.save() + + # Second run saves only a new tag, so the person and the first tag both + # become reap candidates. The person's delete is refused by the server. + with ( + pytest.raises(TrackingGroupCleanupError, match="Unable to delete 1 unused member") as exc_info, + client_sync.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True) as clt, + ): + keeper_tag = clt.create(kind="BuiltinTag", name=keeper_tag_name) + keeper_tag.save(allow_upsert=True) + + assert list(exc_info.value.failures) == [person.id] + + # The refused delete must not prevent the other unused member from being reaped. + with pytest.raises(NodeNotFoundError, match=doomed_tag_name): + client_sync.get(kind="BuiltinTag", name__value=doomed_tag_name) + + # The person survived, and must still be a group member so a later run can retry it. + client_sync.get(kind=TESTING_PERSON, name__value=person_name) + group = client_sync.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert sorted(group.members.peer_ids) == sorted([person.id, keeper_tag.id]) + + def test_zero_member_run_keeps_undeletable_member( + self, client_sync: InfrahubClientSync, base_dataset: None + ) -> None: + person_name = "SyncTrackingRetryPerson" + params = {"person_name": person_name} + + with client_sync.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True) as clt: + person = clt.create(kind=TESTING_PERSON, name=person_name) + person.save(allow_upsert=True) + + cat = client_sync.create(kind=TESTING_CAT, name="SyncTrackingRetryCat", breed="Bengal", owner=person) + cat.save() + + # A zero-member run now attempts the reap; the person's delete is refused. + with ( + pytest.raises(TrackingGroupCleanupError, match="Unable to delete 1 unused member"), + client_sync.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True), + ): + pass + + assert client_sync.mode == InfrahubClientMode.DEFAULT + + group_name = client_sync.group_context._generate_group_name() + group = client_sync.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert group.members.peer_ids == [person.id] + + # Once the blocking node is gone, the next zero-member run reaps the person. + client_sync.delete(kind=TESTING_CAT, id=cat.id) + with client_sync.start_tracking(identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True): + pass + + group = client_sync.get(kind="CoreStandardGroup", name__value=group_name, include=["members"]) + assert len(group.members.peers) == 0 + with pytest.raises(NodeNotFoundError, match=person_name): + client_sync.get(kind=TESTING_PERSON, name__value=person_name) + + def test_zero_member_run_prunes_on_the_tracked_branch( + self, client_sync: InfrahubClientSync, base_dataset: None + ) -> None: + person_name = "SyncBranchTrackingPerson" + tag_name = "sync-branch-tracking-TAG" + params = {"person_name": person_name} + + with client_sync.start_tracking( + identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True, branch=self.BRANCH + ) as clt: + tag = clt.create(kind="BuiltinTag", name=tag_name, branch=self.BRANCH) + tag.save(allow_upsert=True) + person = clt.create(kind=TESTING_PERSON, name=person_name, tags=[tag], branch=self.BRANCH) + person.save(allow_upsert=True) + + group_name = client_sync.group_context._generate_group_name() + group = client_sync.get( + kind="CoreStandardGroup", name__value=group_name, include=["members"], branch=self.BRANCH + ) + assert len(group.members.peers) == 2 + + # The sync group lookup must target the branch. Looking on the client default + # branch would miss this group entirely and skip the cleanup. + with client_sync.start_tracking( + identifier=self.IDENTIFIER, params=params, delete_unused_nodes=True, branch=self.BRANCH + ): + pass + + group = client_sync.get( + kind="CoreStandardGroup", name__value=group_name, include=["members"], branch=self.BRANCH + ) + assert len(group.members.peers) == 0 + + with pytest.raises(NodeNotFoundError, match=tag_name): + client_sync.get(kind="BuiltinTag", name__value=tag_name, branch=self.BRANCH) + with pytest.raises(NodeNotFoundError, match=person_name): + client_sync.get(kind=TESTING_PERSON, name__value=person_name, branch=self.BRANCH) diff --git a/tests/unit/ctl/object/test_inline_peers.py b/tests/unit/ctl/object/test_inline_peers.py new file mode 100644 index 000000000..30b90dafa --- /dev/null +++ b/tests/unit/ctl/object/test_inline_peers.py @@ -0,0 +1,118 @@ +"""Unit tests for ``infrahub_sdk.ctl.object.inline_peers``.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +import pytest + +from infrahub_sdk.ctl.object.inline_peers import ( + raise_if_set_edits_inline_peers, + raise_if_set_names_inline_peers_by_reference, +) +from infrahub_sdk.exceptions import FeatureNotSupportedError +from infrahub_sdk.schema import NodeSchema, NodeSchemaAPI + + +def _pool_schema(relationship: dict[str, Any]) -> NodeSchemaAPI: + data: dict[str, Any] = { + "name": "NumberPool", + "namespace": "Core", + "attributes": [{"name": "name", "kind": "Text", "unique": True}], + "relationships": [relationship], + } + return NodeSchema(**data).convert_api() + + +CREATE_REFUSAL = re.escape( + "'ranges' cannot be set with --set to a reference, because the mutation takes the fields of a " + "CoreNumberPoolRange and not a reference to one. Pass the fields of every CoreNumberPoolRange " + "instead, as a JSON list of objects." +) + +UPDATE_REFUSAL = re.escape( + "'ranges' cannot be changed with --set, because the mutation takes the fields of a CoreNumberPoolRange " + "and not a reference to one. To set the complete list, run infrahubctl object create with it, which " + "upserts the node. To add, change or delete one, run infrahubctl object create, update or delete on the " + "CoreNumberPoolRange node itself." +) + + +def _ranges_schema() -> NodeSchemaAPI: + return _pool_schema( + {"name": "ranges", "peer": "CoreNumberPoolRange", "cardinality": "many", "identifier": "numberpool__range"} + ) + + +def _allowed_ranges_schema() -> NodeSchemaAPI: + return _pool_schema( + { + "name": "allowed_ranges", + "peer": "CoreNumberPoolRange", + "cardinality": "many", + "identifier": "numberpool__allowed_range", + } + ) + + +@dataclass +class ReferenceCase: + name: str + value: Any + + +REFERENCE_CASES = [ + ReferenceCase(name="single-hfid", value="range-1"), + ReferenceCase(name="list-of-hfids", value=["range-1", "range-2"]), + ReferenceCase(name="list-of-hfid-parts", value=[["Core", "range-1"]]), + ReferenceCase(name="uuid", value="550e8400-e29b-41d4-a716-446655440000"), + ReferenceCase(name="object-holding-an-id", value=[{"id": "550e8400-e29b-41d4-a716-446655440000"}]), + ReferenceCase(name="object-holding-an-hfid", value=[{"hfid": ["range-1"]}]), + ReferenceCase(name="object-holding-a-node", value=[{"node": {"start": 1}}]), + ReferenceCase(name="fields-and-a-reference", value=[{"start": 1, "end": 10}, {"id": "range-2"}]), +] + +FIELDS_CASES = [ + ReferenceCase(name="one-range", value=[{"start": 1, "end": 10}]), + ReferenceCase(name="two-ranges", value=[{"start": 1, "end": 10}, {"start": 20}]), + ReferenceCase(name="no-range", value=[]), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in REFERENCE_CASES]) +def test_create_refuses_a_reference_to_a_peer_the_mutation_takes_as_fields(case: ReferenceCase) -> None: + """The reference never reaches Infrahub, so the command says so instead of sending it.""" + with pytest.raises(FeatureNotSupportedError, match=CREATE_REFUSAL): + raise_if_set_names_inline_peers_by_reference({"name": "VLAN IDs", "ranges": case.value}, _ranges_schema()) + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in FIELDS_CASES]) +def test_create_takes_the_fields_of_the_peers(case: ReferenceCase) -> None: + """The fields are what the mutation takes, so --set carries them as they are.""" + raise_if_set_names_inline_peers_by_reference({"name": "VLAN IDs", "ranges": case.value}, _ranges_schema()) + + +def test_create_takes_a_reference_for_another_relationship_to_the_same_peer() -> None: + """Only the relationship Infrahub takes as fields is concerned, not every relationship to a range.""" + raise_if_set_names_inline_peers_by_reference({"allowed_ranges": "range-1"}, _allowed_ranges_schema()) + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in REFERENCE_CASES + FIELDS_CASES]) +def test_update_refuses_every_value_for_such_a_relationship(case: ReferenceCase) -> None: + """An update sends the peers the node was fetched with, so any value given here is dropped.""" + with pytest.raises(FeatureNotSupportedError, match=UPDATE_REFUSAL): + raise_if_set_edits_inline_peers({"name": "VLAN IDs", "ranges": case.value}, _ranges_schema()) + + +def test_update_takes_another_relationship_to_the_same_peer() -> None: + raise_if_set_edits_inline_peers({"allowed_ranges": "range-1"}, _allowed_ranges_schema()) + + +def test_neither_guard_reads_the_schema_of_an_attribute() -> None: + """An attribute has no relationship schema to read, and asking for one raises.""" + schema = _ranges_schema() + + raise_if_set_names_inline_peers_by_reference({"name": "VLAN IDs"}, schema) + raise_if_set_edits_inline_peers({"name": "VLAN IDs"}, schema) diff --git a/tests/unit/ctl/test_troubleshooting_app.py b/tests/unit/ctl/test_troubleshooting_app.py new file mode 100644 index 000000000..fcc73062a --- /dev/null +++ b/tests/unit/ctl/test_troubleshooting_app.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import pytest +from typer.testing import CliRunner + +from infrahub_sdk.ctl.troubleshooting import app + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + +runner = CliRunner() + +URL = "http://mock/api/troubleshooting/trace-sample-ratio" +TRACING_OFF_MESSAGE = "Tracing is not enabled on this instance; set INFRAHUB_TRACE_ENABLE" + +TEMPORARY_STATE = { + "enabled": True, + "default_ratio": 0.1, + "temporary_ratio": 1.0, + "expires_at": "2026-10-22T12:00:00Z", + "ratio_in_force": 1.0, +} +DEFAULT_STATE = { + "enabled": True, + "default_ratio": 0.1, + "temporary_ratio": None, + "expires_at": None, + "ratio_in_force": 0.1, +} +TRACING_OFF_ERROR = {"data": None, "errors": [{"message": TRACING_OFF_MESSAGE, "extensions": {"code": 400}}]} + + +def test_get_prints_the_state(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(method="GET", url=URL, json=TEMPORARY_STATE) + + result = runner.invoke(app=app, args=["trace-sample-ratio", "get"]) + + assert result.exit_code == 0, result.stdout + assert "Ratio in force" in result.stdout + assert "2026-10-22T12:00:00Z" in result.stdout + + +def test_get_when_tracing_is_off_says_so(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(method="GET", url=URL, json={"enabled": False}) + + result = runner.invoke(app=app, args=["trace-sample-ratio", "get"]) + + assert result.exit_code == 0, result.stdout + assert "Tracing enabled" in result.stdout + assert "False" in result.stdout + + +def test_set_sends_the_ratio_and_the_duration(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(method="PUT", url=URL, json=TEMPORARY_STATE) + + result = runner.invoke(app=app, args=["trace-sample-ratio", "set", "--ratio", "1", "--duration", "3600"]) + + assert result.exit_code == 0, result.stdout + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == {"ratio": 1.0, "duration_seconds": 3600} + assert "Ratio in force" in result.stdout + + +def test_clear_sends_a_delete(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(method="DELETE", url=URL, json=DEFAULT_STATE) + + result = runner.invoke(app=app, args=["trace-sample-ratio", "clear"]) + + assert result.exit_code == 0, result.stdout + request = httpx_mock.get_request() + assert request is not None + assert request.method == "DELETE" + + +@dataclass +class ServerErrorCase: + name: str + method: str + args: list[str] + + +SERVER_ERROR_CASES = [ + ServerErrorCase(name="set", method="PUT", args=["trace-sample-ratio", "set", "--ratio", "1", "--duration", "60"]), + ServerErrorCase(name="clear", method="DELETE", args=["trace-sample-ratio", "clear"]), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in SERVER_ERROR_CASES]) +def test_server_error_is_printed_unchanged(case: ServerErrorCase, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(method=case.method, url=URL, status_code=400, json=TRACING_OFF_ERROR) + + result = runner.invoke(app=app, args=case.args) + + assert result.exit_code == 1 + assert TRACING_OFF_MESSAGE in result.stdout.replace("\n", "") + + +@dataclass +class InvalidOptionCase: + name: str + ratio: str + duration: str + + +INVALID_OPTION_CASES = [ + InvalidOptionCase(name="ratio-below-0", ratio="-0.1", duration="60"), + InvalidOptionCase(name="ratio-above-1", ratio="1.5", duration="60"), + InvalidOptionCase(name="duration-below-1", ratio="0.5", duration="0"), + InvalidOptionCase(name="duration-above-24-hours", ratio="0.5", duration="86401"), + InvalidOptionCase(name="ratio-nan", ratio="nan", duration="60"), + InvalidOptionCase(name="duration-nan", ratio="0.5", duration="nan"), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in INVALID_OPTION_CASES]) +def test_set_rejects_values_outside_the_limits_without_a_request( + case: InvalidOptionCase, httpx_mock: HTTPXMock +) -> None: + result = runner.invoke( + app=app, args=["trace-sample-ratio", "set", "--ratio", case.ratio, "--duration", case.duration] + ) + + assert result.exit_code == 2 + assert httpx_mock.get_requests() == [] diff --git a/tests/unit/ctl/test_utils.py b/tests/unit/ctl/test_utils.py index f968072ba..dc88cda75 100644 --- a/tests/unit/ctl/test_utils.py +++ b/tests/unit/ctl/test_utils.py @@ -1,13 +1,68 @@ +import json +from io import StringIO +from pathlib import Path +from typing import Any + +import httpx +import pytest import typer from rich.console import Console from typer.testing import CliRunner from infrahub_sdk.async_typer import AsyncTyper -from infrahub_sdk.ctl.utils import catch_exception +from infrahub_sdk.ctl.utils import ( + catch_exception, + display_object_validate_format_error, + handle_exception, + print_graphql_errors, + print_graphql_query_errors, +) +from infrahub_sdk.exceptions import ( + AuthenticationError, + BranchNotFoundError, + NodeNotFoundError, + SchemaNotFoundError, + TrackingGroupCleanupError, + ValidationError, + authentication_error_from_response, + graphql_error_from_response, +) +from infrahub_sdk.spec.menu import MenuFile from tests.helpers.cli import remove_ansi_color +from tests.helpers.fixtures import read_fixture runner = CliRunner() +FIXTURE_SUBDIR = "error_catalogue" + + +def rendered(recorder: Console) -> str: + return remove_ansi_color(recorder.file.getvalue()) # type: ignore[attr-defined] + + +def recording_console() -> Console: + """A console that captures its output instead of writing it to the terminal.""" + return Console(record=True, file=StringIO(), width=200) + + +def load_envelope(name: str) -> dict[str, Any]: + return json.loads(read_fixture(file_name=name, fixture_subdir=FIXTURE_SUBDIR)) + + +def rendered_for(exc: Exception) -> str: + """Drive the ladder and return what the user would have seen. + + The exit code is asserted rather than matched on: `typer.Exit` carries no message for `match=` to + read, so the code is the only thing that confirms the ladder exited the way it was asked to. + """ + console = recording_console() + + with pytest.raises(typer.Exit) as exc_info: + handle_exception(exc=exc, console=console, exit_code=1) + + assert exc_info.value.exit_code == 1 + return rendered(console) + def test_catch_exception_async_passes_through_typer_exit() -> None: console = Console() @@ -28,6 +83,246 @@ async def fail() -> None: assert "Error: 1" not in stdout +def test_print_graphql_errors_renders_the_server_errors() -> None: + console = recording_console() + + print_graphql_errors( + console=console, + errors=[{"message": "boom", "path": ["TestPerson"]}], + fallback="never reached", + ) + + output = rendered(console) + assert "boom" in output + assert "TestPerson" in output + assert "never reached" not in output + + +def test_print_graphql_errors_degrades_to_the_fallback() -> None: + """An envelope whose entries did not match the declared shape must not exit silently.""" + console = recording_console() + + print_graphql_errors(console=console, errors=[], fallback="the raw payload the server sent") + + assert "the raw payload the server sent" in rendered(console) + + +def test_print_graphql_query_errors_reports_every_error() -> None: + console = recording_console() + exc = graphql_error_from_response(errors=[{"message": "first", "locations": [{"line": 1}]}, {"message": "second"}]) + + print_graphql_query_errors(console=console, exc=exc) + + output = rendered(console) + assert "2 error(s) occurred" in output + assert "Message: first" in output + assert "'line': 1" in output + assert "Message: second" in output + + +def test_print_graphql_query_errors_keeps_the_branch_hint() -> None: + """The hint reads the server's message, since a bare string never survives into `errors`.""" + console = recording_console() + exc = graphql_error_from_response(errors=[{"message": "Branch: does-not-exist not found"}]) + + print_graphql_query_errors(console=console, exc=exc) + + assert "you can specify a different branch with --branch" in rendered(console) + + +def test_print_graphql_query_errors_degrades_to_the_exception_message() -> None: + console = recording_console() + exc = graphql_error_from_response(errors=["a bare string where an array belongs"], query="query { x }") + + print_graphql_query_errors(console=console, exc=exc) + + output = rendered(console) + assert "a bare string where an array belongs" in output + assert "0 error(s)" not in output + + +class TestHandleExceptionLadder: + """Asserts the rendering each class reaches, rather than reading the order off the source.""" + + def test_a_node_lookup_miss_is_not_rendered_as_a_graphql_failure(self) -> None: + """Re-rooting puts this class under `GraphQLError`, whose branch would print an error list.""" + output = rendered_for(NodeNotFoundError(identifier={"name": ["john"]}, node_type="TestPerson")) + + assert "Error: " in output + assert "TestPerson" in output + assert "error(s) occurred" not in output + + def test_a_schema_lookup_miss_keeps_its_own_rendering(self) -> None: + output = rendered_for(SchemaNotFoundError(identifier="TestPerson")) + + assert "Error: Unable to find the schema 'TestPerson'." in output + + def test_a_catalogued_graphql_failure_names_the_code_and_the_server_message(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + exc = graphql_error_from_response(errors=envelope["errors"], query="mutation { TestPersonCreate }") + + output = rendered_for(exc) + + assert "UNIQUENESS_VIOLATION" in output + assert "already has name" in output + assert "mutation { TestPersonCreate }" not in output + + def test_a_single_error_keeps_the_path_naming_the_failed_operation(self) -> None: + """The coded line has nowhere to put the path, so the error list still has to render.""" + envelope = load_envelope("graphql_uniqueness_violation.json") + exc = graphql_error_from_response(errors=envelope["errors"]) + + output = rendered_for(exc) + + assert "TestPersonCreate" in output, "the path tells the user which operation failed" + + def test_the_governing_message_is_not_printed_twice(self) -> None: + output = rendered_for( + graphql_error_from_response(errors=load_envelope("graphql_uniqueness_violation.json")["errors"]) + ) + + assert output.count("already has name") == 1 + + def test_a_catalogued_authentication_failure_is_named_rather_than_labelled(self) -> None: + """Labelling every 401 an authentication failure mislabels PERMISSION_DENIED, so the code wins.""" + request = httpx.Request("POST", "http://mock/graphql/main") + response = httpx.Response(status_code=401, json=load_envelope("auth_token_expired.json"), request=request) + exc = authentication_error_from_response(response=response) + + output = rendered_for(exc) + + assert "TOKEN_EXPIRED" in output + assert "Expired Signature" in output + assert "Authentication failure" not in output + assert "extensions" not in output, "an envelope with no path must not render as a raw dict" + + def test_an_uncatalogued_authentication_failure_keeps_todays_rendering(self) -> None: + """Only errors carrying a code take the new branch; everything else is unchanged.""" + output = rendered_for(AuthenticationError("no token supplied")) + + assert "Authentication failure: no token supplied" in output + + def test_a_branch_lookup_miss_is_not_rendered_as_a_graphql_failure(self) -> None: + """Re-rooted alongside the other two, so it owes the same rendering as the other two.""" + output = rendered_for(BranchNotFoundError(identifier="does-not-exist")) + + assert "Error: Unable to find the branch 'does-not-exist' in the Database." in output + assert "error(s) occurred" not in output + + def test_a_server_message_is_not_read_as_console_markup(self) -> None: + """Rich eats anything shaped like a tag, and a branch name in brackets is exactly that.""" + exc = graphql_error_from_response( + errors=[{"message": "Branch [main] does not exist", "extensions": {"code": "BRANCH_NOT_FOUND"}}] + ) + + output = rendered_for(exc) + + assert "Branch [main] does not exist" in output + + def test_every_server_error_is_rendered_not_just_the_governing_one(self) -> None: + """The code names the first error only, so the rest must still reach the user.""" + envelope = load_envelope("graphql_multiple_errors.json") + exc = graphql_error_from_response(errors=envelope["errors"]) + + output = rendered_for(exc) + + assert "SCHEMA_NOT_FOUND" in output + assert "first failure" in output + assert "second failure" in output + assert "third failure" in output + + def test_an_undescribed_error_without_a_path_keeps_its_raw_entry(self) -> None: + """A validation error carries `locations` and no `path`, and those coordinates are the point. + + Undescribed rendering is unchanged by this feature, so the whole entry still prints. + """ + exc = graphql_error_from_response( + errors=[{"message": "Cannot query field 'nope'.", "locations": [{"line": 1, "column": 9}]}], + query="query { nope }", + ) + + output = rendered_for(exc) + + assert "'line': 1" in output + assert "'column': 9" in output + + def test_a_validation_error_the_server_could_not_describe_keeps_its_coordinates(self) -> None: + """The shape a real server sends: coded `UNDEFINED_ERROR`, with `locations` and no `path`. + + Keying the coded branch on `code is not None` sent this down it and dropped the coordinates, + under a headline that named nothing. + """ + exc = graphql_error_from_response( + errors=[ + { + "message": "Cannot query field 'nope' on type 'Query'.", + "locations": [{"line": 1, "column": 9}], + "extensions": {"code": "UNDEFINED_ERROR", "http_status": 500, "data": {}}, + }, + { + "message": "Cannot query field 'alsonope' on type 'Query'.", + "locations": [{"line": 1, "column": 20}], + "extensions": {"code": "UNDEFINED_ERROR", "http_status": 500, "data": {}}, + }, + ], + query="query { nope alsonope }", + ) + + output = rendered_for(exc) + + assert "'line': 1" in output + assert "'column': 9" in output + assert "'column': 20" in output, "every error still renders, not just the governing one" + # The code still appears inside each raw entry; what it must not be is a headline of its own. + assert "UNDEFINED_ERROR" not in [line.strip() for line in output.splitlines()], ( + "a code that describes nothing must not be the line naming the failure" + ) + + def test_a_lookup_miss_message_is_not_read_as_console_markup(self) -> None: + """The branch name arrives in brackets, which rich reads as a style tag and deletes.""" + output = rendered_for(BranchNotFoundError(identifier="main", message="Not found on the server [main]")) + + assert "Not found on the server [main]" in output + + def test_an_authentication_message_is_not_read_as_console_markup(self) -> None: + output = rendered_for(AuthenticationError("rejected by [proxy-01]")) + + assert "rejected by [proxy-01]" in output + + def test_a_null_path_does_not_render_as_the_word_none(self) -> None: + exc = graphql_error_from_response( + errors=[{"message": "boom", "path": None, "extensions": {"code": "UNIQUENESS_VIOLATION"}}] + ) + + output = rendered_for(exc) + + assert "boom" in output + assert "None" not in output + + def test_an_uncatalogued_graphql_failure_still_renders_the_server_errors(self) -> None: + exc = graphql_error_from_response(errors=[{"message": "boom", "path": ["TestPerson"]}]) + + output = rendered_for(exc) + + assert "boom" in output + assert "TestPerson" in output + + def test_a_tracking_group_cleanup_failure_lists_every_member_and_reason(self) -> None: + """A refused cleanup names each surviving node and why, and its reason is not read as markup.""" + failures = { + "17d5e3f4-1a2b-4c6d-8e9f-0a1b2c3d4e5f": "Cannot delete BuiltinTag [blue]", + "28e6f405-2b3c-4d7e-9fa0-1b2c3d4e5f60": "Cannot delete TestingPerson, a mandatory relationship points at it", + } + + output = rendered_for(TrackingGroupCleanupError(failures=failures)) + + assert "Unused tracking group members that could not be deleted" in output + for node_id, reason in failures.items(): + assert node_id in output + assert reason in output + assert "Traceback" not in output + + def test_catch_exception_sync_passes_through_typer_exit() -> None: console = Console() app = typer.Typer() @@ -45,3 +340,41 @@ def fail() -> None: assert "human-readable failure message" in stdout assert "Traceback" not in stdout assert "Error: 1" not in stdout + + +def test_catch_exception_renders_typer_abort_as_an_unhandled_error() -> None: + """Why a command that has already printed its failure must exit rather than abort. + + `Abort` is an ordinary exception, and this handler has no branch for it, so it reaches the tail + that dumps a traceback - after the command has already rendered the real message. + """ + console = Console() + app = typer.Typer() + + @app.command() + @catch_exception(console=console) + def fail() -> None: + console.print("human-readable failure message") + raise typer.Abort from None + + result = runner.invoke(app, []) + stdout = remove_ansi_color(result.stdout) + + assert result.exit_code == 1 + assert "human-readable failure message" in stdout + assert "Traceback" in stdout, "the tail branch dumps one, which is what `typer.Exit` avoids" + + +def test_display_object_validate_format_error_keeps_brackets() -> None: + menu = MenuFile(location=Path("menus/[main].yml"), content={}) + error = ValidationError( + identifier="menus/[main].yml", + messages=["spec | Input should be a valid dictionary, received '[/devices]' (dict_type)"], + ) + console = recording_console() + display_object_validate_format_error(file=menu, error=error, console=console) + + assert rendered(console) == ( + " File 'menus/[main].yml' is not valid!\n" + " spec | Input should be a valid dictionary, received '[/devices]' (dict_type)\n" + ) diff --git a/tests/unit/meta/__init__.py b/tests/unit/meta/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/meta/test_render_env.py b/tests/unit/meta/test_render_env.py new file mode 100644 index 000000000..a30ac1336 --- /dev/null +++ b/tests/unit/meta/test_render_env.py @@ -0,0 +1,90 @@ +"""Guards for the Rich rendering environment pinned in ``tests/conftest.py``. + +CLI-output assertions elsewhere in this suite compare against text whose colour and width Rich +decides. ``pytest_configure`` pins that decision; these tests pin the pinning, so a future edit +that loosens it fails here with an explanation rather than as a scatter of puzzling +output-comparison failures across the ctl tests. +""" + +from __future__ import annotations + +import os +from importlib.metadata import version +from io import StringIO + +import pytest +from rich.console import Console + +from tests.conftest import RENDER_ENV, RENDER_ENV_TO_CLEAN + +# (major, minor) of the installed Rich. ``pyproject.toml`` floors it at 12.6 -- the first release +# that honours ``FORCE_COLOR`` -- and sets no upper bound, so only the change 14.0 made to the +# meaning of an empty value is still in range. +RICH_VERSION = tuple(int(part) for part in version("rich").split(".")[:2]) + + +def test_render_env_is_pinned() -> None: + """The hook in conftest ran, and ran before this module was imported.""" + for name in RENDER_ENV_TO_CLEAN: + assert name not in os.environ, f"{name} must be unset: a set value makes Rich claim it writes to a terminal" + for name, value in RENDER_ENV.items(): + assert os.environ.get(name) == value + + +@pytest.mark.parametrize("term", ["dumb", "unknown", "xterm-256color", "screen", ""]) +def test_console_width_survives_any_term(term: str, monkeypatch: pytest.MonkeyPatch) -> None: + """``TERM`` must not change the rendered width, whatever the developer's shell exports. + + Rich clamps the width to 80 and ignores ``COLUMNS`` on a *dumb* terminal, but + ``is_dumb_terminal`` is ``is_terminal and TERM in ("dumb", "unknown")`` -- so the clamp needs + Rich to also believe it is on a terminal. Captured test output never is, and the conftest hook + removes the one variable (``FORCE_COLOR``) that would make Rich claim otherwise. That is why + the hook does not pin ``TERM``, and why pinning it to a non-dumb value would be a no-op. + """ + monkeypatch.setenv("TERM", term) + + console = Console(file=StringIO()) + + assert console.is_terminal is False + assert console.is_dumb_terminal is False + assert console.width == int(RENDER_ENV["COLUMNS"]) + assert console.no_color is True + + +@pytest.mark.parametrize("force_color", ["1", "3"]) +def test_force_color_is_what_would_clamp_the_width(force_color: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the failure mode the hook exists to prevent. + + With ``FORCE_COLOR`` set, Rich treats the captured output as a terminal; combined with + ``TERM=dumb`` that drops it to width 80, which truncates the wide tables the CLI-output + fixtures record. This is the state a developer's shell puts the suite in. + """ + monkeypatch.setenv("FORCE_COLOR", force_color) + monkeypatch.setenv("TERM", "dumb") + + console = Console(file=StringIO()) + + assert console.is_terminal is True + assert console.is_dumb_terminal is True + assert console.width == 80 + + +def test_empty_force_color_is_why_the_hook_removes_rather_than_overrides(monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the one value whose meaning depends on the Rich version, which rules out any override. + + Rich 12.6 through 13 test ``FORCE_COLOR is not None``, so ``export FORCE_COLOR=`` forces a + terminal just as ``1`` does. Rich 14 adopted the https://force-color.org/ convention, under + which the empty string means "not a terminal": ``is_terminal`` returns ``False`` before it even + consults ``isatty()``. Overriding the variable with a falsy value would therefore defuse it on + one version and arm it on the other; removing it is the only fix that is correct on both, which + is why ``RENDER_ENV_TO_CLEAN`` exists. + """ + monkeypatch.setenv("FORCE_COLOR", "") + monkeypatch.setenv("TERM", "dumb") + + console = Console(file=StringIO()) + + forces_terminal = RICH_VERSION < (14, 0) + assert console.is_terminal is forces_terminal + assert console.is_dumb_terminal is forces_terminal + assert console.width == (80 if forces_terminal else int(RENDER_ENV["COLUMNS"])) diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index 896a45fb1..b89a3fc77 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -14,6 +14,7 @@ from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync from infrahub_sdk.schema import BranchSupportType, NodeSchema, NodeSchemaAPI from infrahub_sdk.utils import get_fixtures_dir +from tests.helpers.only_data import SCHEMA as ONLY_SCHEMA if TYPE_CHECKING: from pytest_httpx import HTTPXMock @@ -62,6 +63,13 @@ async def echo_clients(clients: BothClients) -> AsyncGenerator[BothClients, None clients.sync.config.echo_graphql_queries = False +@pytest.fixture +def only_clients(clients: BothClients) -> BothClients: + clients.standard.schema.set_cache(ONLY_SCHEMA) + clients.sync.schema.set_cache(ONLY_SCHEMA) + return clients + + @pytest.fixture def return_annotation_map() -> dict[str, str]: return { diff --git a/tests/unit/sdk/pool/conftest.py b/tests/unit/sdk/pool/conftest.py index 43ab113c1..d16bfa282 100644 --- a/tests/unit/sdk/pool/conftest.py +++ b/tests/unit/sdk/pool/conftest.py @@ -116,6 +116,89 @@ async def ipprefix_pool_schema() -> NodeSchemaAPI: return NodeSchema(**data).convert_api() +@pytest.fixture +async def number_pool_schema() -> NodeSchemaAPI: + data: dict[str, Any] = { + "name": "NumberPool", + "namespace": "Core", + "description": "A pool of number resources", + "label": "Number Pool", + "include_in_menu": False, + "branch": BranchSupportType.AGNOSTIC.value, + "inherit_from": ["CoreResourcePool", "LineageSource"], + "human_friendly_id": ["name__value"], + "attributes": [ + {"name": "name", "kind": "Text", "unique": True}, + {"name": "node", "kind": "Text"}, + {"name": "node_attribute", "kind": "Text"}, + {"name": "start_range", "kind": "Number", "optional": True}, + {"name": "end_range", "kind": "Number", "optional": True}, + ], + "relationships": [ + { + "name": "ranges", + "peer": "CoreNumberPoolRange", + "kind": "Component", + "identifier": "numberpool__range", + "cardinality": "many", + "branch": BranchSupportType.AGNOSTIC.value, + "optional": True, + }, + ], + } + return NodeSchema(**data).convert_api() + + +@pytest.fixture +async def number_pool_range_schema() -> NodeSchemaAPI: + """The peer of the ranges relationship, whose own pool relationship is mandatory.""" + data: dict[str, Any] = { + "name": "NumberPoolRange", + "namespace": "Core", + "label": "Number Pool Range", + "branch": BranchSupportType.AGNOSTIC.value, + "attributes": [ + {"name": "start", "kind": "Number"}, + {"name": "end", "kind": "Number", "optional": True}, + {"name": "allocation_weight", "kind": "Number", "optional": True}, + ], + "relationships": [ + { + "name": "pool", + "peer": "CoreNumberPool", + "kind": "Parent", + "identifier": "numberpool__range", + "cardinality": "one", + "branch": BranchSupportType.AGNOSTIC.value, + "optional": False, + }, + ], + } + return NodeSchema(**data).convert_api() + + +@pytest.fixture +async def range_policy_schema() -> NodeSchemaAPI: + """A schema with its own relationship to CoreNumberPoolRange nodes.""" + data: dict[str, Any] = { + "name": "RangePolicy", + "namespace": "Testing", + "label": "Range Policy", + "attributes": [{"name": "name", "kind": "Text", "unique": True}], + "relationships": [ + { + "name": "allowed_ranges", + "peer": "CoreNumberPoolRange", + "kind": "Attribute", + "identifier": "rangepolicy__allowed_range", + "cardinality": "many", + "optional": True, + }, + ], + } + return NodeSchema(**data).convert_api() + + @pytest.fixture async def vlan_schema_with_pool_hfid() -> NodeSchemaAPI: """VLAN schema where vlan_id (NumberPool-sourced) is part of the human_friendly_id.""" diff --git a/tests/unit/sdk/pool/test_attribute_from_pool.py b/tests/unit/sdk/pool/test_attribute_from_pool.py index cc8c70c1d..3fa798cd3 100644 --- a/tests/unit/sdk/pool/test_attribute_from_pool.py +++ b/tests/unit/sdk/pool/test_attribute_from_pool.py @@ -16,7 +16,7 @@ import pytest -from infrahub_sdk.exceptions import ValidationError +from infrahub_sdk.exceptions import GraphQLError, ValidationError from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync if TYPE_CHECKING: @@ -24,6 +24,7 @@ from infrahub_sdk import InfrahubClient, InfrahubClientSync from infrahub_sdk.schema import NodeSchemaAPI + from tests.unit.sdk.conftest import BothClients POOL_ID = "185b9728-1b76-dda7-d13d-106529b1bcd9" @@ -324,6 +325,143 @@ async def test_create_upsert_raises_when_numberpool_attr_in_hfid( await node.create(allow_upsert=True) +client_types = ["standard", "sync"] + +FETCHED_VLAN_ID = "vvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv" +FETCHED_VLAN_DATA: dict[str, Any] = { + "id": FETCHED_VLAN_ID, + "name": {"value": "Example VLAN"}, + "vlan_id": {"value": 100}, +} + + +def _make_vlan( + clients: BothClients, client_type: str, schema: NodeSchemaAPI, data: dict[str, Any] +) -> InfrahubNode | InfrahubNodeSync: + if client_type == "standard": + return InfrahubNode(client=clients.standard, schema=schema, data=data) + return InfrahubNodeSync(client=clients.sync, schema=schema, data=data) + + +async def _save(node: InfrahubNode | InfrahubNodeSync) -> None: + if isinstance(node, InfrahubNode): + await node.save() + else: + node.save() + + +@pytest.mark.parametrize("client_type", client_types) +async def test_create_with_value_and_from_pool_attaches( + clients: BothClients, vlan_schema: NodeSchemaAPI, client_type: str +) -> None: + node = _make_vlan( + clients, + client_type, + vlan_schema, + {"name": "Example VLAN", "vlan_id": {"value": 100, "from_pool": {"id": POOL_ID}}}, + ) + + input_data = node._generate_input_data()["data"]["data"] + + assert input_data["vlan_id"] == {"value": 100, "from_pool": {"id": POOL_ID}} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_update_after_allocation_does_not_resend_from_pool( + clients: BothClients, vlan_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"InfraVLANCreate": {"ok": True, "object": {"id": FETCHED_VLAN_ID, "vlan_id": {"value": 100}}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-infravlan-create"}, + ) + node = _make_vlan( + clients, client_type, vlan_schema, {"name": "Example VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}} + ) + + await _save(node) + + vlan_id = node._generate_input_data(exclude_unmodified=True)["data"]["data"]["vlan_id"] + assert vlan_id == {"value": 100} + assert node._get_attribute("vlan_id").is_from_pool_attribute() is True + + +@pytest.mark.parametrize("client_type", client_types) +async def test_attach_on_fetched_node_keeps_unchanged_value( + clients: BothClients, vlan_schema: NodeSchemaAPI, client_type: str +) -> None: + node = _make_vlan(clients, client_type, vlan_schema, FETCHED_VLAN_DATA) + + node._get_attribute("vlan_id").attach_to_pool(POOL_ID) + + input_data = node._generate_input_data(exclude_unmodified=True)["data"]["data"] + assert input_data == {"id": FETCHED_VLAN_ID, "vlan_id": {"value": 100, "from_pool": {"id": POOL_ID}}} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_detach_on_fetched_node(clients: BothClients, vlan_schema: NodeSchemaAPI, client_type: str) -> None: + node = _make_vlan(clients, client_type, vlan_schema, FETCHED_VLAN_DATA) + + node._get_attribute("vlan_id").detach_from_pool() + + input_data = node._generate_input_data(exclude_unmodified=True)["data"]["data"] + assert input_data == {"id": FETCHED_VLAN_ID, "vlan_id": {"from_pool": None}} + assert node._get_attribute("vlan_id").value == 100 + + +@pytest.mark.parametrize("client_type", client_types) +async def test_detach_before_first_save_keeps_the_value( + clients: BothClients, vlan_schema: NodeSchemaAPI, client_type: str +) -> None: + node = _make_vlan( + clients, + client_type, + vlan_schema, + {"name": "Example VLAN", "vlan_id": {"value": 100, "from_pool": {"id": POOL_ID}}}, + ) + + node._get_attribute("vlan_id").detach_from_pool() + + input_data = node._generate_input_data()["data"]["data"] + assert input_data["vlan_id"] == {"value": 100, "from_pool": None} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_saved_detach_is_not_resent( + clients: BothClients, vlan_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"InfraVLANUpdate": {"ok": True, "object": {"id": FETCHED_VLAN_ID}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-infravlan-update"}, + ) + node = _make_vlan(clients, client_type, vlan_schema, FETCHED_VLAN_DATA) + node._get_attribute("vlan_id").detach_from_pool() + + await _save(node) + + assert node._generate_input_data(exclude_unmodified=True)["data"]["data"] == {"id": FETCHED_VLAN_ID} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_failed_save_keeps_pool_write_pending( + clients: BothClients, vlan_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"errors": [{"message": "pool refused the number"}]}, + match_headers={"X-Infrahub-Tracker": "mutation-infravlan-update"}, + ) + node = _make_vlan(clients, client_type, vlan_schema, FETCHED_VLAN_DATA) + node._get_attribute("vlan_id").attach_to_pool(POOL_ID) + + with pytest.raises(GraphQLError, match="pool refused the number"): + await _save(node) + + vlan_id = node._generate_input_data(exclude_unmodified=True)["data"]["data"]["vlan_id"] + assert vlan_id == {"value": 100, "from_pool": {"id": POOL_ID}} + + def test_create_upsert_raises_when_numberpool_attr_in_hfid_sync( client_sync: InfrahubClientSync, vlan_schema_with_pool_hfid: NodeSchemaAPI, diff --git a/tests/unit/sdk/pool/test_number_pool_ranges.py b/tests/unit/sdk/pool/test_number_pool_ranges.py new file mode 100644 index 000000000..edcba3170 --- /dev/null +++ b/tests/unit/sdk/pool/test_number_pool_ranges.py @@ -0,0 +1,375 @@ +"""Tests that the ranges declared on a CoreNumberPool are sent as bounds, as its mutations take them.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk.exceptions import FeatureNotSupportedError +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync, RelatedNode, RelatedNodeSync +from infrahub_sdk.spec.object import ObjectFile + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk.schema import NodeSchemaAPI, RelationshipSchemaAPI + from tests.unit.sdk.conftest import BothClients + +client_types = ["standard", "sync"] + +REFUSAL_MESSAGE = re.escape( + "The peers of 'ranges' cannot be edited one at a time, because the mutation takes the fields of a " + "CoreNumberPoolRange and not a reference to one. To set them, pass the complete list of 'ranges' to " + "client.create() and save the node with allow_upsert=True, or create, update or delete the " + "CoreNumberPoolRange node itself." +) + +POOL_ID = "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn" +POOL_DATA: dict[str, Any] = {"name": "VLAN IDs", "node": "InfraVLAN", "node_attribute": "vlan_id"} +# Wrapped as a query edge: the pool's own `node` attribute would otherwise be read as the edge's node. +FETCHED_POOL_DATA: dict[str, Any] = { + "node": { + "id": POOL_ID, + "name": {"value": "VLAN IDs"}, + "node": {"value": "InfraVLAN"}, + "node_attribute": {"value": "vlan_id"}, + "ranges": { + "edges": [ + {"node": {"id": "range-1", "display_label": "64512 - 65000", "__typename": "CoreNumberPoolRange"}}, + ] + }, + } +} + + +def _make_node( + clients: BothClients, client_type: str, schema: NodeSchemaAPI, data: dict[str, Any] +) -> InfrahubNode | InfrahubNodeSync: + if client_type == "standard": + return InfrahubNode(client=clients.standard, schema=schema, data=data) + return InfrahubNodeSync(client=clients.sync, schema=schema, data=data) + + +def _make_peer( + clients: BothClients, client_type: str, schema: RelationshipSchemaAPI, data: object +) -> RelatedNode | RelatedNodeSync: + if client_type == "standard": + return RelatedNode(client=clients.standard, branch="main", schema=schema, data=data) + return RelatedNodeSync(client=clients.sync, branch="main", schema=schema, data=data) + + +async def _save(node: InfrahubNode | InfrahubNodeSync, allow_upsert: bool = False) -> None: + if isinstance(node, InfrahubNode): + await node.save(allow_upsert=allow_upsert) + else: + node.save(allow_upsert=allow_upsert) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_create_sends_declared_ranges( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + ranges = [{"start": 64512, "end": 65000, "allocation_weight": 10}, {"start": 65001}] + pool = _make_node(clients, client_type, number_pool_schema, {**POOL_DATA, "ranges": ranges}) + + input_data = pool._generate_input_data()["data"]["data"] + + assert input_data["ranges"] == ranges + + +@pytest.mark.parametrize("client_type", client_types) +async def test_upsert_renders_declared_ranges( + clients: BothClients, number_pool_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"CoreNumberPoolUpsert": {"ok": True, "object": {"id": POOL_ID}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-upsert"}, + ) + pool = _make_node( + clients, + client_type, + number_pool_schema, + {**POOL_DATA, "ranges": [{"start": 64512, "end": 65000, "allocation_weight": 10}, {"start": 65001}]}, + ) + + await _save(pool, allow_upsert=True) + + request = httpx_mock.get_request(match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-upsert"}) + assert request is not None + query = " ".join(json.loads(request.content)["query"].split()) + assert "ranges: [ { start: 64512 end: 65000 allocation_weight: 10 }, { start: 65001 }, ]" in query + + +@pytest.mark.parametrize("client_type", client_types) +async def test_empty_ranges_are_sent(clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str) -> None: + pool = _make_node(clients, client_type, number_pool_schema, {**POOL_DATA, "ranges": []}) + + input_data = pool._generate_input_data()["data"]["data"] + + assert input_data["ranges"] == [] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_ranges_left_out_are_not_sent( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + pool = _make_node(clients, client_type, number_pool_schema, POOL_DATA) + + input_data = pool._generate_input_data()["data"]["data"] + + assert "ranges" not in input_data + + +@dataclass +class RangeReferenceCase: + name: str + declared: Any + + +RANGE_REFERENCE_CASES = [ + RangeReferenceCase(name="id-string", declared="range-1"), + RangeReferenceCase(name="id-dict", declared={"id": "range-1"}), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in RANGE_REFERENCE_CASES]) +async def test_range_given_by_id_is_sent_as_reference( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str, case: RangeReferenceCase +) -> None: + pool = _make_node(clients, client_type, number_pool_schema, {**POOL_DATA, "ranges": [case.declared]}) + + input_data = pool._generate_input_data()["data"]["data"] + + assert input_data["ranges"] == [{"id": "range-1"}] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_declared_ranges_are_not_resent_after_save( + clients: BothClients, number_pool_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"CoreNumberPoolCreate": {"ok": True, "object": {"id": POOL_ID}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-create"}, + ) + pool = _make_node(clients, client_type, number_pool_schema, {**POOL_DATA, "ranges": [{"start": 1, "end": 10}]}) + + await _save(pool) + + assert "ranges" not in pool._generate_input_data(exclude_unmodified=True)["data"]["data"] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_fetched_pool_keeps_range_peers_and_sends_no_ranges( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + pool = _make_node(clients, client_type, number_pool_schema, FETCHED_POOL_DATA) + + assert pool._get_relationship_many("ranges").peer_ids == ["range-1"] + assert pool._generate_input_data(exclude_unmodified=True)["data"]["data"] == {"id": POOL_ID} + + +def _assert_the_pool_is_unchanged(pool: InfrahubNode | InfrahubNodeSync) -> None: + assert pool._get_relationship_many("ranges").peer_ids == ["range-1"] + assert pool._generate_input_data(exclude_unmodified=True)["data"]["data"] == {"id": POOL_ID} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_adding_bounds_to_the_ranges_of_a_pool_is_refused( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + pool = _make_node(clients, client_type, number_pool_schema, FETCHED_POOL_DATA) + + with pytest.raises(FeatureNotSupportedError, match=REFUSAL_MESSAGE): + pool._get_relationship_many("ranges").add({"start": 3000, "end": 3999}) + + _assert_the_pool_is_unchanged(pool) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_adding_a_range_by_reference_to_a_pool_is_refused( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + pool = _make_node(clients, client_type, number_pool_schema, FETCHED_POOL_DATA) + + with pytest.raises(FeatureNotSupportedError, match=REFUSAL_MESSAGE): + pool._get_relationship_many("ranges").add("range-2") + + _assert_the_pool_is_unchanged(pool) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_extending_the_ranges_of_a_pool_is_refused( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + pool = _make_node(clients, client_type, number_pool_schema, FETCHED_POOL_DATA) + + with pytest.raises(FeatureNotSupportedError, match=REFUSAL_MESSAGE): + pool._get_relationship_many("ranges").extend([{"start": 3000}, {"start": 5000}]) + + _assert_the_pool_is_unchanged(pool) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_extending_the_ranges_of_a_pool_with_nothing_is_refused( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + """An empty iterable never reaches add(), so extend() has to refuse on its own.""" + pool = _make_node(clients, client_type, number_pool_schema, FETCHED_POOL_DATA) + + with pytest.raises(FeatureNotSupportedError, match=REFUSAL_MESSAGE): + pool._get_relationship_many("ranges").extend([]) + + _assert_the_pool_is_unchanged(pool) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_removing_a_range_from_a_pool_is_refused( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + pool = _make_node(clients, client_type, number_pool_schema, FETCHED_POOL_DATA) + + with pytest.raises(FeatureNotSupportedError, match=REFUSAL_MESSAGE): + pool._get_relationship_many("ranges").remove("range-1") + + _assert_the_pool_is_unchanged(pool) + + +async def test_changing_the_peer_list_of_a_pool_that_exists_sends_nothing( + clients: BothClients, number_pool_schema: NodeSchemaAPI +) -> None: + """A save of a pool Infrahub already holds sends the peers it was fetched with.""" + pool = InfrahubNode(client=clients.standard, schema=number_pool_schema, data=FETCHED_POOL_DATA) + ranges = pool._get_relationship_many("ranges") + + ranges.peers.append( + RelatedNode(client=clients.standard, branch="main", schema=ranges.schema, data={"start": 50, "end": 60}) + ) + + assert pool._generate_input_data(exclude_unmodified=True)["data"]["data"] == {"id": POOL_ID} + + +async def test_changing_the_peer_list_before_the_pool_exists_sets_what_is_sent( + clients: BothClients, number_pool_schema: NodeSchemaAPI +) -> None: + """The create sends the list as it stands, so the peer list still decides the ranges.""" + pool = InfrahubNode( + client=clients.standard, schema=number_pool_schema, data={**POOL_DATA, "ranges": [{"start": 1, "end": 10}]} + ) + ranges = pool._get_relationship_many("ranges") + + ranges.peers.append( + RelatedNode(client=clients.standard, branch="main", schema=ranges.schema, data={"start": 50, "end": 60}) + ) + + assert pool._generate_input_data()["data"]["data"]["ranges"] == [ + {"start": 1, "end": 10}, + {"start": 50, "end": 60}, + ] + + +async def test_another_relationship_to_a_range_stays_editable( + clients: BothClients, range_policy_schema: NodeSchemaAPI +) -> None: + """Only the relationship Infrahub takes as bounds is refused, not every relationship to a range.""" + policy = InfrahubNode( + client=clients.standard, schema=range_policy_schema, data={"name": "Even numbers", "allowed_ranges": []} + ) + allowed = policy._get_relationship_many("allowed_ranges") + + allowed.add("range-1") + allowed.peers.append(RelatedNode(client=clients.standard, branch="main", schema=allowed.schema, data="range-2")) + + assert allowed.peer_ids == ["range-1", "range-2"] + assert policy._generate_input_data()["data"]["data"]["allowed_ranges"] == [{"id": "range-1"}, {"id": "range-2"}] + + +@dataclass +class ObjectFileRangeCase: + name: str + declared: Any + + +OBJECT_FILE_RANGE_CASES = [ + ObjectFileRangeCase(name="ranges-hold-one-data-list", declared={"data": [{"start": 3000, "end": 3099}]}), + ObjectFileRangeCase(name="each-range-holds-its-data", declared=[{"data": {"start": 3000, "end": 3099}}]), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in OBJECT_FILE_RANGE_CASES]) +async def test_object_file_sends_the_declared_ranges_with_the_pool( + clients: BothClients, + number_pool_schema: NodeSchemaAPI, + number_pool_range_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, + case: ObjectFileRangeCase, +) -> None: + """A range an object file declares is a bound of the pool, not a node to create next to it.""" + client = clients.standard + client.schema.set_cache( + schema={"nodes": [number_pool_schema.model_dump(), number_pool_range_schema.model_dump()]}, branch="main" + ) + httpx_mock.add_response( + method="POST", + json={"data": {"CoreNumberPoolUpsert": {"ok": True, "object": {"id": POOL_ID}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-upsert"}, + ) + obj = ObjectFile( + location=Path("inline"), + content={ + "apiVersion": "infrahub.app/v1", + "kind": "Object", + "spec": {"kind": "CoreNumberPool", "data": [{**POOL_DATA, "ranges": case.declared}]}, + }, + ) + await obj.validate_format(client=client) + + # A request for anything other than the pool mutation, a range node of its own among them, has no + # response registered and fails the test. + await obj.process(client=client) + + request = httpx_mock.get_request(match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-upsert"}) + assert request is not None + query = " ".join(json.loads(request.content)["query"].split()) + assert "ranges: [ { start: 3000 end: 3099 }, ]" in query + + +async def test_object_file_points_the_pool_at_a_range_it_names( + clients: BothClients, + number_pool_schema: NodeSchemaAPI, + number_pool_range_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + """An object file that names existing ranges still points the pool at them.""" + client = clients.standard + client.schema.set_cache( + schema={"nodes": [number_pool_schema.model_dump(), number_pool_range_schema.model_dump()]}, branch="main" + ) + httpx_mock.add_response( + method="POST", + json={"data": {"CoreNumberPoolUpsert": {"ok": True, "object": {"id": POOL_ID}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-upsert"}, + ) + obj = ObjectFile( + location=Path("inline"), + content={ + "apiVersion": "infrahub.app/v1", + "kind": "Object", + "spec": {"kind": "CoreNumberPool", "data": [{**POOL_DATA, "ranges": ["range-1"]}]}, + }, + ) + await obj.validate_format(client=client) + + await obj.process(client=client) + + request = httpx_mock.get_request(match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-upsert"}) + assert request is not None + query = " ".join(json.loads(request.content)["query"].split()) + assert 'ranges: [ { id: "range-1" }, ]' in query diff --git a/tests/unit/sdk/pool/test_pool_queries.py b/tests/unit/sdk/pool/test_pool_queries.py index b4d818a6b..f342dfa34 100644 --- a/tests/unit/sdk/pool/test_pool_queries.py +++ b/tests/unit/sdk/pool/test_pool_queries.py @@ -4,7 +4,8 @@ import pytest -from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync +from infrahub_sdk.constants import PoolAllocationProvenance +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync, PoolAllocation if TYPE_CHECKING: from typing import Any @@ -115,6 +116,11 @@ async def test_get_pool_allocated_resources( "17d9bd8e-31ee-acf0-2786-179fb76f2f67", ] + # Servers older than 1.12 reject the provenance field. + allocated_request = httpx_mock.get_request(match_headers={"X-Infrahub-Tracker": "get-allocated-resources-page1"}) + assert allocated_request is not None + assert b"provenance" not in allocated_request.content + @pytest.mark.parametrize("client_type", client_types) async def test_get_pool_resources_utilization( @@ -183,3 +189,113 @@ async def test_get_pool_resources_utilization( utilizations = ip_pool.get_pool_resources_utilization() assert len(utilizations) == 1 assert utilizations[0]["utilization"] == pytest.approx(93.75) + + +NUMBER_POOL_ID = "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn" + + +def _allocation_row(node_id: str, number: int, provenance: str | None) -> dict[str, Any]: + return { + "node": { + "id": node_id, + "kind": "InfraVLAN", + "branch": "main", + "display_label": str(number), + "identifier": None, + "provenance": provenance, + } + } + + +@pytest.mark.parametrize("client_type", client_types) +async def test_get_pool_allocations( + httpx_mock: HTTPXMock, + clients: BothClients, + number_pool_schema: NodeSchemaAPI, + client_type: str, +) -> None: + httpx_mock.add_response( + method="POST", + json={ + "data": { + "InfrahubResourcePoolAllocated": { + "count": 4, + "edges": [ + _allocation_row(node_id="vlan-1", number=100, provenance="ALLOCATED"), + _allocation_row(node_id="vlan-2", number=101, provenance="PROVIDED"), + _allocation_row(node_id="vlan-3", number=102, provenance="ALLOCATED"), + ], + } + } + }, + match_headers={"X-Infrahub-Tracker": "get-pool-allocations-page1"}, + ) + httpx_mock.add_response( + method="POST", + json={ + "data": { + "InfrahubResourcePoolAllocated": { + "count": 4, + "edges": [_allocation_row(node_id="vlan-4", number=103, provenance=None)], + } + } + }, + match_headers={"X-Infrahub-Tracker": "get-pool-allocations-page2"}, + ) + pool_data = {"id": NUMBER_POOL_ID, "name": "VLAN IDs", "node": "InfraVLAN", "node_attribute": "vlan_id"} + + if client_type == "standard": + pool = InfrahubNode(client=clients.standard, schema=number_pool_schema, data=pool_data) + allocations = await pool.get_pool_allocations(resource=pool) + else: + pool_sync = InfrahubNodeSync(client=clients.sync, schema=number_pool_schema, data=pool_data) + allocations = pool_sync.get_pool_allocations(resource=pool_sync) + + assert allocations == [ + PoolAllocation( + node_id="vlan-1", + kind="InfraVLAN", + branch="main", + display_label="100", + identifier=None, + provenance=PoolAllocationProvenance.ALLOCATED, + ), + PoolAllocation( + node_id="vlan-2", + kind="InfraVLAN", + branch="main", + display_label="101", + identifier=None, + provenance=PoolAllocationProvenance.PROVIDED, + ), + PoolAllocation( + node_id="vlan-3", + kind="InfraVLAN", + branch="main", + display_label="102", + identifier=None, + provenance=PoolAllocationProvenance.ALLOCATED, + ), + PoolAllocation( + node_id="vlan-4", kind="InfraVLAN", branch="main", display_label="103", identifier=None, provenance=None + ), + ] + first_request = httpx_mock.get_request(match_headers={"X-Infrahub-Tracker": "get-pool-allocations-page1"}) + assert first_request is not None + assert b"provenance" in first_request.content + + +@pytest.mark.parametrize("client_type", client_types) +async def test_get_pool_allocations_requires_a_pool( + clients: BothClients, + vlan_schema: NodeSchemaAPI, + client_type: str, +) -> None: + if client_type == "standard": + vlan = InfrahubNode(client=clients.standard, schema=vlan_schema, data={"id": "vlan-1", "name": "VLAN 1"}) + with pytest.raises(ValueError, match="can only be fetched from resource pool nodes"): + await vlan.get_pool_allocations(resource=vlan) + else: + vlan_sync = InfrahubNodeSync(client=clients.sync, schema=vlan_schema, data={"id": "vlan-1", "name": "VLAN 1"}) + with pytest.raises(ValueError, match="can only be fetched from resource pool nodes"): + vlan_sync.get_pool_allocations(resource=vlan_sync) diff --git a/tests/unit/sdk/spec/test_menu.py b/tests/unit/sdk/spec/test_menu.py new file mode 100644 index 000000000..3d59640b5 --- /dev/null +++ b/tests/unit/sdk/spec/test_menu.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import pytest + +from infrahub_sdk.exceptions import ValidationError +from infrahub_sdk.spec.menu import MenuFile +from infrahub_sdk.yaml import InfrahubFile + + +@pytest.fixture +def menu_content() -> dict: + return {"apiVersion": "infrahub.app/v1", "kind": "Menu", "spec": {"data": [{"name": "Devices"}]}} + + +@pytest.fixture +def menu_content_bad_spec(menu_content: dict) -> dict: + menu_content["spec"]["data"] = "not-a-list" + return menu_content + + +def test_validate_content(menu_content: dict) -> None: + menu = MenuFile(location="some/path", content=menu_content) + menu.validate_content() + + assert menu.spec.kind == "CoreMenuItem" + assert menu.spec.data == [{"name": "Devices"}] + + +def test_validate_content_wrong_kind(menu_content: dict) -> None: + menu_content["kind"] = "Object" + menu = MenuFile(location="some/path", content=menu_content) + with pytest.raises(ValidationError, match=r"^some/path: File is not an Infrahub Menu file$") as exc: + menu.validate_content() + + assert exc.value.identifier == "some/path" + + +def test_validate_content_invalid_spec(menu_content_bad_spec: dict) -> None: + menu = MenuFile(location="some/path", content=menu_content_bad_spec) + with pytest.raises(ValidationError, match=r"^some/path: data \| Input should be a valid list") as exc: + menu.validate_content() + + assert exc.value.identifier == "some/path" + assert exc.value.messages == ["data | Input should be a valid list, received 'not-a-list' (list_type)"] + + +def test_spec_invalid(menu_content_bad_spec: dict) -> None: + menu = MenuFile(location="some/path", content=menu_content_bad_spec) + # Load the file envelope only, so the spec is first built by the property. + InfrahubFile.validate_content(menu) + with pytest.raises(ValidationError, match=r"^some/path: data \| Input should be a valid list") as exc: + _ = menu.spec + + assert exc.value.messages == ["data | Input should be a valid list, received 'not-a-list' (list_type)"] + + +def test_spec_before_validate_content_is_not_a_validation_error(menu_content: dict) -> None: + menu = MenuFile(location="some/path", content=menu_content) + with pytest.raises(ValueError, match=r"^_data hasn't been initialized yet$"): + _ = menu.spec diff --git a/tests/unit/sdk/spec/test_object.py b/tests/unit/sdk/spec/test_object.py index 4e666b3a1..4b39bb479 100644 --- a/tests/unit/sdk/spec/test_object.py +++ b/tests/unit/sdk/spec/test_object.py @@ -1026,3 +1026,21 @@ async def test_validate_object_skips_mandatory_check_with_object_profile( mandatory_errors = [e for e in errors if e.message == "type is mandatory"] assert mandatory_errors == [] + + +def test_validate_content_wrong_kind(root_location: dict) -> None: + root_location["kind"] = "Menu" + obj = ObjectFile(location="some/path", content=root_location) + with pytest.raises(ValidationError, match=r"^some/path: File is not an Infrahub Object file$") as exc: + obj.validate_content() + + assert exc.value.identifier == "some/path" + + +def test_validate_content_invalid_spec(root_location: dict) -> None: + del root_location["spec"]["kind"] + obj = ObjectFile(location="some/path", content=root_location) + with pytest.raises(ValidationError, match=r"^some/path: kind \| Field required \(missing\)$") as exc: + obj.validate_content() + + assert exc.value.messages == ["kind | Field required (missing)"] diff --git a/tests/unit/sdk/test_attribute_generate_input_data.py b/tests/unit/sdk/test_attribute_generate_input_data.py index 394623fc5..8c8457e44 100644 --- a/tests/unit/sdk/test_attribute_generate_input_data.py +++ b/tests/unit/sdk/test_attribute_generate_input_data.py @@ -327,6 +327,116 @@ def test_properties_appended_for_from_pool(self) -> None: assert result.payload["is_protected"] is True +# ────────────────────────────────────────────── +# Attaching a held number to a pool +# ────────────────────────────────────────────── + + +class TestAttachToPool: + def test_from_pool_dict_with_value_sends_both(self) -> None: + attr = Attribute( + name="vlan_id", + schema=_make_schema(AttributeKind.NUMBER), + data={"value": 100, "from_pool": {"id": "pool-uuid-1"}}, + ) + + result = attr._generate_input_data() + + assert result.payload == {"value": 100, "from_pool": {"id": "pool-uuid-1"}} + + def test_attach_to_pool_by_id(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + attr.attach_to_pool("pool-uuid-1") + + assert attr._generate_input_data().payload == {"value": 100, "from_pool": {"id": "pool-uuid-1"}} + assert attr.is_from_pool_attribute() is True + assert attr.is_unresolved_pool_attribute() is False + + def test_attach_to_pool_by_node(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + attr.attach_to_pool(_FakeNode(node_id="pool-uuid-1", is_pool=True)) + + assert attr._generate_input_data().payload == {"value": 100, "from_pool": {"id": "pool-uuid-1"}} + + def test_attach_to_pool_without_value_raises(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER, optional=True), data=None) + + with pytest.raises(ValueError, match="holds no number to attach"): + attr.attach_to_pool("pool-uuid-1") + + def test_attach_to_pool_with_pool_node_value_raises(self) -> None: + attr = Attribute( + name="vlan_id", + schema=_make_schema(AttributeKind.NUMBER), + data=_FakeNode(node_id="pool-uuid-1", is_pool=True), + ) + + with pytest.raises(ValueError, match="holds no number to attach"): + attr.attach_to_pool("pool-uuid-2") + + def test_attach_to_unsaved_pool_raises(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + with pytest.raises(ValueError, match="before attaching a number to it"): + attr.attach_to_pool(_FakeNode(node_id="", is_pool=True)) + + def test_attach_to_non_pool_node_raises(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + with pytest.raises(ValueError, match="is not a resource pool"): + attr.attach_to_pool(_FakeNode(node_id="node-uuid-1", is_pool=False)) + + def test_sent_attach_is_not_sent_again(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + attr.attach_to_pool("pool-uuid-1") + + attr._mark_pool_write_sent() + + assert attr._generate_input_data().payload == {"value": 100} + assert attr.is_from_pool_attribute() is True + + +# ────────────────────────────────────────────── +# Detaching a held number from its pool +# ────────────────────────────────────────────── + + +class TestDetachFromPool: + def test_detach_without_a_saved_node_keeps_the_value(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + attr.detach_from_pool() + + assert attr._generate_input_data().payload == {"value": 100, "from_pool": None} + assert attr.is_from_pool_attribute() is False + + def test_detach_with_value_change_sends_both(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + attr.value = 200 + + attr.detach_from_pool() + + assert attr._generate_input_data().payload == {"value": 200, "from_pool": None} + + def test_detach_replaces_pending_attach(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + attr.attach_to_pool("pool-uuid-1") + + attr.detach_from_pool() + + assert attr._generate_input_data().payload == {"value": 100, "from_pool": None} + + def test_sent_detach_is_not_sent_again(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + attr.detach_from_pool() + + attr._mark_pool_write_sent() + + assert attr._generate_input_data().payload == {"value": 100} + + # ────────────────────────────────────────────── # Return type: to_dict() integration # ────────────────────────────────────────────── diff --git a/tests/unit/sdk/test_client.py b/tests/unit/sdk/test_client.py index 890f7aa4a..a164fa4b8 100644 --- a/tests/unit/sdk/test_client.py +++ b/tests/unit/sdk/test_client.py @@ -3,16 +3,21 @@ import inspect import json import ssl -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync -from infrahub_sdk.exceptions import NodeNotFoundError +from infrahub_sdk.exceptions import ( + ApiError, + NodeNotFoundError, + UniquenessViolationError, +) from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync from infrahub_sdk.repository import GitRepoManager +from tests.helpers.fixtures import read_fixture from tests.helpers.utils import change_directory if TYPE_CHECKING: @@ -967,3 +972,56 @@ class GraphQLURLCase: async def test_graphql_url_encodes_branch_name(clients: BothClients, client_type: str, case: GraphQLURLCase) -> None: client = clients.standard if client_type == "standard" else clients.sync assert client._graphql_url(branch_name=case.branch_name) == case.expected_url + + +@dataclass +class CatalogueParityCase: + name: str + code: str + status_code: int + expected_class: type[ApiError] + expected_attributes: dict[str, Any] = field(default_factory=dict) + + +CATALOGUE_PARITY_CASES = [ + CatalogueParityCase( + name="uniqueness-violation-inside-a-200", + code="UNIQUENESS_VIOLATION", + status_code=200, + expected_class=UniquenessViolationError, + expected_attributes={"node_kind": "TestPerson", "fields": ["name"], "http_status": 422}, + ), + CatalogueParityCase( + name="node-not-found-inside-a-200", + code="NODE_NOT_FOUND", + status_code=200, + expected_class=NodeNotFoundError, + expected_attributes={"node_type": "TestPerson", "identifier": "john", "http_status": 404}, + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in CATALOGUE_PARITY_CASES]) +async def test_both_clients_raise_the_same_catalogued_exception( + httpx_mock: HTTPXMock, clients: BothClients, client_type: str, case: CatalogueParityCase +) -> None: + """A promoted payload attribute reaches the caller the same way from either client. + + The exception is built at a different call site per client, so parity is a property of the + client rather than of the factory, and only driving both proves it. + """ + envelope = json.loads(read_fixture(file_name=f"{case.code.lower()}.json", fixture_subdir="error_catalogue/codes")) + httpx_mock.add_response(method="POST", status_code=case.status_code, json=envelope) + client = clients.standard if client_type == "standard" else clients.sync + + with pytest.raises(case.expected_class, match=case.code) as exc_info: + if isinstance(client, InfrahubClient): + await client.execute_graphql(query="query { TestPerson { edges { node { id }}}}") + else: + client.execute_graphql(query="query { TestPerson { edges { node { id }}}}") + + assert type(exc_info.value) is case.expected_class + assert exc_info.value.code == case.code + for attribute, value in case.expected_attributes.items(): + assert getattr(exc_info.value, attribute) == value diff --git a/tests/unit/sdk/test_client_only.py b/tests/unit/sdk/test_client_only.py new file mode 100644 index 000000000..818fc9f13 --- /dev/null +++ b/tests/unit/sdk/test_client_only.py @@ -0,0 +1,748 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk.exceptions import ( + FieldNotFoundError, + FieldNotLoadedError, + FieldSelectionConflictError, + NodeNotFoundError, +) +from infrahub_sdk.node._only import OnlyMarker # noqa: PLC2701 # the helpers under test are private to the package +from infrahub_sdk.node.attribute import Attribute +from tests.helpers.only_data import ( + CAT_ID, + DEVICE_ID, + DEVICE_WITH_HFID, + DEVICE_WITH_NAME_AND_SITE, + DOG_ID, + PARENT_ID, + REFERENCE, + REGION_ID, + SITE_ID, + TAG_2_ID, + TAG_ID, + VALUE, + add_page, + client_of, + expected_body, + node_data, + peer, + peers, + query_nodes, + run, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from pytest_httpx import HTTPXMock + + from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync + from tests.unit.sdk.conftest import BothClients + +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + +client_types = ["standard", "sync"] + +DEVICE_2_ID = "a5f1d5a0-5f5c-4a2b-9a4c-3e1c4c1f0d02" +OWNER_ID = "e9b5f9e4-9d9a-4e6f-9e8a-7c5a8a5b4b01" +OWNER_2_ID = "e9b5f9e4-9d9a-4e6f-9e8a-7c5a8a5b4b02" +WALKER_ID = "e9b5f9e4-9d9a-4e6f-9e8a-7c5a8a5b4b03" +HOME_ID = "b6e2c6b1-6a6d-4b3c-8b5d-4f2d5d2e1e02" +CHILD_ID = "f1a6e1f5-1e1b-4f7a-8f9b-8d6b9b6c5c03" +ANCESTOR_ID = "f1a6e1f5-1e1b-4f7a-8f9b-8d6b9b6c5c04" +DESCENDANT_ID = "f1a6e1f5-1e1b-4f7a-8f9b-8d6b9b6c5c05" + + +@dataclass +class ConflictCase: + name: str + method: str + arguments: dict[str, Any] + + +CONFLICT_CASES = [ + ConflictCase(name="get-with-include", method="get", arguments={"id": DEVICE_ID, "include": ["tags"]}), + ConflictCase(name="get-with-empty-exclude", method="get", arguments={"id": DEVICE_ID, "exclude": []}), + ConflictCase(name="all-with-exclude", method="all", arguments={"exclude": ["description"]}), + ConflictCase(name="all-with-empty-include", method="all", arguments={"include": []}), + ConflictCase(name="filters-with-include", method="filters", arguments={"include": ["tags"], "name__value": "x"}), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in CONFLICT_CASES]) +async def test_only_with_include_or_exclude_raises_before_any_request( + clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: ConflictCase +) -> None: + with pytest.raises(FieldSelectionConflictError, match="only cannot be combined with include or exclude"): + await run(clients, client_type, case.method, kind="InfraDevice", only=["name"], **case.arguments) + + assert httpx_mock.get_requests() == [] + + +@dataclass +class UnknownNameCase: + name: str + method: str + kind: str + only: list[str] + message: str + arguments: dict[str, Any] = field(default_factory=dict) + + +UNKNOWN_NAME_CASES = [ + UnknownNameCase( + name="get-misspelt-name", + method="get", + kind="InfraDevice", + only=["nmae"], + message="'nmae' is not an attribute or relationship of InfraDevice", + arguments={"id": DEVICE_ID}, + ), + UnknownNameCase( + name="all-misspelt-name", + method="all", + kind="InfraDevice", + only=["name", "stie"], + message="'stie' is not an attribute or relationship of InfraDevice", + ), + UnknownNameCase( + name="filters-misspelt-name", + method="filters", + kind="InfraDevice", + only=["descripton"], + message="'descripton' is not an attribute or relationship of InfraDevice", + arguments={"name__value": "dev1"}, + ), + UnknownNameCase( + name="ancestors-on-kind-without-hierarchy", + method="all", + kind="InfraSite", + only=["ancestors"], + message="'ancestors' is not an attribute or relationship of InfraSite", + ), + UnknownNameCase( + name="name-on-no-implementing-kind", + method="all", + kind="TestingAnimal", + only=["leash"], + message="'leash' is not an attribute or relationship of TestingAnimal", + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in UNKNOWN_NAME_CASES]) +async def test_unknown_name_in_only_raises_before_the_query( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: UnknownNameCase +) -> None: + with pytest.raises(FieldNotFoundError, match=case.message): + await run(only_clients, client_type, case.method, kind=case.kind, only=case.only, **case.arguments) + + assert httpx_mock.get_requests() == [] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_only_given_as_a_string_raises_before_the_query( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + with pytest.raises(TypeError, match="only must be a list of field names, not a string"): + await run(only_clients, client_type, "all", kind="InfraDevice", only="name") + + assert httpx_mock.get_requests() == [] + + +@dataclass +class IgnoredNameCase: + name: str + arguments: dict[str, Any] + + +IGNORED_NAME_CASES = [ + IgnoredNameCase(name="include", arguments={"include": ["nmae"]}), + IgnoredNameCase(name="exclude", arguments={"exclude": ["nmae"]}), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in IGNORED_NAME_CASES]) +async def test_unknown_name_in_include_or_exclude_is_ignored( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: IgnoredNameCase +) -> None: + add_page(httpx_mock, "InfraDevice", []) + + await run(only_clients, client_type, "all", kind="InfraDevice") + await run(only_clients, client_type, "all", kind="InfraDevice", **case.arguments) + + default_body, body = [json.loads(request.content) for request in httpx_mock.get_requests()] + assert body["operationName"] == "All_InfraDevice" + assert body == default_body + + +@dataclass +class RequestCase: + name: str + method: str + kind: str + only: list[str] + expected: dict[str, Any] + arguments: dict[str, Any] = field(default_factory=dict) + + +REQUEST_CASES = [ + RequestCase( + name="all-attribute-and-relationship", + method="all", + kind="InfraDevice", + only=["name", "site"], + expected=expected_body("InfraDevice", "All_InfraDevice", {"name": VALUE, "site": {"node": REFERENCE}}), + ), + RequestCase( + name="get-by-id", + method="get", + kind="InfraDevice", + only=["description"], + arguments={"id": DEVICE_ID}, + expected=expected_body("InfraDevice", "Get_InfraDevice", {"description": VALUE}, filters={"ids": [DEVICE_ID]}), + ), + RequestCase( + name="filters-with-filter", + method="filters", + kind="InfraDevice", + only=["tags"], + arguments={"name__value": "dev1"}, + expected=expected_body( + "InfraDevice", + "Filters_InfraDevice", + {"tags": {"edges": {"node": REFERENCE}}}, + filters={"name__value": "dev1"}, + ), + ), + RequestCase( + name="name-only-on-implementing-kind", + method="all", + kind="TestingAnimal", + only=["walker"], + expected=expected_body( + "TestingAnimal", + "All_TestingAnimal", + {"...on TestingDog": {"walker": {"node": REFERENCE, "@alias": "__alias__TestingDog__walker"}}}, + ), + ), + RequestCase( + name="fragment-adds-nothing-on-generic", + method="all", + kind="TestingAnimal", + only=["name", "walker"], + arguments={"fragment": True}, + expected=expected_body( + "TestingAnimal", + "All_TestingAnimal", + { + "name": VALUE, + "...on TestingDog": {"walker": {"node": REFERENCE, "@alias": "__alias__TestingDog__walker"}}, + }, + ), + ), + RequestCase( + name="ancestors-on-hierarchical-kind", + method="all", + kind="LocationRegion", + only=["ancestors"], + expected=expected_body( + "LocationRegion", + "All_LocationRegion", + {"ancestors": {"edges": {"node": {"...on LocationGeneric": REFERENCE}}}}, + ), + ), + RequestCase( + name="hfid-requests-the-hfid-the-server-stores", + method="all", + kind="InfraDevice", + only=["hfid"], + expected=expected_body("InfraDevice", "All_InfraDevice", {}, hfid=True), + ), + RequestCase( + name="hfid-with-a-named-relationship", + method="get", + kind="InfraDevice", + only=["site", "hfid"], + arguments={"id": DEVICE_ID}, + expected=expected_body( + "InfraDevice", "Get_InfraDevice", {"site": {"node": REFERENCE}}, filters={"ids": [DEVICE_ID]}, hfid=True + ), + ), + RequestCase( + name="hfid-on-generic", + method="all", + kind="TestingAnimal", + only=["hfid"], + expected=expected_body("TestingAnimal", "All_TestingAnimal", {}, hfid=True), + ), + RequestCase( + name="hfid-on-kind-without-hfid", + method="all", + kind="TestingShelter", + only=["hfid"], + expected=expected_body("TestingShelter", "All_TestingShelter", {}), + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in REQUEST_CASES]) +async def test_only_request_body( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: RequestCase +) -> None: + edges = [node_data(DEVICE_ID, case.kind)] if case.method == "get" else [] + add_page(httpx_mock, case.kind, edges) + + await run(only_clients, client_type, case.method, kind=case.kind, only=case.only, **case.arguments) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == case.expected + + +@dataclass +class StoreCase: + name: str + kind: str + only: list[str] + edges: list[dict[str, Any]] + expected_markers: dict[str, OnlyMarker] + prefetch_relationships: bool = False + + +DEVICE_WITH_PEERS = node_data( + DEVICE_ID, + "InfraDevice", + site=peer(SITE_ID, "InfraSite", name={"value": "Paris"}), + tags=peers(peer(TAG_ID, "BuiltinTag", name={"value": "red"}), peer(TAG_2_ID, "BuiltinTag", name={"value": "blue"})), +) + +STORE_CASES = [ + StoreCase( + name="nodes-carry-the-marker", + kind="InfraDevice", + only=["name"], + edges=[ + node_data(DEVICE_ID, "InfraDevice", name={"value": "dev1"}), + node_data(DEVICE_2_ID, "InfraDevice", name={"value": "dev2"}), + ], + expected_markers={DEVICE_ID: OnlyMarker(only=("name",)), DEVICE_2_ID: OnlyMarker(only=("name",))}, + ), + StoreCase( + name="references-build-no-peers", + kind="InfraDevice", + only=["site", "tags"], + edges=[DEVICE_WITH_PEERS], + expected_markers={DEVICE_ID: OnlyMarker(only=("site", "tags"))}, + ), + StoreCase( + name="prefetch-builds-named-peers", + kind="InfraDevice", + only=["site", "tags"], + prefetch_relationships=True, + edges=[DEVICE_WITH_PEERS], + expected_markers={ + DEVICE_ID: OnlyMarker(only=("site", "tags")), + SITE_ID: OnlyMarker(only=("site", "tags"), peer_of="InfraDevice.site"), + TAG_ID: OnlyMarker(only=("site", "tags"), peer_of="InfraDevice.tags"), + TAG_2_ID: OnlyMarker(only=("site", "tags"), peer_of="InfraDevice.tags"), + }, + ), + StoreCase( + name="prefetch-ignores-unnamed-relationship-in-response", + kind="InfraDevice", + only=["tags"], + prefetch_relationships=True, + edges=[DEVICE_WITH_PEERS], + expected_markers={ + DEVICE_ID: OnlyMarker(only=("tags",)), + TAG_ID: OnlyMarker(only=("tags",), peer_of="InfraDevice.tags"), + TAG_2_ID: OnlyMarker(only=("tags",), peer_of="InfraDevice.tags"), + }, + ), + StoreCase( + name="prefetch-and-hfid-build-only-named-peers", + kind="InfraDevice", + only=["tags", "hfid"], + prefetch_relationships=True, + edges=[DEVICE_WITH_PEERS], + expected_markers={ + DEVICE_ID: OnlyMarker(only=("tags", "hfid")), + TAG_ID: OnlyMarker(only=("tags", "hfid"), peer_of="InfraDevice.tags"), + TAG_2_ID: OnlyMarker(only=("tags", "hfid"), peer_of="InfraDevice.tags"), + }, + ), + StoreCase( + name="prefetch-builds-parent-and-children-only", + kind="LocationRegion", + only=["parent", "children", "ancestors", "descendants"], + prefetch_relationships=True, + edges=[ + node_data( + REGION_ID, + "LocationRegion", + parent=peer(PARENT_ID, "LocationRegion", name={"value": "Europe"}), + children=peers(peer(CHILD_ID, "LocationRegion", name={"value": "Paris"})), + ancestors=peers(peer(ANCESTOR_ID, "LocationRegion", name={"value": "World"})), + descendants=peers(peer(DESCENDANT_ID, "LocationRegion", name={"value": "Montmartre"})), + ) + ], + expected_markers={ + REGION_ID: OnlyMarker(only=("parent", "children", "ancestors", "descendants")), + PARENT_ID: OnlyMarker( + only=("parent", "children", "ancestors", "descendants"), peer_of="LocationRegion.parent" + ), + CHILD_ID: OnlyMarker( + only=("parent", "children", "ancestors", "descendants"), peer_of="LocationRegion.children" + ), + }, + ), + StoreCase( + name="prefetch-builds-peer-of-relationship-only-on-implementing-kind", + kind="TestingAnimal", + only=["walker"], + prefetch_relationships=True, + edges=[ + node_data( + DOG_ID, + "TestingDog", + __alias__TestingDog__walker=peer(WALKER_ID, "TestingPerson", name={"value": "Bob"}), + ), + node_data(CAT_ID, "TestingCat"), + ], + expected_markers={ + DOG_ID: OnlyMarker(only=("walker",)), + CAT_ID: OnlyMarker(only=("walker",)), + WALKER_ID: OnlyMarker(only=("walker",), peer_of="TestingDog.walker"), + }, + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in STORE_CASES]) +async def test_only_nodes_and_peers_in_store( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: StoreCase +) -> None: + add_page(httpx_mock, case.kind, case.edges) + client = client_of(only_clients, client_type) + + nodes = await query_nodes( + only_clients, + client_type, + "all", + kind=case.kind, + only=case.only, + prefetch_relationships=case.prefetch_relationships, + ) + + assert [node._only_marker for node in nodes] == [case.expected_markers[node.id] for node in nodes] + assert client.store.count() == len(case.expected_markers) + stored_markers = {id: client.store.get(key=id)._only_marker for id in case.expected_markers} + assert stored_markers == case.expected_markers + + +@pytest.mark.parametrize("client_type", client_types) +async def test_peer_of_reference_is_not_found_when_not_in_store( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + add_page(httpx_mock, "InfraDevice", [DEVICE_WITH_PEERS]) + + nodes = await query_nodes(only_clients, client_type, "all", kind="InfraDevice", only=["site"]) + + assert nodes[0].site.id == SITE_ID + with pytest.raises(NodeNotFoundError, match=f"Unable to find the node '{SITE_ID}' in the store"): + _ = nodes[0].site.peer + + +@dataclass +class ServerHfidCase: + name: str + only: list[str] + edge: dict[str, Any] + expected: list[Any] + + +SERVER_HFID_CASES = [ + ServerHfidCase( + name="server-value", + only=["hfid"], + edge=DEVICE_WITH_HFID, + expected=[["dev1", "Paris"], "InfraDevice__dev1__Paris", ["dev1", "Paris"], "dev1__Paris"], + ), + ServerHfidCase( + name="server-value-over-the-fetched-fields", + only=["name", "hfid"], + edge=node_data(DEVICE_ID, "InfraDevice", hfid=["dev1", "Paris"], name={"value": "renamed"}), + expected=[["dev1", "Paris"], "InfraDevice__dev1__Paris", ["dev1", "Paris"], "dev1__Paris"], + ), + ServerHfidCase( + name="empty-server-value", + only=["hfid"], + edge=node_data(DEVICE_ID, "InfraDevice", hfid=[]), + expected=[None, None, None, None], + ), + ServerHfidCase( + name="null-server-value", + only=["name", "hfid"], + edge=node_data(DEVICE_ID, "InfraDevice", hfid=None, name={"value": "dev1"}), + expected=[None, None, None, None], + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in SERVER_HFID_CASES]) +async def test_hfid_from_only_is_the_value_the_server_returned( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: ServerHfidCase +) -> None: + add_page(httpx_mock, "InfraDevice", [case.edge]) + + nodes = await query_nodes(only_clients, client_type, "all", kind="InfraDevice", only=case.only) + + device = nodes[0] + assert [ + device.hfid, + device.hfid_str, + device.get_human_friendly_id(), + device.get_human_friendly_id_as_string(), + ] == case.expected + + +@dataclass +class ChangedHfidFieldCase: + name: str + only: list[str] + prefetch_relationships: bool + edge: dict[str, Any] + change: Callable[[InfrahubNode | InfrahubNodeSync], None] + expected_hfid: list[str] | None + + +def rename_device(device: InfrahubNode | InfrahubNodeSync) -> None: + name = device.name + assert isinstance(name, Attribute) + name.value = "dev2" + + +def replace_site(device: InfrahubNode | InfrahubNodeSync) -> None: + device.site = "another-site-id" + + +CHANGED_HFID_FIELD_CASES = [ + ChangedHfidFieldCase( + name="name-changed-and-site-not-fetched", + only=["name", "hfid"], + prefetch_relationships=False, + edge=node_data(DEVICE_ID, "InfraDevice", hfid=["dev1", "Paris"], name={"value": "dev1"}), + change=rename_device, + expected_hfid=None, + ), + ChangedHfidFieldCase( + name="name-changed-and-site-prefetched", + only=["name", "site", "hfid"], + prefetch_relationships=True, + edge=node_data( + DEVICE_ID, + "InfraDevice", + hfid=["dev1", "Paris"], + name={"value": "dev1"}, + site=peer(SITE_ID, "InfraSite", name={"value": "Paris"}), + ), + change=rename_device, + expected_hfid=["dev2", "Paris"], + ), + ChangedHfidFieldCase( + name="site-replaced", + only=["name", "site", "hfid"], + prefetch_relationships=False, + edge=node_data( + DEVICE_ID, "InfraDevice", hfid=["dev1", "Paris"], name={"value": "dev1"}, site=peer(SITE_ID, "InfraSite") + ), + change=replace_site, + expected_hfid=None, + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in CHANGED_HFID_FIELD_CASES]) +async def test_hfid_from_only_stops_using_the_server_value_when_an_hfid_field_changes( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: ChangedHfidFieldCase +) -> None: + add_page(httpx_mock, "InfraDevice", [case.edge]) + nodes = await query_nodes( + only_clients, + client_type, + "all", + kind="InfraDevice", + only=case.only, + prefetch_relationships=case.prefetch_relationships, + ) + device = nodes[0] + assert device.hfid == ["dev1", "Paris"] + + case.change(device) + + assert [device.hfid, device.get_human_friendly_id()] == [case.expected_hfid, case.expected_hfid] + + +@dataclass +class HfidStoreCase: + name: str + populate_store: bool + expected_store_count: int + + +HFID_STORE_CASES = [ + HfidStoreCase(name="populate-store", populate_store=True, expected_store_count=1), + HfidStoreCase(name="no-store", populate_store=False, expected_store_count=0), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in HFID_STORE_CASES]) +async def test_hfid_from_only_needs_no_peer_in_the_store( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: HfidStoreCase +) -> None: + add_page(httpx_mock, "InfraDevice", [DEVICE_WITH_HFID]) + client = client_of(only_clients, client_type) + + nodes = await query_nodes( + only_clients, client_type, "all", kind="InfraDevice", only=["hfid"], populate_store=case.populate_store + ) + + assert nodes[0].hfid == ["dev1", "Paris"] + assert client.store.count() == case.expected_store_count + + +@pytest.mark.parametrize("client_type", client_types) +async def test_store_finds_node_from_only_by_hfid( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + add_page(httpx_mock, "InfraDevice", [DEVICE_WITH_HFID]) + client = client_of(only_clients, client_type) + + nodes = await query_nodes(only_clients, client_type, "all", kind="InfraDevice", only=["hfid"]) + + assert client.store.get(key=["dev1", "Paris"], kind="InfraDevice") is nodes[0] + assert client.store.get(key="InfraDevice__dev1__Paris") is nodes[0] + + +@dataclass +class CalculatedHfidCase: + name: str + only: list[str] + edge: dict[str, Any] + expected_hfid: list[str] | None + prefetch_relationships: bool = False + + +CALCULATED_HFID_CASES = [ + CalculatedHfidCase( + name="hfid-fields-not-fetched", + only=["description"], + edge=node_data(DEVICE_ID, "InfraDevice", description={"value": "core"}), + expected_hfid=None, + ), + CalculatedHfidCase( + name="hfid-fields-fetched", + only=["name", "site"], + edge=DEVICE_WITH_NAME_AND_SITE, + prefetch_relationships=True, + expected_hfid=["dev1", "Paris"], + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in CALCULATED_HFID_CASES]) +async def test_hfid_from_only_without_hfid_is_calculated_from_the_fetched_fields( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: CalculatedHfidCase +) -> None: + add_page(httpx_mock, "InfraDevice", [case.edge]) + + nodes = await query_nodes( + only_clients, + client_type, + "all", + kind="InfraDevice", + only=case.only, + prefetch_relationships=case.prefetch_relationships, + ) + + assert nodes[0].hfid == case.expected_hfid + + +@pytest.mark.parametrize("client_type", client_types) +async def test_hfid_of_each_implementing_kind_from_only( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + add_page( + httpx_mock, + "TestingAnimal", + [ + node_data(CAT_ID, "TestingCat", hfid=["Paris", "Tom"]), + node_data(DOG_ID, "TestingDog", hfid=["Carol", "Rex"]), + ], + ) + client = client_of(only_clients, client_type) + + nodes = await query_nodes(only_clients, client_type, "all", kind="TestingAnimal", only=["hfid"]) + + assert {node.id: node.hfid for node in nodes} == {CAT_ID: ["Paris", "Tom"], DOG_ID: ["Carol", "Rex"]} + assert client.store.count() == len(nodes) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_hfid_of_a_default_node_ignores_the_value_the_server_returned( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + add_page(httpx_mock, "InfraSite", [node_data(SITE_ID, "InfraSite", hfid=["from-server"], name={"value": "Paris"})]) + + nodes = await query_nodes(only_clients, client_type, "all", kind="InfraSite") + + assert [nodes[0]._only_marker, nodes[0].hfid, nodes[0].hfid_str] == [None, ["Paris"], "InfraSite__Paris"] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_node_from_only_replaces_fuller_copy_in_store( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + add_page( + httpx_mock, + "InfraDevice", + [ + node_data( + DEVICE_ID, + "InfraDevice", + name={"value": "dev1"}, + description={"value": "core"}, + site=peer(SITE_ID, "InfraSite"), + ) + ], + ) + add_page(httpx_mock, "InfraDevice", [node_data(DEVICE_ID, "InfraDevice", name={"value": "dev1"})]) + client = client_of(only_clients, client_type) + + fuller = await query_nodes(only_clients, client_type, "get", kind="InfraDevice", id=DEVICE_ID) + narrower = await query_nodes(only_clients, client_type, "get", kind="InfraDevice", id=DEVICE_ID, only=["name"]) + + assert fuller[0]._only_marker is None + stored = client.store.get(key=DEVICE_ID, kind="InfraDevice") + assert stored is narrower[0] + assert stored._only_marker == OnlyMarker(only=("name",)) + message = "InfraDevice.description was not fetched: the node was queried with only=['name']." + with pytest.raises(FieldNotLoadedError, match=re.escape(message)): + _ = stored.description.value diff --git a/tests/unit/sdk/test_client_put_delete.py b/tests/unit/sdk/test_client_put_delete.py new file mode 100644 index 000000000..5e7a45249 --- /dev/null +++ b/tests/unit/sdk/test_client_put_delete.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + +URL = "http://mock/api/example" +client_types = ["standard", "sync"] + + +def _build_password_client(client_type: str) -> InfrahubClient | InfrahubClientSync: + config = Config(address="http://mock", username="admin", password="password") + client: InfrahubClient | InfrahubClientSync = ( + InfrahubClient(config=config) if client_type == "standard" else InfrahubClientSync(config=config) + ) + client.access_token = "OLD" + client.refresh_token = "refresh-token" + client.headers["Authorization"] = "Bearer OLD" + return client + + +async def _put(client: InfrahubClient | InfrahubClientSync, payload: dict) -> None: + if isinstance(client, InfrahubClient): + await client._put(url=URL, payload=payload) + else: + client._put(url=URL, payload=payload) + + +async def _delete(client: InfrahubClient | InfrahubClientSync) -> None: + if isinstance(client, InfrahubClient): + await client._delete(url=URL) + else: + client._delete(url=URL) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_put_sends_the_payload_as_json(client_type: str, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(method="PUT", url=URL, json={}) + + await _put(_build_password_client(client_type), payload={"ratio": 0.5}) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == {"ratio": 0.5} + assert request.headers["Authorization"] == "Bearer OLD" + + +@pytest.mark.parametrize("client_type", client_types) +async def test_delete_sends_no_body(client_type: str, httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(method="DELETE", url=URL, json={}) + + await _delete(_build_password_client(client_type)) + + request = httpx_mock.get_request() + assert request is not None + assert request.content == b"" + assert request.headers["Authorization"] == "Bearer OLD" + + +@pytest.mark.parametrize("method", ["PUT", "DELETE"]) +@pytest.mark.parametrize("client_type", client_types) +async def test_expired_token_is_refreshed_and_the_request_retried( + client_type: str, method: str, httpx_mock: HTTPXMock +) -> None: + httpx_mock.add_response( + method=method, url=URL, status_code=401, json={"errors": [{"message": "Expired Signature"}]} + ) + httpx_mock.add_response(method="POST", url="http://mock/api/auth/refresh", json={"access_token": "NEW"}) + httpx_mock.add_response(method=method, url=URL, json={}) + + client = _build_password_client(client_type) + if method == "PUT": + await _put(client, payload={"ratio": 0.5}) + else: + await _delete(client) + + requests = [request for request in httpx_mock.get_requests() if str(request.url) == URL] + assert [request.headers["Authorization"] for request in requests] == ["Bearer OLD", "Bearer NEW"] diff --git a/tests/unit/sdk/test_error_catalogue.py b/tests/unit/sdk/test_error_catalogue.py new file mode 100644 index 000000000..a0c2721ef --- /dev/null +++ b/tests/unit/sdk/test_error_catalogue.py @@ -0,0 +1,899 @@ +"""The raise-time factories, exercised directly against captured response envelopes.""" + +from __future__ import annotations + +import copy +import json +import pickle # noqa: S403 +from dataclasses import dataclass +from io import BytesIO +from typing import TYPE_CHECKING, Any + +import httpx +import pytest + +from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync +from infrahub_sdk.exceptions import ( + AttributeConstraintViolationError, + AttributeInvalidTypeError, + AttributeRequiredError, + AuthenticationError, + BranchAlreadyMergedError, + BranchNeedsRebaseError, + BranchNotFoundError, + GraphQLError, + MergeInProgressError, + MergeRecoveryRequiredError, + NodeNotFoundError, + SchemaNotFoundError, + UndefinedError, + UniquenessViolationError, + WorkerTimeoutError, + authentication_error_from_response, + graphql_error_from_response, +) +from infrahub_sdk.exceptions.catalogue import CODE_TO_DATA_MODEL +from tests.helpers.fixtures import read_fixture + +if TYPE_CHECKING: + from collections.abc import Callable + + from pytest_httpx import HTTPXMock + + from tests.unit.sdk.conftest import BothClients + +FIXTURE_SUBDIR = "error_catalogue" +CODES_FIXTURE_SUBDIR = f"{FIXTURE_SUBDIR}/codes" + +# The three codes that reach the SDK on either transport, so neither transport's class can be theirs. +CODES_WITHOUT_A_CLASS = {"AUTHENTICATION_REQUIRED", "PERMISSION_DENIED", "TOKEN_EXPIRED"} + + +def load_envelope(name: str) -> dict[str, Any]: + return json.loads(read_fixture(file_name=name, fixture_subdir=FIXTURE_SUBDIR)) + + +def load_code_envelope(code: str) -> dict[str, Any]: + """The captured response for one catalogue code. + + Addressed by code rather than by listing the directory, so a code whose fixture is missing fails + here instead of quietly dropping out of the parametrisation that is supposed to be exhaustive. + """ + return json.loads(read_fixture(file_name=f"{code.lower()}.json", fixture_subdir=CODES_FIXTURE_SUBDIR)) + + +def auth_response(envelope: dict[str, Any] | str, status_code: int = 401) -> httpx.Response: + """Build the httpx response the SDK would have observed for a rejected request. + + The request is attached because `decode_json` reports the URL it failed on, and would raise + looking for it rather than producing the JsonDecodeError the factory is meant to absorb. + """ + request = httpx.Request("POST", "http://mock/graphql/main") + if isinstance(envelope, str): + return httpx.Response(status_code=status_code, text=envelope, request=request) + return httpx.Response(status_code=status_code, json=envelope, request=request) + + +class TestGraphQLFactory: + def test_reads_code_and_status_off_the_first_error(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }", variables={"a": 1}) + + assert isinstance(exc, GraphQLError) + assert exc.code == "UNIQUENESS_VIOLATION" + assert exc.http_status == 422 + assert exc.extensions is not None + assert exc.extensions["data"] == {"node_kind": "TestPerson", "fields": ["name"]} + + def test_retains_the_complete_error_list_unreordered(self) -> None: + envelope = load_envelope("graphql_multiple_errors.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert [error["message"] for error in exc.errors] == ["first failure", "second failure", "third failure"] + assert exc.code == "SCHEMA_NOT_FOUND", "the first error governs, not the most specific one" + + def test_errors_is_a_sequence_of_dicts(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert isinstance(exc.errors, list) + assert all(isinstance(error, dict) for error in exc.errors) + + def test_query_and_variables_are_retained(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }", variables={"a": 1}) + + assert exc.query == "query { x }" + assert exc.variables == {"a": 1} + + +@dataclass +class CodeCase: + name: str + expected_class: type[GraphQLError] + expected_http_status: int + expected_attributes: dict[str, Any] + + +CODE_CASES = [ + CodeCase( + name="ATTRIBUTE_CONSTRAINT_VIOLATION", + expected_class=AttributeConstraintViolationError, + expected_http_status=422, + expected_attributes={ + "node_kind": "TestPerson", + "field_name": "name", + "constraint": "regex", + "detail": "^[A-Z]", + }, + ), + CodeCase( + name="ATTRIBUTE_INVALID_TYPE", + expected_class=AttributeInvalidTypeError, + expected_http_status=422, + expected_attributes={ + "node_kind": "TestPerson", + "field_name": "height", + "expected_type": "Integer", + "received_type": "String", + }, + ), + CodeCase( + name="ATTRIBUTE_REQUIRED", + expected_class=AttributeRequiredError, + expected_http_status=422, + expected_attributes={"node_kind": "TestPerson", "field_name": "name"}, + ), + CodeCase( + name="AUTHENTICATION_REQUIRED", + expected_class=GraphQLError, + expected_http_status=401, + expected_attributes={}, + ), + CodeCase( + name="BRANCH_ALREADY_MERGED", + expected_class=BranchAlreadyMergedError, + expected_http_status=400, + expected_attributes={"branch_name": "feature-a"}, + ), + CodeCase( + name="BRANCH_NEEDS_REBASE", + expected_class=BranchNeedsRebaseError, + expected_http_status=400, + expected_attributes={"branch_name": "feature-a"}, + ), + CodeCase( + name="BRANCH_NOT_FOUND", + expected_class=BranchNotFoundError, + expected_http_status=400, + expected_attributes={"identifier": "does-not-exist"}, + ), + CodeCase( + name="MERGE_IN_PROGRESS", + expected_class=MergeInProgressError, + expected_http_status=423, + expected_attributes={"branch_name": "main", "merging_branch": "feature-a"}, + ), + CodeCase( + name="MERGE_RECOVERY_REQUIRED", + expected_class=MergeRecoveryRequiredError, + expected_http_status=423, + expected_attributes={"branch_name": "main", "merging_branch": "feature-a"}, + ), + CodeCase( + name="NODE_NOT_FOUND", + expected_class=NodeNotFoundError, + expected_http_status=404, + expected_attributes={"node_type": "TestPerson", "identifier": "john"}, + ), + CodeCase( + name="PERMISSION_DENIED", + expected_class=GraphQLError, + expected_http_status=403, + expected_attributes={}, + ), + CodeCase( + name="SCHEMA_NOT_FOUND", + expected_class=SchemaNotFoundError, + expected_http_status=422, + expected_attributes={"identifier": "TestWidget"}, + ), + CodeCase( + name="TOKEN_EXPIRED", + expected_class=GraphQLError, + expected_http_status=401, + expected_attributes={}, + ), + CodeCase( + name="UNDEFINED_ERROR", + expected_class=UndefinedError, + expected_http_status=500, + expected_attributes={}, + ), + CodeCase( + name="UNIQUENESS_VIOLATION", + expected_class=UniquenessViolationError, + expected_http_status=422, + expected_attributes={"node_kind": "TestPerson", "fields": ["name"]}, + ), + CodeCase( + name="WORKER_TIMEOUT", + expected_class=WorkerTimeoutError, + expected_http_status=504, + expected_attributes={ + "operation": "git.repository.connectivity", + "timeout_seconds": 30, + "retry_after_seconds": 30, + }, + ), +] + + +class TestEveryCatalogueCode: + """One case per code, reading the raised class and its attributes and never a message.""" + + def test_the_cases_cover_every_code_the_bindings_carry(self) -> None: + """Exhaustive only if it is checked against the bindings rather than maintained by hand.""" + assert {case.name for case in CODE_CASES} == set(CODE_TO_DATA_MODEL) + + def test_the_codes_with_no_class_of_their_own_are_the_authentication_ones(self) -> None: + """The one asymmetry in the hierarchy, and the reason the fallback follows the transport.""" + assert {case.name for case in CODE_CASES if case.expected_class is GraphQLError} == CODES_WITHOUT_A_CLASS + + @pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in CODE_CASES]) + def test_the_code_raises_its_class_with_its_payload_promoted(self, case: CodeCase) -> None: + envelope = load_code_envelope(case.name) + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }") + + assert type(exc) is case.expected_class + assert exc.code == case.name + assert exc.http_status == case.expected_http_status + for attribute, value in case.expected_attributes.items(): + assert getattr(exc, attribute) == value + + @pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in CODE_CASES]) + def test_the_raw_payload_stays_available_for_forwarding(self, case: CodeCase) -> None: + envelope = load_code_envelope(case.name) + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert exc.extensions is not None + assert exc.extensions["data"] == envelope["errors"][0]["extensions"]["data"] + + +ROUND_TRIPS = { + "pickle": lambda exc: pickle.loads(pickle.dumps(exc)), # noqa: S301 + "deepcopy": copy.deepcopy, +} + + +class TestARaisedExceptionSurvivesSerialisation: + """A raised exception crosses process boundaries: a task queue, a parallel test runner. + + The catalogued classes take their payload fields as required keyword arguments, which is the + shape that lets a caller read one without a guard - and the shape the default reconstruction + cannot replay, since it calls the class with the message as a lone positional argument. + """ + + @pytest.mark.parametrize("round_trip", [pytest.param(fn, id=name) for name, fn in ROUND_TRIPS.items()]) + @pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in CODE_CASES]) + def test_the_whole_exception_comes_back(self, case: CodeCase, round_trip: Callable[[Any], Any]) -> None: + envelope = load_code_envelope(case.name) + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }", variables={"a": 1}) + + restored = round_trip(exc) + + assert type(restored) is case.expected_class + assert str(restored) == str(exc) + assert restored.code == exc.code + assert restored.http_status == exc.http_status + assert restored.errors == exc.errors + assert restored.extensions == exc.extensions + assert restored.query == exc.query + assert restored.variables == exc.variables + for attribute, value in case.expected_attributes.items(): + assert getattr(restored, attribute) == value + + def test_a_restored_exception_is_still_caught_by_its_own_clause(self) -> None: + """Reconstructing the type is only worth anything if the `except` ladder still sees it.""" + envelope = load_code_envelope("UNIQUENESS_VIOLATION") + exc = graphql_error_from_response(errors=envelope["errors"]) + restored = pickle.loads(pickle.dumps(exc)) # noqa: S301 + + with pytest.raises(UniquenessViolationError, match="UNIQUENESS_VIOLATION") as exc_info: + raise restored + + assert exc_info.value.node_kind == "TestPerson" + assert exc_info.value.fields == ["name"] + + +class TestTheAdoptedClasses: + """The three classes that predate the catalogue, now reachable from a server-reported failure. + + That a server-reported one reaches the class with its payload promoted is covered per code by + the table above. What is only true of these three is the other direction: they are also raised + with no code behind them, and `code` is what tells a caller which they are holding. + """ + + def test_a_client_side_raise_of_the_same_class_carries_no_code(self) -> None: + """`exc.code is not None` is the test for which of the two a caller is holding.""" + assert NodeNotFoundError(identifier="john", node_type="TestPerson").code is None + assert BranchNotFoundError(identifier="does-not-exist").code is None + assert SchemaNotFoundError(identifier="TestWidget").code is None + + +class TestTheFirstErrorGoverns: + def test_a_silent_first_error_governs_over_a_later_coded_one(self) -> None: + """Otherwise a response's class would depend on which error the SDK happens to recognise.""" + errors = [ + {"message": "the failure that came first"}, + { + "message": "and one the catalogue describes", + "extensions": { + "code": "UNIQUENESS_VIOLATION", + "http_status": 422, + "data": {"node_kind": "TestPerson", "fields": ["name"]}, + }, + }, + ] + + exc = graphql_error_from_response(errors=errors, query="mutation { TestPersonCreate }") + + assert type(exc) is GraphQLError + assert exc.code is None + assert exc.http_status is None + + def test_the_complete_list_is_retained_unreordered(self) -> None: + errors = [ + {"message": "the failure that came first"}, + { + "message": "and one the catalogue describes", + "extensions": { + "code": "UNIQUENESS_VIOLATION", + "http_status": 422, + "data": {"node_kind": "TestPerson", "fields": ["name"]}, + }, + }, + ] + + exc = graphql_error_from_response(errors=errors) + + assert [error["message"] for error in exc.errors] == [ + "the failure that came first", + "and one the catalogue describes", + ] + + +class TestTheDeclaredStatus: + """Where `http_status` comes from when the envelope and the class disagree, or one is silent. + + Every captured envelope declares the status its code's class already carries, so these drive the + shapes that tell the two sources apart. + """ + + def test_a_generated_class_keeps_its_catalogue_status_when_the_envelope_omits_one(self) -> None: + envelope = load_code_envelope("UNIQUENESS_VIOLATION") + del envelope["errors"][0]["extensions"]["http_status"] + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert isinstance(exc, UniquenessViolationError) + assert exc.http_status == 422, "the class declares the catalogue's status, so there is one to keep" + + def test_the_envelope_status_wins_over_the_one_the_class_declares(self) -> None: + """The server substitutes its own where its catalogue could not resolve a specific status.""" + envelope = load_code_envelope("UNIQUENESS_VIOLATION") + envelope["errors"][0]["extensions"]["http_status"] = 409 + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert isinstance(exc, UniquenessViolationError) + assert exc.http_status == 409 + + def test_an_adopted_class_has_no_status_of_its_own_to_fall_back_on(self) -> None: + """The three pre-catalogue classes declare none, because they are also raised without one.""" + envelope = load_code_envelope("NODE_NOT_FOUND") + del envelope["errors"][0]["extensions"]["http_status"] + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert isinstance(exc, NodeNotFoundError) + assert exc.http_status is None + + def test_the_generic_class_reports_no_status_where_the_envelope_declared_none(self) -> None: + exc = graphql_error_from_response(errors=[{"message": "boom", "extensions": {"code": "NOT_A_KNOWN_CODE"}}]) + + assert type(exc) is GraphQLError + assert exc.http_status is None + + +class TestTheFallbackFollowsTheObservedTransport: + """Which generic class a code falls back to is decided by how the SDK saw it arrive. + + Following the status the catalogue declares instead would send the three authentication codes to + `AuthenticationError` whenever a resolver raised them inside an HTTP 200, out of reach of the + `except GraphQLError` clause that catches them today - and send a 401 carrying a data code to a + class no caller of that path expects. + """ + + @pytest.mark.parametrize("code", sorted(CODES_WITHOUT_A_CLASS)) + def test_an_authentication_code_inside_a_graphql_response_raises_the_graphql_class(self, code: str) -> None: + envelope = load_code_envelope(code) + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert type(exc) is GraphQLError, "the declared 401 or 403 is metadata, not the transport" + assert not isinstance(exc, AuthenticationError) + assert exc.code == code + + def test_a_data_code_on_a_real_401_raises_the_authentication_class(self) -> None: + response = auth_response(envelope=load_code_envelope("NODE_NOT_FOUND")) + + exc = authentication_error_from_response(response=response) + + assert type(exc) is AuthenticationError, "a class the caller of this path cannot expect is worse than none" + assert exc.code == "NODE_NOT_FOUND" + assert exc.http_status == 404, "the declared status is still readable, it just governs nothing" + + +class TestAuthenticationFactory: + def test_reads_code_and_status_off_a_real_401(self) -> None: + response = auth_response(envelope=load_envelope("auth_token_expired.json")) + + exc = authentication_error_from_response(response=response) + + assert isinstance(exc, AuthenticationError) + assert exc.code == "TOKEN_EXPIRED" + assert exc.http_status == 401 + + def test_errors_is_a_sequence_of_dicts(self) -> None: + response = auth_response(envelope=load_envelope("auth_token_expired.json")) + + exc = authentication_error_from_response(response=response) + + assert isinstance(exc.errors, list) + assert all(isinstance(error, dict) for error in exc.errors) + + def test_only_the_governing_message_is_named_beside_the_code(self) -> None: + response = auth_response(envelope=load_envelope("auth_two_messages.json")) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "AUTHENTICATION_REQUIRED: first problem", ( + "joining the rest would file them under a code that is not theirs, the same rule the GraphQL path follows" + ) + assert [error["message"] for error in exc.errors] == ["first problem", "second problem"], ( + "naming one message must not discard the rest, which stay on the exception" + ) + + def test_an_undescribed_code_keeps_the_joined_messages(self) -> None: + """`UNDEFINED_ERROR` names nothing, so it must not displace the reasons the server gave.""" + response = auth_response( + envelope={ + "errors": [ + {"message": "first problem", "extensions": {"code": "UNDEFINED_ERROR"}}, + {"message": "second problem"}, + ] + } + ) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "first problem | second problem" + assert exc.code == "UNDEFINED_ERROR", "the code is still readable, it is just not the headline" + + def test_rest_integer_code_is_never_a_catalogue_code(self) -> None: + response = auth_response(envelope=load_envelope("rest_legacy_401.json")) + + exc = authentication_error_from_response(response=response) + + assert exc.code is None + assert exc.extensions == {"code": 401} + + def test_empty_error_list_falls_back_to_the_status(self) -> None: + response = auth_response(envelope={"errors": []}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "HTTP 401", "an empty join must not discard the status" + + def test_a_rest_detail_string_is_used_when_there_is_no_error_array(self) -> None: + """The REST API rejects an unauthenticated request with a bare `detail` and no envelope.""" + response = auth_response(envelope={"detail": "Not authenticated"}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "Not authenticated" + assert exc.errors == [] + + def test_a_server_message_wins_over_a_detail_string(self) -> None: + response = auth_response(envelope={"detail": "Not authenticated", "errors": [{"message": "real reason"}]}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "real reason" + + def test_an_object_body_carrying_nothing_readable_falls_back_to_the_status(self) -> None: + response = auth_response(envelope={"unexpected": "shape"}, status_code=403) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "HTTP 403" + + def test_a_403_is_parsed_the_same_way_as_a_401(self) -> None: + response = auth_response(envelope=load_envelope("auth_two_messages.json"), status_code=403) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "AUTHENTICATION_REQUIRED: first problem" + + def test_a_non_auth_status_falls_back_to_that_status(self) -> None: + """A failed token refresh reaches this factory on whatever status the server sent.""" + response = auth_response(envelope="502 Bad Gateway", status_code=502) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "HTTP 502" + + +class TestErrorsAlwaysASequenceOfDicts: + """`ApiError.errors` is declared `Sequence[dict[str, Any]]`, so nothing else may reach it.""" + + @pytest.mark.malformed + def test_graphql_non_list_errors_becomes_an_empty_list(self) -> None: + exc = graphql_error_from_response(errors="a bare string where an array belongs") + + assert exc.errors == [] + + @pytest.mark.malformed + def test_auth_non_list_errors_becomes_an_empty_list(self) -> None: + response = auth_response(envelope={"errors": "a bare string where an array belongs"}) + + exc = authentication_error_from_response(response=response) + + assert exc.errors == [] + + @pytest.mark.malformed + def test_auth_errors_as_an_object_becomes_an_empty_list(self) -> None: + response = auth_response(envelope={"errors": {"message": "an object where an array belongs"}}) + + exc = authentication_error_from_response(response=response) + + assert exc.errors == [] + + @pytest.mark.malformed + def test_non_dict_entries_are_dropped(self) -> None: + exc = graphql_error_from_response(errors=[{"message": "real"}, "stray", None, 7]) + + assert exc.errors == [{"message": "real"}] + + +class TestGuardsAgainstShapesThatLookRight: + @pytest.mark.malformed + def test_a_boolean_is_not_an_http_status(self) -> None: + """`bool` is an `int` subclass, so a JSON `true` would otherwise pass the status check.""" + exc = graphql_error_from_response(errors=[{"extensions": {"code": "X", "http_status": True}}]) + + assert exc.http_status is None + + @pytest.mark.malformed + def test_extensions_that_are_not_an_object_yield_no_extensions(self) -> None: + envelope = load_envelope("malformed_extensions_as_list.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert exc.extensions is None, "a list of extensions is not an extensions object" + assert exc.code is None + + @pytest.mark.malformed + def test_a_non_string_message_is_left_out_of_the_join(self) -> None: + response = auth_response(envelope={"errors": [{"message": "real"}, {"message": None}, {"message": 7}]}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "real" + + @pytest.mark.malformed + def test_a_leading_non_dict_error_does_not_govern(self) -> None: + exc = graphql_error_from_response(errors=["stray", {"extensions": {"code": "UNIQUENESS_VIOLATION"}}]) + + assert exc.code is None, "the first entry governs even when it carries nothing readable" + + +@dataclass +class CrossVersionCase: + name: str + fixture: str + expected_code: str | None + expected_http_status: int | None = None + expected_class: type[GraphQLError] = GraphQLError + + +CROSS_VERSION_CASES = [ + CrossVersionCase( + name="unknown-code-from-a-newer-server", + fixture="graphql_unknown_code.json", + expected_code="SOMETHING_WE_HAVE_NEVER_HEARD_OF", + expected_http_status=418, + ), + CrossVersionCase( + name="known-code-with-an-extra-payload-field", + fixture="graphql_extra_payload_field.json", + expected_code="UNIQUENESS_VIOLATION", + expected_http_status=422, + expected_class=UniquenessViolationError, + ), + CrossVersionCase( + name="error-carrying-no-extensions", + fixture="graphql_no_extensions.json", + expected_code=None, + ), + CrossVersionCase( + name="pre-catalogue-integer-code", + fixture="graphql_integer_code.json", + expected_code=None, + ), +] + + +@pytest.mark.crossversion +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in CROSS_VERSION_CASES]) +def test_a_cross_version_envelope_parses_without_raising(case: CrossVersionCase) -> None: + """Any SDK version talks to any server version, and parsing never raises. + + A code these bindings have a class for still reaches it here: gaining a field it has never heard + of changes nothing, which is the forward compatibility the payload models are for. + """ + envelope = load_envelope(case.fixture) + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }") + + assert type(exc) is case.expected_class + assert exc.code == case.expected_code + assert exc.http_status == case.expected_http_status + + +@pytest.mark.crossversion +def test_an_unknown_payload_field_is_passed_through_untouched() -> None: + """Forward compatibility is only real if the payload we cannot interpret still reaches the caller.""" + envelope = load_envelope("graphql_extra_payload_field.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert exc.extensions is not None + assert exc.extensions["data"]["introduced_in_a_later_version"] == "ignored" + + +@dataclass +class MalformedCase: + name: str + fixture: str + + +MALFORMED_CASES = [ + MalformedCase(name="errors-as-a-bare-string", fixture="malformed_errors_as_string.json"), + MalformedCase(name="extensions-as-a-list", fixture="malformed_extensions_as_list.json"), + MalformedCase(name="code-as-a-nested-object", fixture="malformed_code_as_object.json"), +] + + +@pytest.mark.malformed +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in MALFORMED_CASES]) +def test_malformed_envelope_degrades_to_the_generic_exception(case: MalformedCase) -> None: + """A shape the parser does not recognise must not become an SDK TypeError.""" + envelope = load_envelope(case.fixture) + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }") + + assert type(exc) is GraphQLError + assert exc.code is None + + +@pytest.mark.malformed +def test_malformed_envelope_keeps_the_payload_in_the_message() -> None: + """The server's failure must survive verbatim, not be replaced by the SDK's own.""" + envelope = load_envelope("malformed_errors_as_string.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query="query { x }") + + assert exc.message == f"An error occurred while executing the GraphQL Query query {{ x }}, {envelope['errors']}" + + +@dataclass +class NonJsonBodyCase: + name: str + text: str + + +NON_JSON_BODY_CASES = [ + NonJsonBodyCase(name="html-proxy-page", text="502 Bad Gateway"), + NonJsonBodyCase(name="empty-body", text=""), +] + + +@pytest.mark.malformed +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in NON_JSON_BODY_CASES]) +def test_authentication_factory_tolerates_a_non_json_body(case: NonJsonBodyCase) -> None: + response = auth_response(envelope=case.text) + + exc = authentication_error_from_response(response=response) + + assert isinstance(exc, AuthenticationError) + assert exc.message == "HTTP 401" + assert exc.code is None + assert exc.errors == [] + + +class TestRaisedThroughTheClient: + """The factories are only worth having if the envelope survives all the way to the caller.""" + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + async def test_graphql_error_carries_the_envelope( + self, client_type: str, clients: BothClients, httpx_mock: HTTPXMock + ) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + httpx_mock.add_response(method="POST", status_code=200, json={"data": None, "errors": envelope["errors"]}) + client = getattr(clients, client_type) + query = "query { TestPerson { edges { node { name { value }}}}}" + + with pytest.raises(GraphQLError, match="already has name") as exc_info: + if client_type == "standard": + await client.execute_graphql(query=query) + else: + client.execute_graphql(query=query) + + assert exc_info.value.code == "UNIQUENESS_VIOLATION" + assert exc_info.value.http_status == 422 + assert exc_info.value.query == query + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + async def test_authentication_error_carries_the_envelope( + self, client_type: str, clients: BothClients, httpx_mock: HTTPXMock + ) -> None: + httpx_mock.add_response(method="POST", status_code=403, json=load_envelope("auth_two_messages.json")) + client = getattr(clients, client_type) + + with pytest.raises(AuthenticationError, match="AUTHENTICATION_REQUIRED: first problem") as exc_info: + if client_type == "standard": + await client.execute_graphql(query="query { TestPerson { edges { node { id }}}}") + else: + client.execute_graphql(query="query { TestPerson { edges { node { id }}}}") + + assert exc_info.value.code == "AUTHENTICATION_REQUIRED" + assert exc_info.value.http_status == 401, ( + "http_status is the status the envelope declares, not the one the transport observed" + ) + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + @pytest.mark.parametrize("status_code", [401, 403]) + async def test_a_rejected_file_upload_carries_the_envelope( + self, client_type: str, status_code: int, clients: BothClients, httpx_mock: HTTPXMock + ) -> None: + """The multipart path observes 401 and 403 too, so it owes the same exception as every other.""" + httpx_mock.add_response( + method="POST", + status_code=status_code, + json={"errors": [{"message": "no upload rights", "extensions": {"code": "PERMISSION_DENIED"}}]}, + ) + client = getattr(clients, client_type) + query = "mutation ($file: Upload!) { CoreFileUpload(data: {file: $file}) { ok }}" + + with pytest.raises(AuthenticationError, match="no upload rights") as exc_info: + if client_type == "standard": + await client._execute_graphql_with_file(query=query, file_content=BytesIO(b"x"), file_name="f.txt") + else: + client._execute_graphql_with_file(query=query, file_content=BytesIO(b"x"), file_name="f.txt") + + assert exc_info.value.code == "PERMISSION_DENIED" + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + async def test_a_file_upload_rejected_for_another_reason_still_raises_the_status_error( + self, client_type: str, clients: BothClients, httpx_mock: HTTPXMock + ) -> None: + """Only 401 and 403 are converted; every other status keeps reaching the caller as it did.""" + httpx_mock.add_response(method="POST", status_code=500, json={"errors": [{"message": "boom"}]}) + client = getattr(clients, client_type) + query = "mutation ($file: Upload!) { CoreFileUpload(data: {file: $file}) { ok }}" + + with pytest.raises(httpx.HTTPStatusError): + if client_type == "standard": + await client._execute_graphql_with_file(query=query, file_content=BytesIO(b"x"), file_name="f.txt") + else: + client._execute_graphql_with_file(query=query, file_content=BytesIO(b"x"), file_name="f.txt") + + @pytest.mark.parametrize("client_type", ["standard", "sync"]) + async def test_a_failed_token_refresh_surfaces_as_an_authentication_error( + self, client_type: str, httpx_mock: HTTPXMock + ) -> None: + """A refresh that fails on any status other than 401 still reaches the caller as an auth failure.""" + httpx_mock.add_response( + method="POST", + url="http://mock/api/auth/refresh", + status_code=503, + json={"errors": [{"message": "the auth backend is down"}]}, + ) + 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) + ) + client.refresh_token = "refresh-token" + + with pytest.raises(AuthenticationError, match="the auth backend is down") as exc_info: + if isinstance(client, InfrahubClient): + await client.login(refresh=True) + else: + client.login(refresh=True) + + assert exc_info.value.http_status is None, "the server sent no catalogue status on this path" + + +@pytest.mark.crossversion +def test_cross_version_fallback_is_logged_at_debug_level(caplog: pytest.LogCaptureFixture) -> None: + """A fallback must be diagnosable in the field, not only in a test.""" + envelope = load_envelope("graphql_integer_code.json") + + with caplog.at_level("DEBUG", logger="infrahub_sdk"): + graphql_error_from_response(errors=envelope["errors"]) + + assert any("No catalogue code resolved" in record.getMessage() for record in caplog.records) + + +@dataclass +class FallbackLogCase: + name: str + errors: list[dict[str, Any]] + expected_fragment: str + + +FALLBACK_LOG_CASES = [ + FallbackLogCase( + name="a-code-these-bindings-have-no-class-for", + errors=[{"message": "boom", "extensions": {"code": "SOMETHING_WE_HAVE_NEVER_HEARD_OF", "http_status": 418}}], + expected_fragment="have no class for the catalogue code SOMETHING_WE_HAVE_NEVER_HEARD_OF", + ), + FallbackLogCase( + name="an-error-carrying-no-extensions", + errors=[{"message": "boom"}], + expected_fragment="No catalogue code resolved", + ), + FallbackLogCase( + name="a-payload-the-catalogue-does-not-declare", + errors=[{"message": "boom", "extensions": {"code": "UNIQUENESS_VIOLATION", "data": {"fields": "not-a-list"}}}], + expected_fragment="does not match what the catalogue declares", + ), +] + + +@pytest.mark.crossversion +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in FALLBACK_LOG_CASES]) +def test_every_fallback_is_logged_at_debug_level(case: FallbackLogCase, caplog: pytest.LogCaptureFixture) -> None: + """An SDK meeting a newer server has to be diagnosable from a log, not only from a debugger.""" + with caplog.at_level("DEBUG", logger="infrahub_sdk"): + graphql_error_from_response(errors=case.errors) + + assert any(case.expected_fragment in record.getMessage() for record in caplog.records) + + +def test_a_resolved_code_logs_nothing(caplog: pytest.LogCaptureFixture) -> None: + """The log marks the paths that degraded, so the ordinary one must stay quiet.""" + envelope = load_code_envelope("UNIQUENESS_VIOLATION") + + with caplog.at_level("DEBUG", logger="infrahub_sdk"): + graphql_error_from_response(errors=envelope["errors"]) + + assert caplog.records == [] + + +@pytest.mark.malformed +def test_unparseable_authentication_body_is_logged_at_debug_level(caplog: pytest.LogCaptureFixture) -> None: + response = auth_response(envelope="502") + + with caplog.at_level("DEBUG", logger="infrahub_sdk"): + authentication_error_from_response(response=response) + + assert any("could not be parsed" in record.getMessage() for record in caplog.records) diff --git a/tests/unit/sdk/test_exceptions.py b/tests/unit/sdk/test_exceptions.py new file mode 100644 index 000000000..d03fa29cd --- /dev/null +++ b/tests/unit/sdk/test_exceptions.py @@ -0,0 +1,613 @@ +"""The shape of the hierarchy, and what each `except` clause catches once it is a tree.""" + +from __future__ import annotations + +import copy +import json +import pickle # noqa: S403 +import re +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import httpx +import pytest + +from infrahub_sdk import exceptions +from infrahub_sdk.exceptions import ( + ApiError, + AuthenticationError, + BranchNotFoundError, + Error, + FieldNotFoundError, + FieldNotLoadedError, + FieldSelectionConflictError, + GraphQLError, + NodeInvalidError, + NodeNotFoundError, + SchemaNotFoundError, + authentication_error_from_response, + graphql_error_from_response, +) +from tests.helpers.fixtures import read_fixture + +FIXTURE_SUBDIR = "error_catalogue" + + +def load_envelope(name: str) -> dict[str, Any]: + return json.loads(read_fixture(file_name=name, fixture_subdir=FIXTURE_SUBDIR)) + + +def auth_response(envelope: dict[str, Any], status_code: int = 401) -> httpx.Response: + """The httpx response the SDK would have observed for a rejected request.""" + request = httpx.Request("POST", "http://mock/graphql/main") + return httpx.Response(status_code=status_code, json=envelope, request=request) + + +@dataclass +class NodeNotFoundData: + """Stands in for the generated payload model, which the SDK does not carry yet.""" + + node_kind: str + identifier: str + + +@dataclass +class BranchNotFoundData: + branch_name: str + + +@dataclass +class SchemaNotFoundData: + kind: str + + +@dataclass +class UnifiedClassCase: + """One of the three classes re-rooted under `GraphQLError`.""" + + name: str + exception_class: type[GraphQLError] + code: str + build: Callable[[], GraphQLError] + expected_message: str + + +UNIFIED_CLASS_CASES = [ + UnifiedClassCase( + name="node", + exception_class=NodeNotFoundError, + code="NODE_NOT_FOUND", + build=lambda: NodeNotFoundError(identifier={"name": ["john"]}), + expected_message="Unable to find the node in the database.", + ), + UnifiedClassCase( + name="branch", + exception_class=BranchNotFoundError, + code="BRANCH_NOT_FOUND", + build=lambda: BranchNotFoundError(identifier="dev"), + expected_message="Unable to find the branch 'dev' in the Database.", + ), + UnifiedClassCase( + name="schema", + exception_class=SchemaNotFoundError, + code="SCHEMA_NOT_FOUND", + build=lambda: SchemaNotFoundError(identifier="TestPerson"), + expected_message="Unable to find the schema 'TestPerson'.", + ), +] + +UNIFIED_CLASS_PARAMS = [pytest.param(case, id=case.name) for case in UNIFIED_CLASS_CASES] + + +def exported_classes() -> list[type[BaseException]]: + return [ + value + for name, value in vars(exceptions).items() + if not name.startswith("_") and isinstance(value, type) and issubclass(value, BaseException) + ] + + +class TestHierarchyIsATree: + def test_no_class_has_more_than_one_parent(self) -> None: + """Single inheritance is what keeps `except` ordering predictable for every consumer.""" + multiply_inherited = sorted(cls.__name__ for cls in exported_classes() if len(cls.__bases__) > 1) + + assert multiply_inherited == [] + + def test_graphql_and_authentication_are_siblings_under_api_error(self) -> None: + assert GraphQLError.__bases__ == (ApiError,) + assert AuthenticationError.__bases__ == (ApiError,) + assert not issubclass(GraphQLError, AuthenticationError) + assert not issubclass(AuthenticationError, GraphQLError) + + def test_api_error_is_an_infrahub_error(self) -> None: + assert issubclass(ApiError, Error) + + def test_node_invalid_error_inherits_the_re_rooting(self) -> None: + """`NodeInvalidError` is re-rooted through its parent rather than by its own declaration.""" + exc = NodeInvalidError(identifier={"name": ["john"]}, node_type="TestPerson") + + assert isinstance(exc, GraphQLError) + assert isinstance(exc, NodeNotFoundError) + + @pytest.mark.parametrize("case", UNIFIED_CLASS_PARAMS) + def test_the_unified_classes_sit_under_graphql_error(self, case: UnifiedClassCase) -> None: + assert case.exception_class.__bases__ == (GraphQLError,) + + def test_one_clause_catches_both_transports(self) -> None: + """`except ApiError` is the clause that spans a failure however the server reported it.""" + envelope = load_envelope("graphql_uniqueness_violation.json") + graphql_failure = graphql_error_from_response(errors=envelope["errors"]) + auth_failure = authentication_error_from_response( + response=auth_response(envelope=load_envelope("auth_token_expired.json")) + ) + + assert isinstance(graphql_failure, ApiError) + assert isinstance(auth_failure, ApiError) + + +class TestCatchingAcrossTransports: + def test_a_real_401_is_caught_as_an_authentication_error(self) -> None: + response = auth_response(envelope=load_envelope("auth_token_expired.json")) + exc = authentication_error_from_response(response=response) + + with pytest.raises(AuthenticationError, match="TOKEN_EXPIRED") as exc_info: + raise exc + + assert exc_info.value.code == "TOKEN_EXPIRED" + + def test_the_same_code_inside_a_200_body_is_caught_as_a_graphql_error(self) -> None: + """A resolver-raised auth failure arrives on the GraphQL path and keeps that class.""" + envelope = load_envelope("graphql_permission_denied.json") + exc = graphql_error_from_response(errors=envelope["errors"], query="mutation { x }") + + with pytest.raises(GraphQLError, match="PERMISSION_DENIED") as exc_info: + raise exc + + assert exc_info.value.code == "PERMISSION_DENIED" + assert not isinstance(exc_info.value, AuthenticationError), ( + "the transport the SDK observed decides the class, never the code's declared status" + ) + + +class TestTheEnvelopeIsReadableOnAClientSideRaise: + def test_envelope_attributes_exist_on_a_client_side_node_not_found(self) -> None: + """Re-rooting is only safe if `except GraphQLError` can read the envelope unconditionally.""" + exc = NodeNotFoundError(identifier={"name": ["john"]}, node_type="TestPerson") + + assert exc.errors == [] + assert exc.query is None + assert exc.variables is None + assert exc.code is None, "no server reported this, so there is no catalogue code" + + def test_errors_is_a_list_rather_than_the_base_default(self) -> None: + """The base's tuple is a can't-crash floor; a `GraphQLError` documents `errors` as a list.""" + exc = NodeNotFoundError(identifier="missing-file.py") + + assert isinstance(exc.errors, list) + + @pytest.mark.parametrize("case", UNIFIED_CLASS_PARAMS) + def test_every_unified_class_sets_its_envelope_through_the_constructor(self, case: UnifiedClassCase) -> None: + exc = case.build() + + assert exc.errors == [] + assert isinstance(exc.errors, list) + + def test_the_request_attributes_are_readable_on_an_authentication_failure(self) -> None: + """`except ApiError` is the clause that spans both transports, so it may read all of these. + + An authentication failure is observed at the transport, which has no query to record, so the + attributes are empty rather than absent. + """ + exc = AuthenticationError("boom") + + assert exc.query is None + assert exc.variables is None + assert exc.errors == () + + @pytest.mark.parametrize("attribute", ["code", "http_status", "extensions", "errors", "query", "variables"]) + def test_every_envelope_attribute_is_declared_on_the_shared_base(self, attribute: str) -> None: + """Declared on `ApiError` itself, so neither transport's class can drift out of step.""" + assert hasattr(ApiError, attribute) + + +class TestTheBroadening: + def test_except_graphql_error_now_catches_a_client_side_lookup_miss(self) -> None: + # An accepted behaviour change, asserted rather than worked around so that reverting the + # re-rooting fails here rather than silently narrowing what the clause catches. + with pytest.raises(GraphQLError, match="Unable to find the node in the database"): + raise NodeNotFoundError(identifier={"name": ["john"]}, node_type="TestPerson") + + def test_the_existing_clauses_still_catch_what_they_caught(self) -> None: + with pytest.raises(NodeNotFoundError, match="Unable to find the node in the database"): + raise NodeNotFoundError(identifier={"name": ["john"]}) + with pytest.raises(BranchNotFoundError, match="Unable to find the branch"): + raise BranchNotFoundError(identifier="does-not-exist") + with pytest.raises(SchemaNotFoundError, match="Unable to find the schema"): + raise SchemaNotFoundError(identifier="TestPerson") + + +class TestAdoptionMarkers: + """The adoption markers the generator reads. + + Infrahub derives its bindings by parsing `base.py`, so these strings are load-bearing outside + this repository and have no reader inside it. Only a test keeps them honest. + """ + + @pytest.mark.parametrize("case", UNIFIED_CLASS_PARAMS) + def test_the_adopted_code_is_declared(self, case: UnifiedClassCase) -> None: + assert case.code == case.exception_class.CODE + + def test_declaring_a_code_does_not_make_it_a_raised_code(self) -> None: + """`CODE` says which code the class represents; `code` says which one the server reported.""" + assert NodeNotFoundError(identifier="anything").code is None + + def test_a_subclass_of_an_adopted_class_claims_no_code(self) -> None: + """A wrong-kind result is not a lookup miss, and must not be labelled as one. + + `NodeInvalidError` would otherwise inherit `NODE_NOT_FOUND`, so anything reading the marker + would file it under a code that describes a different failure. + """ + assert NodeInvalidError.CODE is None + assert NodeNotFoundError.CODE == "NODE_NOT_FOUND", "clearing it on the subclass leaves the parent alone" + + +class TestPromotingAServerPayload: + def test_node_not_found_maps_node_kind_onto_node_type(self) -> None: + exc = NodeNotFoundError.from_payload(payload=NodeNotFoundData(node_kind="TestPerson", identifier="john")) + + assert exc.node_type == "TestPerson" + assert exc.identifier == "john" + + def test_branch_not_found_maps_branch_name_onto_identifier(self) -> None: + exc = BranchNotFoundError.from_payload(payload=BranchNotFoundData(branch_name="does-not-exist")) + + assert exc.identifier == "does-not-exist" + + def test_schema_not_found_maps_kind_onto_identifier(self) -> None: + exc = SchemaNotFoundError.from_payload(payload=SchemaNotFoundData(kind="TestPerson")) + + assert exc.identifier == "TestPerson" + + +class TestAnAdoptedCodeRaisesItsOwnClass: + """A server-reported failure under an adopted code reaches the class that adopted it. + + Without this the codes are adopted in name only: `from_payload` has no caller, and a consumer has + to read words out of a message to tell a lookup miss from any other GraphQL failure. + """ + + def test_a_server_reported_node_not_found_raises_node_not_found_error(self) -> None: + errors = [ + { + "message": "Unable to find the node TestPerson/john in the database", + "extensions": { + "code": "NODE_NOT_FOUND", + "http_status": 404, + "data": {"node_kind": "TestPerson", "identifier": "john"}, + }, + } + ] + + exc = graphql_error_from_response(errors=errors, query="mutation { TestPersonDelete }") + + assert isinstance(exc, NodeNotFoundError) + assert exc.node_type == "TestPerson" + assert exc.identifier == "john" + assert exc.code == "NODE_NOT_FOUND" + + def test_the_dispatched_class_still_carries_the_whole_envelope(self) -> None: + """The class builds itself from its payload alone, so the envelope has to be put on it.""" + errors = [ + { + "message": "Unable to find the branch 'dev'", + "extensions": { + "code": "BRANCH_NOT_FOUND", + "http_status": 400, + "data": {"branch_name": "dev"}, + }, + }, + {"message": "and a second problem"}, + ] + + exc = graphql_error_from_response(errors=errors, query="query { x }", variables={"a": 1}) + + assert isinstance(exc, BranchNotFoundError) + assert exc.identifier == "dev" + assert exc.query == "query { x }" + assert exc.variables == {"a": 1} + assert [error["message"] for error in exc.errors] == ["Unable to find the branch 'dev'", "and a second problem"] + + def test_the_dispatched_class_names_its_code_and_the_server_message(self) -> None: + errors = [ + { + "message": "No schema TestWidget on branch main", + "extensions": {"code": "SCHEMA_NOT_FOUND", "http_status": 422, "data": {"kind": "TestWidget"}}, + } + ] + + exc = graphql_error_from_response(errors=errors) + + assert exc.message == "SCHEMA_NOT_FOUND: No schema TestWidget on branch main" + assert str(exc) == "SCHEMA_NOT_FOUND: No schema TestWidget on branch main", ( + "reassigning the message must reassign `args`, which is what `str()` reads" + ) + + @pytest.mark.malformed + def test_a_payload_missing_the_fields_its_class_needs_falls_back(self) -> None: + """The generic class still reaches the caller, rather than a TypeError raised inside the SDK.""" + errors = [ + { + "message": "Unable to find the node", + "extensions": {"code": "NODE_NOT_FOUND", "data": {"node_kind": "TestPerson"}}, + } + ] + + exc = graphql_error_from_response(errors=errors) + + assert not isinstance(exc, NodeNotFoundError) + assert exc.code == "NODE_NOT_FOUND", "the code stays readable even where its payload did not" + + @pytest.mark.crossversion + def test_a_pre_catalogue_node_not_found_still_raises_the_generic_class(self) -> None: + """A server that sends no `extensions` has no payload to build the class from.""" + exc = graphql_error_from_response(errors=[{"message": "Unable to find the node in the database."}]) + + assert not isinstance(exc, NodeNotFoundError) + assert exc.code is None + + +@pytest.mark.message +class TestMessages: + def test_a_catalogued_failure_names_the_code_and_the_server_message(self) -> None: + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query="mutation { TestPersonCreate }") + + assert exc.message == "UNIQUENESS_VIOLATION: Node of kind TestPerson already has name 'John'" + + def test_a_catalogued_failure_carries_no_query_text(self) -> None: + query = "mutation { TestPersonCreate(data: {name: {value: 'John'}}) { ok }}" + envelope = load_envelope("graphql_uniqueness_violation.json") + + exc = graphql_error_from_response(errors=envelope["errors"], query=query) + + assert query not in str(exc) + assert "An error occurred while executing the GraphQL Query" not in str(exc) + + def test_an_uncatalogued_failure_keeps_todays_message_byte_for_byte(self) -> None: + errors = [{"message": "Source node not found: a"}] + query = "query { InfrahubPathTraversal }" + + exc = graphql_error_from_response(errors=errors, query=query) + + assert exc.message == f"An error occurred while executing the GraphQL Query {query}, {errors}" + + def test_a_failure_the_server_could_not_describe_keeps_todays_message_byte_for_byte(self) -> None: + """A current server codes every error, so `UNDEFINED_ERROR` is what most failures arrive as. + + Letting it name the failure would apply the short message universally, dropping the query + text and every error after the first from what reaches a log or a traceback. + """ + errors = [ + { + "message": "Cannot query field 'nope' on type 'Query'.", + "extensions": {"code": "UNDEFINED_ERROR", "http_status": 500, "data": {}}, + }, + {"message": "Cannot query field 'alsonope' on type 'Query'."}, + ] + query = "query { nope alsonope }" + + exc = graphql_error_from_response(errors=errors, query=query) + + assert exc.message == f"An error occurred while executing the GraphQL Query {query}, {errors}" + assert exc.code == "UNDEFINED_ERROR", "the code is still readable, it is just not the headline" + + def test_a_silent_governing_error_does_not_reduce_the_message_to_a_bare_code(self) -> None: + """A code with nothing beside it is a worse headline than what the call site would produce. + + The governing error carries the code but no words, so the reasons live in the later errors; + naming the code alone would drop them from the message entirely. + """ + errors = [ + {"extensions": {"code": "UNIQUENESS_VIOLATION", "http_status": 422}}, + {"message": "the reason the caller actually needs"}, + ] + query = "mutation { TestPersonCreate }" + + exc = graphql_error_from_response(errors=errors, query=query) + + assert exc.message == f"An error occurred while executing the GraphQL Query {query}, {errors}" + assert exc.code == "UNIQUENESS_VIOLATION" + + def test_a_silent_governing_error_leaves_an_adopted_class_its_own_sentence(self) -> None: + errors = [ + { + "extensions": { + "code": "NODE_NOT_FOUND", + "data": {"node_kind": "TestPerson", "identifier": "john"}, + } + } + ] + + exc = graphql_error_from_response(errors=errors) + + assert isinstance(exc, NodeNotFoundError) + assert exc.message == "Unable to find the node in the database." + assert exc.code == "NODE_NOT_FOUND" + + def test_a_silent_governing_error_keeps_the_joined_authentication_reasons(self) -> None: + response = auth_response( + envelope={ + "errors": [ + {"extensions": {"code": "AUTHENTICATION_REQUIRED"}}, + {"message": "the reason the caller actually needs"}, + ] + } + ) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "the reason the caller actually needs" + assert exc.code == "AUTHENTICATION_REQUIRED" + + def test_a_described_authentication_failure_names_its_code(self) -> None: + response = auth_response(envelope=load_envelope("auth_two_messages.json")) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "AUTHENTICATION_REQUIRED: first problem" + + def test_an_uncatalogued_authentication_failure_keeps_the_joined_messages(self) -> None: + response = auth_response(envelope={"errors": [{"message": "first problem"}, {"message": "second problem"}]}) + + exc = authentication_error_from_response(response=response) + + assert exc.message == "first problem | second problem" + + @pytest.mark.parametrize("case", UNIFIED_CLASS_PARAMS) + def test_a_unified_class_raised_with_no_code_behind_it_keeps_todays_message(self, case: UnifiedClassCase) -> None: + """Re-rooting must not put the GraphQL default message on a client-side lookup miss.""" + assert case.build().message == case.expected_message + + def test_a_deliberately_empty_message_is_not_replaced_by_the_graphql_default(self) -> None: + """Re-rooting must not put the GraphQL placeholder in a caller's mouth either.""" + exc = NodeNotFoundError(identifier="missing-file.py", message="") + + assert not exc.message + assert "An error occurred while executing the GraphQL Query" not in str(exc) + + def test_only_the_governing_error_message_is_named_beside_the_code(self) -> None: + """Joining the whole list would file every later error under a code that is not theirs.""" + envelope = load_envelope("graphql_multiple_errors.json") + + exc = graphql_error_from_response(errors=envelope["errors"]) + + assert exc.message == "SCHEMA_NOT_FOUND: first failure" + assert "second failure" not in str(exc) + assert [error["message"] for error in exc.errors] == ["first failure", "second failure", "third failure"], ( + "naming one message must not discard the rest, which stay on the exception" + ) + + def test_the_query_and_variables_survive_a_catalogued_failure(self) -> None: + """The query leaves the message but stays readable, which is the whole trade.""" + envelope = load_envelope("graphql_uniqueness_violation.json") + query = "mutation { TestPersonCreate }" + + exc = graphql_error_from_response(errors=envelope["errors"], query=query, variables={"name": "John"}) + + assert exc.query == query + assert exc.variables == {"name": "John"} + + +@dataclass +class FieldSelectionErrorCase: + name: str + build: Callable[[], Error] + expected_message: str + expected_attributes: dict[str, Any] + + +FIELD_SELECTION_ERROR_CASES = [ + FieldSelectionErrorCase( + name="not-loaded-on-a-queried-node", + build=lambda: FieldNotLoadedError(kind="InfraDevice", field="description", only=["name"]), + expected_message=( + "InfraDevice.description was not fetched: the node was queried with only=['name']. " + "Add 'description' to only, or check is_loaded before reading it." + ), + expected_attributes={"kind": "InfraDevice", "field": "description", "only": ["name"], "peer_of": None}, + ), + FieldSelectionErrorCase( + name="not-loaded-with-an-empty-only", + build=lambda: FieldNotLoadedError(kind="InfraDevice", field="name", only=()), + expected_message=( + "InfraDevice.name was not fetched: the node was queried with only=[]. " + "Add 'name' to only, or check is_loaded before reading it." + ), + expected_attributes={"kind": "InfraDevice", "field": "name", "only": [], "peer_of": None}, + ), + FieldSelectionErrorCase( + name="not-loaded-on-a-peer", + build=lambda: FieldNotLoadedError( + kind="InfraSite", field="description", only=("site", "name"), peer_of="InfraDevice.site" + ), + expected_message=( + "InfraSite.description was not fetched: the node is a peer of InfraDevice.site, " + "queried with only=['site', 'name']. " + "Fetch the peer again with every field you read, or check is_loaded before reading it." + ), + expected_attributes={ + "kind": "InfraSite", + "field": "description", + "only": ["site", "name"], + "peer_of": "InfraDevice.site", + }, + ), + FieldSelectionErrorCase( + name="not-found", + build=lambda: FieldNotFoundError(kind="InfraDevice", field="nmae"), + expected_message="'nmae' is not an attribute or relationship of InfraDevice.", + expected_attributes={"kind": "InfraDevice", "field": "nmae"}, + ), + FieldSelectionErrorCase( + name="conflict", + build=FieldSelectionConflictError, + expected_message="only cannot be combined with include or exclude.", + expected_attributes={}, + ), +] + +FIELD_SELECTION_ERROR_PARAMS = [pytest.param(case, id=case.name) for case in FIELD_SELECTION_ERROR_CASES] +FIELD_SELECTION_ERROR_WITH_ARGUMENTS_PARAMS = [ + pytest.param(case, id=case.name) for case in FIELD_SELECTION_ERROR_CASES if case.expected_attributes +] + +ROUND_TRIPS = { + "pickle": lambda exc: pickle.loads(pickle.dumps(exc)), # noqa: S301 + "deepcopy": copy.deepcopy, + "copy": copy.copy, +} + + +@pytest.mark.message +class TestFieldSelectionErrors: + @pytest.mark.parametrize("case", FIELD_SELECTION_ERROR_PARAMS) + def test_the_message_names_what_the_caller_needs(self, case: FieldSelectionErrorCase) -> None: + exc = case.build() + + assert exc.message == case.expected_message + assert str(exc) == case.expected_message + + @pytest.mark.parametrize("case", FIELD_SELECTION_ERROR_WITH_ARGUMENTS_PARAMS) + def test_the_arguments_stay_readable(self, case: FieldSelectionErrorCase) -> None: + exc = case.build() + + assert {attribute: getattr(exc, attribute) for attribute in case.expected_attributes} == ( + case.expected_attributes + ) + + @pytest.mark.parametrize("round_trip", [pytest.param(fn, id=name) for name, fn in ROUND_TRIPS.items()]) + @pytest.mark.parametrize("case", FIELD_SELECTION_ERROR_PARAMS) + def test_the_error_survives_serialisation( + self, case: FieldSelectionErrorCase, round_trip: Callable[[Error], Error] + ) -> None: + exc = case.build() + + restored = round_trip(exc) + + assert type(restored) is type(exc) + assert [restored.message, str(restored)] == [case.expected_message, case.expected_message] + assert {attribute: getattr(restored, attribute) for attribute in case.expected_attributes} == ( + case.expected_attributes + ) + + @pytest.mark.parametrize("case", FIELD_SELECTION_ERROR_PARAMS) + def test_the_only_base_class_is_error(self, case: FieldSelectionErrorCase) -> None: + assert type(case.build()).__bases__ == (Error,) + + @pytest.mark.parametrize("case", FIELD_SELECTION_ERROR_PARAMS) + def test_except_error_catches_it(self, case: FieldSelectionErrorCase) -> None: + with pytest.raises(Error, match=re.escape(case.expected_message)): + raise case.build() diff --git a/tests/unit/sdk/test_exceptions_layering.py b/tests/unit/sdk/test_exceptions_layering.py new file mode 100644 index 000000000..e76897553 --- /dev/null +++ b/tests/unit/sdk/test_exceptions_layering.py @@ -0,0 +1,189 @@ +"""The exceptions package is strictly layered, and imports may only point downward or outside the SDK. + +Parsed rather than imported, so the property is checked against the source of every module in the +package and cannot decay silently. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +import infrahub_sdk.exceptions as exceptions_package + +ROOT = "infrahub_sdk" +PACKAGE = f"{ROOT}.exceptions" +PACKAGE_DIR = Path(exceptions_package.__file__).parent + +# base.py sits at the bottom, which is what keeps the hand-written hierarchy independent of anything +# built on top of it. Each layer above may import only from below it. The generated catalogue sits +# above base and below factory, so the factory can resolve a code to one of its classes. +LAYERS = { + "base": 0, + "catalogue": 1, + "factory": 2, + "__init__": 3, +} + + +def module_paths() -> list[Path]: + return sorted(PACKAGE_DIR.glob("*.py")) + + +def _submodule_of(dotted: str) -> str | None: + """The package submodule an absolute dotted path names, or None if it points outside.""" + prefix = f"{PACKAGE}." + if not dotted.startswith(prefix): + return None + return dotted[len(prefix) :].split(".", maxsplit=1)[0] + + +def intra_package_targets(tree: ast.AST, own_module: str) -> list[tuple[str, int]]: + """Return (module, lineno) for every import that resolves inside the exceptions package. + + Both the relative and the absolute spelling are recognised, so neither form can be used to slip + past the ordering. Walks the whole tree, so an import inside a function body or a TYPE_CHECKING + block is caught exactly like a module-level one. + """ + module_names = {path.stem for path in module_paths()} + targets: list[tuple[str, int]] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.level == 1 and node.module: + # `from .factory import x` + targets.append((node.module.split(".")[0], node.lineno)) + elif (node.level == 1 and not node.module) or node.module == PACKAGE: + # `from . import factory`, or the same written out in full. The names here are a mix + # of submodules and of the classes the facade re-exports, so only the former count. + targets.extend((alias.name, node.lineno) for alias in node.names if alias.name in module_names) + elif node.level == 0 and node.module: + # `from infrahub_sdk.exceptions.factory import x` + submodule = _submodule_of(node.module) + if submodule: + targets.append((submodule, node.lineno)) + elif isinstance(node, ast.Import): + for alias in node.names: + submodule = _submodule_of(alias.name) + if submodule: + targets.append((submodule, node.lineno)) + + return [(module, lineno) for module, lineno in targets if module != own_module] + + +def _points_outside_package(dotted: str) -> bool: + """Whether a dotted name reaches the SDK outside this package. + + The root itself counts: `import infrahub_sdk` and `from infrahub_sdk import utils` pull in the + façade, which imports the client, so they are dependencies like any other. + """ + if dotted != ROOT and not dotted.startswith(f"{ROOT}."): + return False + return dotted != PACKAGE and not dotted.startswith(f"{PACKAGE}.") + + +def outward_targets(tree: ast.AST) -> list[tuple[str, int]]: + """Return (module, lineno) for every import reaching another part of the SDK. + + A relative import climbing out of the package counts, and so does the absolute spelling of the + same module. Imports of the standard library and of third-party packages do not: the package may + depend on those freely. + """ + targets: list[tuple[str, int]] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.level >= 2: + # `from ..utils import x` — one level up is the package itself, two is out of it. + targets.append((f"{'.' * node.level}{node.module or ''}", node.lineno)) + elif node.level == 0 and node.module and _points_outside_package(node.module): + targets.append((node.module, node.lineno)) + elif isinstance(node, ast.Import): + targets.extend((alias.name, node.lineno) for alias in node.names if _points_outside_package(alias.name)) + + return targets + + +@pytest.mark.parametrize("path", module_paths(), ids=lambda p: p.name) +def test_the_package_depends_on_no_other_part_of_the_sdk(path: Path) -> None: + """Nothing in the SDK may sit below the exceptions package. + + Every other module is free to raise, so a dependency in this direction is a cycle waiting to be + discovered — and the workaround for one is a deferred import inside a function body, which the + walk below catches exactly like a module-level one. + """ + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + assert outward_targets(tree=tree) == [], ( + f"{path.name} imports from elsewhere in the SDK; keep the package self-contained instead" + ) + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("from ..utils import decode_json", id="relative-parent"), + pytest.param("from ...infrahub_sdk import utils", id="relative-grandparent"), + pytest.param("from infrahub_sdk.utils import decode_json", id="absolute-from"), + pytest.param("import infrahub_sdk.utils", id="absolute-import"), + pytest.param("from infrahub_sdk import utils", id="root-from"), + pytest.param("import infrahub_sdk", id="root-import"), + ], +) +def test_an_outward_import_is_detected_however_it_is_spelled(source: str) -> None: + assert outward_targets(tree=ast.parse(source)) != [] + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("from .base import Error", id="intra-package-relative"), + pytest.param("from infrahub_sdk.exceptions.base import Error", id="intra-package-absolute"), + pytest.param("from infrahub_sdk.exceptions import Error", id="package-facade"), + pytest.param("import httpx", id="third-party"), + pytest.param("from collections.abc import Mapping", id="standard-library"), + ], +) +def test_an_allowed_import_is_not_mistaken_for_an_outward_one(source: str) -> None: + assert outward_targets(tree=ast.parse(source)) == [] + + +@pytest.mark.parametrize("path", module_paths(), ids=lambda p: p.name) +def test_imports_point_strictly_downward(path: Path) -> None: + own_module = path.stem + assert own_module in LAYERS, f"{path.name} is not assigned a layer in this test" + own_layer = LAYERS[own_module] + + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + for module, lineno in intra_package_targets(tree=tree, own_module=own_module): + assert module in LAYERS, f"{path.name}:{lineno} imports unknown package module '{module}'" + assert LAYERS[module] < own_layer, ( + f"{path.name}:{lineno} imports '{module}' (layer {LAYERS[module]}) " + f"from layer {own_layer}; imports must point strictly downward" + ) + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("from .factory import graphql_error_from_response", id="relative-from"), + pytest.param("from . import factory", id="relative-package"), + pytest.param("from infrahub_sdk.exceptions.factory import graphql_error_from_response", id="absolute-from"), + pytest.param("from infrahub_sdk.exceptions import factory", id="absolute-package"), + pytest.param("import infrahub_sdk.exceptions.factory", id="absolute-import"), + ], +) +def test_every_spelling_of_an_intra_package_import_is_detected(source: str) -> None: + """The check is only worth having if it cannot be sidestepped by rewording the import.""" + targets = intra_package_targets(tree=ast.parse(source), own_module="base") + + assert targets == [("factory", 1)] + + +def test_re_exported_class_names_are_not_mistaken_for_modules() -> None: + source = "from infrahub_sdk.exceptions import AuthenticationError" + + assert intra_package_targets(tree=ast.parse(source), own_module="base") == [] diff --git a/tests/unit/sdk/test_exceptions_public_names.py b/tests/unit/sdk/test_exceptions_public_names.py new file mode 100644 index 000000000..e89119eb1 --- /dev/null +++ b/tests/unit/sdk/test_exceptions_public_names.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from infrahub_sdk import exceptions +from infrahub_sdk.exceptions import authentication_error_from_response, base, catalogue, graphql_error_from_response +from tests.helpers.fixtures import read_fixture + +if TYPE_CHECKING: + from types import ModuleType + + +def load_snapshot() -> set[str]: + return set(json.loads(read_fixture(file_name="public_names.json", fixture_subdir="error_catalogue"))) + + +def current_names() -> set[str]: + return { + name + for name in dir(exceptions) + if not name.startswith("_") + and isinstance(getattr(exceptions, name), type) + and issubclass(getattr(exceptions, name), BaseException) + } + + +def star_imported_names() -> dict[str, object]: + namespace: dict[str, object] = {} + exec("from infrahub_sdk.exceptions import *", namespace) # noqa: S102 + return {name: value for name, value in namespace.items() if not name.startswith("__")} + + +def classes_defined_in(module: ModuleType) -> set[str]: + """The exception classes a module defines itself, ignoring any it merely imported.""" + return { + name + for name, value in vars(module).items() + if isinstance(value, type) and issubclass(value, BaseException) and value.__module__ == module.__name__ + } + + +def catalogue_exception_names() -> set[str]: + """The exception classes the generated module declares, without its payload models or maps.""" + return { + name + for name in catalogue.__all__ + if isinstance(getattr(catalogue, name), type) and issubclass(getattr(catalogue, name), BaseException) + } + + +def test_the_facade_lists_every_class_base_and_catalogue_declare() -> None: + """The façade writes its exports out by hand, so nothing may drift out of step with its sources. + + A class added to `base.__all__`, or a new code added to the catalogue, has to be added to both + lists in `infrahub_sdk/exceptions/__init__.py`: the import block and `__all__`. + """ + assert set(exceptions.__all__) == set(base.__all__) | catalogue_exception_names() + + +def test_the_facade_re_exports_no_catalogue_payload_model_or_lookup() -> None: + """Only the classes a caller catches are promoted to the package surface. + + The payload models, the two lookup maps and the dispatch helper are internal to the package, so + they stay importable from `catalogue` rather than becoming a stability promise of the package. + """ + non_classes = set(catalogue.__all__) - catalogue_exception_names() + leaked = sorted(non_classes & set(exceptions.__all__)) + + assert leaked == [], f"re-exported from catalogue but not an exception class: {leaked}" + + +def test_every_name_the_facade_declares_is_bound() -> None: + """A name in `__all__` that the import block omits would only fail at a caller's `import *`.""" + missing = sorted(name for name in exceptions.__all__ if not hasattr(exceptions, name)) + + assert missing == [], f"declared in __all__ but never imported: {missing}" + + +def test_base_declares_every_exception_it_defines() -> None: + """An exception class left out of `base.__all__` never reaches the façade at all. + + Without this the omission is invisible: the class is simply absent everywhere downstream, so the + snapshot below still matches and nothing else notices. + """ + undeclared = sorted(classes_defined_in(base) - set(base.__all__)) + + assert undeclared == [], f"defined in base.py but missing from base.__all__: {undeclared}" + + +def test_the_catalogue_declares_every_exception_it_defines() -> None: + """Every other check here trusts `catalogue.__all__`, so nothing else would catch an omission. + + A generated class left out of it is simply absent from the façade, and the two checks that would + otherwise notice both derive from the same list, so they agree with each other and stay green. + """ + undeclared = sorted(classes_defined_in(catalogue) - set(catalogue.__all__)) + + assert undeclared == [], f"defined in catalogue.py but missing from catalogue.__all__: {undeclared}" + + +def test_snapshot_matches_the_exported_exceptions() -> None: + """infrahub_sdk.exceptions is the supported import path. + + Nothing may drop out of it, and a new exception must be added to the snapshot, so the surface + stays a deliberate choice rather than a side effect. + """ + assert current_names() == load_snapshot() + + +def test_every_snapshot_name_still_descends_from_the_root() -> None: + """Re-rooting moves classes around inside the tree; none may leave it. + + `except Error` is the clause a consumer reaches for to catch anything the SDK raises, so a class + that ends up outside that root is as invisible to them as one that stopped being importable. + """ + strays = sorted(name for name in load_snapshot() if not issubclass(getattr(exceptions, name), exceptions.Error)) + + assert strays == [], f"importable but no longer under Error: {strays}" + + +def test_star_import_gives_the_exception_classes_and_nothing_else() -> None: + """What `import *` hands an end user is the classes they catch. + + Without a declared `__all__` the wildcard also carries the `base` and `factory` submodule names, + which are an artefact of the package layout and shadow those names in the caller's scope. The + raise-time factories stay importable by name; they are simply not part of the wildcard. + """ + assert set(star_imported_names()) == load_snapshot() + + +def test_the_raise_time_factories_are_still_importable_by_name() -> None: + """Narrowing the wildcard must not take the supported explicit imports with it. + + The import at the top of this module is the assertion; these calls confirm the names resolve to + the factories rather than to something else the façade happens to bind. + """ + assert callable(authentication_error_from_response) + assert callable(graphql_error_from_response) diff --git a/tests/unit/sdk/test_file_handler.py b/tests/unit/sdk/test_file_handler.py index f8ffacfab..09ae45e73 100644 --- a/tests/unit/sdk/test_file_handler.py +++ b/tests/unit/sdk/test_file_handler.py @@ -4,7 +4,7 @@ import tempfile from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import anyio import httpx @@ -200,6 +200,24 @@ def test_handle_error_response_404() -> None: assert "File not found with ID abc123" in str(excinfo.value) +@pytest.mark.parametrize( + "body", + [ + pytest.param({"text": "404 Not Found"}, id="html-page"), + pytest.param({"text": ""}, id="empty-body"), + pytest.param({"json": ["not an object"]}, id="json-array"), + pytest.param({"json": {"detail": None}}, id="detail-that-is-not-a-string"), + ], +) +def test_handle_error_response_404_with_a_body_carrying_no_detail(body: dict[str, Any]) -> None: + """A 404 body the SDK cannot read must still surface as NodeNotFoundError, never as a decode error.""" + response = httpx.Response(status_code=404, **body) + exc = httpx.HTTPStatusError(message="Not Found", request=httpx.Request("GET", "http://test"), response=response) + + with pytest.raises(NodeNotFoundError, match="File not found"): + FileHandlerBase.handle_error_response(exc=exc) + + def test_handle_error_response_500() -> None: """Test handling 500 server error (re-raises).""" response = httpx.Response(status_code=500, json={"error": "Internal server error"}) diff --git a/tests/unit/sdk/test_group_context.py b/tests/unit/sdk/test_group_context.py index 7b4de5506..d81122697 100644 --- a/tests/unit/sdk/test_group_context.py +++ b/tests/unit/sdk/test_group_context.py @@ -1,16 +1,60 @@ +from __future__ import annotations + import inspect +import json +import pickle # noqa: S403 - round-tripping our own exception, no untrusted data involved from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any +import httpx import pytest -from infrahub_sdk.query_groups import InfrahubGroupContext, InfrahubGroupContextBase, InfrahubGroupContextSync -from infrahub_sdk.schema import NodeSchemaAPI +from infrahub_sdk import query_groups +from infrahub_sdk.exceptions import ( + BranchNotFoundError, + GraphQLError, + MergeInProgressError, + NodeNotFoundError, + RateLimitError, + SchemaNotFoundError, + ServerNotReachableError, + ServerNotResponsiveError, + TrackingGroupCleanupError, + URLNotFoundError, + graphql_error_from_response, +) +from infrahub_sdk.query_groups import ( + InfrahubGroupContext, + InfrahubGroupContextBase, + InfrahubGroupContextSync, + ReapResult, +) +from tests.helpers.fixtures import read_fixture + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk.schema import NodeSchemaAPI + from tests.unit.sdk.conftest import BothClients async_methods = [method for method in dir(InfrahubGroupContext) if not method.startswith("_")] sync_methods = [method for method in dir(InfrahubGroupContextSync) if not method.startswith("_")] client_types = ["standard", "sync"] +GROUP_ID = "gggggggg-gggg-gggg-gggg-gggggggggggg" +TAG_ID = "tttttttt-tttt-tttt-tttt-tttttttttttt" +SECOND_TAG_ID = "uuuuuuuu-uuuu-uuuu-uuuu-uuuuuuuuuuuu" +THIRD_TAG_ID = "vvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv" +IDENTIFIER = "unit-tracking" + +MANDATORY_RELATIONSHIP_ERROR = ( + "Cannot delete TestingPerson 'pppp'. It is linked to mandatory relationship owner on node TestingCat 'cccc'" +) +MISSING_NODE_ERROR = "Unable to find the node BuiltinTag/tttt in the database." +DELETE_QUERY = 'mutation BuiltinTagDelete { BuiltinTagDelete(data: {id: "tttt"}) { ok } }' + async def test_method_sanity() -> None: """Validate that there is at least one public method and that both clients look the same.""" @@ -83,3 +127,402 @@ def test_generate_group_description(std_group_schema: NodeSchemaAPI) -> None: context = InfrahubGroupContextBase() context.set_properties(identifier="MYID", params={"one": "xxxxxxxxxxx", "two": "yyyyyyyyyyy"}) assert context._generate_group_description(schema=std_group_schema) == "one: xxxxxxxxxx..." + + +def test_get_members_combines_groups_and_nodes() -> None: + context = InfrahubGroupContextBase() + context.related_group_ids = ["group1"] + context.related_node_ids = ["node1", "node2"] + assert context._get_members() == ["group1", "node1", "node2"] + + +def test_set_unused_member_ids_diffs_against_the_previous_run() -> None: + context = InfrahubGroupContextBase() + context._set_unused_member_ids(previous_member_ids=["kept", "dropped"], members=["kept", "added"]) + assert context.unused_member_ids == ["dropped"] + + context._set_unused_member_ids(previous_member_ids=["gone1", "gone2"], members=[]) + assert sorted(context.unused_member_ids or []) == ["gone1", "gone2"] + + +def catalogue_error(code: str) -> GraphQLError: + """The exception the GraphQL path raises for a catalogue code's recorded server response.""" + envelope = json.loads(read_fixture(file_name=f"{code}.json", fixture_subdir="error_catalogue/codes")) + return graphql_error_from_response(errors=envelope["errors"], query=DELETE_QUERY) + + +def uncoded_error(message: str) -> GraphQLError: + """What a server older than the error catalogue reports: a message and no extensions.""" + return graphql_error_from_response(errors=[{"message": message}], query=DELETE_QUERY) + + +def test_a_coded_missing_node_is_raised_as_node_not_found() -> None: + """The reap's `except NodeNotFoundError` clause is what tolerates a cascade-deleted member.""" + assert isinstance(catalogue_error("node_not_found"), NodeNotFoundError) + + +@dataclass +class ReapFailureCase: + name: str + build: Callable[[], GraphQLError] + about_the_member: bool + + +REAP_FAILURE_CASES = [ + ReapFailureCase( + name="uncoded-refusal", build=lambda: uncoded_error(MANDATORY_RELATIONSHIP_ERROR), about_the_member=True + ), + ReapFailureCase(name="undefined-error", build=lambda: catalogue_error("undefined_error"), about_the_member=True), + ReapFailureCase( + name="permission-denied", build=lambda: catalogue_error("permission_denied"), about_the_member=True + ), + ReapFailureCase( + name="schema-gone-for-the-members-kind", + build=lambda: SchemaNotFoundError(identifier="TestingCat"), + about_the_member=True, + ), + ReapFailureCase(name="token-expired", build=lambda: catalogue_error("token_expired"), about_the_member=False), + ReapFailureCase( + name="authentication-required", build=lambda: catalogue_error("authentication_required"), about_the_member=False + ), + ReapFailureCase( + name="merge-in-progress", build=lambda: catalogue_error("merge_in_progress"), about_the_member=False + ), + ReapFailureCase( + name="branch-not-found-on-server", build=lambda: catalogue_error("branch_not_found"), about_the_member=False + ), + ReapFailureCase( + name="branch-not-found-in-the-sdk", build=lambda: BranchNotFoundError(identifier="gone"), about_the_member=False + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in REAP_FAILURE_CASES]) +def test_about_the_member(case: ReapFailureCase) -> None: + """Only a failure about the member is recorded against it; one about the request stops the reap. + + The GraphQL path raises an expired token as a plain `GraphQLError`, so the class alone would file + it as a refusal of every member in turn. + """ + assert query_groups._about_the_member(case.build()) is case.about_the_member + + +@dataclass +class AlreadyDeletedCase: + name: str + build: Callable[[], GraphQLError] + expected: bool + + +ALREADY_DELETED_CASES = [ + AlreadyDeletedCase(name="legacy-missing-node", build=lambda: uncoded_error(MISSING_NODE_ERROR), expected=True), + AlreadyDeletedCase( + name="legacy-refusal", build=lambda: uncoded_error(MANDATORY_RELATIONSHIP_ERROR), expected=False + ), + AlreadyDeletedCase( + name="coded-failure-with-the-same-wording", + build=lambda: graphql_error_from_response( + errors=[{"message": MISSING_NODE_ERROR, "extensions": {"code": "UNDEFINED_ERROR"}}], query=DELETE_QUERY + ), + expected=False, + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in ALREADY_DELETED_CASES]) +def test_node_already_deleted(case: AlreadyDeletedCase) -> None: + """The wording only stands in for the code on a server that predates the catalogue.""" + assert query_groups._node_already_deleted(case.build()) is case.expected + + +def test_failure_reason_of_a_described_code_names_it_without_the_query() -> None: + reason = query_groups._failure_reason(catalogue_error("uniqueness_violation")) + + assert reason.startswith("UNIQUENESS_VIOLATION: ") + assert DELETE_QUERY not in reason + + +def test_failure_reason_of_an_undescribed_failure_is_the_server_message_alone() -> None: + assert query_groups._failure_reason(uncoded_error(MANDATORY_RELATIONSHIP_ERROR)) == MANDATORY_RELATIONSHIP_ERROR + + +def test_tracking_group_cleanup_error_survives_serialization() -> None: + """The exception crosses a task-orchestrator boundary, which serializes failed runs.""" + failures = {TAG_ID: MANDATORY_RELATIONSHIP_ERROR} + + restored = pickle.loads(pickle.dumps(TrackingGroupCleanupError(failures=failures))) # noqa: S301 + + assert restored.failures == failures + assert str(restored) == str(TrackingGroupCleanupError(failures=failures)) + + +def _group_query_response(member_ids: list[str]) -> dict[str, Any]: + return { + "data": { + "CoreStandardGroup": { + "count": 1, + "edges": [ + { + "node": { + "id": GROUP_ID, + "display_label": IDENTIFIER, + "__typename": "CoreStandardGroup", + "name": {"value": IDENTIFIER, "is_default": False, "is_from_profile": False}, + "description": {"value": None, "is_default": True, "is_from_profile": False}, + "members": { + "count": len(member_ids), + "edges": [ + {"node": {"id": member_id, "display_label": member_id, "__typename": "BuiltinTag"}} + for member_id in member_ids + ], + }, + } + } + ], + } + } + } + + +NO_GROUP_RESPONSE: dict[str, Any] = {"data": {"CoreStandardGroup": {"count": 0, "edges": []}}} + + +async def _track_nothing(clients: BothClients, client_type: str, schema: dict) -> None: + """Run a tracking block that saves no node, on whichever client is under test.""" + if client_type == "standard": + clients.standard.schema.set_cache(schema=schema, branch="main") + async with clients.standard.start_tracking(identifier=IDENTIFIER, delete_unused_nodes=True): + pass + return + + clients.sync.schema.set_cache(schema=schema, branch="main") + with clients.sync.start_tracking(identifier=IDENTIFIER, delete_unused_nodes=True): + pass + + +@pytest.mark.parametrize("client_type", client_types) +async def test_zero_member_run_without_group_issues_no_mutation( + clients: BothClients, client_type: str, schema_query_05_data: dict, httpx_mock: HTTPXMock +) -> None: + """A run that tracked nothing and finds no group must not create an empty one.""" + httpx_mock.add_response(method="POST", url="http://mock/graphql/main", json=NO_GROUP_RESPONSE) + + await _track_nothing(clients=clients, client_type=client_type, schema=schema_query_05_data) + + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.parametrize("client_type", client_types) +async def test_zero_member_run_with_empty_group_issues_no_mutation( + clients: BothClients, client_type: str, schema_query_05_data: dict, httpx_mock: HTTPXMock +) -> None: + """Repeated zero-member runs settle on the lookup alone once the group is empty.""" + httpx_mock.add_response(method="POST", url="http://mock/graphql/main", json=_group_query_response(member_ids=[])) + + await _track_nothing(clients=clients, client_type=client_type, schema=schema_query_05_data) + + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.parametrize("client_type", client_types) +async def test_failed_reap_still_records_the_membership( + clients: BothClients, client_type: str, schema_query_05_data: dict, httpx_mock: HTTPXMock +) -> None: + """A transport failure mid-reap must not skip the upsert that keeps the unreached members.""" + httpx_mock.add_response( + method="POST", url="http://mock/graphql/main", json=_group_query_response(member_ids=[TAG_ID, SECOND_TAG_ID]) + ) + httpx_mock.add_exception(httpx.ReadTimeout("read timed out"), method="POST", url="http://mock/graphql/main") + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json={"data": {"CoreStandardGroupUpsert": {"ok": True, "object": {"id": GROUP_ID}}}}, + ) + + # The failure is reported as itself, not blamed on the member it interrupted. + with pytest.raises(ServerNotResponsiveError, match="Unable to read from"): + await _track_nothing(clients=clients, client_type=client_type, schema=schema_query_05_data) + + requests = httpx_mock.get_requests() + assert len(requests) == 3 + upsert = requests[-1].read().decode() + # The member never reached stays in the group so a later run retries it. + assert SECOND_TAG_ID in upsert + # Without an answer the interrupted delete may have gone through; naming a node that no + # longer exists would make the server reject the whole write. + assert TAG_ID not in upsert + + +@pytest.mark.parametrize("client_type", client_types) +async def test_interrupted_reap_keeps_only_the_members_it_never_reached( + clients: BothClients, client_type: str, schema_query_05_data: dict, httpx_mock: HTTPXMock +) -> None: + """The deleted member and the interrupted one are dropped; the one never reached is kept.""" + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json=_group_query_response(member_ids=[TAG_ID, SECOND_TAG_ID, THIRD_TAG_ID]), + ) + httpx_mock.add_response( + method="POST", url="http://mock/graphql/main", json={"data": {"BuiltinTagDelete": {"ok": True}}} + ) + httpx_mock.add_exception(httpx.ReadTimeout("read timed out"), method="POST", url="http://mock/graphql/main") + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json={"data": {"CoreStandardGroupUpsert": {"ok": True, "object": {"id": GROUP_ID}}}}, + ) + + with pytest.raises(ServerNotResponsiveError, match="Unable to read from"): + await _track_nothing(clients=clients, client_type=client_type, schema=schema_query_05_data) + + upsert = httpx_mock.get_requests()[-1].read().decode() + assert THIRD_TAG_ID in upsert + assert SECOND_TAG_ID not in upsert + assert TAG_ID not in upsert + + +def code_response(code: str) -> dict[str, Any]: + return json.loads(read_fixture(file_name=f"{code}.json", fixture_subdir="error_catalogue/codes")) + + +UPSERT_RESPONSE: dict[str, Any] = {"data": {"CoreStandardGroupUpsert": {"ok": True, "object": {"id": GROUP_ID}}}} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_cascade_deleted_member_is_dropped_without_a_failure( + clients: BothClients, client_type: str, schema_query_05_data: dict, httpx_mock: HTTPXMock +) -> None: + """A server-coded NODE_NOT_FOUND means another delete already took the member.""" + httpx_mock.add_response( + method="POST", url="http://mock/graphql/main", json=_group_query_response(member_ids=[TAG_ID]) + ) + httpx_mock.add_response(method="POST", url="http://mock/graphql/main", json=code_response("node_not_found")) + httpx_mock.add_response(method="POST", url="http://mock/graphql/main", json=UPSERT_RESPONSE) + + await _track_nothing(clients=clients, client_type=client_type, schema=schema_query_05_data) + + requests = httpx_mock.get_requests() + assert len(requests) == 3 + assert TAG_ID not in requests[-1].read().decode() + + +@pytest.mark.parametrize("client_type", client_types) +async def test_locked_branch_stops_the_reap_instead_of_blaming_each_member( + clients: BothClients, client_type: str, schema_query_05_data: dict, httpx_mock: HTTPXMock +) -> None: + """A coded failure about the request is not recorded against the member it interrupted. + + Both members stay in the group: the server answered, so the interrupted delete removed nothing, + and the second was never attempted. The failure surfaces as itself rather than as a cleanup + error listing every member. + """ + httpx_mock.add_response( + method="POST", url="http://mock/graphql/main", json=_group_query_response(member_ids=[TAG_ID, SECOND_TAG_ID]) + ) + httpx_mock.add_response(method="POST", url="http://mock/graphql/main", json=code_response("merge_in_progress")) + httpx_mock.add_response(method="POST", url="http://mock/graphql/main", json=UPSERT_RESPONSE) + + with pytest.raises(MergeInProgressError, match=r"^MERGE_IN_PROGRESS: "): + await _track_nothing(clients=clients, client_type=client_type, schema=schema_query_05_data) + + requests = httpx_mock.get_requests() + assert len(requests) == 3 + upsert = requests[-1].read().decode() + assert TAG_ID in upsert + assert SECOND_TAG_ID in upsert + + +@pytest.mark.parametrize("client_type", client_types) +async def test_failed_write_does_not_replace_the_error_that_stopped_the_reap( + clients: BothClients, client_type: str, schema_query_05_data: dict, httpx_mock: HTTPXMock +) -> None: + """The write after an interrupted reap usually fails too; the reap's own error is still raised.""" + httpx_mock.add_response( + method="POST", url="http://mock/graphql/main", json=_group_query_response(member_ids=[TAG_ID]) + ) + httpx_mock.add_response(method="POST", url="http://mock/graphql/main", json=code_response("merge_in_progress")) + httpx_mock.add_exception(httpx.ReadTimeout("read timed out"), method="POST", url="http://mock/graphql/main") + + with pytest.raises(MergeInProgressError, match=r"^MERGE_IN_PROGRESS: ") as exc_info: + await _track_nothing(clients=clients, client_type=client_type, schema=schema_query_05_data) + + assert isinstance(exc_info.value.__cause__, ServerNotResponsiveError) + + +@dataclass +class InterruptionCase: + name: str + build: Callable[[], Exception] + interrupted_member_kept: bool + + +INTERRUPTION_CASES = [ + InterruptionCase( + name="coded-request-failure", build=lambda: catalogue_error("merge_in_progress"), interrupted_member_kept=True + ), + InterruptionCase( + name="rate-limited", + build=lambda: RateLimitError(url="http://mock/graphql/main", attempts=3), + interrupted_member_kept=True, + ), + InterruptionCase( + name="endpoint-not-found", + build=lambda: URLNotFoundError(url="http://mock/graphql/main"), + interrupted_member_kept=True, + ), + InterruptionCase( + name="timeout", + build=lambda: ServerNotResponsiveError(url="http://mock/graphql/main"), + interrupted_member_kept=False, + ), + InterruptionCase( + name="connection-lost", + build=lambda: ServerNotReachableError(address="http://mock"), + interrupted_member_kept=False, + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in INTERRUPTION_CASES]) +def test_interrupted_member_is_kept_only_when_the_server_rejected_the_delete(case: InterruptionCase) -> None: + """A rejected delete removed nothing; without an answer the delete may have gone through.""" + result = ReapResult() + candidates = [("BuiltinTag", TAG_ID), ("BuiltinTag", SECOND_TAG_ID)] + + stopped = query_groups._record_failure(result=result, exc=case.build(), candidates=candidates, position=0) + + assert stopped is True + expected = [TAG_ID, SECOND_TAG_ID] if case.interrupted_member_kept else [SECOND_TAG_ID] + assert result.unattempted == expected + + +def test_no_existing_group_leaves_nothing_to_reap_on_a_reused_context() -> None: + """A context reused after an earlier lookup must not reap that lookup's members against this run.""" + context = InfrahubGroupContextBase() + context.unused_member_ids = [TAG_ID] + + assert context._reconcile_with(existing_group=None, members=[]) == [] + assert context.unused_member_ids is None + assert context.previous_members is None + assert context._reap_candidates() == [] + + +def test_reap_result_failure_prefers_the_error_that_stopped_the_reap() -> None: + """A reap that was stopped raises its own error, even when members were refused before that.""" + interruption = ServerNotResponsiveError(url="http://mock/graphql/main") + refusals = {"refused1": MANDATORY_RELATIONSHIP_ERROR} + + assert ReapResult(refused=refusals, error=interruption).failure() is interruption + + failure = ReapResult(refused=refusals).failure() + assert isinstance(failure, TrackingGroupCleanupError) + assert failure.failures == refusals + + assert ReapResult().failure() is None + + +def test_reap_result_retains_refused_and_unattempted_members() -> None: + result = ReapResult(refused={"refused1": MANDATORY_RELATIONSHIP_ERROR}, unattempted=["unattempted1"]) + assert result.retained_member_ids == ["refused1", "unattempted1"] + assert ReapResult().retained_member_ids == [] diff --git a/tests/unit/sdk/test_node_only_access.py b/tests/unit/sdk/test_node_only_access.py new file mode 100644 index 000000000..5fcf73528 --- /dev/null +++ b/tests/unit/sdk/test_node_only_access.py @@ -0,0 +1,1474 @@ +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk.exceptions import Error, FieldNotLoadedError, UninitializedError +from infrahub_sdk.graphql import Mutation +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync, UploadResult +from infrahub_sdk.node._only import OnlyMarker # noqa: PLC2701 # the marker is private to the package +from infrahub_sdk.node.attribute import Attribute +from infrahub_sdk.node.related_node import RelatedNode, RelatedNodeSync +from infrahub_sdk.node.relationship import RelationshipManager, RelationshipManagerSync +from infrahub_sdk.schema import NodeSchema, NodeSchemaAPI, SchemaRootAPI +from tests.helpers import only_data +from tests.helpers.only_data import resolve + +if TYPE_CHECKING: + from pathlib import Path + + from pytest_httpx import HTTPXMock + + from .conftest import BothClients + +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + +client_types = ["standard", "sync"] + +LOCATION_ID = "llllllll-llll-llll-llll-llllllllllll" +TAG_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +LOCATION_FIELDS = ("name", "description", "type", "primary_tag", "tags", "member_of_groups") +HIERARCHY_FIELDS = ("parent", "children", "ancestors", "descendants") +TAG_PEER = {"node": {"id": TAG_ID, "display_label": "blue", "__typename": "BuiltinTag"}} + +AnyNode = InfrahubNode | InfrahubNodeSync + + +def build_node(clients: BothClients, client_type: str, schema: NodeSchemaAPI, data: dict[str, Any] | None) -> AnyNode: + if client_type == "standard": + return InfrahubNode(client=clients.standard, schema=schema, data=data) + return InfrahubNodeSync(client=clients.sync, schema=schema, data=data) + + +def loaded_fields(node: AnyNode, names: tuple[str, ...]) -> dict[str, bool]: + return {name: getattr(node, name).is_loaded for name in names} + + +def every_field(loaded: bool, names: tuple[str, ...] = LOCATION_FIELDS) -> dict[str, bool]: + return dict.fromkeys(names, loaded) + + +def only_these_fields(*loaded: str, names: tuple[str, ...] = LOCATION_FIELDS) -> dict[str, bool]: + return {name: name in loaded for name in names} + + +def hierarchical_location_schema(declares_parent_and_children: bool) -> NodeSchemaAPI: + relationships = [ + {"name": "parent", "peer": "InfraLocation", "optional": True, "cardinality": "one", "kind": "Hierarchy"}, + {"name": "children", "peer": "InfraLocation", "optional": True, "cardinality": "many", "kind": "Hierarchy"}, + ] + schema = NodeSchema( + name="Location", + namespace="Infra", + attributes=[{"name": "name", "kind": "Text"}], + relationships=relationships if declares_parent_and_children else [], + ).convert_api() + # `hierarchy` exists only on the API schema, where the server sets it. + schema.hierarchy = "InfraLocation" + return schema + + +@dataclass +class RecordCase: + name: str + data: dict[str, Any] + expected: dict[str, bool] + + +RECORD_CASES = [ + RecordCase( + name="every-field-in-data", + data={ + "id": LOCATION_ID, + "name": {"value": "DFW"}, + "description": {"value": "Dallas"}, + "type": {"value": "SITE"}, + "primary_tag": TAG_PEER, + "tags": {"count": 1, "edges": [TAG_PEER]}, + "member_of_groups": {"count": 0, "edges": []}, + }, + expected=every_field(loaded=True), + ), + RecordCase(name="only-the-id-in-data", data={"id": LOCATION_ID}, expected=every_field(loaded=False)), + RecordCase( + name="attribute-with-a-null-value", + data={"id": LOCATION_ID, "description": {"value": None}}, + expected=only_these_fields("description"), + ), + RecordCase( + name="cardinality-one-without-a-peer", + data={"id": LOCATION_ID, "primary_tag": {"node": None}}, + expected=only_these_fields("primary_tag"), + ), + RecordCase( + name="cardinality-many-without-peers", + data={"id": LOCATION_ID, "tags": {"count": 0, "edges": []}}, + expected=only_these_fields("tags"), + ), + RecordCase( + name="response-wrapped-in-node", + data={"node": {"id": LOCATION_ID, "name": {"value": "DFW"}, "primary_tag": TAG_PEER}}, + expected=only_these_fields("name", "primary_tag"), + ), + RecordCase( + name="values-given-as-arguments", + data={"id": LOCATION_ID, "name": "DFW", "primary_tag": TAG_ID, "tags": [TAG_ID]}, + expected=only_these_fields("name", "primary_tag", "tags"), + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in RECORD_CASES]) +def test_is_loaded_follows_the_fields_in_the_data( + clients: BothClients, location_schema: NodeSchemaAPI, client_type: str, case: RecordCase +) -> None: + node = build_node(clients=clients, client_type=client_type, schema=location_schema, data=case.data) + + assert loaded_fields(node=node, names=LOCATION_FIELDS) == case.expected + + +@pytest.mark.parametrize("client_type", client_types) +async def test_is_loaded_reads_the_field_names_without_their_alias( + clients: BothClients, location_schema: NodeSchemaAPI, client_type: str +) -> None: + data = { + "node": { + "id": LOCATION_ID, + "__typename": "BuiltinLocation", + "__alias__BuiltinLocation__description": {"value": "Dallas"}, + "__alias__BuiltinLocation__primary_tag": TAG_PEER, + } + } + if client_type == "standard": + node: AnyNode = await InfrahubNode.from_graphql( + client=clients.standard, branch="main", data=data, schema=location_schema + ) + else: + node = InfrahubNodeSync.from_graphql(client=clients.sync, branch="main", data=data, schema=location_schema) + + assert loaded_fields(node=node, names=LOCATION_FIELDS) == only_these_fields("description", "primary_tag") + + +@dataclass +class HierarchyCase: + name: str + declares_parent_and_children: bool + data: dict[str, Any] + expected: dict[str, bool] + + +HIERARCHY_DATA = { + "id": LOCATION_ID, + "parent": {"node": {"id": "parent-1", "__typename": "InfraLocation", "display_label": "USA"}}, + "children": {"count": 1, "edges": [{"node": {"id": "child-1", "__typename": "InfraLocation"}}]}, + "ancestors": {"count": 1, "edges": [{"node": {"id": "parent-1", "__typename": "InfraLocation"}}]}, + "descendants": {"count": 0, "edges": []}, +} + +HIERARCHY_CASES = [ + HierarchyCase( + name="declared-hierarchy-fields-in-data", + declares_parent_and_children=True, + data=HIERARCHY_DATA, + expected=every_field(loaded=True, names=HIERARCHY_FIELDS), + ), + HierarchyCase( + name="declared-hierarchy-fields-not-in-data", + declares_parent_and_children=True, + data={"id": LOCATION_ID}, + expected=every_field(loaded=False, names=HIERARCHY_FIELDS), + ), + HierarchyCase( + name="declared-parent-without-a-peer", + declares_parent_and_children=True, + data={"id": LOCATION_ID, "parent": {"node": None}}, + expected=only_these_fields("parent", names=HIERARCHY_FIELDS), + ), + HierarchyCase( + name="hierarchy-fields-in-data", + declares_parent_and_children=False, + data=HIERARCHY_DATA, + expected=every_field(loaded=True, names=HIERARCHY_FIELDS), + ), + HierarchyCase( + name="hierarchy-fields-not-in-data", + declares_parent_and_children=False, + data={"id": LOCATION_ID}, + expected=every_field(loaded=False, names=HIERARCHY_FIELDS), + ), + HierarchyCase( + name="ancestors-and-descendants-in-data", + declares_parent_and_children=False, + data={"id": LOCATION_ID, "ancestors": HIERARCHY_DATA["ancestors"], "descendants": {"edges": []}}, + expected=only_these_fields("ancestors", "descendants", names=HIERARCHY_FIELDS), + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in HIERARCHY_CASES]) +def test_is_loaded_follows_the_hierarchy_fields_in_the_data( + clients: BothClients, client_type: str, case: HierarchyCase +) -> None: + schema = hierarchical_location_schema(declares_parent_and_children=case.declares_parent_and_children) + node = build_node(clients=clients, client_type=client_type, schema=schema, data=case.data) + + assert {name: node._hierarchical_data[name].is_loaded for name in HIERARCHY_FIELDS} == case.expected + if case.declares_parent_and_children: + assert node._get_relationship_one("parent").is_loaded is case.expected["parent"] + assert node._get_relationship_many("children").is_loaded is case.expected["children"] + + +def assign_value_on_the_attribute(node: AnyNode) -> None: + node._get_attribute("description").value = "Dallas" + + +def assign_value_through_the_node(node: AnyNode) -> None: + node.description = "Dallas" + + +def assign_none_through_the_node(node: AnyNode) -> None: + node.description = None + + +def assign_peer(node: AnyNode) -> None: + node.primary_tag = TAG_ID + + +def clear_peer(node: AnyNode) -> None: + node.primary_tag = None + + +def assign_peers(node: AnyNode) -> None: + node._get_relationship_many("tags").peers = [] + + +@dataclass +class AssignmentCase: + name: str + assign: Callable[[AnyNode], None] + field: str + + +ASSIGNMENT_CASES = [ + AssignmentCase(name="attribute-value-on-the-attribute", assign=assign_value_on_the_attribute, field="description"), + AssignmentCase(name="attribute-value-through-the-node", assign=assign_value_through_the_node, field="description"), + AssignmentCase(name="attribute-none-through-the-node", assign=assign_none_through_the_node, field="description"), + AssignmentCase(name="cardinality-one-peer", assign=assign_peer, field="primary_tag"), + AssignmentCase(name="cardinality-one-cleared", assign=clear_peer, field="primary_tag"), + AssignmentCase(name="cardinality-many-peers", assign=assign_peers, field="tags"), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in ASSIGNMENT_CASES]) +def test_assignment_makes_the_field_loaded( + clients: BothClients, location_schema: NodeSchemaAPI, client_type: str, case: AssignmentCase +) -> None: + node = build_node(clients=clients, client_type=client_type, schema=location_schema, data={"id": LOCATION_ID}) + assert loaded_fields(node=node, names=LOCATION_FIELDS) == every_field(loaded=False) + + case.assign(node) + + assert loaded_fields(node=node, names=LOCATION_FIELDS) == only_these_fields(case.field) + + +@pytest.mark.parametrize("client_type", client_types) +def test_assigning_peers_keeps_the_list_and_the_initialized_flag( + clients: BothClients, location_schema: NodeSchemaAPI, client_type: str +) -> None: + node = build_node(clients=clients, client_type=client_type, schema=location_schema, data={"id": LOCATION_ID}) + tags = node._get_relationship_many("tags") + new_peers: list[Any] = [] + + tags.peers = new_peers + + assert tags.peers is new_peers + assert tags.is_loaded is True + assert tags.initialized is False + + +@pytest.mark.parametrize("client_type", client_types) +def test_peers_returns_the_stored_list(clients: BothClients, location_schema: NodeSchemaAPI, client_type: str) -> None: + data = {"id": LOCATION_ID, "tags": {"count": 1, "edges": [TAG_PEER]}} + node = build_node(clients=clients, client_type=client_type, schema=location_schema, data=data) + tags = node._get_relationship_many("tags") + + tags.peers.clear() + + assert tags.peers == [] + assert tags.peer_ids == [] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_fetch_on_a_manager_makes_it_loaded( + httpx_mock: HTTPXMock, + clients: BothClients, + location_schema: NodeSchemaAPI, + tag_schema: NodeSchemaAPI, + client_type: str, +) -> None: + httpx_mock.add_response( + method="POST", + json={ + "data": { + "BuiltinLocation": { + "count": 1, + "edges": [ + { + "node": { + "id": LOCATION_ID, + "display_label": "dfw1", + "__typename": "BuiltinLocation", + "tags": {"count": 1, "edges": [TAG_PEER]}, + } + } + ], + } + } + }, + match_headers={"X-Infrahub-Tracker": "query-builtinlocation-page1"}, + ) + httpx_mock.add_response( + method="POST", + json={ + "data": { + "BuiltinTag": { + "count": 1, + "edges": [ + { + "node": { + "id": TAG_ID, + "display_label": "blue", + "__typename": "BuiltinTag", + "name": {"value": "blue"}, + "description": {"value": None}, + } + } + ], + } + } + }, + match_headers={"X-Infrahub-Tracker": "query-builtintag-page1"}, + ) + httpx_mock.add_response(method="POST", json={"data": {"BuiltinTag": {"count": 1}}}) + schema = SchemaRootAPI(nodes=[location_schema, tag_schema]) + clients.standard.schema.set_cache(schema=schema) + clients.sync.schema.set_cache(schema=schema) + + node = build_node(clients=clients, client_type=client_type, schema=location_schema, data={"id": LOCATION_ID}) + tags = node._get_relationship_many("tags") + assert tags.is_loaded is False + + if isinstance(tags, RelationshipManager): + await tags.fetch() + else: + tags.fetch() + + assert tags.is_loaded is True + assert tags.peer_ids == [TAG_ID] + assert len([request for request in httpx_mock.get_requests() if request.method == "POST"]) == 3 + + +@pytest.mark.parametrize("client_type", client_types) +async def test_new_node_reports_every_field_loaded_until_it_is_saved( + httpx_mock: HTTPXMock, clients: BothClients, location_schema: NodeSchemaAPI, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"BuiltinLocationCreate": {"ok": True, "object": {"id": LOCATION_ID}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-builtinlocation-create"}, + ) + schema = SchemaRootAPI(nodes=[location_schema]) + clients.standard.schema.set_cache(schema=schema) + clients.sync.schema.set_cache(schema=schema) + + node: AnyNode + if client_type == "standard": + node = await clients.standard.create(kind="BuiltinLocation", name="JFK1", type="SITE") + assert loaded_fields(node=node, names=LOCATION_FIELDS) == every_field(loaded=True) + await node.save() + else: + node = clients.sync.create(kind="BuiltinLocation", name="JFK1", type="SITE") + assert loaded_fields(node=node, names=LOCATION_FIELDS) == every_field(loaded=True) + node.save() + + assert node.id == LOCATION_ID + assert loaded_fields(node=node, names=LOCATION_FIELDS) == only_these_fields("name", "type") + + +@pytest.mark.parametrize("client_type", client_types) +def test_peers_inside_a_manager_are_loaded( + clients: BothClients, location_schema: NodeSchemaAPI, client_type: str +) -> None: + data = {"id": LOCATION_ID, "tags": {"count": 1, "edges": [TAG_PEER]}} + node = build_node(clients=clients, client_type=client_type, schema=location_schema, data=data) + tags = node._get_relationship_many("tags") + + tags.add("rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr") + + assert [peer.is_loaded for peer in tags.peers] == [True, True] + assert tags[0].is_loaded is True + + +@pytest.mark.parametrize("client_type", client_types) +def test_field_objects_built_on_their_own_are_loaded( + clients: BothClients, location_schema: NodeSchemaAPI, client_type: str +) -> None: + node = build_node(clients=clients, client_type=client_type, schema=location_schema, data={"id": LOCATION_ID}) + attribute_schema = location_schema.get_attribute(name="description") + primary_tag_schema = location_schema.get_relationship(name="primary_tag") + + attribute = Attribute(name="description", schema=attribute_schema, data=None) + related_node: RelatedNode | RelatedNodeSync + if isinstance(node, InfrahubNode): + related_node = RelatedNode(client=clients.standard, branch="main", schema=primary_tag_schema, data=None) + else: + related_node = RelatedNodeSync(client=clients.sync, branch="main", schema=primary_tag_schema, data=None) + + assert [attribute.is_loaded, related_node.is_loaded] == [True, True] + + +@pytest.mark.parametrize("client_type", client_types) +def test_fields_that_are_not_loaded_read_as_before( + clients: BothClients, location_schema: NodeSchemaAPI, client_type: str +) -> None: + node = build_node(clients=clients, client_type=client_type, schema=location_schema, data={"id": LOCATION_ID}) + description = node._get_attribute("description") + primary_tag = node._get_relationship_one("primary_tag") + tags = node._get_relationship_many("tags") + assert [description.is_loaded, primary_tag.is_loaded, tags.is_loaded] == [False, False, False] + + assert description.value is None + assert description.value_has_been_mutated is False + assert [ + primary_tag.id, + primary_tag.hfid, + primary_tag.hfid_str, + primary_tag.display_label, + primary_tag.typename, + primary_tag.kind, + ] == [None, None, None, None, None, None] + assert primary_tag.initialized is False + assert primary_tag.is_from_profile is False + with pytest.raises(ValueError, match="Node must have at least one identifier"): + _ = primary_tag.peer + assert [tags.peers, tags.peer_ids, tags.peer_hfids, tags.peer_hfids_str] == [[], [], [], []] + assert tags.initialized is False + assert tags.is_from_profile is False + with pytest.raises(UninitializedError, match="Must call fetch"): + tags.add(TAG_ID) + assert node.get_path_value(path="description__value") is None + assert node.get_path_value(path="primary_tag__name__value") is None + + +@pytest.mark.parametrize("client_type", client_types) +async def test_fetch_on_a_relationship_that_is_not_loaded_raises_as_before( + clients: BothClients, location_schema: NodeSchemaAPI, client_type: str +) -> None: + node = build_node(clients=clients, client_type=client_type, schema=location_schema, data={"id": LOCATION_ID}) + primary_tag = node._get_relationship_one("primary_tag") + assert primary_tag.is_loaded is False + + with pytest.raises(Error, match="Unable to fetch the peer, id and/or typename are not defined"): + if isinstance(primary_tag, RelatedNode): + await primary_tag.fetch() + else: + primary_tag.fetch() + + +@pytest.mark.parametrize("client_type", client_types) +def test_hierarchy_fields_that_are_not_loaded_read_as_before(clients: BothClients, client_type: str) -> None: + schema = hierarchical_location_schema(declares_parent_and_children=False) + node = build_node(clients=clients, client_type=client_type, schema=schema, data={"id": LOCATION_ID}) + parent = node._hierarchical_data["parent"] + assert isinstance(parent, (RelatedNode, RelatedNodeSync)) + assert [parent.is_loaded, parent.id, parent.initialized] == [False, None, False] + + readings = [] + for name in ("children", "ancestors", "descendants"): + manager = node._hierarchical_data[name] + assert isinstance(manager, (RelationshipManager, RelationshipManagerSync)) + readings.append((manager.is_loaded, manager.peers, manager.initialized)) + assert readings == [(False, [], False), (False, [], False), (False, [], False)] + + +DEVICE = "InfraDevice" +SITE = "InfraSite" +TAG = "BuiltinTag" +REGION = "LocationRegion" +CONTRACT = "NetworkContract" +CONTRACT_ID = "cccccccc-cccc-cccc-cccc-cccccccccccc" +CONTRACT_CONTENT = b"contract content" +CONTRACT_CHECKSUM = hashlib.sha1(CONTRACT_CONTENT, usedforsecurity=False).hexdigest() + + +def reference(id: str, typename: str, label: str, **fields: dict[str, Any]) -> dict[str, Any]: + """A peer as the server returns it, with its display label and an HFID made of the label.""" + return {"node": {"id": id, "hfid": [label], "display_label": label, "__typename": typename, **fields}} + + +DEVICE_WITH_NAME = only_data.node_data(only_data.DEVICE_ID, DEVICE, name={"value": "dev1"}) +DEVICE_WITH_HFID = only_data.DEVICE_WITH_HFID +DEVICE_WITH_NAME_AND_SITE = only_data.DEVICE_WITH_NAME_AND_SITE +DEVICE_WITH_EVERY_FIELD = only_data.node_data( + only_data.DEVICE_ID, + DEVICE, + description={"value": "core"}, + site=reference(only_data.SITE_ID, SITE, "Paris", name={"value": "Paris"}, description={"value": None}), + tags={ + "count": 2, + "edges": [ + reference(only_data.TAG_ID, "BuiltinTag", "red", name={"value": "red"}), + reference(only_data.TAG_2_ID, "BuiltinTag", "blue", name={"value": "blue"}), + ], + }, +) +REGION_WITH_NAME = only_data.node_data(only_data.REGION_ID, REGION, name={"value": "Europe"}) +REGION_WITH_PARENT = only_data.node_data( + only_data.REGION_ID, REGION, parent=only_data.peer(only_data.PARENT_ID, REGION, name={"value": "World"}) +) + + +async def query_one( + clients: BothClients, + client_type: str, + httpx_mock: HTTPXMock, + kind: str, + only: list[str], + edge: dict[str, Any], + **kwargs: object, +) -> AnyNode: + """Return the one node that `all(kind, only=...)` builds from a response holding `edge`.""" + only_data.add_page(httpx_mock, kind, [edge]) + nodes = await only_data.query_nodes(clients, client_type, "all", kind=kind, only=only, **kwargs) + return nodes[0] + + +@dataclass +class NotFetched: + kind: str + field: str + only: list[str] + peer_of: str | None = None + + @property + def pattern(self) -> str: + if self.peer_of: + reason = f"the node is a peer of {self.peer_of}, queried with only={self.only!r}." + else: + reason = f"the node was queried with only={self.only!r}." + return re.escape(f"{self.kind}.{self.field} was not fetched: {reason}") + + def check(self, error: FieldNotLoadedError) -> None: + assert [error.kind, error.field, error.only, error.peer_of] == [self.kind, self.field, self.only, self.peer_of] + + +async def fetch_then_read_peer_description(related_node: RelatedNode | RelatedNodeSync) -> object: + await resolve(related_node.fetch()) + return related_node.peer._get_attribute("description").value + + +def cache_no_peer(_field_object: object) -> None: + return None + + +async def fetch_each_peer(manager: RelationshipManager | RelationshipManagerSync) -> None: + for related_node in manager.peers: + await resolve(related_node.fetch()) + + +@dataclass +class ReadCase: + name: str + field: str + read: Callable[[Any], Any] + fetched_value: Any + pages: tuple[tuple[str, list[dict[str, Any]]], ...] = () + # An HFID string needs the peer cached on the relationship, which a fetch sets. + cache_peers: Callable[[Any], Any] = cache_no_peer + + +READ_CASES = [ + ReadCase(name="attribute-value", field="description", read=lambda attribute: attribute.value, fetched_value="core"), + ReadCase(name="related-node-id", field="site", read=lambda rel: rel.id, fetched_value=only_data.SITE_ID), + ReadCase(name="related-node-hfid", field="site", read=lambda rel: rel.hfid, fetched_value=["Paris"]), + ReadCase( + name="related-node-hfid-str", + field="site", + read=lambda rel: rel.hfid_str, + fetched_value="InfraSite__Paris", + pages=((SITE, [only_data.node_data(only_data.SITE_ID, SITE, name={"value": "Paris"})]),), + cache_peers=lambda rel: rel.fetch(), + ), + ReadCase( + name="related-node-display-label", field="site", read=lambda rel: rel.display_label, fetched_value="Paris" + ), + ReadCase(name="related-node-typename", field="site", read=lambda rel: rel.typename, fetched_value=SITE), + # The server sends no kind with a reference. + ReadCase(name="related-node-kind", field="site", read=lambda rel: rel.kind, fetched_value=None), + ReadCase(name="related-node-peer", field="site", read=lambda rel: rel.peer.id, fetched_value=only_data.SITE_ID), + ReadCase(name="related-node-get", field="site", read=lambda rel: rel.get().id, fetched_value=only_data.SITE_ID), + ReadCase( + name="related-node-fetch", + field="site", + read=fetch_then_read_peer_description, + fetched_value="Main site", + pages=( + ( + SITE, + [ + only_data.node_data( + only_data.SITE_ID, SITE, name={"value": "Paris"}, description={"value": "Main site"} + ) + ], + ), + ), + ), + ReadCase( + name="manager-peers", + field="tags", + read=lambda manager: [peer.id for peer in manager.peers], + fetched_value=[only_data.TAG_ID, only_data.TAG_2_ID], + ), + ReadCase( + name="manager-peer-ids", + field="tags", + read=lambda manager: manager.peer_ids, + fetched_value=[only_data.TAG_ID, only_data.TAG_2_ID], + ), + ReadCase( + name="manager-peer-hfids", + field="tags", + read=lambda manager: manager.peer_hfids, + fetched_value=[["red"], ["blue"]], + ), + ReadCase( + name="manager-peer-hfids-str", + field="tags", + read=lambda manager: manager.peer_hfids_str, + fetched_value=["BuiltinTag__red", "BuiltinTag__blue"], + pages=( + (TAG, [only_data.node_data(only_data.TAG_ID, TAG, name={"value": "red"})]), + (TAG, [only_data.node_data(only_data.TAG_2_ID, TAG, name={"value": "blue"})]), + ), + cache_peers=fetch_each_peer, + ), + ReadCase( + name="manager-indexing", field="tags", read=lambda manager: manager[1].id, fetched_value=only_data.TAG_2_ID + ), + ReadCase( + name="manager-iteration", + field="tags", + read=lambda manager: [peer.id for peer in manager], + fetched_value=[only_data.TAG_ID, only_data.TAG_2_ID], + ), + ReadCase( + name="manager-is-from-profile", field="tags", read=lambda manager: manager.is_from_profile, fetched_value=False + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in READ_CASES]) +async def test_read_of_a_field_not_fetched_raises( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: ReadCase +) -> None: + device = await query_one(only_clients, client_type, httpx_mock, kind=DEVICE, only=["name"], edge=DEVICE_WITH_NAME) + field_object = getattr(device, case.field) + expected = NotFetched(kind=DEVICE, field=case.field, only=["name"]) + assert field_object.is_loaded is False + + with pytest.raises(FieldNotLoadedError, match=expected.pattern) as error: + await resolve(case.read(field_object)) + + expected.check(error.value) + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in READ_CASES]) +async def test_read_of_a_fetched_field_returns_its_value( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: ReadCase +) -> None: + device = await query_one( + only_clients, + client_type, + httpx_mock, + kind=DEVICE, + only=["description", "site", "tags"], + edge=DEVICE_WITH_EVERY_FIELD, + prefetch_relationships=True, + ) + for kind, edges in case.pages: + only_data.add_page(httpx_mock, kind, edges) + field_object = getattr(device, case.field) + assert field_object.is_loaded is True + await resolve(case.cache_peers(field_object)) + + assert await resolve(case.read(field_object)) == case.fetched_value + + +ATTRIBUTE_READS_THAT_NEVER_RAISE = ( + "is_loaded", + "name", + "id", + "is_default", + "is_from_profile", + "is_inherited", + "updated_at", + "is_protected", + "source", + "owner", + "updated_by", +) +RELATED_NODE_READS_THAT_NEVER_RAISE = ( + "is_loaded", + "initialized", + "is_resource_pool", + "is_from_profile", + "name", + "is_protected", + "updated_at", + "source", + "owner", +) +MANAGER_READS_THAT_NEVER_RAISE = ("is_loaded", "initialized", "has_update", "name") + + +@pytest.mark.parametrize("client_type", client_types) +async def test_reads_that_never_raise_on_fields_not_fetched( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + device = await query_one(only_clients, client_type, httpx_mock, kind=DEVICE, only=["name"], edge=DEVICE_WITH_NAME) + + site_relationship = device._get_relationship_one("site") + tags_relationship = device._get_relationship_many("tags") + + description = { + name: getattr(device._get_attribute("description"), name) for name in ATTRIBUTE_READS_THAT_NEVER_RAISE + } + site = {name: getattr(site_relationship, name) for name in RELATED_NODE_READS_THAT_NEVER_RAISE} + tags = {name: getattr(tags_relationship, name) for name in MANAGER_READS_THAT_NEVER_RAISE} + + assert description == { + "is_loaded": False, + "name": "description", + "id": None, + "is_default": None, + "is_from_profile": None, + "is_inherited": None, + "updated_at": None, + "is_protected": None, + "source": None, + "owner": None, + "updated_by": None, + } + assert site == { + "is_loaded": False, + "initialized": False, + "is_resource_pool": False, + "is_from_profile": False, + "name": "site", + "is_protected": None, + "updated_at": None, + "source": None, + "owner": None, + } + assert tags == {"is_loaded": False, "initialized": False, "has_update": False, "name": "tags"} + assert [site_relationship.schema.name, tags_relationship.schema.name] == ["site", "tags"] + + +@dataclass +class EditCase: + name: str + edit: Callable[[Any], None] + + +EDIT_CASES = [ + EditCase(name="add", edit=lambda manager: manager.add(only_data.TAG_ID)), + EditCase(name="extend", edit=lambda manager: manager.extend([only_data.TAG_ID])), + EditCase(name="remove", edit=lambda manager: manager.remove(only_data.TAG_ID)), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in EDIT_CASES]) +async def test_edit_of_a_relationship_not_fetched_raises_as_before( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: EditCase +) -> None: + device = await query_one(only_clients, client_type, httpx_mock, kind=DEVICE, only=["name"], edge=DEVICE_WITH_NAME) + tags = device._get_relationship_many("tags") + assert tags.is_loaded is False + + with pytest.raises(UninitializedError, match=re.escape("Must call fetch() on RelationshipManager before editing")): + case.edit(tags) + + assert [tags.is_loaded, tags.has_update] == [False, False] + + +@dataclass +class HierarchyReadCase: + name: str + read: Callable[[Any], Any] + + +HIERARCHY_READ_CASES = [ + HierarchyReadCase(name="parent", read=lambda rel: rel.id), + HierarchyReadCase(name="children", read=lambda manager: manager.peers), + HierarchyReadCase(name="ancestors", read=lambda manager: manager.peers), + HierarchyReadCase(name="descendants", read=lambda manager: manager.peers), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in HIERARCHY_READ_CASES]) +async def test_read_of_a_hierarchy_field_not_fetched_raises( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: HierarchyReadCase +) -> None: + region = await query_one(only_clients, client_type, httpx_mock, kind=REGION, only=["name"], edge=REGION_WITH_NAME) + expected = NotFetched(kind=REGION, field=case.name, only=["name"]) + + for field_object in (getattr(region, case.name), region._hierarchical_data[case.name]): + assert field_object.is_loaded is False + with pytest.raises(FieldNotLoadedError, match=expected.pattern) as error: + case.read(field_object) + expected.check(error.value) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_read_of_a_field_not_fetched_names_the_kind_of_the_node( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + edge = only_data.node_data(only_data.DOG_ID, "TestingDog", name={"value": "Rex"}) + dog = await query_one(only_clients, client_type, httpx_mock, kind="TestingAnimal", only=["name"], edge=edge) + expected = NotFetched(kind="TestingDog", field="owner", only=["name"]) + + with pytest.raises(FieldNotLoadedError, match=expected.pattern) as error: + _ = dog._get_relationship_one("owner").id + + expected.check(error.value) + + +@dataclass +class PeerReadCase: + name: str + kind: str + only: list[str] + edge: dict[str, Any] + peer_id: str + peer_kind: str + peer_name: str + field: str + read: Callable[[Any], Any] + peer_of: str + prefetch_relationships: bool = False + + +PEER_READ_CASES = [ + PeerReadCase( + name="prefetched-site", + kind=DEVICE, + only=["name", "site"], + edge=DEVICE_WITH_NAME_AND_SITE, + peer_id=only_data.SITE_ID, + peer_kind=SITE, + peer_name="Paris", + field="description", + read=lambda attribute: attribute.value, + peer_of="InfraDevice.site", + prefetch_relationships=True, + ), + PeerReadCase( + name="prefetched-parent-cardinality-one", + kind=REGION, + only=["parent"], + edge=REGION_WITH_PARENT, + peer_id=only_data.PARENT_ID, + peer_kind=REGION, + peer_name="World", + field="parent", + read=lambda rel: rel.id, + peer_of="LocationRegion.parent", + prefetch_relationships=True, + ), + PeerReadCase( + name="prefetched-parent-cardinality-many", + kind=REGION, + only=["parent"], + edge=REGION_WITH_PARENT, + peer_id=only_data.PARENT_ID, + peer_kind=REGION, + peer_name="World", + field="children", + read=lambda manager: manager.peer_ids, + peer_of="LocationRegion.parent", + prefetch_relationships=True, + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in PEER_READ_CASES]) +async def test_read_of_a_field_not_fetched_on_a_peer_raises( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: PeerReadCase +) -> None: + await query_one( + only_clients, + client_type, + httpx_mock, + kind=case.kind, + only=case.only, + edge=case.edge, + prefetch_relationships=case.prefetch_relationships, + ) + peer = only_data.client_of(only_clients, client_type).store.get(key=case.peer_id, kind=case.peer_kind) + assert isinstance(peer, (InfrahubNode, InfrahubNodeSync)) + field_object = getattr(peer, case.field) + expected = NotFetched(kind=case.peer_kind, field=case.field, only=case.only, peer_of=case.peer_of) + peer_name = peer._get_attribute("name") + assert [peer_name.is_loaded, peer_name.value, field_object.is_loaded] == [True, case.peer_name, False] + + with pytest.raises(FieldNotLoadedError, match=expected.pattern) as error: + case.read(field_object) + + expected.check(error.value) + + +@dataclass +class NodeReadCase: + name: str + only: list[str] + edge: dict[str, Any] + read: Callable[[Any], Any] + expected_value: Any = None + not_fetched: NotFetched | None = None + prefetch_relationships: bool = False + + +NODE_READ_CASES = [ + NodeReadCase( + name="path-to-an-attribute-not-fetched", + only=["hfid"], + edge=DEVICE_WITH_HFID, + read=lambda node: node.get_path_value(path="description__value"), + not_fetched=NotFetched(kind=DEVICE, field="description", only=["hfid"]), + ), + NodeReadCase( + name="path-to-an-hfid-part-with-hfid-named", + only=["hfid"], + edge=DEVICE_WITH_HFID, + read=lambda node: node.get_path_value(path="name__value"), + not_fetched=NotFetched(kind=DEVICE, field="name", only=["hfid"]), + ), + NodeReadCase( + name="path-through-a-relationship-not-fetched", + only=["name"], + edge=DEVICE_WITH_NAME, + read=lambda node: node.get_path_value(path="site__name__value"), + not_fetched=NotFetched(kind=DEVICE, field="site", only=["name"]), + ), + NodeReadCase( + name="path-to-a-peer-attribute-not-fetched", + only=["name", "site"], + edge=DEVICE_WITH_NAME_AND_SITE, + read=lambda node: node.get_path_value(path="site__description__value"), + not_fetched=NotFetched(kind=SITE, field="description", only=["name", "site"], peer_of="InfraDevice.site"), + prefetch_relationships=True, + ), + NodeReadCase( + name="flat-value-of-an-attribute-not-fetched", + only=["hfid"], + edge=DEVICE_WITH_HFID, + read=lambda node: node.get_flat_value(key="description__value"), + not_fetched=NotFetched(kind=DEVICE, field="description", only=["hfid"]), + ), + NodeReadCase( + name="flat-value-through-a-relationship-not-fetched", + only=["name"], + edge=DEVICE_WITH_NAME, + read=lambda node: node.get_flat_value(key="site__name__value"), + not_fetched=NotFetched(kind=DEVICE, field="site", only=["name"]), + ), + NodeReadCase( + name="path-to-a-fetched-attribute", + only=["name"], + edge=DEVICE_WITH_NAME, + read=lambda node: node.get_path_value(path="name__value"), + expected_value="dev1", + ), + NodeReadCase( + name="path-to-a-fetched-peer-attribute", + only=["name", "site"], + edge=DEVICE_WITH_NAME_AND_SITE, + read=lambda node: node.get_path_value(path="site__name__value"), + expected_value="Paris", + prefetch_relationships=True, + ), + NodeReadCase( + name="flat-value-of-a-fetched-attribute", + only=["name"], + edge=DEVICE_WITH_NAME, + read=lambda node: node.get_flat_value(key="name__value"), + expected_value="dev1", + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in NODE_READ_CASES]) +async def test_node_reads_raise_only_for_fields_not_fetched( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: NodeReadCase +) -> None: + device = await query_one( + only_clients, + client_type, + httpx_mock, + kind=DEVICE, + only=case.only, + edge=case.edge, + prefetch_relationships=case.prefetch_relationships, + ) + + if case.not_fetched is None: + assert await resolve(case.read(device)) == case.expected_value + return + + with pytest.raises(FieldNotLoadedError, match=case.not_fetched.pattern) as error: + await resolve(case.read(device)) + case.not_fetched.check(error.value) + + +@dataclass +class FileCase: + name: str + call: Callable[[Any, Path], Any] + fetched_value: Any + + +FILE_CASES = [ + FileCase( + name="matches-local-checksum", + call=lambda node, _dest: node.matches_local_checksum(source=CONTRACT_CONTENT), + fetched_value=True, + ), + FileCase( + name="download-file-skip-if-unchanged", + call=lambda node, dest: node.download_file(dest=dest, skip_if_unchanged=True), + fetched_value=0, + ), + FileCase( + name="upload-if-changed", + call=lambda node, _dest: node.upload_if_changed(source=CONTRACT_CONTENT, name="contract.pdf"), + fetched_value=UploadResult(was_uploaded=False, checksum=CONTRACT_CHECKSUM), + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in FILE_CASES]) +async def test_file_checks_raise_when_the_checksum_was_not_fetched( + only_clients: BothClients, httpx_mock: HTTPXMock, tmp_path: Path, client_type: str, case: FileCase +) -> None: + dest = tmp_path / "contract.pdf" + dest.write_bytes(CONTRACT_CONTENT) + edge = only_data.node_data(CONTRACT_ID, CONTRACT, file_name={"value": "contract.pdf"}) + contract = await query_one(only_clients, client_type, httpx_mock, kind=CONTRACT, only=["file_name"], edge=edge) + expected = NotFetched(kind=CONTRACT, field="checksum", only=["file_name"]) + + with pytest.raises(FieldNotLoadedError, match=expected.pattern) as error: + await resolve(case.call(contract, dest)) + + expected.check(error.value) + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in FILE_CASES]) +async def test_file_checks_compare_with_a_fetched_checksum( + only_clients: BothClients, httpx_mock: HTTPXMock, tmp_path: Path, client_type: str, case: FileCase +) -> None: + dest = tmp_path / "contract.pdf" + dest.write_bytes(CONTRACT_CONTENT) + edge = only_data.node_data(CONTRACT_ID, CONTRACT, checksum={"value": CONTRACT_CHECKSUM}) + contract = await query_one(only_clients, client_type, httpx_mock, kind=CONTRACT, only=["checksum"], edge=edge) + + assert await resolve(case.call(contract, dest)) == case.fetched_value + + +MUTATION_RESULT_QUERY: dict[str, Any] = {"ok": None, "object": {"id": None}} + + +def add_mutation(httpx_mock: HTTPXMock, mutation: str, id: str) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {mutation: {"ok": True, "object": {"id": id}}}}, + match_headers={"X-Infrahub-Tracker": tracker_of(mutation)}, + ) + + +def tracker_of(mutation: str) -> str: + for action in ("Create", "Update", "Upsert"): + if mutation.endswith(action): + return f"mutation-{mutation.removesuffix(action).lower()}-{action.lower()}" + raise ValueError(f"Unexpected mutation {mutation}") + + +def sent_mutation(httpx_mock: HTTPXMock, mutation: str) -> str: + request = httpx_mock.get_request(match_headers={"X-Infrahub-Tracker": tracker_of(mutation)}) + assert request is not None + return json.loads(request.content)["query"] + + +@dataclass +class UpdateCase: + name: str + kind: str + node_id: str + edge: dict[str, Any] + save: Callable[[Any], Any] + + +UPDATE_CASES = [ + UpdateCase( + name="save-device", + kind=DEVICE, + node_id=only_data.DEVICE_ID, + edge=DEVICE_WITH_NAME, + save=lambda node: node.save(), + ), + UpdateCase( + name="update-device", + kind=DEVICE, + node_id=only_data.DEVICE_ID, + edge=DEVICE_WITH_NAME, + save=lambda node: node.update(), + ), + UpdateCase( + name="save-hierarchical-node", + kind=REGION, + node_id=only_data.REGION_ID, + edge=REGION_WITH_NAME, + save=lambda node: node.save(), + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in UPDATE_CASES]) +async def test_update_of_a_node_from_only_sends_its_id_and_changed_fields( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: UpdateCase +) -> None: + node = await query_one(only_clients, client_type, httpx_mock, kind=case.kind, only=["name"], edge=case.edge) + add_mutation(httpx_mock, f"{case.kind}Update", case.node_id) + + node._get_attribute("name").value = "renamed" + await resolve(case.save(node)) + + expected = Mutation( + mutation=f"{case.kind}Update", + input_data={"data": {"name": {"value": "renamed"}, "id": case.node_id}}, + query=MUTATION_RESULT_QUERY, + ) + assert sent_mutation(httpx_mock, f"{case.kind}Update") == expected.render() + assert [node.id, node._get_attribute("name").value] == [case.node_id, "renamed"] + + +@dataclass +class CreateCase: + name: str + create: Callable[[Any], Any] + mutation: str + + +CREATE_CASES = [ + CreateCase(name="save-with-upsert", create=lambda node: node.save(allow_upsert=True), mutation="InfraDeviceUpsert"), + CreateCase( + name="create-with-upsert", create=lambda node: node.create(allow_upsert=True), mutation="InfraDeviceUpsert" + ), + CreateCase(name="create", create=lambda node: node.create(), mutation="InfraDeviceCreate"), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in CREATE_CASES]) +async def test_create_of_a_node_from_only_sends_its_fetched_fields( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: CreateCase +) -> None: + device = await query_one(only_clients, client_type, httpx_mock, kind=DEVICE, only=["name"], edge=DEVICE_WITH_NAME) + add_mutation(httpx_mock, case.mutation, only_data.DEVICE_ID) + + await resolve(case.create(device)) + + expected = Mutation( + mutation=case.mutation, + input_data={"data": {"name": {"value": "dev1"}, "id": only_data.DEVICE_ID}}, + query=MUTATION_RESULT_QUERY, + ) + assert sent_mutation(httpx_mock, case.mutation) == expected.render() + + +@dataclass +class HfidReadCase: + name: str + only: list[str] + edge: dict[str, Any] + expected: list[Any] + + +HFID_READ_CASES = [ + HfidReadCase( + name="hfid-fields-not-fetched", only=["name"], edge=DEVICE_WITH_NAME, expected=[None, None, None, None] + ), + HfidReadCase( + name="hfid-named", + only=["hfid"], + edge=DEVICE_WITH_HFID, + expected=[["dev1", "Paris"], "InfraDevice__dev1__Paris", ["dev1", "Paris"], "dev1__Paris"], + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in HFID_READ_CASES]) +async def test_hfid_of_a_node_from_only_does_not_raise( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: HfidReadCase +) -> None: + device = await query_one(only_clients, client_type, httpx_mock, kind=DEVICE, only=case.only, edge=case.edge) + + hfid_reads = [ + device.hfid, + device.hfid_str, + device.get_human_friendly_id(), + device.get_human_friendly_id_as_string(), + ] + + assert hfid_reads == case.expected + + +HFID_SITE_SCHEMA = NodeSchema( + name="Site", + namespace="Infra", + human_friendly_id=["name__value"], + attributes=[{"name": "name", "kind": "Text"}], +).convert_api() +HFID_DEVICE_DATA = { + "id": only_data.DEVICE_ID, + "name": {"value": "dev1", "is_protected": True}, + "site": {"node": {"id": only_data.SITE_ID, "hfid": ["Paris"], "display_label": "Paris", "__typename": SITE}}, +} +SITE_WITH_NAME = {"id": only_data.SITE_ID, "name": {"value": "Paris", "is_protected": True}} + + +def device_schema_with_hfid(path: str) -> NodeSchemaAPI: + return NodeSchema( + name="Device", + namespace="Infra", + human_friendly_id=[path], + attributes=[{"name": "name", "kind": "Text"}, {"name": "description", "kind": "Text", "optional": True}], + relationships=[{"name": "site", "peer": SITE, "cardinality": "one", "optional": True}], + ).convert_api() + + +@dataclass +class DefaultHfidCase: + name: str + path: str + path_value: Callable[[Any], Any] + site_data: dict[str, Any] = field(default_factory=lambda: SITE_WITH_NAME) + + +DEFAULT_HFID_CASES = [ + DefaultHfidCase(name="relationship", path="site", path_value=lambda _device: ["Paris"]), + DefaultHfidCase( + name="relationship-to-a-peer-without-hfid", + path="site", + path_value=lambda _device: only_data.SITE_ID, + site_data={"id": only_data.SITE_ID}, + ), + DefaultHfidCase(name="relationship-attribute", path="site__name", path_value=lambda device: device.site.peer.name), + DefaultHfidCase(name="relationship-attribute-value", path="site__name__value", path_value=lambda _device: "Paris"), + DefaultHfidCase( + name="relationship-attribute-property", path="site__name__is_protected", path_value=lambda _device: True + ), + DefaultHfidCase(name="attribute", path="name", path_value=lambda device: device._get_attribute("name")), + DefaultHfidCase(name="attribute-value", path="name__value", path_value=lambda _device: "dev1"), + DefaultHfidCase(name="attribute-property", path="name__is_protected", path_value=lambda _device: True), + DefaultHfidCase(name="attribute-not-in-data", path="description__value", path_value=lambda _device: None), + DefaultHfidCase( + name="attribute-not-in-data-without-property", + path="description", + path_value=lambda device: device._get_attribute("description"), + ), + DefaultHfidCase(name="unknown-first-part", path="rack__name__value", path_value=lambda _device: None), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in DEFAULT_HFID_CASES]) +def test_hfid_of_a_default_node_is_built_from_its_path_values( + clients: BothClients, client_type: str, case: DefaultHfidCase +) -> None: + site = build_node(clients=clients, client_type=client_type, schema=HFID_SITE_SCHEMA, data=case.site_data) + if isinstance(site, InfrahubNode): + clients.standard.store.set(node=site) + else: + clients.sync.store.set(node=site) + device = build_node( + clients=clients, client_type=client_type, schema=device_schema_with_hfid(case.path), data=HFID_DEVICE_DATA + ) + + path_value = device.get_path_value(path=case.path) + expected_hfid = None if path_value is None else [str(path_value)] + + assert path_value == case.path_value(device) + assert [device.hfid, device.get_human_friendly_id()] == [expected_hfid, expected_hfid] + + +@dataclass +class OnlyHfidCase: + name: str + path: str + expected_hfid: Callable[[Any], list[str] | None] + site_data: dict[str, Any] = field(default_factory=lambda: SITE_WITH_NAME) + + +ONLY_HFID_CASES = [ + OnlyHfidCase(name="relationship", path="site", expected_hfid=lambda _device: [str(["Paris"])]), + OnlyHfidCase( + name="relationship-to-a-peer-without-hfid", + path="site", + expected_hfid=lambda _device: [only_data.SITE_ID], + site_data={"id": only_data.SITE_ID}, + ), + OnlyHfidCase( + name="relationship-attribute", + path="site__name", + expected_hfid=lambda device: [str(device.site.peer.name)], + ), + OnlyHfidCase( + name="relationship-attribute-value", path="site__name__value", expected_hfid=lambda _device: ["Paris"] + ), + OnlyHfidCase( + name="relationship-attribute-property", path="site__name__is_protected", expected_hfid=lambda _device: ["True"] + ), + OnlyHfidCase(name="attribute", path="name", expected_hfid=lambda device: [str(device._get_attribute("name"))]), + OnlyHfidCase(name="attribute-value", path="name__value", expected_hfid=lambda _device: ["dev1"]), + OnlyHfidCase(name="attribute-property", path="name__is_protected", expected_hfid=lambda _device: ["True"]), + OnlyHfidCase(name="attribute-not-fetched", path="description__value", expected_hfid=lambda _device: None), + OnlyHfidCase(name="attribute-not-fetched-without-property", path="description", expected_hfid=lambda _device: None), + OnlyHfidCase(name="unknown-first-part", path="rack__name__value", expected_hfid=lambda _device: None), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in ONLY_HFID_CASES]) +def test_hfid_of_a_node_from_only_is_built_from_the_fields_it_fetched( + clients: BothClients, client_type: str, case: OnlyHfidCase +) -> None: + site = build_node(clients=clients, client_type=client_type, schema=HFID_SITE_SCHEMA, data=case.site_data) + if isinstance(site, InfrahubNode): + clients.standard.store.set(node=site) + else: + clients.sync.store.set(node=site) + device = build_node( + clients=clients, client_type=client_type, schema=device_schema_with_hfid(case.path), data=HFID_DEVICE_DATA + ) + device._only_marker = OnlyMarker(only=("name", "site")) + + expected_hfid = case.expected_hfid(device) + + assert [device.hfid, device.get_human_friendly_id()] == [expected_hfid, expected_hfid] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_hfid_of_a_default_node_skips_a_stored_peer_field_that_only_did_not_fetch( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + site_without_name = only_data.node_data(only_data.SITE_ID, SITE, description={"value": "hq"}) + await query_one(only_clients, client_type, httpx_mock, kind=SITE, only=["description"], edge=site_without_name) + device = build_node( + clients=only_clients, + client_type=client_type, + schema=device_schema_with_hfid("site__name__value"), + data=HFID_DEVICE_DATA, + ) + + assert [device.hfid, device.get_human_friendly_id()] == [None, None] + + +@dataclass +class ResponseHfidCase: + name: str + data: dict[str, Any] + expected_hfid: list[str] | None + + +RESPONSE_HFID_CASES = [ + ResponseHfidCase( + name="hfid-fields-in-data", + data={"id": only_data.DEVICE_ID, "hfid": ["from-server"], "name": {"value": "dev1"}}, + expected_hfid=["dev1"], + ), + ResponseHfidCase( + name="hfid-fields-not-in-data", + data={"id": only_data.DEVICE_ID, "hfid": ["from-server"]}, + expected_hfid=None, + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in RESPONSE_HFID_CASES]) +def test_hfid_of_a_default_node_is_calculated_and_never_read_from_the_response( + clients: BothClients, client_type: str, case: ResponseHfidCase +) -> None: + device = build_node( + clients=clients, client_type=client_type, schema=device_schema_with_hfid("name__value"), data=case.data + ) + + assert [device.hfid, device.get_human_friendly_id()] == [case.expected_hfid, case.expected_hfid] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_store_accepts_a_node_from_only( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + client = only_data.client_of(only_clients, client_type) + device = await query_one( + only_clients, client_type, httpx_mock, kind=DEVICE, only=["name"], edge=DEVICE_WITH_NAME, populate_store=False + ) + assert client.store.count() == 0 + + if isinstance(device, InfrahubNode): + only_clients.standard.store.set(node=device) + else: + only_clients.sync.store.set(node=device) + + assert client.store.get(key=only_data.DEVICE_ID, kind=DEVICE) is device + + +@pytest.mark.parametrize("client_type", client_types) +async def test_mutation_result_on_a_node_from_only( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + device = await query_one(only_clients, client_type, httpx_mock, kind=DEVICE, only=["name"], edge=DEVICE_WITH_NAME) + response = { + "InfraDeviceUpdate": {"ok": True, "object": {"id": only_data.DEVICE_ID, "description": {"value": "edge"}}} + } + + await resolve(device._process_mutation_result(mutation_name="InfraDeviceUpdate", response=response)) + + assert [device.id, device._get_attribute("description").is_loaded] == [only_data.DEVICE_ID, False] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_repr_of_a_node_from_only(only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str) -> None: + device = await query_one(only_clients, client_type, httpx_mock, kind=DEVICE, only=["name"], edge=DEVICE_WITH_NAME) + + assert [repr(device), str(device)] == [f"InfraDevice ({only_data.DEVICE_ID}) "] * 2 diff --git a/tests/unit/sdk/test_node_only_helpers.py b/tests/unit/sdk/test_node_only_helpers.py new file mode 100644 index 000000000..680794a67 --- /dev/null +++ b/tests/unit/sdk/test_node_only_helpers.py @@ -0,0 +1,468 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk.exceptions import FieldNotFoundError, FieldNotLoadedError, FieldSelectionConflictError +from infrahub_sdk.node._only import ( # noqa: PLC2701 # the helpers under test are private to the package + OnlyMarker, + check_only_conflict, + check_only_names, + check_only_names_on_every_kind, + field_names, + only_query_fields, +) +from infrahub_sdk.schema import GenericSchemaAPI, NodeSchemaAPI + +if TYPE_CHECKING: + from infrahub_sdk.schema import MainSchemaTypesAPI + +ANIMAL_ATTRIBUTES = [{"name": "name", "kind": "Text"}, {"name": "weight", "kind": "Number", "optional": True}] +ANIMAL_RELATIONSHIPS = [ + {"name": "owner", "peer": "TestingPerson", "cardinality": "one", "identifier": "person__animal"}, + {"name": "best_friend", "peer": "TestingPerson", "cardinality": "one", "optional": True}, +] +ANIMAL_HFID = ["owner__name__value", "name__value"] +BREED_AND_COLOR = [{"name": "breed", "kind": "Text"}, {"name": "color", "kind": "Color", "optional": True}] +HIERARCHY_RELATIONSHIPS = [ + {"name": "parent", "peer": "LocationGeneric", "cardinality": "one", "kind": "Hierarchy", "optional": True}, + {"name": "children", "peer": "LocationGeneric", "cardinality": "many", "kind": "Hierarchy", "optional": True}, +] + +# Implementing kinds carry the generic's fields, as the server returns them. +ANIMAL = GenericSchemaAPI( + name="Animal", + namespace="Testing", + human_friendly_id=ANIMAL_HFID, + used_by=["TestingCat", "TestingDog"], + attributes=ANIMAL_ATTRIBUTES, + relationships=ANIMAL_RELATIONSHIPS, +) +CAT = NodeSchemaAPI( + name="Cat", + namespace="Testing", + inherit_from=["TestingAnimal"], + human_friendly_id=["owner__name__value", "name__value", "color__value"], + attributes=ANIMAL_ATTRIBUTES + BREED_AND_COLOR, + relationships=ANIMAL_RELATIONSHIPS, +) +DOG = NodeSchemaAPI( + name="Dog", + namespace="Testing", + inherit_from=["TestingAnimal"], + human_friendly_id=ANIMAL_HFID, + attributes=ANIMAL_ATTRIBUTES + BREED_AND_COLOR, + relationships=ANIMAL_RELATIONSHIPS, +) +PERSON = NodeSchemaAPI( + name="Person", + namespace="Testing", + human_friendly_id=["name__value"], + attributes=[{"name": "name", "kind": "Text"}, {"name": "height", "kind": "Number", "optional": True}], + relationships=[{"name": "animals", "peer": "TestingAnimal", "cardinality": "many"}], +) +LOCATION_GENERIC = GenericSchemaAPI( + name="Generic", + namespace="Location", + hierarchical=True, + used_by=["LocationSite"], + attributes=[{"name": "name", "kind": "Text"}], + relationships=HIERARCHY_RELATIONSHIPS, +) +SITE = NodeSchemaAPI( + name="Site", + namespace="Location", + inherit_from=["LocationGeneric"], + hierarchy="LocationGeneric", + attributes=[{"name": "name", "kind": "Text"}, {"name": "facility", "kind": "Text"}], + relationships=HIERARCHY_RELATIONSHIPS, +) +PLACE = GenericSchemaAPI( + name="Place", + namespace="Infra", + used_by=["LocationSite"], + attributes=[{"name": "name", "kind": "Text"}], +) +SITE_WITH_HFID = NodeSchemaAPI( + name="Site", + namespace="Location", + inherit_from=["LocationGeneric", "InfraPlace"], + hierarchy="LocationGeneric", + human_friendly_id=["name__value"], + attributes=[{"name": "name", "kind": "Text"}, {"name": "facility", "kind": "Text"}], + relationships=HIERARCHY_RELATIONSHIPS, +) + + +@pytest.fixture +def schemas(location_schema: NodeSchemaAPI) -> dict[str, MainSchemaTypesAPI]: + return { + "location": location_schema, + "animal": ANIMAL, + "cat": CAT, + "dog": DOG, + "person": PERSON, + "location-generic": LOCATION_GENERIC, + "site": SITE, + "site-with-hfid": SITE_WITH_HFID, + "place": PLACE, + } + + +@dataclass +class ConflictCase: + name: str + only: list[str] | None + include: list[str] | None + exclude: list[str] | None + + +CONFLICT_CASES = [ + ConflictCase(name="only-and-include", only=["name"], include=["tags"], exclude=None), + ConflictCase(name="only-and-exclude", only=["name"], include=None, exclude=["tags"]), + ConflictCase(name="only-and-empty-include", only=["name"], include=[], exclude=None), + ConflictCase(name="only-and-empty-exclude", only=["name"], include=None, exclude=[]), + ConflictCase(name="empty-only-and-include", only=[], include=["tags"], exclude=None), + ConflictCase(name="only-and-both", only=["name"], include=["tags"], exclude=["description"]), +] + +NO_CONFLICT_CASES = [ + ConflictCase(name="nothing", only=None, include=None, exclude=None), + ConflictCase(name="include-and-exclude-without-only", only=None, include=["tags"], exclude=["description"]), + ConflictCase(name="empty-include-without-only", only=None, include=[], exclude=None), + ConflictCase(name="only-alone", only=["name"], include=None, exclude=None), + ConflictCase(name="empty-only-alone", only=[], include=None, exclude=None), +] + + +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in CONFLICT_CASES]) +def test_conflict_check_raises_when_only_meets_include_or_exclude(case: ConflictCase) -> None: + with pytest.raises( + FieldSelectionConflictError, match=re.escape("only cannot be combined with include or exclude.") + ): + check_only_conflict(only=case.only, include=case.include, exclude=case.exclude) + + +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in NO_CONFLICT_CASES]) +def test_conflict_check_passes_when_one_side_is_none(case: ConflictCase) -> None: + assert check_only_conflict(only=case.only, include=case.include, exclude=case.exclude) is None + + +@dataclass +class FieldNamesCase: + name: str + schema: str + expected: set[str] + + +FIELD_NAMES_CASES = [ + FieldNamesCase( + name="plain-node", + schema="location", + expected={"name", "description", "type", "tags", "primary_tag", "member_of_groups"}, + ), + FieldNamesCase( + name="hierarchical-node", + schema="site", + expected={"name", "facility", "parent", "children", "ancestors", "descendants"}, + ), + FieldNamesCase( + name="hierarchical-generic", + schema="location-generic", + expected={"name", "parent", "children", "ancestors", "descendants"}, + ), + FieldNamesCase(name="plain-generic", schema="animal", expected={"name", "weight", "owner", "best_friend"}), +] + + +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in FIELD_NAMES_CASES]) +def test_field_names_of_one_kind(case: FieldNamesCase, schemas: dict[str, MainSchemaTypesAPI]) -> None: + assert field_names(schemas[case.schema]) == case.expected + + +@dataclass +class NameCheckCase: + name: str + only: list[str] + schema: str + implementing: list[str] + unknown_field: str | None + + +ACCEPTED_NAME_CASES = [ + NameCheckCase( + name="attributes-and-relationships", + only=["name", "description", "tags", "primary_tag"], + schema="location", + implementing=[], + unknown_field=None, + ), + NameCheckCase( + name="identifiers", + only=["id", "display_label", "hfid"], + schema="location", + implementing=[], + unknown_field=None, + ), + NameCheckCase(name="empty", only=[], schema="location", implementing=[], unknown_field=None), + NameCheckCase(name="duplicates", only=["name", "name"], schema="location", implementing=[], unknown_field=None), + NameCheckCase( + name="hierarchy-on-a-hierarchical-node", + only=["parent", "children", "ancestors", "descendants"], + schema="site", + implementing=[], + unknown_field=None, + ), + NameCheckCase( + name="generic-own-fields", + only=["name", "owner"], + schema="animal", + implementing=["cat", "dog"], + unknown_field=None, + ), + NameCheckCase( + name="generic-fields-of-implementing-kinds", + only=["breed", "color"], + schema="animal", + implementing=["cat", "dog"], + unknown_field=None, + ), + NameCheckCase( + name="hierarchy-on-a-hierarchical-generic", + only=["ancestors", "descendants"], + schema="location-generic", + implementing=[], + unknown_field=None, + ), + NameCheckCase( + name="hierarchy-through-a-hierarchical-implementing-kind", + only=["parent", "descendants"], + schema="place", + implementing=["site"], + unknown_field=None, + ), +] + +REJECTED_NAME_CASES = [ + NameCheckCase(name="misspelt", only=["nmae"], schema="location", implementing=[], unknown_field="nmae"), + NameCheckCase( + name="first-unknown-in-order", + only=["name", "descriptin", "tgas"], + schema="location", + implementing=[], + unknown_field="descriptin", + ), + NameCheckCase( + name="parent-on-a-plain-node", only=["parent"], schema="location", implementing=[], unknown_field="parent" + ), + NameCheckCase( + name="children-on-a-plain-node", only=["children"], schema="location", implementing=[], unknown_field="children" + ), + NameCheckCase( + name="ancestors-on-a-plain-node", + only=["ancestors"], + schema="location", + implementing=[], + unknown_field="ancestors", + ), + NameCheckCase( + name="descendants-on-a-plain-node", + only=["descendants"], + schema="location", + implementing=[], + unknown_field="descendants", + ), + NameCheckCase( + name="generic-without-implementing-kinds", + only=["breed"], + schema="animal", + implementing=[], + unknown_field="breed", + ), + NameCheckCase( + name="generic-name-on-no-kind", + only=["name", "height"], + schema="animal", + implementing=["cat", "dog"], + unknown_field="height", + ), + NameCheckCase( + name="hierarchy-on-a-plain-generic", + only=["ancestors"], + schema="animal", + implementing=["cat", "dog"], + unknown_field="ancestors", + ), + NameCheckCase( + name="hierarchy-on-a-plain-generic-without-implementing-kinds", + only=["descendants"], + schema="place", + implementing=[], + unknown_field="descendants", + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in ACCEPTED_NAME_CASES]) +def test_name_check_accepts(case: NameCheckCase, schemas: dict[str, MainSchemaTypesAPI]) -> None: + implementing = [schemas[kind] for kind in case.implementing] + + assert check_only_names(only=case.only, schema=schemas[case.schema], implementing_schemas=implementing) is None + + +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in REJECTED_NAME_CASES]) +def test_name_check_rejects(case: NameCheckCase, schemas: dict[str, MainSchemaTypesAPI]) -> None: + schema = schemas[case.schema] + implementing = [schemas[kind] for kind in case.implementing] + expected_message = f"'{case.unknown_field}' is not an attribute or relationship of {schema.kind}." + + with pytest.raises(FieldNotFoundError, match=re.escape(expected_message)) as exc_info: + check_only_names(only=case.only, schema=schema, implementing_schemas=implementing) + + assert (exc_info.value.kind, exc_info.value.field) == (schema.kind, case.unknown_field) + + +@dataclass +class PeerCheckCase: + name: str + only: list[str] + peer_kinds: list[str] + unknown_kind: str | None + unknown_field: str | None + + +ACCEPTED_PEER_CASES = [ + PeerCheckCase( + name="every-kind-has-every-name", + only=["name", "breed", "color"], + peer_kinds=["cat", "dog"], + unknown_kind=None, + unknown_field=None, + ), + PeerCheckCase( + name="identifiers-on-every-kind", + only=["id", "display_label", "hfid"], + peer_kinds=["cat", "person"], + unknown_kind=None, + unknown_field=None, + ), + PeerCheckCase(name="no-peer-kinds", only=["anything"], peer_kinds=[], unknown_kind=None, unknown_field=None), +] + +REJECTED_PEER_CASES = [ + PeerCheckCase( + name="missing-on-the-second-kind", + only=["name", "weight"], + peer_kinds=["cat", "person"], + unknown_kind="TestingPerson", + unknown_field="weight", + ), + PeerCheckCase( + name="missing-on-the-first-kind", + only=["name", "height"], + peer_kinds=["cat", "person"], + unknown_kind="TestingCat", + unknown_field="height", + ), + PeerCheckCase( + name="on-an-implementing-kind-but-not-on-the-other-peer", + only=["breed"], + peer_kinds=["dog", "person"], + unknown_kind="TestingPerson", + unknown_field="breed", + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in ACCEPTED_PEER_CASES]) +def test_peer_check_accepts(case: PeerCheckCase, schemas: dict[str, MainSchemaTypesAPI]) -> None: + peer_schemas = [schemas[kind] for kind in case.peer_kinds] + + assert check_only_names_on_every_kind(only=case.only, schemas=peer_schemas) is None + + +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in REJECTED_PEER_CASES]) +def test_peer_check_rejects_a_name_missing_on_one_kind( + case: PeerCheckCase, schemas: dict[str, MainSchemaTypesAPI] +) -> None: + peer_schemas = [schemas[kind] for kind in case.peer_kinds] + expected_message = f"'{case.unknown_field}' is not an attribute or relationship of {case.unknown_kind}." + + with pytest.raises(FieldNotFoundError, match=re.escape(expected_message)) as exc_info: + check_only_names_on_every_kind(only=case.only, schemas=peer_schemas) + + assert (exc_info.value.kind, exc_info.value.field) == (case.unknown_kind, case.unknown_field) + + +@dataclass +class HfidRequestCase: + name: str + only: list[str] + schema: str + implementing: list[str] + expected: bool + + +HFID_REQUEST_CASES = [ + HfidRequestCase(name="node-kind-with-hfid", only=["hfid"], schema="cat", implementing=[], expected=True), + HfidRequestCase(name="hfid-not-named", only=["name", "color"], schema="cat", implementing=[], expected=False), + HfidRequestCase(name="node-kind-without-hfid", only=["hfid"], schema="location", implementing=[], expected=False), + HfidRequestCase( + name="generic-with-hfid", only=["hfid"], schema="animal", implementing=["cat", "dog"], expected=True + ), + HfidRequestCase( + name="generic-without-hfid-implemented-by-a-kind-with-hfid", + only=["hfid"], + schema="place", + implementing=["site-with-hfid"], + expected=True, + ), + HfidRequestCase( + name="generic-and-implementing-kind-without-hfid", + only=["hfid"], + schema="place", + implementing=["site"], + expected=False, + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in HFID_REQUEST_CASES]) +def test_hfid_is_requested_when_named_and_a_kind_has_one( + case: HfidRequestCase, schemas: dict[str, MainSchemaTypesAPI] +) -> None: + schema = schemas[case.schema] + implementing = [schemas[kind] for kind in case.implementing] + + fields = only_query_fields(only=case.only, schema=schema, implementing_schemas=implementing) + without_hfid = only_query_fields( + only=[name for name in case.only if name != "hfid"], schema=schema, implementing_schemas=implementing + ) + + assert fields.hfid is case.expected + assert [fields.node, fields.implementing_kinds] == [without_hfid.node, without_hfid.implementing_kinds] + + +def test_marker_builds_the_error_for_a_queried_node() -> None: + marker = OnlyMarker(only=("name",)) + + error = marker.not_loaded_error(kind="InfraDevice", field="description") + + assert isinstance(error, FieldNotLoadedError) + assert (error.kind, error.field, error.only, error.peer_of) == ("InfraDevice", "description", ["name"], None) + + +def test_marker_builds_the_error_for_a_peer() -> None: + marker = OnlyMarker(only=("site",), peer_of="InfraDevice.site") + + error = marker.not_loaded_error(kind="InfraSite", field="description") + + assert (error.kind, error.field, error.only, error.peer_of) == ( + "InfraSite", + "description", + ["site"], + "InfraDevice.site", + ) diff --git a/tests/unit/sdk/test_node_only_query.py b/tests/unit/sdk/test_node_only_query.py new file mode 100644 index 000000000..99e098366 --- /dev/null +++ b/tests/unit/sdk/test_node_only_query.py @@ -0,0 +1,861 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk.exceptions import FieldNotFoundError +from infrahub_sdk.graphql import Query +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync +from infrahub_sdk.node._only import ( # noqa: PLC2701 # the helpers under test are private to the package + OnlyMarker, + only_query_fields, +) +from infrahub_sdk.schema import GenericSchemaAPI +from infrahub_sdk.types import Order +from tests.helpers import only_data + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk.schema import NodeSchemaAPI + from tests.unit.sdk.conftest import BothClients + +client_types = ["standard", "sync"] + +REFERENCE: dict[str, Any] = {"id": None, "hfid": None, "display_label": None, "__typename": None} +VALUE: dict[str, Any] = {"value": None} +OBJECT: dict[str, Any] = {"id": None, "display_label": None, "__typename": None} +ATTRIBUTE_PROPERTIES: dict[str, Any] = { + "value": None, + "is_default": None, + "is_from_profile": None, + "is_protected": None, + "updated_at": None, + "source": OBJECT, + "owner": OBJECT, +} +RELATIONSHIP_PROPERTIES: dict[str, Any] = {"is_protected": None, "updated_at": None, "source": OBJECT, "owner": OBJECT} +ATTRIBUTE_METADATA: dict[str, Any] = {"value": None, "updated_at": None, "updated_by": OBJECT} +NODE_METADATA: dict[str, Any] = {"created_at": None, "created_by": OBJECT, "updated_at": None, "updated_by": OBJECT} +RELATIONSHIP_METADATA: dict[str, Any] = {"updated_at": None, "updated_by": OBJECT} + +LOCATION_DEFAULT_FIELDS: dict[str, Any] = { + "name": VALUE, + "description": VALUE, + "type": VALUE, + "primary_tag": {"node": REFERENCE}, +} +TAG_DEFAULT_FIELDS: dict[str, Any] = {"name": VALUE, "description": VALUE} +PERSON_DEFAULT_FIELDS: dict[str, Any] = {"name": VALUE, "height": VALUE} + +TAG = { + "name": "Tag", + "namespace": "Builtin", + "default_filter": "name__value", + "attributes": [ + {"name": "name", "kind": "Text", "unique": True}, + {"name": "description", "kind": "Text", "optional": True}, + ], +} +ANIMAL_ATTRIBUTES = [{"name": "name", "kind": "Text"}, {"name": "weight", "kind": "Number", "optional": True}] +ANIMAL_RELATIONSHIPS = [ + {"name": "owner", "peer": "TestingPerson", "cardinality": "one", "identifier": "person__animal"}, + {"name": "best_friend", "peer": "TestingPerson", "cardinality": "one", "optional": True}, +] +HIERARCHY_RELATIONSHIPS = [ + {"name": "parent", "peer": "LocationGeneric", "cardinality": "one", "kind": "Hierarchy", "optional": True}, + {"name": "children", "peer": "LocationGeneric", "cardinality": "many", "kind": "Hierarchy", "optional": True}, +] + + +def schema_payload(rack: NodeSchemaAPI, location: NodeSchemaAPI, grown: bool = False) -> dict[str, Any]: + """The schemas of the tests, as the schema endpoint returns them. + + With `grown`, the rack, the animal generic (and the kinds that inherit its fields) and the dog + each gain an attribute and a relationship that no test names in `only`, and the location and the + person, whose names are parts of HFIDs, each gain an attribute. + """ + rack_data = rack.model_dump() + location_data = location.model_dump() + person_attributes = [{"name": "name", "kind": "Text"}, {"name": "height", "kind": "Number", "optional": True}] + animal_attributes = list(ANIMAL_ATTRIBUTES) + animal_relationships = list(ANIMAL_RELATIONSHIPS) + dog_attributes = [{"name": "breed", "kind": "Text"}] + dog_relationships = [{"name": "walker", "peer": "TestingPerson", "cardinality": "one", "optional": True}] + if grown: + rack_data["attributes"].append({"name": "serial", "kind": "Text", "optional": True}) + rack_data["relationships"].append({"name": "owner", "peer": "TestingPerson", "cardinality": "one"}) + animal_attributes.append({"name": "nickname", "kind": "Text", "optional": True}) + animal_relationships.append({"name": "vet", "peer": "TestingPerson", "cardinality": "one", "optional": True}) + dog_attributes.append({"name": "pattern", "kind": "Text", "optional": True}) + dog_relationships.append({"name": "rival", "peer": "TestingPerson", "cardinality": "one", "optional": True}) + location_data["attributes"].append({"name": "latitude", "kind": "Number", "optional": True}) + person_attributes.append({"name": "age", "kind": "Number", "optional": True}) + + return { + "version": "1.0", + "generics": [ + { + "name": "Animal", + "namespace": "Testing", + "human_friendly_id": ["owner__name__value", "name__value"], + "used_by": ["TestingCat", "TestingDog"], + "attributes": animal_attributes, + "relationships": animal_relationships, + }, + { + "name": "Generic", + "namespace": "Location", + "hierarchical": True, + "used_by": ["LocationSite"], + "attributes": [{"name": "name", "kind": "Text"}], + "relationships": HIERARCHY_RELATIONSHIPS, + }, + { + "name": "Place", + "namespace": "Infra", + "used_by": ["LocationSite"], + "attributes": [{"name": "name", "kind": "Text"}], + }, + ], + "nodes": [ + rack_data, + location_data, + TAG, + { + "name": "Person", + "namespace": "Testing", + "human_friendly_id": ["name__value"], + "attributes": person_attributes, + "relationships": [ + {"name": "animals", "peer": "TestingAnimal", "cardinality": "many", "identifier": "person__animal"} + ], + }, + { + "name": "Cat", + "namespace": "Testing", + "inherit_from": ["TestingAnimal"], + "human_friendly_id": ["owner__name__value", "name__value", "color__value"], + "attributes": [ + *animal_attributes, + {"name": "breed", "kind": "Text"}, + {"name": "color", "kind": "Color", "optional": True}, + ], + "relationships": animal_relationships, + }, + { + "name": "Dog", + "namespace": "Testing", + "inherit_from": ["TestingAnimal"], + "human_friendly_id": ["owner__name__value", "name__value"], + "attributes": [*animal_attributes, *dog_attributes], + "relationships": [*animal_relationships, *dog_relationships], + }, + { + "name": "Site", + "namespace": "Location", + "inherit_from": ["LocationGeneric", "InfraPlace"], + "hierarchy": "LocationGeneric", + "attributes": [ + {"name": "name", "kind": "Text"}, + {"name": "facility", "kind": "Text", "optional": True}, + ], + "relationships": HIERARCHY_RELATIONSHIPS, + }, + ], + } + + +@pytest.fixture +async def query_clients(clients: BothClients, schema_with_hfid: dict[str, NodeSchemaAPI]) -> BothClients: + payload = schema_payload(rack=schema_with_hfid["rack"], location=schema_with_hfid["location"]) + clients.standard.schema.set_cache(payload) + clients.sync.schema.set_cache(payload) + return clients + + +def expected_query( + kind: str, + node_fields: dict[str, Any], + filters: dict[str, Any] | None = None, + include_metadata: bool = False, + hfid: bool = False, +) -> dict[str, Any]: + """The whole query: the queried node always gets `id`, `display_label` and `__typename`, and `hfid` with `hfid`.""" + identifiers: dict[str, Any] = {"id": None, "hfid": None} if hfid else {"id": None} + edges: dict[str, Any] = {"node": {**identifiers, "display_label": None, "__typename": None, **node_fields}} + if include_metadata: + edges["node_metadata"] = NODE_METADATA + return {kind: {"count": None, "@filters": filters or {}, "edges": edges}} + + +def one(peer_fields: dict[str, Any] | None = None, **edge: dict[str, Any]) -> dict[str, Any]: + return {"node": {**REFERENCE, **(peer_fields or {})}, **edge} + + +def many(peer_fields: dict[str, Any] | None = None, **edge: dict[str, Any]) -> dict[str, Any]: + return {"edges": {"node": {**REFERENCE, **(peer_fields or {})}, **edge}} + + +def hierarchy_one(peer_fields: dict[str, Any] | None = None, **edge: dict[str, Any]) -> dict[str, Any]: + return {"node": {"...on LocationGeneric": {**REFERENCE, **(peer_fields or {})}}, **edge} + + +def hierarchy_many(peer_fields: dict[str, Any] | None = None) -> dict[str, Any]: + return {"edges": {"node": {"...on LocationGeneric": {**REFERENCE, **(peer_fields or {})}}}} + + +def aliased(kind: str, name: str, field_data: dict[str, Any]) -> dict[str, Any]: + return {"@alias": f"__alias__{kind}__{name}", **field_data} + + +async def generate_only_query( + clients: BothClients, + client_type: str, + kind: str, + only: list[str], + filters: dict[str, Any] | None = None, + offset: int | str | None = None, + limit: int | str | None = None, + prefetch_relationships: bool = False, + partial_match: bool = False, + property: bool = False, + order: Order | None = None, + include_metadata: bool = False, +) -> dict[str, Any]: + if client_type == "standard": + schema = await clients.standard.schema.get(kind=kind) + implementing_schemas = ( + [await clients.standard.schema.get(kind=name) for name in schema.used_by] + if isinstance(schema, GenericSchemaAPI) + else [] + ) + node = InfrahubNode(client=clients.standard, schema=schema) + return await node._generate_only_query_data( + fields=only_query_fields(only=only, schema=schema, implementing_schemas=implementing_schemas), + filters=filters, + offset=offset, + limit=limit, + prefetch_relationships=prefetch_relationships, + partial_match=partial_match, + property=property, + order=order, + include_metadata=include_metadata, + ) + schema = clients.sync.schema.get(kind=kind) + implementing_schemas = ( + [clients.sync.schema.get(kind=name) for name in schema.used_by] if isinstance(schema, GenericSchemaAPI) else [] + ) + node_sync = InfrahubNodeSync(client=clients.sync, schema=schema) + return node_sync._generate_only_query_data( + fields=only_query_fields(only=only, schema=schema, implementing_schemas=implementing_schemas), + filters=filters, + offset=offset, + limit=limit, + prefetch_relationships=prefetch_relationships, + partial_match=partial_match, + property=property, + order=order, + include_metadata=include_metadata, + ) + + +async def generate_default_query( + clients: BothClients, + client_type: str, + kind: str, + filters: dict[str, Any] | None = None, + offset: int | str | None = None, + limit: int | str | None = None, + include: list[str] | None = None, + fragment: bool = False, + partial_match: bool = False, + order: Order | None = None, +) -> dict[str, Any]: + if client_type == "standard": + schema = await clients.standard.schema.get(kind=kind) + return await InfrahubNode(client=clients.standard, schema=schema).generate_query_data( + filters=filters, + offset=offset, + limit=limit, + include=include, + fragment=fragment, + partial_match=partial_match, + order=order, + ) + schema = clients.sync.schema.get(kind=kind) + return InfrahubNodeSync(client=clients.sync, schema=schema).generate_query_data( + filters=filters, + offset=offset, + limit=limit, + include=include, + fragment=fragment, + partial_match=partial_match, + order=order, + ) + + +@dataclass +class OnlyQueryCase: + name: str + kind: str + only: list[str] + expected: dict[str, Any] + prefetch_relationships: bool = False + property: bool = False + include_metadata: bool = False + + +ONLY_QUERY_CASES = [ + OnlyQueryCase( + name="one-attribute", + kind="BuiltinLocation", + only=["name"], + expected=expected_query("BuiltinLocation", {"name": VALUE}), + ), + OnlyQueryCase(name="empty", kind="BuiltinLocation", only=[], expected=expected_query("BuiltinLocation", {})), + OnlyQueryCase( + name="id-and-display-label", + kind="BuiltinLocation", + only=["id", "display_label"], + expected=expected_query("BuiltinLocation", {}), + ), + OnlyQueryCase( + name="duplicate-names", + kind="BuiltinLocation", + only=["name", "id", "name"], + expected=expected_query("BuiltinLocation", {"name": VALUE}), + ), + OnlyQueryCase( + name="relationships-as-references", + kind="BuiltinRack", + only=["location", "tags", "member_of_groups"], + expected=expected_query("BuiltinRack", {"location": one(), "tags": many(), "member_of_groups": many()}), + ), + OnlyQueryCase( + name="prefetch-cardinality-one", + kind="BuiltinRack", + only=["location"], + prefetch_relationships=True, + expected=expected_query("BuiltinRack", {"location": one(LOCATION_DEFAULT_FIELDS)}), + ), + OnlyQueryCase( + name="prefetch-only-named-relationships", + kind="BuiltinRack", + only=["facility_id", "tags"], + prefetch_relationships=True, + expected=expected_query("BuiltinRack", {"facility_id": VALUE, "tags": many(TAG_DEFAULT_FIELDS)}), + ), + OnlyQueryCase( + name="parent-as-reference", + kind="LocationSite", + only=["parent"], + expected=expected_query("LocationSite", {"parent": hierarchy_one()}), + ), + OnlyQueryCase( + name="children-ancestors-descendants-as-references", + kind="LocationSite", + only=["children", "ancestors", "descendants"], + expected=expected_query( + "LocationSite", + {"children": hierarchy_many(), "ancestors": hierarchy_many(), "descendants": hierarchy_many()}, + ), + ), + OnlyQueryCase( + name="hierarchy-fields-with-prefetch", + kind="LocationSite", + only=["parent", "descendants"], + prefetch_relationships=True, + expected=expected_query( + "LocationSite", + {"parent": hierarchy_one({"name": VALUE}), "descendants": hierarchy_many({"name": VALUE})}, + ), + ), + OnlyQueryCase( + name="prefetch-adds-no-unnamed-hierarchy-field", + kind="LocationSite", + only=["name"], + prefetch_relationships=True, + expected=expected_query("LocationSite", {"name": VALUE}), + ), + OnlyQueryCase( + name="hierarchy-fields-on-hierarchical-generic", + kind="LocationGeneric", + only=["name", "children"], + expected=expected_query("LocationGeneric", {"name": VALUE, "children": hierarchy_many()}), + ), + OnlyQueryCase( + name="hierarchy-field-only-on-implementing-kind", + kind="InfraPlace", + only=["parent"], + expected=expected_query( + "InfraPlace", {"...on LocationSite": {"parent": aliased("LocationSite", "parent", hierarchy_one())}} + ), + ), + OnlyQueryCase( + name="hfid-on-kind-whose-hfid-reads-an-attribute", + kind="BuiltinLocation", + only=["hfid"], + expected=expected_query("BuiltinLocation", {}, hfid=True), + ), + OnlyQueryCase( + name="hfid-on-kind-whose-hfid-reads-a-relationship", + kind="BuiltinRack", + only=["hfid"], + expected=expected_query("BuiltinRack", {}, hfid=True), + ), + OnlyQueryCase( + name="hfid-merged-with-named-fields", + kind="BuiltinRack", + only=["location", "description", "hfid"], + expected=expected_query("BuiltinRack", {"description": VALUE, "location": one()}, hfid=True), + ), + OnlyQueryCase( + name="hfid-with-prefetch", + kind="BuiltinRack", + only=["tags", "hfid"], + prefetch_relationships=True, + expected=expected_query("BuiltinRack", {"tags": many(TAG_DEFAULT_FIELDS)}, hfid=True), + ), + OnlyQueryCase( + name="hfid-with-prefetch-of-a-relationship-the-hfid-reads", + kind="BuiltinRack", + only=["location", "hfid"], + prefetch_relationships=True, + expected=expected_query("BuiltinRack", {"location": one(LOCATION_DEFAULT_FIELDS)}, hfid=True), + ), + OnlyQueryCase( + name="hfid-on-kind-without-hfid", kind="BuiltinTag", only=["hfid"], expected=expected_query("BuiltinTag", {}) + ), + OnlyQueryCase( + name="generic-name-on-generic", + kind="TestingAnimal", + only=["name", "owner"], + expected=expected_query("TestingAnimal", {"name": VALUE, "owner": one()}), + ), + OnlyQueryCase( + name="generic-name-on-one-implementing-kind", + kind="TestingAnimal", + only=["color", "walker"], + expected=expected_query( + "TestingAnimal", + { + "...on TestingCat": {"color": aliased("TestingCat", "color", VALUE)}, + "...on TestingDog": {"walker": aliased("TestingDog", "walker", one())}, + }, + ), + ), + OnlyQueryCase( + name="generic-name-on-every-implementing-kind", + kind="TestingAnimal", + only=["breed"], + expected=expected_query( + "TestingAnimal", + { + "...on TestingCat": {"breed": aliased("TestingCat", "breed", VALUE)}, + "...on TestingDog": {"breed": aliased("TestingDog", "breed", VALUE)}, + }, + ), + ), + OnlyQueryCase( + name="generic-implementing-kind-relationship-with-prefetch", + kind="TestingAnimal", + only=["walker"], + prefetch_relationships=True, + expected=expected_query( + "TestingAnimal", + {"...on TestingDog": {"walker": aliased("TestingDog", "walker", one(PERSON_DEFAULT_FIELDS))}}, + ), + ), + OnlyQueryCase( + name="generic-hfid", + kind="TestingAnimal", + only=["hfid"], + expected=expected_query("TestingAnimal", {}, hfid=True), + ), + OnlyQueryCase( + name="generic-hfid-merged-with-named-fields", + kind="TestingAnimal", + only=["owner", "color", "hfid"], + expected=expected_query( + "TestingAnimal", + {"owner": one(), "...on TestingCat": {"color": aliased("TestingCat", "color", VALUE)}}, + hfid=True, + ), + ), + OnlyQueryCase( + name="generic-hfid-with-prefetch", + kind="TestingAnimal", + only=["owner", "hfid"], + prefetch_relationships=True, + expected=expected_query("TestingAnimal", {"owner": one(PERSON_DEFAULT_FIELDS)}, hfid=True), + ), + OnlyQueryCase( + name="property-on-named-fields", + kind="BuiltinRack", + only=["description", "location"], + property=True, + expected=expected_query( + "BuiltinRack", + {"description": ATTRIBUTE_PROPERTIES, "location": one(properties=RELATIONSHIP_PROPERTIES)}, + ), + ), + OnlyQueryCase( + name="property-adds-nothing-to-hfid", + kind="BuiltinRack", + only=["description", "hfid"], + property=True, + expected=expected_query("BuiltinRack", {"description": ATTRIBUTE_PROPERTIES}, hfid=True), + ), + OnlyQueryCase( + name="property-on-hierarchy-field", + kind="LocationSite", + only=["parent"], + property=True, + expected=expected_query("LocationSite", {"parent": hierarchy_one(properties=RELATIONSHIP_PROPERTIES)}), + ), + OnlyQueryCase( + name="metadata-on-named-fields", + kind="BuiltinRack", + only=["description", "location"], + include_metadata=True, + expected=expected_query( + "BuiltinRack", + { + "description": ATTRIBUTE_METADATA, + "location": one(node_metadata=NODE_METADATA, relationship_metadata=RELATIONSHIP_METADATA), + }, + include_metadata=True, + ), + ), + OnlyQueryCase( + name="metadata-not-on-hierarchy-field", + kind="LocationSite", + only=["parent"], + include_metadata=True, + expected=expected_query("LocationSite", {"parent": hierarchy_one()}, include_metadata=True), + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in ONLY_QUERY_CASES]) +async def test_only_query(query_clients: BothClients, client_type: str, case: OnlyQueryCase) -> None: + data = await generate_only_query( + query_clients, + client_type, + kind=case.kind, + only=case.only, + prefetch_relationships=case.prefetch_relationships, + property=case.property, + include_metadata=case.include_metadata, + ) + + assert data == case.expected + + +@pytest.mark.parametrize("client_type", client_types) +async def test_only_query_hierarchy_prefetch_matches_default_when_the_generic_is_the_declared_peer( + query_clients: BothClients, client_type: str +) -> None: + hierarchy_names = ["parent", "children", "ancestors", "descendants"] + data = await generate_only_query( + query_clients, client_type, kind="LocationSite", only=hierarchy_names, prefetch_relationships=True + ) + default = await generate_default_query(query_clients, client_type, kind="LocationSite", include=hierarchy_names) + + only_node = data["LocationSite"]["edges"]["node"] + default_node = default["LocationSite"]["edges"]["node"] + assert {name: only_node[name] for name in hierarchy_names} == {name: default_node[name] for name in hierarchy_names} + assert only_node["parent"] == hierarchy_one({"name": VALUE}) + + +def declared_parent_schema(parent_peer: str) -> dict[str, Any]: + """A site whose HFID reads `code`, which only the region has, through a `parent` that declares `parent_peer`. + + The region has its own `parent` and `children`, which a prefetch of the site's parent must not request. + """ + return { + "version": "1.0", + "generics": [ + { + "name": "Generic", + "namespace": "Location", + "hierarchical": True, + "used_by": ["LocationRegion", "LocationSite"], + "attributes": [{"name": "name", "kind": "Text"}], + } + ], + "nodes": [ + { + "name": "Region", + "namespace": "Location", + "inherit_from": ["LocationGeneric"], + "hierarchy": "LocationGeneric", + "attributes": [{"name": "name", "kind": "Text"}, {"name": "code", "kind": "Text"}], + "relationships": [ + { + "name": "parent", + "peer": "LocationGeneric", + "cardinality": "one", + "kind": "Hierarchy", + "optional": True, + }, + { + "name": "children", + "peer": "LocationSite", + "cardinality": "many", + "kind": "Hierarchy", + "optional": True, + }, + ], + }, + { + "name": "Site", + "namespace": "Location", + "inherit_from": ["LocationGeneric"], + "hierarchy": "LocationGeneric", + "human_friendly_id": ["parent__code__value", "name__value"], + "attributes": [{"name": "name", "kind": "Text"}], + "relationships": [ + {"name": "parent", "peer": parent_peer, "cardinality": "one", "kind": "Hierarchy", "optional": True} + ], + }, + ], + } + + +@pytest.mark.parametrize("client_type", client_types) +async def test_only_query_prefetch_uses_the_declared_parent_kind(clients: BothClients, client_type: str) -> None: + schema = declared_parent_schema(parent_peer="LocationRegion") + clients.standard.schema.set_cache(schema) + clients.sync.schema.set_cache(schema) + + data = await generate_only_query( + clients, client_type, kind="LocationSite", only=["parent"], prefetch_relationships=True + ) + + assert data == expected_query( + "LocationSite", + {"parent": {"node": {"...on LocationRegion": {**REFERENCE, "name": VALUE, "code": VALUE}}}}, + ) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_prefetched_parent_of_the_declared_kind_is_built_and_stored( + clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + schema = declared_parent_schema(parent_peer="LocationRegion") + clients.standard.schema.set_cache(schema) + clients.sync.schema.set_cache(schema) + region_data = only_data.peer(only_data.REGION_ID, "LocationRegion", name={"value": "Europe"}, code={"value": "EU"}) + only_data.add_page( + httpx_mock, + "LocationSite", + [only_data.node_data(only_data.SITE_ID, "LocationSite", name={"value": "Lyon"}, parent=region_data)], + ) + + [site] = await only_data.query_nodes( + clients, client_type, "all", kind="LocationSite", only=["name", "parent"], prefetch_relationships=True + ) + + region = only_data.client_of(clients, client_type).store.get(key=only_data.REGION_ID, kind="LocationRegion") + assert [region.get_kind(), region._get_attribute("code").value, region._only_marker] == [ + "LocationRegion", + "EU", + OnlyMarker(only=("name", "parent"), peer_of="LocationSite.parent"), + ] + assert site.hfid == ["EU", "Lyon"] + + +def place_schema(site_hfid: list[str] | None) -> dict[str, Any]: + """A generic without an HFID, implemented by a site whose HFID is `site_hfid`.""" + return { + "version": "1.0", + "generics": [ + { + "name": "Place", + "namespace": "Infra", + "used_by": ["InfraSite"], + "attributes": [{"name": "name", "kind": "Text"}], + } + ], + "nodes": [ + { + "name": "Site", + "namespace": "Infra", + "inherit_from": ["InfraPlace"], + "human_friendly_id": site_hfid, + "attributes": [{"name": "name", "kind": "Text"}], + } + ], + } + + +@dataclass +class GenericHfidCase: + name: str + site_hfid: list[str] | None + expected: dict[str, Any] + + +GENERIC_HFID_CASES = [ + GenericHfidCase( + name="implementing-kind-with-hfid", + site_hfid=["name__value"], + expected=expected_query("InfraPlace", {}, hfid=True), + ), + GenericHfidCase(name="no-kind-with-hfid", site_hfid=None, expected=expected_query("InfraPlace", {})), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in GENERIC_HFID_CASES]) +async def test_only_query_hfid_on_a_generic_without_hfid_follows_its_implementing_kinds( + clients: BothClients, client_type: str, case: GenericHfidCase +) -> None: + schema = place_schema(site_hfid=case.site_hfid) + clients.standard.schema.set_cache(schema) + clients.sync.schema.set_cache(schema) + + data = await generate_only_query(clients, client_type, kind="InfraPlace", only=["hfid"]) + + assert data == case.expected + + +@pytest.mark.parametrize("client_type", client_types) +async def test_only_query_arguments(query_clients: BothClients, client_type: str) -> None: + arguments: dict[str, Any] = { + "filters": {"facility_id__value": "R1"}, + "offset": "$offset", + "limit": "$limit", + "partial_match": True, + "order": Order(disable=True), + } + + data = await generate_only_query(query_clients, client_type, kind="BuiltinRack", only=["facility_id"], **arguments) + default = await generate_default_query(query_clients, client_type, kind="BuiltinRack", **arguments) + + assert data == expected_query( + "BuiltinRack", + {"facility_id": VALUE}, + filters={ + "facility_id__value": "R1", + "order": Order(disable=True), + "offset": "$offset", + "limit": "$limit", + "partial_match": True, + }, + ) + assert data["BuiltinRack"]["@filters"] == default["BuiltinRack"]["@filters"] + assert data["BuiltinRack"]["count"] == default["BuiltinRack"]["count"] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_only_query_text_does_not_depend_on_name_order(query_clients: BothClients, client_type: str) -> None: + names = ["breed", "walker", "hfid", "weight", "color"] + forward = await generate_only_query(query_clients, client_type, kind="TestingAnimal", only=names) + backward = await generate_only_query(query_clients, client_type, kind="TestingAnimal", only=names[::-1]) + + assert Query(query=backward, name="Animals").render() == Query(query=forward, name="Animals").render() + + +@pytest.mark.parametrize("client_type", client_types) +async def test_only_query_rejects_unknown_name(query_clients: BothClients, client_type: str) -> None: + with pytest.raises( + FieldNotFoundError, match=re.escape("'nmae' is not an attribute or relationship of BuiltinRack.") + ): + await generate_only_query(query_clients, client_type, kind="BuiltinRack", only=["facility_id", "nmae"]) + + +@dataclass +class SchemaGrowthCase: + name: str + kind: str + only: list[str] + expected: dict[str, Any] + grown_kind: str + prefetch_relationships: bool = False + + +SCHEMA_GROWTH_CASES = [ + SchemaGrowthCase( + name="node-kind", + kind="BuiltinRack", + only=["description", "tags", "hfid"], + expected=expected_query("BuiltinRack", {"description": VALUE, "tags": many()}, hfid=True), + grown_kind="BuiltinRack", + ), + SchemaGrowthCase( + name="peer-kind-the-hfid-reads-with-prefetch", + kind="BuiltinRack", + only=["facility_id", "hfid"], + prefetch_relationships=True, + expected=expected_query("BuiltinRack", {"facility_id": VALUE}, hfid=True), + grown_kind="BuiltinLocation", + ), + SchemaGrowthCase( + name="generic-peer-kind-the-hfid-reads-with-prefetch", + kind="TestingAnimal", + only=["hfid"], + prefetch_relationships=True, + expected=expected_query("TestingAnimal", {}, hfid=True), + grown_kind="TestingPerson", + ), + SchemaGrowthCase( + name="generic-kind-and-implementing-kinds", + kind="TestingAnimal", + only=["weight", "breed", "walker", "hfid"], + expected=expected_query( + "TestingAnimal", + { + "weight": VALUE, + "...on TestingCat": {"breed": aliased("TestingCat", "breed", VALUE)}, + "...on TestingDog": { + "breed": aliased("TestingDog", "breed", VALUE), + "walker": aliased("TestingDog", "walker", one()), + }, + }, + hfid=True, + ), + grown_kind="TestingAnimal", + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in SCHEMA_GROWTH_CASES]) +async def test_only_query_unchanged_when_schema_grows( + query_clients: BothClients, + schema_with_hfid: dict[str, NodeSchemaAPI], + client_type: str, + case: SchemaGrowthCase, +) -> None: + before = await generate_only_query( + query_clients, + client_type, + kind=case.kind, + only=case.only, + prefetch_relationships=case.prefetch_relationships, + ) + default_before = await generate_default_query(query_clients, client_type, kind=case.grown_kind, fragment=True) + + grown = schema_payload(rack=schema_with_hfid["rack"], location=schema_with_hfid["location"], grown=True) + query_clients.standard.schema.set_cache(grown) + query_clients.sync.schema.set_cache(grown) + + after = await generate_only_query( + query_clients, + client_type, + kind=case.kind, + only=case.only, + prefetch_relationships=case.prefetch_relationships, + ) + default_after = await generate_default_query(query_clients, client_type, kind=case.grown_kind, fragment=True) + + assert default_after != default_before + assert before == case.expected + assert after == case.expected diff --git a/tests/unit/sdk/test_object_store.py b/tests/unit/sdk/test_object_store.py index 5e47f3d54..9e5f6e110 100644 --- a/tests/unit/sdk/test_object_store.py +++ b/tests/unit/sdk/test_object_store.py @@ -107,16 +107,60 @@ async def test_object_store_get_raises_authentication_error( httpx_mock.add_response( method="GET", status_code=status_code, - json={"errors": [{"message": "forbidden"}]}, + json={"errors": [{"message": "forbidden"}, {"message": "and stay out"}]}, ) client = getattr(clients, client_type) - with pytest.raises(AuthenticationError): + with pytest.raises(AuthenticationError, match=r"forbidden \| and stay out") as exc_info: if client_type == "standard": await client.object_store.get(identifier="whatever") else: client.object_store.get(identifier="whatever") + assert exc_info.value.http_status is None + assert [error["message"] for error in exc_info.value.errors] == ["forbidden", "and stay out"] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("status_code", [401, 403]) +async def test_object_store_upload_raises_authentication_error( + client_type: str, status_code: int, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + httpx_mock.add_response( + method="POST", + status_code=status_code, + json={"errors": [{"message": "no write access", "extensions": {"code": "PERMISSION_DENIED"}}]}, + ) + client = getattr(clients, client_type) + + with pytest.raises(AuthenticationError, match="no write access") as exc_info: + if client_type == "standard": + await client.object_store.upload(content=FILE_CONTENT_01) + else: + client.object_store.upload(content=FILE_CONTENT_01) + + assert exc_info.value.code == "PERMISSION_DENIED" + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("status_code", [401, 403]) +async def test_object_store_get_file_raises_authentication_error( + client_type: str, status_code: int, clients: BothClients, httpx_mock: HTTPXMock +) -> None: + """The file lookups reach the same factory through their own request helper.""" + httpx_mock.add_response( + method="GET", + status_code=status_code, + json={"errors": [{"message": "forbidden"}]}, + ) + client = getattr(clients, client_type) + + with pytest.raises(AuthenticationError, match="forbidden"): + if client_type == "standard": + await client.object_store.get_file_by_storage_id(storage_id="whatever") + else: + client.object_store.get_file_by_storage_id(storage_id="whatever") + @pytest.mark.parametrize("client_type", client_types) async def test_object_store_upload_raises_on_500(client_type: str, clients: BothClients, httpx_mock: HTTPXMock) -> None: diff --git a/tests/unit/sdk/test_protocols_generator.py b/tests/unit/sdk/test_protocols_generator.py index 531556bcd..28ec5b588 100644 --- a/tests/unit/sdk/test_protocols_generator.py +++ b/tests/unit/sdk/test_protocols_generator.py @@ -5,11 +5,20 @@ from infrahub_sdk import InfrahubClient from infrahub_sdk.protocols_generator.generator import CodeGenerator -from infrahub_sdk.schema import AttributeSchemaAPI +from infrahub_sdk.protocols_generator.target import ProtocolTarget +from infrahub_sdk.schema import ( + AttributeSchemaAPI, + GenericSchemaAPI, + RelationshipCardinality, + RelationshipSchemaAPI, +) +from tests.helpers.fixtures import read_fixture if TYPE_CHECKING: from pytest_httpx import HTTPXMock +GOLDEN_SUBDIR = "protocols_generator" + @dataclass class SyncifyTestCase: @@ -25,7 +34,7 @@ class SyncifyTestCase: name="sync-list", sync=True, input=["LineageSource", "CoreNode", "CoreObjectTemplate"], - output=["LineageSource", "CoreObjectTemplateSync", "CoreNodeSync"], + output=["LineageSourceSync", "CoreObjectTemplateSync", "CoreNodeSync"], ), SyncifyTestCase(name="async-str", sync=False, input=["CoreNode"], output=["CoreNode"]), SyncifyTestCase( @@ -73,6 +82,107 @@ class RenderAttributeTestCase: ] +@dataclass +class RenderRelationshipTestCase: + name: str + sync: bool + peer: str + cardinality: RelationshipCardinality + expected: str + + +RENDER_RELATIONSHIP_TEST_CASES = [ + RenderRelationshipTestCase( + name="sync-core-node-peer", + sync=True, + peer="CoreNode", + cardinality=RelationshipCardinality.MANY, + expected="members: RelationshipManagerSync[CoreNodeSync]", + ), + RenderRelationshipTestCase( + name="async-core-node-peer", + sync=False, + peer="CoreNode", + cardinality=RelationshipCardinality.MANY, + expected="members: RelationshipManager[CoreNode]", + ), + RenderRelationshipTestCase( + name="sync-peer-with-sync-twin", + sync=True, + peer="CoreGroup", + cardinality=RelationshipCardinality.MANY, + expected="members: RelationshipManagerSync[CoreGroupSync]", + ), + RenderRelationshipTestCase( + name="sync-peer-without-sync-twin", + sync=True, + peer="LocationRack", + cardinality=RelationshipCardinality.ONE, + expected="members: RelationshipAttributeSync[LocationRack]", + ), +] + + +@dataclass +class HierarchyTestCase: + name: str + declared_relationships: list[str] + + +HIERARCHY_TEST_CASES = [ + HierarchyTestCase(name="neither-declared", declared_relationships=[]), + HierarchyTestCase(name="only-parent-declared", declared_relationships=["parent"]), + HierarchyTestCase(name="only-children-declared", declared_relationships=["children"]), + HierarchyTestCase(name="both-declared", declared_relationships=["parent", "children"]), +] + + +@dataclass +class OrderTestCase: + name: str + first_namespace: str + first_name: str + second_namespace: str + second_name: str + expected_first: str + expected_second: str + + +ORDER_TEST_CASES = [ + OrderTestCase( + name="same-name-different-namespace", + first_namespace="Location", + first_name="Site", + second_namespace="Network", + second_name="Site", + expected_first="LocationSite", + expected_second="NetworkSite", + ), + OrderTestCase( + name="names-differing-only-in-case", + first_namespace="Infra", + first_name="DEvice", + second_namespace="Infra", + second_name="Device", + expected_first="InfraDEvice", + expected_second="InfraDevice", + ), +] + + +@dataclass +class GoldenTestCase: + name: str + sync: bool + fixture: str + + +GOLDEN_TEST_CASES = [ + GoldenTestCase(name="async", sync=False, fixture="user_schema_async.txt"), + GoldenTestCase(name="sync", sync=True, fixture="user_schema_sync.txt"), +] + + @pytest.mark.parametrize( "test_case", [pytest.param(tc, id=tc.name) for tc in RENDER_ATTRIBUTE_TEST_CASES], @@ -87,13 +197,36 @@ async def test_filter_render_attribute(test_case: RenderAttributeTestCase) -> No assert CodeGenerator._jinja2_filter_render_attribute(attr) == test_case.expected +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in RENDER_RELATIONSHIP_TEST_CASES], +) +async def test_filter_render_relationship(test_case: RenderRelationshipTestCase) -> None: + """A peer is referenced by its ``*Sync`` variant in sync output whenever one exists. + + ``CoreNode`` is the implicit base and is excluded from ``base_protocols``, so a relationship + whose peer is exactly ``CoreNode`` still has to switch to ``CoreNodeSync`` here; a peer with no + sync twin keeps its name because it already is the sync class. + """ + generator = CodeGenerator(schema={}) + relationship = RelationshipSchemaAPI( + name="members", + peer=test_case.peer, + cardinality=test_case.cardinality, + ) + + assert generator._jinja2_filter_render_relationship(relationship, sync=test_case.sync) == test_case.expected + + @pytest.mark.parametrize( "test_case", [pytest.param(tc, id=tc.name) for tc in SYNCIFY_TEST_CASES], ) async def test_filter_syncify(test_case: SyncifyTestCase) -> None: - assert CodeGenerator._jinja2_filter_syncify(value=test_case.input, sync=test_case.sync) == test_case.output - assert CodeGenerator._jinja2_filter_syncify(value=test_case.input, sync=test_case.sync) == test_case.output + generator = CodeGenerator(schema={}) + + assert generator._jinja2_filter_syncify(value=test_case.input, sync=test_case.sync) == test_case.output + assert generator._jinja2_filter_syncify(value=test_case.input, sync=test_case.sync) == test_case.output async def test_generator(client: InfrahubClient, mock_schema_query_05: "HTTPXMock") -> None: @@ -104,7 +237,7 @@ async def test_generator(client: InfrahubClient, mock_schema_query_05: "HTTPXMoc assert "class LocationGeneric(CoreNodeSync)" in sync_protocols assert "class LocationCountry(LocationGeneric)" in sync_protocols - assert "class TemplateInfraDevice(LineageSource, CoreObjectTemplateSync, CoreNodeSync)" in sync_protocols + assert "class TemplateInfraDevice(LineageSourceSync, CoreObjectTemplateSync, CoreNodeSync)" in sync_protocols location_site_sync = """ class LocationSite(LocationGeneric): @@ -128,3 +261,105 @@ class LocationSite(LocationGeneric): assert "class LocationGeneric(CoreNode)" in async_protocols assert "class LocationCountry(LocationGeneric)" in async_protocols assert "class TemplateInfraDevice(LineageSource, CoreObjectTemplate, CoreNode)" in async_protocols + + +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in GOLDEN_TEST_CASES], +) +async def test_render_user_schema_matches_golden( + test_case: GoldenTestCase, client: InfrahubClient, mock_schema_query_05: "HTTPXMock" +) -> None: + """Rendering a user schema produces output byte-identical to the committed reference. + + The output of `infrahubctl protocols` is checked into user repositories and type-checked + there, so any change to it - a renamed annotation, a reordered member, a different import + line - is a change to something users depend on. Comparing the whole file makes that + surface as a diff a reviewer has to accept on purpose, rather than passing unnoticed + because the assertions only sampled a few lines. + """ + schemas = await client.schema.fetch(branch="main") + + rendered = CodeGenerator(schema=schemas).render(sync=test_case.sync) + + assert rendered == read_fixture(test_case.fixture, GOLDEN_SUBDIR) + + +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in HIERARCHY_TEST_CASES], +) +async def test_hierarchy_members_are_declared_once(test_case: HierarchyTestCase) -> None: + """A hierarchical kind declares `parent` and `children` exactly once. + + Both come from the hierarchy unless the schema already exposes them as relationships, which + it does for anything read from the API. Emitting either one twice is not a type error, so a + duplicate silently overrides the first declaration with whatever the second one says. + """ + relationships = [ + RelationshipSchemaAPI( + name=name, + peer="LocationSite", + cardinality=RelationshipCardinality.ONE if name == "parent" else RelationshipCardinality.MANY, + ) + for name in test_case.declared_relationships + ] + generic = GenericSchemaAPI(name="Site", namespace="Location", hierarchical=True, relationships=relationships) + + rendered = CodeGenerator(schema={"LocationSite": generic}).render(sync=False) + + body = rendered[rendered.index("class LocationSite") :] + assert body.count("parent:") == 1 + assert body.count("children:") == 1 + + +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in ORDER_TEST_CASES], +) +async def test_render_order_does_not_depend_on_input_order(test_case: OrderTestCase) -> None: + """Kinds a sort key could compare equal render in the same order however they are supplied. + + The rendered file is committed and validated by regenerating it and diffing, so an ordering + that depends on how the schema was handed over turns into a build that fails intermittently. + Ordering on the kind is total, because that is the name the class renders as. + """ + first = GenericSchemaAPI(name=test_case.first_name, namespace=test_case.first_namespace) + second = GenericSchemaAPI(name=test_case.second_name, namespace=test_case.second_namespace) + + forwards = CodeGenerator(schema={first.kind: first, second.kind: second}).render(sync=False) + backwards = CodeGenerator(schema={second.kind: second, first.kind: first}).render(sync=False) + + assert forwards == backwards + assert forwards.index(f"class {test_case.expected_first}(") < forwards.index(f"class {test_case.expected_second}(") + + +async def test_render_sdk_core_emits_both_variants(client: InfrahubClient, mock_schema_query_05: "HTTPXMock") -> None: + """Generating infrahub_sdk.protocols itself puts both variants in one module. + + Every kind is local there, so the module cannot import the kinds it defines, the sync classes + need a suffix to keep their names distinct, and a peer has to be referenced by its own sync + class rather than by an imported one. + """ + schemas = await client.schema.fetch(branch="main") + + rendered = CodeGenerator(schema=schemas, target=ProtocolTarget.SDK_CORE).render() + + assert "from infrahub_sdk.protocols import" not in rendered + assert "from .protocols_base import CoreNode, CoreNodeSync" in rendered + + assert "class LocationGeneric(CoreNode):" in rendered + assert "class LocationGenericSync(CoreNodeSync):" in rendered + assert "class LocationSite(LocationGeneric):" in rendered + assert "class LocationSiteSync(LocationGenericSync):" in rendered + + assert "parent: RelationshipAttribute[LocationCountry]" in rendered + assert "parent: RelationshipAttributeSync[LocationCountrySync]" in rendered + + +async def test_render_sdk_core_ignores_sync_argument(client: InfrahubClient, mock_schema_query_05: "HTTPXMock") -> None: + """The async/sync choice does not apply when both variants land in the same module.""" + schemas = await client.schema.fetch(branch="main") + generator = CodeGenerator(schema=schemas, target=ProtocolTarget.SDK_CORE) + + assert generator.render(sync=True) == generator.render(sync=False) diff --git a/tests/unit/sdk/test_relationship_only.py b/tests/unit/sdk/test_relationship_only.py new file mode 100644 index 000000000..a10c73208 --- /dev/null +++ b/tests/unit/sdk/test_relationship_only.py @@ -0,0 +1,605 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk.exceptions import Error, FieldNotFoundError, FieldNotLoadedError +from infrahub_sdk.graphql import Query +from infrahub_sdk.node._only import OnlyMarker # noqa: PLC2701 # the marker is private to the package +from infrahub_sdk.types import Order +from tests.helpers import only_data +from tests.helpers.only_data import resolve + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from .conftest import BothClients + +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + +client_types = ["standard", "sync"] + +DEVICE = "InfraDevice" +SITE = "InfraSite" +TAG = "BuiltinTag" +REGION = "LocationRegion" +CAT = "TestingCat" +DOG = "TestingDog" +SHELTER = "TestingShelter" +SHELTER_ID = "a7c9e1b3-2d4f-4a6b-8c0d-1e2f3a4b5c01" +REGION_PEER_ID = "f1a6e1f5-1e1b-4f7a-8f9b-8d6b9b6c5c09" + +NODE_FIELDS = {"id": None, "hfid": None, "display_label": None, "__typename": None} +PEER_ORDER = Order(disable=True) + + +def reference(id: str, typename: str, hfid: list[str]) -> dict[str, Any]: + """A peer as the server returns it in a reference, with the HFID the server calculated.""" + return {"node": {"id": id, "hfid": hfid, "display_label": hfid[0], "__typename": typename}} + + +def add_counts(httpx_mock: HTTPXMock, counts: dict[str, int]) -> None: + """Answer the count queries of a parallel filters call, which carry no tracker header. + + Register it after every tracked response, so a tracked request never takes it. + """ + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json={"data": {kind: {"count": count} for kind, count in counts.items()}}, + ) + + +def request_bodies(httpx_mock: HTTPXMock) -> list[dict[str, Any]]: + return [json.loads(request.content) for request in httpx_mock.get_requests()] + + +def body_of(httpx_mock: HTTPXMock, operation: str) -> dict[str, Any]: + bodies = [body for body in request_bodies(httpx_mock) if body["operationName"] == operation] + assert len(bodies) == 1 + return bodies[0] + + +def default_body( + kind: str, query_name: str, node_fields: dict[str, Any], filters: dict[str, Any] | None = None +) -> dict[str, Any]: + """The JSON body of the first page request of a query without `only`, which also requests `hfid`.""" + query = { + kind: { + "count": None, + "edges": {"node": {**NODE_FIELDS, **node_fields}}, + "@filters": {**(filters or {}), **only_data.PAGINATION}, + } + } + return { + "query": Query(query=query, name=query_name, variables={"offset": int, "limit": int}).render(), + "variables": {"offset": 0, "limit": 3}, + "operationName": query_name, + } + + +async def query_all( + clients: BothClients, + client_type: str, + httpx_mock: HTTPXMock, + kind: str, + edge: dict[str, Any], + only: list[str] | None, +) -> list[Any]: + """Return the nodes that `all(kind, only=...)` builds from a response holding `edge`. + + With `only`, `edge` may hold only the fields the query requests, as the server's response would. + """ + if only is not None: + assert set(edge["node"]) <= {"id", "display_label", "__typename", *only} + only_data.add_page(httpx_mock, kind, [edge]) + return await only_data.query_nodes(clients, client_type, "all", kind=kind, only=only) + + +def not_fetched(kind: str, field: str, only: list[str]) -> str: + return re.escape(f"{kind}.{field} was not fetched: the node was queried with only={only!r}.") + + +DEVICE_WITH_SITE = only_data.node_data(only_data.DEVICE_ID, DEVICE, site=reference(only_data.SITE_ID, SITE, ["Paris"])) +SHELTER_WITH_ANIMALS = only_data.node_data( + SHELTER_ID, + SHELTER, + animals=only_data.peers(only_data.peer(only_data.CAT_ID, CAT), only_data.peer(only_data.DOG_ID, DOG)), +) +SHELTER_WITH_NAME = only_data.node_data(SHELTER_ID, SHELTER, name={"value": "North"}) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_related_node_fetch_with_only_requests_the_named_fields( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + [device] = await query_all(only_clients, client_type, httpx_mock, kind=DEVICE, edge=DEVICE_WITH_SITE, only=["site"]) + only_data.add_page(httpx_mock, SITE, [only_data.node_data(only_data.SITE_ID, SITE, name={"value": "Paris"})]) + client = only_data.client_of(only_clients, client_type) + + await resolve(device.site.fetch(only=["name"])) + + assert request_bodies(httpx_mock)[1:] == [ + only_data.expected_body(SITE, "Get_InfraSite", {"name": only_data.VALUE}, filters={"ids": [only_data.SITE_ID]}) + ] + site = device.site.peer + assert [site.name.value, site._only_marker] == ["Paris", OnlyMarker(only=("name",))] + assert client.store.get(key=only_data.SITE_ID, kind=SITE) is site + with pytest.raises(FieldNotLoadedError, match=not_fetched(SITE, "description", ["name"])): + _ = site.description.value + + +@dataclass +class DeviceCase: + name: str + device_only: list[str] | None + + +DEVICE_CASES = [ + DeviceCase(name="node-from-only", device_only=["site"]), + DeviceCase(name="default-node", device_only=None), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in DEVICE_CASES]) +async def test_related_node_fetch_with_unknown_name_in_only_sends_no_peer_query( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: DeviceCase +) -> None: + [device] = await query_all( + only_clients, client_type, httpx_mock, kind=DEVICE, edge=DEVICE_WITH_SITE, only=case.device_only + ) + + with pytest.raises(FieldNotFoundError, match=re.escape("'nmae' is not an attribute or relationship of InfraSite.")): + await resolve(device.site.fetch(only=["nmae"])) + + assert [body["operationName"] for body in request_bodies(httpx_mock)] == ["All_InfraDevice"] + + +@dataclass +class FetchedHfidCase: + name: str + fetch_only: list[str] | None + fetched_fields: dict[str, Any] + expected_hfid: list[str] | None + expected_hfid_str: str | None + + +FETCHED_HFID_CASES = [ + FetchedHfidCase( + name="hfid-fields-not-fetched-keeps-the-reference-hfid", + fetch_only=["description"], + fetched_fields={"description": {"value": "Main site"}}, + expected_hfid=["Paris"], + expected_hfid_str="InfraSite__Paris", + ), + FetchedHfidCase( + name="hfid-fields-fetched-gives-the-calculated-hfid", + fetch_only=["name"], + fetched_fields={"name": {"value": "Lyon"}}, + expected_hfid=["Lyon"], + expected_hfid_str="InfraSite__Lyon", + ), + FetchedHfidCase( + name="hfid-named-gives-the-server-hfid", + fetch_only=["hfid"], + fetched_fields={"hfid": ["Lyon"]}, + expected_hfid=["Lyon"], + expected_hfid_str="InfraSite__Lyon", + ), + FetchedHfidCase( + name="hfid-named-with-a-null-server-hfid-keeps-the-reference-hfid", + fetch_only=["hfid"], + fetched_fields={"hfid": None}, + expected_hfid=["Paris"], + expected_hfid_str="InfraSite__Paris", + ), + FetchedHfidCase( + name="peer-fetched-without-only-keeps-the-calculated-hfid", + fetch_only=None, + fetched_fields={"description": {"value": "Main site"}}, + expected_hfid=None, + expected_hfid_str=None, + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in FETCHED_HFID_CASES]) +async def test_related_node_hfid_after_fetch( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: FetchedHfidCase +) -> None: + [device] = await query_all(only_clients, client_type, httpx_mock, kind=DEVICE, edge=DEVICE_WITH_SITE, only=["site"]) + only_data.add_page(httpx_mock, SITE, [only_data.node_data(only_data.SITE_ID, SITE, **case.fetched_fields)]) + + await resolve(device.site.fetch(only=case.fetch_only)) + + site = device.site + assert [site.hfid, site.hfid_str, site.initialized] == [case.expected_hfid, case.expected_hfid_str, True] + + +@dataclass +class NotLoadedFetchCase: + name: str + device_only: list[str] | None + error: type[Error] + message: str + + +NOT_LOADED_FETCH_CASES = [ + NotLoadedFetchCase( + name="node-from-only", + device_only=["name"], + error=FieldNotLoadedError, + message=not_fetched(DEVICE, "site", ["name"]), + ), + NotLoadedFetchCase( + name="default-node", + device_only=None, + error=Error, + message="Unable to fetch the peer, id and/or typename are not defined", + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in NOT_LOADED_FETCH_CASES]) +async def test_related_node_fetch_with_only_on_relationship_not_loaded_raises( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: NotLoadedFetchCase +) -> None: + edge = only_data.node_data(only_data.DEVICE_ID, DEVICE, name={"value": "dev1"}) + [device] = await query_all(only_clients, client_type, httpx_mock, kind=DEVICE, edge=edge, only=case.device_only) + + with pytest.raises(Error, match=case.message) as error: + await resolve(device.site.fetch(only=["name"])) + + assert type(error.value) is case.error + assert len(httpx_mock.get_requests()) == 1 + + +@dataclass +class PeerKindCase: + name: str + fetch_only: list[str] + message: str + + +PEER_KIND_CASES = [ + PeerKindCase( + name="name-missing-on-first-peer-kind", + fetch_only=["walker"], + message="'walker' is not an attribute or relationship of TestingCat", + ), + PeerKindCase( + name="name-missing-on-second-peer-kind", + fetch_only=["name", "home"], + message="'home' is not an attribute or relationship of TestingDog", + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in PEER_KIND_CASES]) +async def test_manager_fetch_with_name_missing_on_one_peer_kind_sends_no_peer_query( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: PeerKindCase +) -> None: + [shelter] = await query_all( + only_clients, client_type, httpx_mock, kind=SHELTER, edge=SHELTER_WITH_ANIMALS, only=["animals"] + ) + assert shelter.animals.is_loaded is True + + with pytest.raises(FieldNotFoundError, match=case.message): + await resolve(shelter.animals.fetch(only=case.fetch_only)) + + assert [body["operationName"] for body in request_bodies(httpx_mock)] == ["All_TestingShelter"] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_manager_fetch_reloads_the_relationship_before_checking_each_peer_kind( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + [shelter] = await query_all( + only_clients, client_type, httpx_mock, kind=SHELTER, edge=SHELTER_WITH_NAME, only=["name"] + ) + only_data.add_page(httpx_mock, SHELTER, [SHELTER_WITH_ANIMALS]) + assert shelter.animals.is_loaded is False + + with pytest.raises(FieldNotFoundError, match="'walker' is not an attribute or relationship of TestingCat"): + await resolve(shelter.animals.fetch(only=["walker"])) + + assert [body["operationName"] for body in request_bodies(httpx_mock)] == [ + "All_TestingShelter", + "Get_TestingShelter", + ] + assert body_of(httpx_mock, "Get_TestingShelter") == only_data.expected_body( + SHELTER, + "Get_TestingShelter", + {"animals": {"edges": {"node": only_data.REFERENCE}}}, + filters={"ids": [SHELTER_ID]}, + ) + + +@dataclass +class MisspeltNameCase: + name: str + kind: str + edge: dict[str, Any] + relationship: str + message: str + + +MISSPELT_NAME_CASES = [ + MisspeltNameCase( + name="node-peer-kind", + kind=DEVICE, + edge=only_data.node_data(only_data.DEVICE_ID, DEVICE, name={"value": "dev1"}), + relationship="tags", + message="'nmae' is not an attribute or relationship of BuiltinTag", + ), + MisspeltNameCase( + name="generic-peer-kind", + kind=SHELTER, + edge=SHELTER_WITH_NAME, + relationship="animals", + message="'nmae' is not an attribute or relationship of TestingAnimal", + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in MISSPELT_NAME_CASES]) +async def test_manager_fetch_with_misspelt_name_on_relationship_not_loaded_sends_no_request( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: MisspeltNameCase +) -> None: + [node] = await query_all(only_clients, client_type, httpx_mock, kind=case.kind, edge=case.edge, only=["name"]) + manager = getattr(node, case.relationship) + + with pytest.raises(FieldNotFoundError, match=case.message): + await resolve(manager.fetch(only=["nmae"])) + + assert manager.is_loaded is False + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.parametrize("client_type", client_types) +async def test_manager_fetch_reloads_relationship_of_node_from_only( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + edge = only_data.node_data(only_data.DEVICE_ID, DEVICE, name={"value": "dev1"}) + [device] = await query_all(only_clients, client_type, httpx_mock, kind=DEVICE, edge=edge, only=["name"]) + only_data.add_page( + httpx_mock, + DEVICE, + [ + only_data.node_data( + only_data.DEVICE_ID, + DEVICE, + tags=only_data.peers(only_data.peer(only_data.TAG_ID, TAG), only_data.peer(only_data.TAG_2_ID, TAG)), + ) + ], + ) + only_data.add_page( + httpx_mock, + TAG, + [ + only_data.node_data(only_data.TAG_ID, TAG, name={"value": "red"}), + only_data.node_data(only_data.TAG_2_ID, TAG, name={"value": "blue"}), + ], + ) + add_counts(httpx_mock, {TAG: 2}) + client = only_data.client_of(only_clients, client_type) + + await resolve(device.tags.fetch(only=["name"])) + + assert [body["operationName"] for body in request_bodies(httpx_mock)] == [ + "All_InfraDevice", + "Get_InfraDevice", + "Count_BuiltinTag", + "Filters_BuiltinTag", + ] + assert body_of(httpx_mock, "Get_InfraDevice") == only_data.expected_body( + DEVICE, + "Get_InfraDevice", + {"tags": {"edges": {"node": only_data.REFERENCE}}}, + filters={"ids": [only_data.DEVICE_ID]}, + ) + assert body_of(httpx_mock, "Filters_BuiltinTag") == only_data.expected_body( + TAG, + "Filters_BuiltinTag", + {"name": only_data.VALUE}, + filters={"ids": [only_data.TAG_ID, only_data.TAG_2_ID], "order": PEER_ORDER}, + ) + assert client.store.get(key=only_data.DEVICE_ID, kind=DEVICE) is device + assert [device.tags.is_loaded, device.tags.peer_ids] == [True, [only_data.TAG_ID, only_data.TAG_2_ID]] + stored_tags = [client.store.get(key=id, kind=TAG) for id in (only_data.TAG_ID, only_data.TAG_2_ID)] + assert [(tag._get_attribute("name").value, tag._only_marker) for tag in stored_tags] == [ + ("red", OnlyMarker(only=("name",))), + ("blue", OnlyMarker(only=("name",))), + ] + + +@dataclass +class HierarchyFetchCase: + name: str + relationship: str + fetch_only: list[str] | None + expected_peer_marker: OnlyMarker | None + + +HIERARCHY_FETCH_CASES = [ + HierarchyFetchCase( + name="ancestors-with-only", + relationship="ancestors", + fetch_only=["name"], + expected_peer_marker=OnlyMarker(only=("name",)), + ), + HierarchyFetchCase( + name="descendants-with-only", + relationship="descendants", + fetch_only=["name"], + expected_peer_marker=OnlyMarker(only=("name",)), + ), + HierarchyFetchCase( + name="ancestors-without-only", relationship="ancestors", fetch_only=None, expected_peer_marker=None + ), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(case, id=case.name) for case in HIERARCHY_FETCH_CASES]) +async def test_hierarchy_manager_fetch_loads_peers_of_node_from_only( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, case: HierarchyFetchCase +) -> None: + edge = only_data.node_data(only_data.REGION_ID, REGION, name={"value": "Europe"}) + [region] = await query_all(only_clients, client_type, httpx_mock, kind=REGION, edge=edge, only=["name"]) + only_data.add_page( + httpx_mock, + REGION, + [ + only_data.node_data( + only_data.REGION_ID, + REGION, + **{case.relationship: only_data.peers(only_data.peer(REGION_PEER_ID, REGION))}, + ) + ], + ) + only_data.add_page(httpx_mock, REGION, [only_data.node_data(REGION_PEER_ID, REGION, name={"value": "World"})]) + add_counts(httpx_mock, {REGION: 1}) + client = only_data.client_of(only_clients, client_type) + manager = getattr(region, case.relationship) + + await resolve(manager.fetch(only=case.fetch_only)) + + assert body_of(httpx_mock, "Get_LocationRegion") == only_data.expected_body( + REGION, + "Get_LocationRegion", + {case.relationship: {"edges": {"node": {"...on LocationGeneric": only_data.REFERENCE}}}}, + filters={"ids": [only_data.REGION_ID]}, + ) + assert [manager.is_loaded, manager.peer_ids] == [True, [REGION_PEER_ID]] + stored_peer = client.store.get(key=REGION_PEER_ID, kind=REGION) + assert [stored_peer._get_attribute("name").value, stored_peer._only_marker] == ["World", case.expected_peer_marker] + assert client.store.get(key=only_data.REGION_ID, kind=REGION) is region + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("relationship", ["ancestors", "descendants"]) +async def test_hierarchy_manager_fetch_reloads_the_relationship_of_a_default_node( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str, relationship: str +) -> None: + edge = only_data.node_data(only_data.REGION_ID, REGION, name={"value": "Europe"}) + [region] = await query_all(only_clients, client_type, httpx_mock, kind=REGION, edge=edge, only=None) + only_data.add_page( + httpx_mock, + REGION, + [ + only_data.node_data( + only_data.REGION_ID, REGION, **{relationship: only_data.peers(only_data.peer(REGION_PEER_ID, REGION))} + ) + ], + ) + only_data.add_page(httpx_mock, REGION, [only_data.node_data(REGION_PEER_ID, REGION, name={"value": "World"})]) + add_counts(httpx_mock, {REGION: 1}) + manager = getattr(region, relationship) + + await resolve(manager.fetch()) + + assert body_of(httpx_mock, "Get_LocationRegion") == default_body( + REGION, + "Get_LocationRegion", + {relationship: {"edges": {"node": {"...on LocationGeneric": NODE_FIELDS}}}}, + filters={"ids": [only_data.REGION_ID]}, + ) + assert [manager.is_loaded, manager.peer_ids] == [True, [REGION_PEER_ID]] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_peers_from_manager_fetch_with_only_carry_the_marker( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + [shelter] = await query_all( + only_clients, client_type, httpx_mock, kind=SHELTER, edge=SHELTER_WITH_ANIMALS, only=["animals"] + ) + only_data.add_page(httpx_mock, CAT, [only_data.node_data(only_data.CAT_ID, CAT, name={"value": "Tom"})]) + only_data.add_page(httpx_mock, DOG, [only_data.node_data(only_data.DOG_ID, DOG, name={"value": "Rex"})]) + add_counts(httpx_mock, {CAT: 1, DOG: 1}) + client = only_data.client_of(only_clients, client_type) + + await resolve(shelter.animals.fetch(only=["name"])) + + for kind, id in ((CAT, only_data.CAT_ID), (DOG, only_data.DOG_ID)): + assert body_of(httpx_mock, f"Filters_{kind}") == only_data.expected_body( + kind, f"Filters_{kind}", {"name": only_data.VALUE}, filters={"ids": [id], "order": PEER_ORDER} + ) + cat = client.store.get(key=only_data.CAT_ID, kind=CAT) + dog = client.store.get(key=only_data.DOG_ID, kind=DOG) + assert [ + (cat._get_attribute("name").value, cat._only_marker), + (dog._get_attribute("name").value, dog._only_marker), + ] == [ + ("Tom", OnlyMarker(only=("name",))), + ("Rex", OnlyMarker(only=("name",))), + ] + with pytest.raises(FieldNotLoadedError, match=not_fetched(CAT, "owner", ["name"])): + _ = cat._get_relationship_one("owner").id + + +@pytest.mark.parametrize("client_type", client_types) +async def test_related_node_fetch_without_only_behaves_as_before( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + [device] = await query_all(only_clients, client_type, httpx_mock, kind=DEVICE, edge=DEVICE_WITH_SITE, only=None) + only_data.add_page( + httpx_mock, + SITE, + [only_data.node_data(only_data.SITE_ID, SITE, name={"value": "Paris"}, description={"value": "Main site"})], + ) + client = only_data.client_of(only_clients, client_type) + + await resolve(device.site.fetch()) + + assert body_of(httpx_mock, "Get_InfraSite") == default_body( + SITE, + "Get_InfraSite", + {"name": only_data.VALUE, "description": only_data.VALUE}, + filters={"ids": [only_data.SITE_ID]}, + ) + site = device.site.peer + assert [site.name.value, site.description.value, site._only_marker] == ["Paris", "Main site", None] + assert [device.site.hfid, device.site.hfid_str] == [["Paris"], "InfraSite__Paris"] + assert client.store.get(key=only_data.SITE_ID, kind=SITE) is site + + +@pytest.mark.parametrize("client_type", client_types) +async def test_manager_fetch_without_only_behaves_as_before( + only_clients: BothClients, httpx_mock: HTTPXMock, client_type: str +) -> None: + edge = only_data.node_data(only_data.DEVICE_ID, DEVICE, name={"value": "dev1"}) + [device] = await query_all(only_clients, client_type, httpx_mock, kind=DEVICE, edge=edge, only=None) + only_data.add_page( + httpx_mock, + DEVICE, + [only_data.node_data(only_data.DEVICE_ID, DEVICE, tags=only_data.peers(only_data.peer(only_data.TAG_ID, TAG)))], + ) + only_data.add_page(httpx_mock, TAG, [only_data.node_data(only_data.TAG_ID, TAG, name={"value": "red"})]) + add_counts(httpx_mock, {TAG: 1}) + client = only_data.client_of(only_clients, client_type) + + await resolve(device.tags.fetch()) + + assert body_of(httpx_mock, "Get_InfraDevice") == default_body( + DEVICE, + "Get_InfraDevice", + {"tags": {"edges": {"node": {**NODE_FIELDS, "name": only_data.VALUE}}}}, + filters={"ids": [only_data.DEVICE_ID]}, + ) + assert body_of(httpx_mock, "Filters_BuiltinTag") == default_body( + TAG, "Filters_BuiltinTag", {"name": only_data.VALUE}, filters={"ids": [only_data.TAG_ID], "order": PEER_ORDER} + ) + assert [device.tags.is_loaded, device.tags.peer_ids] == [True, [only_data.TAG_ID]] + stored_tag = client.store.get(key=only_data.TAG_ID, kind=TAG) + assert [stored_tag._get_attribute("name").value, stored_tag._only_marker] == ["red", None] diff --git a/tests/unit/sdk/test_relogin_headers.py b/tests/unit/sdk/test_relogin_headers.py index 76dd913c3..f614ae9c6 100644 --- a/tests/unit/sdk/test_relogin_headers.py +++ b/tests/unit/sdk/test_relogin_headers.py @@ -1,11 +1,13 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING import pytest from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync from infrahub_sdk.constants import Priority +from infrahub_sdk.exceptions import AuthenticationError if TYPE_CHECKING: from pytest_httpx import HTTPXMock @@ -71,6 +73,132 @@ async def test_relogin_retry_uses_refreshed_auth_header(client_type: str, httpx_ assert all(r.headers["x-priority"] == "high" for r in graphql_requests) +@dataclass +class RefreshCase: + name: str + body: dict | str + expected_attempts: int + expected_message: str + + +REFRESH_CASES = [ + RefreshCase( + name="catalogued-token-expired-refreshes", + body={"errors": [{"message": "Token has expired", "extensions": {"code": "TOKEN_EXPIRED"}}]}, + expected_attempts=2, + expected_message="Token has expired", + ), + RefreshCase( + name="legacy-expired-signature-refreshes", + body={"errors": [{"message": "Expired Signature"}]}, + expected_attempts=2, + expected_message="Expired Signature", + ), + RefreshCase( + name="unrelated-401-does-not-refresh", + body={"errors": [{"message": "Invalid credentials", "extensions": {"code": "AUTHENTICATION_REQUIRED"}}]}, + expected_attempts=1, + expected_message="Invalid credentials", + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in REFRESH_CASES]) +@pytest.mark.parametrize("client_type", client_types) +async def test_refresh_decision_reads_the_code_then_falls_back_to_the_message( + client_type: str, case: RefreshCase, httpx_mock: HTTPXMock +) -> None: + """The silent refresh is decided by the catalogue code, with the legacy string as the fallback.""" + httpx_mock.add_response( + method="POST", url="http://mock/graphql/main", status_code=401, json=case.body, is_reusable=True + ) + if case.expected_attempts > 1: + httpx_mock.add_response(method="POST", url="http://mock/api/auth/refresh", json={"access_token": "NEW"}) + + client = _build_password_client(client_type) + query = "query { InfrahubInfo { version }}" + + with pytest.raises(AuthenticationError, match=case.expected_message): + if isinstance(client, InfrahubClient): + await client.execute_graphql(query=query, branch_name="main") + else: + client.execute_graphql(query=query, branch_name="main") + + graphql_requests = [r for r in httpx_mock.get_requests() if str(r.url) == "http://mock/graphql/main"] + assert len(graphql_requests) == case.expected_attempts + + +@pytest.mark.parametrize("client_type", client_types) +async def test_an_api_token_client_does_not_retry_a_stale_token_401(client_type: str, httpx_mock: HTTPXMock) -> None: + """`login(refresh=True)` cannot mint a token for an API-token client, so the retry is skipped. + + Retrying would replay the same token the server just rejected and earn a second 401 for nothing. + """ + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + status_code=401, + json={"errors": [{"message": "Token has expired", "extensions": {"code": "TOKEN_EXPIRED"}}]}, + is_reusable=True, + ) + config = Config(address="http://mock", api_token="static-token", insert_tracker=True) + client: InfrahubClient | InfrahubClientSync = ( + InfrahubClient(config=config) if client_type == "standard" else InfrahubClientSync(config=config) + ) + query = "query { InfrahubInfo { version }}" + + with pytest.raises(AuthenticationError, match="Token has expired"): + if isinstance(client, InfrahubClient): + await client.execute_graphql(query=query, branch_name="main") + else: + client.execute_graphql(query=query, branch_name="main") + + graphql_requests = [r for r in httpx_mock.get_requests() if str(r.url) == "http://mock/graphql/main"] + assert len(graphql_requests) == 1 + assert not [r for r in httpx_mock.get_requests() if "auth/refresh" in str(r.url)] + + +@dataclass +class UnreadableBodyCase: + name: str + text: str + + +UNREADABLE_BODY_CASES = [ + UnreadableBodyCase(name="html-proxy-error-page", text="

502 Bad Gateway

"), + UnreadableBodyCase(name="empty-body", text=""), + # Valid JSON, but not the object the envelope is supposed to be. A gateway that answers in its + # own format reaches the same code path, and reading `errors` off it must not throw. + UnreadableBodyCase(name="json-array", text='[{"message": "denied"}]'), + UnreadableBodyCase(name="json-null", text="null"), + UnreadableBodyCase(name="json-string", text='"denied"'), + UnreadableBodyCase(name="json-number", text="401"), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in UNREADABLE_BODY_CASES]) +@pytest.mark.parametrize("client_type", client_types) +async def test_refresh_decision_tolerates_a_body_it_cannot_read( + client_type: str, case: UnreadableBodyCase, httpx_mock: HTTPXMock +) -> None: + """A 401 body the SDK cannot read must surface as an AuthenticationError, never as an SDK error.""" + httpx_mock.add_response( + method="POST", url="http://mock/graphql/main", status_code=401, text=case.text, is_reusable=True + ) + + client = _build_password_client(client_type) + query = "query { InfrahubInfo { version }}" + + with pytest.raises(AuthenticationError, match="HTTP 401"): + if isinstance(client, InfrahubClient): + await client.execute_graphql(query=query, branch_name="main") + else: + client.execute_graphql(query=query, branch_name="main") + + graphql_requests = [r for r in httpx_mock.get_requests() if str(r.url) == "http://mock/graphql/main"] + assert len(graphql_requests) == 1, "a body carrying no refresh signal means no retry" + + @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. diff --git a/tests/unit/sdk/test_repository.py b/tests/unit/sdk/test_repository.py index a8364b1c9..81933237b 100644 --- a/tests/unit/sdk/test_repository.py +++ b/tests/unit/sdk/test_repository.py @@ -1,3 +1,4 @@ +import stat import tempfile from collections.abc import Generator from pathlib import Path @@ -92,3 +93,43 @@ def test_gitrepo_init(temp_dir: str) -> None: commit = repo._repo.git[repo._repo.git.head()] assert isinstance(commit, Commit) assert commit.message.decode("utf-8") == "First commit" + + +def _private_source(temp_dir: str) -> Path: + """A source directory that only its owner can access, as pytest's `tmp_path` creates them.""" + source = Path(temp_dir) / "source" + source.mkdir(mode=0o700) + (source / ".infrahub.yml").write_text("---\n", encoding="utf-8") + (source / "script.sh").write_text("#!/bin/sh\n", encoding="utf-8") + (source / "script.sh").chmod(0o700) + return source + + +def _permissions(path: Path) -> int: + return stat.S_IMODE(path.stat().st_mode) + + +def test_gitrepo_keeps_the_permissions_of_the_source_by_default(temp_dir: str) -> None: + destination = Path(temp_dir) / "remotes" + repo = GitRepo(name="private", src_directory=_private_source(temp_dir), dst_directory=destination) + + assert _permissions(destination / "private") == 0o700 + with pytest.raises(KeyError): + repo.repo.git.get_config().get((b"core",), b"sharedRepository") + + +def test_gitrepo_shared_gives_every_user_read_and_write_access(temp_dir: str) -> None: + destination = Path(temp_dir) / "remotes" + repo = GitRepo(name="shared", src_directory=_private_source(temp_dir), dst_directory=destination, shared=True) + root = destination / "shared" + + assert repo.repo.git.get_config().get((b"core",), b"sharedRepository") == b"0666" + for path in [root, *root.rglob("*")]: + if path.is_symlink(): + continue + if path.is_dir(): + assert _permissions(path) == 0o777, path + else: + assert _permissions(path) & 0o666 == 0o666, path + assert _permissions(root / "script.sh") == 0o777 + assert _permissions(root / ".infrahub.yml") == 0o666 diff --git a/tests/unit/sdk/test_retry.py b/tests/unit/sdk/test_retry.py index dcd83c629..f5a4e882f 100644 --- a/tests/unit/sdk/test_retry.py +++ b/tests/unit/sdk/test_retry.py @@ -12,6 +12,7 @@ import errno import io import logging +import re import tempfile import threading import time @@ -605,15 +606,27 @@ async def test_graphql_and_transport_retries_share_one_attempt_counter( class NonTransientCase: name: str errors: list[dict] + expected_message: str +# A governing error carrying a catalogue code names the failure with it; one the catalogue could not +# describe keeps the message this call site has always produced. NON_TRANSIENT_CASES = [ NonTransientCase( name="non-transient-status", errors=[{"message": "Unknown field", "extensions": {"code": "GRAPHQL_VALIDATION", "http_status": 400}}], + expected_message="GRAPHQL_VALIDATION: Unknown field", + ), + NonTransientCase( + name="mixed", + errors=[_transient_error(503), {"message": "Unknown field"}], + expected_message="DATABASE_UNAVAILABLE: Unable to connect to the database", + ), + NonTransientCase( + name="unclassified", + errors=[{"message": "legacy error without extensions"}], + expected_message="An error occurred while executing the GraphQL Query", ), - NonTransientCase(name="mixed", errors=[_transient_error(503), {"message": "Unknown field"}]), - NonTransientCase(name="unclassified", errors=[{"message": "legacy error without extensions"}]), ] @@ -626,7 +639,7 @@ async def test_non_transient_graphql_errors_raise_immediately( requester = ScriptedRequester([_graphql_errors(*case.errors), _ok()]) client = _build_client(client_type, requester, retry_on_failure=True, max_retry_duration=0) - with pytest.raises(GraphQLError, match="An error occurred while executing the GraphQL Query") as exc: + with pytest.raises(GraphQLError, match=re.escape(case.expected_message)) as exc: await _execute_graphql(client) assert exc.value.errors == case.errors @@ -926,7 +939,7 @@ async def test_multipart_mutation_raises_non_transient_graphql_errors_immediatel httpx_mock.add_response(status_code=200, json={"data": None, "errors": errors}) client = _build_client_over_httpx(client_type, retry_on_failure=True, max_retry_duration=0) - with pytest.raises(GraphQLError, match="An error occurred while executing the GraphQL Query") as exc: + with pytest.raises(GraphQLError, match="GRAPHQL_VALIDATION: Unknown field") as exc: await _upload(client, io.BytesIO(MULTIPART_FILE_CONTENT)) assert exc.value.errors == errors diff --git a/tests/unit/sdk/test_schema.py b/tests/unit/sdk/test_schema.py index 2e134bce6..25d410522 100644 --- a/tests/unit/sdk/test_schema.py +++ b/tests/unit/sdk/test_schema.py @@ -1,4 +1,5 @@ import inspect +from collections.abc import Generator from io import StringIO from unittest import mock from unittest.mock import MagicMock @@ -362,6 +363,22 @@ async def test_python_transform_config_description() -> None: assert config_explicit_none.description is None +@pytest.fixture +def schema_console() -> Generator[Console, None, None]: + """Capture what ``infrahub_sdk.ctl.schema`` prints, with colour and width pinned. + + ``width`` is set high so the error lines under test are never wrapped, and ``no_color`` / + ``force_terminal`` are explicit so the captured text does not depend on the ambient + environment even though ``tests/conftest.py`` already pins it. + + Yields: + Console: The console patched in place of ``infrahub_sdk.ctl.schema.console``. + """ + console = Console(file=StringIO(), width=1000, no_color=True, force_terminal=False) + with mock.patch("infrahub_sdk.ctl.schema.console", console): + yield console + + @mock.patch( "infrahub_sdk.ctl.schema.get_node", return_value={ @@ -370,7 +387,7 @@ async def test_python_transform_config_description() -> None: "attributes": [{"name": "name", "kind": "Text"}, {"name": "status", "kind": "Dropdown"}], }, ) -async def test_display_schema_load_errors_details_dropdown(mock_get_node: MagicMock) -> None: +async def test_display_schema_load_errors_details_dropdown(mock_get_node: MagicMock, schema_console: Console) -> None: """Validate error message with details when loading schema.""" error = { "detail": [ @@ -385,14 +402,14 @@ async def test_display_schema_load_errors_details_dropdown(mock_get_node: MagicM ] } - with mock.patch("infrahub_sdk.ctl.schema.console", Console(file=StringIO(), width=1000)) as console: - display_schema_load_errors(response=error, schemas_data=[]) - mock_get_node.assert_called_once() - output = console.file.getvalue() - expected_console = """Unable to load the schema: + display_schema_load_errors(response=error, schemas_data=[]) + + mock_get_node.assert_called_once() + output = schema_console.file.getvalue() + expected_console = """Unable to load the schema: Node: CloudInstance | Attribute: status ({'name': 'status', 'kind': 'Dropdown'}) | Value error, The property 'choices' is required for kind=Dropdown (value_error) """ # noqa: E501 - assert output == expected_console + assert output == expected_console @mock.patch( @@ -403,7 +420,7 @@ async def test_display_schema_load_errors_details_dropdown(mock_get_node: MagicM "attributes": [{"name": "name", "kind": "Text"}, {"name": "status", "kind": "Dropdown"}], }, ) -async def test_display_schema_load_errors_details_namespace(mock_get_node: MagicMock) -> None: +async def test_display_schema_load_errors_details_namespace(mock_get_node: MagicMock, schema_console: Console) -> None: """Validate error message with details when loading schema.""" error = { "detail": [ @@ -418,14 +435,14 @@ async def test_display_schema_load_errors_details_namespace(mock_get_node: Magic ] } - with mock.patch("infrahub_sdk.ctl.schema.console", Console(file=StringIO(), width=1000)) as console: - display_schema_load_errors(response=error, schemas_data=[]) - mock_get_node.assert_called_once() - output = console.file.getvalue() - expected_console = """Unable to load the schema: + display_schema_load_errors(response=error, schemas_data=[]) + + mock_get_node.assert_called_once() + output = schema_console.file.getvalue() + expected_console = """Unable to load the schema: Node: OuTInstance | namespace (OuT) | String should match pattern '^[A-Z][a-z0-9]+$' (string_pattern_mismatch) """ - assert output == expected_console + assert output == expected_console @mock.patch( @@ -459,7 +476,7 @@ async def test_display_schema_load_errors_details_namespace(mock_get_node: Magic }, ) async def test_display_schema_load_errors_details_when_error_is_in_attribute_or_relationship( - mock_get_node: MagicMock, + mock_get_node: MagicMock, schema_console: Console ) -> None: """Validate error message with details when loading schema and errors are in attribute or relationship.""" error = { @@ -479,15 +496,15 @@ async def test_display_schema_load_errors_details_when_error_is_in_attribute_or_ ] } - with mock.patch("infrahub_sdk.ctl.schema.console", Console(file=StringIO(), width=1000)) as console: - display_schema_load_errors(response=error, schemas_data=[]) - assert mock_get_node.call_count == 2 - output = console.file.getvalue() - expected_console = """Unable to load the schema: + display_schema_load_errors(response=error, schemas_data=[]) + + assert mock_get_node.call_count == 2 + output = schema_console.file.getvalue() + expected_console = """Unable to load the schema: Node: SecurityTailscaleSSHRule | Attribute: check_period (0) | Extra inputs are not permitted (extra_forbidden) Node: SecurityTailscaleSSHRule | Attribute: check_period (10080) | Extra inputs are not permitted (extra_forbidden) """ - assert output == expected_console + assert output == expected_console @pytest.mark.parametrize( diff --git a/tests/unit/sdk/test_trace_context.py b/tests/unit/sdk/test_trace_context.py new file mode 100644 index 000000000..0a917a723 --- /dev/null +++ b/tests/unit/sdk/test_trace_context.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +import opentelemetry +import pytest +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags, use_span + +from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync, trace_context + +if TYPE_CHECKING: + from collections.abc import Iterator + + from pytest_httpx import HTTPXMock + +TRACE_ID = 0x0AF7651916CD43DD8448EB211C80319C +SPAN_ID = 0xB7AD6B7169203331 +EXPECTED_TRACEPARENT = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" +QUERY = "query { InfrahubInfo { version } }" + +client_types = ["standard", "sync"] + + +def _span() -> NonRecordingSpan: + return NonRecordingSpan( + SpanContext(trace_id=TRACE_ID, span_id=SPAN_ID, is_remote=False, trace_flags=TraceFlags(TraceFlags.SAMPLED)) + ) + + +async def _execute(client_type: str) -> None: + config = Config(address="http://mock", api_token="token") + if client_type == "standard": + await InfrahubClient(config=config).execute_graphql(query=QUERY) + else: + InfrahubClientSync(config=config).execute_graphql(query=QUERY) + + +@pytest.fixture(autouse=True) +def fresh_version_check() -> Iterator[None]: + trace_context._supported_opentelemetry_is_installed.cache_clear() + yield + trace_context._supported_opentelemetry_is_installed.cache_clear() + + +@pytest.fixture +def graphql_response(httpx_mock: HTTPXMock) -> HTTPXMock: + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json={"data": {"InfrahubInfo": {"version": "1"}}}, + is_reusable=True, + ) + return httpx_mock + + +@pytest.mark.parametrize("client_type", client_types) +async def test_request_inside_a_span_carries_its_traceparent(client_type: str, graphql_response: HTTPXMock) -> None: + with use_span(_span()): + await _execute(client_type) + + request = graphql_response.get_request() + assert request is not None + assert request.headers["traceparent"] == EXPECTED_TRACEPARENT + + +@pytest.mark.parametrize("client_type", client_types) +async def test_request_outside_a_span_has_no_traceparent(client_type: str, graphql_response: HTTPXMock) -> None: + await _execute(client_type) + + request = graphql_response.get_request() + assert request is not None + assert "traceparent" not in request.headers + + +@pytest.mark.parametrize("client_type", client_types) +async def test_request_without_opentelemetry_has_no_traceparent( + client_type: str, graphql_response: HTTPXMock, monkeypatch: pytest.MonkeyPatch +) -> None: + span = _span() + monkeypatch.setitem(sys.modules, "opentelemetry", None) + + with use_span(span): + await _execute(client_type) + + request = graphql_response.get_request() + assert request is not None + assert "traceparent" not in request.headers + + +@pytest.mark.parametrize("client_type", client_types) +async def test_invalid_propagator_setting_sends_no_traceparent_and_warns_once( + client_type: str, graphql_response: HTTPXMock, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + propagator = f"not-a-propagator-{client_type}" + monkeypatch.setenv("OTEL_PROPAGATORS", propagator) + monkeypatch.delitem(sys.modules, "opentelemetry.propagate", raising=False) + monkeypatch.delattr(opentelemetry, "propagate", raising=False) + + with use_span(_span()), caplog.at_level("WARNING", logger="infrahub_sdk"): + await _execute(client_type) + await _execute(client_type) + + requests = graphql_response.get_requests() + assert len(requests) == 2 + assert all("traceparent" not in request.headers for request in requests) + expected = ( + "Not sending the trace context: OpenTelemetry could not load its propagators " + f"(Propagator {propagator} not found. It is either misspelled or not installed.)" + ) + assert [record.getMessage() for record in caplog.records if "trace context" in record.getMessage()] == [expected] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_unsupported_opentelemetry_is_never_imported_and_warns_once( + client_type: str, graphql_response: HTTPXMock, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setattr(trace_context, "version", lambda _distribution: "1.18.0") + monkeypatch.delitem(sys.modules, "opentelemetry.propagate", raising=False) + monkeypatch.delattr(opentelemetry, "propagate", raising=False) + + with use_span(_span()), caplog.at_level("WARNING", logger="infrahub_sdk"): + await _execute(client_type) + await _execute(client_type) + + requests = graphql_response.get_requests() + assert len(requests) == 2 + assert all("traceparent" not in request.headers for request in requests) + assert "opentelemetry.propagate" not in sys.modules + assert [record.getMessage() for record in caplog.records if "trace context" in record.getMessage()] == [ + "Not sending the trace context: opentelemetry-api 1.18.0 is installed, 1.33 or later is required" + ] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_installed_version_is_read_once( + client_type: str, graphql_response: HTTPXMock, monkeypatch: pytest.MonkeyPatch +) -> None: + lookups: list[str] = [] + + def fake_version(distribution: str) -> str: + lookups.append(distribution) + return "1.45.1" + + monkeypatch.setattr(trace_context, "version", fake_version) + + with use_span(_span()): + await _execute(client_type) + await _execute(client_type) + + assert lookups == ["opentelemetry-api"] + assert [request.headers["traceparent"] for request in graphql_response.get_requests()] == [ + EXPECTED_TRACEPARENT, + EXPECTED_TRACEPARENT, + ] diff --git a/tests/unit/sdk/test_yaml.py b/tests/unit/sdk/test_yaml.py index e99ba6fc6..112ff7ac9 100644 --- a/tests/unit/sdk/test_yaml.py +++ b/tests/unit/sdk/test_yaml.py @@ -1,14 +1,60 @@ +from dataclasses import dataclass from pathlib import Path import pytest -from infrahub_sdk.exceptions import FileNotValidError +from infrahub_sdk.exceptions import FileNotValidError, ValidationError from infrahub_sdk.utils import get_fixtures_dir -from infrahub_sdk.yaml import YamlFile +from infrahub_sdk.yaml import InfrahubFile, YamlFile here = Path(__file__).parent.resolve() +@dataclass +class InvalidInfrahubFileCase: + name: str + content: dict + message: str + + +INVALID_INFRAHUB_FILE_CASES = [ + InvalidInfrahubFileCase( + name="unknown-kind", + content={"apiVersion": "infrahub.app/v1", "kind": "Schema", "spec": {}}, + message="kind | Input should be 'Menu' or 'Object', received 'Schema' (enum)", + ), + InvalidInfrahubFileCase( + name="missing-spec", + content={"apiVersion": "infrahub.app/v1", "kind": "Object"}, + message="spec | Field required (missing)", + ), + InvalidInfrahubFileCase( + name="unknown-api-version", + content={"apiVersion": "infrahub.app/v2", "kind": "Object", "spec": {}}, + message="apiVersion | Input should be 'infrahub.app/v1', received 'infrahub.app/v2' (enum)", + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in INVALID_INFRAHUB_FILE_CASES]) +def test_infrahub_file_validate_content_invalid(case: InvalidInfrahubFileCase) -> None: + infrahub_file = InfrahubFile(location=Path("some/path"), content=case.content) + with pytest.raises(ValidationError, match=r"^some/path: ") as exc: + infrahub_file.validate_content() + + assert exc.value.identifier == "some/path" + assert exc.value.messages == [case.message] + + +def test_infrahub_file_validate_content_non_string_keys() -> None: + # PyYAML reads unquoted keys such as `2:` and `off:` as int and bool. + content = {"apiVersion": "infrahub.app/v1", "kind": "Object", "spec": {}, 2: "extra", False: "off"} + infrahub_file = InfrahubFile(location=Path("some/path"), content=content) + infrahub_file.validate_content() + + assert infrahub_file.data.spec == {} + + def test_read_missing_file() -> None: file_name = "i_do_not_exist.yml" test_data_dir = here / "test_data" diff --git a/tests/unit/test_packaging_metadata.py b/tests/unit/test_packaging_metadata.py new file mode 100644 index 000000000..f789b8226 --- /dev/null +++ b/tests/unit/test_packaging_metadata.py @@ -0,0 +1,343 @@ +"""Checks that keep the declared dependency surface in `pyproject.toml` honest. + +The version matrix in CI installs whatever `uv.lock` pins, so it never exercises the ranges +declared in `pyproject.toml`. These checks cover the part of that gap that costs no extra CI +time: that every requirement is bounded, that the extras aggregate correctly, and that the +declared dependencies and the imports in the shipped package agree with each other. +""" + +from __future__ import annotations + +import ast +import re +import sys +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any, TypeGuard + +import pytest +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +from tests.constants import PACKAGE_DIR, PYPROJECT_FILE, REPO_ROOT + +if sys.version_info >= (3, 11): + import tomllib +else: + # Drop this branch, and the suppression, once Python 3.10 support ends. + import tomli as tomllib # type: ignore[import-not-found] + +PROJECT_NAME = "infrahub-sdk" +BASE_SECTION = "project.dependencies" + +# Modules that ship in the wheel but are allowed to need an extra, mapped to the extra that +# supplies them. `ctl/` is the CLI; `async_typer` and `graphql/plugin.py` sit outside it but are +# only reachable from it. Everything else must import on a plain install, which is why the entries +# are per-file where a package is split: `testing/repository.py` needs nothing beyond the core +# dependencies, so it is deliberately absent and held to the base install. +EXTRA_ONLY_MODULES = { + "ctl": ("ctl/", "async_typer.py", "graphql/plugin.py"), + "testing": ("pytest_plugin/", "testing/schemas/"), + "testcontainers": ("testing/docker.py",), +} + +# Operators that establish a floor. A requirement without one of these lets a resolver reach back +# to the first release ever published, which is never a version we have tested against. +LOWER_BOUND_OPERATORS = frozenset({">=", ">", "==", "~="}) + +# Only modules whose import name differs from their distribution name in a way PEP 503 +# normalisation cannot recover. Everything else maps by normalising the module name. +IMPORT_NAME_TO_DISTRIBUTION = { + "graphql": "graphql-core", + "opentelemetry": "opentelemetry-api", + "ruamel": "ruamel.yaml", + "yaml": "pyyaml", +} + +# Standard library on a Python newer than the interpreter running these checks, so absent from +# this interpreter's `sys.stdlib_module_names`. They are imported behind a `sys.version_info` +# guard alongside a declared backport, and must not be mistaken for undeclared packages on the +# older interpreters in the test matrix. +STDLIB_ON_NEWER_PYTHON = frozenset({"tomllib"}) + +# Distributions declared on purpose without being imported, with the reason why. +DECLARED_WITHOUT_IMPORT = { + "click": "Constrains the `click` version that `typer` resolves to; never imported directly.", +} + + +def _normalize(name: str) -> str: + """Normalise a distribution name per PEP 503 so `ruamel.yaml` and `Jinja2` compare cleanly.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def _pyproject() -> dict[str, Any]: + with PYPROJECT_FILE.open("rb") as handle: + return tomllib.load(handle)["project"] + + +def _optional_dependencies() -> dict[str, list[str]]: + return _pyproject()["optional-dependencies"] + + +def _is_self_reference(requirement: Requirement) -> bool: + return _normalize(requirement.name) == _normalize(PROJECT_NAME) + + +@dataclass +class RequirementCase: + name: str + section: str + requirement: str + + +@dataclass +class ImportCase: + name: str + source: str + runs_on_import: bool + + +IMPORT_CLASSIFICATION_CASES = [ + ImportCase(name="module-level", source="import pyarrow\n", runs_on_import=True), + ImportCase( + name="module-level-try", + source="try:\n import pyarrow\nexcept ImportError:\n pyarrow = None\n", + runs_on_import=True, + ), + ImportCase( + name="inside-function", + source="def load():\n import pyarrow\n return pyarrow\n", + runs_on_import=False, + ), + ImportCase( + name="type-checking-guard", + source="from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n import pyarrow\n", + runs_on_import=False, + ), + ImportCase( + name="qualified-type-checking-guard", + source="import typing\nif typing.TYPE_CHECKING:\n import pyarrow\n", + runs_on_import=False, + ), + ImportCase( + name="runtime-fallback-of-type-checking-guard", + source="from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n pass\nelse:\n import pyarrow\n", + runs_on_import=True, + ), +] + + +def _requirement_cases() -> list[RequirementCase]: + """Every requirement a user can install, labelled with the section that declares it.""" + project = _pyproject() + sections = {BASE_SECTION: project["dependencies"], **project["optional-dependencies"]} + return [ + RequirementCase(name=f"{section}-{Requirement(requirement).name}", section=section, requirement=requirement) + for section, requirements in sections.items() + for requirement in requirements + ] + + +def _declared_in(sections: Iterable[str]) -> set[str]: + """The distributions installed by the given sections of `pyproject.toml`. + + Self-referential extras are followed, so asking for `testcontainers` also returns whatever + the `testing` extra it references installs. Without that, an extra defined by self-reference + would look as though it installed nothing. + """ + cases = _requirement_cases() + pending, seen, declared = [_normalize(section) for section in sections], set(), set() + while pending: + section = pending.pop() + if section in seen: + continue + seen.add(section) + for case in cases: + if _normalize(case.section) != section: + continue + requirement = Requirement(case.requirement) + if _is_self_reference(requirement): + pending.extend(_normalize(extra) for extra in requirement.extras) + else: + declared.add(_normalize(requirement.name)) + return declared + + +def _surface_of(relative_path: Path) -> str | None: + """The extra a shipped module may rely on, or None when it has to work on a base install.""" + posix = relative_path.as_posix() + for extra, prefixes in EXTRA_ONLY_MODULES.items(): + if any(posix.startswith(prefix) for prefix in prefixes): + return extra + return None + + +def _is_type_checking_guard(node: ast.AST) -> TypeGuard[ast.If]: + """Whether *node* is an `if TYPE_CHECKING:` (or `if typing.TYPE_CHECKING:`) statement.""" + if not isinstance(node, ast.If): + return False + test = node.test + name = test.id if isinstance(test, ast.Name) else test.attr if isinstance(test, ast.Attribute) else None + return name == "TYPE_CHECKING" + + +def _import_time_nodes(tree: ast.Module) -> Iterator[ast.AST]: + """Walk the nodes that execute when the module is first loaded. + + Function bodies and `if TYPE_CHECKING:` blocks are both skipped, because neither runs on + import: a deferred import is the sanctioned way to reach for an extra, as the JSON importer + does for pyarrow, and a type-checking import never executes at all. + + Yields: + Each node reachable at import time, including those nested in module-level `if` and `try` + blocks and in class bodies. + """ + stack: list[ast.AST] = list(tree.body) + while stack: + node = stack.pop() + yield node + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if _is_type_checking_guard(node): + # The else branch of a type-checking guard is the runtime fallback, so it still counts. + stack.extend(node.orelse) + continue + stack.extend(ast.iter_child_nodes(node)) + + +def _imports_of(path: Path, *, include_deferred: bool) -> set[str]: + """The third-party distributions a single module imports.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + distributions = set() + for node in ast.walk(tree) if include_deferred else _import_time_nodes(tree): + if isinstance(node, ast.Import): + modules = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + modules = [node.module] + else: + continue + for module in modules: + top_level = module.split(".")[0] + if ( + top_level in sys.stdlib_module_names + or top_level in STDLIB_ON_NEWER_PYTHON + or top_level == PACKAGE_DIR.name + ): + continue + distributions.add(_normalize(IMPORT_NAME_TO_DISTRIBUTION.get(top_level, top_level))) + return distributions + + +def _imported_distributions() -> dict[str, Path]: + """Map each third-party distribution imported under the package to a file that imports it.""" + imported: dict[str, Path] = {} + for path in sorted(PACKAGE_DIR.rglob("*.py")): + for distribution in sorted(_imports_of(path, include_deferred=True)): + imported.setdefault(distribution, path.relative_to(REPO_ROOT)) + return imported + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in IMPORT_CLASSIFICATION_CASES]) +def test_import_classification(case: ImportCase, tmp_path: Path) -> None: + """Whether an import counts at import time decides what the surface check enforces.""" + module = tmp_path / "probe.py" + module.write_text(case.source, encoding="utf-8") + + assert ("pyarrow" in _imports_of(module, include_deferred=False)) is case.runs_on_import + assert "pyarrow" in _imports_of(module, include_deferred=True), ( + "every import should be found when deferred ones are included" + ) + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in _requirement_cases()]) +def test_requirement_declares_a_lower_bound(case: RequirementCase) -> None: + requirement = Requirement(case.requirement) + if _is_self_reference(requirement): + pytest.skip("self-referential extra, versioned by the project itself") + + operators = {specifier.operator for specifier in requirement.specifier} + assert operators & LOWER_BOUND_OPERATORS, ( + f"{case.requirement!r} has no lower bound, so a resolver may select an ancient release we " + f"have never tested against. Add a floor matching the oldest version known to work." + ) + + +def test_all_extra_aggregates_every_other_extra() -> None: + """`all` must be defined by self-reference so it cannot drift out of sync with the other extras.""" + extras = _optional_dependencies() + aggregated: set[str] = set() + for raw in extras["all"]: + requirement = Requirement(raw) + assert _is_self_reference(requirement), ( + f"the 'all' extra must aggregate this project's own extras by self-reference, found {raw!r}" + ) + aggregated |= {_normalize(extra) for extra in requirement.extras} + + assert aggregated == {_normalize(name) for name in extras if name != "all"} + + +def test_shipped_modules_only_import_what_their_install_provides() -> None: + """Every shipped module must be importable with the install its own surface implies. + + Checking against the union of every extra would let a module that ships in the base wheel + import a package only the `ctl` extra installs, which is how `infrahub_sdk.template` came to + raise `ModuleNotFoundError` on a plain install. + """ + base = _declared_in({BASE_SECTION}) + failures = [] + for path in sorted(PACKAGE_DIR.rglob("*.py")): + extra = _surface_of(path.relative_to(PACKAGE_DIR)) + available = base if extra is None else base | _declared_in({extra}) + install = "a base install" if extra is None else f"the '{extra}' extra" + failures += [ + f"{path.relative_to(REPO_ROOT)} imports {distribution}, which is not installed by {install}" + for distribution in sorted(_imports_of(path, include_deferred=False) - available) + ] + + assert not failures, "\n".join(failures) + + +def test_imports_in_shipped_package_are_declared() -> None: + """Anything the wheel imports must be installable, not left to arrive as a transitive dependency.""" + declared = _declared_in({BASE_SECTION, *_optional_dependencies()}) + undeclared = { + distribution: source + for distribution, source in _imported_distributions().items() + if distribution not in declared + } + assert not undeclared, "\n".join( + f"{distribution} is imported by {source} but is declared nowhere in pyproject.toml" + for distribution, source in sorted(undeclared.items()) + ) + + +def test_declared_dependencies_are_imported() -> None: + """A declared dependency nobody imports is either dead weight or belongs in a dependency group.""" + imported = _imported_distributions() + unused = sorted( + distribution + for distribution in _declared_in({BASE_SECTION, *_optional_dependencies()}) + if distribution not in imported and distribution not in DECLARED_WITHOUT_IMPORT + ) + assert not unused, ( + f"declared but never imported under {PACKAGE_DIR.name}/: {', '.join(unused)}. Move it to a " + f"dependency group if it is only needed for development, or record the reason it is declared " + f"in DECLARED_WITHOUT_IMPORT." + ) + + +def test_requires_python_matches_classifiers() -> None: + project = _pyproject() + supported = SpecifierSet(project["requires-python"]) + prefix = "Programming Language :: Python :: 3." + + from_classifiers = {c.removeprefix(prefix) for c in project["classifiers"] if c.startswith(prefix)} + from_specifier = {str(minor) for minor in range(50) if Version(f"3.{minor}") in supported} + + assert from_classifiers == from_specifier, ( + f"requires-python allows 3.x for x in {sorted(from_specifier)} but the classifiers " + f"advertise {sorted(from_classifiers)}" + ) diff --git a/tests/unit/test_schema_offline_validation.py b/tests/unit/test_schema_offline_validation.py index ef5119352..a673b8297 100644 --- a/tests/unit/test_schema_offline_validation.py +++ b/tests/unit/test_schema_offline_validation.py @@ -13,7 +13,7 @@ import pytest -from infrahub_sdk.schema import InfrahubSchemaRead, InfrahubSchemaWrite, validate_schema +from infrahub_sdk.schema import InfrahubSchemaRead, InfrahubSchemaWrite, SchemaRoot, validate_schema from infrahub_sdk.schema.validate import SchemaValidationResult @@ -161,6 +161,90 @@ def test_enum_backed_relationship_cardinality_valid_value_passes() -> None: assert result.valid is True, result.messages +# --------------------------------------------------------------------------- +# NumberPool parameters: ranges, the deprecated shorthand, or neither +# --------------------------------------------------------------------------- + + +def _number_pool_parameters_of(schema: dict) -> dict: + dumped = InfrahubSchemaWrite.model_validate(schema).model_dump(exclude_unset=True) + return dumped["nodes"][0]["attributes"][0].get("parameters", {}) + + +def test_number_pool_ranges_declaration_passes() -> None: + schema = _schema_with_kind_and_parameters( + "NumberPool", {"ranges": [{"start": 1, "end": 10}, {"start": 20, "end": 30, "weight": 5}]} + ) + + result = validate_schema(schema=schema) + + assert result.valid is True, result.messages + assert result.warnings == [] + assert _number_pool_parameters_of(schema)["ranges"] == [ + {"start": 1, "end": 10}, + {"start": 20, "end": 30, "weight": 5}, + ] + + +def test_number_pool_shorthand_is_still_accepted() -> None: + schema = _schema_with_kind_and_parameters("NumberPool", {"start_range": 1, "end_range": 10}) + + result = validate_schema(schema=schema) + + assert result.valid is True, result.messages + assert result.warnings == [] + assert _number_pool_parameters_of(schema) == {"start_range": 1, "end_range": 10} + + +@pytest.mark.parametrize("parameters", [None, {}], ids=["no-parameters", "empty-parameters"]) +def test_number_pool_without_any_range_spelling_leaves_the_range_fields_absent(parameters: dict | None) -> None: + # No default range is invented for the user: a pool declared without ranges is submitted as such. + schema = _valid_schema() + schema["nodes"][0]["attributes"][0]["kind"] = "NumberPool" + if parameters is not None: + schema["nodes"][0]["attributes"][0]["parameters"] = parameters + + result = validate_schema(schema=schema) + + assert result.valid is True, result.messages + assert _number_pool_parameters_of(schema) == {} + # The server drops None values before building its own schema, so no range may survive that dump either. + dumped = InfrahubSchemaWrite.model_validate(schema).model_dump(exclude_none=True) + submitted = dumped["nodes"][0]["attributes"][0].get("parameters") or {} + assert "ranges" not in submitted + + +def test_number_pool_explicit_empty_ranges_survive_the_dumps() -> None: + # An explicitly empty range list declares zero ranges, unlike a declaration that leaves the ranges out. + schema = _schema_with_kind_and_parameters("NumberPool", {"ranges": []}) + + result = validate_schema(schema=schema) + + assert result.valid is True, result.messages + server_dump = InfrahubSchemaWrite.model_validate(schema).model_dump(exclude_none=True) + assert server_dump["nodes"][0]["attributes"][0]["parameters"] == {"ranges": []} + client_dump = SchemaRoot.model_validate(schema).to_schema_dict() + assert client_dump["nodes"][0]["attributes"][0]["parameters"] == {"ranges": []} + + +def test_number_pool_range_with_one_bound_is_accepted() -> None: + schema = _schema_with_kind_and_parameters("NumberPool", {"ranges": [{"start": 1}, {"end": 0}]}) + + result = validate_schema(schema=schema) + + assert result.valid is True + + +def test_number_pool_range_with_a_weight_that_is_not_a_number_is_rejected() -> None: + schema = _schema_with_kind_and_parameters("NumberPool", {"ranges": [{"start": 1, "weight": "heavy"}]}) + + result = validate_schema(schema=schema) + + assert result.valid is False + # A violation inside a discriminated union is located through the variant that was selected. + assert _fields_named(result) == {"nodes[0].attributes[0].NumberPool.parameters.ranges[0].weight"} + + # --------------------------------------------------------------------------- # Read-only fields are accepted with a warning # --------------------------------------------------------------------------- @@ -233,6 +317,16 @@ class ReadOnlyCase: "nodes[0].attributes[0].choices[0].state", }, ), + ReadOnlyCase( + name="number-pool-range-bookkeeping-fields", + schema=_schema_with_kind_and_parameters( + "NumberPool", {"ranges": [{"start": 1, "end": 10, "id": None, "state": "present"}]} + ), + expected_fields={ + "nodes[0].attributes[0].parameters.ranges[0].id", + "nodes[0].attributes[0].parameters.ranges[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( @@ -344,6 +438,11 @@ class UnknownFieldCase: schema=_schema_with_parameters({"not_a_real_param": 1}), expected_fields={"nodes[0].attributes[0].parameters.not_a_real_param"}, ), + UnknownFieldCase( + name="number-pool-range-unknown-field", + schema=_schema_with_kind_and_parameters("NumberPool", {"ranges": [{"start": 1, "end": 10, "step": 2}]}), + expected_fields={"nodes[0].attributes[0].parameters.ranges[0].step"}, + ), # 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( diff --git a/uv.lock b/uv.lock index 1754d252c..c5d989183 100644 --- a/uv.lock +++ b/uv.lock @@ -4,8 +4,7 @@ requires-python = ">=3.10, <3.16" resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.13' and python_full_version < '3.15'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version < '3.11'", ] @@ -742,13 +741,18 @@ wheels = [ name = "infrahub-sdk" source = { editable = "." } dependencies = [ + { name = "anyio" }, { name = "dulwich" }, { name = "graphql-core" }, { name = "httpx" }, + { name = "jinja2" }, { name = "netutils" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "rich" }, { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, { name = "ujson" }, { name = "whenever" }, ] @@ -757,33 +761,34 @@ dependencies = [ all = [ { name = "ariadne-codegen" }, { name = "click" }, - { name = "jinja2" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "infrahub-testcontainers" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, { name = "pyarrow", version = "25.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pyarrow", version = "26.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pytest" }, - { name = "pyyaml" }, - { name = "rich" }, { name = "ruamel-yaml" }, { name = "typer" }, ] ctl = [ { name = "ariadne-codegen" }, { name = "click" }, - { name = "jinja2" }, - { name = "mdxify" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pyarrow", version = "25.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pyarrow", version = "26.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pyyaml" }, - { name = "rich" }, { name = "ruamel-yaml" }, { name = "typer" }, ] +testcontainers = [ + { name = "infrahub-testcontainers" }, + { name = "packaging" }, + { name = "pytest" }, +] +testing = [ + { name = "pytest" }, +] +tracing = [ + { name = "opentelemetry-api" }, +] [package.dev-dependencies] dev = [ @@ -794,7 +799,9 @@ dev = [ { 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 = "mdxify" }, { name = "mypy" }, + { name = "opentelemetry-api" }, { name = "prek" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -812,6 +819,9 @@ dev = [ { name = "types-ujson" }, { name = "yamllint" }, ] +docs = [ + { name = "mdxify" }, +] lint = [ { name = "astroid" }, { name = "mypy" }, @@ -823,6 +833,7 @@ lint = [ tests = [ { name = "infrahub-testcontainers" }, { name = "jsonschema" }, + { name = "opentelemetry-api" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-clarity" }, @@ -838,6 +849,7 @@ types = [ [package.metadata] requires-dist = [ + { name = "anyio", specifier = ">=3.3.0" }, { name = "ariadne-codegen", marker = "extra == 'all'", specifier = "==0.18.0" }, { name = "ariadne-codegen", marker = "extra == 'ctl'", specifier = "==0.18.0" }, { name = "click", marker = "extra == 'all'", specifier = ">=8.3,<9" }, @@ -845,42 +857,45 @@ requires-dist = [ { name = "dulwich", specifier = ">=0.24.7" }, { name = "graphql-core", specifier = ">=3.2.12,<3.3" }, { name = "httpx", specifier = ">=0.20" }, - { name = "jinja2", marker = "extra == 'all'", specifier = ">=3" }, - { name = "jinja2", marker = "extra == 'ctl'", specifier = ">=3" }, - { name = "mdxify", marker = "python_full_version >= '3.10' and extra == 'ctl'", specifier = ">=0.2.23" }, + { name = "infrahub-testcontainers", marker = "extra == 'all'", specifier = ">=1.7.3" }, + { name = "infrahub-testcontainers", marker = "extra == 'testcontainers'", specifier = ">=1.7.3" }, + { name = "jinja2", specifier = ">=3.1.5" }, { name = "netutils", specifier = ">=1.0.0" }, - { name = "numpy", marker = "python_full_version >= '3.12' and extra == 'all'", specifier = ">=1.26.2" }, - { name = "numpy", marker = "python_full_version >= '3.12' and extra == 'ctl'", specifier = ">=1.26.2" }, - { name = "numpy", marker = "python_full_version < '3.12' and extra == 'all'", specifier = ">=1.24.2" }, - { name = "numpy", marker = "python_full_version < '3.12' and extra == 'ctl'", specifier = ">=1.24.2" }, + { name = "opentelemetry-api", marker = "extra == 'all'", specifier = ">=1.33" }, + { name = "opentelemetry-api", marker = "extra == 'tracing'", specifier = ">=1.33" }, + { name = "packaging", marker = "extra == 'all'", specifier = ">=21.0" }, + { name = "packaging", marker = "extra == 'testcontainers'", specifier = ">=21.0" }, { name = "pyarrow", marker = "extra == 'all'", specifier = ">=14" }, { name = "pyarrow", marker = "extra == 'ctl'", specifier = ">=14" }, - { name = "pydantic", specifier = ">=2.0.0,!=2.0.1,!=2.1.0,<3.0.0" }, + { name = "pydantic", specifier = ">=2.0.3,!=2.1.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.0" }, - { name = "pytest", marker = "extra == 'all'" }, - { name = "pyyaml", marker = "extra == 'all'", specifier = ">=6" }, - { name = "pyyaml", marker = "extra == 'ctl'", specifier = ">=6" }, - { name = "rich", marker = "extra == 'all'", specifier = ">=12.6,<14" }, - { name = "rich", marker = "extra == 'ctl'", specifier = ">=12.6,<14" }, + { name = "pytest", marker = "extra == 'all'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'testcontainers'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'testing'", specifier = ">=7.0" }, + { name = "pyyaml", specifier = ">=6" }, + { name = "rich", specifier = ">=12.6" }, { name = "ruamel-yaml", marker = "extra == 'all'", specifier = ">=0.18" }, { name = "ruamel-yaml", marker = "extra == 'ctl'", specifier = ">=0.18" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }, - { name = "typer", marker = "extra == 'all'", specifier = ">=0.15.0" }, - { name = "typer", marker = "extra == 'ctl'", specifier = ">=0.15.0" }, + { name = "typer", marker = "extra == 'all'", specifier = ">=0.16.0" }, + { name = "typer", marker = "extra == 'ctl'", specifier = ">=0.16.0" }, + { name = "typing-extensions", specifier = ">=4.4.0" }, { name = "ujson", specifier = ">=5" }, { name = "whenever", specifier = ">=0.9.3,<0.10.0" }, ] -provides-extras = ["all", "ctl"] +provides-extras = ["all", "ctl", "testcontainers", "testing", "tracing"] [package.metadata.requires-dev] dev = [ { name = "astroid", specifier = ">=3.1,<4.0" }, - { name = "codecov" }, + { name = "codecov", specifier = ">=2.1" }, { name = "infrahub-testcontainers", specifier = ">=1.7.3" }, { name = "invoke", specifier = ">=2.2.1" }, - { name = "ipython" }, + { name = "ipython", specifier = ">=8.18" }, { name = "jsonschema", specifier = ">=4.25.1" }, + { name = "mdxify", specifier = ">=0.2.23" }, { name = "mypy", specifier = "==2.3.1" }, + { name = "opentelemetry-api", specifier = ">=1.33" }, { name = "prek", specifier = ">=0.3.0" }, { name = "pytest", specifier = ">=9.0,<9.1" }, { name = "pytest-asyncio", specifier = ">=1.3,<1.4" }, @@ -892,23 +907,25 @@ dev = [ { name = "ruff", specifier = "==0.15.12" }, { name = "rumdl", specifier = "==0.2.28" }, { name = "towncrier", specifier = ">=24.8.0" }, - { name = "ty", specifier = "==0.0.14" }, + { name = "ty", specifier = "==0.0.74" }, { name = "types-python-slugify", specifier = ">=8.0.0.3" }, - { name = "types-pyyaml" }, - { name = "types-ujson" }, - { name = "yamllint" }, + { name = "types-pyyaml", specifier = ">=6.0.12" }, + { name = "types-ujson", specifier = ">=5.10" }, + { name = "yamllint", specifier = ">=1.35" }, ] +docs = [{ name = "mdxify", specifier = ">=0.2.23" }] lint = [ { name = "astroid", specifier = ">=3.1,<4.0" }, { name = "mypy", specifier = "==2.3.1" }, { name = "ruff", specifier = "==0.15.12" }, { name = "rumdl", specifier = "==0.2.28" }, - { name = "ty", specifier = "==0.0.14" }, - { name = "yamllint" }, + { name = "ty", specifier = "==0.0.74" }, + { name = "yamllint", specifier = ">=1.35" }, ] tests = [ { name = "infrahub-testcontainers", specifier = ">=1.7.3" }, { name = "jsonschema", specifier = ">=4.25.1" }, + { name = "opentelemetry-api", specifier = ">=1.33" }, { name = "pytest", specifier = ">=9.0,<9.1" }, { name = "pytest-asyncio", specifier = ">=1.3,<1.4" }, { name = "pytest-clarity", specifier = ">=1.0.1" }, @@ -918,8 +935,8 @@ tests = [ ] types = [ { name = "types-python-slugify", specifier = ">=8.0.0.3" }, - { name = "types-pyyaml" }, - { name = "types-ujson" }, + { name = "types-pyyaml", specifier = ">=6.0.12" }, + { name = "types-ujson", specifier = ">=5.10" }, ] [[package]] @@ -989,8 +1006,7 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.13' and python_full_version < '3.15'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", ] dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1498,231 +1514,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/a2/20e3cc569b3a41cc36181212c99f3e3c0aa9201174b2bf99313328824a2b/netutils-1.15.1-py3-none-any.whl", hash = "sha256:c42886d456f9b21bee395628b100dc2cd4b68fcc223f33c669672c3468d6b4dc", size = 532245, upload-time = "2025-10-21T00:41:06.141Z" }, ] -[[package]] -name = "numpy" -version = "2.2.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, -] - -[[package]] -name = "numpy" -version = "2.4.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.11.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, - { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, - { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, - { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, - { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, - { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, - { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, - { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, - { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, - { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, - { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, - { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, - { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, - { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, - { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, - { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, - { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, - { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, - { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, - { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, - { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, - { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, - { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, - { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, - { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, - { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, - { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, - { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, - { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, - { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, - { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, - { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, - { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, - { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, - { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, - { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, -] - -[[package]] -name = "numpy" -version = "2.5.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version >= '3.13' and python_full_version < '3.15'", - "python_full_version == '3.12.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, - { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, - { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, - { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, - { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, - { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, - { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, - { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" }, - { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" }, - { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" }, - { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" }, - { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" }, - { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" }, - { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" }, - { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" }, - { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" }, - { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" }, - { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, - { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, - { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, - { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, - { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, - { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, - { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, - { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, - { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, - { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, - { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, - { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, - { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, - { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, - { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, - { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, - { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, - { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, - { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, - { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, - { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, - { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, - { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, - { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, - { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, - { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, - { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, -] - [[package]] name = "opentelemetry-api" version = "1.38.0" @@ -2122,8 +1913,7 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.13' and python_full_version < '3.15'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", ] sdist = { url = "https://files.pythonhosted.org/packages/ec/34/17c34cb38e5d940e38f0f0d9fdfa0e8a506676409ea9b85aff7e3079f831/pyarrow-26.0.0.tar.gz", hash = "sha256:0cccd36e00ea3afeb52ded61f2721ce71f604853d70c45365c58324eb773d6ae", size = 1239433, upload-time = "2026-10-09T08:26:25.315Z" } wheels = [ @@ -2915,8 +2705,7 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.13' and python_full_version < '3.15'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", ] sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" } wheels = [ @@ -3316,26 +3105,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" }, - { url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" }, - { url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" }, - { url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" }, - { url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" }, - { url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" }, - { url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" }, - { url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" }, - { url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" }, - { url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" }, +version = "0.0.74" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/0f/c767853e88567a2ec7e996dd95e3105b1bc62c95d103689311ef0f4a603c/ty-0.0.74.tar.gz", hash = "sha256:da14344fc8625fc9ff359bafb856ad575636ea86d9bb6a629b146bff27b380e6", size = 6786318, upload-time = "2026-08-22T15:05:54.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/95/6ded58bc97885c6d88fa1f9cd815031489200738f961cbf0466663213f80/ty-0.0.74-py3-none-linux_armv6l.whl", hash = "sha256:8969ef4e508debf00cf58f9ea85a539f799b1732c59cdfcecd037630b9755b30", size = 12790043, upload-time = "2026-08-22T15:05:05.015Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8a/5e323603b6ab8731144421877ee8a0f8ac5a5511e67857127caa09f6730e/ty-0.0.74-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51fb6cf5b98e1e1140825b2430943f78d744876a735231656eafbb4c3f7eca3c", size = 12371748, upload-time = "2026-08-22T15:05:08.609Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/ee72e08cb705281e8d8c42917dd577aa598a8a098008495fda5176ee3f6e/ty-0.0.74-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ebe60b1f0a948c793d6c77fc9e9ddda599e4f023c04ab16e8e03bcb428c3fa0", size = 12282403, upload-time = "2026-08-22T15:05:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/fd935b694ff68bc278af50f7ad04770b36ce6306399baef7e1847b553a9d/ty-0.0.74-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa97f407a695c890a53615966a663c7d2167e2cabe88db7ca1a24d62635cdfc8", size = 12345164, upload-time = "2026-08-22T15:05:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/54/5c/5b5825268e029ebb164c909780103dbbae367f069801410068bf1cef29b3/ty-0.0.74-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:673ddb733d4a0db31385ba1ed9ff1f6bd9dc5565413ce57b1ca5ac4c7803da5d", size = 12556646, upload-time = "2026-08-22T15:05:16.994Z" }, + { url = "https://files.pythonhosted.org/packages/56/e7/515914e571d62ce0101744fed3f881936eeb1b30dc37beb72b4f7ca1e289/ty-0.0.74-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1028e7c6b4f6145e9704552f43a5fffdcd51b42263ffdcd9c9677762bc395a4a", size = 13311653, upload-time = "2026-08-22T15:05:20.254Z" }, + { url = "https://files.pythonhosted.org/packages/b0/07/d1452babb6f9266c2122cabc095180b70ed306fb770b2996753814d2237d/ty-0.0.74-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79841a8890493021fb308772474983316eb91f7b56cb227a6a05a06b262a36f0", size = 13768284, upload-time = "2026-08-22T15:05:23.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/60/8d4a2fc7842a47210a1cb0a16a187d9de39ad5d509a00fb74c1c073afcde/ty-0.0.74-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94859d321f3c6a6c8f7bfc3f40e8319cda7e6e012e613440f3dfd145d5010e2e", size = 13422306, upload-time = "2026-08-22T15:05:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/76/ebbc269a8c4efcc4d44624993bd188145f20d60ebda9680b15aaec42cc50/ty-0.0.74-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:970a8b2c09ff3be04c8a1c6767332d861be4fce85efe7bb205e4ade7c8655274", size = 12970637, upload-time = "2026-08-22T15:05:29.15Z" }, + { url = "https://files.pythonhosted.org/packages/9e/dd/b99f7236acbf856780ca1779a48143d2d9f2c24d7f531a0ce15a022b8a87/ty-0.0.74-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:795f763b3ded85574c2c2846a6fb8acf2aa76e9e83d761143e92b1f0c7ffa2cd", size = 13344891, upload-time = "2026-08-22T15:05:32.033Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/9ff7449a4c7e6428f2c6f298e74cf24b70668f29d45c249507a723ff3782/ty-0.0.74-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dc086db5367d912c31c0cc872deb7387290e779a4b9b54fcb944673a7cd52c7b", size = 12395272, upload-time = "2026-08-22T15:05:34.702Z" }, + { url = "https://files.pythonhosted.org/packages/b1/dd/b23a5b6b35d37df89dc8dc5daa09efd9245a668b50c4c81c25de21567dc1/ty-0.0.74-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0314d7b391cf684e47c2fa093d2ce4c597cfc9b01d9a315fe204aed6359b271b", size = 12573079, upload-time = "2026-08-22T15:05:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/23/c5/ccba16239d6129533c8b3603458d0f4dd2ba69478e47059073968e74261d/ty-0.0.74-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4a45dd2e991e8bdae82ba78c8cd051b253f60bc71a6536598fa3ef580b4fc9b", size = 12832506, upload-time = "2026-08-22T15:05:40.505Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1c/2390912634dff4f341f97b397f2aee341ff062be0a66cda37d59375454f2/ty-0.0.74-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:210e2eac6b018fb934e2b8dac3956a0ba076a3fb1fa6f135058c825e5b759b81", size = 13154752, upload-time = "2026-08-22T15:05:43.355Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/a8c12188227e6f74f91853a7374e01ed81d6ad21c16c8b70e92dbebfe46a/ty-0.0.74-py3-none-win32.whl", hash = "sha256:db0bb6a8f098ef9bd1be861f73b4f7c0320d40d4c05c7ae0a8677d4e7aa4f6e5", size = 12130002, upload-time = "2026-08-22T15:05:46.058Z" }, + { url = "https://files.pythonhosted.org/packages/21/5c/064f28ccb9c234cfce5a2f7aa69a256663d5ae5bb0290b3a9706cc4d1e4c/ty-0.0.74-py3-none-win_amd64.whl", hash = "sha256:bebff181515255b3c78bd2e7693ae66fab6064ad4feea2065c68bc01022aa678", size = 12771435, upload-time = "2026-08-22T15:05:48.811Z" }, + { url = "https://files.pythonhosted.org/packages/fe/06/d6becdaca0315346c26b6df97cb0eafa81de4f870945d6989e88704374ed/ty-0.0.74-py3-none-win_arm64.whl", hash = "sha256:1a3469eaaf8c85b1c0a15bede25d36daea4b09fce1d913e965b24e24b3f1d6c6", size = 12558299, upload-time = "2026-08-22T15:05:51.543Z" }, ] [[package]]