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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Unreleased

## Features

### Explorer API
* Added Explorer resource support with query, CSV export, saved view CRUD, saved view result query, and saved view CSV export endpoints.
* Added Explorer models, client registration, comprehensive unit tests, and end-to-end example usage.
* Refactored Explorer resource helpers for organization and saved-view id validation and for shared create/update attribute serialization (no API behavior change).
* Explorer: added structured logging (debug/info for operations and fallbacks) and `ValidationError` when create/read/update saved-view responses are not a valid json:api single-resource envelope.

# v0.1.3

## Enhancements
Expand Down
452 changes: 452 additions & 0 deletions examples/explorer.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/pytfe/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .resources.agents import Agents, AgentTokens
from .resources.apply import Applies
from .resources.configuration_version import ConfigurationVersions
from .resources.explorer import Explorer
from .resources.notification_configuration import NotificationConfigurations
from .resources.oauth_client import OAuthClients
from .resources.oauth_token import OAuthTokens
Expand Down Expand Up @@ -72,6 +73,7 @@ def __init__(self, config: TFEConfig | None = None):
self.plans = Plans(self._transport)
self.organizations = Organizations(self._transport)
self.organization_memberships = OrganizationMemberships(self._transport)
self.explorer = Explorer(self._transport) # org Explorer queries and saved views

self.projects = Projects(self._transport)
self.variables = Variables(self._transport)
Expand Down
7 changes: 7 additions & 0 deletions src/pytfe/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,13 @@ def __init__(self, message: str = "invalid value for query run ID"):
super().__init__(message)


class InvalidExplorerSavedViewIDError(InvalidValues):
"""Raised when a saved view id is missing or blank (Explorer view-scoped routes)."""

def __init__(self, message: str = "invalid value for explorer saved view ID"):
super().__init__(message)


class TerraformVersionValidForPlanOnlyError(ValidationError):
"""Raised when terraform_version is set without plan_only being true."""

Expand Down
21 changes: 21 additions & 0 deletions src/pytfe/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@
DataRetentionPolicyDontDeleteSetOptions,
DataRetentionPolicySetOptions,
)
from .explorer import (
ExplorerQueryOptions,
ExplorerRow,
ExplorerSavedQuery,
ExplorerSavedQueryFilter,
ExplorerSavedView,
ExplorerSavedViewCreateOptions,
ExplorerSavedViewUpdateOptions,
ExplorerUrlFilter,
ExplorerViewType,
)

# ── OAuth ─────────────────────────────────────────────────────────────────────
from .oauth_client import (
Expand Down Expand Up @@ -484,6 +495,16 @@
"QueryRunStatus",
"QueryRunStatusTimestamps",
"QueryRunVariable",
# Explorer
"ExplorerQueryOptions",
"ExplorerRow",
"ExplorerSavedQuery",
"ExplorerSavedQueryFilter",
"ExplorerSavedView",
"ExplorerSavedViewCreateOptions",
"ExplorerSavedViewUpdateOptions",
"ExplorerUrlFilter",
"ExplorerViewType",
# Core (from old types.py, now split)
"Entitlements",
"ExecutionMode",
Expand Down
123 changes: 123 additions & 0 deletions src/pytfe/models/explorer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Copyright IBM Corp. 2025, 2026
# SPDX-License-Identifier: MPL-2.0

"""Pydantic models for the Explorer API (query options, rows, saved views).

Aliases mirror JSON:API and Explorer query-string names (type, page[number], etc.).
"""

from __future__ import annotations

from datetime import datetime
from enum import Enum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field


class ExplorerViewType(str, Enum):
"""Explorer `type` / `query-type` discriminator (see product docs for supported views)."""

WORKSPACES = "workspaces"
TF_VERSIONS = "tf_versions"
PROVIDERS = "providers"
MODULES = "modules"
RESOURCES = "resources" # Present when the deployment exposes a resources view.


class ExplorerUrlFilter(BaseModel):
"""One slot in ExplorerQueryOptions.filters → filter[i][field][op][idx] query keys."""

index: int = Field(..., ge=0, description="Filter index in the query string")
field: str = Field(
..., min_length=1, description="Explorer field name in snake_case"
)
operator: str = Field(..., min_length=1, description="Explorer filter operator")
value: str = Field(..., description="Filter value")
value_index: int = Field(
0,
ge=0,
description="Reserved index for filter value; currently expected as zero",
)


class ExplorerQueryOptions(BaseModel):
"""GET /organizations/{org}/explorer (and export/csv) query string as structured fields."""

model_config = ConfigDict(populate_by_name=True)

view_type: ExplorerViewType = Field(..., alias="type")
sort: str | None = Field(
None,
description="Sort field (snake_case); prefix with '-' for descending order",
)
fields: str | None = Field(
None,
description="Comma-separated list of fields to include in each row",
)
page_number: int | None = Field(None, alias="page[number]", ge=1)
page_size: int | None = Field(None, alias="page[size]", ge=1, le=100)
filters: list[ExplorerUrlFilter] | None = Field(
None,
description="Expanded filter objects mapped to filter[index][field][operator][value_index]",
)


class ExplorerRow(BaseModel):
"""One Explorer result row: json:api id/type plus flat attributes for the view."""

model_config = ConfigDict(populate_by_name=True)

id: str
row_type: str = Field(..., alias="type")
attributes: dict[str, Any] = Field(default_factory=dict)


class ExplorerSavedQueryFilter(BaseModel):
"""One saved-view filter row (list-valued `value` matches create/update JSON)."""

field: str = Field(..., min_length=1)
operator: str = Field(..., min_length=1)
value: list[str] = Field(default_factory=list)


class ExplorerSavedQuery(BaseModel):
"""Nested query on a saved view: view type, filters, optional fields and sort lists."""

model_config = ConfigDict(populate_by_name=True)

query_type: ExplorerViewType = Field(..., alias="type")
filter: list[ExplorerSavedQueryFilter] | None = None
fields: list[str] | None = None
sort: list[str] | None = None


class ExplorerSavedView(BaseModel):
"""Saved view resource: metadata plus embedded query (response and some request paths)."""

model_config = ConfigDict(populate_by_name=True)

id: str
name: str
created_at: datetime | None = Field(None, alias="created-at")
query: ExplorerSavedQuery = Field(...)
query_type: ExplorerViewType = Field(..., alias="query-type")


class ExplorerSavedViewCreateOptions(BaseModel):
"""POST .../explorer/views attributes: display name, top-level query-type, nested query."""

model_config = ConfigDict(populate_by_name=True)

name: str = Field(..., min_length=1)
query_type: ExplorerViewType = Field(..., alias="query-type")
query: ExplorerSavedQuery


class ExplorerSavedViewUpdateOptions(BaseModel):
"""PATCH .../explorer/views/{id} attributes: name and full replacement query."""

model_config = ConfigDict(populate_by_name=True)

name: str = Field(..., min_length=1)
query: ExplorerSavedQuery
Loading
Loading