Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.8.1] - 2026-08-11

### Fixed

- `bcli post`, `bcli patch`, and `bcli action` no longer crash with a raw
`json.JSONDecodeError` traceback when `--data`/`-d` fails to parse. All
three commands had their own copy of the same unguarded `json.loads()`
call: a shell stripping the quotes out of an inline JSON literal (routine
in PowerShell — `{"a": 1}` arrives as `{a: 1}`) or a bare file path passed
without the `@` prefix both surfaced a ~25-line Python traceback instead
of an actionable error. The three copies are now one helper
(`bcli_cli._data_arg.parse_data_argument`), and every failure raises
`typer.BadParameter` instead: the message names the JSON error's
line/column/reason plus a short excerpt of what was received (never the
whole payload), and — when the input looks like a filesystem path or a
shell-mangled literal — adds a one-line hint pointing at `-d @file.json`.
A malformed `@file` now names the file in its error; a missing `@file`
still reports `File not found` unchanged.

## [0.8.0] - 2026-08-10

### Added
Expand Down
10 changes: 10 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,14 @@ Create a new record.
bcli post <endpoint> --data <json-or-@file> [--publisher ...] [--group ...] [--version ...]
```

`--data`/`-d` takes a JSON literal or `@<path>` to read the body from a
file — the `@` is required; a bare path (`--data payload.json`) is parsed
as JSON and rejected, not silently treated as a file. On PowerShell,
inline JSON often arrives with its quotes stripped by the shell before
bcli ever sees it, so prefer `--data @payload.json` over an inline literal
there. A malformed `--data` value fails with a usage error naming the
problem, not a Python traceback.

---

## patch
Expand All @@ -338,6 +346,8 @@ Update an existing record.
bcli patch <endpoint> <record-id> --data <json-or-@file> [--etag <tag>] [--publisher ...] [--group ...] [--version ...]
```

Same `--data`/`-d` rules as `post` above.

---

## delete
Expand Down
7 changes: 7 additions & 0 deletions docs/write-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ bcli post customers --data @customer.json

The `@` prefix reads JSON from a file. The response shows the created record.

The `@` is required — a bare path (`--data customer.json`, no `@`) is parsed
as inline JSON and rejected, it isn't silently treated as a file. If your
shell strips quotes from inline JSON (PowerShell does this routinely), pass
`--data @customer.json` instead of fighting the quoting. Either way, a
malformed `--data` value fails with a usage error naming the problem, not a
Python traceback.

## PATCH (Update)

Update an existing record by ID:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ build-backend = "hatchling.build"
# installed CLI binary (`bcli`) are unaffected — only `pip install` /
# `uv tool install` use this name.
name = "bc-cli"
version = "0.8.0"
version = "0.8.1"
description = "Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs"
readme = "README.md"
license = "Apache-2.0"
Expand Down
127 changes: 127 additions & 0 deletions src/bcli_cli/_data_arg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Shared ``--data``/``-d`` parsing for ``post``, ``patch``, and ``action``.

All three verbs accept a request body as either a literal JSON string or
an ``@filename`` reference. Both had bare ``json.loads()`` calls with
nothing catching a decode failure, so a mangled inline literal (a shell
stripping quotes is the common case) or a bare file path passed without
the ``@`` prefix surfaced as a raw ``json.JSONDecodeError`` traceback
instead of a usage error naming the fix.
"""

from __future__ import annotations

import json
import re
from pathlib import Path

import typer

_EXCERPT_LIMIT = 80

_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
_UNQUOTED_KEY_RE = re.compile(r'[{,]\s*[A-Za-z_][\w-]*\s*:')


def _excerpt(value: str, limit: int = _EXCERPT_LIMIT) -> str:
"""Truncate ``value`` for safe inclusion in an error message."""
value = value.strip()
if len(value) <= limit:
return value
return value[:limit] + "…"


def _looks_like_path(data: str) -> bool:
"""True if ``data`` looks like a filesystem path rather than JSON.

