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
19 changes: 19 additions & 0 deletions src/fulfil_cli/cli/commands/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import sys
from pathlib import Path
from typing import Any, NoReturn

import click
Expand Down Expand Up @@ -52,3 +53,21 @@ def parse_json_arg(value: str, arg_name: str) -> Any:
except json.JSONDecodeError as exc:
console.print(f"[red]Invalid JSON for {arg_name}: {exc}[/red]")
raise typer.Exit(code=EXIT_VALIDATION) from None


def resolve_value_or_file(value: str, arg_name: str) -> str:
"""Return *value*, or the contents of the referenced file when prefixed with '@'.

Follows the curl/gh convention: '@path/to/file.json' reads from the file.
Use '\\@literal' to pass a value that actually starts with a literal '@'.
"""
if value.startswith("\\@"):
return value[1:]
if value.startswith("@"):
path = Path(value[1:])
try:
return path.read_text()
except OSError as exc:
console.print(f"[red]Cannot read file for {arg_name}: {exc}[/red]")
raise typer.Exit(code=EXIT_VALIDATION) from None
return value
9 changes: 6 additions & 3 deletions src/fulfil_cli/cli/commands/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import typer
from rich.console import Console

from fulfil_cli.cli.commands.common import handle_error, parse_json_arg
from fulfil_cli.cli.commands.common import handle_error, parse_json_arg, resolve_value_or_file
from fulfil_cli.cli.state import AppContext, format_option
from fulfil_cli.client.errors import EXIT_NOT_FOUND, EXIT_OK, EXIT_USAGE, FulfilError
from fulfil_cli.output.formatter import output, output_model_describe
Expand Down Expand Up @@ -313,7 +313,10 @@ def count_cmd(ctx: click.Context, where: str | None, output_format: str | None)
@click.option(
"--data",
default=None,
help=("Extra method arguments as a JSON object. Example: '{\"warehouse\": 1}'"),
help=(
"Extra method arguments as a JSON object. Example: '{\"warehouse\": 1}'. "
"Prefix with '@' to read from a file: --data @payload.json"
),
)
@format_option
@click.pass_context
Expand All @@ -339,7 +342,7 @@ def call_cmd(
if ids:
params["ids"] = _parse_ids(ids)
if data:
extra = parse_json_arg(data, "--data")
extra = parse_json_arg(resolve_value_or_file(data, "--data"), "--data")
if isinstance(extra, dict):
params.update(extra)

Expand Down
20 changes: 20 additions & 0 deletions tests/test_cli/test_model_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,26 @@ def test_with_ids_and_data(self, httpx_mock, cli_env, jsonrpc_success):
assert body["params"]["ids"] == [42]
assert body["params"]["force"] is True

def test_data_from_file(self, httpx_mock, cli_env, jsonrpc_success, tmp_path):
httpx_mock.add_response(json=jsonrpc_success({"ok": True}))

payload = tmp_path / "payload.json"
payload.write_text('{"warehouse": 7, "force": true}')

result = runner.invoke(app, ["sale_order", "call", "process", "--data", f"@{payload}"])
assert result.exit_code == 0, result.stdout + result.stderr

body = json.loads(httpx_mock.get_request().content)
assert body["params"]["warehouse"] == 7
assert body["params"]["force"] is True

def test_data_file_missing(self, cli_env):
result = runner.invoke(
app, ["sale_order", "call", "process", "--data", "@/no/such/file.json"]
)
assert result.exit_code == 7
assert "Cannot read file" in (result.stdout + result.stderr)


class TestDescribeCommand:
def test_full_describe(self, httpx_mock, cli_env, jsonrpc_success):
Expand Down
Loading