diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e5164c..ad0ee002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Unreleased + # Released +# v1.0.0 + +## Features + +### Explorer API +* Added Explorer resource support with query, CSV export, saved view CRUD, saved view result query, and saved view CSV export endpoints. + + # v0.1.5 * `pytfe.__version__` added in src/pytfe/init.py via importlib.metadata.version("pytfe"). This will resolve to the version from pyproject.toml. diff --git a/examples/comment.py b/examples/comment.py new file mode 100644 index 00000000..59626ba6 --- /dev/null +++ b/examples/comment.py @@ -0,0 +1,72 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import CommentCreateOptions + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser(description="Comments demo for python-tfe SDK") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--run-id", required=True, help="Run ID (e.g. run-xxxxx)") + parser.add_argument("--create", action="store_true", help="Create a new comment") + parser.add_argument("--body", help="Comment body text (required with --create)") + parser.add_argument("--read", action="store_true", help="Read a specific comment") + parser.add_argument("--id", help="Comment ID (e.g. com-xxxxx), required for --read") + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) Always list existing comments for the run + _print_header(f"Listing comments for run: {args.run_id}") + comment_count = 0 + for comment in client.comments.list(run_id=args.run_id): + comment_count += 1 + print(f"- ID: {comment.id}") + print(f" Body: {comment.body}") + print() + + if comment_count == 0: + print("No comments found.") + else: + print(f"Total: {comment_count} comments") + + # 2) Create a new comment + if args.create: + if not args.body: + print("--body is required for --create") + else: + _print_header(f"Creating a comment on run: {args.run_id}") + opts = CommentCreateOptions(body=args.body) + comment = client.comments.create(run_id=args.run_id, options=opts) + print(f"Created comment: {comment.id}") + print(f" Body: {comment.body}") + + # 3) Read a specific comment + if args.read: + if not args.id: + print("--id is required for --read") + else: + _print_header(f"Reading comment: {args.id}") + comment = client.comments.read(comment_id=args.id) + print(f"ID: {comment.id}") + print(f"Body: {comment.body}") + + +if __name__ == "__main__": + main() diff --git a/examples/explorer.py b/examples/explorer.py new file mode 100644 index 00000000..50f27b57 --- /dev/null +++ b/examples/explorer.py @@ -0,0 +1,449 @@ +#!/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 values + match HashiCorp docs: workspaces, tf_versions, providers, modules. + 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=500, + 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 and returns None. + client.explorer.delete_saved_view(org, created.id) + print(f" delete_saved_view: completed for id {created.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/examples/organization_token.py b/examples/organization_token.py new file mode 100644 index 00000000..0573ef13 --- /dev/null +++ b/examples/organization_token.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Organization Token Operations Example + +Demonstrates usage of all 6 organization token operations: +1. create() - Create a new organization token, replacing any existing token +2. create_with_options() - Create with options like expiration date and token type +3. read() - Read the organization token +4. read_with_options() - Read with options like token type +5. delete() - Delete the organization token +6. delete_with_options() - Delete with options like token type + +Usage: +- Modify organization names as needed for your environment +- Ensure you have proper TFE credentials and organization access +- Organization tokens are used for organization-level API access + +Prerequisites: +- Set TFE_TOKEN and TFE_ADDRESS environment variables +- You need an existing organization or admin permissions to create one +- Appropriate permissions to manage organization tokens +""" + +from datetime import datetime, timedelta + +# Add the src directory to the path +##sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + OrganizationTokenCreateOptions, + OrganizationTokenDeleteOptions, + OrganizationTokenReadOptions, + TokenType, +) + + +def redact_token(token_value: str | None) -> str: + """Redact token value for safe display.""" + if not token_value: + return "None" + if len(token_value) <= 8: + return f"{'*' * len(token_value)}" + # Show first 3 and last 3 characters + return f"{token_value[:3]}...{token_value[-3:]}".replace( + token_value[3:-3], "*" * (len(token_value) - 6) + ) + + +def redact_id(id_value: str | None) -> str: + """Redact ID for safe display.""" + if not id_value: + return "None" + if len(id_value) <= 6: + return f"{'*' * len(id_value)}" + # Show first 3 and last 3 characters + return f"{id_value[:3]}...{id_value[-3:]}" + + +def main(): + """Execute organization token operation examples.""" + + print("=" * 80) + print("ORGANIZATION TOKEN OPERATIONS") + print("=" * 80) + + # Initialize the TFE client + client = TFEClient(TFEConfig.from_env()) + organization_name = "prab-sandbox02" + # ===================================================== + # 1. CREATE ORGANIZATION TOKEN (BASIC) + # ===================================================== + print("\n1. create() - Create a new organization token:") + print("-" * 40) + try: + print(f"Creating token for organization: {organization_name}") + token = client.organization_tokens.create(organization_name) + + print("Token created successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Created At: {token.created_at}") + print(f" Description: {token.description}") + print(f" Token Value: {redact_token(token.token)}") + if token.expired_at: + print(f" Expires At: {token.expired_at}") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + # 2. CREATE WITH OPTIONS (WITH EXPIRATION) + # ===================================================== + print("2. create_with_options() - Create token with expiration date:") + print("-" * 40) + try: + # Create a token that expires in 30 days + expiry_date = datetime.utcnow() + timedelta(days=30) + options = OrganizationTokenCreateOptions(expired_at=expiry_date) + + print(f"Creating organization token with expiration date: {expiry_date}") + token = client.organization_tokens.create_with_options( + organization_name, options + ) + + print("Token created with options successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Created At: {token.created_at}") + if token.expired_at: + print(f" Expires At: {token.expired_at}") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + print("3. create_with_options() - Create audit-trails token:") + print("-" * 40) + try: + options = OrganizationTokenCreateOptions(token_type=TokenType.AUDIT_TRAILS) + + print(f"Creating audit-trails token for organization: {organization_name}") + token = client.organization_tokens.create_with_options( + organization_name, options + ) + + print(" Audit-trails token created successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Token Value: {redact_token(token.token)}") + print() + + except Exception as e: + print(f"Error: {e}") + print() + + # ===================================================== + print("4. read() - Read the organization token:") + print("-" * 40) + try: + print(f"Reading organization token for organization: {organization_name}") + token = client.organization_tokens.read(organization_name) + + print("Token read successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Created At: {token.created_at}") + print(f" Description: {token.description}") + if token.last_used_at: + print(f" Last Used At: {token.last_used_at}") + if token.expired_at: + print(f" Expires At: {token.expired_at}") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + print("5. read_with_options() - Read audit-trails token:") + print("-" * 40) + try: + options = OrganizationTokenReadOptions(token_type=TokenType.AUDIT_TRAILS) + + print(f"Reading audit-trails token for organization: {organization_name}") + token = client.organization_tokens.read_with_options(organization_name, options) + + print(" Audit-trails token read successfully!") + print(f" Token ID: {redact_id(token.id)}") + print(f" Token Value: {redact_token(token.token)}") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + print("6. delete() - Delete the organization token:") + print("-" * 40) + try: + print(f"Deleting organization token for organization: {organization_name}") + client.organization_tokens.delete(organization_name) + + print(" Token deleted successfully!") + print() + + except Exception as e: + print(f" Error: {e}") + print() + + # ===================================================== + print("7. delete_with_options() - Delete audit-trails token:") + print("-" * 40) + try: + options = OrganizationTokenDeleteOptions(token_type=TokenType.AUDIT_TRAILS) + + print(f"Deleting audit-trails token for organization: {organization_name}") + client.organization_tokens.delete_with_options(organization_name, options) + + print(" Audit-trails token deleted successfully!") + print() + + except Exception as e: + print(f"Error: {e}") + print() + + print("=" * 80) + print("ORGANIZATION TOKEN OPERATIONS COMPLETED") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/examples/registry_provider_platform.py b/examples/registry_provider_platform.py new file mode 100644 index 00000000..a6cf01ac --- /dev/null +++ b/examples/registry_provider_platform.py @@ -0,0 +1,236 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + RegistryProviderPlatformCreateOptions, + RegistryProviderPlatformID, + RegistryProviderPlatformListOptions, + RegistryProviderVersionID, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="Registry Provider Platforms demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--organization", required=True, help="Organization name") + parser.add_argument( + "--registry-name", + default="private", + help="Registry name (default: private)", + ) + parser.add_argument("--namespace", required=True, help="Provider namespace") + parser.add_argument("--name", required=True, help="Provider name") + parser.add_argument( + "--version", required=True, help="Provider version (e.g., 1.0.0)" + ) + parser.add_argument( + "--page-size", + type=int, + default=100, + help="Page size for listing platforms", + ) + parser.add_argument("--create", action="store_true", help="Create a platform") + parser.add_argument("--read", action="store_true", help="Read a specific platform") + parser.add_argument( + "--delete", action="store_true", help="Delete a specific platform" + ) + parser.add_argument( + "--os", dest="os", help="Operating system (e.g., linux, darwin)" + ) + parser.add_argument("--arch", help="Architecture (e.g., amd64, arm64)") + parser.add_argument("--shasum", help="SHA256 checksum of the provider binary") + parser.add_argument("--filename", help="Filename of the provider binary zip") + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + version_id = RegistryProviderVersionID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + version=args.version, + ) + + # 1) List all platforms for the provider version + _print_header( + f"Listing platforms for {args.registry_name}/{args.namespace}/{args.name} @ {args.version}" + ) + + list_options = RegistryProviderPlatformListOptions(page_size=args.page_size) + + platform_count = 0 + for platform in client.registry_provider_platforms.list( + version_id=version_id, + options=list_options, + ): + platform_count += 1 + print(f"- Platform {platform.os}/{platform.arch} (ID: {platform.id})") + print(f" Filename: {platform.filename}") + print(f" Shasum: {platform.shasum}") + print(f" Provider Binary Uploaded: {platform.provider_binary_uploaded}") + if platform.permissions: + print(" Permissions:") + print(f" Can Delete: {platform.permissions.can_delete}") + print(f" Can Upload Asset: {platform.permissions.can_upload_asset}") + if platform.links: + print(" Links:") + for key, value in platform.links.items(): + print(f" {key}: {value}") + print() + + if platform_count == 0: + print("No platforms found.") + else: + print(f"Total: {platform_count} platforms") + + # 2) Create a new platform (if --create flag is provided) + if args.create: + if not args.os: + print("Error: --os is required for create operation") + return + if not args.arch: + print("Error: --arch is required for create operation") + return + if not args.shasum: + print("Error: --shasum is required for create operation") + return + if not args.filename: + print("Error: --filename is required for create operation") + return + + _print_header(f"Creating platform: {args.os}/{args.arch}") + + create_options = RegistryProviderPlatformCreateOptions( + os=args.os, + arch=args.arch, + shasum=args.shasum, + filename=args.filename, + ) + + new_platform = client.registry_provider_platforms.create( + version_id=version_id, + options=create_options, + ) + + print(f"Created platform: {new_platform.id}") + print(f" OS: {new_platform.os}") + print(f" Arch: {new_platform.arch}") + print(f" Filename: {new_platform.filename}") + print(f" Shasum: {new_platform.shasum}") + print(f" Provider Binary Uploaded: {new_platform.provider_binary_uploaded}") + + if new_platform.links: + print("\n Upload URLs:") + if "provider-binary-upload" in new_platform.links: + print( + f" Provider Binary: {new_platform.links['provider-binary-upload']}" + ) + + # 3) Read a specific platform (if --read flag is provided) + if args.read: + if not args.os: + print("Error: --os is required for read operation") + return + if not args.arch: + print("Error: --arch is required for read operation") + return + + _print_header(f"Reading platform: {args.os}/{args.arch}") + + platform_id = RegistryProviderPlatformID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + version=args.version, + os=args.os, + arch=args.arch, + ) + + platform = client.registry_provider_platforms.read(platform_id) + + print(f"Platform ID: {platform.id}") + print(f" OS: {platform.os}") + print(f" Arch: {platform.arch}") + print(f" Filename: {platform.filename}") + print(f" Shasum: {platform.shasum}") + print(f" Provider Binary Uploaded: {platform.provider_binary_uploaded}") + + if platform.permissions: + print(" Permissions:") + print(f" Can Delete: {platform.permissions.can_delete}") + print(f" Can Upload Asset: {platform.permissions.can_upload_asset}") + + if platform.links: + print(" Links:") + for key, value in platform.links.items(): + print(f" {key}: {value}") + + # 4) Delete a platform (if --delete flag is provided) + if args.delete: + if not args.os: + print("Error: --os is required for delete operation") + return + if not args.arch: + print("Error: --arch is required for delete operation") + return + + _print_header(f"Deleting platform: {args.os}/{args.arch}") + + platform_id = RegistryProviderPlatformID( + organization_name=args.organization, + registry_name=args.registry_name, + namespace=args.namespace, + name=args.name, + version=args.version, + os=args.os, + arch=args.arch, + ) + + try: + platform_to_delete = client.registry_provider_platforms.read(platform_id) + print("Platform to delete:") + print(f" ID: {platform_to_delete.id}") + print(f" OS/Arch: {platform_to_delete.os}/{platform_to_delete.arch}") + print(f" Filename: {platform_to_delete.filename}") + except Exception as e: + print(f"Error reading platform: {e}") + return + + client.registry_provider_platforms.delete(platform_id) + print(f"\n Successfully deleted platform: {args.os}/{args.arch}") + + # List remaining platforms + _print_header("Listing platforms after deletion") + remaining_count = 0 + for platform in client.registry_provider_platforms.list(version_id=version_id): + remaining_count += 1 + print(f"- {platform.os}/{platform.arch} (ID: {platform.id})") + + if remaining_count == 0: + print("No platforms remaining.") + else: + print(f"Total remaining: {remaining_count} platforms") + + +if __name__ == "__main__": + main() diff --git a/examples/stack.py b/examples/stack.py new file mode 100644 index 00000000..6e8546e8 --- /dev/null +++ b/examples/stack.py @@ -0,0 +1,224 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models.agent import AgentPool +from pytfe.models.project import Project +from pytfe.models.stack import ( + StackCreateOptions, + StackListOptions, + StackSortColumn, + StackUpdateOptions, + StackVcsRepoOptions, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def _print_stack(item): + print(f"- id: {item.id}") + print(f"- name: {item.name}") + print(f"- description: {item.description}") + print(f"- created_at: {item.created_at}") + print(f"- updated_at: {item.updated_at}") + print(f"- speculation_enabled: {item.speculation_enabled}") + print(f"- project_id: {item.project.id if item.project else None}") + print(f"- agent_pool_id: {item.agent_pool.id if item.agent_pool else None}") + + if item.vcs_repo: + print("- vcs_repo:") + print(f" identifier={item.vcs_repo.identifier}") + print(f" branch={item.vcs_repo.branch}") + print(f" github_app_installation_id={item.vcs_repo.gha_installation_id}") + print(f" oauth_token_id={item.vcs_repo.oauth_token_id}") + + +def _build_vcs_repo_options(args) -> StackVcsRepoOptions | None: + if not args.vcs_identifier: + return None + + return StackVcsRepoOptions( + identifier=args.vcs_identifier, + branch=args.vcs_branch, + gha_installation_id=args.vcs_github_app_installation_id, + oauth_token_id=args.vcs_oauth_token_id, + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Stacks operations demo for python-tfe" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--organization", help="Organization name (required for list)") + parser.add_argument( + "--operation", + required=True, + choices=["create", "read", "update", "list", "delete", "force-delete"], + help="Operation to execute", + ) + + parser.add_argument( + "--stack-id", help="Stack ID (required for read/update/delete/force-delete)" + ) + + parser.add_argument("--name", help="Stack name (required for create)") + parser.add_argument("--description", help="Stack description") + parser.add_argument( + "--speculation-enabled", + type=lambda v: str(v).lower() in ("1", "true", "yes", "y"), + default=None, + help="Enable speculation (true/false)", + ) + + parser.add_argument( + "--project-id", + help="Project ID (required for create, optional for list filter)", + ) + parser.add_argument( + "--agent-pool-id", help="Agent pool ID (optional for create/update)" + ) + + parser.add_argument( + "--vcs-identifier", + help="VCS repo identifier (e.g. org/repo), optional for create/update", + ) + parser.add_argument("--vcs-branch", help="VCS branch") + parser.add_argument( + "--vcs-github-app-installation-id", + help="GitHub App installation ID for VCS repo", + ) + parser.add_argument("--vcs-oauth-token-id", help="OAuth token ID for VCS repo") + + parser.add_argument("--page-size", type=int, default=20, help="Page size for list") + parser.add_argument( + "--sort", + choices=[item.value for item in StackSortColumn], + default=None, + help="Sort column for list", + ) + parser.add_argument( + "--search-name", + default=None, + help="Search stacks by name", + ) + + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + if args.operation == "create": + if not args.name: + parser.error("--name is required for operation=create") + if not args.project_id: + parser.error("--project-id is required for operation=create") + + _print_header("Creating stack") + options = StackCreateOptions( + name=args.name, + description=args.description, + speculation_enabled=args.speculation_enabled, + vcs_repo=_build_vcs_repo_options(args), + project=Project(id=args.project_id), + agent_pool=AgentPool(id=args.agent_pool_id) if args.agent_pool_id else None, + ) + result = client.stacks.create(options) + print("Created stack") + _print_stack(result) + return + + if args.operation == "read": + if not args.stack_id: + parser.error("--stack-id is required for operation=read") + + _print_header("Reading stack") + result = client.stacks.read(args.stack_id) + print("Retrieved stack") + _print_stack(result) + return + + if args.operation == "update": + if not args.stack_id: + parser.error("--stack-id is required for operation=update") + if not any( + [ + args.name, + args.description, + args.speculation_enabled is not None, + args.agent_pool_id, + args.vcs_identifier, + args.vcs_branch, + args.vcs_github_app_installation_id, + args.vcs_oauth_token_id, + args.project_id, + ] + ): + parser.error("Provide at least one field to update") + + _print_header("Updating stack") + options = StackUpdateOptions( + name=args.name, + description=args.description, + speculation_enabled=args.speculation_enabled, + vcs_repo=_build_vcs_repo_options(args), + agent_pool=AgentPool(id=args.agent_pool_id) if args.agent_pool_id else None, + project=Project(id=args.project_id) if args.project_id else None, + ) + result = client.stacks.update(args.stack_id, options) + print("Updated stack") + _print_stack(result) + return + + if args.operation == "list": + if not args.organization: + parser.error("--organization is required for operation=list") + + _print_header("Listing stacks") + list_options = StackListOptions( + page_size=args.page_size, + project_id=args.project_id, + sort=StackSortColumn(args.sort) if args.sort else None, + search_by_name=args.search_name, + ) + + items = list(client.stacks.list(args.organization, list_options)) + print(f"Found {len(items)} stacks") + for item in items: + print("-") + _print_stack(item) + return + + if args.operation == "delete": + if not args.stack_id: + parser.error("--stack-id is required for operation=delete") + + _print_header("Deleting stack") + client.stacks.delete(args.stack_id) + print(f"Deleted stack: {args.stack_id}") + return + + if args.operation == "force-delete": + if not args.stack_id: + parser.error("--stack-id is required for operation=force-delete") + + _print_header("Force deleting stack") + client.stacks.force_delete(args.stack_id) + print(f"Force deleted stack: {args.stack_id}") + return + + +if __name__ == "__main__": + main() diff --git a/examples/stack_configuration.py b/examples/stack_configuration.py new file mode 100644 index 00000000..d51ace60 --- /dev/null +++ b/examples/stack_configuration.py @@ -0,0 +1,116 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + StackConfigurationCreateOptions, + StackConfigurationListOptions, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser( + description="Stack Configurations demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--stack-id", required=True, help="Stack ID (e.g. st-xxxxx)") + parser.add_argument( + "--page-size", + type=int, + default=100, + help="Page size for listing configurations", + ) + parser.add_argument( + "--create", action="store_true", help="Create a new stack configuration" + ) + parser.add_argument( + "--speculative", + action="store_true", + help="Mark created configuration as speculative", + ) + parser.add_argument( + "--read", action="store_true", help="Read a specific stack configuration" + ) + parser.add_argument( + "--upload-url", + action="store_true", + help="Fetch the upload URL for a stack configuration", + ) + parser.add_argument( + "--fetch-from-vcs", + action="store_true", + help="Trigger fetch of latest config from VCS", + ) + parser.add_argument("--id", help="Stack configuration ID (e.g. stc-xxxxx)") + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) Always list existing stack configurations + _print_header(f"Listing stack configurations for stack: {args.stack_id}") + options = StackConfigurationListOptions(page_size=args.page_size) + config_count = 0 + for config in client.stack_configurations.list( + stack_id=args.stack_id, options=options + ): + config_count += 1 + print(f"- ID: {config.id}") + print(f" Status: {config.status.value if config.status else None}") + print(f" Sequence: {config.sequence_number}") + print(f" Speculative: {config.speculative}") + print(f" Created: {config.created_at}") + print(f" Updated: {config.updated_at}") + print() + + if config_count == 0: + print("No stack configurations found.") + else: + print(f"Total: {config_count} stack configurations") + + # 2) Create a new stack configuration + if args.create: + _print_header("Creating a new stack configuration") + create_opts = StackConfigurationCreateOptions( + speculative_enabled=args.speculative + ) + config = client.stack_configurations.create( + stack_id=args.stack_id, options=create_opts + ) + print(f"Created stack configuration: {config.id}") + print(f" Status: {config.status.value if config.status else None}") + print(f" Speculative: {config.speculative}") + print(f" Sequence: {config.sequence_number}") + print(f" Created: {config.created_at}") + + # 3) Read a specific stack configuration + if args.read: + if not args.id: + print("--id is required for --read") + else: + _print_header(f"Reading stack configuration: {args.id}") + config = client.stack_configurations.read(stack_configuration_id=args.id) + print(f"ID: {config.id}") + print(f"Status: {config.status.value if config.status else None}") + print(f"Sequence: {config.sequence_number}") + print(f"Speculative: {config.speculative}") + print(f"Created: {config.created_at}") + print(f"Updated: {config.updated_at}") + + +if __name__ == "__main__": + main() diff --git a/examples/state_versions.py b/examples/state_versions.py index 61187619..8efdfd52 100644 --- a/examples/state_versions.py +++ b/examples/state_versions.py @@ -4,6 +4,8 @@ from __future__ import annotations import argparse +import hashlib +import json import os from pathlib import Path @@ -14,7 +16,9 @@ StateVersionCurrentOptions, StateVersionListOptions, StateVersionOutputsListOptions, + StateVersionReadOptions, ) +from pytfe.models.workspace import WorkspaceLockOptions def _print_header(title: str): @@ -23,6 +27,44 @@ def _print_header(title: str): print("=" * 80) +def _install_debug_hook(client: TFEClient, token: str) -> None: + """ + Wrap the transport's request() to print every URL and its headers. + The Authorization token value is masked so it is safe to share output. + """ + transport = client.state_versions.t + original_request = transport.request + + def _debug_request(method, path, **kwargs): + use_defaults = kwargs.get("use_default_headers", True) + extra_headers = kwargs.get("headers") or {} + + # Reconstruct exactly what the transport will send + if use_defaults: + sent_headers = dict(transport.headers) + sent_headers.update(extra_headers) + else: + sent_headers = dict(extra_headers) + + # Mask the bearer token so it is safe to print + display_headers = {} + for k, v in sent_headers.items(): + if k.lower() == "authorization": + masked = v[:14] + "***" + v[-4:] if len(v) > 18 else "***" + display_headers[k] = masked + else: + display_headers[k] = v + + url = transport._build_url(path) + print(f"\n [DEBUG] {method} {url}") + for k, v in display_headers.items(): + print(f" {k}: {v}") + + return original_request(method, path, **kwargs) + + transport.request = _debug_request + + def main(): parser = argparse.ArgumentParser( description="State Versions demo for python-tfe SDK" @@ -34,55 +76,80 @@ def main(): parser.add_argument("--org", required=True, help="Organization name") parser.add_argument("--workspace", required=True, help="Workspace name") parser.add_argument("--workspace-id", required=True, help="Workspace ID") - parser.add_argument("--download", help="Path to save downloaded current state") - parser.add_argument("--upload", help="Path to a .tfstate (or JSON state) to upload") + parser.add_argument( + "--download", help="Optional path to save downloaded current state" + ) + parser.add_argument( + "--upload", + help="Optional path to a .tfstate JSON to upload (defaults to current state with serial bumped by 1)", + ) + parser.add_argument( + "--skip-upload", + action="store_true", + help="Skip the upload demo (upload requires locking the workspace).", + ) + parser.add_argument( + "--demo-backing-data", + action="store_true", + help="Exercise TFE-only soft_delete/restore backing-data actions on the newly uploaded SV.", + ) parser.add_argument("--page-size", type=int, default=10) + parser.add_argument( + "--debug", + action="store_true", + help="Print every request URL and headers (token masked).", + ) args = parser.parse_args() cfg = TFEConfig(address=args.address, token=args.token) client = TFEClient(cfg) - options = StateVersionListOptions( - page_size=args.page_size, - organization=args.org, - workspace=args.workspace, - ) + if args.debug: + _install_debug_hook(client, args.token) - sv_list = list(client.state_versions.list(options)) - print(f"Total state versions: {len(sv_list)}") - print() - - for sv in sv_list: - print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") - - # 1) List all state versions across org and workspace filters - _print_header("Org-scoped listing via /api/v2/state-versions (first page)") - all_sv = client.state_versions.list( - StateVersionListOptions( - organization=args.org, workspace=args.workspace, page_size=args.page_size + # 1) List state versions filtered by org + workspace + _print_header("Listing state versions (filter[organization]+filter[workspace])") + sv_list = list( + client.state_versions.list( + StateVersionListOptions( + page_size=args.page_size, + organization=args.org, + workspace=args.workspace, + ) ) ) - for sv in all_sv: + print(f"Total state versions returned: {len(sv_list)}") + for sv in sv_list: print(f"- {sv.id} | status={sv.status} | created_at={sv.created_at}") - # 2) Read the current state version (with outputs included if you want) - _print_header("Reading current state version") + # 2) Read the current state version with include=outputs + _print_header("read_current_with_options(include=outputs)") current = client.state_versions.read_current_with_options( args.workspace_id, StateVersionCurrentOptions(include=["outputs"]) ) - print( - f"Current SV: {current.id} status={current.status} durl={current.hosted_state_download_url}" + print(f"Current SV: {current.id} status={current.status}") + print(f" download_url: {current.hosted_state_download_url}") + print(f" json_download_url: {current.hosted_json_state_download_url}") + + # 3) Read by ID, with and without include options + _print_header("read(sv_id) and read_with_options(sv_id, include=[run,outputs])") + sv_read = client.state_versions.read(current.id) + print(f"read(): id={sv_read.id} serial={sv_read.serial}") + sv_read_opts = client.state_versions.read_with_options( + current.id, StateVersionReadOptions(include=["run", "outputs"]) ) + print(f"read_with_options(): id={sv_read_opts.id} serial={sv_read_opts.serial}") - # 3) (Optional) Download the current state (optional) + # 4) Download current state bytes + _print_header("download(current_sv_id)") + raw_current = client.state_versions.download(current.id) + print(f"Downloaded {len(raw_current)} bytes of state") if args.download: - _print_header(f"Downloading current state to: {args.download}") - raw = client.state_versions.download(current.id) - Path(args.download).write_bytes(raw) - print(f"Wrote {len(raw)} bytes to {args.download}") + Path(args.download).write_bytes(raw_current) + print(f" wrote bytes to {args.download}") - # 4) List outputs for the current state version (paged) - _print_header("Listing outputs (current state version)") + # 5) List outputs (by SV and via workspace shortcut) + _print_header("list_outputs(current_sv_id)") outs = list( client.state_versions.list_outputs( current.id, options=StateVersionOutputsListOptions(page_size=50) @@ -91,40 +158,101 @@ def main(): if not outs: print("No outputs found.") for o in outs: - # Sensitive outputs will have value = None print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") - if args.workspace_id: - # 4b) List outputs for the current state version via workspace endpoint - _print_header("Listing outputs via workspace endpoint") - outs2 = list( - client.state_version_outputs.read_current( - args.workspace_id, options=StateVersionOutputsListOptions(page_size=50) - ) + _print_header("state_version_outputs.read_current(workspace_id)") + outs2 = list( + client.state_version_outputs.read_current( + args.workspace_id, options=StateVersionOutputsListOptions(page_size=50) ) - if not outs2: - print("No outputs found.") - for o in outs2: - print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") + ) + if not outs2: + print("No outputs found.") + for o in outs2: + print(f"- {o.name}: sensitive={o.sensitive} type={o.type} value={o.value}") + + # 6) Upload demo: requires the workspace to be locked. + if args.skip_upload: + _print_header("Skipping upload demo (--skip-upload)") + return - # 5) (Optional) Upload a new state file + _print_header("upload(workspace_id, raw_state=..., options=...)") if args.upload: - _print_header(f"Uploading new state from: {args.upload}") payload = Path(args.upload).read_bytes() + print(f"Using user-provided payload from {args.upload} ({len(payload)} bytes)") + else: + try: + state_obj = json.loads(raw_current.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + print(f"Could not parse current state as JSON; skip upload: {e}") + return + state_obj["serial"] = int(state_obj.get("serial", 0)) + 1 + payload = json.dumps(state_obj).encode("utf-8") + print( + f"Synthesized payload from current state with serial bumped to " + f"{state_obj['serial']} ({len(payload)} bytes)" + ) + + try: + state_obj = json.loads(payload.decode("utf-8")) + serial = int(state_obj["serial"]) + lineage = state_obj.get("lineage") + except (KeyError, ValueError, json.JSONDecodeError) as e: + print(f"Upload input must be valid Terraform state JSON with a serial: {e}") + return + + md5 = hashlib.md5(payload).hexdigest() # nosec B324 + + locked = False + try: + client.workspaces.lock( + args.workspace_id, + WorkspaceLockOptions(reason="python-tfe state_versions example"), + ) + locked = True + print(f"Locked workspace {args.workspace_id}") + except Exception as e: + print(f"Could not lock workspace (continuing without lock): {e}") + + new_sv = None + try: + new_sv = client.state_versions.upload( + args.workspace_id, + raw_state=payload, + options=StateVersionCreateOptions( + serial=serial, + md5=md5, + lineage=lineage, + ), + ) + print( + f"Uploaded new SV: {new_sv.id} status={new_sv.status} serial={new_sv.serial}" + ) + except ErrStateVersionUploadNotSupported as e: + print(f"Upload not supported on this server: {e}") + except Exception as e: + print(f"Upload failed: {e}") + finally: + if locked: + try: + client.workspaces.unlock(args.workspace_id) + print(f"Unlocked workspace {args.workspace_id}") + except Exception as e: + print(f"Failed to unlock workspace: {e}") + + # 7) Optional: exercise TFE-only backing data actions on the new SV + if args.demo_backing_data and new_sv is not None: + _print_header("TFE-only backing data actions on the new SV") try: - # If your server supports signed uploads, this will: - # a) create SV (to get upload URL) - # b) PUT bytes to the signed URL - # c) read back the SV to return a hydrated object - new_sv = client.state_versions.upload( - args.workspace_id, - raw_state=payload, - options=StateVersionCreateOptions(), + client.state_versions.soft_delete_backing_data(new_sv.id) + print("soft_delete_backing_data: OK") + client.state_versions.restore_backing_data(new_sv.id) + print("restore_backing_data: OK") + print("(skipping permanently_delete_backing_data — irreversible)") + except Exception as e: + print( + f"Backing-data actions not available (likely HCP Terraform, not TFE): {e}" ) - print(f"Uploaded new SV: {new_sv.id} status={new_sv.status}") - except ErrStateVersionUploadNotSupported as e: - # Some older/self-hosted versions don’t support direct upload - print(f"Upload not supported on this server: {e}") if __name__ == "__main__": diff --git a/examples/team.py b/examples/team.py new file mode 100644 index 00000000..5615a8b8 --- /dev/null +++ b/examples/team.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + TeamCreateOptions, + TeamIncludeOpt, + TeamListOptions, + TeamUpdateOptions, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main(): + parser = argparse.ArgumentParser(description="Teams list demo for python-tfe SDK") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument( + "--org", + required=True, + help="Organization name", + ) + parser.add_argument( + "--page-size", + type=int, + default=20, + help="Page size for fetching teams", + ) + parser.add_argument( + "--query", + default=None, + help="Optional q filter for team search", + ) + parser.add_argument( + "--names", + nargs="+", + default=None, + help="Optional team names filter (space-separated)", + ) + parser.add_argument( + "--include-users", + action="store_true", + help="Include related users", + ) + parser.add_argument( + "--include-memberships", + action="store_true", + help="Include related organization-memberships", + ) + parser.add_argument( + "--create", + action="store_true", + help="Create a new team before listing", + ) + parser.add_argument( + "--name", + default=None, + help="Team name for create operation", + ) + parser.add_argument( + "--visibility", + default="secret", + help="Team visibility for create operation (secret or organization)", + ) + parser.add_argument( + "--sso-team-id", + default=None, + help="Optional SSO team ID for create operation", + ) + parser.add_argument( + "--allow-member-token-management", + action="store_true", + help="Enable member token management on create/update", + ) + parser.add_argument( + "--update", + action="store_true", + help="Update a team before listing", + ) + parser.add_argument( + "--read", + action="store_true", + help="Read a team by ID before listing", + ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete a team by ID before listing", + ) + parser.add_argument( + "--team-id", + default=None, + help="Team ID for read/update/delete operation", + ) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + if args.create: + if not args.name: + print("Error: --name is required when using --create") + return + + _print_header(f"Creating team in organization: {args.org}") + create_options = TeamCreateOptions( + name=args.name, + visibility=args.visibility, + sso_team_id=args.sso_team_id, + allow_member_token_management=args.allow_member_token_management, + ) + new_team = client.teams.create(args.org, create_options) + print(f"Created Team ID: {new_team.id}") + print(f"Name: {new_team.name}") + print(f"Visibility: {new_team.visibility}") + print( + f"Allow Member Token Management: {new_team.allow_member_token_management}" + ) + print() + + if args.update: + if not args.team_id: + print("Error: --team-id is required when using --update") + return + + _print_header(f"Updating team: {args.team_id}") + update_options = TeamUpdateOptions( + name=args.name, + visibility=args.visibility, + sso_team_id=args.sso_team_id, + allow_member_token_management=args.allow_member_token_management, + ) + updated_team = client.teams.update(args.team_id, update_options) + print(f"Updated Team ID: {updated_team.id}") + print(f"Name: {updated_team.name}") + print(f"Visibility: {updated_team.visibility}") + print( + f"Allow Member Token Management: {updated_team.allow_member_token_management}" + ) + print() + + if args.read: + if not args.team_id: + print("Error: --team-id is required when using --read") + return + + _print_header(f"Reading team: {args.team_id}") + team = client.teams.read(args.team_id) + print(f"Team ID: {team.id}") + print(f"Name: {team.name}") + print(f"Visibility: {team.visibility}") + print(f"Is Unified: {team.is_unified}") + print(f"User Count: {team.user_count}") + print(f"Allow Member Token Management: {team.allow_member_token_management}") + + if team.organization_access: + print("Organization Access:") + print(f" - manage_workspaces={team.organization_access.manage_workspaces}") + print(f" - read_workspaces={team.organization_access.read_workspaces}") + print(f" - manage_projects={team.organization_access.manage_projects}") + + if team.permissions: + print("Permissions:") + print(f" - can_update_membership={team.permissions.can_update_membership}") + print(f" - can_destroy={team.permissions.can_destroy}") + + print(f"Users included: {len(team.users)}") + print( + f"Organization memberships included: {len(team.organization_memberships)}" + ) + print() + + if args.delete: + if not args.team_id: + print("Error: --team-id is required when using --delete") + return + + _print_header(f"Deleting team: {args.team_id}") + client.teams.delete(args.team_id) + print(f"Deleted Team ID: {args.team_id}") + print() + + includes: list[TeamIncludeOpt] = [] + if args.include_users: + includes.append(TeamIncludeOpt.TEAM_USERS) + if args.include_memberships: + includes.append(TeamIncludeOpt.TEAM_ORGANIZATION_MEMBERSHIPS) + + options = TeamListOptions( + page_size=args.page_size, + query=args.query, + names=args.names, + include=includes or None, + ) + + _print_header(f"Listing teams for organization: {args.org}") + print("Options:") + print(f"- page_size={args.page_size}") + print(f"- query={args.query}") + print(f"- names={args.names}") + print(f"- include={[item.value for item in includes] if includes else None}") + print() + + count = 0 + for team in client.teams.list(args.org, options): + count += 1 + print(f"[{count}] Team ID: {team.id}") + print(f"Name: {team.name}") + print(f"Visibility: {team.visibility}") + print(f"Is Unified: {team.is_unified}") + print(f"User Count: {team.user_count}") + print(f"Allow Member Token Management: {team.allow_member_token_management}") + + if team.organization_access: + print("Organization Access:") + print(f" - manage_workspaces={team.organization_access.manage_workspaces}") + print(f" - read_workspaces={team.organization_access.read_workspaces}") + print(f" - manage_projects={team.organization_access.manage_projects}") + + if team.permissions: + print("Permissions:") + print(f" - can_update_membership={team.permissions.can_update_membership}") + print(f" - can_destroy={team.permissions.can_destroy}") + + print(f"Users included: {len(team.users)}") + print( + f"Organization memberships included: {len(team.organization_memberships)}" + ) + print() + + if count == 0: + print("No teams found.") + else: + print(f"Total teams: {count}") + + +if __name__ == "__main__": + main() diff --git a/examples/team_project_access.py b/examples/team_project_access.py new file mode 100644 index 00000000..b91b1942 --- /dev/null +++ b/examples/team_project_access.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models.project import Project +from pytfe.models.team import Team +from pytfe.models.team_project_access import ( + ProjectSettingsPermissionType, + ProjectTeamsPermissionType, + ProjectVariableSetsPermissionType, + TeamProjectAccessAddOptions, + TeamProjectAccessListOptions, + TeamProjectAccessProjectPermissionsOptions, + TeamProjectAccessType, + TeamProjectAccessUpdateOptions, + TeamProjectAccessWorkspacePermissionsOptions, + WorkspaceRunsPermissionType, + WorkspaceSentinelMocksPermissionType, + WorkspaceStateVersionsPermissionType, + WorkspaceVariablesPermissionType, +) + + +def _print_header(title: str): + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def _print_team_project_access(result): + print(f"- id: {result.id}") + print(f"- access: {result.access.value if result.access else None}") + print(f"- team_id: {result.team.id if result.team else None}") + print(f"- project_id: {result.project.id if result.project else None}") + + if result.project_access: + print("- project_access:") + print(f" settings={result.project_access.project_settings_permission.value}") + print(f" teams={result.project_access.project_teams_permission.value}") + print( + " variable_sets=" + f"{result.project_access.project_variable_sets_permission.value}" + ) + + if result.workspace_access: + print("- workspace_access:") + print( + f" runs={result.workspace_access.runs.value if result.workspace_access.runs else None}" + ) + print( + " sentinel_mocks=" + f"{result.workspace_access.sentinel_mocks.value if result.workspace_access.sentinel_mocks else None}" + ) + print( + " state_versions=" + f"{result.workspace_access.state_versions.value if result.workspace_access.state_versions else None}" + ) + print( + f" variables={result.workspace_access.variables.value if result.workspace_access.variables else None}" + ) + print(f" create={result.workspace_access.create}") + print(f" delete={result.workspace_access.delete}") + print(f" locking={result.workspace_access.locking}") + print(f" move={result.workspace_access.move}") + print(f" run_tasks={result.workspace_access.run_tasks}") + + +def main(): + parser = argparse.ArgumentParser( + description="Team Project Access operations demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument( + "--operation", + required=True, + choices=["add", "read", "update", "list", "remove"], + help="Operation to execute", + ) + parser.add_argument("--team-id", help="Team ID (required for add)") + parser.add_argument("--project-id", help="Project ID (required for add/list)") + parser.add_argument( + "--team-project-access-id", + help="Team Project Access ID (required for read/update/remove)", + ) + parser.add_argument( + "--page-size", + type=int, + default=20, + help="Page size for list operation", + ) + parser.add_argument( + "--access", + choices=[item.value for item in TeamProjectAccessType], + default=None, + help="Access level (required as custom when granular project/workspace permissions are set)", + ) + + # Optional custom project permissions + parser.add_argument( + "--project-settings", + choices=[item.value for item in ProjectSettingsPermissionType], + default=None, + help="Project settings permission (custom access)", + ) + parser.add_argument( + "--project-teams", + choices=[item.value for item in ProjectTeamsPermissionType], + default=None, + help="Project teams permission (custom access)", + ) + parser.add_argument( + "--project-variable-sets", + choices=[item.value for item in ProjectVariableSetsPermissionType], + default=None, + help="Project variable sets permission (custom access)", + ) + + # Optional custom workspace permissions + parser.add_argument( + "--workspace-runs", + choices=[item.value for item in WorkspaceRunsPermissionType], + default=None, + help="Workspace runs permission (custom access)", + ) + parser.add_argument( + "--workspace-sentinel-mocks", + choices=[item.value for item in WorkspaceSentinelMocksPermissionType], + default=None, + help="Workspace sentinel-mocks permission (custom access)", + ) + parser.add_argument( + "--workspace-state-versions", + choices=[item.value for item in WorkspaceStateVersionsPermissionType], + default=None, + help="Workspace state-versions permission (custom access)", + ) + parser.add_argument( + "--workspace-variables", + choices=[item.value for item in WorkspaceVariablesPermissionType], + default=None, + help="Workspace variables permission (custom access)", + ) + parser.add_argument("--workspace-create", action="store_true", default=None) + parser.add_argument("--workspace-delete", action="store_true", default=None) + parser.add_argument("--workspace-locking", action="store_true", default=None) + parser.add_argument("--workspace-move", action="store_true", default=None) + parser.add_argument("--workspace-run-tasks", action="store_true", default=None) + + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + project_access = None + if any([args.project_settings, args.project_teams, args.project_variable_sets]): + project_access = TeamProjectAccessProjectPermissionsOptions( + settings=( + ProjectSettingsPermissionType(args.project_settings) + if args.project_settings + else None + ), + teams=( + ProjectTeamsPermissionType(args.project_teams) + if args.project_teams + else None + ), + variable_sets=( + ProjectVariableSetsPermissionType(args.project_variable_sets) + if args.project_variable_sets + else None + ), + ) + + workspace_access = None + if any( + [ + args.workspace_runs, + args.workspace_sentinel_mocks, + args.workspace_state_versions, + args.workspace_variables, + args.workspace_create, + args.workspace_delete, + args.workspace_locking, + args.workspace_move, + args.workspace_run_tasks, + ] + ): + workspace_access = TeamProjectAccessWorkspacePermissionsOptions( + runs=( + WorkspaceRunsPermissionType(args.workspace_runs) + if args.workspace_runs + else None + ), + sentinel_mocks=( + WorkspaceSentinelMocksPermissionType(args.workspace_sentinel_mocks) + if args.workspace_sentinel_mocks + else None + ), + state_versions=( + WorkspaceStateVersionsPermissionType(args.workspace_state_versions) + if args.workspace_state_versions + else None + ), + variables=( + WorkspaceVariablesPermissionType(args.workspace_variables) + if args.workspace_variables + else None + ), + create=args.workspace_create, + delete=args.workspace_delete, + locking=args.workspace_locking, + move=args.workspace_move, + run_tasks=args.workspace_run_tasks, + ) + + has_granular_permissions = ( + project_access is not None or workspace_access is not None + ) + if ( + has_granular_permissions + and args.access + and args.access != TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM.value + ): + parser.error( + "When custom project/workspace permissions are provided, --access must be 'custom'" + ) + + if args.operation == "add": + if not args.team_id or not args.project_id: + parser.error("--team-id and --project-id are required for operation=add") + + _print_header("Adding team project access") + access_value = args.access + if access_value is None: + access_value = ( + TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM.value + if has_granular_permissions + else TeamProjectAccessType.TEAM_PROJECT_ACCESS_READ.value + ) + + options = TeamProjectAccessAddOptions( + access=TeamProjectAccessType(access_value), + team=Team(id=args.team_id), + project=Project(id=args.project_id), + project_access=project_access, + workspace_access=workspace_access, + ) + result = client.team_project_accesses.add(options) + print("Created team project access") + _print_team_project_access(result) + return + + if args.operation == "read": + if not args.team_project_access_id: + parser.error("--team-project-access-id is required for operation=read") + + _print_header("Reading team project access") + result = client.team_project_accesses.read(args.team_project_access_id) + print("Retrieved team project access") + _print_team_project_access(result) + return + + if args.operation == "update": + if not args.team_project_access_id: + parser.error("--team-project-access-id is required for operation=update") + + _print_header("Updating team project access") + update_access = None + if args.access: + update_access = TeamProjectAccessType(args.access) + elif has_granular_permissions: + update_access = TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM + + update_options = TeamProjectAccessUpdateOptions( + access=update_access, + project_access=project_access, + workspace_access=workspace_access, + ) + result = client.team_project_accesses.update( + args.team_project_access_id, + update_options, + ) + print("Updated team project access") + _print_team_project_access(result) + return + + if args.operation == "list": + if not args.project_id: + parser.error("--project-id is required for operation=list") + + _print_header("Listing team project accesses") + list_options = TeamProjectAccessListOptions( + page_size=args.page_size, + Project_id=args.project_id, + ) + results = list(client.team_project_accesses.list(list_options)) + print(f"Found {len(results)} team project access entries") + for item in results: + print("-") + _print_team_project_access(item) + return + + if args.operation == "remove": + if not args.team_project_access_id: + parser.error("--team-project-access-id is required for operation=remove") + + _print_header("Removing team project access") + client.team_project_accesses.remove(args.team_project_access_id) + print(f"Removed team project access: {args.team_project_access_id}") + return + + +if __name__ == "__main__": + main() diff --git a/examples/user.py b/examples/user.py new file mode 100644 index 00000000..8a1dd82c --- /dev/null +++ b/examples/user.py @@ -0,0 +1,45 @@ +"""Example usage of the Users API. + +This example demonstrates how to read a user by ID using the Python TFE SDK. +""" + +import os +import sys + +# Add the src directory to the Python path so we can import the local package. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from pytfe import TFEClient, TFEConfig + + +def main() -> None: + """Read and print user details from Terraform Cloud.""" + user_id = os.getenv("TFE_USER_ID") + + try: + client = TFEClient(TFEConfig.from_env()) + + current_user = client.users.read_current() + print("=== Current Terraform Cloud User ===") + print(f"User ID: {current_user.id}") + print(f"Username: {current_user.username}") + print(f"Email: {current_user.email or 'N/A'}") + print(f"Auth Method: {current_user.auth_method or 'N/A'}") + + if not user_id: + print("\nTFE_USER_ID not set. Skipping client.users.read(user_id).") + return + + user = client.users.read(user_id) + + print("\n=== Terraform Cloud User By ID ===") + print(f"User ID: {user.id}") + print(f"Username: {user.username}") + print(f"Email: {user.email or 'N/A'}") + print(f"Auth Method: {user.auth_method or 'N/A'}") + except Exception as e: + print(f"Error running user example: {e}") + + +if __name__ == "__main__": + main() diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 30b506b9..4aead338 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -8,11 +8,14 @@ from .resources.agent_pools import AgentPools from .resources.agents import Agents, AgentTokens from .resources.apply import Applies +from .resources.comment import Comments 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 from .resources.organization_membership import OrganizationMemberships +from .resources.organization_token import OrganizationTokens from .resources.organizations import Organizations from .resources.plan import Plans from .resources.policy import Policies @@ -26,6 +29,7 @@ from .resources.query_run import QueryRuns from .resources.registry_module import RegistryModules from .resources.registry_provider import RegistryProviders +from .resources.registry_provider_platform import RegistryProviderPlatforms from .resources.registry_provider_version import RegistryProviderVersions from .resources.reserved_tag_key import ReservedTagKeys from .resources.run import Runs @@ -33,8 +37,13 @@ from .resources.run_task import RunTasks from .resources.run_trigger import RunTriggers from .resources.ssh_keys import SSHKeys +from .resources.stack import Stacks +from .resources.stack_configuration import StackConfigurations from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions +from .resources.team import Teams +from .resources.team_project_access import TeamProjectAccesses +from .resources.user import Users from .resources.variable import Variables from .resources.variable_sets import VariableSets, VariableSetVariables from .resources.workspace_resources import WorkspaceResourcesService @@ -72,7 +81,12 @@ 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.users = Users(self._transport) + self.organization_tokens = OrganizationTokens(self._transport) self.projects = Projects(self._transport) self.variables = Variables(self._transport) self.variable_sets = VariableSets(self._transport) @@ -82,6 +96,11 @@ def __init__(self, config: TFEConfig | None = None): self.registry_modules = RegistryModules(self._transport) self.registry_providers = RegistryProviders(self._transport) self.registry_provider_versions = RegistryProviderVersions(self._transport) + self.registry_provider_platforms = RegistryProviderPlatforms(self._transport) + + # Stack resources + self.stacks = Stacks(self._transport) + self.stack_configurations = StackConfigurations(self._transport) # State and execution resources self.state_versions = StateVersions(self._transport) @@ -91,6 +110,7 @@ def __init__(self, config: TFEConfig | None = None): self.runs = Runs(self._transport) self.query_runs = QueryRuns(self._transport) self.run_events = RunEvents(self._transport) + self.comments = Comments(self._transport) self.policies = Policies(self._transport) self.policy_evaluations = PolicyEvaluations(self._transport) self.policy_checks = PolicyChecks(self._transport) @@ -102,6 +122,10 @@ def __init__(self, config: TFEConfig | None = None): # SSH Keys self.ssh_keys = SSHKeys(self._transport) + # Team project access + self.teams = Teams(self._transport) + self.team_project_accesses = TeamProjectAccesses(self._transport) + # Reserved Tag Key self.reserved_tag_key = ReservedTagKeys(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index e913f6d4..1209a552 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.""" @@ -530,3 +537,130 @@ class InvalidKeyIDError(InvalidValues): def __init__(self, message: str = "invalid value for key-id"): super().__init__(message) + + +# Team errors +class EmptyTeamNameError(InvalidValues): + """Raised when a team name is empty.""" + + def __init__(self, message: str = "team names cannot be empty"): + super().__init__(message) + + +class InvalidTeamIDError(InvalidValues): + """Raised when an invalid team ID is provided.""" + + def __init__(self, message: str = "invalid value for team ID"): + super().__init__(message) + + +# Team Project Access errors +class InvalidProjectIDError(InvalidValues): + """Raised when an invalid project ID is provided.""" + + def __init__(self, message: str = "invalid value for project ID"): + super().__init__(message) + + +class RequiredTeamError(RequiredFieldMissing): + """Raised when a required team field is missing.""" + + def __init__(self, message: str = "team is required"): + super().__init__(message) + + +class InvalidTeamProjectAccessIDError(InvalidValues): + """Raised when an invalid team project access ID is provided.""" + + def __init__(self, message: str = "invalid value for team project access ID"): + super().__init__(message) + + +# Registry Provider Platform errors +class RequiredOSError(RequiredFieldMissing): + """Raised when a required OS field is missing.""" + + def __init__(self, message: str = "os is required"): + super().__init__(message) + + +class RequiredArchError(RequiredFieldMissing): + """Raised when a required architecture field is missing.""" + + def __init__(self, message: str = "arch is required"): + super().__init__(message) + + +class RequiredShasumError(RequiredFieldMissing): + """Raised when a required shasum field is missing.""" + + def __init__(self, message: str = "shasum is required"): + super().__init__(message) + + +class RequiredFilenameError(RequiredFieldMissing): + """Raised when a required filename field is missing.""" + + def __init__(self, message: str = "filename is required"): + super().__init__(message) + + +class InvalidOSError(InvalidValues): + """Raised when an invalid OS field is provided.""" + + def __init__(self, message: str = "invalid value for os"): + super().__init__(message) + + +class InvalidArchError(InvalidValues): + """Raised when an invalid architecture field is provided.""" + + def __init__(self, message: str = "invalid value for arch"): + super().__init__(message) + + +class InvalidNamespaceError(InvalidValues): + """Raised when an invalid namespace field is provided.""" + + def __init__(self, message: str = "invalid value for namespace"): + super().__init__(message) + + +class InvalidRegistryNameError(InvalidValues): + """Raised when an invalid registry name field is provided.""" + + def __init__( + self, + message: str = "invalid value for registry-name. It must be either private or public", + ): + super().__init__(message) + + +# Stack Configuration errors +class InvalidStackIDError(InvalidValues): + """Raised when an invalid stack ID is provided.""" + + def __init__(self, message: str = "invalid value for stack ID"): + super().__init__(message) + + +class InvalidStackConfigurationIDError(InvalidValues): + """Raised when an invalid stack configuration ID is provided.""" + + def __init__(self, message: str = "invalid value for stack configuration ID"): + super().__init__(message) + + +# Comment errors +class InvalidCommentIDError(InvalidValues): + """Raised when an invalid comment ID is provided.""" + + def __init__(self, message: str = "invalid value for comment ID"): + super().__init__(message) + + +class RequiredCommentBodyError(TFEError): + """Raised when comment body is empty or missing.""" + + def __init__(self, message: str = "comment body is required"): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 7aa184f0..fd263c1d 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -21,6 +21,10 @@ AgentTokenCreateOptions, AgentTokenListOptions, ) +from .comment import ( + Comment, + CommentCreateOptions, +) # ── Core models split out of old types.py ───────────────────────────────────── # Adjust these imports to match where you placed them during the split. @@ -58,6 +62,17 @@ DataRetentionPolicyDontDeleteSetOptions, DataRetentionPolicySetOptions, ) +from .explorer import ( + ExplorerQueryOptions, + ExplorerRow, + ExplorerSavedQuery, + ExplorerSavedQueryFilter, + ExplorerSavedView, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, + ExplorerViewType, +) # ── Notification Configurations ─────────────────────────────────────────────── from .notification_configuration import ( @@ -230,6 +245,13 @@ RegistryProviderPermissions, RegistryProviderReadOptions, ) +from .registry_provider_platform import ( + RegistryProviderPlatform, + RegistryProviderPlatformCreateOptions, + RegistryProviderPlatformID, + RegistryProviderPlatformListOptions, + RegistryProviderPlatformPermissions, +) from .registry_provider_version import ( RegistryProviderVersion, RegistryProviderVersionCreateOptions, @@ -306,6 +328,16 @@ SSHKeyListOptions, SSHKeyUpdateOptions, ) +from .stack_configuration import ( + StackComponent, + StackConfiguration, + StackConfigurationCreateOptions, + StackConfigurationIncludeOps, + StackConfigurationListOptions, + StackConfigurationReadOptions, + StackConfigurationSource, + StackConfigurationStatus, +) from .state_version import ( StateVersion, StateVersionCreateOptions, @@ -320,7 +352,11 @@ from .team import ( OrganizationAccess, Team, + TeamCreateOptions, + TeamIncludeOpt, + TeamListOptions, TeamPermissions, + TeamUpdateOptions, ) # Variables @@ -496,6 +532,21 @@ "RegistryProviderVersionID", "RegistryProviderVersionListOptions", "RegistryProviderVersionPermissions", + # Registry provider platforms + "RegistryProviderPlatform", + "RegistryProviderPlatformCreateOptions", + "RegistryProviderPlatformID", + "RegistryProviderPlatformListOptions", + "RegistryProviderPlatformPermissions", + # Stack Configuration + "StackComponent", + "StackConfiguration", + "StackConfigurationCreateOptions", + "StackConfigurationIncludeOps", + "StackConfigurationListOptions", + "StackConfigurationReadOptions", + "StackConfigurationSource", + "StackConfigurationStatus", # Query runs "QueryRun", "QueryRunActions", @@ -507,6 +558,16 @@ "QueryRunStatus", "QueryRunStatusTimestamps", "QueryRunVariable", + # Explorer + "ExplorerQueryOptions", + "ExplorerRow", + "ExplorerSavedQuery", + "ExplorerSavedQueryFilter", + "ExplorerSavedView", + "ExplorerSavedViewCreateOptions", + "ExplorerSavedViewUpdateOptions", + "ExplorerUrlFilter", + "ExplorerViewType", # Core (from old types.py, now split) "Entitlements", "ExecutionMode", @@ -523,6 +584,10 @@ "OrganizationAccess", "Team", "TeamPermissions", + "TeamCreateOptions", + "TeamIncludeOpt", + "TeamListOptions", + "TeamUpdateOptions", "Project", "ProjectAddTagBindingsOptions", "ProjectCreateOptions", @@ -602,6 +667,9 @@ "RunEventList", "RunEventListOptions", "RunEventReadOptions", + # Comments + "Comment", + "CommentCreateOptions", # Run tasks "RunTask", "RunTaskIncludeOptions", @@ -698,3 +766,6 @@ # Rebuild models with forward references after all models are loaded PolicyCheck.model_rebuild() +RegistryProvider.model_rebuild() +RegistryProviderVersion.model_rebuild() +RegistryProviderPlatform.model_rebuild() diff --git a/src/pytfe/models/comment.py b/src/pytfe/models/comment.py index 19cc25ca..8bc0d110 100644 --- a/src/pytfe/models/comment.py +++ b/src/pytfe/models/comment.py @@ -3,7 +3,10 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import RequiredCommentBodyError +from ..utils import valid_string class Comment(BaseModel): @@ -11,3 +14,17 @@ class Comment(BaseModel): id: str body: str = Field(default="", alias="body") + + +class CommentCreateOptions(BaseModel): + """Options for creating a comment on a run.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + body: str = Field(alias="body") + + @model_validator(mode="after") + def valid(self) -> CommentCreateOptions: + if not valid_string(self.body): + raise RequiredCommentBodyError() + return self diff --git a/src/pytfe/models/explorer.py b/src/pytfe/models/explorer.py new file mode 100644 index 00000000..66d73aa2 --- /dev/null +++ b/src/pytfe/models/explorer.py @@ -0,0 +1,122 @@ +# 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 (HashiCorp Explorer API view types only).""" + + WORKSPACES = "workspaces" + TF_VERSIONS = "tf_versions" + PROVIDERS = "providers" + MODULES = "modules" + + +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/models/organization_membership.py b/src/pytfe/models/organization_membership.py index a80cbcb3..cc00a4f5 100644 --- a/src/pytfe/models/organization_membership.py +++ b/src/pytfe/models/organization_membership.py @@ -34,8 +34,8 @@ class OrganizationMembership(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str - status: OrganizationMembershipStatus - email: str + status: OrganizationMembershipStatus | None = Field(default=None, alias="status") + email: str = Field(default="", alias="email") # Relations organization: Organization | None = None diff --git a/src/pytfe/models/organization_token.py b/src/pytfe/models/organization_token.py new file mode 100644 index 00000000..24f1cb0c --- /dev/null +++ b/src/pytfe/models/organization_token.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + pass + + +class TokenType(str, Enum): + """Token type enumeration.""" + + AUDIT_TRAILS = "audit-trails" + + +class OrganizationToken(BaseModel): + """Organization token represents a Terraform Enterprise organization token.""" + + model_config = ConfigDict(extra="forbid") + + id: str = Field(..., description="Organization token ID") + created_at: datetime = Field(..., description="Creation timestamp") + description: str | None = Field(None, description="Token description") + last_used_at: datetime | None = Field(None, description="Last usage timestamp") + token: str | None = Field(None, description="The actual token value") + expired_at: datetime | None = Field(None, description="Token expiration timestamp") + created_by: Any | None = Field( + None, description="The entity that created this token" + ) + + +class OrganizationTokenCreateOptions(BaseModel): + """Options for creating an organization token.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + expired_at: datetime | None = Field( + None, + description="The token's expiration date. Available in TFE release v202305-1 and later", + ) + token_type: TokenType | None = Field( + None, + alias="token", + description="What type of token to create. Only applicable to HCP Terraform", + ) + + +class OrganizationTokenReadOptions(BaseModel): + """Options for reading an organization token.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + token_type: TokenType | None = Field( + None, + alias="token", + description="What type of token to read. Only applicable to HCP Terraform", + ) + + +class OrganizationTokenDeleteOptions(BaseModel): + """Options for deleting an organization token.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + token_type: TokenType | None = Field( + None, + alias="token", + description="What type of token to delete. Only applicable to HCP Terraform", + ) diff --git a/src/pytfe/models/registry_provider.py b/src/pytfe/models/registry_provider.py index 2861acac..5cd57414 100644 --- a/src/pytfe/models/registry_provider.py +++ b/src/pytfe/models/registry_provider.py @@ -7,7 +7,15 @@ from enum import Enum from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator + +from ..errors import ( + InvalidNameError, + InvalidNamespaceError, + InvalidOrgError, + InvalidValues, +) +from ..utils import valid_string_id class RegistryName(Enum): @@ -35,12 +43,14 @@ class RegistryProvider(BaseModel): """Registry provider model.""" id: str - name: str - namespace: str - created_at: datetime = Field(alias="created-at") - updated_at: datetime = Field(alias="updated-at") - registry_name: RegistryName = Field(alias="registry-name") - permissions: RegistryProviderPermissions + name: str = Field(alias="name", default="") + namespace: str = Field(alias="namespace", default="") + created_at: datetime | None = Field(alias="created-at", default=None) + updated_at: datetime | None = Field(alias="updated-at", default=None) + registry_name: RegistryName | None = Field(alias="registry-name", default=None) + permissions: RegistryProviderPermissions | None = Field( + alias="permissions", default=None + ) # Relations organization: dict[str, Any] | None = None @@ -62,6 +72,19 @@ class RegistryProviderID(BaseModel): namespace: str name: str + @model_validator(mode="after") + def valid(self) -> RegistryProviderID: + """Validate the registry provider ID.""" + if not valid_string_id(self.organization_name): + raise InvalidOrgError() + if not valid_string_id(self.name): + raise InvalidNameError() + if not valid_string_id(self.namespace): + raise InvalidNamespaceError() + if not valid_string_id(self.registry_name.value): + raise InvalidValues("invalid value for registry name") + return self + class RegistryProviderCreateOptions(BaseModel): """Options for creating a registry provider.""" @@ -72,6 +95,15 @@ class RegistryProviderCreateOptions(BaseModel): model_config = {"populate_by_name": True} + @model_validator(mode="after") + def valid(self) -> RegistryProviderCreateOptions: + """Validate the create options.""" + if not valid_string_id(self.name): + raise InvalidNameError() + if not valid_string_id(self.namespace): + raise InvalidNamespaceError() + return self + class RegistryProviderReadOptions(BaseModel): """Options for reading a registry provider.""" diff --git a/src/pytfe/models/registry_provider_platform.py b/src/pytfe/models/registry_provider_platform.py new file mode 100644 index 00000000..716ac6e4 --- /dev/null +++ b/src/pytfe/models/registry_provider_platform.py @@ -0,0 +1,105 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ( + InvalidArchError, + InvalidOSError, + RequiredArchError, + RequiredFilenameError, + RequiredOSError, + RequiredShasumError, +) +from ..utils import valid_string, valid_string_id +from .registry_provider_version import ( + RegistryProviderVersion, + RegistryProviderVersionID, +) + + +class RegistryProviderPlatformPermissions(BaseModel): + """Registry provider platform permissions.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_delete: bool = Field(alias="can-delete") + can_upload_asset: bool = Field(alias="can-upload-asset") + + +class RegistryProviderPlatform(BaseModel): + """Registry provider platform model.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + os: str = Field(alias="os", default="") + arch: str = Field(alias="arch", default="") + filename: str = Field(alias="filename", default="") + shasum: str = Field(alias="shasum", default="") + provider_binary_uploaded: bool | None = Field( + alias="provider-binary-uploaded", default=None + ) + permissions: RegistryProviderPlatformPermissions | None = None + + # Relations + registry_provider_version: RegistryProviderVersion | None = Field( + alias="registry-provider-version", default=None + ) + + # Links + links: dict[str, Any] | None = None + + +class RegistryProviderPlatformID(RegistryProviderVersionID): + """Registry provider platform identifier. + + Extends RegistryProviderVersionID with OS and arch to uniquely + identify a specific platform of a provider version. + """ + + os: str + arch: str + + @model_validator(mode="after") + def valid_platform_id(self) -> RegistryProviderPlatformID: + if not valid_string_id(self.os): + raise InvalidOSError() + if not valid_string_id(self.arch): + raise InvalidArchError() + return self + + +class RegistryProviderPlatformCreateOptions(BaseModel): + """Options for creating a registry provider platform.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + os: str = Field(alias="os") + arch: str = Field(alias="arch") + shasum: str = Field(alias="shasum") + filename: str = Field(alias="filename") + + @model_validator(mode="after") + def valid(self) -> RegistryProviderPlatformCreateOptions: + if not valid_string(self.os): + raise RequiredOSError() + if not valid_string(self.arch): + raise RequiredArchError() + if not valid_string_id(self.shasum): + raise RequiredShasumError() + if not valid_string_id(self.filename): + raise RequiredFilenameError() + return self + + +class RegistryProviderPlatformListOptions(BaseModel): + """Options for listing registry provider platforms.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(alias="page[size]", default=None) diff --git a/src/pytfe/models/registry_provider_version.py b/src/pytfe/models/registry_provider_version.py index e5875051..8ec0d442 100644 --- a/src/pytfe/models/registry_provider_version.py +++ b/src/pytfe/models/registry_provider_version.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -16,9 +16,13 @@ from ..utils import valid_string_id from .registry_provider import ( RegistryName, + RegistryProvider, RegistryProviderID, ) +if TYPE_CHECKING: + from .registry_provider_platform import RegistryProviderPlatform + class RegistryProviderVersionPermissions(BaseModel): """Registry provider version permissions.""" @@ -35,20 +39,24 @@ class RegistryProviderVersion(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str - version: str - created_at: datetime = Field(alias="created-at") - updated_at: datetime = Field(alias="updated-at") - key_id: str = Field(alias="key-id") - protocols: list[str] - permissions: RegistryProviderVersionPermissions - shasums_uploaded: bool = Field(alias="shasums-uploaded") - shasums_sig_uploaded: bool = Field(alias="shasums-sig-uploaded") + version: str = Field(alias="version", default="") + created_at: datetime | None = Field(alias="created-at", default=None) + updated_at: datetime | None = Field(alias="updated-at", default=None) + key_id: str = Field(alias="key-id", default="") + protocols: list[str] = Field(alias="protocols", default_factory=list) + permissions: RegistryProviderVersionPermissions | None = Field( + alias="permissions", default=None + ) + shasums_uploaded: bool | None = Field(alias="shasums-uploaded", default=None) + shasums_sig_uploaded: bool | None = Field( + alias="shasums-sig-uploaded", default=None + ) # Relations - registry_provider: dict[str, Any] | None = Field( + registry_provider: RegistryProvider | None = Field( alias="registry-provider", default=None ) - registry_provider_platforms: list[dict[str, Any]] | None = Field( + registry_provider_platforms: list[RegistryProviderPlatform] | None = Field( alias="platforms", default=None ) @@ -142,7 +150,7 @@ class RegistryProviderVersionID(RegistryProviderID): version: str @model_validator(mode="after") - def valid(self) -> RegistryProviderVersionID: + def valid_version_id(self) -> RegistryProviderVersionID: if not valid_string_id(self.version): raise InvalidVersionError() if self.registry_name != RegistryName.PRIVATE: diff --git a/src/pytfe/models/stack.py b/src/pytfe/models/stack.py new file mode 100644 index 00000000..c6378f17 --- /dev/null +++ b/src/pytfe/models/stack.py @@ -0,0 +1,120 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ERR_REQUIRED_NAME, ERR_REQUIRED_PROJECT +from .agent import AgentPool +from .project import Project + + +class StackSortColumn(str, Enum): + """StackSortColumn represents a string that can be used to sort items when using the List method.""" + + STACK_SORT_BY_NAME = "name" + STACK_SORT_BY_UPDATED_AT = "updated-at" + STACK_SORT_BY_NAME_DESC = "-name" + STACK_SORT_BY_UPDATED_AT_DESC = "-updated-at" + + +class StackVcsRepo(BaseModel): + """StackVCSRepo represents the version control system repository for a stack.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + identifier: str = Field(alias="identifier") + branch: str | None = Field(default=None, alias="branch") + gha_installation_id: str | None = Field( + default=None, alias="github-app-installation-id" + ) + oauth_token_id: str | None = Field(default=None, alias="oauth-token-id") + + +class StackVcsRepoOptions(BaseModel): + """StackVCSRepoOptions represents the options for the version control system repository for a stack.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + identifier: str = Field(alias="identifier") + branch: str | None = Field(default=None, alias="branch") + gha_installation_id: str | None = Field( + default=None, alias="github-app-installation-id" + ) + oauth_token_id: str | None = Field(default=None, alias="oauth-token-id") + + +class Stack(BaseModel): + """Stack represents a stack in Terraform Cloud.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + name: str | None = Field(default=None, alias="name") + description: str | None = Field(default=None, alias="description") + created_at: datetime | None = Field(default=None, alias="created-at") + updated_at: datetime | None = Field(default=None, alias="updated-at") + vcs_repo: StackVcsRepo | None = Field(default=None, alias="vcs-repo") + speculation_enabled: bool | None = Field(default=None, alias="speculation-enabled") + upstream_count: int | None = Field(default=None, alias="upstream-count") + downstream_count: int | None = Field(default=None, alias="downstream-count") + inputs_count: int | None = Field(default=None, alias="inputs-count") + outputs_count: int | None = Field(default=None, alias="outputs-count") + creation_source: str | None = Field(default=None, alias="creation-source") + + # Relations + project: Project | None = Field(default=None, alias="project") + agent_pool: AgentPool | None = Field(default=None, alias="agent-pool") + # latest_stack_configuration: dict[str, Any] | None = Field(default=None, alias="latest-stack-configuration") + + +class StackListOptions(BaseModel): + """StackListOptions represents the options for listing stacks.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + project_id: str | None = Field(default=None, alias="filter[project][id]") + sort: StackSortColumn | None = Field(default=None, alias="sort") + search_by_name: str | None = Field(default=None, alias="search[name]") + + +class StackCreateOptions(BaseModel): + """StackCreateOptions represents the options for creating a stack.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str = Field(alias="name") + migration: bool | None = Field(default=None, alias="migration") + description: str | None = Field(default=None, alias="description") + speculation_enabled: bool | None = Field(default=None, alias="speculation-enabled") + vcs_repo: StackVcsRepoOptions | None = Field(default=None, alias="vcs-repo") + project: Project = Field(alias="project") + agent_pool: AgentPool | None = Field(default=None, alias="agent-pool") + + @model_validator(mode="after") + def valid(self) -> StackCreateOptions: + if self.name == "": + raise ValueError(ERR_REQUIRED_NAME) + + if self.project and self.project.id == "": + raise ValueError(ERR_REQUIRED_PROJECT) + + return self + + +class StackUpdateOptions(BaseModel): + """StackUpdateOptions represents the options for updating a stack.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str | None = Field(default=None, alias="name") + description: str | None = Field(default=None, alias="description") + speculation_enabled: bool | None = Field(default=None, alias="speculation-enabled") + vcs_repo: StackVcsRepoOptions | None = Field(default=None, alias="vcs-repo") + project: Project | None = Field(default=None, alias="project") + agent_pool: AgentPool | None = Field(default=None, alias="agent-pool") diff --git a/src/pytfe/models/stack_configuration.py b/src/pytfe/models/stack_configuration.py new file mode 100644 index 00000000..b92931a9 --- /dev/null +++ b/src/pytfe/models/stack_configuration.py @@ -0,0 +1,100 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from .configuration_version import IngressAttributes +from .stack import Stack + + +class StackConfigurationStatus(str, Enum): + """StackConfigurationStatus represents the status of a stack configuration.""" + + PENDING = "pending" + QUEUED = "queued" + PREPARING = "preparing" + COMPLETED = "completed" + FAILED = "failed" + + +class StackComponent(BaseModel): + """StackComponent represents a stack component, specified by configuration""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str = Field(alias="name", default="") + correlator: str = Field(alias="correlator", default="") + expanded: bool | None = Field(alias="expanded", default=None) + removed: bool | None = Field(alias="removed", default=None) + + +class StackConfigurationSource(str, Enum): + """StackConfigurationSource controls how configuration content is sourced.""" + + MANUAL = "manual" + FETCH = "fetch" + REUSE = "reuse" + + +class StackConfigurationIncludeOps(str, Enum): + """StackConfigurationIncludeOps represents include options for stack configuration endpoints.""" + + INGRESS_ATTRIBUTES = "ingress_attributes" + STACK_DIAGNOSTICS = "stack_diagnostics" + + +class StackConfiguration(BaseModel): + """StackConfiguration represents a snapshot of a stack's configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + status: StackConfigurationStatus | None = Field(default=None, alias="status") + sequence_number: int | None = Field(default=None, alias="sequence-number") + components: list[StackComponent] = Field(default_factory=list, alias="components") + preparing_event_stream_url: str = Field( + default="", alias="preparing-event-stream-url" + ) + created_at: datetime | None = Field(default=None, alias="created-at") + updated_at: datetime | None = Field(default=None, alias="updated-at") + speculative: bool | None = Field(default=None, alias="speculative") + + # Relations + stack: Stack | None = Field(default=None, alias="stack") + ingress_attributes: IngressAttributes | None = Field( + default=None, alias="ingress-attributes" + ) + + +class StackConfigurationCreateOptions(BaseModel): + """Options for creating a stack configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + speculative_enabled: bool = Field(default=False, alias="speculative") + destroy_all: bool = Field(default=False, alias="destroy-all") + selected_deployments: list[str] | None = Field( + default=None, alias="selected-deployments" + ) + + +class StackConfigurationListOptions(BaseModel): + """Options for listing stack configurations.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + include: list[StackConfigurationIncludeOps] | None = None + + +class StackConfigurationReadOptions(BaseModel): + """Options for reading a stack configuration.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: list[StackConfigurationIncludeOps] | None = None diff --git a/src/pytfe/models/state_version.py b/src/pytfe/models/state_version.py index dab42619..dbbe06f2 100644 --- a/src/pytfe/models/state_version.py +++ b/src/pytfe/models/state_version.py @@ -36,8 +36,18 @@ class StateVersion(BaseModel): hosted_state_download_url: str | None = Field( None, alias="hosted-state-download-url" ) + hosted_json_state_download_url: str | None = Field( + None, alias="hosted-json-state-download-url" + ) hosted_state_upload_url: str | None = Field(None, alias="hosted-state-upload-url") + hosted_json_state_upload_url: str | None = Field( + None, alias="hosted-json-state-upload-url" + ) status: StateVersionStatus | None = Field(None, alias="status") + serial: int | None = Field(None, alias="serial") + size: int | None = Field(None, alias="size") + terraform_version: str | None = Field(None, alias="terraform-version") + state_version: int | None = Field(None, alias="state-version") # Optional/advanced fields (present on newer servers; keep loose) resources_processed: bool | None = Field(None, alias="resources-processed") diff --git a/src/pytfe/models/team.py b/src/pytfe/models/team.py index 8b0b8330..5d9772ae 100644 --- a/src/pytfe/models/team.py +++ b/src/pytfe/models/team.py @@ -3,13 +3,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from enum import Enum -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field, model_validator -if TYPE_CHECKING: - from .organization_membership import OrganizationMembership - from .user import User +from ..errors import ERR_REQUIRED_NAME, EmptyTeamNameError +from .organization_membership import OrganizationMembership +from .user import User class OrganizationAccess(BaseModel): @@ -17,21 +17,25 @@ class OrganizationAccess(BaseModel): model_config = ConfigDict(populate_by_name=True) - manage_policies: bool = False - manage_policy_overrides: bool = False - manage_workspaces: bool = False - manage_vcs_settings: bool = False - manage_providers: bool = False - manage_modules: bool = False - manage_run_tasks: bool = False - manage_projects: bool = False - read_workspaces: bool = False - read_projects: bool = False - manage_membership: bool = False - manage_teams: bool = False - manage_organization_access: bool = False - access_secret_teams: bool = False - manage_agent_pools: bool = False + manage_policies: bool = Field(default=False, alias="manage-policies") + manage_policy_overrides: bool = Field( + default=False, alias="manage-policy-overrides" + ) + manage_workspaces: bool = Field(default=False, alias="manage-workspaces") + manage_vcs_settings: bool = Field(default=False, alias="manage-vcs-settings") + manage_providers: bool = Field(default=False, alias="manage-providers") + manage_modules: bool = Field(default=False, alias="manage-modules") + manage_run_tasks: bool = Field(default=False, alias="manage-run-tasks") + manage_projects: bool = Field(default=False, alias="manage-projects") + read_workspaces: bool = Field(default=False, alias="read-workspaces") + read_projects: bool = Field(default=False, alias="read-projects") + manage_membership: bool = Field(default=False, alias="manage-membership") + manage_teams: bool = Field(default=False, alias="manage-teams") + manage_organization_access: bool = Field( + default=False, alias="manage-organization-access" + ) + access_secret_teams: bool = Field(default=False, alias="access-secret-teams") + manage_agent_pools: bool = Field(default=False, alias="manage-agent-pools") class TeamPermissions(BaseModel): @@ -39,8 +43,8 @@ class TeamPermissions(BaseModel): model_config = ConfigDict(populate_by_name=True) - can_destroy: bool = False - can_update_membership: bool = False + can_destroy: bool = Field(alias="can-destroy") + can_update_membership: bool = Field(alias="can-update-membership") class Team(BaseModel): @@ -49,27 +53,106 @@ class Team(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str - name: str | None = None - is_unified: bool = False - organization_access: OrganizationAccess | None = None - visibility: str | None = None - permissions: TeamPermissions | None = None - user_count: int = 0 - sso_team_id: str | None = None - allow_member_token_management: bool = False + name: str | None = Field(default=None, alias="name") + is_unified: bool = Field(default=False, alias="is-unified") + organization_access: OrganizationAccess | None = Field( + default=None, alias="organization-access" + ) + visibility: str | None = Field(default=None, alias="visibility") + permissions: TeamPermissions | None = Field(default=None, alias="permissions") + user_count: int = Field(default=0, alias="user-count") + sso_team_id: str | None = Field(default=None, alias="sso-team-id") + # AllowMemberTokenManagement is false for TFE versions older than v202408 + allow_member_token_management: bool = Field( + default=False, alias="allow-member-token-management" + ) # Relations - users: list[User] | None = None - organization_memberships: list[OrganizationMembership] | None = None + users: list[User] = Field(alias="users", default_factory=list) + organization_memberships: list[OrganizationMembership] = Field( + alias="organization-memberships", default_factory=list + ) -def _rebuild_models() -> None: - """Rebuild models to resolve forward references.""" - from .organization import Organization # noqa: F401 - from .organization_membership import OrganizationMembership # noqa: F401 - from .user import User # noqa: F401 +class TeamIncludeOpt(str, Enum): + """TeamIncludeOpt represents the available options for include query params.""" - Team.model_rebuild() + TEAM_USERS = "users" + TEAM_ORGANIZATION_MEMBERSHIPS = "organization-memberships" -_rebuild_models() +class TeamListOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + page_size: int | None = Field(None, alias="page[size]") + include: list[TeamIncludeOpt] | None = Field(None, alias="include") + names: list[str] | None = Field(None, alias="filter[names]") + query: str | None = Field(None, alias="q") + + @model_validator(mode="after") + def valid(self) -> TeamListOptions: + """Validate the options.""" + + if self.names is not None and any(not name for name in self.names): + raise EmptyTeamNameError() + + return self + + +class OrganizationAccessOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + manage_policies: bool | None = Field(default=False, alias="manage-policies") + manage_policy_overrides: bool | None = Field( + default=False, alias="manage-policy-overrides" + ) + manage_workspaces: bool | None = Field(default=False, alias="manage-workspaces") + manage_vcs_settings: bool | None = Field(default=False, alias="manage-vcs-settings") + manage_providers: bool | None = Field(default=False, alias="manage-providers") + manage_modules: bool | None = Field(default=False, alias="manage-modules") + manage_run_tasks: bool | None = Field(default=False, alias="manage-run-tasks") + manage_projects: bool | None = Field(default=False, alias="manage-projects") + read_workspaces: bool | None = Field(default=False, alias="read-workspaces") + read_projects: bool | None = Field(default=False, alias="read-projects") + manage_membership: bool | None = Field(default=False, alias="manage-membership") + manage_teams: bool | None = Field(default=False, alias="manage-teams") + manage_organization_access: bool | None = Field( + default=False, alias="manage-organization-access" + ) + access_secret_teams: bool | None = Field(default=False, alias="access-secret-teams") + manage_agent_pools: bool | None = Field(default=False, alias="manage-agent-pools") + + +class TeamCreateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + name: str = Field(alias="name") + sso_team_id: str | None = Field(default=None, alias="sso-team-id") + organization_access: OrganizationAccessOptions | None = Field( + default=None, alias="organization-access" + ) + visibility: str | None = Field(alias="visibility") + allow_member_token_management: bool | None = Field( + default=None, alias="allow-member-token-management" + ) + + @model_validator(mode="after") + def valid(self) -> TeamCreateOptions: + """Validate the options.""" + if not self.name: + raise ValueError(ERR_REQUIRED_NAME) + return self + + +class TeamUpdateOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + name: str | None = Field(default=None, alias="name") + sso_team_id: str | None = Field(default=None, alias="sso-team-id") + organization_access: OrganizationAccessOptions | None = Field( + default=None, alias="organization-access" + ) + visibility: str | None = Field(alias="visibility") + allow_member_token_management: bool | None = Field( + default=None, alias="allow-member-token-management" + ) diff --git a/src/pytfe/models/team_project_access.py b/src/pytfe/models/team_project_access.py new file mode 100644 index 00000000..29aa8cad --- /dev/null +++ b/src/pytfe/models/team_project_access.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ..errors import ERR_REQUIRED_PROJECT, InvalidProjectIDError, RequiredTeamError +from ..utils import valid_string_id +from .project import Project +from .team import Team + + +class TeamProjectAccessType(str, Enum): + """TeamProjectAccessType represents a team project access type.""" + + TEAM_PROJECT_ACCESS_ADMIN = "admin" + TEAM_PROJECT_ACCESS_MAINTAIN = "maintain" + TEAM_PROJECT_ACCESS_WRITE = "write" + TEAM_PROJECT_ACCESS_READ = "read" + TEAM_PROJECT_ACCESS_CUSTOM = "custom" + + +class ProjectSettingsPermissionType(str, Enum): + """ProjectSettingsPermissionType represents the permissiontype to a project's settings""" + + PROJECT_SETTINGS_PERMISSION_READ = "read" + PROJECT_SETTINGS_PERMISSION_UPDATE = "update" + PROJECT_SETTINGS_PERMISSION_DELETE = "delete" + + +class ProjectTeamsPermissionType(str, Enum): + """ProjectTeamsPermissionType represents the permissiontype to a project's teams""" + + PROJECT_TEAMS_PERMISSION_READ = "read" + PROJECT_TEAMS_PERMISSION_NONE = "none" + PROJECT_TEAMS_PERMISSION_MANAGE = "manage" + + +class ProjectVariableSetsPermissionType(str, Enum): + """ProjectVariableSetsPermissionType represents the permissiontype to a project's variable sets""" + + PROJECT_VARIABLE_SETS_PERMISSION_READ = "read" + PROJECT_VARIABLE_SETS_PERMISSION_WRITE = "write" + PROJECT_VARIABLE_SETS_PERMISSION_NONE = "none" + + +class TeamProjectAccessProjectPermissions(BaseModel): + """ProjectPermissions represents the team's permissions on its project""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + project_settings_permission: ProjectSettingsPermissionType = Field(alias="settings") + project_teams_permission: ProjectTeamsPermissionType = Field(alias="teams") + # ProjectVariableSetsPermission represents read, manage, and no access custom permission for project-level variable sets + project_variable_sets_permission: ProjectVariableSetsPermissionType = Field( + alias="variable-sets" + ) + + +class WorkspaceRunsPermissionType(str, Enum): + """WorkspaceRunsPermissionType represents the permissiontype to project workspaces' runs""" + + WORKSPACE_RUNS_PERMISSION_READ = "read" + WORKSPACE_RUNS_PERMISSION_PLAN = "plan" + WORKSPACE_RUNS_PERMISSION_APPLY = "apply" + + +class WorkspaceSentinelMocksPermissionType(str, Enum): + """WorkspaceSentinelMocksPermissionType represents the permissiontype to project workspaces' sentinel-mocks""" + + WORKSPACE_SENTINEL_MOCKS_PERMISSION_READ = "read" + WORKSPACE_SENTINEL_MOCKS_PERMISSION_NONE = "none" + + +class WorkspaceStateVersionsPermissionType(str, Enum): + """WorkspaceStateVersionsPermissionType represents the permissiontype to project workspaces' state-versions""" + + WORKSPACE_STATE_VERSIONS_PERMISSION_NONE = "none" + WORKSPACE_STATE_VERSIONS_PERMISSION_READ_OUTPUTS = "read-outputs" + WORKSPACE_STATE_VERSIONS_PERMISSION_WRITE = "write" + WORKSPACE_STATE_VERSIONS_PERMISSION_READ = "read" + + +class WorkspaceVariablesPermissionType(str, Enum): + """WorkspaceVariablesPermissionType represents the permissiontype to project workspaces' variables""" + + WORKSPACE_VARIABLES_PERMISSION_NONE = "none" + WORKSPACE_VARIABLES_PERMISSION_READ = "read" + WORKSPACE_VARIABLES_PERMISSION_WRITE = "write" + + +class TeamProjectAccessWorkspacePermissions(BaseModel): + """WorkspacePermissions represents the team's permission on all workspaces in its project""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + runs: WorkspaceRunsPermissionType | None = Field(default=None, alias="runs") + sentinel_mocks: WorkspaceSentinelMocksPermissionType | None = Field( + default=None, alias="sentinel-mocks" + ) + state_versions: WorkspaceStateVersionsPermissionType | None = Field( + default=None, alias="state-versions" + ) + variables: WorkspaceVariablesPermissionType | None = Field( + default=None, alias="variables" + ) + create: bool = Field(default=False, alias="create") + delete: bool = Field(default=False, alias="delete") + locking: bool = Field(default=False, alias="locking") + move: bool = Field(default=False, alias="move") + run_tasks: bool = Field(default=False, alias="run-tasks") + + +class TeamProjectAccess(BaseModel): + """TeamProjectAccess represents a project access for a team""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str + access: TeamProjectAccessType | None = Field(default=None, alias="access") + project_access: TeamProjectAccessProjectPermissions | None = Field( + default=None, alias="project-access" + ) + workspace_access: TeamProjectAccessWorkspacePermissions | None = Field( + default=None, alias="workspace-access" + ) + + # relations + project: Project | None = Field(default=None, alias="project") + team: Team | None = Field(default=None, alias="team") + + +class TeamProjectAccessListOptions(BaseModel): + """TeamProjectAccessListOptions represents the options for listing team project accesses""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + Project_id: str | None = Field(default=None, alias="filter[project][id]") + + @model_validator(mode="after") + def valid(self) -> TeamProjectAccessListOptions: + """Validate the options.""" + if self.Project_id is not None and not valid_string_id(self.Project_id): + raise InvalidProjectIDError() + return self + + +class TeamProjectAccessProjectPermissionsOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + settings: ProjectSettingsPermissionType | None = Field( + default=None, alias="settings" + ) + teams: ProjectTeamsPermissionType | None = Field(default=None, alias="teams") + variable_sets: ProjectVariableSetsPermissionType | None = Field( + default=None, alias="variable-sets" + ) + + +class TeamProjectAccessWorkspacePermissionsOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + runs: WorkspaceRunsPermissionType | None = Field(default=None, alias="runs") + sentinel_mocks: WorkspaceSentinelMocksPermissionType | None = Field( + default=None, alias="sentinel-mocks" + ) + state_versions: WorkspaceStateVersionsPermissionType | None = Field( + default=None, alias="state-versions" + ) + variables: WorkspaceVariablesPermissionType | None = Field( + default=None, alias="variables" + ) + create: bool | None = Field(default=None, alias="create") + delete: bool | None = Field(default=None, alias="delete") + locking: bool | None = Field(default=None, alias="locking") + move: bool | None = Field(default=None, alias="move") + run_tasks: bool | None = Field(default=None, alias="run-tasks") + + +class TeamProjectAccessAddOptions(BaseModel): + """TeamProjectAccessAddOptions represents the options for adding team access for a project""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + access: TeamProjectAccessType = Field(alias="access") + project_access: TeamProjectAccessProjectPermissionsOptions | None = Field( + default=None, alias="project-access" + ) + workspace_access: TeamProjectAccessWorkspacePermissionsOptions | None = Field( + default=None, alias="workspace-access" + ) + + # relations + team: Team | None = Field(default=None, alias="team") + project: Project | None = Field(default=None, alias="project") + + @model_validator(mode="after") + def valid(self) -> TeamProjectAccessAddOptions: + """Validate the options.""" + + if self.team is None: + raise RequiredTeamError() + if self.project is None: + raise ValueError(ERR_REQUIRED_PROJECT) + return self + + +class TeamProjectAccessUpdateOptions(BaseModel): + """TeamProjectAccessUpdateOptions represents the options for updating a team project access""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + access: TeamProjectAccessType | None = Field(default=None, alias="access") + project_access: TeamProjectAccessProjectPermissionsOptions | None = Field( + default=None, alias="project-access" + ) + workspace_access: TeamProjectAccessWorkspacePermissionsOptions | None = Field( + default=None, alias="workspace-access" + ) + + @model_validator(mode="after") + def valid(self) -> TeamProjectAccessUpdateOptions: + """Validate the options.""" + if ( + self.access is None + and self.project_access is None + and self.workspace_access is None + ): + raise ValueError( + "At least one of access, project_access, or workspace_access must be provided" + ) + return self diff --git a/src/pytfe/models/user.py b/src/pytfe/models/user.py index bfa43359..c72d1075 100644 --- a/src/pytfe/models/user.py +++ b/src/pytfe/models/user.py @@ -1,26 +1,53 @@ # Copyright IBM Corp. 2025, 2026 # SPDX-License-Identifier: MPL-2.0 -from __future__ import annotations - from pydantic import BaseModel, ConfigDict, Field +class TwoFactor(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + enabled: bool = Field(default=False, alias="enabled") + verified: bool = Field(default=False, alias="verified") + + +class UserPermissions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + can_create_organizations: bool = Field( + default=False, alias="can-create-organizations" + ) + can_change_email: bool = Field(default=False, alias="can-change-email") + can_change_username: bool = Field(default=False, alias="can-change-username") + can_manage_user_tokens: bool = Field(default=False, alias="can-manage-user-tokens") + can_view_2fa_settings: bool = Field(default=False, alias="can-view2fa-settings") + can_manage_hcp_account: bool = Field(default=False, alias="can-manage-hcp-account") + + class User(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) id: str = Field(..., alias="id") - avatar_url: str = Field(default="", alias="avatar-url") - email: str = Field(default="", alias="email") + auth_method: str | None = Field(default=None, alias="auth-method") + avatar_url: str | None = Field(default=None, alias="avatar-url") + email: str | None = Field(default=None, alias="email") is_service_account: bool = Field(default=False, alias="is-service-account") - two_factor: dict = Field(default_factory=dict, alias="two-factor") - unconfirmed_email: str = Field(default="", alias="unconfirmed-email") + two_factor: TwoFactor | None = Field(default=None, alias="two-factor") + unconfirmed_email: str | None = Field(default=None, alias="unconfirmed-email") username: str = Field(default="", alias="username") v2_only: bool = Field(default=False, alias="v2-only") - is_site_admin: bool = Field(default=False, alias="is-site-admin") # Deprecated - is_admin: bool = Field(default=False, alias="is-admin") - is_sso_login: bool = Field(default=False, alias="is-sso-login") - permissions: dict = Field(default_factory=dict, alias="permissions") + is_site_admin: bool | None = Field( + default=None, alias="is-site-admin" + ) # Deprecated + is_admin: bool | None = Field(default=None, alias="is-admin") + is_sso_login: bool | None = Field(default=None, alias="is-sso-login") + permissions: UserPermissions | None = Field(default=None, alias="permissions") # Relations # authentication_tokens: AuthenticationTokens = Field(..., alias="authentication-tokens") + + +class UserUpdateCurrentOptions(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + username: str | None = Field(default=None, alias="username") + email: str | None = Field(default=None, alias="email") diff --git a/src/pytfe/resources/_base.py b/src/pytfe/resources/_base.py index a6e65dd7..b60c17f2 100644 --- a/src/pytfe/resources/_base.py +++ b/src/pytfe/resources/_base.py @@ -9,6 +9,18 @@ from .._http import HTTPTransport +def _to_int(value: Any) -> int | None: + """Best-effort integer coercion for pagination metadata values.""" + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + class _Service: def __init__(self, t: HTTPTransport) -> None: self.t = t @@ -16,20 +28,60 @@ def __init__(self, t: HTTPTransport) -> None: def _list( self, path: str, *, params: dict | None = None ) -> Iterator[dict[str, Any]]: - page = 1 + base_params = dict(params or {}) + page = int(base_params.get("page[number]", 1)) while True: - p = dict(params or {}) + p = dict(base_params) p["page[number]"] = page p.setdefault("page[size]", 100) r = self.t.request("GET", path, params=p) # Handle cases where r.json() returns None or is not a dict json_response = r.json() - if json_response is None: + if json_response is None or not isinstance(json_response, dict): json_response = {} data = json_response.get("data", []) + if not isinstance(data, list): + data = [] yield from data + if not data: + # Defensive stop: some endpoints can return inconsistent pagination + # metadata while yielding no rows; avoid unbounded follow-up requests. + break + + # Prefer server pagination metadata when available. This avoids + # prematurely terminating when servers clamp requested page sizes. + meta = json_response.get("meta") + pagination = meta.get("pagination", {}) if isinstance(meta, dict) else {} + if isinstance(pagination, dict) and pagination: + next_page = _to_int( + pagination.get("next-page", pagination.get("next_page")) + ) + if next_page is not None and next_page > page: + page = next_page + continue + + current_page = _to_int( + pagination.get("current-page", pagination.get("current_page")) + ) + total_pages = _to_int( + pagination.get("total-pages", pagination.get("total_pages")) + ) + if ( + current_page is not None + and total_pages is not None + and current_page < total_pages + ): + candidate_page = current_page + 1 + if candidate_page > page: + page = candidate_page + continue + + # Metadata present and indicates no next page. + break + + # Fallback for endpoints that do not return pagination metadata. page_size = int(p["page[size]"]) if len(data) < page_size: break diff --git a/src/pytfe/resources/comment.py b/src/pytfe/resources/comment.py new file mode 100644 index 00000000..e079366a --- /dev/null +++ b/src/pytfe/resources/comment.py @@ -0,0 +1,54 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import InvalidCommentIDError, InvalidRunIDError +from ..models.comment import Comment, CommentCreateOptions +from ..utils import valid_string_id +from ._base import _Service + + +class Comments(_Service): + """Service for managing run comments.""" + + def list(self, run_id: str) -> Iterator[Comment]: + """List all comments for the given run.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + path = f"/api/v2/runs/{run_id}/comments" + for item in self._list(path=path): + yield self._comment_from(item) + + def read(self, comment_id: str) -> Comment: + """Read a comment by its ID.""" + if not valid_string_id(comment_id): + raise InvalidCommentIDError() + r = self.t.request("GET", path=f"/api/v2/comments/{comment_id}") + data = r.json().get("data", {}) + return self._comment_from(data) + + def create(self, run_id: str, options: CommentCreateOptions) -> Comment: + """Create a new comment on the given run.""" + if not valid_string_id(run_id): + raise InvalidRunIDError() + payload = { + "data": { + "type": "comments", + "attributes": options.model_dump(by_alias=True, exclude_none=True), + } + } + r = self.t.request( + "POST", path=f"/api/v2/runs/{run_id}/comments", json_body=payload + ) + data = r.json().get("data", {}) + return self._comment_from(data) + + def _comment_from(self, data: dict[str, Any]) -> Comment: + """Parse a Comment from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + return Comment.model_validate(attrs) diff --git a/src/pytfe/resources/explorer.py b/src/pytfe/resources/explorer.py new file mode 100644 index 00000000..50d6ae58 --- /dev/null +++ b/src/pytfe/resources/explorer.py @@ -0,0 +1,704 @@ +# 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, + ExplorerViewType, +) +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 _normalize_filter_field_name(raw_field: Any) -> str: + """Normalize filter field names to SDK model style.""" + return str(raw_field).replace("-", "_") + + +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 = _normalize_filter_field_name(entry.get("field", "")) + 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": _normalize_filter_field_name(entry["field"]), + "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": _normalize_filter_field_name(field_name), + "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 _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, + } + ) + + +# Column order matches HashiCorp Explorer API docs (view-type field tables and export/csv +# workspaces sample): https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer +_EXPLORER_CSV_COLUMNS: dict[ExplorerViewType, tuple[str, ...]] = { + ExplorerViewType.WORKSPACES: ( + "all_checks_succeeded", + "current_rum_count", + "checks_errored", + "checks_failed", + "checks_passed", + "checks_unknown", + "current_run_applied_at", + "current_run_external_id", + "current_run_status", + "drifted", + "external_id", + "module_count", + "modules", + "organization_name", + "project_external_id", + "project_name", + "provider_count", + "providers", + "resources_drifted", + "resources_undrifted", + "state_version_terraform_version", + "vcs_repo_identifier", + "workspace_created_at", + "workspace_name", + "workspace_terraform_version", + "workspace_updated_at", + ), + ExplorerViewType.TF_VERSIONS: ("version", "workspace_count", "workspaces"), + ExplorerViewType.PROVIDERS: ( + "name", + "source", + "version", + "workspace_count", + "workspaces", + ), + ExplorerViewType.MODULES: ( + "name", + "source", + "version", + "workspace_count", + "workspaces", + ), +} + +_ROW_TYPE_TO_VIEW: dict[str, ExplorerViewType] = { + "visibility-workspace": ExplorerViewType.WORKSPACES, +} + + +def _infer_view_type_from_csv_header(header: list[str]) -> ExplorerViewType | None: + """Pick Explorer view type from CSV header names (no extra API call).""" + h = frozenset(header) + candidates: list[tuple[int, int, str, ExplorerViewType]] = [] + for vt, cols in _EXPLORER_CSV_COLUMNS.items(): + colset = frozenset(cols) + overlap = len(h & colset) + if overlap == 0: + continue + # Prefer more matching columns; tie-break to a narrower schema (e.g. tf_versions). + candidates.append((overlap, -len(colset), vt.value, vt)) + if not candidates: + return None + _, _, _, vt = max(candidates) + return vt + + +def _explorer_attribute_value(attrs: dict[str, Any], logical_snake: str) -> Any: + """Resolve API attribute keys (snake_case or kebab-case) for one logical Explorer column.""" + hyphen = logical_snake.replace("_", "-") + if logical_snake in attrs: + return attrs[logical_snake] + if hyphen in attrs: + return attrs[hyphen] + return "" + + +def _csv_fieldnames_for_explorer_rows( + rows: list[ExplorerRow], + view_type: ExplorerViewType | None, +) -> tuple[list[str], frozenset[str]]: + """Doc-ordered columns first; trailing columns for attributes not in the doc schema.""" + all_raw: set[str] = set() + for row in rows: + all_raw.update(row.attributes.keys()) + + order = _EXPLORER_CSV_COLUMNS.get(view_type) if view_type is not None else None + if not order: + seen: set[str] = set() + visit: list[str] = [] + for row in rows: + for k in row.attributes: + if k not in seen: + seen.add(k) + visit.append(k) + return visit, frozenset() + + canonical_set = frozenset(order) + matched_raw: set[str] = set() + for raw in all_raw: + for col in order: + if raw == col or raw == col.replace("_", "-"): + matched_raw.add(raw) + break + + extras: list[str] = [] + seen_extras: set[str] = set() + for row in rows: + for raw in row.attributes: + if raw not in canonical_set and raw not in seen_extras: + seen_extras.add(raw) + extras.append(raw) + return list(order) + extras, canonical_set + + +def _infer_view_type_from_rows(rows: list[ExplorerRow]) -> ExplorerViewType | None: + if not rows: + return None + return _ROW_TYPE_TO_VIEW.get(rows[0].row_type) + + +def _normalize_explorer_csv_column_order( + csv_text: str, view_type: ExplorerViewType | None +) -> str: + """Reorder CSV header/data columns to match Explorer API doc order (GET CSV varies).""" + if not csv_text.strip() or view_type is None: + return csv_text + order = _EXPLORER_CSV_COLUMNS.get(view_type) + if not order: + return csv_text + try: + reader = csv.reader(io.StringIO(csv_text)) + rows = list(reader) + except csv.Error: + return csv_text + if not rows or not rows[0]: + return csv_text + header = rows[0] + idx = {name: i for i, name in enumerate(header)} + order_set = frozenset(order) + canonical = [c for c in order if c in idx] + extras = [h for h in header if h not in order_set] + new_header = canonical + extras + if new_header == header: + return csv_text + perm = [idx[h] for h in new_header] + ncols = len(header) + out_rows: list[list[str]] = [new_header] + for row in rows[1:]: + padded = list(row) + [""] * max(0, ncols - len(row)) + padded = padded[:ncols] + out_rows.append([padded[i] for i in perm]) + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="\n") + writer.writerows(out_rows) + return buf.getvalue() + + +def _rows_to_csv( + rows: list[ExplorerRow], + *, + view_type: ExplorerViewType | None = None, +) -> str: + """Build CSV from result rows; column order follows Explorer API docs when view_type is known.""" + if not rows: + return "" + vt = view_type if view_type is not None else _infer_view_type_from_rows(rows) + fieldnames, canonical_set = _csv_fieldnames_for_explorer_rows(rows, vt) + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for row in rows: + attrs = row.attributes + row_out: dict[str, Any] = {} + for name in fieldnames: + if name in canonical_set: + row_out[name] = _explorer_attribute_value(attrs, name) + else: + row_out[name] = attrs.get(name, "") + writer.writerow(row_out) + 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]: + """Execute an Explorer query and iterate result rows across all pages. + + Args: + organization: Organization slug that owns the Explorer data. + options: Query options including view type, filters, sort, and paging. + + Yields: + ExplorerRow items returned by the Explorer endpoint. + """ + _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: + """Run an Explorer query and return CSV text from the export endpoint. + + Args: + organization: Organization slug that owns the Explorer data. + options: Query options including view type, filters, sort, and paging. + + Returns: + Raw CSV text returned by the server. + """ + _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]: + """Iterate all saved Explorer views in an organization. + + Args: + organization: Organization slug that owns the saved views. + + Yields: + ExplorerSavedView resources from the list endpoint. + """ + _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: + """Create a saved Explorer view. + + Args: + organization: Organization slug that owns the saved view. + options: Saved-view name and query definition to persist. + + Returns: + The created ExplorerSavedView as returned by the API. + """ + _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: + """Read one saved Explorer view by id. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + + Returns: + The saved view definition and query metadata. + """ + _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: + """Replace attributes of an existing saved Explorer view. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + options: Updated name and full replacement query definition. + + Returns: + The updated ExplorerSavedView as returned by the API. + """ + _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) -> None: + """Delete a saved Explorer view. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + + Returns: + None. + """ + _require_organization_and_view(organization, view_id) + path = f"/api/v2/organizations/{organization}/explorer/views/{view_id}" + self.t.request("DELETE", path) + + def saved_view_results( + self, organization: str, view_id: str + ) -> Iterator[ExplorerRow]: + """Execute a saved view and iterate result rows across all pages. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + + Yields: + ExplorerRow items produced by the saved query. + """ + _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: + """Return CSV for a saved view with resilient fallback behavior. + + Tries the dedicated saved-view CSV endpoint first, then falls back to replaying + the saved view through ``export_csv`` and finally to materializing rows from the + paginated results endpoint. + + Args: + organization: Organization slug that owns the saved view. + view_id: Saved-view id (for example, ``sq-...``). + + Returns: + CSV text for the saved view results. + """ + _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) + csv_text = resp.text + try: + parsed = list(csv.reader(io.StringIO(csv_text))) + except csv.Error: + return csv_text + if parsed and parsed[0]: + vt = _infer_view_type_from_csv_header(parsed[0]) + if vt is not None: + csv_text = _normalize_explorer_csv_column_order(csv_text, vt) + return csv_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. + saved_for_csv: ExplorerSavedView | None = None + try: + saved_for_csv = self.read_saved_view(organization, view_id) + options = _query_options_from_saved_view(saved_for_csv) + csv_text = self.export_csv(organization, options) + csv_text = _normalize_explorer_csv_column_order( + csv_text, saved_for_csv.query_type + ) + _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)) + vt = saved_for_csv.query_type if saved_for_csv is not None else None + return _rows_to_csv(rows, view_type=vt) diff --git a/src/pytfe/resources/organization_token.py b/src/pytfe/resources/organization_token.py new file mode 100644 index 00000000..dcbcfb28 --- /dev/null +++ b/src/pytfe/resources/organization_token.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from urllib.parse import quote + +from ..errors import ERR_INVALID_ORG +from ..models.organization_token import ( + OrganizationToken, + OrganizationTokenCreateOptions, + OrganizationTokenDeleteOptions, + OrganizationTokenReadOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class OrganizationTokens(_Service): + """Organization tokens service for managing TFE organization tokens.""" + + def create(self, organization: str) -> OrganizationToken: + """Create a new organization token, replacing any existing token. + + Args: + organization: The organization name or ID + + Returns: + OrganizationToken: The created organization token + + Raises: + ValueError: If the organization name is invalid + """ + return self.create_with_options(organization) + + def create_with_options( + self, + organization: str, + options: OrganizationTokenCreateOptions | None = None, + ) -> OrganizationToken: + """Create a new organization token with options, replacing any existing token. + + Args: + organization: The organization name or ID + options: Options for creating the token + + Returns: + OrganizationToken: The created organization token + + Raises: + ValueError: If the organization name is invalid + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/authentication-token" + + # Build request body + body: dict[str, Any] = { + "data": { + "type": "authentication-token", + "attributes": {}, + } + } + + # Add optional attributes + if options and options.expired_at is not None: + body["data"]["attributes"]["expired-at"] = options.expired_at.isoformat() + + # Add query parameters for token type if specified + params = {} + if options and options.token_type is not None: + params["token"] = options.token_type.value + + if params: + response = self.t.request("POST", path, json_body=body, params=params) + else: + response = self.t.request("POST", path, json_body=body) + + data = response.json() + + if "data" in data: + return self._parse_organization_token(data["data"]) + + raise ValueError("Invalid response format") + + def read(self, organization: str) -> OrganizationToken: + """Read an organization token. + + Args: + organization: The organization name or ID + + Returns: + OrganizationToken: The organization token + + Raises: + ValueError: If the organization name is invalid + """ + return self.read_with_options(organization, None) + + def read_with_options( + self, + organization: str, + options: OrganizationTokenReadOptions | None = None, + ) -> OrganizationToken: + """Read an organization token with options. + + Args: + organization: The organization name or ID + options: Options for reading the token + + Returns: + OrganizationToken: The organization token + + Raises: + ValueError: If the organization name is invalid + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/authentication-token" + + # Add query parameters for token type if specified + params = {} + if options and options.token_type is not None: + params["token"] = options.token_type.value + + response = self.t.request("GET", path, params=params if params else None) + data = response.json() + + if "data" in data: + return self._parse_organization_token(data["data"]) + + raise ValueError("Invalid response format") + + def delete(self, organization: str) -> None: + """Delete an organization token. + + Args: + organization: The organization name or ID + + Raises: + ValueError: If the organization name is invalid + """ + return self.delete_with_options(organization, None) + + def delete_with_options( + self, + organization: str, + options: OrganizationTokenDeleteOptions | None = None, + ) -> None: + """Delete an organization token with options. + + Args: + organization: The organization name or ID + options: Options for deleting the token + + Raises: + ValueError: If the organization name is invalid + """ + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + + path = f"/api/v2/organizations/{quote(organization)}/authentication-token" + + # Add query parameters for token type if specified + params = {} + if options and options.token_type is not None: + params["token"] = options.token_type.value + + if params: + self.t.request("DELETE", path, params=params) + else: + self.t.request("DELETE", path) + + def _parse_organization_token(self, data: dict[str, Any]) -> OrganizationToken: + """Parse organization token data from API response. + + Args: + data: The token data from the API response + + Returns: + OrganizationToken: The parsed organization token + """ + attributes = data.get("attributes", {}) + + # Parse timestamps + created_at_str = attributes.get("created-at") + created_at = ( + datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) + if created_at_str + else datetime.now() + ) + + last_used_at_str = attributes.get("last-used-at") + last_used_at = ( + datetime.fromisoformat(last_used_at_str.replace("Z", "+00:00")) + if last_used_at_str + else None + ) + + expired_at_str = attributes.get("expired-at") + expired_at = ( + datetime.fromisoformat(expired_at_str.replace("Z", "+00:00")) + if expired_at_str + else None + ) + + # Parse created-by relationship + created_by = None + # For now, just set to None since it's mainly for display + + return OrganizationToken( + id=data.get("id", ""), + created_at=created_at, + description=attributes.get("description", ""), + last_used_at=last_used_at, + token=attributes.get("token", ""), + expired_at=expired_at, + created_by=created_by, + ) diff --git a/src/pytfe/resources/registry_provider.py b/src/pytfe/resources/registry_provider.py index d4ae122b..e9f2b48d 100644 --- a/src/pytfe/resources/registry_provider.py +++ b/src/pytfe/resources/registry_provider.py @@ -61,9 +61,6 @@ def create( if not valid_string_id(organization): raise ValueError(ERR_INVALID_ORG) - if not self._validate_create_options(options): - raise ValueError("Invalid create options") - path = f"/api/v2/organizations/{organization}/registry-providers" # Prepare the data payload @@ -88,9 +85,6 @@ def read( options: RegistryProviderReadOptions | None = None, ) -> RegistryProvider: """Read a specific registry provider.""" - if not self._validate_provider_id(provider_id): - raise ValueError("Invalid provider ID") - path = ( f"/api/v2/organizations/{provider_id.organization_name}/" f"registry-providers/{provider_id.registry_name.value}/" @@ -107,9 +101,6 @@ def read( def delete(self, provider_id: RegistryProviderID) -> None: """Delete a registry provider.""" - if not self._validate_provider_id(provider_id): - raise ValueError("Invalid provider ID") - path = ( f"/api/v2/organizations/{provider_id.organization_name}/" f"registry-providers/{provider_id.registry_name.value}/" @@ -118,28 +109,6 @@ def delete(self, provider_id: RegistryProviderID) -> None: self.t.request("DELETE", path) - def _validate_provider_id(self, provider_id: RegistryProviderID) -> bool: - """Validate a registry provider ID.""" - if not valid_string_id(provider_id.organization_name): - return False - if not valid_string_id(provider_id.name): - return False - if not valid_string_id(provider_id.namespace): - return False - if provider_id.registry_name not in [RegistryName.PRIVATE, RegistryName.PUBLIC]: - return False - return True - - def _validate_create_options(self, options: RegistryProviderCreateOptions) -> bool: - """Validate create options.""" - if not valid_string_id(options.name): - return False - if not valid_string_id(options.namespace): - return False - if options.registry_name not in [RegistryName.PRIVATE, RegistryName.PUBLIC]: - return False - return True - def _parse_registry_provider(self, data: dict[str, Any]) -> RegistryProvider: """Parse a registry provider from API response data.""" if data is None: diff --git a/src/pytfe/resources/registry_provider_platform.py b/src/pytfe/resources/registry_provider_platform.py new file mode 100644 index 00000000..a25c8e17 --- /dev/null +++ b/src/pytfe/resources/registry_provider_platform.py @@ -0,0 +1,106 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..models.registry_provider_platform import ( + RegistryProviderPlatform, + RegistryProviderPlatformCreateOptions, + RegistryProviderPlatformID, + RegistryProviderPlatformListOptions, +) +from ..models.registry_provider_version import ( + RegistryProviderVersion, + RegistryProviderVersionID, +) +from ._base import _Service + + +class RegistryProviderPlatforms(_Service): + """Service for managing Terraform registry provider platforms.""" + + def create( + self, + version_id: RegistryProviderVersionID, + options: RegistryProviderPlatformCreateOptions, + ) -> RegistryProviderPlatform: + """Create a registry provider platform""" + path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}/platforms" + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = { + "data": { + "type": "registry-provider-platforms", + "attributes": attributes, + } + } + r = self.t.request("POST", path=path, json_body=payload) + data = r.json().get("data", {}) + return self._registry_provider_platform_from(data) + + def list( + self, + version_id: RegistryProviderVersionID, + options: RegistryProviderPlatformListOptions | None = None, + ) -> Iterator[RegistryProviderPlatform]: + """List registry provider platforms for a specific version""" + path = ( + f"/api/v2/organizations/{version_id.organization_name}" + f"/registry-providers/{version_id.registry_name.value}" + f"/{version_id.namespace}/{version_id.name}" + f"/versions/{version_id.version}/platforms" + ) + params = options.model_dump(by_alias=True) if options else {} + for item in self._list(path=path, params=params): + yield self._registry_provider_platform_from(item) + + def read(self, platform_id: RegistryProviderPlatformID) -> RegistryProviderPlatform: + """Read a specific registry provider platform""" + path = ( + f"/api/v2/organizations/{platform_id.organization_name}" + f"/registry-providers/{platform_id.registry_name.value}" + f"/{platform_id.namespace}/{platform_id.name}" + f"/versions/{platform_id.version}" + f"/platforms/{platform_id.os}/{platform_id.arch}" + ) + r = self.t.request("GET", path=path) + data = r.json().get("data", {}) + return self._registry_provider_platform_from(data) + + def delete(self, platform_id: RegistryProviderPlatformID) -> None: + """Delete a specific registry provider platform""" + path = ( + f"/api/v2/organizations/{platform_id.organization_name}" + f"/registry-providers/{platform_id.registry_name.value}" + f"/{platform_id.namespace}/{platform_id.name}" + f"/versions/{platform_id.version}" + f"/platforms/{platform_id.os}/{platform_id.arch}" + ) + self.t.request("DELETE", path=path) + return None + + def _registry_provider_platform_from( + self, data: dict[str, Any] + ) -> RegistryProviderPlatform: + """Parse a registry provider platform from API response data.""" + attrs = data.get("attributes", {}) + relationships = data.get("relationships", {}) + attrs["id"] = data.get("id") + + if ( + "registry-provider-version" in relationships + and "data" in relationships["registry-provider-version"] + and relationships["registry-provider-version"]["data"] is not None + ): + attrs["registry-provider-version"] = ( + RegistryProviderVersion.model_construct( + id=relationships["registry-provider-version"]["data"].get("id") + ) + ) + + if "links" in data: + attrs["links"] = data["links"] + + return RegistryProviderPlatform.model_validate(attrs) diff --git a/src/pytfe/resources/registry_provider_version.py b/src/pytfe/resources/registry_provider_version.py index 08735c48..03156afe 100644 --- a/src/pytfe/resources/registry_provider_version.py +++ b/src/pytfe/resources/registry_provider_version.py @@ -11,15 +11,16 @@ ) from ..models.registry_provider import ( RegistryName, + RegistryProvider, RegistryProviderID, ) +from ..models.registry_provider_platform import RegistryProviderPlatform from ..models.registry_provider_version import ( RegistryProviderVersion, RegistryProviderVersionCreateOptions, RegistryProviderVersionID, RegistryProviderVersionListOptions, ) -from ..utils import valid_string_id from ._base import _Service @@ -32,9 +33,6 @@ def create( options: RegistryProviderVersionCreateOptions, ) -> RegistryProviderVersion: """Create a registry provider version""" - if not self._validate_provider_id(provider_id): - raise ValueError("Invalid provider ID") - if provider_id.registry_name != RegistryName.PRIVATE: raise RequiredPrivateRegistryError() path = f"/api/v2/organizations/{provider_id.organization_name}/registry-providers/{provider_id.registry_name.value}/{provider_id.namespace}/{provider_id.name}/versions" @@ -53,18 +51,6 @@ def create( data = r.json().get("data", {}) return self._registry_provider_version_from(data) - def _validate_provider_id(self, provider_id: RegistryProviderID) -> bool: - """Validate a registry provider ID.""" - if not valid_string_id(provider_id.organization_name): - return False - if not valid_string_id(provider_id.name): - return False - if not valid_string_id(provider_id.namespace): - return False - if provider_id.registry_name not in [RegistryName.PRIVATE, RegistryName.PUBLIC]: - return False - return True - def _registry_provider_version_from( self, data: dict[str, Any] ) -> RegistryProviderVersion: @@ -74,16 +60,22 @@ def _registry_provider_version_from( relationships = data.get("relationships", {}) attrs["id"] = data.get("id") - # Parse relationships + # Parse relationships as typed stubs if "registry-provider" in relationships: - attrs["registry_provider"] = relationships["registry-provider"].get( - "data", {} - ) + rp_data = relationships["registry-provider"].get("data") + if rp_data and rp_data.get("id"): + attrs["registry_provider"] = RegistryProvider.model_construct( + id=rp_data["id"] + ) if "platforms" in relationships: - attrs["registry_provider_platforms"] = relationships["platforms"].get( - "data", [] - ) + platforms_data = relationships["platforms"].get("data", []) + if platforms_data: + attrs["registry_provider_platforms"] = [ + RegistryProviderPlatform.model_construct(id=p["id"]) + for p in platforms_data + if p.get("id") + ] return RegistryProviderVersion.model_validate(attrs) @@ -93,9 +85,6 @@ def list( options: RegistryProviderVersionListOptions | None = None, ) -> Iterator[RegistryProviderVersion]: """List registry provider versions""" - if not self._validate_provider_id(provider_id): - raise ValueError("Invalid provider ID") - path = f"/api/v2/organizations/{provider_id.organization_name}/registry-providers/{provider_id.registry_name.value}/{provider_id.namespace}/{provider_id.name}/versions" params = options.model_dump(by_alias=True) if options else {} for item in self._list(path=path, params=params): @@ -103,9 +92,6 @@ def list( def read(self, version_id: RegistryProviderVersionID) -> RegistryProviderVersion: """Read a specific registry provider version""" - if not self._validate_provider_id(version_id): - raise ValueError("Invalid provider ID") - path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}" r = self.t.request( "GET", @@ -116,9 +102,6 @@ def read(self, version_id: RegistryProviderVersionID) -> RegistryProviderVersion def delete(self, version_id: RegistryProviderVersionID) -> None: """Delete a specific registry provider version""" - if not self._validate_provider_id(version_id): - raise ValueError("Invalid provider ID") - path = f"/api/v2/organizations/{version_id.organization_name}/registry-providers/{version_id.registry_name.value}/{version_id.namespace}/{version_id.name}/versions/{version_id.version}" self.t.request( "DELETE", diff --git a/src/pytfe/resources/stack.py b/src/pytfe/resources/stack.py new file mode 100644 index 00000000..f683fd17 --- /dev/null +++ b/src/pytfe/resources/stack.py @@ -0,0 +1,142 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator + +from pytfe.models import ( + AgentPool, + Project, +) + +from ..models.stack import ( + Stack, + StackCreateOptions, + StackListOptions, + StackUpdateOptions, + StackVcsRepo, +) +from ._base import _Service + + +class Stacks(_Service): + def create(self, options: StackCreateOptions) -> Stack: + """Create a new stack within a project.""" + payload = { + "data": { + "attributes": options.model_dump( + by_alias=True, exclude_none=True, exclude={"project", "agent_pool"} + ), + "type": "stacks", + "relationships": {}, + } + } + relationships = {} + if options.project: + relationships["project"] = { + "data": {"id": options.project.id, "type": "projects"} + } + if options.agent_pool: + relationships["agent-pool"] = { + "data": {"id": options.agent_pool.id, "type": "agent-pools"} + } + payload["data"]["relationships"] = relationships + r = self.t.request( + "POST", + path="/api/v2/stacks", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._stack_from(data) + + def update(self, stack_id: str, options: StackUpdateOptions) -> Stack: + """Update an existing stack.""" + payload = { + "data": { + "attributes": options.model_dump( + by_alias=True, + exclude_none=True, + exclude={"agent_pool", "project"}, + ), + "type": "stacks", + "relationships": {}, + } + } + relationships = {} + if options.project: + relationships.update( + {"project": {"data": {"id": options.project.id, "type": "projects"}}} + ) + if options.agent_pool: + relationships.update( + { + "agent-pool": { + "data": {"id": options.agent_pool.id, "type": "agent-pools"} + } + } + ) + payload["data"]["relationships"] = relationships + r = self.t.request( + "PATCH", + path=f"/api/v2/stacks/{stack_id}", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._stack_from(data) + + def list(self, organization: str, options: StackListOptions) -> Iterator[Stack]: + """List stacks within an organization, with optional filtering by project.""" + params = options.model_dump(by_alias=True, exclude_none=True) + path = f"/api/v2/organizations/{organization}/stacks" + for item in self._list(path, params=params): + yield self._stack_from(item) + + def read(self, stack_id: str) -> Stack: + """Read a stack by ID.""" + r = self.t.request( + "GET", + path=f"/api/v2/stacks/{stack_id}", + ) + data = r.json().get("data", {}) + return self._stack_from(data) + + def delete(self, stack_id: str) -> None: + """Delete a stack by ID.""" + self.t.request( + "DELETE", + path=f"/api/v2/stacks/{stack_id}", + ) + return None + + def force_delete(self, stack_id: str) -> None: + """ForceDelete deletes a stack that still has deployments.""" + self.t.request( + "DELETE", + path=f"/api/v2/stacks/{stack_id}?force=true", + ) + return None + + def fetch_latest_from_vcs(self, stack_id: str) -> Stack: + """FetchLatestFromVcs updates the configuration of a stack, triggering stack preparation.""" + path = f"/api/v2/stacks/{stack_id}/fetch-latest-from-vcs" + r = self.t.request("POST", path=path) + data = r.json().get("data", {}) + return self._stack_from(data) + + def _stack_from(self, data: dict) -> Stack: + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + relationships = data.get("relationships", {}) + vcs_repo_raw = attrs.get("vcs-repo") + if vcs_repo_raw: + attrs["vcs_repo"] = StackVcsRepo.model_validate(vcs_repo_raw) + else: + attrs["vcs_repo"] = None + project_data = relationships.get("project", {}).get("data", {}) + agent_pool_data = relationships.get("agent-pool", {}).get("data", {}) + if isinstance(project_data, dict) and project_data.get("id"): + attrs["project"] = Project(id=project_data["id"]) + if isinstance(agent_pool_data, dict) and agent_pool_data.get("id"): + attrs["agent_pool"] = AgentPool(id=agent_pool_data["id"]) + return Stack.model_validate(attrs) diff --git a/src/pytfe/resources/stack_configuration.py b/src/pytfe/resources/stack_configuration.py new file mode 100644 index 00000000..3b672b23 --- /dev/null +++ b/src/pytfe/resources/stack_configuration.py @@ -0,0 +1,98 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from pytfe.models.configuration_version import IngressAttributes + +from ..models.stack import Stack +from ..models.stack_configuration import ( + StackConfiguration, + StackConfigurationCreateOptions, + StackConfigurationListOptions, + StackConfigurationReadOptions, + StackConfigurationSource, +) +from ._base import _Service + + +class StackConfigurations(_Service): + """Service for managing Terraform stack configurations.""" + + def create( + self, + stack_id: str, + options: StackConfigurationCreateOptions | None = None, + source: StackConfigurationSource = StackConfigurationSource.MANUAL, + ) -> StackConfiguration: + """Create a stack configuration for the given stack.""" + path = f"/api/v2/stacks/{stack_id}/stack-configurations" + params: dict[str, str] = {} + if source != StackConfigurationSource.MANUAL: + params["source"] = source.value + + attributes: dict[str, Any] = {} + if options: + attributes = options.model_dump(by_alias=True, exclude_none=True) + + payload = { + "data": { + "type": "stack-configurations", + "attributes": attributes, + } + } + r = self.t.request("POST", path=path, json_body=payload, params=params) + data = r.json().get("data", {}) + return self._stack_configuration_from(data) + + def list( + self, + stack_id: str, + options: StackConfigurationListOptions | None = None, + ) -> Iterator[StackConfiguration]: + """List stack configurations for the given stack.""" + path = f"/api/v2/stacks/{stack_id}/stack-configurations" + params: dict[str, Any] = {} + if options: + if options.page_size is not None: + params["page[size]"] = options.page_size + if options.include: + params["include"] = ",".join([i.value for i in options.include]) + for item in self._list(path=path, params=params): + yield self._stack_configuration_from(item) + + def read( + self, + stack_configuration_id: str, + options: StackConfigurationReadOptions | None = None, + ) -> StackConfiguration: + """Read a stack configuration by its ID.""" + path = f"/api/v2/stack-configurations/{stack_configuration_id}" + params: dict[str, str] = {} + if options and options.include: + params["include"] = ",".join([i.value for i in options.include]) + r = self.t.request("GET", path=path, params=params) + data = r.json().get("data", {}) + return self._stack_configuration_from(data) + + def _stack_configuration_from(self, data: dict[str, Any]) -> StackConfiguration: + """Parse a StackConfiguration from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + relationships = data.get("relationships", {}) + + stack_data = relationships.get("stack", {}).get("data") + if stack_data and stack_data.get("id"): + attrs["stack"] = Stack.model_validate({"id": stack_data["id"]}) + ingress_attributes_data = relationships.get("ingress-attributes", {}).get( + "data" + ) + if ingress_attributes_data and ingress_attributes_data.get("id"): + attrs["ingress_attributes"] = IngressAttributes.model_validate( + {"id": ingress_attributes_data["id"]} + ) + + return StackConfiguration.model_validate(attrs) diff --git a/src/pytfe/resources/state_versions.py b/src/pytfe/resources/state_versions.py index e98e13bb..8ff07403 100644 --- a/src/pytfe/resources/state_versions.py +++ b/src/pytfe/resources/state_versions.py @@ -7,7 +7,9 @@ from typing import Any from urllib.parse import urlencode +from ..errors import ErrStateVersionUploadNotSupported from ..errors import NotFound +from ..errors import TFEError # Pydantic models for this feature from ..models.state_version import ( @@ -193,18 +195,66 @@ def create( **{k.replace("-", "_"): v for k, v in attr.items()}, ) - """ def upload( self, workspace: str, *, - raw_state: bytes | None = None, + raw_state: bytes | None, raw_json_state: bytes | None = None, - options: Optional[StateVersionCreateOptions] = None, - organization: Optional[str] = None, + options: StateVersionCreateOptions, + organization: str | None = None, ) -> StateVersion: - # TBD: Implements Upload State Functionality - """ + """ + Create a state version and upload state bytes to signed Archivist URLs. + + This mirrors Terraform's recommended workflow: + 1. POST /workspaces/:id/state-versions with serial+md5 and no inline state + 2. PUT raw state bytes to hosted-state-upload-url + 3. Optional PUT JSON state bytes to hosted-json-state-upload-url + 4. Read the state version again and return the refreshed object + """ + if raw_state is None: + raise ValueError("raw_state is required") + if options.state is not None or options.json_state is not None: + raise ValueError( + "options.state and options.json_state must be omitted when using upload" + ) + + try: + sv = self.create(workspace, options, organization=organization) + except TFEError as exc: + # Older servers can reject the create-without-inline-state flow. + if "param is missing or the value is empty: state" in str(exc): + raise ErrStateVersionUploadNotSupported( + "state version upload is not supported by this server" + ) from exc + raise + + if not sv.hosted_state_upload_url: + raise ErrStateVersionUploadNotSupported( + "hosted-state-upload-url not returned by server" + ) + + self.t.request( + "PUT", + sv.hosted_state_upload_url, + data=raw_state, + headers={"Content-Type": "application/octet-stream"}, + ) + + if raw_json_state is not None: + if not sv.hosted_json_state_upload_url: + raise ErrStateVersionUploadNotSupported( + "hosted-json-state-upload-url not returned by server" + ) + self.t.request( + "PUT", + sv.hosted_json_state_upload_url, + data=raw_json_state, + headers={"Content-Type": "application/octet-stream"}, + ) + + return self.read(sv.id) def download(self, state_version_id: str) -> bytes: """ @@ -226,9 +276,12 @@ def download(self, state_version_id: str) -> bytes: raise NotFound("download url not available for this state version") # Download the bytes from the signed Archivist URL (follow redirects). - # Avoid JSON:API headers here; Accept */* is fine. + # Avoid API default headers here; Accept */* is fine. resp = self.t.request( - "GET", url, allow_redirects=True, headers={"Accept": "application/json"} + "GET", + url, + allow_redirects=True, + headers={"Accept": "*/*"}, ) return resp.content @@ -244,7 +297,10 @@ def download_current(self, workspace_id: str) -> bytes: raise NotFound("download url not available for current state") resp = self.t.request( - "GET", url, allow_redirects=True, headers={"Accept": "*/*"} + "GET", + url, + allow_redirects=True, + headers={"Accept": "*/*"}, ) return resp.content diff --git a/src/pytfe/resources/team.py b/src/pytfe/resources/team.py new file mode 100644 index 00000000..37df9876 --- /dev/null +++ b/src/pytfe/resources/team.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from collections.abc import Iterator + +from ..errors import ( + ERR_INVALID_ORG, + InvalidTeamIDError, +) +from ..models.organization_membership import OrganizationMembership +from ..models.team import ( + Team, + TeamCreateOptions, + TeamListOptions, + TeamUpdateOptions, +) +from ..models.user import User +from ..utils import valid_string_id +from ._base import _Service + + +class Teams(_Service): + def list( + self, organization: str, options: TeamListOptions | None = None + ) -> Iterator[Team]: + """List all teams in the given organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + params = ( + options.model_dump(by_alias=True, exclude_none=True, exclude={"include"}) + if options + else {} + ) + if options and options.include: + params["include"] = ",".join([opt.value for opt in options.include]) + path = f"/api/v2/organizations/{organization}/teams" + for item in self._list(path, params=params): + yield self._team_from(item) + + def _team_from(self, data: dict) -> Team: + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + + relationships = data.get("relationships", {}) + + users_data = relationships.get("users", {}).get("data", []) + attrs["users"] = [ + User.model_validate({"id": user_data.get("id")}) + for user_data in users_data + if user_data.get("id") + ] + attrs["organization-memberships"] = [ + OrganizationMembership.model_validate({"id": om_data.get("id")}) + for om_data in relationships.get("organization-memberships", {}).get( + "data", [] + ) + if om_data.get("id") + ] + + return Team.model_validate(attrs) + + def create(self, organization: str, options: TeamCreateOptions) -> Team: + """Create a new team in the given organization.""" + if not valid_string_id(organization): + raise ValueError(ERR_INVALID_ORG) + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = {"data": {"attributes": attributes, "type": "teams"}} + r = self.t.request( + "POST", + path=f"/api/v2/organizations/{organization}/teams", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._team_from(data) + + def update(self, team_id: str, options: TeamUpdateOptions) -> Team: + """Update a team by its ID.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = {"data": {"attributes": attributes, "type": "teams"}} + r = self.t.request( + "PATCH", + path=f"/api/v2/teams/{team_id}", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._team_from(data) + + def read(self, team_id: str) -> Team: + """Read a single team by its ID.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + r = self.t.request( + "GET", + path=f"/api/v2/teams/{team_id}", + ) + data = r.json().get("data", {}) + return self._team_from(data) + + def delete(self, team_id: str) -> None: + """Delete a team by its ID.""" + if not valid_string_id(team_id): + raise InvalidTeamIDError() + self.t.request( + "DELETE", + path=f"/api/v2/teams/{team_id}", + ) + return None diff --git a/src/pytfe/resources/team_project_access.py b/src/pytfe/resources/team_project_access.py new file mode 100644 index 00000000..746fc59f --- /dev/null +++ b/src/pytfe/resources/team_project_access.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from collections.abc import Iterator + +from ..errors import InvalidTeamProjectAccessIDError +from ..models.project import Project +from ..models.team import Team +from ..models.team_project_access import ( + ProjectSettingsPermissionType, + ProjectTeamsPermissionType, + ProjectVariableSetsPermissionType, + TeamProjectAccess, + TeamProjectAccessAddOptions, + TeamProjectAccessListOptions, + TeamProjectAccessProjectPermissions, + TeamProjectAccessType, + TeamProjectAccessUpdateOptions, + TeamProjectAccessWorkspacePermissions, + WorkspaceRunsPermissionType, + WorkspaceSentinelMocksPermissionType, + WorkspaceStateVersionsPermissionType, + WorkspaceVariablesPermissionType, +) +from ..utils import valid_string_id +from ._base import _Service + + +class TeamProjectAccesses(_Service): + def add(self, options: TeamProjectAccessAddOptions) -> TeamProjectAccess: + """Add a team access for a project.""" + attributes = options.model_dump( + by_alias=True, exclude_none=True, exclude={"team", "project"} + ) + relationships = { + "team": {"data": {"id": options.team.id, "type": "teams"}} + if options.team + else None, + "project": {"data": {"id": options.project.id, "type": "projects"}} + if options.project + else None, + } + payload = { + "data": { + "attributes": attributes, + "relationships": relationships, + "type": "team-project-access", + } + } + r = self.t.request( + "POST", + path="/api/v2/team-projects", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._team_project_access_from(data) + + def _team_project_access_from(self, data: dict) -> TeamProjectAccess: + attrs = data.get("attributes", {}) + attrs["id"] = data.get("id") + attrs["access"] = ( + TeamProjectAccessType(attrs.get("access")) if attrs.get("access") else None + ) + + if attrs.get("project-access"): + project_access: dict[str, object] = {} + project_access["project_variable_sets_permission"] = ( + ProjectVariableSetsPermissionType( + attrs.get("project-access").get("variable-sets") + ) + ) + project_access["project_settings_permission"] = ( + ProjectSettingsPermissionType( + attrs.get("project-access").get("settings") + ) + ) + project_access["project_teams_permission"] = ProjectTeamsPermissionType( + attrs.get("project-access").get("teams") + ) + attrs["project_access"] = ( + TeamProjectAccessProjectPermissions.model_validate(project_access) + ) + if attrs.get("workspace-access"): + workspace_access: dict[str, object] = {} + workspace_access["runs"] = WorkspaceRunsPermissionType( + attrs.get("workspace-access").get("runs") + ) + workspace_access["sentinel_mocks"] = WorkspaceSentinelMocksPermissionType( + attrs.get("workspace-access").get("sentinel-mocks") + ) + workspace_access["state_versions"] = WorkspaceStateVersionsPermissionType( + attrs.get("workspace-access").get("state-versions") + ) + workspace_access["variables"] = WorkspaceVariablesPermissionType( + attrs.get("workspace-access").get("variables") + ) + workspace_access["run_tasks"] = attrs.get("workspace-access").get( + "run-tasks" + ) + workspace_access["move"] = attrs.get("workspace-access").get("move") + workspace_access["locking"] = attrs.get("workspace-access").get("locking") + workspace_access["delete"] = attrs.get("workspace-access").get("delete") + workspace_access["create"] = attrs.get("workspace-access").get("create") + attrs["workspace_access"] = ( + TeamProjectAccessWorkspacePermissions.model_validate(workspace_access) + ) + + relationships = data.get("relationships", {}) + team_data = relationships.get("team", {}).get("data", {}) + project_data = relationships.get("project", {}).get("data", {}) + attrs["team"] = Team(id=team_data.get("id")) if team_data else None + attrs["project"] = Project(id=project_data.get("id")) if project_data else None + + return TeamProjectAccess.model_validate(attrs) + + def update( + self, team_project_access_id: str, options: TeamProjectAccessUpdateOptions + ) -> TeamProjectAccess: + """Update a team access for a project.""" + if not valid_string_id(team_project_access_id): + raise InvalidTeamProjectAccessIDError() + attributes = options.model_dump(by_alias=True, exclude_none=True) + payload = { + "data": { + "attributes": attributes, + "type": "team-project-access", + } + } + r = self.t.request( + "PATCH", + path=f"/api/v2/team-projects/{team_project_access_id}", + json_body=payload, + ) + data = r.json().get("data", {}) + return self._team_project_access_from(data) + + def read(self, team_project_access_id: str) -> TeamProjectAccess: + """Read a team access for a project.""" + if not valid_string_id(team_project_access_id): + raise InvalidTeamProjectAccessIDError() + r = self.t.request( + "GET", + path=f"/api/v2/team-projects/{team_project_access_id}", + ) + data = r.json().get("data", {}) + return self._team_project_access_from(data) + + def list( + self, options: TeamProjectAccessListOptions + ) -> Iterator[TeamProjectAccess]: + """List team accesses for projects.""" + params = options.model_dump(by_alias=True, exclude_none=True) + path = "/api/v2/team-projects" + for item in self._list(path, params=params): + yield self._team_project_access_from(item) + + def remove(self, team_project_access_id: str) -> None: + """Remove a team access for a project.""" + if not valid_string_id(team_project_access_id): + raise InvalidTeamProjectAccessIDError() + self.t.request( + "DELETE", + path=f"/api/v2/team-projects/{team_project_access_id}", + ) + return None diff --git a/src/pytfe/resources/user.py b/src/pytfe/resources/user.py new file mode 100644 index 00000000..ab5a7e3b --- /dev/null +++ b/src/pytfe/resources/user.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from ..models.user import User, UserUpdateCurrentOptions +from ..utils import valid_string_id +from ._base import _Service + + +class Users(_Service): + def read(self, user_id: str) -> User: + if not valid_string_id(user_id): + raise ValueError("invalid user id") + + r = self.t.request("GET", f"/api/v2/users/{user_id}") + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + user_data = dict(attr) + user_data["id"] = d.get("id") + return User(**user_data) + + def read_current(self) -> User: + r = self.t.request("GET", "/api/v2/account/details") + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + user_data = dict(attr) + user_data["id"] = d.get("id") + return User(**user_data) + + def update_current(self, options: UserUpdateCurrentOptions) -> User: + body = { + "data": { + "type": "users", + "attributes": options.model_dump(exclude_none=True), + } + } + r = self.t.request("PATCH", "/api/v2/account/update", json_body=body) + d = r.json()["data"] + attr = d.get("attributes", {}) or {} + user_data = dict(attr) + user_data["id"] = d.get("id") + return User(**user_data) diff --git a/tests/units/test_comment.py b/tests/units/test_comment.py new file mode 100644 index 00000000..8e6d5b9a --- /dev/null +++ b/tests/units/test_comment.py @@ -0,0 +1,164 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the comment module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidCommentIDError, + InvalidRunIDError, + RequiredCommentBodyError, +) +from pytfe.models.comment import Comment, CommentCreateOptions +from pytfe.resources.comment import Comments + + +class TestComments: + """Test the Comments service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a Comments service with mocked transport.""" + return Comments(mock_transport) + + @pytest.fixture + def comment_api_data(self): + """Typical API response for a single comment.""" + return { + "id": "com-abc123", + "type": "comments", + "attributes": { + "body": "This is a test comment.", + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_create_options_valid(self): + """CommentCreateOptions accepts a valid body.""" + opts = CommentCreateOptions(body="Hello world") + assert opts.body == "Hello world" + + def test_create_options_empty_body_raises(self): + """CommentCreateOptions raises RequiredCommentBodyError when body is empty.""" + with pytest.raises(RequiredCommentBodyError): + CommentCreateOptions(body="") + + def test_create_options_serializes_with_alias(self): + """CommentCreateOptions serialises using the API alias.""" + opts = CommentCreateOptions(body="My comment") + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"body": "My comment"} + + def test_comment_model_fields(self): + """Comment model stores id and body.""" + c = Comment(id="com-123", body="test") + assert c.id == "com-123" + assert c.body == "test" + + def test_comment_model_default_body(self): + """Comment body defaults to empty string.""" + c = Comment(id="com-123") + assert c.body == "" + + # ── Parser tests ───────────────────────────────────────────────────────── + + def test_comment_from_full_data(self, service, comment_api_data): + """_comment_from parses id and body from API data.""" + result = service._comment_from(comment_api_data) + + assert isinstance(result, Comment) + assert result.id == "com-abc123" + assert result.body == "This is a test comment." + + def test_comment_from_missing_body(self, service): + """_comment_from handles missing body attribute gracefully.""" + data = {"id": "com-xyz", "attributes": {}} + result = service._comment_from(data) + + assert result.id == "com-xyz" + assert result.body == "" + + # ── Resource method tests ───────────────────────────────────────────────── + + def test_list_success(self, service, comment_api_data): + """list() yields Comment objects from paginated results.""" + service._list = Mock(return_value=[comment_api_data]) + + results = list(service.list(run_id="run-abc123")) + + service._list.assert_called_once_with(path="/api/v2/runs/run-abc123/comments") + assert len(results) == 1 + assert isinstance(results[0], Comment) + assert results[0].id == "com-abc123" + assert results[0].body == "This is a test comment." + + def test_list_empty(self, service): + """list() returns empty iterator when no comments exist.""" + service._list = Mock(return_value=[]) + + results = list(service.list(run_id="run-abc123")) + assert results == [] + + def test_list_invalid_run_id(self, service): + """list() raises InvalidRunIDError for a bad run ID.""" + with pytest.raises(InvalidRunIDError): + list(service.list(run_id="not valid!")) + + def test_read_success(self, service, mock_transport, comment_api_data): + """read() GETs the correct path and returns a Comment.""" + mock_response = Mock() + mock_response.json.return_value = {"data": comment_api_data} + mock_transport.request.return_value = mock_response + + result = service.read(comment_id="com-abc123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/comments/com-abc123" + ) + assert isinstance(result, Comment) + assert result.id == "com-abc123" + assert result.body == "This is a test comment." + + def test_read_invalid_comment_id(self, service): + """read() raises InvalidCommentIDError for a bad comment ID.""" + with pytest.raises(InvalidCommentIDError): + service.read(comment_id="not valid!") + + def test_create_success(self, service, mock_transport, comment_api_data): + """create() POSTs the correct payload and returns a Comment.""" + mock_response = Mock() + mock_response.json.return_value = {"data": comment_api_data} + mock_transport.request.return_value = mock_response + + opts = CommentCreateOptions(body="This is a test comment.") + result = service.create(run_id="run-abc123", options=opts) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/runs/run-abc123/comments", + json_body={ + "data": { + "type": "comments", + "attributes": {"body": "This is a test comment."}, + } + }, + ) + assert isinstance(result, Comment) + assert result.id == "com-abc123" + assert result.body == "This is a test comment." + + def test_create_invalid_run_id(self, service): + """create() raises InvalidRunIDError for a bad run ID.""" + opts = CommentCreateOptions(body="Hello") + with pytest.raises(InvalidRunIDError): + service.create(run_id="not valid!", options=opts) diff --git a/tests/units/test_explorer.py b/tests/units/test_explorer.py new file mode 100644 index 00000000..a0b0dd27 --- /dev/null +++ b/tests/units/test_explorer.py @@ -0,0 +1,661 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for Explorer API resource.""" + +import csv +from unittest.mock import Mock, call + +import pytest + +from pytfe.errors import ( + InvalidExplorerSavedViewIDError, + InvalidOrgError, + NotFound, + ServerError, + ValidationError, +) +from pytfe.models import ( + ExplorerQueryOptions, + ExplorerRow, + ExplorerSavedQuery, + ExplorerSavedQueryFilter, + ExplorerSavedViewCreateOptions, + ExplorerSavedViewUpdateOptions, + ExplorerUrlFilter, + ExplorerViewType, +) +from pytfe.resources.explorer import ( + Explorer, + _normalize_explorer_csv_column_order, + _rows_to_csv, +) + +ORG = "acme" +VIEW_ID = "sq-1" +EXPLORER_PATH = f"/api/v2/organizations/{ORG}/explorer" +VIEWS_PATH = f"{EXPLORER_PATH}/views" + + +@pytest.fixture +def mock_transport(): + return Mock() + + +@pytest.fixture +def explorer_service(mock_transport): + return Explorer(mock_transport) + + +def test_normalize_explorer_csv_column_order_workspaces(): + raw = "workspace_name,all_checks_succeeded\ndemo,true\n" + out = _normalize_explorer_csv_column_order(raw, ExplorerViewType.WORKSPACES) + assert out.splitlines()[0].startswith("all_checks_succeeded,workspace_name") + + +def test_rows_to_csv_workspace_column_order_matches_doc(): + """Fallback CSV header matches Explorer export/csv workspaces sample column order.""" + rows = [ + ExplorerRow.model_validate( + { + "id": "ws-1", + "type": "visibility-workspace", + "attributes": {"workspace-name": "demo-workspace"}, + } + ) + ] + csv_text = _rows_to_csv(rows, view_type=ExplorerViewType.WORKSPACES) + header = csv_text.strip().splitlines()[0] + assert header.startswith( + "all_checks_succeeded,current_rum_count,checks_errored,checks_failed," + "checks_passed,checks_unknown,current_run_applied_at,current_run_external_id," + "current_run_status,drifted,external_id,module_count,modules,organization_name," + "project_external_id,project_name,provider_count,providers,resources_drifted," + "resources_undrifted,state_version_terraform_version,vcs_repo_identifier," + "workspace_created_at,workspace_name,workspace_terraform_version,workspace_updated_at" + ) + assert "demo-workspace" in csv_text + + +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": []}, + }, + }, + } + + +def _assert_single_request_call( + mock_transport, method: str, path: str, **kwargs +) -> None: + mock_transport.request.assert_called_once_with(method, path, **kwargs) + + +def _query_request_params(page_number: int) -> dict: + return { + "type": "workspaces", + "sort": "-workspace_name", + "fields": "workspace_name,organization_name", + "page[size]": 1, + "filter[0][workspace_name][contains][0]": "test", + "page[number]": page_number, + } + + +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": [_row_payload("ws-2")]} + third = Mock() + third.json.return_value = {"data": [_row_payload("ws-3")]} + fourth = Mock() + fourth.json.return_value = {"data": []} + mock_transport.request.side_effect = [first, second, third, fourth] + + 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(ORG, options)) + assert len(rows) == 3 + assert [row.id for row in rows] == ["ws-1", "ws-2", "ws-3"] + assert all(row.row_type == "visibility-workspace" for row in rows) + + expected_calls = [ + call("GET", EXPLORER_PATH, params=_query_request_params(page_number=1)), + call("GET", EXPLORER_PATH, params=_query_request_params(page_number=2)), + call("GET", EXPLORER_PATH, params=_query_request_params(page_number=3)), + call("GET", EXPLORER_PATH, params=_query_request_params(page_number=4)), + ] + mock_transport.request.assert_has_calls(expected_calls) + assert mock_transport.request.call_count == 4 + + def test_query_uses_pagination_meta_when_server_caps_page_size( + self, explorer_service, mock_transport + ): + first = Mock() + first.json.return_value = { + "data": [_row_payload("ws-1"), _row_payload("ws-2")], + "meta": { + "pagination": { + "current-page": 1, + "page-size": 2, + "next-page": 2, + "total-pages": 2, + } + }, + } + second = Mock() + second.json.return_value = { + "data": [_row_payload("ws-3")], + "meta": { + "pagination": { + "current-page": 2, + "page-size": 2, + "next-page": None, + "total-pages": 2, + } + }, + } + mock_transport.request.side_effect = [first, second] + + options = ExplorerQueryOptions( + view_type=ExplorerViewType.WORKSPACES, + page_size=50, + ) + + rows = list(explorer_service.query(ORG, options)) + assert [row.id for row in rows] == ["ws-1", "ws-2", "ws-3"] + + expected_calls = [ + call( + "GET", + EXPLORER_PATH, + params={"type": "workspaces", "page[size]": 50, "page[number]": 1}, + ), + call( + "GET", + EXPLORER_PATH, + params={"type": "workspaces", "page[size]": 50, "page[number]": 2}, + ), + ] + mock_transport.request.assert_has_calls(expected_calls) + assert mock_transport.request.call_count == 2 + + def test_query_uses_current_and_total_pages_when_next_page_missing( + self, explorer_service, mock_transport + ): + first = Mock() + first.json.return_value = { + "data": [_row_payload("ws-1")], + "meta": {"pagination": {"current-page": 1, "total-pages": 2}}, + } + second = Mock() + second.json.return_value = { + "data": [_row_payload("ws-2")], + "meta": {"pagination": {"current-page": 2, "total-pages": 2}}, + } + mock_transport.request.side_effect = [first, second] + + rows = list( + explorer_service.query( + ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + ) + assert [row.id for row in rows] == ["ws-1", "ws-2"] + + expected_calls = [ + call( + "GET", + EXPLORER_PATH, + params={"type": "workspaces", "page[number]": 1, "page[size]": 100}, + ), + call( + "GET", + EXPLORER_PATH, + params={"type": "workspaces", "page[number]": 2, "page[size]": 100}, + ), + ] + mock_transport.request.assert_has_calls(expected_calls) + assert mock_transport.request.call_count == 2 + + def test_query_stops_when_pagination_meta_does_not_advance( + self, explorer_service, mock_transport + ): + first = Mock() + first.json.return_value = { + "data": [_row_payload("ws-1")], + "meta": {"pagination": {"current-page": 1, "total-pages": 2}}, + } + second = Mock() + second.json.return_value = { + "data": [_row_payload("ws-1")], + "meta": {"pagination": {"current-page": 1, "total-pages": 2}}, + } + mock_transport.request.side_effect = [first, second] + + rows = list( + explorer_service.query( + ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + ) + assert [row.id for row in rows] == ["ws-1", "ws-1"] + assert mock_transport.request.call_count == 2 + + def test_query_stops_on_empty_page_even_if_next_page_present( + self, explorer_service, mock_transport + ): + first = Mock() + first.json.return_value = { + "data": [], + "meta": { + "pagination": {"current-page": 1, "next-page": 2, "total-pages": 5} + }, + } + mock_transport.request.return_value = first + + rows = list( + explorer_service.query( + ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + ) + assert rows == [] + assert mock_transport.request.call_count == 1 + + def test_query_invalid_org(self, explorer_service): + with pytest.raises(InvalidOrgError): + list( + explorer_service.query( + "", + ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES), + ) + ) + + @pytest.mark.parametrize("org", ["", None]) + def test_export_csv_invalid_org(self, explorer_service, org): + with pytest.raises(InvalidOrgError): + explorer_service.export_csv( + org, + 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( + ORG, ExplorerQueryOptions(view_type=ExplorerViewType.WORKSPACES) + ) + + assert "workspace_name" in csv_text + _assert_single_request_call( + mock_transport, + "GET", + f"{EXPLORER_PATH}/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(ORG)) + 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(ORG, options) + + assert view.id == "sq-new" + call = mock_transport.request.call_args + assert call[0][0] == "POST" + assert call[0][1] == VIEWS_PATH + 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(ORG, 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(ORG, VIEW_ID) + + 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(ORG, VIEW_ID) + assert view.id == "sq-1" + + _assert_single_request_call(mock_transport, "GET", f"{VIEWS_PATH}/{VIEW_ID}") + + 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(ORG, "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(ORG, VIEW_ID, options) + + assert view.id == "sq-1" + expected_body = { + "data": { + "type": "explorer-saved-queries", + "id": VIEW_ID, + "attributes": { + "name": "my-view-updated", + "query": { + "type": "workspaces", + "filter": [{"workspace_name": {"contains": ["prod"]}}], + }, + }, + } + } + _assert_single_request_call( + mock_transport, + "PATCH", + f"{VIEWS_PATH}/{VIEW_ID}", + json_body=expected_body, + ) + + def test_update_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 = ExplorerSavedViewUpdateOptions( + name="my-view-updated", + query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ) + with pytest.raises(ValidationError, match="update_saved_view"): + explorer_service.update_saved_view(ORG, VIEW_ID, options) + + @pytest.mark.parametrize("payload", [[], "bad-payload", {"data": []}]) + def test_update_saved_view_invalid_data_shape_raises( + self, explorer_service, mock_transport, payload + ): + response = Mock() + response.json.return_value = payload + mock_transport.request.return_value = response + + options = ExplorerSavedViewUpdateOptions( + name="my-view-updated", + query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ) + with pytest.raises(ValidationError, match="update_saved_view"): + explorer_service.update_saved_view(ORG, VIEW_ID, options) + + def test_delete_saved_view(self, explorer_service, mock_transport): + result = explorer_service.delete_saved_view(ORG, VIEW_ID) + assert result is None + + _assert_single_request_call(mock_transport, "DELETE", f"{VIEWS_PATH}/{VIEW_ID}") + + def test_delete_saved_view_ignores_response_body( + self, explorer_service, mock_transport + ): + response = Mock() + response.text = '{"data":{"id":"unexpected"}}' + response.json.side_effect = ValueError("No JSON body") + mock_transport.request.return_value = response + + result = explorer_service.delete_saved_view(ORG, VIEW_ID) + assert result is None + + 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(ORG, VIEW_ID)) + assert len(rows) == 1 + assert rows[0].id == "ws-1" + + mock_transport.request.assert_any_call( + "GET", + f"{VIEWS_PATH}/{VIEW_ID}/results", + params={"page[number]": 1, "page[size]": 100}, + ) + + def test_saved_view_results_csv(self, explorer_service, mock_transport): + csv_resp = Mock() + csv_resp.text = "workspace_name,all_checks_succeeded\ndemo,true\n" + mock_transport.request.return_value = csv_resp + + csv_text = explorer_service.saved_view_results_csv(ORG, VIEW_ID) + assert csv_text.splitlines()[0].startswith( + "all_checks_succeeded,workspace_name" + ) + _assert_single_request_call( + mock_transport, "GET", f"{VIEWS_PATH}/{VIEW_ID}/csv" + ) + + def test_saved_view_results_csv_invalid_csv_returns_raw( + self, explorer_service, mock_transport, monkeypatch + ): + csv_resp = Mock() + csv_resp.text = "raw-csv" + mock_transport.request.return_value = csv_resp + + def _raise_csv_error(*_args, **_kwargs): + raise csv.Error("invalid csv") + + monkeypatch.setattr("pytfe.resources.explorer.csv.reader", _raise_csv_error) + + csv_text = explorer_service.saved_view_results_csv(ORG, VIEW_ID) + assert csv_text == "raw-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(ORG, VIEW_ID) + assert "from-export" in csv_text + + def test_saved_view_results_csv_server_error_fallback_to_export( + self, explorer_service, mock_transport + ): + first = ServerError("server error", status=500) + 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(ORG, VIEW_ID) + 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(ORG, VIEW_ID) + header = csv_text.strip().splitlines()[0] + assert header.startswith( + "all_checks_succeeded,current_rum_count,checks_errored,checks_failed," + "checks_passed,checks_unknown,current_run_applied_at,current_run_external_id," + "current_run_status,drifted,external_id,module_count,modules,organization_name," + "project_external_id,project_name,provider_count,providers,resources_drifted," + "resources_undrifted,state_version_terraform_version,vcs_repo_identifier," + "workspace_created_at,workspace_name,workspace_terraform_version,workspace_updated_at" + ) + 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, VIEW_ID) + + @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(ORG, view_id) + + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.update_saved_view( + ORG, + view_id, + ExplorerSavedViewUpdateOptions( + name="updated", + query=ExplorerSavedQuery(query_type=ExplorerViewType.WORKSPACES), + ), + ) + + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.delete_saved_view(ORG, view_id) + + with pytest.raises(InvalidExplorerSavedViewIDError): + list(explorer_service.saved_view_results(ORG, view_id)) + + with pytest.raises(InvalidExplorerSavedViewIDError): + explorer_service.saved_view_results_csv(ORG, view_id) diff --git a/tests/units/test_organization_token.py b/tests/units/test_organization_token.py new file mode 100644 index 00000000..826f2239 --- /dev/null +++ b/tests/units/test_organization_token.py @@ -0,0 +1,313 @@ +"""Unit tests for the organization token module.""" + +from datetime import datetime +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ERR_INVALID_ORG +from pytfe.models.organization_token import ( + OrganizationToken, + OrganizationTokenCreateOptions, + OrganizationTokenDeleteOptions, + OrganizationTokenReadOptions, + TokenType, +) +from pytfe.resources.organization_token import OrganizationTokens + + +class TestOrganizationTokens: + """Test the OrganizationTokens service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def org_tokens_service(self, mock_transport): + """Create an OrganizationTokens service with mocked transport.""" + return OrganizationTokens(mock_transport) + + def test_create_success(self, org_tokens_service): + """Test successful create operation.""" + mock_response_data = { + "data": { + "id": "at-test123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "description": "Test token", + "token": "test-token-value", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + result = org_tokens_service.create("test-org") + + mock_t.request.assert_called_once() + call_args = mock_t.request.call_args + + assert call_args[0][0] == "POST" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + assert "json_body" in call_args[1] + assert "data" in call_args[1]["json_body"] + assert "attributes" in call_args[1]["json_body"]["data"] + assert isinstance(result, OrganizationToken) + assert result.id == "at-test123" + assert result.description == "Test token" + + def test_create_validation_errors(self, org_tokens_service): + """Test create with invalid organization name.""" + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.create("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.create(None) + + def test_create_with_options_expiration_success(self, org_tokens_service): + """Test create with options including expiration date.""" + mock_response_data = { + "data": { + "id": "at-exp-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "token": "token-value", + "expired-at": "2024-01-01T00:00:00Z", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + expiry = datetime(2024, 1, 1, 0, 0, 0) + options = OrganizationTokenCreateOptions(expired_at=expiry) + + result = org_tokens_service.create_with_options("test-org", options) + + assert isinstance(result, OrganizationToken) + assert result.expired_at is not None + + call_args = mock_t.request.call_args + assert call_args[0][0] == "POST" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + body = call_args[1]["json_body"] + assert "expired-at" in body["data"]["attributes"] + assert body["data"]["attributes"]["expired-at"] == "2024-01-01T00:00:00" + + def test_create_with_options_token_type_success(self, org_tokens_service): + """Test create with options including token type.""" + mock_response_data = { + "data": { + "id": "at-audit-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "token": "audit-token-value", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + options = OrganizationTokenCreateOptions(token_type=TokenType.AUDIT_TRAILS) + result = org_tokens_service.create_with_options("test-org", options) + + assert isinstance(result, OrganizationToken) + call_args = mock_t.request.call_args + assert call_args[0][0] == "POST" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + assert "params" in call_args[1] + assert call_args[1]["params"]["token"] == "audit-trails" + assert "json_body" in call_args[1] + + def test_read_success(self, org_tokens_service): + """Test successful read operation.""" + mock_response_data = { + "data": { + "id": "at-read-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "description": "Read token", + "token": "read-token-value", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + result = org_tokens_service.read("test-org") + + assert isinstance(result, OrganizationToken) + assert result.id == "at-read-123" + + call_args = mock_t.request.call_args + assert call_args[0][0] == "GET" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + + def test_read_validation_errors(self, org_tokens_service): + """Test read with invalid organization name.""" + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.read("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.read(None) + + def test_read_with_options_token_type_success(self, org_tokens_service): + """Test read with options including token type.""" + mock_response_data = { + "data": { + "id": "at-audit-read-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "token": "audit-read-value", + }, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + options = OrganizationTokenReadOptions(token_type=TokenType.AUDIT_TRAILS) + result = org_tokens_service.read_with_options("test-org", options) + + assert isinstance(result, OrganizationToken) + call_args = mock_t.request.call_args + assert call_args[0][0] == "GET" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + assert call_args[1]["params"]["token"] == "audit-trails" + + def test_delete_success(self, org_tokens_service): + """Test successful delete operation.""" + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = Mock() + + result = org_tokens_service.delete("test-org") + + assert result is None + call_args = mock_t.request.call_args + assert call_args[0][0] == "DELETE" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + + def test_delete_validation_errors(self, org_tokens_service): + """Test delete with invalid organization name.""" + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.delete("") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + org_tokens_service.delete(None) + + def test_delete_with_options_token_type_success(self, org_tokens_service): + """Test delete with options including token type.""" + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = Mock() + + options = OrganizationTokenDeleteOptions(token_type=TokenType.AUDIT_TRAILS) + result = org_tokens_service.delete_with_options("test-org", options) + + assert result is None + call_args = mock_t.request.call_args + assert call_args[0][0] == "DELETE" + assert ( + call_args[0][1] == "/api/v2/organizations/test-org/authentication-token" + ) + assert call_args[1]["params"]["token"] == "audit-trails" + + def test_parse_token_minimal(self, org_tokens_service): + """Test parsing token with minimal data.""" + data = { + "id": "at-minimal-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "description": "Minimal token", + "token": "minimal-value", + }, + "relationships": {}, + } + + result = org_tokens_service._parse_organization_token(data) + + assert result.id == "at-minimal-123" + assert isinstance(result.created_at, datetime) + assert result.description == "Minimal token" + assert result.token == "minimal-value" + assert result.last_used_at is None + assert result.expired_at is None + + def test_parse_token_all_fields(self, org_tokens_service): + """Test parsing token with all fields populated.""" + data = { + "id": "at-full-123", + "attributes": { + "created-at": "2023-01-01T00:00:00Z", + "description": "Full token", + "token": "full-value", + "last-used-at": "2023-01-15T12:30:00Z", + "expired-at": "2024-01-01T00:00:00Z", + }, + "relationships": {}, + } + + result = org_tokens_service._parse_organization_token(data) + + assert result.id == "at-full-123" + assert result.description == "Full token" + assert result.token == "full-value" + assert result.last_used_at is not None + assert result.expired_at is not None + assert isinstance(result.last_used_at, datetime) + assert isinstance(result.expired_at, datetime) + + def test_invalid_response_format_on_create(self, org_tokens_service): + """Test handling of invalid response format when creating.""" + mock_response = Mock() + mock_response.json.return_value = {"error": "Invalid"} + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + with pytest.raises(ValueError, match="Invalid response format"): + org_tokens_service.create("test-org") + + def test_invalid_response_format_on_read(self, org_tokens_service): + """Test handling of invalid response format when reading.""" + mock_response = Mock() + mock_response.json.return_value = {"error": "Invalid"} + + with patch.object(org_tokens_service, "t") as mock_t: + mock_t.request.return_value = mock_response + + with pytest.raises(ValueError, match="Invalid response format"): + org_tokens_service.read("test-org") diff --git a/tests/units/test_registry_provider_platform.py b/tests/units/test_registry_provider_platform.py new file mode 100644 index 00000000..817157aa --- /dev/null +++ b/tests/units/test_registry_provider_platform.py @@ -0,0 +1,392 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the registry_provider_platform module.""" + +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidArchError, + InvalidNameError, + InvalidNamespaceError, + InvalidOrgError, + InvalidOSError, + InvalidVersionError, + RequiredArchError, + RequiredFilenameError, + RequiredOSError, + RequiredPrivateRegistryError, + RequiredShasumError, +) +from pytfe.models.registry_provider import RegistryName +from pytfe.models.registry_provider_platform import ( + RegistryProviderPlatform, + RegistryProviderPlatformCreateOptions, + RegistryProviderPlatformID, + RegistryProviderPlatformListOptions, +) +from pytfe.models.registry_provider_version import ( + RegistryProviderVersion, + RegistryProviderVersionID, +) +from pytfe.resources.registry_provider_platform import RegistryProviderPlatforms + + +class TestRegistryProviderPlatforms: + """Test the RegistryProviderPlatforms service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def platforms_service(self, mock_transport): + """Create a RegistryProviderPlatforms service with mocked transport.""" + return RegistryProviderPlatforms(mock_transport) + + @pytest.fixture + def valid_version_id(self): + """Create a valid version ID.""" + return RegistryProviderVersionID( + organization_name="test-org", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + version="1.0.0", + ) + + @pytest.fixture + def valid_platform_id(self): + """Create a valid platform ID.""" + return RegistryProviderPlatformID( + organization_name="test-org", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + version="1.0.0", + os="linux", + arch="amd64", + ) + + @pytest.fixture + def platform_api_data(self): + """Typical API response data for a single platform.""" + return { + "id": "provpltfrm-123", + "type": "registry-provider-platforms", + "attributes": { + "os": "linux", + "arch": "amd64", + "filename": "terraform-provider-test_1.0.0_linux_amd64.zip", + "shasum": "abc123def456", + "provider-binary-uploaded": False, + "permissions": { + "can-delete": True, + "can-upload-asset": True, + }, + }, + "relationships": { + "registry-provider-version": { + "data": { + "id": "provver-456", + "type": "registry-provider-versions", + } + } + }, + "links": { + "provider-binary-upload": "https://example.com/upload", + }, + } + + # ------------------------------------------------------------------------- + # ID validation tests + # ------------------------------------------------------------------------- + + def test_invalid_platform_id_fields(self): + """Test RegistryProviderPlatformID raises correct error for each invalid field.""" + base = { + "organization_name": "test-org", + "registry_name": RegistryName.PRIVATE, + "namespace": "test-namespace", + "name": "test-provider", + "version": "1.0.0", + "os": "linux", + "arch": "amd64", + } + with pytest.raises(InvalidOrgError): + RegistryProviderPlatformID(**{**base, "organization_name": ""}) + with pytest.raises(InvalidOrgError): + RegistryProviderPlatformID(**{**base, "organization_name": " "}) + with pytest.raises(InvalidNameError): + RegistryProviderPlatformID(**{**base, "name": ""}) + with pytest.raises(InvalidNamespaceError): + RegistryProviderPlatformID(**{**base, "namespace": ""}) + with pytest.raises(InvalidVersionError): + RegistryProviderPlatformID(**{**base, "version": ""}) + with pytest.raises(RequiredPrivateRegistryError): + RegistryProviderPlatformID(**{**base, "registry_name": RegistryName.PUBLIC}) + with pytest.raises(InvalidOSError): + RegistryProviderPlatformID(**{**base, "os": ""}) + with pytest.raises(InvalidArchError): + RegistryProviderPlatformID(**{**base, "arch": ""}) + + def test_valid_platform_id(self, valid_platform_id): + """Test RegistryProviderPlatformID with valid data.""" + assert valid_platform_id.organization_name == "test-org" + assert valid_platform_id.registry_name == RegistryName.PRIVATE + assert valid_platform_id.namespace == "test-namespace" + assert valid_platform_id.name == "test-provider" + assert valid_platform_id.version == "1.0.0" + assert valid_platform_id.os == "linux" + assert valid_platform_id.arch == "amd64" + + # ------------------------------------------------------------------------- + # CreateOptions validation tests + # ------------------------------------------------------------------------- + + def test_create_options_invalid_fields(self): + """Test RegistryProviderPlatformCreateOptions raises correct error for each invalid field.""" + base = { + "os": "linux", + "arch": "amd64", + "shasum": "abc123", + "filename": "provider.zip", + } + with pytest.raises(RequiredOSError): + RegistryProviderPlatformCreateOptions(**{**base, "os": ""}) + with pytest.raises(RequiredArchError): + RegistryProviderPlatformCreateOptions(**{**base, "arch": ""}) + with pytest.raises(RequiredShasumError): + RegistryProviderPlatformCreateOptions(**{**base, "shasum": ""}) + with pytest.raises(RequiredFilenameError): + RegistryProviderPlatformCreateOptions(**{**base, "filename": ""}) + + def test_create_options_valid(self): + """Test RegistryProviderPlatformCreateOptions with valid data.""" + options = RegistryProviderPlatformCreateOptions( + os="linux", + arch="amd64", + shasum="abc123def456", + filename="terraform-provider-test_1.0.0_linux_amd64.zip", + ) + assert options.os == "linux" + assert options.arch == "amd64" + assert options.shasum == "abc123def456" + assert options.filename == "terraform-provider-test_1.0.0_linux_amd64.zip" + + # ------------------------------------------------------------------------- + # create() + # ------------------------------------------------------------------------- + + def test_create_platform_success( + self, platforms_service, valid_version_id, mock_transport, platform_api_data + ): + """Test successful create operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": platform_api_data} + mock_transport.request.return_value = mock_response + + options = RegistryProviderPlatformCreateOptions( + os="linux", + arch="amd64", + shasum="abc123def456", + filename="terraform-provider-test_1.0.0_linux_amd64.zip", + ) + + result = platforms_service.create(valid_version_id, options) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0/platforms", + json_body={ + "data": { + "type": "registry-provider-platforms", + "attributes": { + "os": "linux", + "arch": "amd64", + "shasum": "abc123def456", + "filename": "terraform-provider-test_1.0.0_linux_amd64.zip", + }, + } + }, + ) + + assert isinstance(result, RegistryProviderPlatform) + assert result.id == "provpltfrm-123" + assert result.os == "linux" + assert result.arch == "amd64" + assert result.shasum == "abc123def456" + assert result.provider_binary_uploaded is False + assert result.permissions.can_delete is True + assert result.permissions.can_upload_asset is True + + # ------------------------------------------------------------------------- + # list() + # ------------------------------------------------------------------------- + + def test_list_platforms_success( + self, platforms_service, valid_version_id, platform_api_data + ): + """Test successful list operation.""" + second = {**platform_api_data, "id": "provpltfrm-456"} + second["attributes"] = { + **platform_api_data["attributes"], + "os": "darwin", + "arch": "arm64", + } + + with patch.object( + platforms_service, "_list", return_value=[platform_api_data, second] + ): + result = list(platforms_service.list(valid_version_id)) + + assert len(result) == 2 + assert result[0].id == "provpltfrm-123" + assert result[0].os == "linux" + assert result[0].arch == "amd64" + assert result[1].id == "provpltfrm-456" + assert result[1].os == "darwin" + assert result[1].arch == "arm64" + + def test_list_platforms_with_options( + self, platforms_service, valid_version_id, mock_transport, platform_api_data + ): + """Test list operation passes page_size param.""" + mock_response = Mock() + mock_response.json.return_value = {"data": [platform_api_data]} + mock_transport.request.return_value = mock_response + + options = RegistryProviderPlatformListOptions(page_size=10) + + with patch.object( + platforms_service, "_list", return_value=[platform_api_data] + ) as mock_list: + result = list(platforms_service.list(valid_version_id, options)) + mock_list.assert_called_once_with( + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0/platforms", + params={"page[size]": 10}, + ) + + assert len(result) == 1 + + def test_list_platforms_empty(self, platforms_service, valid_version_id): + """Test list operation returns empty iterator when no platforms exist.""" + with patch.object(platforms_service, "_list", return_value=[]): + result = list(platforms_service.list(valid_version_id)) + + assert result == [] + + # ------------------------------------------------------------------------- + # read() + # ------------------------------------------------------------------------- + + def test_read_platform_success( + self, platforms_service, valid_platform_id, mock_transport, platform_api_data + ): + """Test successful read operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": platform_api_data} + mock_transport.request.return_value = mock_response + + result = platforms_service.read(valid_platform_id) + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0/platforms/linux/amd64", + ) + + assert isinstance(result, RegistryProviderPlatform) + assert result.id == "provpltfrm-123" + assert result.os == "linux" + assert result.arch == "amd64" + + # ------------------------------------------------------------------------- + # delete() + # ------------------------------------------------------------------------- + + def test_delete_platform_success( + self, platforms_service, valid_platform_id, mock_transport + ): + """Test successful delete operation.""" + result = platforms_service.delete(valid_platform_id) + + mock_transport.request.assert_called_once_with( + "DELETE", + path="/api/v2/organizations/test-org/registry-providers/private/test-namespace/test-provider/versions/1.0.0/platforms/linux/amd64", + ) + + assert result is None + + # ------------------------------------------------------------------------- + # _registry_provider_platform_from() + # ------------------------------------------------------------------------- + + def test_platform_from_full_data(self, platforms_service, platform_api_data): + """Test _registry_provider_platform_from with full API response including relationships and links.""" + result = platforms_service._registry_provider_platform_from(platform_api_data) + + assert isinstance(result, RegistryProviderPlatform) + assert result.id == "provpltfrm-123" + assert result.os == "linux" + assert result.arch == "amd64" + assert result.filename == "terraform-provider-test_1.0.0_linux_amd64.zip" + assert result.shasum == "abc123def456" + assert result.provider_binary_uploaded is False + assert result.permissions.can_delete is True + assert result.permissions.can_upload_asset is True + # registry-provider-version relation parsed as typed stub + assert isinstance(result.registry_provider_version, RegistryProviderVersion) + assert result.registry_provider_version.id == "provver-456" + # links preserved + assert result.links is not None + assert "provider-binary-upload" in result.links + + def test_platform_from_no_relationships(self, platforms_service): + """Test _registry_provider_platform_from when relationships are absent.""" + data = { + "id": "provpltfrm-789", + "type": "registry-provider-platforms", + "attributes": { + "os": "windows", + "arch": "amd64", + "filename": "terraform-provider-test_1.0.0_windows_amd64.zip", + "shasum": "deadbeef", + "provider-binary-uploaded": True, + "permissions": { + "can-delete": False, + "can-upload-asset": False, + }, + }, + } + + result = platforms_service._registry_provider_platform_from(data) + + assert result.id == "provpltfrm-789" + assert result.os == "windows" + assert result.arch == "amd64" + assert result.registry_provider_version is None + assert result.links is None + + def test_platform_from_null_version_relationship(self, platforms_service): + """Test _registry_provider_platform_from when registry-provider-version data is null.""" + data = { + "id": "provpltfrm-abc", + "type": "registry-provider-platforms", + "attributes": { + "os": "linux", + "arch": "arm64", + "filename": "provider.zip", + "shasum": "abc123", + "provider-binary-uploaded": False, + "permissions": {"can-delete": True, "can-upload-asset": True}, + }, + "relationships": {"registry-provider-version": {"data": None}}, + } + + result = platforms_service._registry_provider_platform_from(data) + + assert result.registry_provider_version is None diff --git a/tests/units/test_registry_provider_version.py b/tests/units/test_registry_provider_version.py index e291e602..a1239fc0 100644 --- a/tests/units/test_registry_provider_version.py +++ b/tests/units/test_registry_provider_version.py @@ -10,11 +10,15 @@ from pytfe._http import HTTPTransport from pytfe.errors import ( InvalidKeyIDError, + InvalidNameError, + InvalidNamespaceError, + InvalidOrgError, InvalidVersionError, RequiredPrivateRegistryError, ) from pytfe.models.registry_provider import ( RegistryName, + RegistryProvider, RegistryProviderID, ) from pytfe.models.registry_provider_version import ( @@ -59,34 +63,52 @@ def valid_version_id(self): version="1.0.0", ) - def test_validate_provider_id_success(self, versions_service, valid_provider_id): - """Test _validate_provider_id with valid provider ID.""" - result = versions_service._validate_provider_id(valid_provider_id) - assert result is True - - def test_validate_provider_id_invalid_organization( - self, versions_service, valid_provider_id - ): - """Test _validate_provider_id with invalid organization name.""" - valid_provider_id.organization_name = "" - result = versions_service._validate_provider_id(valid_provider_id) - assert result is False - def test_create_version_validations(self, versions_service): - """Test create method validations.""" - # Test with invalid provider ID - invalid_provider_id = RegistryProviderID( - organization_name="", - registry_name=RegistryName.PRIVATE, - namespace="test-namespace", - name="test-provider", - ) - options = RegistryProviderVersionCreateOptions( - version="1.0.0", **{"key-id": "test-key-id"}, protocols=["5.0"] - ) + """Test create method raises error when constructing invalid provider ID.""" + with pytest.raises(InvalidOrgError): + RegistryProviderID( + organization_name="", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + ) - with pytest.raises(ValueError, match="Invalid provider ID"): - versions_service.create(invalid_provider_id, options) + def test_invalid_provider_id_fields(self): + """Test RegistryProviderID raises correct error for each invalid field.""" + base = { + "organization_name": "test-org", + "registry_name": RegistryName.PRIVATE, + "namespace": "test-namespace", + "name": "test-provider", + } + with pytest.raises(InvalidOrgError): + RegistryProviderID(**{**base, "organization_name": ""}) + with pytest.raises(InvalidOrgError): + RegistryProviderID(**{**base, "organization_name": " "}) + with pytest.raises(InvalidNameError): + RegistryProviderID(**{**base, "name": ""}) + with pytest.raises(InvalidNamespaceError): + RegistryProviderID(**{**base, "namespace": ""}) + + def test_invalid_version_id_fields(self): + """Test RegistryProviderVersionID raises correct error for each invalid field.""" + base = { + "organization_name": "test-org", + "registry_name": RegistryName.PRIVATE, + "namespace": "test-namespace", + "name": "test-provider", + "version": "1.0.0", + } + with pytest.raises(InvalidOrgError): + RegistryProviderVersionID(**{**base, "organization_name": ""}) + with pytest.raises(InvalidNameError): + RegistryProviderVersionID(**{**base, "name": ""}) + with pytest.raises(InvalidNamespaceError): + RegistryProviderVersionID(**{**base, "namespace": ""}) + with pytest.raises(InvalidVersionError): + RegistryProviderVersionID(**{**base, "version": ""}) + with pytest.raises(RequiredPrivateRegistryError): + RegistryProviderVersionID(**{**base, "registry_name": RegistryName.PUBLIC}) def test_create_version_requires_private_registry( self, versions_service, mock_transport @@ -240,17 +262,15 @@ def test_list_versions_success_without_options( assert result[1].shasums_uploaded is True def test_read_version_validations(self, versions_service): - """Test read method with invalid version ID.""" - invalid_version_id = RegistryProviderVersionID( - organization_name="", - registry_name=RegistryName.PRIVATE, - namespace="test-namespace", - name="test-provider", - version="1.0.0", - ) - - with pytest.raises(ValueError, match="Invalid provider ID"): - versions_service.read(invalid_version_id) + """Test read method raises error when constructing invalid version ID.""" + with pytest.raises(InvalidOrgError): + RegistryProviderVersionID( + organization_name="", + registry_name=RegistryName.PRIVATE, + namespace="test-namespace", + name="test-provider", + version="1.0.0", + ) def test_read_version_success( self, versions_service, valid_version_id, mock_transport @@ -359,10 +379,8 @@ def test_registry_provider_version_from_success(self, versions_service): assert result.id == "provver-123" assert result.version == "1.0.0" assert result.key_id == "test-key-id" - assert result.registry_provider == { - "id": "prov-123", - "type": "registry-providers", - } + assert isinstance(result.registry_provider, RegistryProvider) + assert result.registry_provider.id == "prov-123" assert result.registry_provider_platforms is not None assert len(result.registry_provider_platforms) == 2 diff --git a/tests/units/test_stack.py b/tests/units/test_stack.py new file mode 100644 index 00000000..40f57c2a --- /dev/null +++ b/tests/units/test_stack.py @@ -0,0 +1,339 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.models.agent import AgentPool +from pytfe.models.project import Project +from pytfe.models.stack import ( + Stack, + StackCreateOptions, + StackListOptions, + StackSortColumn, + StackUpdateOptions, + StackVcsRepoOptions, +) +from pytfe.resources.stack import Stacks + + +class TestStacks: + """Test the Stacks service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def stacks_service(self, mock_transport): + """Create a Stacks service with mocked transport.""" + return Stacks(mock_transport) + + @pytest.fixture + def stack_response_data(self): + """Return sample API response data for a stack.""" + return { + "id": "st-123", + "attributes": { + "name": "demo-stack", + "description": "Stack description", + "speculation-enabled": True, + "vcs-repo": { + "identifier": "hashicorp/terraform", + "branch": "main", + "oauth-token-id": "ot-123", + }, + }, + "relationships": { + "project": {"data": {"id": "prj-123", "type": "projects"}}, + "agent-pool": {"data": {"id": "apool-123", "type": "agent-pools"}}, + }, + } + + def test_list_stacks_success(self, stacks_service, stack_response_data): + """Test successful list operation.""" + stacks_service._list = Mock(return_value=[stack_response_data]) + + options = StackListOptions( + page_size=10, + project_id="prj-123", + sort=StackSortColumn.STACK_SORT_BY_NAME, + search_by_name="demo", + ) + + result_iter = stacks_service.list("org-123", options) + items = list(result_iter) + + stacks_service._list.assert_called_once_with( + "/api/v2/organizations/org-123/stacks", + params={ + "page[size]": 10, + "filter[project][id]": "prj-123", + "sort": "name", + "search[name]": "demo", + }, + ) + + assert len(items) == 1 + assert isinstance(items[0], Stack) + assert items[0].id == "st-123" + assert items[0].name == "demo-stack" + + def test_create_stack_success( + self, stacks_service, mock_transport, stack_response_data + ): + """Test successful create operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_response_data} + mock_transport.request.return_value = mock_response + + options = StackCreateOptions( + name="demo-stack", + description="Stack description", + speculation_enabled=True, + vcs_repo=StackVcsRepoOptions( + identifier="hashicorp/terraform", + branch="main", + oauth_token_id="ot-123", + ), + project=Project(id="prj-123"), + agent_pool=AgentPool(id="apool-123"), + ) + + result = stacks_service.create(options) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stacks", + json_body={ + "data": { + "attributes": { + "name": "demo-stack", + "description": "Stack description", + "speculation-enabled": True, + "vcs-repo": { + "identifier": "hashicorp/terraform", + "branch": "main", + "oauth-token-id": "ot-123", + }, + }, + "type": "stacks", + "relationships": { + "project": {"data": {"id": "prj-123", "type": "projects"}}, + "agent-pool": { + "data": {"id": "apool-123", "type": "agent-pools"} + }, + }, + } + }, + ) + + assert isinstance(result, Stack) + assert result.id == "st-123" + assert result.project.id == "prj-123" + assert result.agent_pool.id == "apool-123" + + def test_update_stack_success( + self, stacks_service, mock_transport, stack_response_data + ): + """Test successful update operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_response_data} + mock_transport.request.return_value = mock_response + + options = StackUpdateOptions( + description="Updated description", + vcs_repo=StackVcsRepoOptions( + identifier="hashicorp/terraform", + branch="main", + ), + project=Project(id="prj-123"), + agent_pool=AgentPool(id="apool-123"), + ) + + result = stacks_service.update("st-123", options) + + mock_transport.request.assert_called_once_with( + "PATCH", + path="/api/v2/stacks/st-123", + json_body={ + "data": { + "attributes": { + "description": "Updated description", + "vcs-repo": { + "identifier": "hashicorp/terraform", + "branch": "main", + }, + }, + "type": "stacks", + "relationships": { + "project": {"data": {"id": "prj-123", "type": "projects"}}, + "agent-pool": { + "data": {"id": "apool-123", "type": "agent-pools"} + }, + }, + } + }, + ) + + assert isinstance(result, Stack) + assert result.id == "st-123" + + def test_read_stack_success( + self, stacks_service, mock_transport, stack_response_data + ): + """Test successful read operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_response_data} + mock_transport.request.return_value = mock_response + + result = stacks_service.read("st-123") + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/stacks/st-123", + ) + + assert isinstance(result, Stack) + assert result.id == "st-123" + assert result.name == "demo-stack" + + def test_delete_stack_success(self, stacks_service, mock_transport): + """Test successful delete operation.""" + result = stacks_service.delete("st-123") + + mock_transport.request.assert_called_once_with( + "DELETE", + path="/api/v2/stacks/st-123", + ) + assert result is None + + def test_force_delete_stack_success(self, stacks_service, mock_transport): + """Test successful force-delete operation.""" + result = stacks_service.force_delete("st-123") + + mock_transport.request.assert_called_once_with( + "DELETE", + path="/api/v2/stacks/st-123?force=true", + ) + assert result is None + + def test_stack_from_handles_null_vcs_repo(self, stacks_service): + """Test parsing stack data when vcs-repo is null.""" + data = { + "id": "st-456", + "attributes": { + "name": "no-vcs-stack", + "vcs-repo": None, + }, + "relationships": { + "project": {"data": {"id": "prj-999", "type": "projects"}}, + }, + } + + result = stacks_service._stack_from(data) + + assert isinstance(result, Stack) + assert result.id == "st-456" + assert result.vcs_repo is None + assert result.project is not None + assert result.project.id == "prj-999" + assert result.agent_pool is None + + def test_stack_from_handles_missing_relationships(self, stacks_service): + """Test parsing stack data when relationship data is missing.""" + data = { + "id": "st-789", + "attributes": { + "name": "minimal-stack", + "vcs-repo": None, + }, + "relationships": { + "project": {"data": None}, + "agent-pool": {"data": None}, + }, + } + + result = stacks_service._stack_from(data) + + assert isinstance(result, Stack) + assert result.id == "st-789" + assert result.project is None + assert result.agent_pool is None + + def test_fetch_latest_from_vcs_success( + self, stacks_service, mock_transport, stack_response_data + ): + """Test successful fetch-latest-from-vcs operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_response_data} + mock_transport.request.return_value = mock_response + + result = stacks_service.fetch_latest_from_vcs("st-123") + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stacks/st-123/fetch-latest-from-vcs", + ) + assert isinstance(result, Stack) + assert result.id == "st-123" + + def test_create_stack_invalid_name(self): + """StackCreateOptions raises when name is empty.""" + with pytest.raises(ValueError): + StackCreateOptions( + name="", + project=Project(id="prj-123"), + ) + + def test_create_stack_invalid_project_id(self): + """StackCreateOptions raises when project id is empty.""" + with pytest.raises(ValueError): + StackCreateOptions( + name="demo-stack", + project=Project(id=""), + ) + + def test_list_stacks_no_options(self, stacks_service): + """list() works correctly with minimal options (no filter/sort).""" + stacks_service._list = Mock(return_value=[]) + + results = list( + stacks_service.list( + "org-123", + StackListOptions(), + ) + ) + + stacks_service._list.assert_called_once_with( + "/api/v2/organizations/org-123/stacks", + params={}, + ) + assert results == [] + + def test_stack_from_with_vcs_repo(self, stacks_service): + """_stack_from parses vcs-repo fields correctly.""" + data = { + "id": "st-vcs", + "attributes": { + "name": "vcs-stack", + "vcs-repo": { + "identifier": "hashicorp/terraform", + "branch": "main", + "oauth-token-id": "ot-abc", + }, + }, + "relationships": {}, + } + + result = stacks_service._stack_from(data) + + assert result.vcs_repo is not None + assert result.vcs_repo.identifier == "hashicorp/terraform" + assert result.vcs_repo.branch == "main" + assert result.vcs_repo.oauth_token_id == "ot-abc" diff --git a/tests/units/test_stack_configuration.py b/tests/units/test_stack_configuration.py new file mode 100644 index 00000000..f547bbef --- /dev/null +++ b/tests/units/test_stack_configuration.py @@ -0,0 +1,355 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_configuration module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.models.configuration_version import IngressAttributes +from pytfe.models.stack import Stack +from pytfe.models.stack_configuration import ( + StackComponent, + StackConfiguration, + StackConfigurationCreateOptions, + StackConfigurationIncludeOps, + StackConfigurationListOptions, + StackConfigurationReadOptions, + StackConfigurationSource, + StackConfigurationStatus, +) +from pytfe.resources.stack_configuration import StackConfigurations + + +class TestStackConfigurations: + """Test the StackConfigurations service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a StackConfigurations service with mocked transport.""" + return StackConfigurations(mock_transport) + + @pytest.fixture + def stack_configuration_api_data(self): + """Typical API response for a single stack configuration.""" + return { + "id": "stc-abc123", + "type": "stack-configurations", + "attributes": { + "status": "completed", + "sequence-number": 3, + "speculative": False, + "destroy-all": False, + "preparing-event-stream-url": "https://example.com/stream", + "created-at": "2026-05-07T11:32:17.031000+00:00", + "updated-at": "2026-05-07T11:32:50.500000+00:00", + "components": [ + { + "name": "simple_default", + "correlator": "simple_default", + "expanded": True, + "removed": False, + } + ], + }, + "relationships": { + "stack": {"data": {"id": "st-xyz789", "type": "stacks"}}, + "ingress-attributes": { + "data": {"id": "ia-111", "type": "ingress-attributes"} + }, + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_stack_component_defaults(self): + """StackComponent can be constructed with defaults.""" + comp = StackComponent() + assert comp.name == "" + assert comp.correlator == "" + assert comp.expanded is None + assert comp.removed is None + + def test_stack_component_full(self): + """StackComponent parses all fields.""" + comp = StackComponent.model_validate( + { + "name": "my_stack", + "correlator": "corr-1", + "expanded": True, + "removed": False, + } + ) + assert comp.name == "my_stack" + assert comp.correlator == "corr-1" + assert comp.expanded is True + assert comp.removed is False + + def test_stack_configuration_status_enum(self): + """StackConfigurationStatus values are correct.""" + assert StackConfigurationStatus.PENDING == "pending" + assert StackConfigurationStatus.QUEUED == "queued" + assert StackConfigurationStatus.PREPARING == "preparing" + assert StackConfigurationStatus.COMPLETED == "completed" + assert StackConfigurationStatus.FAILED == "failed" + + def test_stack_configuration_source_enum(self): + """StackConfigurationSource values are correct.""" + assert StackConfigurationSource.MANUAL == "manual" + assert StackConfigurationSource.FETCH == "fetch" + assert StackConfigurationSource.REUSE == "reuse" + + def test_stack_configuration_include_enum(self): + """StackConfigurationIncludeOps values are correct.""" + assert StackConfigurationIncludeOps.INGRESS_ATTRIBUTES == "ingress_attributes" + assert StackConfigurationIncludeOps.STACK_DIAGNOSTICS == "stack_diagnostics" + + def test_create_options_defaults(self): + """StackConfigurationCreateOptions has sane defaults.""" + opts = StackConfigurationCreateOptions() + assert opts.speculative_enabled is False + assert opts.destroy_all is False + assert opts.selected_deployments is None + + def test_create_options_serializes_with_aliases(self): + """StackConfigurationCreateOptions serialises with API aliases.""" + opts = StackConfigurationCreateOptions( + speculative_enabled=True, + destroy_all=True, + selected_deployments=["dep-a", "dep-b"], + ) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["speculative"] is True + assert dumped["destroy-all"] is True + assert dumped["selected-deployments"] == ["dep-a", "dep-b"] + + def test_list_options_serialization(self): + """StackConfigurationListOptions serialises page[size] alias.""" + opts = StackConfigurationListOptions( + page_size=50, + include=[StackConfigurationIncludeOps.INGRESS_ATTRIBUTES], + ) + assert opts.page_size == 50 + assert opts.include == [StackConfigurationIncludeOps.INGRESS_ATTRIBUTES] + + def test_read_options(self): + """StackConfigurationReadOptions stores include list.""" + opts = StackConfigurationReadOptions( + include=[ + StackConfigurationIncludeOps.INGRESS_ATTRIBUTES, + StackConfigurationIncludeOps.STACK_DIAGNOSTICS, + ] + ) + assert len(opts.include) == 2 + + # ── Parser tests ───────────────────────────────────────────────────────── + + def test_stack_configuration_from_full_data( + self, service, stack_configuration_api_data + ): + """_stack_configuration_from parses all attributes and relations.""" + result = service._stack_configuration_from(stack_configuration_api_data) + + assert isinstance(result, StackConfiguration) + assert result.id == "stc-abc123" + assert result.status == StackConfigurationStatus.COMPLETED + assert result.sequence_number == 3 + assert result.speculative is False + assert result.preparing_event_stream_url == "https://example.com/stream" + assert result.created_at is not None + assert result.updated_at is not None + + # Components + assert len(result.components) == 1 + assert result.components[0].name == "simple_default" + assert result.components[0].expanded is True + + # Relations + assert isinstance(result.stack, Stack) + assert result.stack.id == "st-xyz789" + assert isinstance(result.ingress_attributes, IngressAttributes) + + def test_stack_configuration_from_no_relationships(self, service): + """_stack_configuration_from handles missing relationship data gracefully.""" + data = { + "id": "stc-min", + "attributes": { + "status": "pending", + "sequence-number": 1, + }, + "relationships": {}, + } + result = service._stack_configuration_from(data) + + assert result.id == "stc-min" + assert result.status == StackConfigurationStatus.PENDING + assert result.stack is None + assert result.ingress_attributes is None + + def test_stack_configuration_from_null_relationship_data(self, service): + """_stack_configuration_from handles null data inside relationship.""" + data = { + "id": "stc-null", + "attributes": {"status": "queued"}, + "relationships": { + "stack": {"data": None}, + "ingress-attributes": {"data": None}, + }, + } + result = service._stack_configuration_from(data) + + assert result.id == "stc-null" + assert result.stack is None + assert result.ingress_attributes is None + + def test_stack_configuration_from_empty_components(self, service): + """_stack_configuration_from handles empty components list.""" + data = { + "id": "stc-empty", + "attributes": {"status": "completed", "components": []}, + "relationships": {}, + } + result = service._stack_configuration_from(data) + assert result.components == [] + + # ── Resource method tests ───────────────────────────────────────────────── + + def test_create_success( + self, service, mock_transport, stack_configuration_api_data + ): + """create() POSTs the correct payload and returns a StackConfiguration.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + opts = StackConfigurationCreateOptions(speculative_enabled=True) + result = service.create(stack_id="st-xyz789", options=opts) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stacks/st-xyz789/stack-configurations", + json_body={ + "data": { + "type": "stack-configurations", + "attributes": {"speculative": True, "destroy-all": False}, + } + }, + params={}, + ) + assert isinstance(result, StackConfiguration) + assert result.id == "stc-abc123" + + def test_create_with_fetch_source( + self, service, mock_transport, stack_configuration_api_data + ): + """create() passes source param when not MANUAL.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + service.create(stack_id="st-xyz789", source=StackConfigurationSource.FETCH) + + _, kwargs = mock_transport.request.call_args + assert kwargs["params"] == {"source": "fetch"} + + def test_create_no_options( + self, service, mock_transport, stack_configuration_api_data + ): + """create() sends empty attributes when no options provided.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + service.create(stack_id="st-xyz789") + + _, kwargs = mock_transport.request.call_args + assert kwargs["json_body"]["data"]["attributes"] == {} + + def test_list_success(self, service, stack_configuration_api_data): + """list() yields StackConfiguration objects from paginated results.""" + service._list = Mock(return_value=[stack_configuration_api_data]) + + opts = StackConfigurationListOptions(page_size=20) + results = list(service.list(stack_id="st-xyz789", options=opts)) + + service._list.assert_called_once_with( + path="/api/v2/stacks/st-xyz789/stack-configurations", + params={"page[size]": 20}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackConfiguration) + assert results[0].id == "stc-abc123" + + def test_list_with_include(self, service, stack_configuration_api_data): + """list() passes include param as comma-separated string.""" + service._list = Mock(return_value=[stack_configuration_api_data]) + + opts = StackConfigurationListOptions( + include=[StackConfigurationIncludeOps.INGRESS_ATTRIBUTES] + ) + list(service.list(stack_id="st-xyz789", options=opts)) + + _, kwargs = service._list.call_args + assert kwargs["params"]["include"] == "ingress_attributes" + + def test_list_empty(self, service): + """list() returns empty iterator when no items returned.""" + service._list = Mock(return_value=[]) + + results = list(service.list(stack_id="st-xyz789")) + assert results == [] + + def test_list_no_options(self, service, stack_configuration_api_data): + """list() works correctly when no options are given.""" + service._list = Mock(return_value=[stack_configuration_api_data]) + + results = list(service.list(stack_id="st-xyz789")) + + service._list.assert_called_once_with( + path="/api/v2/stacks/st-xyz789/stack-configurations", + params={}, + ) + assert len(results) == 1 + + def test_read_success(self, service, mock_transport, stack_configuration_api_data): + """read() GETs the correct path and returns a StackConfiguration.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + result = service.read(stack_configuration_id="stc-abc123") + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/stack-configurations/stc-abc123", + params={}, + ) + assert isinstance(result, StackConfiguration) + assert result.id == "stc-abc123" + assert result.status == StackConfigurationStatus.COMPLETED + + def test_read_with_include( + self, service, mock_transport, stack_configuration_api_data + ): + """read() appends include query param.""" + mock_response = Mock() + mock_response.json.return_value = {"data": stack_configuration_api_data} + mock_transport.request.return_value = mock_response + + opts = StackConfigurationReadOptions( + include=[ + StackConfigurationIncludeOps.INGRESS_ATTRIBUTES, + StackConfigurationIncludeOps.STACK_DIAGNOSTICS, + ] + ) + service.read(stack_configuration_id="stc-abc123", options=opts) + + _, kwargs = mock_transport.request.call_args + assert kwargs["params"]["include"] == "ingress_attributes,stack_diagnostics" diff --git a/tests/units/test_state_version.py b/tests/units/test_state_version.py index 11f67c8f..385fdd7e 100644 --- a/tests/units/test_state_version.py +++ b/tests/units/test_state_version.py @@ -5,7 +5,9 @@ import pytest from pytfe._http import HTTPTransport +from pytfe.errors import ErrStateVersionUploadNotSupported from pytfe.errors import NotFound +from pytfe.errors import TFEError from pytfe.models.state_version import ( StateVersion, StateVersionCreateOptions, @@ -131,6 +133,7 @@ def test_read_state_version_success(self, state_versions_service, mock_transport ) assert result.id == "sv-read-1" assert result.status == StateVersionStatus.FINALIZED + assert result.serial == 9 assert result.hosted_state_download_url == "https://example.com/download" def test_read_with_options_success(self, state_versions_service, mock_transport): @@ -204,6 +207,7 @@ def test_read_current_with_options_success( params={"include": "created_by"}, ) assert result.id == "sv-current-1" + assert result.serial == 9 def test_create_state_version_success(self, state_versions_service, mock_transport): """Test successful create() operation.""" @@ -247,6 +251,86 @@ def test_create_state_version_success(self, state_versions_service, mock_transpo assert result.id == "sv-new-1" assert result.status == StateVersionStatus.PENDING + def test_upload_state_version_success(self, state_versions_service, mock_transport): + """Test upload() creates, uploads raw bytes, and re-reads state version.""" + created_sv = StateVersion( + id="sv-upload-1", + created_at="2024-01-01T00:00:00Z", + status=StateVersionStatus.PENDING, + hosted_state_upload_url="https://example.com/upload-raw", + hosted_json_state_upload_url="https://example.com/upload-json", + ) + final_sv = StateVersion( + id="sv-upload-1", + created_at="2024-01-01T00:00:00Z", + status=StateVersionStatus.FINALIZED, + hosted_state_download_url="https://example.com/download-raw", + ) + options = StateVersionCreateOptions(serial=10, md5="abc123") + + with patch.object(state_versions_service, "create", return_value=created_sv): + with patch.object(state_versions_service, "read", return_value=final_sv): + result = state_versions_service.upload( + "ws-123", + raw_state=b"raw-state", + raw_json_state=b"json-state", + options=options, + ) + + assert result.id == "sv-upload-1" + assert result.status == StateVersionStatus.FINALIZED + assert mock_transport.request.call_count == 2 + mock_transport.request.assert_any_call( + "PUT", + "https://example.com/upload-raw", + data=b"raw-state", + headers={"Content-Type": "application/octet-stream"}, + ) + mock_transport.request.assert_any_call( + "PUT", + "https://example.com/upload-json", + data=b"json-state", + headers={"Content-Type": "application/octet-stream"}, + ) + + def test_upload_state_version_unsupported_on_create_error( + self, state_versions_service + ): + """Test upload() maps legacy create error text to typed unsupported error.""" + options = StateVersionCreateOptions(serial=10, md5="abc123") + legacy_err = TFEError("param is missing or the value is empty: state") + + with patch.object(state_versions_service, "create", side_effect=legacy_err): + with pytest.raises(ErrStateVersionUploadNotSupported): + state_versions_service.upload( + "ws-123", raw_state=b"raw-state", options=options + ) + + def test_upload_state_version_requires_signed_url(self, state_versions_service): + """Test upload() raises when server does not return hosted-state-upload-url.""" + created_sv = StateVersion( + id="sv-upload-2", + created_at="2024-01-01T00:00:00Z", + status=StateVersionStatus.PENDING, + hosted_state_upload_url=None, + ) + options = StateVersionCreateOptions(serial=10, md5="abc123") + + with patch.object(state_versions_service, "create", return_value=created_sv): + with pytest.raises(ErrStateVersionUploadNotSupported): + state_versions_service.upload( + "ws-123", raw_state=b"raw-state", options=options + ) + + def test_upload_state_version_rejects_inline_state(self, state_versions_service): + """Test upload() enforces omission of inline state/json-state in options.""" + options = StateVersionCreateOptions(serial=10, md5="abc123", state="abc") + + with pytest.raises(ValueError, match="must be omitted"): + state_versions_service.upload( + "ws-123", raw_state=b"raw-state", options=options + ) + def test_download_state_version_not_found_when_url_missing( self, state_versions_service ): @@ -283,7 +367,7 @@ def test_download_state_version_success( "GET", "https://example.com/signed-download", allow_redirects=True, - headers={"Accept": "application/json"}, + headers={"Accept": "*/*"}, ) assert result == b"{}" diff --git a/tests/units/test_team.py b/tests/units/test_team.py new file mode 100644 index 00000000..cd38ab0b --- /dev/null +++ b/tests/units/test_team.py @@ -0,0 +1,265 @@ +"""Unit tests for the team resource.""" + +from unittest.mock import Mock, patch + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ERR_INVALID_ORG, InvalidTeamIDError +from pytfe.models import ( + Team, + TeamCreateOptions, + TeamIncludeOpt, + TeamListOptions, + TeamUpdateOptions, +) +from pytfe.resources.team import Teams + + +class TestTeams: + """Test the Teams service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def teams_service(self, mock_transport): + """Create a Teams service with mocked transport.""" + return Teams(mock_transport) + + def test_list_teams_validations(self, teams_service): + """Test list method with invalid organization values.""" + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + list(teams_service.list("")) + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + list(teams_service.list(None)) + + def test_list_teams_success_without_options(self, teams_service): + """Test successful list operation without options.""" + + mock_data = [ + { + "id": "team-123", + "attributes": { + "name": "owners", + "visibility": "organization", + "is-unified": False, + "user-count": 2, + "allow-member-token-management": False, + }, + "relationships": {}, + } + ] + + with patch.object(teams_service, "_list") as mock_list: + mock_list.return_value = iter(mock_data) + + result = list(teams_service.list("my-org")) + + mock_list.assert_called_once_with( + "/api/v2/organizations/my-org/teams", params={} + ) + + assert len(result) == 1 + assert isinstance(result[0], Team) + assert result[0].id == "team-123" + assert result[0].name == "owners" + assert result[0].visibility == "organization" + assert result[0].user_count == 2 + + def test_list_teams_with_options(self, teams_service): + """Test successful list operation with list options.""" + + with patch.object(teams_service, "_list") as mock_list: + mock_list.return_value = iter([]) + + options = TeamListOptions( + page_size=10, + query="owner", + names=["owners", "admins"], + include=[ + TeamIncludeOpt.TEAM_USERS, + TeamIncludeOpt.TEAM_ORGANIZATION_MEMBERSHIPS, + ], + ) + + result = list(teams_service.list("my-org", options)) + + mock_list.assert_called_once_with( + "/api/v2/organizations/my-org/teams", + params={ + "page[size]": 10, + "q": "owner", + "filter[names]": ["owners", "admins"], + "include": "users,organization-memberships", + }, + ) + assert len(result) == 0 + + def test_create_team_validations(self, teams_service): + """Test create method validations.""" + + options = TeamCreateOptions(name="platform", visibility="organization") + + with pytest.raises(ValueError, match=ERR_INVALID_ORG): + teams_service.create("", options) + + def test_create_team_success(self, teams_service, mock_transport): + """Test successful create operation.""" + + mock_response_data = { + "data": { + "id": "team-456", + "attributes": { + "name": "platform", + "visibility": "organization", + "is-unified": False, + "user-count": 0, + "allow-member-token-management": True, + }, + "relationships": {}, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = TeamCreateOptions( + name="platform", + visibility="organization", + allow_member_token_management=True, + ) + + result = teams_service.create("my-org", options) + + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/organizations/my-org/teams", + json_body={ + "data": { + "attributes": { + "name": "platform", + "visibility": "organization", + "allow-member-token-management": True, + }, + "type": "teams", + } + }, + ) + + assert isinstance(result, Team) + assert result.id == "team-456" + assert result.name == "platform" + assert result.visibility == "organization" + + def test_update_team_validations(self, teams_service): + """Test update method validations.""" + + options = TeamUpdateOptions(name="new-name", visibility="organization") + + with pytest.raises(InvalidTeamIDError): + teams_service.update("", options) + + def test_update_team_success(self, teams_service, mock_transport): + """Test successful update operation.""" + + mock_response_data = { + "data": { + "id": "team-789", + "attributes": { + "name": "platform-admins", + "visibility": "secret", + "is-unified": False, + "user-count": 1, + "allow-member-token-management": False, + }, + "relationships": {}, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + options = TeamUpdateOptions(name="platform-admins", visibility="secret") + + result = teams_service.update("team-789", options) + + mock_transport.request.assert_called_once_with( + "PATCH", + path="/api/v2/teams/team-789", + json_body={ + "data": { + "attributes": { + "name": "platform-admins", + "visibility": "secret", + }, + "type": "teams", + } + }, + ) + + assert isinstance(result, Team) + assert result.id == "team-789" + assert result.name == "platform-admins" + assert result.visibility == "secret" + + def test_read_team_validations(self, teams_service): + """Test read method validations.""" + + with pytest.raises(InvalidTeamIDError): + teams_service.read("") + + def test_read_team_success(self, teams_service, mock_transport): + """Test successful read operation.""" + + mock_response_data = { + "data": { + "id": "team-789", + "attributes": { + "name": "platform-admins", + "visibility": "secret", + "is-unified": False, + "user-count": 1, + "allow-member-token-management": False, + }, + "relationships": {}, + } + } + + mock_response = Mock() + mock_response.json.return_value = mock_response_data + mock_transport.request.return_value = mock_response + + result = teams_service.read("team-789") + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/teams/team-789", + ) + + assert isinstance(result, Team) + assert result.id == "team-789" + assert result.name == "platform-admins" + + def test_delete_team_validations(self, teams_service): + """Test delete method validations.""" + + with pytest.raises(InvalidTeamIDError): + teams_service.delete("") + + def test_delete_team_success(self, teams_service, mock_transport): + """Test successful delete operation.""" + + result = teams_service.delete("team-789") + + mock_transport.request.assert_called_once_with( + "DELETE", + path="/api/v2/teams/team-789", + ) + assert result is None diff --git a/tests/units/test_team_project_access.py b/tests/units/test_team_project_access.py new file mode 100644 index 00000000..75b6a4a6 --- /dev/null +++ b/tests/units/test_team_project_access.py @@ -0,0 +1,215 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the team_project_access module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidTeamProjectAccessIDError +from pytfe.models.project import Project +from pytfe.models.team import Team +from pytfe.models.team_project_access import ( + ProjectSettingsPermissionType, + ProjectTeamsPermissionType, + ProjectVariableSetsPermissionType, + TeamProjectAccess, + TeamProjectAccessAddOptions, + TeamProjectAccessListOptions, + TeamProjectAccessProjectPermissionsOptions, + TeamProjectAccessType, + TeamProjectAccessUpdateOptions, + TeamProjectAccessWorkspacePermissionsOptions, + WorkspaceRunsPermissionType, + WorkspaceSentinelMocksPermissionType, + WorkspaceStateVersionsPermissionType, + WorkspaceVariablesPermissionType, +) +from pytfe.resources.team_project_access import TeamProjectAccesses + + +class TestTeamProjectAccesses: + """Test the TeamProjectAccesses service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def team_project_accesses_service(self, mock_transport): + """Create a TeamProjectAccesses service with mocked transport.""" + return TeamProjectAccesses(mock_transport) + + @pytest.fixture + def team_project_access_response_data(self): + """Return sample API response data for team project access.""" + return { + "id": "tprj-123", + "attributes": { + "access": "custom", + "project-access": { + "settings": "update", + "teams": "manage", + "variable-sets": "read", + }, + "workspace-access": { + "runs": "plan", + "sentinel-mocks": "none", + "state-versions": "read-outputs", + "variables": "write", + "run-tasks": True, + "move": False, + "locking": True, + "delete": False, + "create": True, + }, + }, + "relationships": { + "team": {"data": {"id": "team-123", "type": "teams"}}, + "project": {"data": {"id": "prj-123", "type": "projects"}}, + }, + } + + def test_add_team_project_access_success( + self, + team_project_accesses_service, + mock_transport, + team_project_access_response_data, + ): + """Test successful add operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": team_project_access_response_data} + mock_transport.request.return_value = mock_response + + options = TeamProjectAccessAddOptions( + access=TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM, + team=Team(id="team-123"), + project=Project(id="prj-123"), + project_access=TeamProjectAccessProjectPermissionsOptions( + settings=ProjectSettingsPermissionType.PROJECT_SETTINGS_PERMISSION_UPDATE, + teams=ProjectTeamsPermissionType.PROJECT_TEAMS_PERMISSION_MANAGE, + variable_sets=ProjectVariableSetsPermissionType.PROJECT_VARIABLE_SETS_PERMISSION_READ, + ), + workspace_access=TeamProjectAccessWorkspacePermissionsOptions( + runs=WorkspaceRunsPermissionType.WORKSPACE_RUNS_PERMISSION_PLAN, + sentinel_mocks=WorkspaceSentinelMocksPermissionType.WORKSPACE_SENTINEL_MOCKS_PERMISSION_NONE, + state_versions=WorkspaceStateVersionsPermissionType.WORKSPACE_STATE_VERSIONS_PERMISSION_READ_OUTPUTS, + variables=WorkspaceVariablesPermissionType.WORKSPACE_VARIABLES_PERMISSION_WRITE, + create=True, + delete=False, + locking=True, + move=False, + run_tasks=True, + ), + ) + + result = team_project_accesses_service.add(options) + + mock_transport.request.assert_called_once() + assert isinstance(result, TeamProjectAccess) + assert result.id == "tprj-123" + assert result.access == TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM + assert result.team.id == "team-123" + assert result.project.id == "prj-123" + + def test_read_team_project_access_success( + self, + team_project_accesses_service, + mock_transport, + team_project_access_response_data, + ): + """Test successful read operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": team_project_access_response_data} + mock_transport.request.return_value = mock_response + + result = team_project_accesses_service.read("tprj-123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/team-projects/tprj-123" + ) + assert isinstance(result, TeamProjectAccess) + assert result.id == "tprj-123" + assert result.workspace_access.run_tasks is True + + def test_read_team_project_access_invalid_id(self, team_project_accesses_service): + """Test read operation with invalid team project access ID.""" + with pytest.raises(InvalidTeamProjectAccessIDError): + team_project_accesses_service.read("") + + def test_update_team_project_access_success( + self, + team_project_accesses_service, + mock_transport, + team_project_access_response_data, + ): + """Test successful update operation.""" + mock_response = Mock() + mock_response.json.return_value = {"data": team_project_access_response_data} + mock_transport.request.return_value = mock_response + + options = TeamProjectAccessUpdateOptions( + access=TeamProjectAccessType.TEAM_PROJECT_ACCESS_CUSTOM, + workspace_access=TeamProjectAccessWorkspacePermissionsOptions( + run_tasks=True + ), + ) + + result = team_project_accesses_service.update("tprj-123", options) + + mock_transport.request.assert_called_once() + assert isinstance(result, TeamProjectAccess) + assert result.id == "tprj-123" + + def test_update_team_project_access_invalid_id(self, team_project_accesses_service): + """Test update operation with invalid team project access ID.""" + options = TeamProjectAccessUpdateOptions( + access=TeamProjectAccessType.TEAM_PROJECT_ACCESS_READ + ) + + with pytest.raises(InvalidTeamProjectAccessIDError): + team_project_accesses_service.update("", options) + + def test_list_team_project_accesses_success( + self, + team_project_accesses_service, + team_project_access_response_data, + ): + """Test successful list operation.""" + team_project_accesses_service._list = Mock( + return_value=[team_project_access_response_data] + ) + + options = TeamProjectAccessListOptions(page_size=10, Project_id="prj-123") + + result_iter = team_project_accesses_service.list(options) + items = list(result_iter) + + team_project_accesses_service._list.assert_called_once_with( + "/api/v2/team-projects", + params={"page[size]": 10, "filter[project][id]": "prj-123"}, + ) + assert len(items) == 1 + assert isinstance(items[0], TeamProjectAccess) + assert items[0].id == "tprj-123" + + def test_remove_team_project_access_success( + self, + team_project_accesses_service, + mock_transport, + ): + """Test successful remove operation.""" + result = team_project_accesses_service.remove("tprj-123") + + mock_transport.request.assert_called_once_with( + "DELETE", path="/api/v2/team-projects/tprj-123" + ) + assert result is None + + def test_remove_team_project_access_invalid_id(self, team_project_accesses_service): + """Test remove operation with invalid team project access ID.""" + with pytest.raises(InvalidTeamProjectAccessIDError): + team_project_accesses_service.remove("") diff --git a/tests/units/test_user.py b/tests/units/test_user.py new file mode 100644 index 00000000..2be95650 --- /dev/null +++ b/tests/units/test_user.py @@ -0,0 +1,167 @@ +"""Unit tests for the Users resource.""" + +import copy +from unittest.mock import Mock + +import pytest + +from pytfe.models.user import User, UserPermissions, UserUpdateCurrentOptions +from pytfe.resources.user import Users + + +class TestUsers: + """Test suite for user resource operations.""" + + @pytest.fixture + def mock_transport(self): + """Mock HTTP transport.""" + return Mock() + + @pytest.fixture + def users_service(self, mock_transport): + """Create users service with mocked transport.""" + return Users(mock_transport) + + @pytest.fixture + def sample_user_response(self): + """Sample JSON:API response for a user.""" + return { + "data": { + "id": "user-MA4GL63FmYRpSFxa", + "type": "users", + "attributes": { + "username": "admin", + "email": "admin@example.com", + "is-service-account": False, + "auth-method": "hcp_sso", + "avatar-url": "https://example.com/avatar.png", + "v2-only": True, + "permissions": { + "can-create-organizations": False, + "can-change-email": True, + "can-change-username": True, + }, + }, + } + } + + def test_read_user(self, users_service, mock_transport, sample_user_response): + """Test reading a specific user by ID.""" + mock_transport.request.return_value.json.return_value = sample_user_response + + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) + + mock_transport.request.assert_called_once_with( + "GET", f"/api/v2/users/{user_id}" + ) + assert isinstance(user, User) + assert user.id == user_id + assert user.username == "admin" + assert user.email == "admin@example.com" + assert user.is_service_account is False + assert user.auth_method == "hcp_sso" + assert user.avatar_url == "https://example.com/avatar.png" + assert user.v2_only is True + assert isinstance(user.permissions, UserPermissions) + assert user.permissions is not None + assert user.permissions.can_create_organizations is False + assert user.permissions.can_change_email is True + assert user.permissions.can_change_username is True + assert user.permissions.can_manage_user_tokens is False + assert user.permissions.can_view_2fa_settings is False + assert user.permissions.can_manage_hcp_account is False + + def test_read_user_invalid_id(self, users_service): + """Test reading a user with an invalid user ID.""" + with pytest.raises(ValueError, match="invalid user id"): + users_service.read("") + + def test_read_user_with_null_unconfirmed_email( + self, users_service, mock_transport, sample_user_response + ): + """Test reading a user when unconfirmed-email is null.""" + sample_user_response["data"]["attributes"]["unconfirmed-email"] = None + mock_transport.request.return_value.json.return_value = sample_user_response + + user = users_service.read("user-MA4GL63FmYRpSFxa") + + assert isinstance(user, User) + assert user.unconfirmed_email is None + + def test_read_user_two_factor_parsing( + self, users_service, mock_transport, sample_user_response + ): + """Test reading a user with two-factor data.""" + modified_response = copy.deepcopy(sample_user_response) + modified_response["data"]["attributes"]["two-factor"] = { + "enabled": True, + "verified": False, + } + mock_transport.request.return_value.json.return_value = modified_response + + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) + + assert user.two_factor is not None + assert user.two_factor.enabled is True + assert user.two_factor.verified is False + + def test_read_user_nullable_bools( + self, users_service, mock_transport, sample_user_response + ): + """Test reading a user when pointer-style boolean fields are null.""" + modified_response = copy.deepcopy(sample_user_response) + modified_response["data"]["attributes"]["is-site-admin"] = None + modified_response["data"]["attributes"]["is-admin"] = None + modified_response["data"]["attributes"]["is-sso-login"] = None + mock_transport.request.return_value.json.return_value = modified_response + + user_id = "user-MA4GL63FmYRpSFxa" + user = users_service.read(user_id) + + assert user.is_site_admin is None + assert user.is_admin is None + assert user.is_sso_login is None + + def test_read_current_user( + self, users_service, mock_transport, sample_user_response + ): + """Test reading the currently authenticated user.""" + mock_transport.request.return_value.json.return_value = sample_user_response + + user = users_service.read_current() + + mock_transport.request.assert_called_once_with("GET", "/api/v2/account/details") + assert isinstance(user, User) + assert user.id == "user-MA4GL63FmYRpSFxa" + assert user.username == "admin" + assert user.email == "admin@example.com" + + def test_update_current_user( + self, users_service, mock_transport, sample_user_response + ): + """Test updating the currently authenticated user.""" + mock_transport.request.return_value.json.return_value = sample_user_response + options = UserUpdateCurrentOptions( + username="new-admin", + email="new-admin@example.com", + ) + + user = users_service.update_current(options) + + mock_transport.request.assert_called_once_with( + "PATCH", + "/api/v2/account/update", + json_body={ + "data": { + "type": "users", + "attributes": { + "username": "new-admin", + "email": "new-admin@example.com", + }, + } + }, + ) + assert isinstance(user, User) + assert user.id == "user-MA4GL63FmYRpSFxa"