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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/vehicles_dev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
VinDecodeResult,
)

__version__ = "0.1.0"
__version__ = "0.1.1"

__all__ = [
"AsyncVehicles",
Expand Down
14 changes: 9 additions & 5 deletions src/vehicles_dev/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
33 changes: 27 additions & 6 deletions tests/test_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -223,18 +223,39 @@ 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:
requests.append(request)
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"}
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading