From c6284f7c6a37b6431e22ed6c9157f8d4af676a89 Mon Sep 17 00:00:00 2001 From: jasodeep Date: Thu, 23 Apr 2026 14:20:36 +0530 Subject: [PATCH 1/5] cp --- CHANGELOG.md | 6 + examples/explorer.py | 206 ++++++++++++++++++++++++ src/pytfe/client.py | 2 + src/pytfe/errors.py | 7 + src/pytfe/models/__init__.py | 21 +++ src/pytfe/models/explorer.py | 119 ++++++++++++++ src/pytfe/resources/explorer.py | 148 +++++++++++++++++ tests/units/test_explorer.py | 273 ++++++++++++++++++++++++++++++++ 8 files changed, 782 insertions(+) create mode 100644 examples/explorer.py create mode 100644 src/pytfe/models/explorer.py create mode 100644 src/pytfe/resources/explorer.py create mode 100644 tests/units/test_explorer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db63b0d..2341229c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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. + # v0.1.3 ## Enhancements diff --git a/examples/explorer.py b/examples/explorer.py new file mode 100644 index 00000000..85000cd1 --- /dev/null +++ b/examples/explorer.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Sample driver for ``TFEClient.explorer``. + +Install the package in editable mode (``pip install -e .`` from the repo root) before +running: ``python examples/explorer.py``. + +Sections 1–3 always run (read-only). Sections 4–6 require ``TFE_EXPLORER_VIEW_ID``. +Section 7 mutates state (create/update/delete one saved view) and runs only when +``TFE_EXPLORER_DEMO_MUTATIONS=1``. + +Environment +----------- +``TFE_TOKEN`` (required) + API token with Explorer access for the target organization. + +``TFE_ADDRESS`` (optional) + Defaults to ``https://app.terraform.io``. + +``TFE_ORGANIZATION`` (optional) + Organization name; replace the placeholder when testing against a real org. + +``TFE_EXPLORER_VIEW_ID`` (optional) + Saved view id (``sq-...``) to exercise read, results iterator, and results CSV. + +``TFE_EXPLORER_DEMO_MUTATIONS`` + Set to ``1`` to run the create/update/delete demo (uses a unique view name per run). +""" + +from __future__ import annotations + +import os +import sys +import uuid + +from pytfe import TFEClient, TFEConfig +from pytfe.errors import TFEError +from pytfe.models import ( + ExplorerQueryOptions, + ExplorerSavedQuery, + ExplorerSavedQueryFilter, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerViewType, +) + + +def main() -> None: + """Execute the scripted scenarios; environment variables gate optional paths.""" + token = os.getenv("TFE_TOKEN") + if not token: + print("Error: TFE_TOKEN is not set.") + sys.exit(1) + + address = os.getenv("TFE_ADDRESS", "https://app.terraform.io") + org = os.getenv("TFE_ORGANIZATION", "your-org-name") + view_id = os.getenv("TFE_EXPLORER_VIEW_ID") + demo_mutations = os.getenv("TFE_EXPLORER_DEMO_MUTATIONS") == "1" + + client = TFEClient(TFEConfig(address=address, token=token)) + + print(f"Explorer example — organization: {org!r}") + print("=" * 60) + + # Workspaces view; optional ExplorerUrlFilter in query_opts.filters (see SDK models). + print("\n1. Query workspaces view (first 5 rows)") + print("-" * 60) + query_opts = ExplorerQueryOptions( + view_type=ExplorerViewType.WORKSPACES, + sort="-workspace_name", + # filters=[ + # ExplorerUrlFilter( + # index=0, + # field="workspace_name", + # operator="contains", + # value="prod", + # ), + # ], + ) + try: + for i, row in enumerate(client.explorer.query(org, query_opts)): + if i >= 5: + break + name = row.attributes.get("workspace-name") or row.attributes.get( + "workspace_name" + ) + print(f" {row.id} workspace-name={name!r}") + except TFEError as e: + print(f" TFE API error: {e}") + except Exception as e: + print(f" Error: {e}") + + print("\n2. CSV export (first 400 characters)") + print("-" * 60) + try: + csv_text = client.explorer.export_csv( + org, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + print(csv_text[:400] + ("..." if len(csv_text) > 400 else "")) + except TFEError as e: + print(f" TFE API error: {e}") + except Exception as e: + print(f" Error: {e}") + + print("\n3. List saved views") + print("-" * 60) + try: + for sv in client.explorer.list_saved_views(org): + print(f" {sv.id} {sv.name!r} query-type={sv.query_type!r}") + except TFEError as e: + print(f" TFE API error: {e}") + except Exception as e: + print(f" Error: {e}") + + if view_id: + print("\n4. Read saved view") + print("-" * 60) + try: + sv = client.explorer.read_saved_view(org, view_id) + print(f" {sv.id} {sv.name!r} query={sv.query!r}") + except TFEError as e: + print(f" TFE API error: {e}") + + print("\n5. Saved view results (first 3 rows)") + print("-" * 60) + try: + for i, row in enumerate(client.explorer.saved_view_results(org, view_id)): + if i >= 3: + break + print(f" {row.id} type={row.row_type!r}") + except TFEError as e: + print(f" TFE API error: {e}") + + print("\n6. Saved view results as CSV (first 300 chars)") + print("-" * 60) + try: + csv_sv = client.explorer.saved_view_results_csv(org, view_id) + print(csv_sv[:300] + ("..." if len(csv_sv) > 300 else "")) + except TFEError as e: + print(f" TFE API error: {e}") + print( + " Hint: ``not found`` often means ``TFE_EXPLORER_VIEW_ID`` was deleted or " + "belongs to another org. pytfe also falls back to ``export_csv`` and to CSV " + "built from ``saved_view_results``; if step 5 worked, reinstall editable pytfe." + ) + else: + print("\n4–6. Skipped (set TFE_EXPLORER_VIEW_ID to exercise read/results/csv)") + print("-" * 60) + + if demo_mutations: + suffix = uuid.uuid4().hex[:8] + base_name = f"python-tfe-explorer-example-{suffix}" + print(f"\n7. Demo mutations — create / update / delete ({base_name!r})") + print("-" * 60) + try: + create_opts = ExplorerSavedViewCreateOptions( + name=base_name, + query_type=ExplorerViewType.WORKSPACES, + query=ExplorerSavedQuery( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", + operator="contains", + value=["test"], + ) + ], + ), + ) + created = client.explorer.create_saved_view(org, create_opts) + print(f" Created: {created.id}") + + update_opts = ExplorerSavedViewUpdateOptions( + name=f"{base_name}-updated", + query=ExplorerSavedQuery( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", + operator="contains", + value=["demo"], + ) + ], + ), + ) + updated = client.explorer.update_saved_view(org, created.id, update_opts) + print(f" Updated: {updated.name!r}") + + deleted = client.explorer.delete_saved_view(org, created.id) + print(f" Deleted: {deleted.id}") + except TFEError as e: + print(f" TFE API error: {e}") + sys.exit(1) + else: + print( + "\n7. Skipped (set TFE_EXPLORER_DEMO_MUTATIONS=1 to run create/update/delete)" + ) + print("-" * 60) + + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 30b506b9..fd8c45ac 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -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 @@ -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) self.projects = Projects(self._transport) self.variables = Variables(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index e913f6d4..341b85fa 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -372,6 +372,13 @@ def __init__(self, message: str = "invalid value for query run ID"): super().__init__(message) +class InvalidExplorerSavedViewIDError(InvalidValues): + """Raised when an invalid Explorer saved view ID is provided.""" + + 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.""" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 0f1435d8..fb12856b 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -58,6 +58,17 @@ DataRetentionPolicyDontDeleteSetOptions, DataRetentionPolicySetOptions, ) +from .explorer import ( + ExplorerQueryOptions, + ExplorerRow, + ExplorerSavedQuery, + ExplorerSavedQueryFilter, + ExplorerSavedView, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, + ExplorerViewType, +) # ── OAuth ───────────────────────────────────────────────────────────────────── from .oauth_client import ( @@ -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", diff --git a/src/pytfe/models/explorer.py b/src/pytfe/models/explorer.py new file mode 100644 index 00000000..983d130c --- /dev/null +++ b/src/pytfe/models/explorer.py @@ -0,0 +1,119 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Explorer models for Terraform Enterprise.""" + +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): + """Available Explorer view types.""" + + WORKSPACES = "workspaces" + TF_VERSIONS = "tf_versions" + PROVIDERS = "providers" + MODULES = "modules" + + +class ExplorerUrlFilter(BaseModel): + """Represents one URL filter entry for query endpoints.""" + + 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): + """Options for executing an Explorer query.""" + + 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): + """Represents a single Explorer query result row.""" + + 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): + """Filter object stored in saved query payloads.""" + + field: str = Field(..., min_length=1) + operator: str = Field(..., min_length=1) + value: list[str] = Field(default_factory=list) + + +class ExplorerSavedQuery(BaseModel): + """Query definition used by Explorer saved views.""" + + 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 Explorer query metadata and query definition.""" + + 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): + """Request body options for creating a saved view.""" + + 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): + """Request body options for updating a saved view.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., min_length=1) + query: ExplorerSavedQuery diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py new file mode 100644 index 00000000..15084bb0 --- /dev/null +++ b/src/pytfe/resources/explorer.py @@ -0,0 +1,148 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Explorer API resource.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import InvalidExplorerSavedViewIDError, InvalidOrgError +from ..models.explorer import ( + ExplorerQueryOptions, + ExplorerRow, + ExplorerSavedView, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +def _query_params(options: ExplorerQueryOptions) -> dict[str, Any]: + params = options.model_dump(by_alias=True, exclude_none=True, exclude={"filters"}) + if options.filters: + for flt in options.filters: + params[ + f"filter[{flt.index}][{flt.field}][{flt.operator}][{flt.value_index}]" + ] = flt.value + return params + + +def _parse_row(item: dict[str, Any]) -> ExplorerRow: + return ExplorerRow.model_validate(item) + + +def _parse_saved_view(item: dict[str, Any]) -> ExplorerSavedView: + attrs = item.get("attributes", {}) + return ExplorerSavedView.model_validate( + { + "id": item.get("id"), + "name": attrs.get("name"), + "created-at": attrs.get("created-at"), + "query": attrs.get("query", {}), + "query-type": attrs.get("query-type"), + } + ) + + +class Explorer(_Service): + """Explorer API for Terraform Enterprise.""" + + def query( + self, organization: str, options: ExplorerQueryOptions + ) -> Iterator[ExplorerRow]: + if not valid_string_id(organization): + raise InvalidOrgError() + path = f"/api/v2/organizations/{organization}/explorer" + for item in self._list(path, params=_query_params(options)): + yield _parse_row(item) + + def export_csv(self, organization: str, options: ExplorerQueryOptions) -> str: + if not valid_string_id(organization): + raise InvalidOrgError() + path = f"/api/v2/organizations/{organization}/explorer/export/csv" + resp = self.t.request("GET", path, params=_query_params(options)) + return resp.text + + def list_saved_views(self, organization: str) -> Iterator[ExplorerSavedView]: + if not valid_string_id(organization): + raise InvalidOrgError() + path = f"/api/v2/organizations/{organization}/explorer/views" + for item in self._list(path): + yield _parse_saved_view(item) + + def create_saved_view( + self, organization: str, options: ExplorerSavedViewCreateOptions + ) -> ExplorerSavedView: + if not valid_string_id(organization): + raise InvalidOrgError() + body = { + "data": { + "type": "explorer-saved-queries", + "attributes": options.model_dump(by_alias=True, exclude_none=True), + } + } + path = f"/api/v2/organizations/{organization}/explorer/views" + resp = self.t.request("POST", path, json_body=body) + return _parse_saved_view(resp.json()["data"]) + + def read_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView: + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" + resp = self.t.request("GET", path) + return _parse_saved_view(resp.json()["data"]) + + def update_saved_view( + self, + organization: str, + view_id: str, + options: ExplorerSavedViewUpdateOptions, + ) -> ExplorerSavedView: + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() + body = { + "data": { + "type": "explorer-saved-queries", + "id": view_id, + "attributes": options.model_dump(by_alias=True, exclude_none=True), + } + } + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" + resp = self.t.request("PATCH", path, json_body=body) + return _parse_saved_view(resp.json()["data"]) + + def delete_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView: + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" + resp = self.t.request("DELETE", path) + return _parse_saved_view(resp.json()["data"]) + + def saved_view_results( + self, organization: str, view_id: str + ) -> Iterator[ExplorerRow]: + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}/results" + for item in self._list(path): + yield _parse_row(item) + + def saved_view_results_csv(self, organization: str, view_id: str) -> str: + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}/csv" + resp = self.t.request("GET", path) + return resp.text diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py new file mode 100644 index 00000000..fc566aa2 --- /dev/null +++ b/tests/units/test_explorer.py @@ -0,0 +1,273 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for Explorer API resource.""" + +from unittest.mock import Mock + +import pytest + +from pytfe.errors import InvalidExplorerSavedViewIDError, InvalidOrgError +from pytfe.models import ( + ExplorerQueryOptions, + ExplorerSavedQuery, + ExplorerSavedQueryFilter, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, + ExplorerViewType, +) +from pytfe.resources.explorer import Explorer + + +@pytest.fixture +def mock_transport(): + return Mock() + + +@pytest.fixture +def explorer_service(mock_transport): + return Explorer(mock_transport) + + +def _row_payload(row_id: str) -> dict: + return { + "id": row_id, + "type": "visibility-workspace", + "attributes": {"workspace-name": "demo-workspace"}, + } + + +def _saved_view_payload(view_id: str) -> dict: + return { + "id": view_id, + "type": "explorer-saved-queries", + "attributes": { + "name": "my-view", + "created-at": "2024-10-11T16:18:51.442Z", + "query-type": "workspaces", + "query": { + "type": "workspaces", + "filter": [ + { + "field": "workspace_name", + "operator": "contains", + "value": ["child"], + } + ], + }, + }, + } + + +class TestExplorerQuery: + def test_query_with_filter_and_pagination(self, explorer_service, mock_transport): + first = Mock() + first.json.return_value = {"data": [_row_payload("ws-1")]} + second = Mock() + second.json.return_value = {"data": []} + mock_transport.request.side_effect = [first, second] + + options = ExplorerQueryOptions( + view_type=ExplorerViewType.WORKSPACES, + sort="-workspace_name", + fields="workspace_name,organization_name", + page_size=1, + filters=[ + ExplorerUrlFilter( + index=0, + field="workspace_name", + operator="contains", + value="test", + ) + ], + ) + + rows = list(explorer_service.query("acme", options)) + assert len(rows) == 1 + assert rows[0].id == "ws-1" + assert rows[0].row_type == "visibility-workspace" + + first_call = mock_transport.request.call_args_list[0] + assert first_call[0][0] == "GET" + assert first_call[0][1] == "/api/v2/organizations/acme/explorer" + params = first_call[1]["params"] + assert params["type"] == "workspaces" + assert params["sort"] == "-workspace_name" + assert params["fields"] == "workspace_name,organization_name" + assert params["page[size]"] == 1 + assert params["filter[0][workspace_name][contains][0]"] == "test" + + def test_query_invalid_org(self, explorer_service): + with pytest.raises(InvalidOrgError): + list( + explorer_service.query( + "", + ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES), + ) + ) + + def test_export_csv(self, explorer_service, mock_transport): + response = Mock() + response.text = "workspace_name\nexample\n" + mock_transport.request.return_value = response + + csv_text = explorer_service.export_csv( + "acme", ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + + assert "workspace_name" in csv_text + mock_transport.request.assert_called_once_with( + "GET", + "/api/v2/organizations/acme/explorer/export/csv", + params={"type": "workspaces"}, + ) + + +class TestExplorerSavedViews: + def test_list_saved_views(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": [_saved_view_payload("sq-1")]} + mock_transport.request.return_value = response + + views = list(explorer_service.list_saved_views("acme")) + assert len(views) == 1 + assert views[0].id == "sq-1" + assert views[0].query_type == ExplorerViewType.WORKSPACES + assert views[0].query.query_type == ExplorerViewType.WORKSPACES + + def test_create_saved_view(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": _saved_view_payload("sq-new")} + mock_transport.request.return_value = response + + options = ExplorerSavedViewCreateOptions( + name="my-view", + query_type=ExplorerViewType.WORKSPACES, + query=ExplorerSavedQuery( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", operator="contains", value=["test"] + ) + ], + ), + ) + view = explorer_service.create_saved_view("acme", options) + + assert view.id == "sq-new" + call = mock_transport.request.call_args + assert call[0][0] == "POST" + assert call[0][1] == "/api/v2/organizations/acme/explorer/views" + body = call[1]["json_body"] + assert body["data"]["type"] == "explorer-saved-queries" + assert body["data"]["attributes"]["query-type"] == "workspaces" + + def test_read_saved_view(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": _saved_view_payload("sq-1")} + mock_transport.request.return_value = response + + view = explorer_service.read_saved_view("acme", "sq-1") + assert view.id == "sq-1" + + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/organizations/acme/explorer/views/sq-1" + ) + + def test_update_saved_view(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": _saved_view_payload("sq-1")} + mock_transport.request.return_value = response + + options = ExplorerSavedViewUpdateOptions( + name="my-view-updated", + query=ExplorerSavedQuery( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", operator="contains", value=["prod"] + ) + ], + ), + ) + view = explorer_service.update_saved_view("acme", "sq-1", options) + + assert view.id == "sq-1" + call = mock_transport.request.call_args + assert call[0][0] == "PATCH" + assert call[0][1] == "/api/v2/organizations/acme/explorer/views/sq-1" + assert call[1]["json_body"]["data"]["id"] == "sq-1" + assert call[1]["json_body"]["data"]["attributes"]["name"] == "my-view-updated" + + def test_delete_saved_view(self, explorer_service, mock_transport): + response = Mock() + response.json.return_value = {"data": _saved_view_payload("sq-1")} + mock_transport.request.return_value = response + + view = explorer_service.delete_saved_view("acme", "sq-1") + assert view.id == "sq-1" + + mock_transport.request.assert_called_once_with( + "DELETE", "/api/v2/organizations/acme/explorer/views/sq-1" + ) + + def test_saved_view_results(self, explorer_service, mock_transport): + first = Mock() + first.json.return_value = {"data": [_row_payload("ws-1")]} + second = Mock() + second.json.return_value = {"data": []} + mock_transport.request.side_effect = [first, second] + + rows = list(explorer_service.saved_view_results("acme", "sq-1")) + assert len(rows) == 1 + assert rows[0].id == "ws-1" + + mock_transport.request.assert_any_call( + "GET", + "/api/v2/organizations/acme/explorer/views/sq-1/results", + params={"page[number]": 1, "page[size]": 100}, + ) + + def test_saved_view_results_csv(self, explorer_service, mock_transport): + response = Mock() + response.text = "workspace_name\nexample\n" + mock_transport.request.return_value = response + + csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") + assert "workspace_name" in csv_text + mock_transport.request.assert_called_once_with( + "GET", "/api/v2/organizations/acme/explorer/views/sq-1/csv" + ) + + @pytest.mark.parametrize("org", ["", None]) + def test_saved_view_methods_invalid_org(self, explorer_service, org): + with pytest.raises(InvalidOrgError): + list(explorer_service.list_saved_views(org)) + + with pytest.raises(InvalidOrgError): + explorer_service.read_saved_view(org, "sq-1") + + @pytest.mark.parametrize("view_id", ["", None]) + def test_saved_view_methods_invalid_id(self, explorer_service, view_id): + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.read_saved_view("acme", view_id) + + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.update_saved_view( + "acme", + view_id, + ExplorerSavedViewUpdateOptions( + name="updated", + query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ), + ) + + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.delete_saved_view("acme", view_id) + + with pytest.raises(InvalidExplorerSavedViewIDError): + list(explorer_service.saved_view_results("acme", view_id)) + + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.saved_view_results_csv("acme", view_id) From b542dd7c848c052230c88efedab597d52acd8bb1 Mon Sep 17 00:00:00 2001 From: jasodeep Date: Thu, 23 Apr 2026 14:32:34 +0530 Subject: [PATCH 2/5] cp2 --- src/pytfe/models/explorer.py | 1 + src/pytfe/resources/explorer.py | 133 ++++++++++++++++++++++++++++++-- tests/units/test_explorer.py | 49 ++++++++++++ 3 files changed, 177 insertions(+), 6 deletions(-) diff --git a/src/pytfe/models/explorer.py b/src/pytfe/models/explorer.py index 983d130c..abdfa2e1 100644 --- a/src/pytfe/models/explorer.py +++ b/src/pytfe/models/explorer.py @@ -19,6 +19,7 @@ class ExplorerViewType(str, Enum): TF_VERSIONS = "tf_versions" PROVIDERS = "providers" MODULES = "modules" + RESOURCES = "resources" class ExplorerUrlFilter(BaseModel): diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py index 15084bb0..787265d6 100644 --- a/src/pytfe/resources/explorer.py +++ b/src/pytfe/resources/explorer.py @@ -21,7 +21,12 @@ def _query_params(options: ExplorerQueryOptions) -> dict[str, Any]: - params = options.model_dump(by_alias=True, exclude_none=True, exclude={"filters"}) + params = options.model_dump( + by_alias=True, + exclude_none=True, + exclude={"filters"}, + mode="json", + ) if options.filters: for flt in options.filters: params[ @@ -34,15 +39,112 @@ def _parse_row(item: dict[str, Any]) -> ExplorerRow: return ExplorerRow.model_validate(item) +def _saved_query_to_api_shape(raw_query: dict[str, Any]) -> dict[str, Any]: + """Transform normalized saved-query payload to API-accepted create/update shape.""" + query = dict(raw_query) + raw_filter = query.get("filter") + if isinstance(raw_filter, list): + mapped_filters: list[dict[str, Any]] = [] + for entry in raw_filter: + if not isinstance(entry, dict): + continue + # Already API-compatible map style. + if "field" not in entry or "operator" not in entry: + mapped_filters.append(entry) + continue + field = str(entry.get("field", "")).replace("-", "_") + operator = str(entry.get("operator", "")) + values = entry.get("value", []) + if not isinstance(values, list): + values = [values] + mapped_filters.append({field: {operator: [str(v) for v in values]}}) + query["filter"] = mapped_filters + return query + + +def _normalize_saved_query( + raw_query: dict[str, Any], raw_query_type: str | None +) -> dict[str, Any]: + """Normalize API variants of saved-query payloads to model shape.""" + query = dict(raw_query) + + if "type" not in query and raw_query_type: + query["type"] = raw_query_type + + raw_filter = query.get("filter") + if isinstance(raw_filter, list): + normalized_filters: list[dict[str, Any]] = [] + for entry in raw_filter: + # Variant A (documented): {"field": "...", "operator": "...", "value": [...]} + if isinstance(entry, dict) and "field" in entry and "operator" in entry: + value = entry.get("value") + if value is None: + value = [] + if not isinstance(value, list): + value = [str(value)] + normalized_filters.append( + { + "field": str(entry["field"]).replace("-", "_"), + "operator": str(entry["operator"]), + "value": [str(v) for v in value], + } + ) + continue + + # Variant B (observed): {"workspace-name": {"contains": ["foo"]}} + if isinstance(entry, dict): + for field_name, operators in entry.items(): + if not isinstance(operators, dict): + continue + for operator, values in operators.items(): + vals = values if isinstance(values, list) else [values] + normalized_filters.append( + { + "field": str(field_name).replace("-", "_"), + "operator": str(operator), + "value": [str(v) for v in vals], + } + ) + query["filter"] = normalized_filters + + raw_fields = query.get("fields") + # Some responses return fields as {"workspaces": [...]}. + if isinstance(raw_fields, dict): + list_values: list[str] = [] + for value in raw_fields.values(): + if isinstance(value, list): + list_values.extend(str(v) for v in value) + query["fields"] = list_values + + return query + + def _parse_saved_view(item: dict[str, Any]) -> ExplorerSavedView: attrs = item.get("attributes", {}) + query_type = attrs.get("query-type") + query = attrs.get("query", {}) + if not isinstance(query, dict): + query = {} + return ExplorerSavedView.model_validate( { "id": item.get("id"), "name": attrs.get("name"), "created-at": attrs.get("created-at"), - "query": attrs.get("query", {}), - "query-type": attrs.get("query-type"), + "query": _normalize_saved_query(query, query_type), + "query-type": query_type, + } + ) + + +def _deleted_saved_view_fallback(view_id: str) -> ExplorerSavedView: + """Build a minimal saved view when delete responses have no body.""" + return ExplorerSavedView.model_validate( + { + "id": view_id, + "name": "", + "query-type": "workspaces", + "query": {"type": "workspaces"}, } ) @@ -78,10 +180,14 @@ def create_saved_view( ) -> ExplorerSavedView: if not valid_string_id(organization): raise InvalidOrgError() + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + raw_query = attrs.get("query") + if isinstance(raw_query, dict): + attrs["query"] = _saved_query_to_api_shape(raw_query) body = { "data": { "type": "explorer-saved-queries", - "attributes": options.model_dump(by_alias=True, exclude_none=True), + "attributes": attrs, } } path = f"/api/v2/organizations/{organization}/explorer/views" @@ -107,11 +213,15 @@ def update_saved_view( raise InvalidOrgError() if not valid_string_id(view_id): raise InvalidExplorerSavedViewIDError() + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + raw_query = attrs.get("query") + if isinstance(raw_query, dict): + attrs["query"] = _saved_query_to_api_shape(raw_query) body = { "data": { "type": "explorer-saved-queries", "id": view_id, - "attributes": options.model_dump(by_alias=True, exclude_none=True), + "attributes": attrs, } } path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" @@ -125,7 +235,18 @@ def delete_saved_view(self, organization: str, view_id: str) -> ExplorerSavedVie raise InvalidExplorerSavedViewIDError() path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" resp = self.t.request("DELETE", path) - return _parse_saved_view(resp.json()["data"]) + raw_text = (resp.text or "").strip() + if not raw_text: + return _deleted_saved_view_fallback(view_id) + + try: + payload = resp.json() + except ValueError: + return _deleted_saved_view_fallback(view_id) + + if isinstance(payload, dict) and isinstance(payload.get("data"), dict): + return _parse_saved_view(payload["data"]) + return _deleted_saved_view_fallback(view_id) def saved_view_results( self, organization: str, view_id: str diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py index fc566aa2..75b98c2e 100644 --- a/tests/units/test_explorer.py +++ b/tests/units/test_explorer.py @@ -60,6 +60,22 @@ def _saved_view_payload(view_id: str) -> dict: } +def _saved_view_payload_live_variant(view_id: str) -> dict: + return { + "id": view_id, + "type": "explorer-saved-queries", + "attributes": { + "name": "my-view", + "created-at": "2024-10-11T16:18:51.442Z", + "query-type": "workspaces", + "query": { + "filter": [{"workspace-name": {"contains": ["r2l7cj4v"]}}], + "fields": {"workspaces": []}, + }, + }, + } + + class TestExplorerQuery: def test_query_with_filter_and_pagination(self, explorer_service, mock_transport): first = Mock() @@ -162,6 +178,9 @@ def test_create_saved_view(self, explorer_service, mock_transport): body = call[1]["json_body"] assert body["data"]["type"] == "explorer-saved-queries" assert body["data"]["attributes"]["query-type"] == "workspaces" + assert body["data"]["attributes"]["query"]["filter"] == [ + {"workspace_name": {"contains": ["test"]}} + ] def test_read_saved_view(self, explorer_service, mock_transport): response = Mock() @@ -175,6 +194,23 @@ def test_read_saved_view(self, explorer_service, mock_transport): "GET", "/api/v2/organizations/acme/explorer/views/sq-1" ) + def test_read_saved_view_with_live_query_shape( + self, explorer_service, mock_transport + ): + response = Mock() + response.json.return_value = {"data": _saved_view_payload_live_variant("sq-2")} + mock_transport.request.return_value = response + + view = explorer_service.read_saved_view("acme", "sq-2") + + assert view.id == "sq-2" + assert view.query.query_type == ExplorerViewType.WORKSPACES + assert view.query.filter is not None + assert view.query.filter[0].field == "workspace_name" + assert view.query.filter[0].operator == "contains" + assert view.query.filter[0].value == ["r2l7cj4v"] + assert view.query.fields == [] + def test_update_saved_view(self, explorer_service, mock_transport): response = Mock() response.json.return_value = {"data": _saved_view_payload("sq-1")} @@ -199,10 +235,14 @@ def test_update_saved_view(self, explorer_service, mock_transport): assert call[0][1] == "/api/v2/organizations/acme/explorer/views/sq-1" assert call[1]["json_body"]["data"]["id"] == "sq-1" assert call[1]["json_body"]["data"]["attributes"]["name"] == "my-view-updated" + assert call[1]["json_body"]["data"]["attributes"]["query"]["filter"] == [ + {"workspace_name": {"contains": ["prod"]}} + ] def test_delete_saved_view(self, explorer_service, mock_transport): response = Mock() response.json.return_value = {"data": _saved_view_payload("sq-1")} + response.text = '{"data":{"id":"sq-1"}}' mock_transport.request.return_value = response view = explorer_service.delete_saved_view("acme", "sq-1") @@ -212,6 +252,15 @@ def test_delete_saved_view(self, explorer_service, mock_transport): "DELETE", "/api/v2/organizations/acme/explorer/views/sq-1" ) + def test_delete_saved_view_empty_response(self, explorer_service, mock_transport): + response = Mock() + response.text = "" + response.json.side_effect = ValueError("No JSON body") + mock_transport.request.return_value = response + + view = explorer_service.delete_saved_view("acme", "sq-1") + assert view.id == "sq-1" + def test_saved_view_results(self, explorer_service, mock_transport): first = Mock() first.json.return_value = {"data": [_row_payload("ws-1")]} From 012b68f09da6a5dc1931d709ea000f1998bd8a67 Mon Sep 17 00:00:00 2001 From: jasodeep Date: Thu, 23 Apr 2026 18:12:13 +0530 Subject: [PATCH 3/5] cp3 --- src/pytfe/resources/explorer.py | 69 +++++++++++++++++++++++++++++++-- tests/units/test_explorer.py | 37 +++++++++++++++++- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py index 787265d6..0d4a5d2f 100644 --- a/src/pytfe/resources/explorer.py +++ b/src/pytfe/resources/explorer.py @@ -5,16 +5,24 @@ from __future__ import annotations +import csv +import io from collections.abc import Iterator from typing import Any -from ..errors import InvalidExplorerSavedViewIDError, InvalidOrgError +from ..errors import ( + InvalidExplorerSavedViewIDError, + InvalidOrgError, + NotFound, + ServerError, +) from ..models.explorer import ( ExplorerQueryOptions, ExplorerRow, ExplorerSavedView, ExplorerSavedViewCreateOptions, ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, ) from ..utils import valid_string_id from ._base import _Service @@ -149,6 +157,50 @@ def _deleted_saved_view_fallback(view_id: str) -> ExplorerSavedView: ) +def _query_options_from_saved_view( + saved_view: ExplorerSavedView, +) -> ExplorerQueryOptions: + """Convert a saved view query into ExplorerQueryOptions.""" + query = saved_view.query + filters: list[ExplorerUrlFilter] = [] + if query.filter: + for idx, flt in enumerate(query.filter): + for value_index, value in enumerate(flt.value or []): + filters.append( + ExplorerUrlFilter( + index=idx, + field=flt.field, + operator=flt.operator, + value=str(value), + value_index=value_index, + ) + ) + return ExplorerQueryOptions.model_validate( + { + "type": saved_view.query_type, + "sort": ",".join(query.sort) if query.sort else None, + "fields": ",".join(query.fields) if query.fields else None, + "filters": filters or None, + } + ) + + +def _rows_to_csv(rows: list[ExplorerRow]) -> str: + """Build CSV from Explorer rows attributes.""" + if not rows: + return "" + keys: set[str] = set() + for row in rows: + keys.update(row.attributes.keys()) + fieldnames = sorted(keys) + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for row in rows: + writer.writerow({k: row.attributes.get(k, "") for k in fieldnames}) + return buf.getvalue() + + class Explorer(_Service): """Explorer API for Terraform Enterprise.""" @@ -265,5 +317,16 @@ def saved_view_results_csv(self, organization: str, view_id: str) -> str: if not valid_string_id(view_id): raise InvalidExplorerSavedViewIDError() path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}/csv" - resp = self.t.request("GET", path) - return resp.text + try: + resp = self.t.request("GET", path) + return resp.text + except (NotFound, ServerError): + pass + + try: + saved_view = self.read_saved_view(organization, view_id) + options = _query_options_from_saved_view(saved_view) + return self.export_csv(organization, options) + except (NotFound, ServerError): + rows = list(self.saved_view_results(organization, view_id)) + return _rows_to_csv(rows) diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py index 75b98c2e..a5d86cb3 100644 --- a/tests/units/test_explorer.py +++ b/tests/units/test_explorer.py @@ -7,7 +7,7 @@ import pytest -from pytfe.errors import InvalidExplorerSavedViewIDError, InvalidOrgError +from pytfe.errors import InvalidExplorerSavedViewIDError, InvalidOrgError, NotFound from pytfe.models import ( ExplorerQueryOptions, ExplorerSavedQuery, @@ -289,6 +289,41 @@ def test_saved_view_results_csv(self, explorer_service, mock_transport): "GET", "/api/v2/organizations/acme/explorer/views/sq-1/csv" ) + def test_saved_view_results_csv_fallback_to_export( + self, explorer_service, mock_transport + ): + first = NotFound("not found", status=404) + read_resp = Mock() + read_resp.json.return_value = {"data": _saved_view_payload("sq-1")} + export_resp = Mock() + export_resp.text = "workspace_name\nfrom-export\n" + mock_transport.request.side_effect = [first, read_resp, export_resp] + + csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") + assert "from-export" in csv_text + + def test_saved_view_results_csv_fallback_to_rows( + self, explorer_service, mock_transport + ): + not_found = NotFound("not found", status=404) + read_resp = Mock() + read_resp.json.return_value = {"data": _saved_view_payload("sq-1")} + first_results = Mock() + first_results.json.return_value = {"data": [_row_payload("ws-1")]} + second_results = Mock() + second_results.json.return_value = {"data": []} + mock_transport.request.side_effect = [ + not_found, # /csv + read_resp, # read saved view + not_found, # export_csv fallback fails + first_results, # saved_view_results page 1 + second_results, # saved_view_results page 2 + ] + + csv_text = explorer_service.saved_view_results_csv("acme", "sq-1") + assert "workspace-name" in csv_text + assert "demo-workspace" in csv_text + @pytest.mark.parametrize("org", ["", None]) def test_saved_view_methods_invalid_org(self, explorer_service, org): with pytest.raises(InvalidOrgError): From 0f17d72cc5431eccb8133f6b811d3a075aaefe3d Mon Sep 17 00:00:00 2001 From: jasodeep Date: Thu, 23 Apr 2026 18:29:32 +0530 Subject: [PATCH 4/5] c4 --- examples/explorer.py | 191 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 177 insertions(+), 14 deletions(-) diff --git a/examples/explorer.py b/examples/explorer.py index 85000cd1..8306834c 100644 --- a/examples/explorer.py +++ b/examples/explorer.py @@ -2,14 +2,121 @@ # Copyright IBM Corp. 2025, 2026 # SPDX-License-Identifier: MPL-2.0 -"""Sample driver for ``TFEClient.explorer``. +"""Detailed sample driver for ``TFEClient.explorer``. Install the package in editable mode (``pip install -e .`` from the repo root) before running: ``python examples/explorer.py``. -Sections 1–3 always run (read-only). Sections 4–6 require ``TFE_EXPLORER_VIEW_ID``. -Section 7 mutates state (create/update/delete one saved view) and runs only when -``TFE_EXPLORER_DEMO_MUTATIONS=1``. +This example demonstrates all 9 Explorer service methods: + +1) ``query(organization, options)`` +2) ``export_csv(organization, options)`` +3) ``list_saved_views(organization)`` +4) ``read_saved_view(organization, view_id)`` +5) ``saved_view_results(organization, view_id)`` +6) ``saved_view_results_csv(organization, view_id)`` +7) ``create_saved_view(organization, options)`` +8) ``update_saved_view(organization, view_id, options)`` +9) ``delete_saved_view(organization, view_id)`` + +Method parameter reference (complete): + +1) ``query(organization, options)`` +- ``organization`` (required, ``str``): Terraform organization name. +- ``options`` (required, ``ExplorerQueryOptions``): + - ``view_type`` / alias ``type`` (required, ``ExplorerViewType``): + ``workspaces``, ``tf_versions``, ``providers``, ``modules``, ``resources``. + - ``sort`` (optional, ``str``): comma-separated fields; prefix each field with ``-`` + for descending order. + - ``fields`` (optional, ``str``): comma-separated list of fields to return. + - ``page_number`` / alias ``page[number]`` (optional, ``int`` >= 1). + - ``page_size`` / alias ``page[size]`` (optional, ``int`` in [1, 100]). + - ``filters`` (optional, ``list[ExplorerUrlFilter]``). + +2) ``export_csv(organization, options)`` +- Same parameters as ``query``. +- Returns full unpaged CSV text. + +3) ``list_saved_views(organization)`` +- ``organization`` (required, ``str``). + +4) ``read_saved_view(organization, view_id)`` +- ``organization`` (required, ``str``). +- ``view_id`` (required, ``str``): saved view identifier. + +5) ``saved_view_results(organization, view_id)`` +- ``organization`` (required, ``str``). +- ``view_id`` (required, ``str``). + +6) ``saved_view_results_csv(organization, view_id)`` +- ``organization`` (required, ``str``). +- ``view_id`` (required, ``str``). + +7) ``create_saved_view(organization, options)`` +- ``organization`` (required, ``str``). +- ``options`` (required, ``ExplorerSavedViewCreateOptions``): + - ``name`` (required, ``str``). + - ``query_type`` / alias ``query-type`` (required, ``ExplorerViewType``). + - ``query`` (required, ``ExplorerSavedQuery``): + - ``query_type`` / alias ``type`` (required, ``ExplorerViewType``). + - ``filter`` (optional, ``list[ExplorerSavedQueryFilter]``). + - ``fields`` (optional, ``list[str]``). + - ``sort`` (optional, ``list[str]``). + +8) ``update_saved_view(organization, view_id, options)`` +- ``organization`` (required, ``str``). +- ``view_id`` (required, ``str``). +- ``options`` (required, ``ExplorerSavedViewUpdateOptions``): + - ``name`` (required, ``str``). + - ``query`` (required, ``ExplorerSavedQuery``) with the same fields as above. + +9) ``delete_saved_view(organization, view_id)`` +- ``organization`` (required, ``str``). +- ``view_id`` (required, ``str``). + +Filter object parameter reference: +- ``ExplorerUrlFilter(index, field, operator, value, value_index=0)`` + - ``index`` (required, ``int`` >= 0): filter index in URL query. + - ``field`` (required, ``str``): target column in snake_case. + - ``operator`` (required, ``str``): for example ``contains``, ``is``, ``is_not``, + ``gt``, ``lt``, ``gteq``, ``lteq``, ``is_empty``, ``is_not_empty``, + ``is_before``, ``is_after``. + - ``value`` (required, ``str``): filter comparison value. + - ``value_index`` (optional, ``int`` >= 0, default ``0``). + +Saved query filter parameter reference: +- ``ExplorerSavedQueryFilter(field, operator, value)`` + - ``field`` (required, ``str``). + - ``operator`` (required, ``str``). + - ``value`` (required, ``list[str]``). + +Execution layout: +- Sections 1-3 always run (read-only operations). +- Sections 4-6 run only when ``TFE_EXPLORER_VIEW_ID`` is set. +- Section 7 runs only when ``TFE_EXPLORER_DEMO_MUTATIONS=1`` because it creates, + updates, and deletes a real saved view. + +Input model notes used by this example: +- ``ExplorerQueryOptions``: + - ``view_type`` (required): one of ``workspaces``, ``tf_versions``, ``providers``, + ``modules``, ``resources``. + - ``sort`` (optional): field name; prefix with ``-`` for descending. + - ``fields`` (optional): comma-separated field list. + - ``filters`` (optional): list of ``ExplorerUrlFilter`` entries. +- ``ExplorerUrlFilter``: + - ``index``: filter group index in URL shape. + - ``field``: target column (snake_case). + - ``operator``: filter operator (for example ``contains``, ``is``, ``gt``). + - ``value``: filter value string. + - ``value_index``: usually ``0``. +- ``ExplorerSavedViewCreateOptions``: + - ``name``, ``query_type``, ``query``. +- ``ExplorerSavedViewUpdateOptions``: + - ``name``, ``query``. +- ``ExplorerSavedQuery``: + - ``query_type``, optional ``filter``, optional ``fields``, optional ``sort``. +- ``ExplorerSavedQueryFilter``: + - ``field``, ``operator``, ``value`` (list of strings). Environment ----------- @@ -43,12 +150,13 @@ ExplorerSavedQueryFilter, ExplorerSavedViewCreateOptions, ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, ExplorerViewType, ) def main() -> None: - """Execute the scripted scenarios; environment variables gate optional paths.""" + """Run all Explorer scenarios with clear inputs for each method call.""" token = os.getenv("TFE_TOKEN") if not token: print("Error: TFE_TOKEN is not set.") @@ -64,20 +172,28 @@ def main() -> None: print(f"Explorer example — organization: {org!r}") print("=" * 60) - # Workspaces view; optional ExplorerUrlFilter in query_opts.filters (see SDK models). + # 1) query(organization, options) + # Inputs: + # - organization: org name string (``org``) + # - options: ExplorerQueryOptions + # - view_type: selects Explorer dataset/view + # - sort: descending by workspace_name + # - filters: one URL-style filter expression + # Output: + # - Iterator[ExplorerRow], each row containing id/type/attributes print("\n1. Query workspaces view (first 5 rows)") print("-" * 60) query_opts = ExplorerQueryOptions( view_type=ExplorerViewType.WORKSPACES, sort="-workspace_name", - # filters=[ - # ExplorerUrlFilter( - # index=0, - # field="workspace_name", - # operator="contains", - # value="prod", - # ), - # ], + filters=[ + ExplorerUrlFilter( + index=0, + field="workspace_name", + operator="contains", + value="42", + ), + ], ) try: for i, row in enumerate(client.explorer.query(org, query_opts)): @@ -92,6 +208,12 @@ def main() -> None: except Exception as e: print(f" Error: {e}") + # 2) export_csv(organization, options) + # Inputs: + # - organization: org name + # - options: ExplorerQueryOptions (minimum required input: view_type) + # Output: + # - CSV string for full unpaged query result print("\n2. CSV export (first 400 characters)") print("-" * 60) try: @@ -104,6 +226,11 @@ def main() -> None: except Exception as e: print(f" Error: {e}") + # 3) list_saved_views(organization) + # Inputs: + # - organization: org name + # Output: + # - Iterator[ExplorerSavedView] print("\n3. List saved views") print("-" * 60) try: @@ -115,6 +242,12 @@ def main() -> None: print(f" Error: {e}") if view_id: + # 4) read_saved_view(organization, view_id) + # Inputs: + # - organization: org name + # - view_id: saved view identifier (``esv-...`` in many tenants) + # Output: + # - ExplorerSavedView with name/query/query_type print("\n4. Read saved view") print("-" * 60) try: @@ -123,6 +256,12 @@ def main() -> None: except TFEError as e: print(f" TFE API error: {e}") + # 5) saved_view_results(organization, view_id) + # Inputs: + # - organization: org name + # - view_id: saved view identifier + # Output: + # - Iterator[ExplorerRow] from re-executing current saved query definition print("\n5. Saved view results (first 3 rows)") print("-" * 60) try: @@ -133,6 +272,12 @@ def main() -> None: except TFEError as e: print(f" TFE API error: {e}") + # 6) saved_view_results_csv(organization, view_id) + # Inputs: + # - organization: org name + # - view_id: saved view identifier + # Output: + # - CSV string. SDK includes fallback paths if direct CSV endpoint is unavailable. print("\n6. Saved view results as CSV (first 300 chars)") print("-" * 60) try: @@ -152,6 +297,24 @@ def main() -> None: if demo_mutations: suffix = uuid.uuid4().hex[:8] base_name = f"python-tfe-explorer-example-{suffix}" + # 7) create_saved_view(organization, options) + # 8) update_saved_view(organization, view_id, options) + # 9) delete_saved_view(organization, view_id) + # + # Inputs: + # - organization: org name + # - create options: + # - name: unique per run + # - query_type: workspaces + # - query.filter: workspace_name contains "test" + # - update options: + # - name: updated name + # - query.filter: workspace_name contains "demo" + # + # Outputs: + # - created: ExplorerSavedView (uses returned ``id`` for next calls) + # - updated: ExplorerSavedView + # - deleted: ExplorerSavedView (or minimal object if API delete response is empty) print(f"\n7. Demo mutations — create / update / delete ({base_name!r})") print("-" * 60) try: From 7172207b57b27d2f0e223971af6596c1473394e6 Mon Sep 17 00:00:00 2001 From: jasodeep Date: Thu, 23 Apr 2026 20:25:39 +0530 Subject: [PATCH 5/5] c5 --- CHANGELOG.md | 2 + examples/explorer.py | 553 ++++++++++++++++++-------------- src/pytfe/client.py | 2 +- src/pytfe/errors.py | 2 +- src/pytfe/models/explorer.py | 25 +- src/pytfe/resources/explorer.py | 235 +++++++++++--- tests/units/test_explorer.py | 30 +- 7 files changed, 550 insertions(+), 299 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2341229c..ca9ae0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### 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 diff --git a/examples/explorer.py b/examples/explorer.py index 8306834c..c75c854a 100644 --- a/examples/explorer.py +++ b/examples/explorer.py @@ -2,144 +2,138 @@ # Copyright IBM Corp. 2025, 2026 # SPDX-License-Identifier: MPL-2.0 -"""Detailed sample driver for ``TFEClient.explorer``. - -Install the package in editable mode (``pip install -e .`` from the repo root) before -running: ``python examples/explorer.py``. - -This example demonstrates all 9 Explorer service methods: - -1) ``query(organization, options)`` -2) ``export_csv(organization, options)`` -3) ``list_saved_views(organization)`` -4) ``read_saved_view(organization, view_id)`` -5) ``saved_view_results(organization, view_id)`` -6) ``saved_view_results_csv(organization, view_id)`` -7) ``create_saved_view(organization, options)`` -8) ``update_saved_view(organization, view_id, options)`` -9) ``delete_saved_view(organization, view_id)`` - -Method parameter reference (complete): - -1) ``query(organization, options)`` -- ``organization`` (required, ``str``): Terraform organization name. -- ``options`` (required, ``ExplorerQueryOptions``): - - ``view_type`` / alias ``type`` (required, ``ExplorerViewType``): - ``workspaces``, ``tf_versions``, ``providers``, ``modules``, ``resources``. - - ``sort`` (optional, ``str``): comma-separated fields; prefix each field with ``-`` - for descending order. - - ``fields`` (optional, ``str``): comma-separated list of fields to return. - - ``page_number`` / alias ``page[number]`` (optional, ``int`` >= 1). - - ``page_size`` / alias ``page[size]`` (optional, ``int`` in [1, 100]). - - ``filters`` (optional, ``list[ExplorerUrlFilter]``). - -2) ``export_csv(organization, options)`` -- Same parameters as ``query``. -- Returns full unpaged CSV text. - -3) ``list_saved_views(organization)`` -- ``organization`` (required, ``str``). - -4) ``read_saved_view(organization, view_id)`` -- ``organization`` (required, ``str``). -- ``view_id`` (required, ``str``): saved view identifier. - -5) ``saved_view_results(organization, view_id)`` -- ``organization`` (required, ``str``). -- ``view_id`` (required, ``str``). - -6) ``saved_view_results_csv(organization, view_id)`` -- ``organization`` (required, ``str``). -- ``view_id`` (required, ``str``). - -7) ``create_saved_view(organization, options)`` -- ``organization`` (required, ``str``). -- ``options`` (required, ``ExplorerSavedViewCreateOptions``): - - ``name`` (required, ``str``). - - ``query_type`` / alias ``query-type`` (required, ``ExplorerViewType``). - - ``query`` (required, ``ExplorerSavedQuery``): - - ``query_type`` / alias ``type`` (required, ``ExplorerViewType``). - - ``filter`` (optional, ``list[ExplorerSavedQueryFilter]``). - - ``fields`` (optional, ``list[str]``). - - ``sort`` (optional, ``list[str]``). - -8) ``update_saved_view(organization, view_id, options)`` -- ``organization`` (required, ``str``). -- ``view_id`` (required, ``str``). -- ``options`` (required, ``ExplorerSavedViewUpdateOptions``): - - ``name`` (required, ``str``). - - ``query`` (required, ``ExplorerSavedQuery``) with the same fields as above. - -9) ``delete_saved_view(organization, view_id)`` -- ``organization`` (required, ``str``). -- ``view_id`` (required, ``str``). - -Filter object parameter reference: -- ``ExplorerUrlFilter(index, field, operator, value, value_index=0)`` - - ``index`` (required, ``int`` >= 0): filter index in URL query. - - ``field`` (required, ``str``): target column in snake_case. - - ``operator`` (required, ``str``): for example ``contains``, ``is``, ``is_not``, - ``gt``, ``lt``, ``gteq``, ``lteq``, ``is_empty``, ``is_not_empty``, - ``is_before``, ``is_after``. - - ``value`` (required, ``str``): filter comparison value. - - ``value_index`` (optional, ``int`` >= 0, default ``0``). - -Saved query filter parameter reference: -- ``ExplorerSavedQueryFilter(field, operator, value)`` - - ``field`` (required, ``str``). - - ``operator`` (required, ``str``). - - ``value`` (required, ``list[str]``). - -Execution layout: -- Sections 1-3 always run (read-only operations). -- Sections 4-6 run only when ``TFE_EXPLORER_VIEW_ID`` is set. -- Section 7 runs only when ``TFE_EXPLORER_DEMO_MUTATIONS=1`` because it creates, - updates, and deletes a real saved view. - -Input model notes used by this example: -- ``ExplorerQueryOptions``: - - ``view_type`` (required): one of ``workspaces``, ``tf_versions``, ``providers``, - ``modules``, ``resources``. - - ``sort`` (optional): field name; prefix with ``-`` for descending. - - ``fields`` (optional): comma-separated field list. - - ``filters`` (optional): list of ``ExplorerUrlFilter`` entries. -- ``ExplorerUrlFilter``: - - ``index``: filter group index in URL shape. - - ``field``: target column (snake_case). - - ``operator``: filter operator (for example ``contains``, ``is``, ``gt``). - - ``value``: filter value string. - - ``value_index``: usually ``0``. -- ``ExplorerSavedViewCreateOptions``: - - ``name``, ``query_type``, ``query``. -- ``ExplorerSavedViewUpdateOptions``: - - ``name``, ``query``. -- ``ExplorerSavedQuery``: - - ``query_type``, optional ``filter``, optional ``fields``, optional ``sort``. -- ``ExplorerSavedQueryFilter``: - - ``field``, ``operator``, ``value`` (list of strings). - -Environment ------------ -``TFE_TOKEN`` (required) - API token with Explorer access for the target organization. - -``TFE_ADDRESS`` (optional) - Defaults to ``https://app.terraform.io``. - -``TFE_ORGANIZATION`` (optional) - Organization name; replace the placeholder when testing against a real org. - -``TFE_EXPLORER_VIEW_ID`` (optional) - Saved view id (``sq-...``) to exercise read, results iterator, and results CSV. - -``TFE_EXPLORER_DEMO_MUTATIONS`` - Set to ``1`` to run the create/update/delete demo (uses a unique view name per run). +""" +================================================================================ + Terraform Explorer API — walkthrough (TFEClient.explorer) +================================================================================ + + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer + + PUBLIC FUNCTIONS + ─────────────────────────────────────────────────── + ┌────────────────────────┬────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────┬──────────────────────────────┐ + │ Function │ Purpose │ Input parameters │ Returns │ + ├────────────────────────┼────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────┼──────────────────────────────┤ + │ query │ Execute any Explorer query │ organization: str; options: ExplorerQueryOptions │ Iterator[ExplorerRow] │ + │ export_csv │ Export query results as CSV │ organization: str; options: ExplorerQueryOptions │ str (CSV document) │ + │ list_saved_views │ List saved Explorer views │ organization: str │ Iterator[ExplorerSavedView] │ + │ create_saved_view │ Create saved Explorer view │ organization: str; options: ExplorerSavedViewCreateOptions │ ExplorerSavedView │ + │ read_saved_view │ Fetch one saved view by id │ organization: str; view_id: str │ ExplorerSavedView │ + │ update_saved_view │ Update saved view definition │ organization: str; view_id: str; options: ExplorerSavedViewUpdateOptions │ ExplorerSavedView │ + │ delete_saved_view │ Remove saved view by id │ organization: str; view_id: str │ ExplorerSavedView │ + │ saved_view_results │ Execute saved view, stream rows │ organization: str; view_id: str │ Iterator[ExplorerRow] │ + │ saved_view_results_csv │ Saved view results as CSV │ organization: str; view_id: str │ str (CSV; fallbacks) │ + └────────────────────────┴────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────┴──────────────────────────────┘ + delete_saved_view: if the DELETE response has no JSON body, the client returns a + minimal ExplorerSavedView with the same id. + saved_view_results_csv: tries the saved-view CSV endpoint first; on failure it may + call export_csv after read_saved_view, or build CSV from saved_view_results. + + INPUT AND OUTPUT MODELS (how to pass; allowed values) + ─────────────────────────────────────────────────────── + Full column tables and operator semantics: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer + + Plain string parameters (no model) + - organization — First argument on every method: org name as str (non-empty; invalid + values raise InvalidOrgError). + - view_id — str for saved-view routes (non-empty; invalid values raise + InvalidExplorerSavedViewIDError). Use the id returned by list_saved_views or + create_saved_view. + + ExplorerQueryOptions — second argument to query(org, options) and export_csv(org, options) + How to pass: build one instance and pass it by name, for example + ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES, sort="-workspace_name", + filters=[ExplorerUrlFilter(...)]). + Required: + - view_type — ExplorerViewType (serialized to HTTP query key type). Allowed strings + per product docs: workspaces, tf_versions, providers, modules. This SDK also + defines resources for APIs that support that view. + Optional: + - sort — Comma-separated snake_case field names for the active view; prefix "-" for + descending order. + - fields — Comma-separated snake_case columns to return (must be valid for the view). + - page_number, page_size — Integers; page_number ≥ 1; page_size between 1 and 100. + - filters — List of ExplorerUrlFilter; combined with logical AND. + + ExplorerUrlFilter — each element of ExplorerQueryOptions.filters + How to pass: ExplorerUrlFilter(index=0, field="workspace_name", operator="contains", + value="prod", value_index=0). + Allowed: + - index — int ≥ 0 (first filter is 0, then 1, 2, …). + - field — snake_case column name for the current view_type (see Explorer doc View Types). + - operator — one of: is, is_not, contains, does not contain, is_empty, is_not_empty, + gt, lt, gteq, lteq, is_before, is_after (use the exact token your API version documents; + each operator only applies to compatible field types). + - value — str; use ISO 8601 timestamps for is_before / is_after when filtering datetimes. + - value_index — must be 0. + + ExplorerSavedViewCreateOptions — second argument to create_saved_view(org, options) + How to pass: ExplorerSavedViewCreateOptions(name="...", query_type=ExplorerViewType...., + query=ExplorerSavedQuery(...)). + Allowed: + - name — non-empty str. + - query_type — same ExplorerViewType set as view_type (JSON body key query-type). + - query — ExplorerSavedQuery (see below). + + ExplorerSavedViewUpdateOptions — third argument to update_saved_view(org, view_id, options) + How to pass: ExplorerSavedViewUpdateOptions(name="...", query=ExplorerSavedQuery(...)). + PATCH replaces the stored query entirely—send a full ExplorerSavedQuery each time. + + ExplorerSavedQuery — nested only inside create/update options + How to pass: ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES, filter=[...], + fields=[...], sort=[...]). + Allowed: + - query_type — required; same values as ExplorerQueryOptions.view_type (JSON key type). + - filter — optional list of ExplorerSavedQueryFilter(field=..., operator=..., value=[...]). + - fields — optional list of snake_case column names. + - sort — optional list of field names; leading "-" on an entry means descending. + + ExplorerSavedQueryFilter — one dict-like row inside ExplorerSavedQuery.filter + How to pass: ExplorerSavedQueryFilter(field="workspace_name", operator="contains", + value=["prod"]). + Allowed: field and operator follow the same rules as URL filters; value is always a + list of strings (even for a single operand). + + Output models (return values only; you do not instantiate these for requests) + ExplorerRow — from query(), saved_view_results(): read .id, .row_type, .attributes. + .attributes is a dict of column values; keys may be hyphenated or snake_case depending + on the API field name. + ExplorerSavedView — from create_saved_view, read_saved_view, update_saved_view, + delete_saved_view, list_saved_views: .id, .name, .created_at, .query_type, .query. + str — from export_csv, saved_view_results_csv: raw CSV document body. + Iterator[...] — lazy streams; consume with for-loops or list(...) if you need a list. + + SCRIPT SECTIONS + ─────────────── + Sections 1 through 3 always run (read-only): query, export_csv, list_saved_views. + Sections 4 through 6 run when TFE_EXPLORER_VIEW_ID is set: read_saved_view, + saved_view_results, saved_view_results_csv. + Section 7 runs when TFE_EXPLORER_DEMO_MUTATIONS=1: create_saved_view, + update_saved_view, delete_saved_view. + + HOW TO RUN + ────────── + From the repository root, install in editable mode, then execute this file: + pip install -e . + python examples/explorer.py + + + ENVIRONMENT VARIABLES + ───────────────────── + TFE_TOKEN Required. API token with Explorer access. + TFE_ADDRESS Optional. API base URL; defaults to https://app.terraform.io + TFE_ORGANIZATION Optional. Organization name (the script substitutes a placeholder if unset). + TFE_EXPLORER_VIEW_ID Optional. When set, exercises saved-view read and export paths (sections 4–6). + TFE_EXPLORER_DEMO_MUTATIONS Optional. Allowed value to enable writes: 1 only. + Any other value skips section 7 (create, update, delete). """ from __future__ import annotations import os import sys +import textwrap import uuid from pytfe import TFEClient, TFEConfig @@ -154,12 +148,43 @@ ExplorerViewType, ) +_LINE = "-" * 72 + + +def _banner(title: str, subtitle: str = "") -> None: + """Print a plain section divider and title for stdout readability.""" + print(f"\n{_LINE}\n{title}") + if subtitle: + print(subtitle) + print(_LINE) + + +def _print_csv_lines(label: str, csv_text: str, max_chars: int, max_lines: int) -> None: + """Print a readable, line-oriented slice of a CSV string without decorative framing.""" + snippet = csv_text[:max_chars] + truncated = len(csv_text) > max_chars + lines = snippet.splitlines() or ([snippet] if snippet else ["(empty)"]) + print(label) + for raw in lines[:max_lines]: + display = raw if len(raw) <= 68 else raw[:67] + "..." + print(f" {display}") + if len(lines) > max_lines: + print( + f" ... ({len(lines) - max_lines} more line(s) not shown in this preview)" + ) + if truncated: + print( + f" (Preview truncated by character limit; full length {len(csv_text):,} chars.)" + ) + def main() -> None: - """Run all Explorer scenarios with clear inputs for each method call.""" + """Execute the Explorer walkthrough; refer to the module docstring for API details.""" token = os.getenv("TFE_TOKEN") if not token: - print("Error: TFE_TOKEN is not set.") + print( + "Error: TFE_TOKEN is not set. Export a valid API token before running this example." + ) sys.exit(1) address = os.getenv("TFE_ADDRESS", "https://app.terraform.io") @@ -167,22 +192,28 @@ def main() -> None: view_id = os.getenv("TFE_EXPLORER_VIEW_ID") demo_mutations = os.getenv("TFE_EXPLORER_DEMO_MUTATIONS") == "1" + # TFEClient is the entry point for all Terraform Enterprise / HCP Terraform API + # access in this SDK. TFEConfig carries the base URL and bearer token; every + # resource (including explorer) uses the same underlying HTTP session. client = TFEClient(TFEConfig(address=address, token=token)) - print(f"Explorer example — organization: {org!r}") - print("=" * 60) - - # 1) query(organization, options) - # Inputs: - # - organization: org name string (``org``) - # - options: ExplorerQueryOptions - # - view_type: selects Explorer dataset/view - # - sort: descending by workspace_name - # - filters: one URL-style filter expression - # Output: - # - Iterator[ExplorerRow], each row containing id/type/attributes - print("\n1. Query workspaces view (first 5 rows)") - print("-" * 60) + _banner( + "Terraform Explorer API example", + f"Organization: {org!r}\nAPI base URL: {address}", + ) + + # ------------------------------------------------------------------------- + # Step 1: client.explorer.query(organization, options) + # ------------------------------------------------------------------------- + # Runs GET .../organizations/{org}/explorer with query-string parameters derived + # from ExplorerQueryOptions. Here we request the workspaces view, sort by + # workspace_name descending (leading hyphen in sort), and add a single URL-style + # filter (workspace_name contains "42"). The iterator yields ExplorerRow objects + # (id, row_type, attributes dict); we only print the first five rows. + _banner( + "Step 1 of 7: query()", + "Workspaces view, sorted by -workspace_name, filter workspace_name contains '42'.", + ) query_opts = ExplorerQueryOptions( view_type=ExplorerViewType.WORKSPACES, sort="-workspace_name", @@ -196,128 +227,169 @@ def main() -> None: ], ) try: + count = 0 for i, row in enumerate(client.explorer.query(org, query_opts)): if i >= 5: break + count += 1 name = row.attributes.get("workspace-name") or row.attributes.get( "workspace_name" ) - print(f" {row.id} workspace-name={name!r}") + print(f" Row {count}:") + print(f" id: {row.id}") + print(f" row_type: {row.row_type!r}") + print(f" workspace_name: {name!r}") + print(" ---") + print(f"Summary: printed {count} row(s) (limit 5).") except TFEError as e: - print(f" TFE API error: {e}") + print(f" API error: {e}") except Exception as e: print(f" Error: {e}") - # 2) export_csv(organization, options) - # Inputs: - # - organization: org name - # - options: ExplorerQueryOptions (minimum required input: view_type) - # Output: - # - CSV string for full unpaged query result - print("\n2. CSV export (first 400 characters)") - print("-" * 60) + # ------------------------------------------------------------------------- + # Step 2: client.explorer.export_csv(organization, options) + # ------------------------------------------------------------------------- + # Same query parameters as query(), but the response is a single CSV document + # (full unpaged export per API semantics). We only print an opening slice so the + # terminal stays readable. + _banner( + "Step 2 of 7: export_csv()", + "Workspaces view, no filters; preview first 400 characters / up to 8 lines.", + ) try: csv_text = client.explorer.export_csv( org, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) ) - print(csv_text[:400] + ("..." if len(csv_text) > 400 else "")) + _print_csv_lines( + "CSV preview (document may be large):", + csv_text, + max_chars=400, + max_lines=8, + ) + print("Summary: export_csv completed.") except TFEError as e: - print(f" TFE API error: {e}") + print(f" API error: {e}") except Exception as e: print(f" Error: {e}") - # 3) list_saved_views(organization) - # Inputs: - # - organization: org name - # Output: - # - Iterator[ExplorerSavedView] - print("\n3. List saved views") - print("-" * 60) + # ------------------------------------------------------------------------- + # Step 3: client.explorer.list_saved_views(organization) + # ------------------------------------------------------------------------- + # GET .../organizations/{org}/explorer/views returns every saved Explorer view + # (saved query) in the organization. Each item is an ExplorerSavedView with id, + # name, query, and query_type. + _banner( + "Step 3 of 7: list_saved_views()", + "Iterate all saved views; print id, name, and query_type for each.", + ) try: + n = 0 for sv in client.explorer.list_saved_views(org): - print(f" {sv.id} {sv.name!r} query-type={sv.query_type!r}") + n += 1 + print(f" Saved view {n}:") + print(f" id: {sv.id}") + print(f" name: {sv.name!r}") + print(f" query_type: {sv.query_type!r}") + print(" ---") + print(f"Summary: listed {n} saved view(s).") except TFEError as e: - print(f" TFE API error: {e}") + print(f" API error: {e}") except Exception as e: print(f" Error: {e}") if view_id: - # 4) read_saved_view(organization, view_id) - # Inputs: - # - organization: org name - # - view_id: saved view identifier (``esv-...`` in many tenants) - # Output: - # - ExplorerSavedView with name/query/query_type - print("\n4. Read saved view") - print("-" * 60) + # --------------------------------------------------------------------- + # Step 4: client.explorer.read_saved_view(organization, view_id) + # --------------------------------------------------------------------- + # GET .../explorer/views/{view_id} fetches one saved view definition (not the + # materialized result rows). view_id must be an id returned by list or create. + _banner( + "Step 4 of 7: read_saved_view()", + f"view_id from TFE_EXPLORER_VIEW_ID: {view_id!r}", + ) try: sv = client.explorer.read_saved_view(org, view_id) - print(f" {sv.id} {sv.name!r} query={sv.query!r}") + print(" Saved view record:") + print(f" id: {sv.id}") + print(f" name: {sv.name!r}") + q_preview = textwrap.shorten(repr(sv.query), width=68, placeholder=" ...") + print(f" query: {q_preview}") + print(f" query_type: {sv.query_type!r}") + print("Summary: read_saved_view completed.") except TFEError as e: - print(f" TFE API error: {e}") - - # 5) saved_view_results(organization, view_id) - # Inputs: - # - organization: org name - # - view_id: saved view identifier - # Output: - # - Iterator[ExplorerRow] from re-executing current saved query definition - print("\n5. Saved view results (first 3 rows)") - print("-" * 60) + print(f" API error: {e}") + + # --------------------------------------------------------------------- + # Step 5: client.explorer.saved_view_results(organization, view_id) + # --------------------------------------------------------------------- + # GET .../explorer/views/{view_id}/results re-executes the saved query and + # streams ExplorerRow results (same shape as query()). We print the first three. + _banner( + "Step 5 of 7: saved_view_results()", + "First 3 rows from re-running the saved view query.", + ) try: for i, row in enumerate(client.explorer.saved_view_results(org, view_id)): if i >= 3: break - print(f" {row.id} type={row.row_type!r}") + print(f" Result row {i + 1}:") + print(f" id: {row.id}") + print(f" row_type: {row.row_type!r}") + print(" ---") + print("Summary: saved_view_results completed (limit 3 rows printed).") except TFEError as e: - print(f" TFE API error: {e}") - - # 6) saved_view_results_csv(organization, view_id) - # Inputs: - # - organization: org name - # - view_id: saved view identifier - # Output: - # - CSV string. SDK includes fallback paths if direct CSV endpoint is unavailable. - print("\n6. Saved view results as CSV (first 300 chars)") - print("-" * 60) + print(f" API error: {e}") + + # --------------------------------------------------------------------- + # Step 6: client.explorer.saved_view_results_csv(organization, view_id) + # --------------------------------------------------------------------- + # Intended to match GET .../explorer/views/{view_id}/csv. This SDK may fall + # back to export_csv after read_saved_view, or synthesize CSV from results, + # when the dedicated CSV route is unavailable. + _banner( + "Step 6 of 7: saved_view_results_csv()", + "Preview first 300 characters / up to 6 lines; fallbacks may apply.", + ) try: csv_sv = client.explorer.saved_view_results_csv(org, view_id) - print(csv_sv[:300] + ("..." if len(csv_sv) > 300 else "")) + _print_csv_lines( + "CSV preview:", + csv_sv, + max_chars=300, + max_lines=6, + ) + print("Summary: saved_view_results_csv completed.") except TFEError as e: - print(f" TFE API error: {e}") - print( - " Hint: ``not found`` often means ``TFE_EXPLORER_VIEW_ID`` was deleted or " - "belongs to another org. pytfe also falls back to ``export_csv`` and to CSV " - "built from ``saved_view_results``; if step 5 worked, reinstall editable pytfe." + print(f" API error: {e}") + note = textwrap.fill( + "Note: A 404 often means the saved view was removed, the id belongs to " + "another organization, or this deployment has no dedicated CSV route. " + "The client retries via export_csv after read_saved_view, then builds " + "CSV from saved_view_results. If step 5 worked, confirm an editable " + "install (pip install -e .).", + width=70, + subsequent_indent=" ", ) + for line in note.splitlines(): + print(f" {line}") else: - print("\n4–6. Skipped (set TFE_EXPLORER_VIEW_ID to exercise read/results/csv)") - print("-" * 60) + _banner( + "Steps 4 through 6 skipped", + "Set environment variable TFE_EXPLORER_VIEW_ID to the saved view id to run " + "read_saved_view, saved_view_results, and saved_view_results_csv.", + ) if demo_mutations: suffix = uuid.uuid4().hex[:8] base_name = f"python-tfe-explorer-example-{suffix}" - # 7) create_saved_view(organization, options) - # 8) update_saved_view(organization, view_id, options) - # 9) delete_saved_view(organization, view_id) - # - # Inputs: - # - organization: org name - # - create options: - # - name: unique per run - # - query_type: workspaces - # - query.filter: workspace_name contains "test" - # - update options: - # - name: updated name - # - query.filter: workspace_name contains "demo" - # - # Outputs: - # - created: ExplorerSavedView (uses returned ``id`` for next calls) - # - updated: ExplorerSavedView - # - deleted: ExplorerSavedView (or minimal object if API delete response is empty) - print(f"\n7. Demo mutations — create / update / delete ({base_name!r})") - print("-" * 60) + _banner( + "Step 7 of 7: create_saved_view, update_saved_view, delete_saved_view", + f"Uses a unique temporary name so reruns do not collide: {base_name!r}", + ) try: + # ExplorerSavedViewCreateOptions maps to POST .../explorer/views: a display + # name, the primary query_type for the saved definition, and an embedded + # ExplorerSavedQuery (view type, optional filters with list-valued operands). create_opts = ExplorerSavedViewCreateOptions( name=base_name, query_type=ExplorerViewType.WORKSPACES, @@ -332,9 +404,13 @@ def main() -> None: ], ), ) + # client.explorer.create_saved_view persists a new saved view; the response + # includes the server-assigned id required for subsequent update/delete. created = client.explorer.create_saved_view(org, create_opts) - print(f" Created: {created.id}") + print(f" create_saved_view: new id {created.id}") + # ExplorerSavedViewUpdateOptions maps to PATCH: at minimum a new name and + # a full replacement ExplorerSavedQuery payload for the stored definition. update_opts = ExplorerSavedViewUpdateOptions( name=f"{base_name}-updated", query=ExplorerSavedQuery( @@ -348,21 +424,28 @@ def main() -> None: ], ), ) + # client.explorer.update_saved_view applies the patch to the id returned + # from create_saved_view in this demonstration sequence. updated = client.explorer.update_saved_view(org, created.id, update_opts) - print(f" Updated: {updated.name!r}") + print(f" update_saved_view: name is now {updated.name!r}") + # client.explorer.delete_saved_view removes the saved view; some API + # responses omit JSON, in which case the client still returns a minimal + # ExplorerSavedView carrying the deleted id. deleted = client.explorer.delete_saved_view(org, created.id) - print(f" Deleted: {deleted.id}") + print(f" delete_saved_view: completed for id {deleted.id}") + print("Summary: mutation sequence finished.") except TFEError as e: - print(f" TFE API error: {e}") + print(f" API error: {e}") sys.exit(1) else: - print( - "\n7. Skipped (set TFE_EXPLORER_DEMO_MUTATIONS=1 to run create/update/delete)" + _banner( + "Step 7 skipped", + "Set TFE_EXPLORER_DEMO_MUTATIONS=1 to run create_saved_view, " + "update_saved_view, and delete_saved_view (writes to your organization).", ) - print("-" * 60) - print("\nDone.") + print(f"\n{_LINE}\nExample completed.\n{_LINE}") if __name__ == "__main__": diff --git a/src/pytfe/client.py b/src/pytfe/client.py index fd8c45ac..cc642031 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -73,7 +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) + self.explorer = Explorer(self._transport) # org Explorer queries and saved views self.projects = Projects(self._transport) self.variables = Variables(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index 341b85fa..40acad3d 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -373,7 +373,7 @@ def __init__(self, message: str = "invalid value for query run ID"): class InvalidExplorerSavedViewIDError(InvalidValues): - """Raised when an invalid Explorer saved view ID is provided.""" + """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) diff --git a/src/pytfe/models/explorer.py b/src/pytfe/models/explorer.py index abdfa2e1..28335abf 100644 --- a/src/pytfe/models/explorer.py +++ b/src/pytfe/models/explorer.py @@ -1,7 +1,10 @@ # Copyright IBM Corp. 2025, 2026 # SPDX-License-Identifier: MPL-2.0 -"""Explorer models for Terraform Enterprise.""" +"""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 @@ -13,17 +16,17 @@ class ExplorerViewType(str, Enum): - """Available Explorer view types.""" + """Explorer `type` / `query-type` discriminator (see product docs for supported views).""" WORKSPACES = "workspaces" TF_VERSIONS = "tf_versions" PROVIDERS = "providers" MODULES = "modules" - RESOURCES = "resources" + RESOURCES = "resources" # Present when the deployment exposes a resources view. class ExplorerUrlFilter(BaseModel): - """Represents one URL filter entry for query endpoints.""" + """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( @@ -39,7 +42,7 @@ class ExplorerUrlFilter(BaseModel): class ExplorerQueryOptions(BaseModel): - """Options for executing an Explorer query.""" + """GET /organizations/{org}/explorer (and export/csv) query string as structured fields.""" model_config = ConfigDict(populate_by_name=True) @@ -61,7 +64,7 @@ class ExplorerQueryOptions(BaseModel): class ExplorerRow(BaseModel): - """Represents a single Explorer query result row.""" + """One Explorer result row: json:api id/type plus flat attributes for the view.""" model_config = ConfigDict(populate_by_name=True) @@ -71,7 +74,7 @@ class ExplorerRow(BaseModel): class ExplorerSavedQueryFilter(BaseModel): - """Filter object stored in saved query payloads.""" + """One saved-view filter row (list-valued `value` matches create/update JSON).""" field: str = Field(..., min_length=1) operator: str = Field(..., min_length=1) @@ -79,7 +82,7 @@ class ExplorerSavedQueryFilter(BaseModel): class ExplorerSavedQuery(BaseModel): - """Query definition used by Explorer saved views.""" + """Nested query on a saved view: view type, filters, optional fields and sort lists.""" model_config = ConfigDict(populate_by_name=True) @@ -90,7 +93,7 @@ class ExplorerSavedQuery(BaseModel): class ExplorerSavedView(BaseModel): - """Saved Explorer query metadata and query definition.""" + """Saved view resource: metadata plus embedded query (response and some request paths).""" model_config = ConfigDict(populate_by_name=True) @@ -102,7 +105,7 @@ class ExplorerSavedView(BaseModel): class ExplorerSavedViewCreateOptions(BaseModel): - """Request body options for creating a saved view.""" + """POST .../explorer/views attributes: display name, top-level query-type, nested query.""" model_config = ConfigDict(populate_by_name=True) @@ -112,7 +115,7 @@ class ExplorerSavedViewCreateOptions(BaseModel): class ExplorerSavedViewUpdateOptions(BaseModel): - """Request body options for updating a saved view.""" + """PATCH .../explorer/views/{id} attributes: name and full replacement query.""" model_config = ConfigDict(populate_by_name=True) diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py index 0d4a5d2f..1ea5bb64 100644 --- a/src/pytfe/resources/explorer.py +++ b/src/pytfe/resources/explorer.py @@ -1,12 +1,18 @@ # Copyright IBM Corp. 2025, 2026 # SPDX-License-Identifier: MPL-2.0 -"""Explorer API resource.""" +"""Explorer API resource. + +Maps organization-scoped Explorer endpoints (ad hoc query, CSV export, saved views) to +typed models. Saved-view create/update reshape filter JSON; read paths normalize API +variants before validation. +""" from __future__ import annotations import csv import io +import logging from collections.abc import Iterator from typing import Any @@ -15,6 +21,7 @@ InvalidOrgError, NotFound, ServerError, + ValidationError, ) from ..models.explorer import ( ExplorerQueryOptions, @@ -27,8 +34,72 @@ from ..utils import valid_string_id from ._base import _Service +_log = logging.getLogger(__name__) + + +def _explorer_single_resource_data( + resp: Any, + *, + operation: str, + organization: str, + view_id: str | None = None, +) -> dict[str, Any]: + """Parse json:api envelope for a single Explorer saved view; raise ValidationError if unusable.""" + ctx = f"org={organization!r}" + if view_id is not None: + ctx += f" view_id={view_id!r}" + try: + payload = resp.json() + except ValueError as exc: + _log.warning("explorer.%s: invalid JSON response (%s)", operation, ctx) + raise ValidationError( + f"Explorer {operation}: response body is not valid JSON ({ctx})" + ) from exc + if not isinstance(payload, dict): + _log.warning("explorer.%s: top-level JSON is not an object (%s)", operation, ctx) + raise ValidationError( + f"Explorer {operation}: expected JSON object at top level ({ctx})" + ) + data = payload.get("data") + if not isinstance(data, dict): + _log.warning( + "explorer.%s: missing or invalid 'data' (type=%s) (%s)", + operation, + type(data).__name__, + ctx, + ) + raise ValidationError( + f"Explorer {operation}: expected json:api 'data' object ({ctx})" + ) + return data + + +def _require_organization(organization: str) -> None: + """Reject blank organization identifiers before building paths.""" + if not valid_string_id(organization): + raise InvalidOrgError() + + +def _require_organization_and_view(organization: str, view_id: str) -> None: + """Validate org and saved-view id for routes under .../explorer/views/{view_id}.""" + _require_organization(organization) + if not valid_string_id(view_id): + raise InvalidExplorerSavedViewIDError() + + +def _write_attributes_with_query_shape( + options: ExplorerSavedViewCreateOptions | ExplorerSavedViewUpdateOptions, +) -> dict[str, Any]: + """Serialize create/update options; map saved-query filters to the map shape POST/PATCH expect.""" + attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") + raw_query = attrs.get("query") + if isinstance(raw_query, dict): + attrs["query"] = _saved_query_to_api_shape(raw_query) + return attrs + def _query_params(options: ExplorerQueryOptions) -> dict[str, Any]: + # mode="json" keeps ExplorerViewType as strings; filters are expanded separately (Explorer URL grammar). params = options.model_dump( by_alias=True, exclude_none=True, @@ -48,7 +119,7 @@ def _parse_row(item: dict[str, Any]) -> ExplorerRow: def _saved_query_to_api_shape(raw_query: dict[str, Any]) -> dict[str, Any]: - """Transform normalized saved-query payload to API-accepted create/update shape.""" + """Map {field, operator, value} filter rows to nested {field: {operator: [...]}} JSON.""" query = dict(raw_query) raw_filter = query.get("filter") if isinstance(raw_filter, list): @@ -73,7 +144,7 @@ def _saved_query_to_api_shape(raw_query: dict[str, Any]) -> dict[str, Any]: def _normalize_saved_query( raw_query: dict[str, Any], raw_query_type: str | None ) -> dict[str, Any]: - """Normalize API variants of saved-query payloads to model shape.""" + """Coerce saved-view query JSON into the flat filter + list fields shape our models use.""" query = dict(raw_query) if "type" not in query and raw_query_type: @@ -128,6 +199,7 @@ def _normalize_saved_query( def _parse_saved_view(item: dict[str, Any]) -> ExplorerSavedView: + # json:api envelope: attributes carry name, timestamps, nested query and query-type. attrs = item.get("attributes", {}) query_type = attrs.get("query-type") query = attrs.get("query", {}) @@ -160,7 +232,7 @@ def _deleted_saved_view_fallback(view_id: str) -> ExplorerSavedView: def _query_options_from_saved_view( saved_view: ExplorerSavedView, ) -> ExplorerQueryOptions: - """Convert a saved view query into ExplorerQueryOptions.""" + """Replay a stored saved query as GET /explorer query params (used by CSV fallback).""" query = saved_view.query filters: list[ExplorerUrlFilter] = [] if query.filter: @@ -186,7 +258,7 @@ def _query_options_from_saved_view( def _rows_to_csv(rows: list[ExplorerRow]) -> str: - """Build CSV from Explorer rows attributes.""" + """Union of row attribute keys as header; last-resort CSV when /views/.../csv is unavailable.""" if not rows: return "" keys: set[str] = set() @@ -202,27 +274,38 @@ def _rows_to_csv(rows: list[ExplorerRow]) -> str: class Explorer(_Service): - """Explorer API for Terraform Enterprise.""" + """Organization Explorer: ad hoc queries, CSV export, and saved view CRUD.""" def query( self, organization: str, options: ExplorerQueryOptions ) -> Iterator[ExplorerRow]: - if not valid_string_id(organization): - raise InvalidOrgError() + _require_organization(organization) + _log.debug( + "explorer.query org=%r view_type=%s", + organization, + options.view_type.value, + ) + # GET .../explorer — paginated JSON rows for the given view and filters. path = f"/api/v2/organizations/{organization}/explorer" for item in self._list(path, params=_query_params(options)): yield _parse_row(item) def export_csv(self, organization: str, options: ExplorerQueryOptions) -> str: - if not valid_string_id(organization): - raise InvalidOrgError() + _require_organization(organization) + _log.debug( + "explorer.export_csv org=%r view_type=%s", + organization, + options.view_type.value, + ) + # Same query string as query(); response is a single unpaged CSV document. path = f"/api/v2/organizations/{organization}/explorer/export/csv" resp = self.t.request("GET", path, params=_query_params(options)) return resp.text def list_saved_views(self, organization: str) -> Iterator[ExplorerSavedView]: - if not valid_string_id(organization): - raise InvalidOrgError() + _require_organization(organization) + _log.debug("explorer.list_saved_views org=%r", organization) + # GET collection of explorer-saved-queries for the org. path = f"/api/v2/organizations/{organization}/explorer/views" for item in self._list(path): yield _parse_saved_view(item) @@ -230,12 +313,9 @@ def list_saved_views(self, organization: str) -> Iterator[ExplorerSavedView]: def create_saved_view( self, organization: str, options: ExplorerSavedViewCreateOptions ) -> ExplorerSavedView: - if not valid_string_id(organization): - raise InvalidOrgError() - attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") - raw_query = attrs.get("query") - if isinstance(raw_query, dict): - attrs["query"] = _saved_query_to_api_shape(raw_query) + _require_organization(organization) + # POST json:api explorer-saved-queries; filters rewritten for server expectations. + attrs = _write_attributes_with_query_shape(options) body = { "data": { "type": "explorer-saved-queries", @@ -244,16 +324,30 @@ def create_saved_view( } path = f"/api/v2/organizations/{organization}/explorer/views" resp = self.t.request("POST", path, json_body=body) - return _parse_saved_view(resp.json()["data"]) + data = _explorer_single_resource_data( + resp, operation="create_saved_view", organization=organization + ) + view = _parse_saved_view(data) + _log.info("explorer.create_saved_view org=%r id=%r", organization, view.id) + return view def read_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView: - if not valid_string_id(organization): - raise InvalidOrgError() - if not valid_string_id(view_id): - raise InvalidExplorerSavedViewIDError() + _require_organization_and_view(organization, view_id) + _log.debug( + "explorer.read_saved_view org=%r view_id=%r", + organization, + view_id, + ) + # Returns stored definition only; does not execute the query (see saved_view_results). path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" resp = self.t.request("GET", path) - return _parse_saved_view(resp.json()["data"]) + data = _explorer_single_resource_data( + resp, + operation="read_saved_view", + organization=organization, + view_id=view_id, + ) + return _parse_saved_view(data) def update_saved_view( self, @@ -261,14 +355,9 @@ def update_saved_view( view_id: str, options: ExplorerSavedViewUpdateOptions, ) -> ExplorerSavedView: - if not valid_string_id(organization): - raise InvalidOrgError() - if not valid_string_id(view_id): - raise InvalidExplorerSavedViewIDError() - attrs = options.model_dump(by_alias=True, exclude_none=True, mode="json") - raw_query = attrs.get("query") - if isinstance(raw_query, dict): - attrs["query"] = _saved_query_to_api_shape(raw_query) + _require_organization_and_view(organization, view_id) + attrs = _write_attributes_with_query_shape(options) + # PATCH includes resource id in the envelope per json:api update conventions. body = { "data": { "type": "explorer-saved-queries", @@ -278,55 +367,101 @@ def update_saved_view( } path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" resp = self.t.request("PATCH", path, json_body=body) - return _parse_saved_view(resp.json()["data"]) + data = _explorer_single_resource_data( + resp, + operation="update_saved_view", + organization=organization, + view_id=view_id, + ) + view = _parse_saved_view(data) + _log.info("explorer.update_saved_view org=%r id=%r", organization, view.id) + return view def delete_saved_view(self, organization: str, view_id: str) -> ExplorerSavedView: - if not valid_string_id(organization): - raise InvalidOrgError() - if not valid_string_id(view_id): - raise InvalidExplorerSavedViewIDError() + _require_organization_and_view(organization, view_id) path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" resp = self.t.request("DELETE", path) + # DELETE often returns an empty body; callers still receive a minimal ExplorerSavedView. raw_text = (resp.text or "").strip() if not raw_text: + _log.debug( + "explorer.delete_saved_view: empty body, returning stub org=%r id=%r", + organization, + view_id, + ) return _deleted_saved_view_fallback(view_id) try: payload = resp.json() except ValueError: + _log.debug( + "explorer.delete_saved_view: non-JSON body, returning stub org=%r id=%r", + organization, + view_id, + ) return _deleted_saved_view_fallback(view_id) if isinstance(payload, dict) and isinstance(payload.get("data"), dict): return _parse_saved_view(payload["data"]) + _log.debug( + "explorer.delete_saved_view: no data object, returning stub org=%r id=%r", + organization, + view_id, + ) return _deleted_saved_view_fallback(view_id) def saved_view_results( self, organization: str, view_id: str ) -> Iterator[ExplorerRow]: - if not valid_string_id(organization): - raise InvalidOrgError() - if not valid_string_id(view_id): - raise InvalidExplorerSavedViewIDError() + _require_organization_and_view(organization, view_id) + _log.debug( + "explorer.saved_view_results org=%r view_id=%r", + organization, + view_id, + ) + # Re-runs the saved query; rows match ad hoc query() shape (current data only). path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}/results" for item in self._list(path): yield _parse_row(item) def saved_view_results_csv(self, organization: str, view_id: str) -> str: - if not valid_string_id(organization): - raise InvalidOrgError() - if not valid_string_id(view_id): - raise InvalidExplorerSavedViewIDError() + _require_organization_and_view(organization, view_id) + _log.debug( + "explorer.saved_view_results_csv org=%r view_id=%r", + organization, + view_id, + ) path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}/csv" try: resp = self.t.request("GET", path) return resp.text - except (NotFound, ServerError): - pass - + except (NotFound, ServerError) as exc: + _log.info( + "explorer.saved_view_results_csv: primary CSV route unavailable (%s); " + "trying export_csv replay org=%r view_id=%r", + exc.__class__.__name__, + organization, + view_id, + ) + + # Fall back: replay saved definition via export_csv, then row materialization if needed. try: saved_view = self.read_saved_view(organization, view_id) options = _query_options_from_saved_view(saved_view) - return self.export_csv(organization, options) - except (NotFound, ServerError): + csv_text = self.export_csv(organization, options) + _log.info( + "explorer.saved_view_results_csv: used export_csv fallback org=%r view_id=%r", + organization, + view_id, + ) + return csv_text + except (NotFound, ServerError) as exc: + _log.warning( + "explorer.saved_view_results_csv: export_csv fallback failed (%s); " + "building CSV from row stream org=%r view_id=%r", + exc.__class__.__name__, + organization, + view_id, + ) rows = list(self.saved_view_results(organization, view_id)) return _rows_to_csv(rows) diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py index a5d86cb3..ec634bff 100644 --- a/tests/units/test_explorer.py +++ b/tests/units/test_explorer.py @@ -7,7 +7,12 @@ import pytest -from pytfe.errors import InvalidExplorerSavedViewIDError, InvalidOrgError, NotFound +from pytfe.errors import ( + InvalidExplorerSavedViewIDError, + InvalidOrgError, + NotFound, + ValidationError, +) from pytfe.models import ( ExplorerQueryOptions, ExplorerSavedQuery, @@ -182,6 +187,29 @@ def test_create_saved_view(self, explorer_service, mock_transport): {"workspace_name": {"contains": ["test"]}} ] + def test_create_saved_view_invalid_json_raises(self, explorer_service, mock_transport): + response = Mock() + response.json.side_effect = ValueError("invalid json") + mock_transport.request.return_value = response + + options = ExplorerSavedViewCreateOptions( + name="my-view", + query_type=ExplorerViewType.WORKSPACES, + query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ) + with pytest.raises(ValidationError, match="create_saved_view"): + explorer_service.create_saved_view("acme", options) + + def test_read_saved_view_missing_data_object_raises( + self, explorer_service, mock_transport + ): + response = Mock() + response.json.return_value = {"data": []} + mock_transport.request.return_value = response + + with pytest.raises(ValidationError, match="read_saved_view"): + explorer_service.read_saved_view("acme", "sq-1") + def test_read_saved_view(self, explorer_service, mock_transport): response = Mock() response.json.return_value = {"data": _saved_view_payload("sq-1")}