Only called after ``json.loads`` has already failed, so this doesn't
need to be precise — just catch the shapes that mean "this needed an
``@`` prefix": a real file on disk (the strongest signal), a bare
Windows drive path, or a path with a separator ending in ``.json``.
"""
if data.startswith("{") or data.startswith("["):
return False
# A malformed payload is arbitrary text, not a path: on Linux anything
# over 255 bytes raises ENAMETOOLONG here, and an embedded NUL raises
# ValueError. Letting either escape would put back exactly the raw
# traceback this module exists to prevent — for the *long* payloads that
# are the most likely to be malformed in the first place.
try:
if Path(data).is_file():
return True
except (OSError, ValueError):
return False
if _WINDOWS_DRIVE_RE.match(data):
return True
if ("/" in data or "\\" in data) and data.lower().endswith(".json"):
return True
return False


def _looks_shell_mangled(data: str) -> bool:
"""True if ``data`` looks like a shell stripped the quotes out of an
inline JSON literal — e.g. PowerShell turning
``{"bladeType": "HPT BLADE"}`` into ``{bladeType: HPT BLADE}``.
"""
if not data.startswith("{"):
return False
if '"' not in data:
return True
return bool(_UNQUOTED_KEY_RE.search(data))


def _mangling_hint(data: str) -> str | None:
if _looks_like_path(data):
return (
f"'{data}' looks like a file path — pass '-d @{data}' "
"to load it from a file."
)
if _looks_shell_mangled(data):
return (
"Some shells (e.g. PowerShell) strip quotes from inline JSON — "
"pass the payload as a file instead: -d @payload.json."
)
return None


def parse_data_argument(data: str) -> dict:
"""Parse a ``--data``/``-d`` argument: a JSON literal or ``@filename``.

