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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/dora/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ func (m *Module) PythonAPIDocs() map[string]types.ModuleDoc {
"list_networks": {Signature: "list_networks() -> list[dict]", Description: "List networks with Dora explorers"},
"get_base_url": {Signature: "get_base_url(network) -> str", Description: "Get Dora base URL for a network"},
"get_network_overview": {Signature: "get_network_overview(network) -> dict", Description: "Get current and finalized epoch, slot, finality status (finalizing), validator counts, and participation when Dora provides it"},
"get_clients": {Signature: "get_clients(network) -> list[dict]", Description: "List client nodes tracked by Dora; client_name is the ethnode instance label, status is online/offline"},
"get_validator": {Signature: "get_validator(network, index_or_pubkey) -> dict", Description: "Get validator by index or pubkey"},
"get_validators": {Signature: "get_validators(network, status=None, limit=100) -> list", Description: "List validators with optional filter"},
"get_slot": {Signature: "get_slot(network, slot_or_hash) -> dict", Description: "Get slot by number or hash"},
Expand Down
13 changes: 13 additions & 0 deletions modules/dora/python/dora.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ def get_network_overview(network: str) -> dict[str, Any]:
return _runtime.invoke_data("dora.get_network_overview", {"network": network})


def get_clients(network: str) -> list[dict[str, Any]]:
"""List client nodes connected to the network's Dora explorer.

Each entry's client_name is the per-node instance label (e.g.
'lighthouse-geth-1') accepted by ethnode calls; status distinguishes
online from offline nodes.
"""
_require_dora_available()
data = _runtime.invoke_data("dora.get_clients", {"network": network})
clients = data.get("clients", [])
return clients if isinstance(clients, list) else []


def get_validator(network: str, index_or_pubkey: str) -> dict[str, Any]:
_require_dora_available()
payload = _runtime.invoke_json(
Expand Down
1 change: 1 addition & 0 deletions modules/ethnode/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ func (m *Module) PythonAPIDocs() map[string]types.ModuleDoc {
// Discovery functions.
"list_datasources": {Signature: "list_datasources() -> list[dict]", Description: "List available ethnode datasources"},
"list_networks": {Signature: "list_networks() -> list[dict]", Description: "List active network ids reachable for direct node access. Use instance='lb' for the load-balanced endpoint."},
"list_instances": {Signature: "list_instances(network) -> list[dict]", Description: "List per-node instance labels (name, status) for a network via its Dora explorer; pass name as the instance argument of other ethnode calls"},
// Beacon node (CL) functions.
"get_node_version": {Signature: "get_node_version(network, instance) -> dict", Description: "Get beacon node software version"},
"get_node_syncing": {Signature: "get_node_syncing(network, instance) -> dict", Description: "Get beacon node sync status"},
Expand Down
31 changes: 29 additions & 2 deletions modules/ethnode/python/ethnode.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,42 @@ def list_networks() -> list[dict[str, Any]]:
"""List active network ids reachable for direct node access.

Entries include at least name and type. The name is the network id accepted
by ethnode calls. The per-node instance label cannot be enumerated and must
be supplied by the caller. Use instance="lb" for the load-balanced endpoint.
by ethnode calls. Use list_instances(network) to enumerate per-node instance
labels, or instance="lb" for the load-balanced endpoint.
"""
_require_ethnode_available()
data = _runtime.invoke_data("ethnode.list_networks") or {}
networks = data.get("networks", [])
return networks if isinstance(networks, list) else []


def list_instances(network: str) -> list[dict[str, Any]]:
"""List per-node instance labels for a network.

Entries have name (the instance argument accepted by other ethnode calls)
and status (online/offline), sourced from the network's Dora explorer,
which tracks every node. instance="lb" (load-balanced) also always works.
"""
_require_ethnode_available()
from ethpandaops import dora as _dora

try:
clients = _dora.get_clients(network)
except Exception as err:
raise ValueError(
f"cannot enumerate instances for {network!r}: this reads the "
f"network's Dora explorer, which is unavailable ({err}). "
f'Use instance="lb" for the load-balanced endpoint.'
) from err

instances = [
{"name": c.get("client_name", ""), "status": c.get("status", "")}
for c in clients
if c.get("client_name")
]
return sorted(instances, key=lambda entry: entry["name"])


def beacon_get(
network: str,
instance: str,
Expand Down
13 changes: 12 additions & 1 deletion pkg/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,18 @@ func runBuild(cmd *cobra.Command, args []string) error {
}

if isJSON() {
return printJSON(result)
if err := printJSON(result); err != nil {
return err
}

// Mirror the text path's exit status: a failed or cancelled build must
// be detectable from the process, not only by reading the payload.
switch result.Conclusion {
case "failure", "cancelled":
return &exitCodeError{code: 1}
}

return nil
}

switch result.Conclusion {
Expand Down
3 changes: 3 additions & 0 deletions pkg/cli/clickhouse_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ var clickhouseQueryRawCmd = &cobra.Command{
Short: "Execute a SQL query and return raw rows (always JSON)",
Long: `Execute a SQL query and return raw rows as JSON.

Failures are also JSON on stdout ({"error": ...}, exit code 1), so piping into
a JSON parser surfaces the real error instead of a parse failure.

Keep result sets bounded: aggregate in SQL or add a LIMIT when inspecting rows.
For cross-source analysis, run separate bounded queries and combine them with
'panda execute' or another client-side step instead of dumping unbounded rows
Expand Down
14 changes: 12 additions & 2 deletions pkg/cli/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Examples:

func init() {
rootCmd.AddCommand(executeCmd)
executeCmd.Flags().StringVar(&executeCode, "code", "", "Python code to execute")
executeCmd.Flags().StringVarP(&executeCode, "code", "c", "", "Python code to execute")
executeCmd.Flags().StringVar(&executeFile, "file", "", "Path to Python file to execute")
executeCmd.Flags().IntVar(&executeTimeout, "timeout", 0, "Execution timeout in seconds (default: from config)")
executeCmd.Flags().StringVar(&executeSession, "session", "", "Session ID to reuse")
Expand All @@ -88,7 +88,17 @@ func runExecute(cmd *cobra.Command, _ []string) error {
}

if isJSON() {
return printJSON(result)
if err := printJSON(result); err != nil {
return err
}

// Mirror the text path's exit status: a failed execution must be
// detectable from the process, not only by parsing exit_code.
if result.ExitCode != 0 {
return &exitCodeError{code: result.ExitCode}
}

return nil
}

// Print stdout to stdout, stderr to stderr.
Expand Down
37 changes: 37 additions & 0 deletions pkg/cli/machine_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cli

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// machineOutputFor mirrors the PersistentPreRunE decision so the rule can be
// exercised without executing a command.
func machineOutputFor(t *testing.T, format, commandName string) bool {
t.Helper()

previous := outputFormat
t.Cleanup(func() { outputFormat = previous })

outputFormat = format

return isJSON() || alwaysJSONCommands[commandName]
}

func TestMachineOutputCoversEveryJSONSurface(t *testing.T) {
// The failure this guards is a JSON consumer merging stderr into the pipe:
// it breaks on any command rendering JSON, not just the always-JSON ones.
assert.True(t, machineOutputFor(t, "json", "query"),
"any command in JSON output mode is a machine surface")
assert.True(t, machineOutputFor(t, "text", "query-raw"),
"query-raw emits JSON regardless of --output")
assert.False(t, machineOutputFor(t, "text", "query"),
"human text output keeps cobra's stderr error line")
}

func TestQueryRawStaysAnAlwaysJSONSurface(t *testing.T) {
// Guards the contract its help text advertises.
require.True(t, alwaysJSONCommands[clickhouseQueryRawCmd.Name()])
}
15 changes: 15 additions & 0 deletions pkg/cli/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ func TestServerErrorHintUsesExistingResourcesCommand(t *testing.T) {
assert.NotContains(t, hint, "panda resources list")
}

func TestServerErrorHintClassifiesJSONRPCErrors(t *testing.T) {
// Method-not-found is a permanent capability gap of the client; the hint
// must steer away from the gateway-status "retry" framing, whatever the
// HTTP status an old or new server wrapped it in.
for _, status := range []int{http.StatusBadRequest, http.StatusBadGateway} {
hint := serverErrorHint(status, "JSON-RPC error -32601: the method debug_chainConfig does not exist/is not available")
assert.Contains(t, hint, "capability gap")
assert.NotContains(t, hint, "temporarily unreachable")
}

hint := serverErrorHint(http.StatusBadRequest, "JSON-RPC error -32000: execution reverted (data: 0x08c379a0)")
assert.Contains(t, hint, "rejected it")
assert.Contains(t, hint, "(data: ...)")
}

func TestServerErrorHintClassifiesClickHouseErrors(t *testing.T) {
tests := []struct {
name string
Expand Down
74 changes: 71 additions & 3 deletions pkg/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"

Expand Down Expand Up @@ -65,6 +66,19 @@ var skipUpdateCheckCommands = map[string]bool{
"help": true,
}

// alwaysJSONCommands emit JSON regardless of --output, so they are machine
// surfaces even without the flag.
var alwaysJSONCommands = map[string]bool{
"query-raw": true,
}

// machineOutput reports whether this run's stdout is a JSON document destined
// for a parser. In that mode the CLI owns error rendering: stdout carries
// exactly one JSON document, stderr stays empty, and the exit code conveys
// failure — consumers routinely merge stderr into the pipe, where an
// "Error:" line or an update notice would corrupt the parse.
var machineOutput bool

var rootCmd = &cobra.Command{
Use: "panda",
Short: "Ethereum network analytics CLI",
Expand All @@ -85,14 +99,22 @@ var rootCmd = &cobra.Command{
outputFormat = "json"
}

machineOutput = isJSON() || alwaysJSONCommands[cmd.Name()]
if machineOutput {
// Execute renders the error as JSON on stdout instead. Reached
// via cmd, not rootCmd, which cannot be named inside its own
// initializer.
cmd.Root().SilenceErrors = true
}

if shouldCheckForUpdate(cmd) {
go backgroundUpdateCheck()
}

return nil
},
PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
if !shouldCheckForUpdate(cmd) {
if machineOutput || !shouldCheckForUpdate(cmd) {
return nil
}

Expand All @@ -111,13 +133,23 @@ func Execute() {
return
}

var exitErr *exitCodeError
isExitCode := errors.As(err, &exitErr)

// In machine mode cobra's stderr line is suppressed, so render the failure
// here. An exitCodeError means the command already wrote its own JSON
// payload and is only reporting the outcome — a second document would
// break the parse it is meant to protect, so the exit code speaks alone.
if machineOutput && !isExitCode {
_ = printJSON(map[string]any{"error": err.Error()})
}

if hint := unknownCommandHint(err); hint != "" {
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, hint)
}

var exitErr *exitCodeError
if errors.As(err, &exitErr) {
if isExitCode {
os.Exit(exitErr.code)
}

Expand All @@ -130,9 +162,35 @@ func executeWithSignals() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// Once: wrapping is not idempotent, and every command is registered by
// now (subcommand init() order is not guaranteed relative to this file's).
attachUsageOnce.Do(func() { attachUsageToArgErrors(rootCmd) })

return rootCmd.ExecuteContext(ctx)
}

var attachUsageOnce sync.Once

// attachUsageToArgErrors wraps every command's positional-arg validator so a
// count/shape error carries the command's usage line. SilenceUsage hides the
// usage block on runtime errors, which is right for HTTP failures but leaves
// "accepts 1 arg(s), received 0" with no path to the correct invocation.
func attachUsageToArgErrors(cmd *cobra.Command) {
if validate := cmd.Args; validate != nil {
cmd.Args = func(c *cobra.Command, args []string) error {
if err := validate(c, args); err != nil {
return fmt.Errorf("%w\n\nUsage: %s", err, c.UseLine())
}

return nil
}
}

for _, sub := range cmd.Commands() {
attachUsageToArgErrors(sub)
}
}

func unknownCommandHint(err error) string {
if err == nil || !strings.Contains(err.Error(), "unknown command") {
return ""
Expand All @@ -144,6 +202,16 @@ func unknownCommandHint(err error) string {
func init() {
rootCmd.SetVersionTemplate("panda version {{.Version}}\n")

// SilenceUsage suppresses the usage block everywhere, so a flag typo
// would otherwise surface as a bare "unknown flag" with no correction
// path. Attach the one-line usage to the error itself.
rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
return fmt.Errorf(
"%w\n\nUsage: %s\nRun '%s --help' for available flags",
err, cmd.UseLine(), cmd.CommandPath(),
)
})

rootCmd.AddGroup(
&cobra.Group{ID: groupWorkflow, Title: "Workflow:"},
&cobra.Group{ID: groupDiscovery, Title: "Discovery:"},
Expand Down
19 changes: 19 additions & 0 deletions pkg/cli/serverclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,25 @@ func serverErrorHint(status int, message string) string {
return "the first Prometheus argument must be a live datasource name; list them with 'panda prometheus list-datasources' or 'panda datasources --type prometheus'"
}

if strings.Contains(normalized, "embedding query") || strings.Contains(normalized, "calling proxy embed") {
return "semantic search could not reach its embedding upstream; the query is fine — retry shortly, and if it persists check proxy connectivity with 'panda auth status'"
}

if strings.Contains(normalized, "index not ready") || strings.Contains(normalized, "search index not available") {
return "the semantic search index is not serving yet (still warming after server start, or never activated because the proxy advertises no embedding model); the query is fine — retry, then check 'panda datasources'"
}

// A JSON-RPC error rode back on a healthy node connection: the node
// processed and rejected the request, so the gateway-status hint below
// ("retry") would misread a deterministic failure as transient.
if strings.Contains(message, "JSON-RPC error -32601") {
return "the node answered but does not expose this RPC method; that is a capability gap of this client, not an outage — try another instance or client instead of retrying"
}

if strings.Contains(message, "JSON-RPC error ") {
return "the node processed the request and rejected it; adjust the method, params, or request scope rather than retrying unchanged — any (data: ...) payload carries the node's reason (e.g. a revert)"
}

switch status {
case http.StatusNotFound:
return "the requested module, operation, datasource, or resource is not available on this server; check 'panda datasources' and 'panda resources'"
Expand Down
Loading
Loading