diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db63b0d..ca9ae0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Unreleased +## Features + +### Explorer API +* Added Explorer resource support with query, CSV export, saved view CRUD, saved view result query, and saved view CSV export endpoints. +* Added Explorer models, client registration, comprehensive unit tests, and end-to-end example usage. +* Refactored Explorer resource helpers for organization and saved-view id validation and for shared create/update attribute serialization (no API behavior change). +* Explorer: added structured logging (debug/info for operations and fallbacks) and `ValidationError` when create/read/update saved-view responses are not a valid json:api single-resource envelope. + # v0.1.3 ## Enhancements diff --git a/examples/explorer.py b/examples/explorer.py new file mode 100644 index 00000000..c75c854a --- /dev/null +++ b/examples/explorer.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +""" +================================================================================ + 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 +from pytfe.errors import TFEError +from pytfe.models import ( + ExplorerQueryOptions, + ExplorerSavedQuery, + ExplorerSavedQueryFilter, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, + 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: + """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. Export a valid API token before running this example." + ) + 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" + + # 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)) + + _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", + filters=[ + ExplorerUrlFilter( + index=0, + field="workspace_name", + operator="contains", + value="42", + ), + ], + ) + 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 {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" API error: {e}") + except Exception as e: + print(f" Error: {e}") + + # ------------------------------------------------------------------------- + # 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_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" API error: {e}") + except Exception as e: + print(f" Error: {e}") + + # ------------------------------------------------------------------------- + # 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): + 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" API error: {e}") + except Exception as e: + print(f" Error: {e}") + + if view_id: + # --------------------------------------------------------------------- + # 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(" 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" 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" 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" 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_lines( + "CSV preview:", + csv_sv, + max_chars=300, + max_lines=6, + ) + print("Summary: saved_view_results_csv completed.") + except TFEError as e: + 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: + _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}" + _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, + query=ExplorerSavedQuery( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", + operator="contains", + value=["test"], + ) + ], + ), + ) + # 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" 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( + query_type=ExplorerViewType.WORKSPACES, + filter=[ + ExplorerSavedQueryFilter( + field="workspace_name", + operator="contains", + value=["demo"], + ) + ], + ), + ) + # 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" 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" delete_saved_view: completed for id {deleted.id}") + print("Summary: mutation sequence finished.") + except TFEError as e: + print(f" API error: {e}") + sys.exit(1) + else: + _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(f"\n{_LINE}\nExample completed.\n{_LINE}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 30b506b9..cc642031 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) # 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 e913f6d4..40acad3d 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 a saved view id is missing or blank (Explorer view-scoped routes).""" + + def __init__(self, message: str = "invalid value for explorer saved view ID"): + super().__init__(message) + + class TerraformVersionValidForPlanOnlyError(ValidationError): """Raised when terraform_version is set without plan_only being true.""" 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..28335abf --- /dev/null +++ b/src/pytfe/models/explorer.py @@ -0,0 +1,123 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Pydantic models for the Explorer API (query options, rows, saved views). + +Aliases mirror JSON:API and Explorer query-string names (type, page[number], etc.). +""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ExplorerViewType(str, Enum): + """Explorer `type` / `query-type` discriminator (see product docs for supported views).""" + + WORKSPACES = "workspaces" + TF_VERSIONS = "tf_versions" + PROVIDERS = "providers" + MODULES = "modules" + RESOURCES = "resources" # Present when the deployment exposes a resources view. + + +class ExplorerUrlFilter(BaseModel): + """One slot in ExplorerQueryOptions.filters → filter[i][field][op][idx] query keys.""" + + index: int = Field(..., ge=0, description="Filter index in the query string") + field: str = Field( + ..., min_length=1, description="Explorer field name in snake_case" + ) + operator: str = Field(..., min_length=1, description="Explorer filter operator") + value: str = Field(..., description="Filter value") + value_index: int = Field( + 0, + ge=0, + description="Reserved index for filter value; currently expected as zero", + ) + + +class ExplorerQueryOptions(BaseModel): + """GET /organizations/{org}/explorer (and export/csv) query string as structured fields.""" + + model_config = ConfigDict(populate_by_name=True) + + view_type: ExplorerViewType = Field(..., alias="type") + sort: str | None = Field( + None, + description="Sort field (snake_case); prefix with '-' for descending order", + ) + fields: str | None = Field( + None, + description="Comma-separated list of fields to include in each row", + ) + page_number: int | None = Field(None, alias="page[number]", ge=1) + page_size: int | None = Field(None, alias="page[size]", ge=1, le=100) + filters: list[ExplorerUrlFilter] | None = Field( + None, + description="Expanded filter objects mapped to filter[index][field][operator][value_index]", + ) + + +class ExplorerRow(BaseModel): + """One Explorer result row: json:api id/type plus flat attributes for the view.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + row_type: str = Field(..., alias="type") + attributes: dict[str, Any] = Field(default_factory=dict) + + +class ExplorerSavedQueryFilter(BaseModel): + """One saved-view filter row (list-valued `value` matches create/update JSON).""" + + field: str = Field(..., min_length=1) + operator: str = Field(..., min_length=1) + value: list[str] = Field(default_factory=list) + + +class ExplorerSavedQuery(BaseModel): + """Nested query on a saved view: view type, filters, optional fields and sort lists.""" + + model_config = ConfigDict(populate_by_name=True) + + query_type: ExplorerViewType = Field(..., alias="type") + filter: list[ExplorerSavedQueryFilter] | None = None + fields: list[str] | None = None + sort: list[str] | None = None + + +class ExplorerSavedView(BaseModel): + """Saved view resource: metadata plus embedded query (response and some request paths).""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + name: str + created_at: datetime | None = Field(None, alias="created-at") + query: ExplorerSavedQuery = Field(...) + query_type: ExplorerViewType = Field(..., alias="query-type") + + +class ExplorerSavedViewCreateOptions(BaseModel): + """POST .../explorer/views attributes: display name, top-level query-type, nested query.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., min_length=1) + query_type: ExplorerViewType = Field(..., alias="query-type") + query: ExplorerSavedQuery + + +class ExplorerSavedViewUpdateOptions(BaseModel): + """PATCH .../explorer/views/{id} attributes: name and full replacement query.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(..., min_length=1) + query: ExplorerSavedQuery diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py new file mode 100644 index 00000000..1ea5bb64 --- /dev/null +++ b/src/pytfe/resources/explorer.py @@ -0,0 +1,467 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""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 + +from ..errors import ( + InvalidExplorerSavedViewIDError, + InvalidOrgError, + NotFound, + ServerError, + ValidationError, +) +from ..models.explorer import ( + ExplorerQueryOptions, + ExplorerRow, + ExplorerSavedView, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, +) +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, + exclude={"filters"}, + mode="json", + ) + 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 _saved_query_to_api_shape(raw_query: dict[str, Any]) -> dict[str, Any]: + """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): + 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]: + """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: + 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: + # 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", {}) + 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": _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"}, + } + ) + + +def _query_options_from_saved_view( + saved_view: ExplorerSavedView, +) -> 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: + 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: + """Union of row attribute keys as header; last-resort CSV when /views/.../csv is unavailable.""" + 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): + """Organization Explorer: ad hoc queries, CSV export, and saved view CRUD.""" + + def query( + self, organization: str, options: ExplorerQueryOptions + ) -> Iterator[ExplorerRow]: + _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: + _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]: + _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) + + def create_saved_view( + self, organization: str, options: ExplorerSavedViewCreateOptions + ) -> ExplorerSavedView: + _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", + "attributes": attrs, + } + } + path = f"/api/v2/organizations/{organization}/explorer/views" + resp = self.t.request("POST", path, json_body=body) + 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: + _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) + 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, + organization: str, + view_id: str, + options: ExplorerSavedViewUpdateOptions, + ) -> ExplorerSavedView: + _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", + "id": view_id, + "attributes": attrs, + } + } + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" + resp = self.t.request("PATCH", path, json_body=body) + 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: + _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]: + _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: + _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) 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) + 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 new file mode 100644 index 00000000..ec634bff --- /dev/null +++ b/tests/units/test_explorer.py @@ -0,0 +1,385 @@ +# 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, + NotFound, + ValidationError, +) +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"], + } + ], + }, + }, + } + + +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() + 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" + assert body["data"]["attributes"]["query"]["filter"] == [ + {"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")} + 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_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")} + 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" + 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") + assert view.id == "sq-1" + + mock_transport.request.assert_called_once_with( + "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")]} + 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" + ) + + 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): + 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)