Raises ``typer.BadParameter`` — never a raw ``json.JSONDecodeError`` —
with a message that names the fix: a missing ``@`` prefix, likely
shell quote-stripping, or (for a malformed file) the file's path.
"""
if data.startswith("@"):
path = Path(data[1:])
# Same guard as _looks_like_path: an over-long or NUL-bearing name
# raises here rather than returning False, and an unreadable file
# raises on read. Report them as bad input, not as a traceback.
try:
is_file = path.is_file()
except (OSError, ValueError):
is_file = False
if not is_file:
raise typer.BadParameter(f"File not found: {path}")
try:
return json.loads(path.read_text(encoding="utf-8"))
except OSError as e:
raise typer.BadParameter(f"Could not read {path}: {e.strerror or e}") from e
except json.JSONDecodeError as e:
raise typer.BadParameter(
f"{path} is not valid JSON: {e.msg} "
f"(line {e.lineno}, column {e.colno})."
) from e

try:
return json.loads(data)
except json.JSONDecodeError as e:
message = (
f"--data is not valid JSON: {e.msg} "
f"(line {e.lineno}, column {e.colno}). Received: {_excerpt(data)!r}"
)
hint = _mangling_hint(data)
if hint:
message += "\n " + hint
raise typer.BadParameter(message) from e


__all__ = ["parse_data_argument"]
14 changes: 2 additions & 12 deletions src/bcli_cli/commands/action_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@
from __future__ import annotations

import asyncio
import json
from pathlib import Path
from typing import Optional

import typer
from rich.console import Console

from bcli.errors import BCLIError
from bcli_cli._data_arg import parse_data_argument
from bcli_cli._envelope_wrap import capture, validate_flags
from bcli_cli._out_path import atomic_write_bytes, prepare_out_path
from bcli_cli._state import state
Expand Down Expand Up @@ -142,7 +142,7 @@ def action_command(
"Pass one or the other (or neither — empty body is the default).",
)

body: dict = {} if data is None else _parse_data(data)
body: dict = {} if data is None else parse_data_argument(data)

ns = namespace or DEFAULT_NAMESPACE
# Compose the synthetic bound-action string. The registry validator
Expand Down Expand Up @@ -291,13 +291,3 @@ def _write_decoded_payload(result: dict | str, dest: Path, *, overwrite: bool) -
raw = _decode_base64_payload(result)
atomic_write_bytes(dest, raw, overwrite=overwrite)
return len(raw)


def _parse_data(data: str) -> dict:
"""Parse --data argument: JSON string or @filename."""
if data.startswith("@"):
path = Path(data[1:])
if not path.is_file():
raise typer.BadParameter(f"File not found: {path}")
return json.loads(path.read_text(encoding="utf-8"))
return json.loads(data)
13 changes: 2 additions & 11 deletions src/bcli_cli/commands/patch_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
from __future__ import annotations

import asyncio
import json
from pathlib import Path
from typing import Optional

import typer
from rich.console import Console

from bcli_cli._data_arg import parse_data_argument
from bcli_cli._envelope_wrap import capture, validate_flags
from bcli_cli._state import state
from bcli_cli.output import format_output, print_context_banner
Expand Down Expand Up @@ -53,7 +53,7 @@ def patch_command(

print_context_banner()

body = _parse_data(data)
body = parse_data_argument(data)

with capture(
method="PATCH",
Expand Down Expand Up @@ -121,12 +121,3 @@ async def _audited_patch(endpoint, record_id, body, **kwargs):
async def _execute_patch(endpoint, record_id, body, **kwargs):
async with state.make_async_client() as client:
return await client.patch(endpoint, record_id, body, **kwargs)


def _parse_data(data: str) -> dict:
if data.startswith("@"):
path = Path(data[1:])
if not path.is_file():
raise typer.BadParameter(f"File not found: {path}")
return json.loads(path.read_text(encoding="utf-8"))
return json.loads(data)
14 changes: 2 additions & 12 deletions src/bcli_cli/commands/post_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
from __future__ import annotations

import asyncio
import json
from pathlib import Path
from typing import Optional

import typer
from rich.console import Console

from bcli_cli._data_arg import parse_data_argument
from bcli_cli._envelope_wrap import capture, validate_flags
from bcli_cli._state import state
from bcli_cli.output import format_output, print_context_banner
Expand Down Expand Up @@ -51,7 +51,7 @@ def post_command(

print_context_banner()

body = _parse_data(data)
body = parse_data_argument(data)

with capture(
method="POST",
Expand Down Expand Up @@ -119,13 +119,3 @@ async def _execute_post(endpoint, body, **kwargs):
async with state.make_async_client() as client:
# ``client.post`` accepts ``idempotency_key``; passthrough.
return await client.post(endpoint, body, **kwargs)


def _parse_data(data: str) -> dict:
"""Parse --data argument: JSON string or @filename."""
if data.startswith("@"):
path = Path(data[1:])
if not path.is_file():
raise typer.BadParameter(f"File not found: {path}")
return json.loads(path.read_text(encoding="utf-8"))
return json.loads(data)
31 changes: 31 additions & 0 deletions tests/test_cli/test_action_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,37 @@ def test_data_and_no_data_mutually_exclusive(self, cli_state, fake_client):
with pytest.raises(typer.BadParameter):
_run(data='{"x": 1}', no_data=True)

def test_malformed_inline_json_raises_bad_parameter_not_traceback(
self, cli_state, fake_client,
):
"""Regression: a mangled inline literal used to surface a raw
json.JSONDecodeError. It must come back as a usage error, and
the call must never reach the network."""
with pytest.raises(typer.BadParameter):
_run(data="{not valid json")
fake_client.post.assert_not_called()

def test_bare_file_path_without_at_prefix_hints_the_fix(
self, cli_state, fake_client, tmp_path: Path,
):
f = tmp_path / "payload.json"
f.write_text('{"x": 1}', encoding="utf-8")
with pytest.raises(typer.BadParameter) as exc_info:
_run(data=str(f))
assert "looks like a file path" in str(exc_info.value)
assert f"-d @{f}" in str(exc_info.value)
fake_client.post.assert_not_called()

def test_malformed_at_file_names_the_file(
self, cli_state, fake_client, tmp_path: Path,
):
f = tmp_path / "bad.json"
f.write_text("{not valid json", encoding="utf-8")
with pytest.raises(typer.BadParameter) as exc_info:
_run(data=f"@{f}")
assert str(f) in str(exc_info.value)
fake_client.post.assert_not_called()


class TestProfileOverrides:
def test_publisher_group_version_forwarded(self, cli_state, fake_client):
Expand Down
Loading
Loading