From a2a1827e32be6a2aaa6718b7ebfca707f6e45711 Mon Sep 17 00:00:00 2001 From: Arnas Date: Mon, 17 Aug 2026 12:33:49 +0300 Subject: [PATCH] fix: align VIN validation with API contract --- CHANGELOG.md | 8 ++++++++ README.md | 11 ++++++----- pyproject.toml | 2 +- src/vehicles_dev/__init__.py | 2 +- src/vehicles_dev/client.py | 14 +++++++++----- tests/test_operations.py | 33 +++++++++++++++++++++++++++------ uv.lock | 2 +- 7 files changed, 53 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eb6c7c..b4401e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [0.1.1] - 2026-08-17 + +### Fixed + +- Aligned immediate-response VIN validation with the API's exact 17-character VIN-safe contract, + including lowercase canonicalization and rejection of I, O, and Q. +- Corrected the quickstart decode example to read the wire-format `year` field. + ## [0.1.0] - 2026-08-17 ### Added diff --git a/README.md b/README.md index 6189dad..30fc1a9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Python 3.11 and newer, with synchronous and asynchronous clients powered by `htt The PyPI release is not enabled yet. Install the tagged starter directly from GitHub: ```sh -pip install "vehicles-dev @ git+https://github.com/vehicles-dev/python-sdk.git@v0.1.0" +pip install "vehicles-dev @ git+https://github.com/vehicles-dev/python-sdk.git@v0.1.1" ``` Once the PyPI package is published, the install command will be `pip install vehicles-dev`. @@ -28,7 +28,7 @@ from vehicles_dev import Vehicles with Vehicles(os.environ["VEHICLES_API_KEY"]) as vehicles: decoded = vehicles.decode_vin("1HGCM82633A004352") vehicle = decoded["vehicle"] - print({field: vehicle.get(field) for field in ("make", "model", "modelYear")}) + print({field: vehicle.get(field) for field in ("make", "model", "year")}) value = vehicles.get_market_value( year=2003, @@ -92,9 +92,10 @@ Arguments and options use idiomatic `snake_case` and map to the API wire names. | `history_reports.get_status(id)` | `GET /v1/vehicles/history-reports/{id}` | | `history_reports.get_result(id)` | `GET /v1/vehicles/history-reports/{id}/result` | -VIN path values are trimmed, uppercased, and percent-encoded. Immediate data endpoints enforce the -gateway's 1–32 character VIN contract. Durable history-report creation uppercases its body value but -leaves the route's strict 17-character VIN validation to the server. +VIN path values are trimmed, uppercased, validated, and percent-encoded. Immediate data endpoints +require exactly 17 VIN-safe characters matching `[A-HJ-NPR-Za-hj-npr-z0-9]{17}`; the letters I, O, +and Q are not allowed. Durable history-report creation uppercases its body value but leaves the +route's strict 17-character VIN validation to the server. ### Listings diff --git a/pyproject.toml b/pyproject.toml index de6c57f..7be3e83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "vehicles-dev" -version = "0.1.0" +version = "0.1.1" description = "Official Python SDK for the Vehicles.dev API" readme = "README.md" requires-python = ">=3.11" diff --git a/src/vehicles_dev/__init__.py b/src/vehicles_dev/__init__.py index af6b8d6..5fddc26 100644 --- a/src/vehicles_dev/__init__.py +++ b/src/vehicles_dev/__init__.py @@ -21,7 +21,7 @@ VinDecodeResult, ) -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "AsyncVehicles", diff --git a/src/vehicles_dev/client.py b/src/vehicles_dev/client.py index 287b9d8..aafa279 100644 --- a/src/vehicles_dev/client.py +++ b/src/vehicles_dev/client.py @@ -39,11 +39,12 @@ DEFAULT_BASE_URL = "https://api.vehicles.dev" DEFAULT_TIMEOUT = 30.0 DEFAULT_MAX_WAIT = 300.0 -USER_AGENT = "vehicles-dev-python/0.1.0" +USER_AGENT = "vehicles-dev-python/0.1.1" _UUID_PATTERN = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.IGNORECASE, ) +_VIN_PATTERN = re.compile(r"^[A-HJ-NPR-Za-hj-npr-z0-9]{17}$") _NO_BODY = object() _QueryValue = bool | float | int | str | None @@ -105,10 +106,13 @@ def _normalize_api_key(value: object) -> str: def _normalize_vin(vin: object) -> str: if not isinstance(vin, str): raise TypeError("VIN must be a string") - normalized = vin.strip().upper() - if not 1 <= len(normalized) <= 32: - raise ValueError("VIN must contain between 1 and 32 characters") - return normalized + trimmed = vin.strip() + if _VIN_PATTERN.fullmatch(trimmed) is None: + raise ValueError( + "VIN must be exactly 17 characters using A-H, J-N, P, R-Z, and 0-9 " + "(I, O, and Q are not allowed)" + ) + return trimmed.upper() def _normalize_history_vin(vin: object) -> str: diff --git a/tests/test_operations.py b/tests/test_operations.py index 490f481..dccd595 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -12,7 +12,7 @@ def _assert_common_headers(request: httpx.Request) -> None: assert request.headers["authorization"] == f"Bearer {API_KEY}" assert request.headers["accept"] == "application/json" - assert request.headers["user-agent"] == "vehicles-dev-python/0.1.0" + assert request.headers["user-agent"] == "vehicles-dev-python/0.1.1" assert "cookie" not in request.headers assert "origin" not in request.headers @@ -223,7 +223,7 @@ def test_requires_a_nonblank_api_key(api_key: str | None) -> None: Vehicles(api_key) # type: ignore[arg-type] -def test_validates_immediate_vin_paths_but_defers_history_vin_validation() -> None: +def test_trims_and_canonicalizes_lowercase_immediate_vin() -> None: requests: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: @@ -231,10 +231,31 @@ def handler(request: httpx.Request) -> httpx.Response: return json_response({}) with Vehicles(API_KEY, transport=httpx.MockTransport(handler)) as client: - with pytest.raises(ValueError, match="between 1 and 32"): - client.decode_vin("") - with pytest.raises(ValueError, match="between 1 and 32"): - client.get_photos("x" * 33) + client.decode_vin(f" {VIN.lower()} ") + + assert requests[0].url.path == f"/v1/vehicles/vin/{VIN}" + + +@pytest.mark.parametrize( + "vin", + [ + "A" * 16, + "A" * 18, + "1HGCM826I3A004352", + "1HGCM826O3A004352", + "1HGCM826Q3A004352", + ], +) +def test_rejects_invalid_immediate_vins_but_defers_history_vin_validation(vin: str) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return json_response({}) + + with Vehicles(API_KEY, transport=httpx.MockTransport(handler)) as client: + with pytest.raises(ValueError, match="exactly 17 characters"): + client.decode_vin(vin) client.history_reports.create(" short ", idempotency_key=IDEMPOTENCY_KEY) assert json.loads(requests[0].content) == {"vin": "SHORT"} diff --git a/uv.lock b/uv.lock index 684ae55..daad12b 100644 --- a/uv.lock +++ b/uv.lock @@ -755,7 +755,7 @@ wheels = [ [[package]] name = "vehicles-dev" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "httpx" },