diff --git a/pyproject.toml b/pyproject.toml index 0601bc8..65d79c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,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.1" +version = "0.8.2" description = "Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs" readme = "README.md" license = "Apache-2.0" diff --git a/src/bcli_cli/_data_arg.py b/src/bcli_cli/_data_arg.py index 1e6593b..7725a8a 100644 --- a/src/bcli_cli/_data_arg.py +++ b/src/bcli_cli/_data_arg.py @@ -102,7 +102,11 @@ def parse_data_argument(data: str) -> dict: if not is_file: raise typer.BadParameter(f"File not found: {path}") try: - return json.loads(path.read_text(encoding="utf-8")) + # utf-8-sig, not utf-8: Windows PowerShell 5.1 writes a UTF-8 BOM for + # `Set-Content -Encoding utf8`, and json.loads rejects the BOM + # outright. utf-8-sig strips one if present and is identical to + # utf-8 when it is not, so this only ever accepts more. + return json.loads(path.read_text(encoding="utf-8-sig")) except OSError as e: raise typer.BadParameter(f"Could not read {path}: {e.strerror or e}") from e except json.JSONDecodeError as e: diff --git a/tests/test_cli/test_data_arg.py b/tests/test_cli/test_data_arg.py index b9bfd3e..fce4c66 100644 --- a/tests/test_cli/test_data_arg.py +++ b/tests/test_cli/test_data_arg.py @@ -194,3 +194,35 @@ def test_long_or_nul_payload_still_raises_bad_parameter(self, payload): def test_long_at_path_reports_not_found_not_oserror(self): with pytest.raises(typer.BadParameter, match="File not found"): parse_data_argument("@" + "z" * 300) + + +class TestBomTolerantAtFile: + """A UTF-8 BOM in an @file must not be a hard failure. + + Windows PowerShell 5.1 — the runtime install-real.ps1 targets — writes a + BOM for `Set-Content -Encoding utf8`, and `utf8NoBOM` only exists in + PowerShell 6+. Reading as plain utf-8 made json.loads reject the payload + with "Unexpected UTF-8 BOM" before any request went out, which broke the + documented Windows workflow for -d. + """ + + def test_bom_prefixed_file_parses(self, tmp_path): + p = tmp_path / "payload.json" + p.write_bytes(b'\xef\xbb\xbf{"bladeType": "FAN BLADE"}') + assert parse_data_argument(f"@{p}") == {"bladeType": "FAN BLADE"} + + def test_plain_utf8_file_still_parses(self, tmp_path): + p = tmp_path / "payload.json" + p.write_bytes(b'{"bladeType": "HPT BLADE"}') + assert parse_data_argument(f"@{p}") == {"bladeType": "HPT BLADE"} + + def test_non_ascii_without_bom_still_parses(self, tmp_path): + p = tmp_path / "payload.json" + p.write_text('{"note": "Grüße"}', encoding="utf-8") + assert parse_data_argument(f"@{p}") == {"note": "Grüße"} + + def test_malformed_bom_file_still_reports_cleanly(self, tmp_path): + p = tmp_path / "payload.json" + p.write_bytes(b"\xef\xbb\xbf{not valid") + with pytest.raises(typer.BadParameter, match="is not valid JSON"): + parse_data_argument(f"@{p}") diff --git a/uv.lock b/uv.lock index 82086d7..9929b4b 100644 --- a/uv.lock +++ b/uv.lock @@ -321,7 +321,7 @@ wheels = [ [[package]] name = "bc-cli" -version = "0.8.1" +version = "0.8.2" source = { editable = "." } dependencies = [ { name = "httpx" },