diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index f1282d17..fd227d67 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,7 +26,7 @@ If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - OS: [e.g. macOS, Linux, Windows] - Python version: [e.g. 3.10, 3.11, 3.12, 3.13] - - SDK version: [e.g. 26.3.2] (run `python -c "import graphiant_sdk; print(graphiant_sdk.__version__)"` to check) + - SDK version: [e.g. 26.3.3] (run `python -c "import graphiant_sdk; print(graphiant_sdk.__version__)"` to check) - Installation method: [e.g. pip, from source] **Additional context** diff --git a/CHANGELOG.md b/CHANGELOG.md index e184f85d..01b07531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to the Graphiant SDK Python package will be documented in th The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [26.3.3] - 2026-04-10 + +### Changed +- **Version:** Package **26.3.3** (same bundled OpenAPI input file as **26.3.2**: `graphiant_api_docs_v26.3.1.json`). +- **CLI:** **`strip_bearer_prefix`** in **`rest_client`** avoids **`Authorization: Bearer Bearer …`** when **`GRAPHIANT_ACCESS_TOKEN`** or stored credentials already include the **`Bearer `** prefix; **`graphiant rest`** and **`graphiant invoke`** use it. +- **CLI (shell completion):** Declare **`add_completion=True`**, call **`app(prog_name="graphiant")`** so Click/Typer completion uses **`_GRAPHIANT_COMPLETE`**. Add runtime dependency **`shellingham`** (Typer uses it for **`graphiant --install-completion`**). Document setup in the README. +- **CLI:** On **HTTP 403** with API JSON **`displayError`: `Token Expired`** (e.g. from **`graphiant rest`** or **`graphiant whoami`**), print a short **re-login** hint instead of only the raw body; **`graphiant invoke`** treats matching **`ApiException`** bodies the same way. +- **CLI (`graphiant api list` / `graphiant apis`):** Prints a **Rich table** with **SDK method**, **HTTP verb**, and **path** (parsed from the generated client) next to each operation. Use **`--plain`** / **`-1`** for one method name per line (previous behavior). +- **CLI (`graphiant invoke` / `graphiant api invoke`):** The OpenAPI client no longer sets **`Configuration.api_key["jwtAuth"]`** for CLI invokes. Operations already send **`Authorization`** from the **`authorization`** parameter; combining both produced **two** auth headers (e.g. **`Authorization`** and **`authorization`**), which **Microsoft Azure Application Gateway** rejects with **400 Bad Request**. +- **CLI (`graphiant whoami`):** Calls **`GET /v1/auth/user`** and **`GET /v1/users?id=…`** to resolve **user** display name; **Rich tables** for session, permissions, and profile (no **`/v1/enterprises`** lookup). **`lastActiveAt`** and protobuf **`{seconds,nanos}`** values are formatted as human-readable **UTC** with an explicit **`(UTC)`** label; trailing raw auth **JSON** dump removed. +- **CLI (`graphiant login`):** Default is **`--no-export`** so the bearer token is **not** printed to **stdout** after login (avoids echoing credentials in the terminal). Token is still written to **`~/.graphiant/env.sh`**; use **`graphiant login --export`** or **`graphiant login env-export`** when a shell export line on stdout is required. +- **CLI:** After **`graphiant logout`**, the CLI reminds you to run **`unset GRAPHIANT_ACCESS_TOKEN`** when the shell still exports the old token. README **`graphiant logout`** row updated. +- **Documentation:** README API reference sample endpoints, **`graphiant rest`** / **`invoke`** examples, and related fixes (e.g. **`/v1/edges-summary`** path). + ## [26.3.2] - 2026-03-27 ### Changed diff --git a/README.md b/README.md index dd48336b..7b27495a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ More product and platform context: [Graphiant Docs](https://docs.graphiant.com). | [**Graphiant CLI**](#graphiant-cli) | Full CLI documentation (login, configure, `invoke`, `rest`, env vars) | | [Advanced usage](#advanced-usage) | Patterns and error handling | | [Development](#development) | Build, test, code generation | -| [API reference (overview)](#api-reference) | Core classes and common endpoints | +| [API reference (overview)](#api-reference) | Bundled OpenAPI, model docs, sample endpoints | | [Security](#security) | Auth and environment variables | | [Contributing](#contributing) | PR workflow | | [Support](#support) | Links and contact | @@ -33,6 +33,8 @@ More product and platform context: [Graphiant Docs](https://docs.graphiant.com). | **Automation** | [Graphiant Automation](https://docs.graphiant.com/docs/automation) | | **REST API** | [Graphiant Portal REST API](https://docs.graphiant.com/docs/graphiant-portal-rest-api) | | **Method index (repo)** | [DefaultApi.md](https://github.com/Graphiant-Inc/graphiant-sdk-python/blob/main/docs/DefaultApi.md) | +| **OpenAPI bundle (this build)** | [`graphiant_api_docs_v26.3.1.json`](https://github.com/Graphiant-Inc/graphiant-sdk-python/blob/main/graphiant_api_docs_v26.3.1.json) — source for generated paths and models | +| **Model docs (`*.md`)** | [`docs/`](https://github.com/Graphiant-Inc/graphiant-sdk-python/tree/main/docs) (same names as Python classes, e.g. `V1EdgesSummaryGetResponse.md`) | | **PyPI** | [graphiant-sdk](https://pypi.org/project/graphiant-sdk) | | **Changelog** | [CHANGELOG.md](https://github.com/Graphiant-Inc/graphiant-sdk-python/blob/main/CHANGELOG.md) | @@ -55,7 +57,7 @@ This provides both **`graphiant_sdk`** (Python) and the **`graphiant`** executab ### 2. Sign in with the CLI -Complete login in the Chromium window that opens (or paste a token when prompted). Then **load the token into your shell** (the CLI cannot set parent-shell variables by itself): +Complete login in the Chromium window that opens (or paste a token when prompted). Then **load the token into your shell**: ```bash graphiant login @@ -130,6 +132,26 @@ The **`graphiant`** command ships with `graphiant-sdk`. Use it to log in via the - **Help:** `graphiant --help`, `graphiant login --help`, etc. - **Python usage:** After `source ~/.graphiant/env.sh`, read `GRAPHIANT_ACCESS_TOKEN` in code — see [§3 Basic Python usage](#3-basic-python-usage). +### Shell completion (bash, zsh, fish) + +Tab completion is provided by **Typer/Click** but is **not** enabled until you install it once for your shell: + +```bash +graphiant --install-completion +``` + +Then restart the terminal or **`source ~/.zshrc`** / **`~/.bashrc`**. After that, **`graphiant `** completes subcommands (e.g. `login`, `rest`, `whoami`) and options. + +- Inspect the script without modifying your config: **`graphiant --show-completion`** +- **zsh** must run **`compinit`** (most frameworks do this already). +- The package depends on **`shellingham`** so **`--install-completion`** can detect your shell. + +If you prefer to wire **zsh** manually: + +```bash +echo 'eval "$(_GRAPHIANT_COMPLETE=zsh_source graphiant)"' >> ~/.zshrc +``` + ### Typical workflow ```bash @@ -144,7 +166,7 @@ graphiant whoami # 4) Call an API (token from env or saved profile) graphiant invoke v1_edges_summary_get -graphiant rest GET /v1/edges/summary +graphiant rest GET /v1/edges-summary ``` ### `graphiant login` @@ -157,13 +179,13 @@ graphiant rest GET /v1/edges/summary | `--no-capture` | No Playwright; open portal and paste token (full **`Authorization`** value including **`Bearer`**, or raw JWT). | | `--no-browser` | Print portal URL only; paste when prompted. | | `--profile ` | Store token under named profile (default **`default`**). | -| `--export` / `--no-export` | After success, also print **`export GRAPHIANT_ACCESS_TOKEN=…`** to **stdout** (default: **on**). `env.sh` is always written. | +| `--export` / `--no-export` | After success, also print **`export GRAPHIANT_ACCESS_TOKEN=…`** to **stdout** for scripts (default: **off**, so the token is not echoed). `~/.graphiant/env.sh` is always written. | | `-v`, `--verbose` | Debug logging on stderr. Or **`GRAPHIANT_LOG=debug`** / **`info`** / **`warning`**. | | `graphiant login env-export` | Print one **`export …`** line to stdout (no browser). Use: `eval "$(graphiant login env-export)"`. | **Paste / DevTools:** If auto-capture fails, copy the **full** **`Authorization`** header from Network (including the word **`Bearer`**). The CLI does not read DevTools; it only listens inside its own Chromium session. -**Why `GRAPHIANT_ACCESS_TOKEN` is empty after login:** The `graphiant` process is a **child** of your shell and **cannot** modify the parent’s environment. The token is saved under **`~/.graphiant/`** and in **`env.sh`**. Run **`source ~/.graphiant/env.sh`** (or **`eval "$(graphiant login env-export)"`**) in the terminal where you need the variable. New IDE terminals don’t inherit another tab’s `source` unless you reload it there too. +**If `GRAPHIANT_ACCESS_TOKEN` is empty after login:** The token is saved under **`~/.graphiant/`** in **`env.sh`**. In this terminal, run **`source ~/.graphiant/env.sh`** or **`eval "$(graphiant login env-export)"`**. You can also chain: **`graphiant login && source ~/.graphiant/env.sh`** (add your usual `login` flags before `&&`). New IDE terminals don’t inherit another tab’s `source` unless you reload it there too. ### `graphiant configure` @@ -178,23 +200,50 @@ graphiant rest GET /v1/edges/summary Exact method names match **`DefaultApi`** in the SDK — see [DefaultApi.md](https://github.com/Graphiant-Inc/graphiant-sdk-python/blob/main/docs/DefaultApi.md) or list locally: ```bash -graphiant api list --prefix v1_auth_ +graphiant api list --prefix v1_auth_ # table: SDK method, HTTP, path +graphiant apis --plain --prefix v1_auth_ # one SDK method name per line graphiant invoke v1_auth_get graphiant api invoke v1_edges_summary_get graphiant invoke v1_edges_summary_get --kwargs '{"enterprise_id": 123}' ``` +**`graphiant invoke`** sends a **single** **`Authorization`** header (the generated client’s **`authorization`** parameter only). It does **not** also apply **`jwtAuth`** from **`Configuration`**, so gateways that reject duplicate auth headers (for example **Azure Application Gateway**) accept the request. + +#### Query parameters and filters + +- **`graphiant invoke` / `graphiant api invoke`** — Uses the **generated `DefaultApi` method signature**. Anything that is a query string in REST becomes a **keyword argument** on that method, named in **snake_case** (OpenAPI `enterpriseId` → `enterprise_id`). Pass them inside **`--kwargs`** as JSON. You do **not** pass `authorization` manually; the CLI fills **`Bearer `** for you. + + ```bash + graphiant invoke v1_edges_summary_get --kwargs '{"enterprise_id": 123, "is_requested": true}' + ``` + + Optional arguments can be omitted. For **positional** parameters (rare), use **`--args`** with a JSON array in **parameter order**; the first slot is usually **`authorization`**, which the CLI injects if you skip it by using **`--kwargs`** only. + +- **POST / PATCH with a JSON body** — Bodies use the same keyword names as **`DefaultApi`** (often a single **`v1_*_post_request`** argument whose JSON matches the Pydantic model). See **`docs/.md`** for fields. + + ```bash + graphiant invoke v1_edges_summary_post --kwargs '{"v1_edges_summary_post_request": {"filter": {}}}' + graphiant invoke v1_global_summary_post --kwargs '{"v1_global_summary_post_request": {"ntpType": true}}' + ``` + +- **`graphiant rest`** — Query strings are a single **`--query`** / **`-q`** string: **`key=value`** pairs joined with **`&`**. Values are strings (URL-encode special characters in the shell if needed). + + ```bash + graphiant rest GET /v1/edges-summary --query 'enterpriseId=123&isRequested=true' + ``` + Raw HTTP (path under configured API host): ```bash -graphiant rest GET /v1/edges/summary -graphiant rest POST /v1/resource --body '{"key": "value"}' --query 'a=b' +graphiant rest GET /v1/edges-summary +graphiant rest GET /v1/devices/1234567890123 +graphiant rest POST /v1/global/summary --body '{"ntpType": true}' ``` | Command | Purpose | |---------|---------| -| `graphiant whoami` | **`GET /v1/auth`** with current token. | -| `graphiant logout` | Clear stored profile (see **`--profile`**). | +| `graphiant whoami` | **`GET /v1/auth/user`** and **`GET /v1/users?id=…`**; Rich tables (session, permissions, profile). **`lastActiveAt`** (and similar protobuf timestamps) are shown in **UTC**, labeled **`(UTC)`**. | +| `graphiant logout` | Clear stored profile (see **`--profile`**). Your shell may still export **`GRAPHIANT_ACCESS_TOKEN`** — run **`unset GRAPHIANT_ACCESS_TOKEN`** in that terminal if needed. | | `graphiant version` | Print CLI and package version. | ### Environment variables & files @@ -224,7 +273,7 @@ graphiant rest POST /v1/resource --body '{"key": "value"}' --query 'a=b' | Module | Role | |--------|------| -| `main.py` | Typer app: `login`, `configure`, `api`, `rest`, `whoami`, … | +| `main.py` | Typer app: `login`, `configure`, `api`, `rest`, `whoami` (`GET /v1/auth/user`), … | | `browser_capture.py` | Playwright session and network capture | | `token_parsing.py` | Headers, JSON, URL matching, token validation | | `login_common.py` | Save credentials, user-facing success text, stdout export | @@ -425,10 +474,10 @@ openapi-generator generate \ --git-user-id Graphiant-Inc \ --git-repo-id graphiant-sdk-python \ --package-name graphiant_sdk \ - --additional-properties=packageVersion=26.3.2 + --additional-properties=packageVersion=26.3.3 ``` -> **Note:** Download the latest API bundle from the Graphiant portal under **Support Hub** → **Developer Tools**. Set **`packageVersion`** to the SDK release you are publishing (this branch: **26.3.2**). The **`-i`** filename reflects the API doc bundle version (here `graphiant_api_docs_v26.3.1.json`) and may stay the same across patch releases when the spec is unchanged. +> **Note:** Download the latest API bundle from the Graphiant portal under **Support Hub** → **Developer Tools**. Set **`packageVersion`** to the SDK release you are publishing (this branch: **26.3.3**). The **`-i`** filename reflects the API doc bundle version (here `graphiant_api_docs_v26.3.1.json`) and may stay the same across patch releases when the spec is unchanged. ### Testing @@ -442,31 +491,55 @@ python -m pytest tests/ --cov=graphiant_sdk --cov-report=html ## 📖 API Reference -### Core Classes - -- **`Configuration`**: Client configuration with authentication -- **`ApiClient`**: HTTP client for API requests -- **`DefaultApi`**: Main API interface with all endpoints - -### Key Models - -- **`V1AuthLoginPostRequest`**: Authentication request -- **`V1AuthLoginPostResponse`**: Authentication response -- **`V1EdgesSummaryGetResponse`**: Device summary response -- **`V1DevicesDeviceIdConfigPutRequest`**: Device configuration request -- **`V1DevicesDeviceIdConfigPutResponse`**: Device configuration response -- **`V1GlobalSummaryPostResponse`**: Global summary response (uses `ManaV2GlobalObjectSummary` for inner items) - -### Common Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/v1/auth/login` | POST | Authenticate and get bearer token | -| `/v1/edges/summary` | GET | Get all device summaries | -| `/v1/devices/{device_id}` | GET | Get device details | -| `/v1/devices/{device_id}/config` | PUT | Update device configuration | -| `/v1/circuits` | GET | List circuits | -| `/v1/alarms` | GET | Get system alarms | +### Source of truth (this release) + +Operations and schemas are generated from **`graphiant_api_docs_v26.3.1.json`** (repo root and PyPI wheel). For a newer portal/API, download the current bundle (Support Hub → Developer Tools) and diff paths before relying on URLs here. + +| How to explore | Where | +|----------------|-------| +| **Every operation** (method, path, parameters) | [`docs/DefaultApi.md`](https://github.com/Graphiant-Inc/graphiant-sdk-python/blob/main/docs/DefaultApi.md) | +| **CLI: SDK name + HTTP + path** | `graphiant api list` or `graphiant apis --prefix v1_` | +| **Request/response field lists** | [`docs/*.md`](https://github.com/Graphiant-Inc/graphiant-sdk-python/tree/main/docs) — file basename matches the Python model (e.g. `V1EdgesSummaryGetResponse.md`) | +| **Python imports** | `from graphiant_sdk import …` or `graphiant_sdk.models` | + +REST query parameters use **camelCase** in URLs (`enterpriseId`). Generated Python kwargs use **snake_case** (`enterprise_id`, `device_id`). Path templates below follow OpenAPI (`{deviceId}`). + +### Core classes + +- **`Configuration`** — API host, timeouts; do **not** set **`api_key["jwtAuth"]`** when every call passes **`authorization=`** (avoids duplicate **`Authorization`** headers on strict gateways). +- **`ApiClient`** — HTTP client used by **`DefaultApi`**. +- **`DefaultApi`** — one method per operation (e.g. **`v1_edges_summary_get`** → **GET** **`/v1/edges-summary`**). + +### Example SDK models (verified in this package) + +| Model (`import graphiant_sdk` or `graphiant_sdk.models`) | Typical operation | +|----------------------------------------------------------|-------------------| +| **`V1AuthLoginPostRequest`**, **`V1AuthLoginPostResponse`** | **`POST /v1/auth/login`** | +| **`V1AuthUserGetResponse`** | **`GET /v1/auth/user`** | +| **`V1EdgesSummaryGetResponse`** | **`GET /v1/edges-summary`** | +| **`V1EdgesSummaryPostRequest`** (optional **`filter`**) | **`POST /v1/edges-summary`** | +| **`V1DevicesDeviceIdGetResponse`** | **`GET /v1/devices/{deviceId}`** | +| **`V1DevicesDeviceIdConfigPutRequest`**, **`V1DevicesDeviceIdConfigPutResponse`** | **`PUT /v1/devices/{deviceId}/config`** (job accepted; **no GET** on **`…/config`** in this spec) | +| **`ManaV2EdgeDeviceConfig`** | Nested **`edge`** object inside **`V1DevicesDeviceIdConfigPutRequest`** | +| **`V1GlobalSummaryPostRequest`**, **`V1GlobalSummaryPostResponse`** | **`POST /v1/global/summary`** | +| **`V2ParentalertlistPostRequest`**, **`V2ParentalertlistPostResponse`** | **`POST /v2/parentalertlist`** | + +### Sample HTTP endpoints (from `graphiant_api_docs_v26.3.1.json`) + +The API surface is large; this table lists **real** paths from the bundled spec. For the full set, use **`graphiant api list`** or **`DefaultApi.md`**. + +| Endpoint | Method | Example `DefaultApi` method | Notes | +|----------|--------|----------------------------|-------| +| `/v1/auth/login` | POST | `v1_auth_login_post` | Body: **`V1AuthLoginPostRequest`** | +| `/v1/auth/user` | GET | `v1_auth_user_get` | Session user | +| `/v1/users` | GET | `v1_users_get` | e.g. **`id`** query (see **`graphiant whoami`**) | +| `/v1/edges-summary` | GET | `v1_edges_summary_get` | Queries e.g. **`enterpriseId`**, **`isRequested`** | +| `/v1/edges-summary` | POST | `v1_edges_summary_post` | Body: **`V1EdgesSummaryPostRequest`** | +| `/v1/devices/{deviceId}` | GET | `v1_devices_device_id_get` | Device detail | +| `/v1/devices/{deviceId}/config` | PUT | `v1_devices_device_id_config_put` | Body: **`V1DevicesDeviceIdConfigPutRequest`** | +| `/v1/global/summary` | POST | `v1_global_summary_post` | Body: **`V1GlobalSummaryPostRequest`** | +| `/v1/sites/{siteId}/circuits` | GET | `v1_sites_site_id_circuits_get` | Circuits for a site | +| `/v2/parentalertlist` | POST | `v2_parentalertlist_post` | Body: **`V2ParentalertlistPostRequest`** | ## 🔐 Security diff --git a/SECURITY.md b/SECURITY.md index 7781a968..6da00b90 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ We actively support the following versions with security updates: | Version | Supported | Notes | | ------- | ------------------ | ---------------------------------------- | -| 26.3.x | :white_check_mark: | Current stable release (latest: **26.3.2**) | +| 26.3.x | :white_check_mark: | Current stable release (latest: **26.3.3**) | | 26.2.x | :white_check_mark: | Previous release | | 26.1.x | :white_check_mark: | Older supported release | | 25.12.x | :white_check_mark: | Legacy release | diff --git a/graphiant_cli/login_common.py b/graphiant_cli/login_common.py index b61d5d04..bf834b01 100644 --- a/graphiant_cli/login_common.py +++ b/graphiant_cli/login_common.py @@ -42,20 +42,25 @@ def print_login_success( env_file: Path | None = None, verbose: bool = False, ) -> None: - """Post-login instructions. Default is short; ``verbose`` adds child-process and stdout details.""" + """Post-login instructions. Short by default; ``verbose`` adds stdout / file hints.""" env_path = str(env_file or ENV_EXPORT_FILE) console.print("\n[bold green]Login saved.[/bold green]") - console.print(f"Run [cyan]source {env_path}[/cyan] in this terminal to set [bold]GRAPHIANT_ACCESS_TOKEN[/bold].") + console.print( + f"Run [cyan]source {env_path}[/cyan] in this terminal to set " + f"[bold]GRAPHIANT_ACCESS_TOKEN[/bold]." + ) + console.print( + f"[dim]Or in one line next time:[/dim] " + f"[cyan]graphiant login && source {env_path}[/cyan]" + ) if verbose: console.print( - "[dim]Why: a child process cannot change your shell’s environment; the token is in the file above.[/dim]" - ) - console.print( - f"[dim]Alternative (no browser): [cyan]eval \"$(graphiant login env-export)\"[/cyan][/dim]" + "[dim]Alternative:[/dim] [cyan]eval \"$(graphiant login env-export)\"[/cyan]" ) if export_shell: console.print( - "[dim]An [cyan]export GRAPHIANT_ACCESS_TOKEN=…[/cyan] line was also sent to **stdout** for scripts.[/dim]" + "[dim]An [cyan]export GRAPHIANT_ACCESS_TOKEN=…[/cyan] line was printed to " + "**stdout** (--export).[/dim]" ) console.print(f"[dim]Credentials file:[/dim] [cyan]{env_path}[/cyan]") diff --git a/graphiant_cli/main.py b/graphiant_cli/main.py index d3daf110..7c098c9b 100644 --- a/graphiant_cli/main.py +++ b/graphiant_cli/main.py @@ -5,12 +5,14 @@ import json import os import sys -from typing import Optional +from datetime import datetime, timezone +from typing import Any, Optional import click import typer from rich.console import Console from rich.json import JSON +from rich.table import Table from graphiant_cli import __version__ from graphiant_cli.cli_logging import configure_logging, get_logger @@ -37,7 +39,8 @@ portal_url_from_config, ) from graphiant_cli.rest_client import request as rest_request -from graphiant_cli.sdk_invoke import invoke_method, list_api_methods +from graphiant_cli.sdk_invoke import invoke_method, list_api_method_rows, list_api_methods +from graphiant_sdk.exceptions import ApiException login_logger = get_logger("login") @@ -45,6 +48,7 @@ name="graphiant", help="Graphiant CLI — portal login, SDK API calls, and raw REST.", no_args_is_help=True, + add_completion=True, ) configure_app = typer.Typer(help="Default API host and portal URL.") login_app = typer.Typer( @@ -59,6 +63,40 @@ console = Console(stderr=True) +def _is_token_expired_api_response(status: int, text: str) -> bool: + """True when the API returns the portal-style JSON (e.g. displayError: Token Expired).""" + if status != 403: + return False + try: + obj = json.loads((text or "").strip() or "{}") + except json.JSONDecodeError: + return False + if not isinstance(obj, dict): + return False + if obj.get("displayError") == "Token Expired": + return True + if obj.get("message") == "Token Expired": + return True + return False + + +def _print_token_expired_relogin_hint() -> None: + console.print("[red]Your session token has expired.[/red]") + console.print( + "Run [bold]graphiant login[/bold], then load the new token into this shell, for example:\n" + " [cyan]source ~/.graphiant/env.sh[/cyan]\n" + "Then retry your command." + ) + + +def _handle_cli_http_error(status: int, text: str) -> None: + """Print a friendly message for token expiry, else raw HTTP error.""" + if _is_token_expired_api_response(status, text): + _print_token_expired_relogin_hint() + else: + console.print(f"[red]HTTP {status}[/red]\n{text}") + + def _default_host() -> str: cfg = load_config() return cfg.get("host") or os.environ.get("GRAPHIANT_API_HOST", "https://api.graphiant.com") @@ -136,9 +174,10 @@ def login_callback( help="Seconds to wait for API bearer capture after opening the browser (default 90; then paste prompt)", ), export_shell: bool = typer.Option( - True, + False, "--export/--no-export", - help="After success, print 'export GRAPHIANT_ACCESS_TOKEN=…' to stdout (for eval) and keep ~/.graphiant/env.sh", + help="After success, also print 'export GRAPHIANT_ACCESS_TOKEN=…' to stdout (for scripts/eval). " + "Default off so the token is not echoed to the terminal; ~/.graphiant/env.sh is always written.", ), verbose: bool = typer.Option( False, @@ -266,7 +305,12 @@ def login_env_export(profile: Optional[str] = typer.Option(None, "--profile", "- def logout_cmd(profile: Optional[str] = typer.Option(None, "--profile", "-p")) -> None: name = profile or os.environ.get("GRAPHIANT_PROFILE", DEFAULT_PROFILE) clear_profile(name) + login_logger.info("Logged out profile %s (clear shell with unset GRAPHIANT_ACCESS_TOKEN if needed)", name) console.print(f"[green]Logged out[/green] profile [bold]{name}[/bold]") + console.print( + "[dim]If this terminal still has a token exported, run:[/dim] " + "[cyan]unset GRAPHIANT_ACCESS_TOKEN[/cyan]" + ) def _print_sdk_result(result: object) -> None: @@ -291,6 +335,14 @@ def _run_api_invoke( try: out = invoke_method(host, token, method_name, args_json, kwargs_json) _print_sdk_result(out) + except ApiException as e: + body = getattr(e, "body", None) or "" + st = getattr(e, "status", None) + if st is not None and _is_token_expired_api_response(int(st), str(body)): + _print_token_expired_relogin_hint() + else: + console.print(f"[red]{e}[/red]") + raise typer.Exit(1) from e except Exception as e: console.print(f"[red]{e}[/red]") raise typer.Exit(1) from e @@ -331,20 +383,41 @@ def invoke_alias_cmd( _run_api_invoke(method_name, args_json, kwargs_json, profile) +def _print_api_list(prefix: str, plain: bool) -> None: + if plain: + for m in list_api_methods(prefix): + console.print(m) + return + t = Table(title="DefaultApi operations", show_header=True, header_style="bold") + t.add_column("SDK method", style="cyan", overflow="fold") + t.add_column("HTTP", style="green", no_wrap=True) + t.add_column("Path", overflow="fold") + for name, verb, path in list_api_method_rows(prefix): + t.add_row(name, verb, path) + console.print(t) + + @api_app.command("list") def api_list_cmd( prefix: str = typer.Option("", "--prefix", help="Only methods starting with this prefix (e.g. v1_edges)"), + plain: bool = typer.Option( + False, + "--plain", + "-1", + help="Print one SDK method name per line (no HTTP/path table)", + ), ) -> None: - """List DefaultApi method names (for use with graphiant api invoke).""" - for m in list_api_methods(prefix): - console.print(m) + """List DefaultApi method names and raw HTTP path (for graphiant api invoke / graphiant rest).""" + _print_api_list(prefix, plain) @app.command("apis") -def apis_alias_cmd(prefix: str = typer.Option("", "--prefix")) -> None: +def apis_alias_cmd( + prefix: str = typer.Option("", "--prefix"), + plain: bool = typer.Option(False, "--plain", "-1", help="SDK method names only, one per line"), +) -> None: """Shorthand for [cyan]graphiant api list[/cyan].""" - for m in list_api_methods(prefix): - console.print(m) + _print_api_list(prefix, plain) @app.command("rest") @@ -384,28 +457,220 @@ def rest_cmd( except json.JSONDecodeError: console.print(text) else: - console.print(f"[red]HTTP {status}[/red]\n{text}") + _handle_cli_http_error(status, text) raise typer.Exit(1) +def _whoami_key_is_last_active(key: str) -> bool: + return str(key).replace("-", "_").lower() in ("lastactiveat", "last_active_at") + + +def _whoami_row_keys(data: dict[str, Any]) -> list[str]: + """Alphabetical property order, with lastActiveAt (any casing) as the final row(s).""" + last_active = sorted(k for k in data if _whoami_key_is_last_active(k)) + rest = sorted(k for k in data if k not in set(last_active)) + return rest + last_active + + +def _parse_unix_timestamp_value(val: Any) -> float | None: + """Coerce protobuf JSON, JSON string, epoch int/float/str → Unix seconds (fractional).""" + if isinstance(val, str): + s = val.strip() + if s.startswith("{") and "seconds" in s: + try: + val = json.loads(s) + except json.JSONDecodeError: + return None + if isinstance(val, dict): + raw_s = val.get("seconds") + if raw_s is None: + return None + try: + ts = float(raw_s) + except (TypeError, ValueError): + return None + nanos = val.get("nanos", 0) + try: + ts += float(nanos) / 1e9 + except (TypeError, ValueError): + pass + return ts + if isinstance(val, (int, float)): + ts = float(val) + if ts > 1e12: + ts /= 1000.0 + return ts + if isinstance(val, str): + s = val.strip() + if not s: + return None + try: + ts = float(s) + except ValueError: + return None + if ts > 1e12: + ts /= 1000.0 + return ts + return None + + +def _format_utc_time_human(val: Any) -> str | None: + """Readable instant in UTC (Unix / protobuf timestamps are UTC-based).""" + ts = _parse_unix_timestamp_value(val) + if ts is None: + return None + try: + dt = datetime.fromtimestamp(ts, tz=timezone.utc) + except (OSError, ValueError, OverflowError): + return None + base = dt.strftime("%B %d, %Y at %I:%M:%S %p") + return f"{base} (UTC)" + + +def _user_display_name(user: dict[str, Any]) -> str: + fn = (user.get("firstName") or "").strip() + ln = (user.get("lastName") or "").strip() + full = f"{fn} {ln}".strip() + if full: + return full + email = (user.get("email") or "").strip() + return email or "—" + + +def _whoami_value_cell(key: str, val: Any) -> str: + if _whoami_key_is_last_active(key): + human = _format_utc_time_human(val) + if human is not None: + return human + if isinstance(val, dict) and "seconds" in val and set(val.keys()) <= {"seconds", "nanos"}: + human = _format_utc_time_human(val) + if human is not None: + return human + if isinstance(val, (dict, list)): + return json.dumps(val, default=str) + return str(val) + + +def _whoami_table_from_dict(title: str, caption: str, data: dict[str, Any]) -> Table: + t = Table(title=title, caption=caption, show_header=True, header_style="bold") + t.add_column("Property", style="cyan", overflow="fold") + t.add_column("Value", overflow="fold") + for key in _whoami_row_keys(data): + t.add_row(str(key), _whoami_value_cell(key, data[key])) + return t + + +def _print_whoami_tables( + host: str, + raw_token: str, + auth: dict[str, Any], +) -> None: + """Tabular output: session, permissions, and user profile (no raw JSON dump).""" + user_id = auth.get("userId") + ent_id = auth.get("enterpriseId") + + user_note = "—" + user_obj: dict[str, Any] | None = None + if user_id is not None and str(user_id).strip(): + st_u, body_u, _ = rest_request( + host, + "GET", + "/v1/users", + raw_token, + body=None, + query={"id": str(user_id)}, + ) + if 200 <= st_u < 300: + try: + uj = json.loads(body_u) + users = uj.get("users") if isinstance(uj, dict) else None + if isinstance(users, list) and users and isinstance(users[0], dict): + user_obj = users[0] + user_note = _user_display_name(user_obj) + else: + user_note = f"(no users in response, HTTP {st_u})" + except (json.JSONDecodeError, TypeError): + user_note = f"(invalid JSON, HTTP {st_u})" + else: + user_note = f"(lookup failed HTTP {st_u})" + + extra_auth = {k: v for k, v in auth.items() if k not in ("permissions",)} + slim = {k: v for k, v in extra_auth.items() if k not in ("userId", "enterpriseId", "timeZone")} + + session = Table( + title="[bold]Session[/bold]", + caption="[dim]GET /v1/auth/user (+ /v1/users for your display name)[/dim]", + show_header=True, + header_style="bold", + ) + session.add_column("Property", style="cyan", no_wrap=True) + session.add_column("Value", overflow="fold") + session.add_row("userId", str(user_id) if user_id is not None else "—") + session.add_row("User name", user_note) + session.add_row("enterpriseId", str(ent_id) if ent_id is not None else "—") + session.add_row("timeZone", str(auth.get("timeZone", "—"))) + for key in _whoami_row_keys(slim): + session.add_row(str(key), _whoami_value_cell(key, slim[key])) + console.print() + console.print(session) + + perms = auth.get("permissions") + if isinstance(perms, dict) and perms: + pt = Table( + title="[bold]Permissions[/bold]", + caption="[dim]From GET /v1/auth/user[/dim]", + show_header=True, + header_style="bold", + ) + pt.add_column("Permission", style="cyan") + pt.add_column("Level") + for pk in sorted(perms.keys()): + pv = perms[pk] + pt.add_row(str(pk), "—" if pv is None or pv == "" else str(pv)) + console.print() + console.print(pt) + + if user_obj is not None: + console.print() + console.print( + _whoami_table_from_dict( + "[bold]Profile[/bold]", + f"[dim]GET /v1/users?id={user_id}[/dim]", + user_obj, + ) + ) + + @app.command("whoami") def whoami_cmd(profile: Optional[str] = typer.Option(None, "--profile", "-p")) -> None: - """Call /v1/auth with the current bearer token.""" + """Show who you are: session, permissions, and profile (GET /v1/auth/user + /v1/users). + + Uses the raw REST client with a single Authorization header. The OpenAPI client + would also apply jwtAuth (duplicate authorization header keys), which some + gateways (e.g. Azure Application Gateway) reject with 400. + """ token, host = _token_for_profile(profile) if not token: console.print("[red]Not logged in. Run [bold]graphiant login[/bold][/red]") raise typer.Exit(1) - from graphiant_sdk import ApiClient, Configuration, DefaultApi - - cfg = Configuration(host=host) - cfg.api_key["jwtAuth"] = token - cfg.api_key_prefix["jwtAuth"] = "Bearer" - auth = f"Bearer {token}" + raw_tok = token[7:].strip() if token.lower().startswith("bearer ") else token try: - with ApiClient(cfg) as client: - api = DefaultApi(client) - r = api.v1_auth_get(authorization=auth) - console.print(JSON(json.dumps(r.to_dict(), default=str))) + status, text, _ = rest_request(host, "GET", "/v1/auth/user", raw_tok, body=None, query=None) + if 200 <= status < 300: + try: + payload = json.loads(text) + except json.JSONDecodeError: + console.print(text) + return + if isinstance(payload, dict): + _print_whoami_tables(host, raw_tok, payload) + else: + console.print(JSON(json.dumps(payload, indent=2, default=str))) + else: + _handle_cli_http_error(status, text) + raise typer.Exit(1) + except typer.Exit: + raise except Exception as e: console.print(f"[red]{e}[/red]") raise typer.Exit(1) from e @@ -417,7 +682,8 @@ def version_cmd() -> None: def main() -> None: - app() + # Stable prog name for Click/Typer shell completion (_GRAPHIANT_COMPLETE, --install-completion). + app(prog_name="graphiant") if __name__ == "__main__": diff --git a/graphiant_cli/rest_client.py b/graphiant_cli/rest_client.py index d0ac11a2..ff0e3bef 100644 --- a/graphiant_cli/rest_client.py +++ b/graphiant_cli/rest_client.py @@ -4,10 +4,33 @@ import json import urllib.parse +from collections.abc import Sequence from typing import Any import urllib3 +# Query values: str/int or repeated keys (e.g. enterpriseIds) as a sequence. +QueryDict = dict[str, str | int | Sequence[str | int]] + + +def strip_bearer_prefix(token: str) -> str: + """Return the JWT/opaque secret without a leading ``Bearer `` prefix (case-insensitive).""" + t = (token or "").strip() + if t.lower().startswith("bearer "): + return t[7:].strip() + return t + + +def _encode_query(query: QueryDict) -> str: + pairs: list[tuple[str, str]] = [] + for key, val in query.items(): + if isinstance(val, (list, tuple)): + for item in val: + pairs.append((key, str(item))) + else: + pairs.append((key, str(val))) + return urllib.parse.urlencode(pairs) + def request( host: str, @@ -15,7 +38,7 @@ def request( path: str, token: str, body: dict[str, Any] | str | None = None, - query: dict[str, str] | None = None, + query: QueryDict | None = None, timeout: float = 120.0, ) -> tuple[int, str, dict[str, str]]: """Return (status_code, response_text, response_headers).""" @@ -23,11 +46,12 @@ def request( p = path if path.startswith("/") else f"/{path}" url = base + p if query: - url = f"{url}?{urllib.parse.urlencode(query)}" + url = f"{url}?{_encode_query(query)}" http = urllib3.PoolManager() + raw = strip_bearer_prefix(token) headers = { - "Authorization": f"Bearer {token}", + "Authorization": f"Bearer {raw}", "Accept": "application/json", } body_bytes: bytes | None = None diff --git a/graphiant_cli/sdk_invoke.py b/graphiant_cli/sdk_invoke.py index a7144484..79563ddd 100644 --- a/graphiant_cli/sdk_invoke.py +++ b/graphiant_cli/sdk_invoke.py @@ -4,12 +4,34 @@ import inspect import json +import re +from functools import lru_cache +from pathlib import Path from typing import Annotated, Any, Optional, get_args, get_origin from pydantic import BaseModel +from graphiant_cli.rest_client import strip_bearer_prefix from graphiant_sdk import ApiClient, Configuration, DefaultApi +_ROUTE_FROM_SERIALIZE = re.compile( + r"def _(v[0-9]+_[a-z0-9_]+)_serialize\(" + r"[\s\S]*?" + r"return self\.api_client\.param_serialize\(\s*" + r"method='([A-Z]+)',\s*" + r"resource_path='([^']+)'", + re.MULTILINE, +) + + +@lru_cache(maxsize=1) +def _default_api_route_index() -> dict[str, tuple[str, str]]: + """Map DefaultApi operation name → (HTTP verb, path) from generated client source.""" + import graphiant_sdk.api.default_api as mod + + text = Path(mod.__file__).read_text(encoding="utf-8") + return {m: (verb, path) for m, verb, path in _ROUTE_FROM_SERIALIZE.findall(text)} + def list_api_methods(prefix: str = "") -> list[str]: """List callable DefaultApi operation methods (excludes *_with_http_info, etc.).""" @@ -28,6 +50,16 @@ def list_api_methods(prefix: str = "") -> list[str]: return out +def list_api_method_rows(prefix: str = "") -> list[tuple[str, str, str]]: + """(sdk_method_name, http_verb, path) for each operation, sorted by method name.""" + routes = _default_api_route_index() + rows: list[tuple[str, str, str]] = [] + for name in list_api_methods(prefix): + verb, path = routes.get(name, ("—", "—")) + rows.append((name, verb, path)) + return rows + + def _unwrap_annotated(annotation: Any) -> Any: if annotation is None or annotation is inspect.Parameter.empty: return annotation @@ -56,8 +88,9 @@ def invoke_method( kwargs_json: Optional[str], ) -> Any: cfg = Configuration(host=host) - cfg.api_key["jwtAuth"] = token - cfg.api_key_prefix["jwtAuth"] = "Bearer" + # Do not set api_key["jwtAuth"]: operations already send Authorization from the + # `authorization` argument. jwtAuth would add a second header (lowercase key + # "authorization"), which Azure Application Gateway rejects with 400. with ApiClient(cfg) as client: api = DefaultApi(client) @@ -81,7 +114,7 @@ def invoke_method( ai += 1 continue if p.name == "authorization": - merged["authorization"] = f"Bearer {token}" + merged["authorization"] = f"Bearer {strip_bearer_prefix(token)}" for name, param in sig.parameters.items(): if name.startswith("_"): diff --git a/graphiant_sdk/__init__.py b/graphiant_sdk/__init__.py index ba6b6288..ed64c80f 100644 --- a/graphiant_sdk/__init__.py +++ b/graphiant_sdk/__init__.py @@ -14,7 +14,7 @@ """ # noqa: E501 -__version__ = "26.3.2" +__version__ = "26.3.3" # Define package exports __all__ = [ diff --git a/graphiant_sdk/api_client.py b/graphiant_sdk/api_client.py index 0371960d..719e7582 100644 --- a/graphiant_sdk/api_client.py +++ b/graphiant_sdk/api_client.py @@ -91,7 +91,7 @@ def __init__( self.default_headers[header_name] = header_value self.cookie = cookie # Set default User-Agent. - self.user_agent = 'OpenAPI-Generator/26.3.2/python' + self.user_agent = 'OpenAPI-Generator/26.3.3/python' self.client_side_validation = configuration.client_side_validation def __enter__(self): diff --git a/graphiant_sdk/configuration.py b/graphiant_sdk/configuration.py index 630e0591..53824991 100644 --- a/graphiant_sdk/configuration.py +++ b/graphiant_sdk/configuration.py @@ -554,7 +554,7 @@ def to_debug_report(self) -> str: "OS: {env}\n"\ "Python Version: {pyversion}\n"\ "Version of the API: 1.0.0\n"\ - "SDK Package Version: 26.3.2".\ + "SDK Package Version: 26.3.3".\ format(env=sys.platform, pyversion=sys.version) def get_host_settings(self) -> List[HostSetting]: diff --git a/pyproject.toml b/pyproject.toml index 4802ca6f..1c29cff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "graphiant_sdk" -version = "26.3.2" +version = "26.3.3" description = "Graphiant APIs" authors = [ {name = "OpenAPI Generator Community",email = "team@openapitools.org"}, @@ -15,6 +15,7 @@ dependencies = [ "pydantic (>=2.11)", "typing-extensions (>=4.7.1)", "typer (>=0.9,<1)", + "shellingham (>=1.3.0)", "rich (>=13,<15)", "pygments (>=2.20,<3)", "playwright (>=1.40,<2)", diff --git a/setup.py b/setup.py index c95d3f19..a24c8f10 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ from setuptools import setup, find_packages # noqa: H301 NAME = "graphiant_sdk" -VERSION = "26.3.2" +VERSION = "26.3.3" PYTHON_REQUIRES = ">= 3.10" REQUIRES = [ "urllib3 >= 2.1.0, < 3.0.0", @@ -32,6 +32,7 @@ "pydantic >= 2", "typing-extensions >= 4.7.1", "typer >= 0.9.0, < 1.0.0", + "shellingham >= 1.3.0", "rich >= 13.0.0, < 15.0.0", "pygments >= 2.20, < 3.0.0", "playwright >= 1.40.0, < 2.0.0", diff --git a/tests/test_portal_login.py b/tests/test_portal_login.py index 69cf7a2b..6e258e23 100644 --- a/tests/test_portal_login.py +++ b/tests/test_portal_login.py @@ -130,14 +130,14 @@ def test_url_ignored_for_bearer_capture_login_pre() -> None: def test_eligible_capture_combines_plausible_and_url() -> None: assert not _eligible_capture("null", "https://api.graphiant.com/v1/auth/login/pre") - assert not _eligible_capture("null", "https://api.graphiant.com/v1/edges/summary") + assert not _eligible_capture("null", "https://api.graphiant.com/v1/edges-summary") tok = "gr-auth-" + "x" * 40 - assert _eligible_capture(tok, "https://api.graphiant.com/v1/edges/summary") + assert _eligible_capture(tok, "https://api.graphiant.com/v1/edges-summary") assert not _eligible_capture(tok, "https://api.graphiant.com/v1/auth/login/pre") def test_is_graphiant_api_url() -> None: - assert _is_graphiant_api_url("https://api.graphiant.com/v1/edges/summary") + assert _is_graphiant_api_url("https://api.graphiant.com/v1/edges-summary") assert _is_graphiant_api_url("https://api.graphiant.com/v2/edges/summary") assert _is_graphiant_api_url("https://api.graphiant.com/v1/auth/refresh?x=1") assert _is_graphiant_api_url("https://reg.example.com/api/v1/auth/refresh") @@ -150,7 +150,7 @@ class _Req: headers = {"authorization": "Bearer tok-from-req"} class _Resp: - url = "https://api.graphiant.com/v1/edges/summary" + url = "https://api.graphiant.com/v1/edges-summary" status = 200 headers: dict[str, str] = {} request = _Req() diff --git a/tests/test_rest_client.py b/tests/test_rest_client.py new file mode 100644 index 00000000..e303eff3 --- /dev/null +++ b/tests/test_rest_client.py @@ -0,0 +1,16 @@ +"""Tests for graphiant_cli.rest_client helpers.""" + +from graphiant_cli.rest_client import strip_bearer_prefix + + +def test_strip_bearer_prefix_plain() -> None: + assert strip_bearer_prefix("opaque-token") == "opaque-token" + + +def test_strip_bearer_prefix_removes_prefix() -> None: + assert strip_bearer_prefix("Bearer jwt-here") == "jwt-here" + assert strip_bearer_prefix("bearer jwt-here") == "jwt-here" + + +def test_strip_bearer_prefix_empty() -> None: + assert strip_bearer_prefix("") == "" diff --git a/tests/test_sdk_invoke.py b/tests/test_sdk_invoke.py index fed72b1c..3aeb0284 100644 --- a/tests/test_sdk_invoke.py +++ b/tests/test_sdk_invoke.py @@ -1,6 +1,6 @@ """Tests for SDK method listing (no live API calls).""" -from graphiant_cli.sdk_invoke import list_api_methods +from graphiant_cli.sdk_invoke import list_api_method_rows, list_api_methods def test_list_api_methods_includes_auth_get() -> None: @@ -12,3 +12,9 @@ def test_list_api_methods_includes_auth_get() -> None: def test_list_api_methods_prefix_filter() -> None: edges = list_api_methods("v1_edges_summary") assert "v1_edges_summary_get" in edges + + +def test_list_api_method_rows_includes_raw_http() -> None: + rows = {name: (verb, path) for name, verb, path in list_api_method_rows("v1_edges_summary")} + assert rows["v1_edges_summary_get"] == ("GET", "/v1/edges-summary") + assert rows["v1_edges_summary_post"][0] == "POST"