refactor: flatten CLI directory structure - remove 'box in a box'
BEFORE: /opt/aitbc/cli/ ├── aitbc_cli/ # Python package (box in a box) │ ├── commands/ │ ├── main.py │ └── ... ├── setup.py AFTER: /opt/aitbc/cli/ # Flat structure ├── commands/ # Direct access ├── main.py # Direct access ├── auth/ ├── config/ ├── core/ ├── models/ ├── utils/ ├── plugins.py └── setup.py CHANGES MADE: - Moved all files from aitbc_cli/ to cli/ root - Fixed all relative imports (from . to absolute imports) - Updated setup.py entry point: aitbc_cli.main → main - Added CLI directory to Python path in entry script - Simplified deployment.py to remove dependency on deleted core.deployment - Fixed import paths in all command files - Recreated virtual environment with new structure BENEFITS: - Eliminated 'box in a box' nesting - Simpler directory structure - Direct access to all modules - Cleaner imports - Easier maintenance and development - CLI works with both 'python main.py' and 'aitbc' commands
This commit is contained in:
599
cli/commands/client.py
Executable file
599
cli/commands/client.py
Executable file
@@ -0,0 +1,599 @@
|
||||
"""Client commands for AITBC CLI"""
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import json
|
||||
import time
|
||||
from typing import Optional
|
||||
from utils import output, error, success
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
def client(ctx):
|
||||
"""Submit and manage jobs"""
|
||||
# Set role for client commands
|
||||
ctx.ensure_object(dict)
|
||||
ctx.parent.detected_role = 'client'
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.option("--type", "job_type", default="inference", help="Job type")
|
||||
@click.option("--prompt", help="Prompt for inference jobs")
|
||||
@click.option("--model", help="Model name")
|
||||
@click.option("--ttl", default=900, help="Time to live in seconds")
|
||||
@click.option("--file", type=click.File('r'), help="Submit job from JSON file")
|
||||
@click.option("--retries", default=0, help="Number of retry attempts (0 = no retry)")
|
||||
@click.option("--retry-delay", default=1.0, help="Initial retry delay in seconds")
|
||||
@click.pass_context
|
||||
def submit(ctx, job_type: str, prompt: Optional[str], model: Optional[str],
|
||||
ttl: int, file, retries: int, retry_delay: float):
|
||||
"""Submit a job to the coordinator"""
|
||||
# Check if we're in test mode
|
||||
if ctx.parent and ctx.parent.parent and ctx.parent.parent.params.get('test_mode', False):
|
||||
output({
|
||||
"job_id": "job_test123",
|
||||
"status": "submitted",
|
||||
"type": job_type,
|
||||
"prompt": prompt or "test prompt",
|
||||
"model": model or "test-model",
|
||||
"ttl": ttl,
|
||||
"submitted_at": "2026-03-07T10:00:00Z"
|
||||
}, ctx.obj.get("output_format", "table"))
|
||||
return
|
||||
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Build job data
|
||||
if file:
|
||||
try:
|
||||
task_data = json.load(file)
|
||||
except Exception as e:
|
||||
error(f"Failed to read job file: {e}")
|
||||
return
|
||||
else:
|
||||
task_data = {"type": job_type}
|
||||
if prompt:
|
||||
task_data["prompt"] = prompt
|
||||
if model:
|
||||
task_data["model"] = model
|
||||
|
||||
# Submit job with retry and exponential backoff
|
||||
max_attempts = retries + 1
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
# Use correct API endpoint format
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/jobs",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={
|
||||
"payload": task_data,
|
||||
"ttl_seconds": ttl
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
job = response.json()
|
||||
result = {
|
||||
"job_id": job.get('job_id'),
|
||||
"status": "submitted",
|
||||
"message": "Job submitted successfully"
|
||||
}
|
||||
if attempt > 1:
|
||||
result["attempts"] = attempt
|
||||
output(result, ctx.obj['output_format'])
|
||||
return
|
||||
else:
|
||||
if attempt < max_attempts:
|
||||
delay = retry_delay * (2 ** (attempt - 1))
|
||||
click.echo(f"Attempt {attempt}/{max_attempts} failed ({response.status_code}), retrying in {delay:.1f}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
error(f"Failed to submit job: {response.status_code} - {response.text}")
|
||||
ctx.exit(response.status_code)
|
||||
except Exception as e:
|
||||
if attempt < max_attempts:
|
||||
delay = retry_delay * (2 ** (attempt - 1))
|
||||
click.echo(f"Attempt {attempt}/{max_attempts} failed ({e}), retrying in {delay:.1f}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
error(f"Network error after {max_attempts} attempts: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.argument("job_id")
|
||||
@click.pass_context
|
||||
def status(ctx, job_id: str):
|
||||
"""Check job status"""
|
||||
# Check if we're in test mode
|
||||
if ctx.parent and ctx.parent.parent and ctx.parent.parent.params.get('test_mode', False):
|
||||
output({
|
||||
"job_id": job_id,
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"result": "Test job completed successfully",
|
||||
"created_at": "2026-03-07T10:00:00Z",
|
||||
"completed_at": "2026-03-07T10:01:00Z"
|
||||
}, ctx.obj.get("output_format", "table"))
|
||||
return
|
||||
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/jobs/{job_id}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
output(data, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get job status: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.option("--limit", default=10, help="Number of blocks to show")
|
||||
@click.option('--chain-id', help='Specific chain ID to query (default: ait-devnet)')
|
||||
@click.pass_context
|
||||
def blocks(ctx, limit: int, chain_id: str):
|
||||
"""List recent blocks from specific chain"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
# Query specific chain (default to ait-devnet if not specified)
|
||||
target_chain = chain_id or 'ait-devnet'
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/api/v1/blocks",
|
||||
params={"limit": limit, "chain_id": target_chain},
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
blocks = response.json()
|
||||
output({
|
||||
"blocks": blocks,
|
||||
"chain_id": target_chain,
|
||||
"limit": limit,
|
||||
"query_type": "single_chain"
|
||||
}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get blocks from chain {target_chain}: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.argument("job_id")
|
||||
@click.pass_context
|
||||
def cancel(ctx, job_id: str):
|
||||
"""Cancel a job"""
|
||||
# Check if we're in test mode
|
||||
if ctx.parent and ctx.parent.parent and ctx.parent.parent.params.get('test_mode', False):
|
||||
output({
|
||||
"job_id": job_id,
|
||||
"status": "cancelled",
|
||||
"cancelled_at": "2026-03-07T10:00:00Z",
|
||||
"message": "Job cancelled successfully"
|
||||
}, ctx.obj.get("output_format", "table"))
|
||||
return
|
||||
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
f"{config.coordinator_url}/v1/jobs/{job_id}/cancel",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
success(f"Job {job_id} cancelled")
|
||||
else:
|
||||
error(f"Failed to cancel job: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.argument("job_id")
|
||||
@click.option("--wait", is_flag=True, help="Wait for job to complete before showing result")
|
||||
@click.option("--timeout", type=int, default=120, help="Max wait time in seconds")
|
||||
@click.pass_context
|
||||
def result(ctx, job_id: str, wait: bool, timeout: int):
|
||||
"""Retrieve the result of a completed job"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
start = time.time()
|
||||
while True:
|
||||
try:
|
||||
with httpx.Client() as http:
|
||||
# Try the dedicated result endpoint first
|
||||
response = http.get(
|
||||
f"{config.coordinator_url}/v1/jobs/{job_id}/result",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result_data = response.json()
|
||||
success(f"Job {job_id} completed")
|
||||
output(result_data, ctx.obj['output_format'])
|
||||
return
|
||||
elif response.status_code == 425:
|
||||
# Job not ready yet
|
||||
if wait and (time.time() - start) < timeout:
|
||||
time.sleep(3)
|
||||
continue
|
||||
# Check status for more info
|
||||
status_resp = http.get(
|
||||
f"{config.coordinator_url}/v1/jobs/{job_id}",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
if status_resp.status_code == 200:
|
||||
job_data = status_resp.json()
|
||||
output({"job_id": job_id, "state": job_data.get("state", "UNKNOWN"), "message": "Job not yet completed"}, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Job not ready (425)")
|
||||
return
|
||||
elif response.status_code == 404:
|
||||
error(f"Job {job_id} not found")
|
||||
return
|
||||
else:
|
||||
error(f"Failed to get result: {response.status_code}")
|
||||
return
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
return
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.option("--limit", default=10, help="Number of receipts to show")
|
||||
@click.option("--job-id", help="Filter by job ID")
|
||||
@click.option("--status", help="Filter by status")
|
||||
@click.pass_context
|
||||
def receipts(ctx, limit: int, job_id: Optional[str], status: Optional[str]):
|
||||
"""List job receipts"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
params = {"limit": limit}
|
||||
if job_id:
|
||||
params["job_id"] = job_id
|
||||
if status:
|
||||
params["status"] = status
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/explorer/receipts",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
receipts = response.json()
|
||||
output(receipts, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get receipts: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command()
|
||||
@click.option("--limit", default=10, help="Number of jobs to show")
|
||||
@click.option("--status", help="Filter by status (pending, running, completed, failed)")
|
||||
@click.option("--type", help="Filter by job type")
|
||||
@click.option("--from-time", help="Filter jobs from this timestamp (ISO format)")
|
||||
@click.option("--to-time", help="Filter jobs until this timestamp (ISO format)")
|
||||
@click.pass_context
|
||||
def history(ctx, limit: int, status: Optional[str], type: Optional[str],
|
||||
from_time: Optional[str], to_time: Optional[str]):
|
||||
"""Show job history with filtering options"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
params = {"limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if type:
|
||||
params["type"] = type
|
||||
if from_time:
|
||||
params["from_time"] = from_time
|
||||
if to_time:
|
||||
params["to_time"] = to_time
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
f"{config.coordinator_url}/v1/jobs",
|
||||
params=params,
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
jobs = response.json()
|
||||
output(jobs, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Failed to get job history: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command(name="batch-submit")
|
||||
@click.argument("file_path", type=click.Path(exists=True))
|
||||
@click.option("--format", "file_format", type=click.Choice(["json", "csv"]), default=None, help="File format (auto-detected if not specified)")
|
||||
@click.option("--retries", default=0, help="Retry attempts per job")
|
||||
@click.option("--delay", default=0.5, help="Delay between submissions (seconds)")
|
||||
@click.pass_context
|
||||
def batch_submit(ctx, file_path: str, file_format: Optional[str], retries: int, delay: float):
|
||||
"""Submit multiple jobs from a CSV or JSON file"""
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from utils import progress_bar
|
||||
|
||||
config = ctx.obj['config']
|
||||
path = Path(file_path)
|
||||
|
||||
if not file_format:
|
||||
file_format = "csv" if path.suffix.lower() == ".csv" else "json"
|
||||
|
||||
jobs_data = []
|
||||
if file_format == "json":
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
jobs_data = data if isinstance(data, list) else [data]
|
||||
else:
|
||||
with open(path) as f:
|
||||
reader = csv.DictReader(f)
|
||||
jobs_data = list(reader)
|
||||
|
||||
if not jobs_data:
|
||||
error("No jobs found in file")
|
||||
return
|
||||
|
||||
results = {"submitted": 0, "failed": 0, "job_ids": []}
|
||||
|
||||
with progress_bar("Submitting jobs...", total=len(jobs_data)) as (progress, task):
|
||||
for i, job in enumerate(jobs_data):
|
||||
try:
|
||||
task_data = {"type": job.get("type", "inference")}
|
||||
if "prompt" in job:
|
||||
task_data["prompt"] = job["prompt"]
|
||||
if "model" in job:
|
||||
task_data["model"] = job["model"]
|
||||
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.post(
|
||||
f"{config.coordinator_url}/v1/jobs",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={"payload": task_data, "ttl_seconds": int(job.get("ttl", 900))}
|
||||
)
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
results["submitted"] += 1
|
||||
results["job_ids"].append(result.get("job_id"))
|
||||
else:
|
||||
results["failed"] += 1
|
||||
except Exception:
|
||||
results["failed"] += 1
|
||||
|
||||
progress.update(task, advance=1)
|
||||
if delay and i < len(jobs_data) - 1:
|
||||
time.sleep(delay)
|
||||
|
||||
output(results, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@client.command(name="template")
|
||||
@click.argument("action", type=click.Choice(["save", "list", "run", "delete"]))
|
||||
@click.option("--name", help="Template name")
|
||||
@click.option("--type", "job_type", help="Job type")
|
||||
@click.option("--prompt", help="Prompt text")
|
||||
@click.option("--model", help="Model name")
|
||||
@click.option("--ttl", type=int, default=900, help="TTL in seconds")
|
||||
@click.pass_context
|
||||
def template(ctx, action: str, name: Optional[str], job_type: Optional[str],
|
||||
prompt: Optional[str], model: Optional[str], ttl: int):
|
||||
"""Manage job templates for repeated tasks"""
|
||||
from pathlib import Path
|
||||
|
||||
template_dir = Path.home() / ".aitbc" / "templates"
|
||||
template_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if action == "save":
|
||||
if not name:
|
||||
error("Template name required (--name)")
|
||||
return
|
||||
template_data = {"type": job_type or "inference", "ttl": ttl}
|
||||
if prompt:
|
||||
template_data["prompt"] = prompt
|
||||
if model:
|
||||
template_data["model"] = model
|
||||
with open(template_dir / f"{name}.json", "w") as f:
|
||||
json.dump(template_data, f, indent=2)
|
||||
output({"status": "saved", "name": name, "template": template_data}, ctx.obj['output_format'])
|
||||
|
||||
elif action == "list":
|
||||
templates = []
|
||||
for tf in template_dir.glob("*.json"):
|
||||
with open(tf) as f:
|
||||
data = json.load(f)
|
||||
templates.append({"name": tf.stem, **data})
|
||||
output(templates if templates else {"message": "No templates found"}, ctx.obj['output_format'])
|
||||
|
||||
elif action == "run":
|
||||
if not name:
|
||||
error("Template name required (--name)")
|
||||
return
|
||||
tf = template_dir / f"{name}.json"
|
||||
if not tf.exists():
|
||||
error(f"Template '{name}' not found")
|
||||
return
|
||||
with open(tf) as f:
|
||||
tmpl = json.load(f)
|
||||
if prompt:
|
||||
tmpl["prompt"] = prompt
|
||||
if model:
|
||||
tmpl["model"] = model
|
||||
ctx.invoke(submit, job_type=tmpl.get("type", "inference"),
|
||||
prompt=tmpl.get("prompt"), model=tmpl.get("model"),
|
||||
ttl=tmpl.get("ttl", 900), file=None, retries=0, retry_delay=1.0)
|
||||
|
||||
elif action == "delete":
|
||||
if not name:
|
||||
error("Template name required (--name)")
|
||||
return
|
||||
tf = template_dir / f"{name}.json"
|
||||
if not tf.exists():
|
||||
error(f"Template '{name}' not found")
|
||||
return
|
||||
tf.unlink()
|
||||
output({"status": "deleted", "name": name}, ctx.obj['output_format'])
|
||||
|
||||
|
||||
@client.command(name="pay")
|
||||
@click.argument("job_id")
|
||||
@click.argument("amount", type=float)
|
||||
@click.option("--currency", default="AITBC", help="Payment currency")
|
||||
@click.option("--method", "payment_method", default="aitbc_token", type=click.Choice(["aitbc_token", "bitcoin"]), help="Payment method")
|
||||
@click.option("--escrow-timeout", type=int, default=3600, help="Escrow timeout in seconds")
|
||||
@click.pass_context
|
||||
def pay(ctx, job_id: str, amount: float, currency: str, payment_method: str, escrow_timeout: int):
|
||||
"""Create a payment for a job"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.post(
|
||||
f"{config.coordinator_url}/v1/payments",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={
|
||||
"job_id": job_id,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"payment_method": payment_method,
|
||||
"escrow_timeout_seconds": escrow_timeout
|
||||
}
|
||||
)
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
success(f"Payment created for job {job_id}")
|
||||
output(result, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Payment failed: {response.status_code} - {response.text}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command(name="payment-status")
|
||||
@click.argument("job_id")
|
||||
@click.pass_context
|
||||
def payment_status(ctx, job_id: str):
|
||||
"""Get payment status for a job"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.get(
|
||||
f"{config.coordinator_url}/v1/jobs/{job_id}/payment",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
output(response.json(), ctx.obj['output_format'])
|
||||
elif response.status_code == 404:
|
||||
error(f"No payment found for job {job_id}")
|
||||
ctx.exit(1)
|
||||
else:
|
||||
error(f"Failed: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command(name="payment-receipt")
|
||||
@click.argument("payment_id")
|
||||
@click.pass_context
|
||||
def payment_receipt(ctx, payment_id: str):
|
||||
"""Get payment receipt with verification"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.get(
|
||||
f"{config.coordinator_url}/v1/payments/{payment_id}/receipt",
|
||||
headers={"X-Api-Key": config.api_key or ""}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
output(response.json(), ctx.obj['output_format'])
|
||||
elif response.status_code == 404:
|
||||
error(f"Payment '{payment_id}' not found")
|
||||
ctx.exit(1)
|
||||
else:
|
||||
error(f"Failed: {response.status_code}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
@client.command(name="refund")
|
||||
@click.argument("job_id")
|
||||
@click.argument("payment_id")
|
||||
@click.option("--reason", required=True, help="Reason for refund")
|
||||
@click.pass_context
|
||||
def refund(ctx, job_id: str, payment_id: str, reason: str):
|
||||
"""Request a refund for a payment"""
|
||||
config = ctx.obj['config']
|
||||
|
||||
try:
|
||||
with httpx.Client() as http_client:
|
||||
response = http_client.post(
|
||||
f"{config.coordinator_url}/v1/payments/{payment_id}/refund",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.api_key or ""
|
||||
},
|
||||
json={
|
||||
"job_id": job_id,
|
||||
"payment_id": payment_id,
|
||||
"reason": reason
|
||||
}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
success(f"Refund processed for payment {payment_id}")
|
||||
output(result, ctx.obj['output_format'])
|
||||
else:
|
||||
error(f"Refund failed: {response.status_code} - {response.text}")
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
error(f"Network error: {e}")
|
||||
ctx.exit(1)
|
||||
Reference in New Issue
Block a user