Metadata-Version: 2.4
Name: zyfy
Version: 0.1.1
Summary: Official Python client library for the Zyfy UK data enrichment API
License: MIT
Project-URL: Homepage, https://zyfy.uk
Project-URL: Repository, https://github.com/zyfy-uk/zyfy-python
Project-URL: Bug Tracker, https://github.com/zyfy-uk/zyfy-python/issues
Keywords: uk,united-kingdom,vehicle,postcode,dvla,dvsa,mot,vehicle-history,vehicle-check,enrichment,data,intelligence,lookup,address,flood-risk,crime-data,property-data,api-client
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Dist: typing_extensions>=4.0; python_version < "3.10"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Dynamic: license-file

# zyfy

[![PyPI version](https://img.shields.io/pypi/v/zyfy.svg)](https://pypi.org/project/zyfy/)
[![Python versions](https://img.shields.io/pypi/pyversions/zyfy.svg)](https://pypi.org/project/zyfy/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Official Python client library for the [Zyfy](https://zyfy.uk) UK data enrichment API.

**Two products in one package:**
- **Vehicle Intelligence** — DVLA + DVSA MOT history, ULEZ, tax status, odometer trend, buy recommendation
- **Postcode Intelligence** — broadband, flood risk, crime, property prices, deprivation, air quality, MP data, and more

Get your API key at [zyfy.uk/signup](https://zyfy.uk/signup). Full API docs at [zyfy.uk/docs](https://zyfy.uk/docs).

## Installation

```bash
pip install zyfy
```

Requires Python 3.9 or later.

## Quickstart

### Synchronous

```python
from zyfy import Zyfy

client = Zyfy(api_key="ea_live_...")

# Vehicle lookup
vehicle = client.vehicle.lookup("AB12CDE")
print(vehicle.summary.buy_recommendation)  # "good" | "consider" | "caution" | "avoid"
print(vehicle.signals.mot_expiry_date)
print(vehicle.quota.remaining)             # requests left this month

# Postcode lookup
postcode = client.postcode.lookup("SW1A 2AA")
print(postcode.summary.liveability_level)  # "low" | "medium" | "high"
print(postcode.signals.crime.rate_band)
print(postcode.quota.remaining)
```

### Asynchronous

```python
import asyncio
from zyfy import AsyncZyfy

async def main() -> None:
    async with AsyncZyfy(api_key="ea_live_...") as client:
        vehicle = await client.vehicle.lookup("AB12CDE")
        print(vehicle.summary.buy_recommendation)

        postcode = await client.postcode.lookup("SW1A 2AA")
        print(postcode.signals.crime.rate_band)

asyncio.run(main())
```

The API key can also be set via the `ZYFY_API_KEY` environment variable — if it is, you can construct the client with no arguments:

```python
client = Zyfy()               # reads ZYFY_API_KEY
client = AsyncZyfy()          # same
```

Both clients support context managers for automatic cleanup:

```python
with Zyfy() as client:
    result = client.vehicle.lookup("AB12CDE")

async with AsyncZyfy() as client:
    result = await client.vehicle.lookup("AB12CDE")
```

## Configuration reference

All options are keyword-only. All are optional if `ZYFY_API_KEY` is set.

| Option | Type | Default | Description |
|---|---|---|---|
| `api_key` | `str` | `ZYFY_API_KEY` env var | Your Zyfy API key. |
| `max_enrichment_retries` | `int` | `10` | Auto-retries when `enrichment_pending=True`. Each retry waits the `Retry-After` seconds from the response. Set to `0` to disable. |
| `timeout_ms` | `int` | `10000` | Per-request timeout in milliseconds. Surfaces as `NetworkError` on expiry. |
| `base_url` | `str` | `https://zyfy.uk/v1` | Override for local development or testing. |
| `debug` | `bool` | `False` | Logs requests and responses via the `zyfy` logger at `DEBUG` level. The API key is always redacted. |

## Full reference

### `client.vehicle.lookup(registration, *, max_enrichment_retries=None)`

Look up a single UK vehicle by registration mark. Returns DVLA details, DVSA MOT history, emissions, and computed intelligence.

Full signal reference: [zyfy.uk/docs/vehicle.html](https://zyfy.uk/docs/vehicle.html)

| Parameter | Type | Description |
|---|---|---|
| `registration` | `str` | UK registration mark. Spaces and case are normalised automatically. |
| `max_enrichment_retries` | `int \| None` | Per-call override of `max_enrichment_retries`. |

Returns: `VehicleResult`

---

### `client.vehicle.bulk_lookup(registrations, *, max_enrichment_retries=None)`

Synchronous bulk vehicle lookup. Runs sequentially on the server. Up to your tier's bulk cap per call.

Returns: `BulkVehicleResult`

Use `is_vehicle_bulk_error(item)` to distinguish error items from successful results:

```python
from zyfy import Zyfy, is_vehicle_bulk_error

result = client.vehicle.bulk_lookup(["AB12CDE", "NOTREAL"])
for item in result.results:
    if is_vehicle_bulk_error(item):
        print(item.registration, item.error)  # "not_found" | "invalid_format"
    else:
        print(item.registration, item.summary.buy_recommendation if item.summary else None)
```

---

### `client.vehicle.submit_bulk(registrations)`

Submit an async bulk job. Returns immediately with a `job_id`. Poll with `get_job()`.

Returns: `BulkJobSubmitted`

---

### `client.vehicle.get_job(job_id)`

Poll the status of an async bulk job. Check `status == "complete"` before reading `results`.

Returns: `BulkJobStatus[VehicleResult | VehicleBulkItemError]`

`status` values: `"queued"` | `"processing"` | `"complete"` | `"expired"`

---

### `client.vehicle.delete_job(job_id)`

Delete a bulk job and its results. Jobs expire automatically after 24 hours.

Returns: `DeletedJob`

---

### `client.postcode.lookup(postcode, *, max_enrichment_retries=None)`

Look up a single UK postcode. Returns geographic classification, broadband, flood risk, property prices, crime, deprivation, air quality, housing, political, and demographic signals.

Northern Ireland postcodes (BT prefix) are not supported. Raises `ValidationError` with `code="unsupported_region"`.

Full signal reference: [zyfy.uk/docs/postcode.html](https://zyfy.uk/docs/postcode.html)

Returns: `PostcodeResult`

---

### `client.postcode.nearest(lat, lon, *, radius=None, max_enrichment_retries=None)`

Find the nearest postcode to a set of WGS84 coordinates. Coordinates must be within the UK bounding box. The response includes a `query_point_distance_metres` field.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `lat` | `float` | — | WGS84 latitude. |
| `lon` | `float` | — | WGS84 longitude. |
| `radius` | `int \| None` | `1000` | Search radius in metres. Raises `NotFoundError` if no postcode centroid is found within the radius. |
| `max_enrichment_retries` | `int \| None` | — | Per-call override of `max_enrichment_retries`. |

Returns: `PostcodeResult`

---

### `client.postcode.bulk_lookup(postcodes, *, max_enrichment_retries=None)`

Synchronous bulk postcode lookup.

Returns: `BulkPostcodeResult`

Use `is_postcode_bulk_error(item)` to distinguish errors:

```python
from zyfy import Zyfy, is_postcode_bulk_error

result = client.postcode.bulk_lookup(["SW1A 2AA", "BT1 1AA"])
for item in result.results:
    if is_postcode_bulk_error(item):
        print(item.postcode, item.error)  # "not_found" | "unsupported_region"
    else:
        print(item.postcode, item.summary.liveability_level if item.summary else None)
```

---

### `client.postcode.submit_bulk(postcodes)` / `get_job(job_id)` / `delete_job(job_id)`

Same async bulk pattern as vehicle. `submit_bulk` returns `BulkJobSubmitted`, `get_job` returns `BulkJobStatus[PostcodeResult | PostcodeBulkItemError]`, `delete_job` returns `DeletedJob`.

---

## Error handling

All errors extend `ZyfyError`. Import the specific classes for `isinstance` checks.

```python
import time
from zyfy import (
    Zyfy,
    QuotaExhaustedError,
    RateLimitError,
    ValidationError,
    NotFoundError,
    AuthenticationError,
    ApiError,
    NetworkError,
)

try:
    result = client.vehicle.lookup("AB12CDE")
except QuotaExhaustedError as exc:
    # Monthly quota exhausted. exc.resets is an ISO 8601 UTC string indicating
    # when the quota rolls over — None if no monthly reset applies.
    if exc.resets:
        hours_until_reset = round(exc.retry_after / 3600)
        print(f"Quota exhausted. Resets at {exc.resets} (~{hours_until_reset}h)")
    else:
        print("Quota exhausted. Contact support to increase your limit.")
except RateLimitError as exc:
    # Per-minute rate limit exceeded. Back off by exc.retry_after seconds and retry.
    print(f"Rate limited. Retrying in {exc.retry_after}s")
    time.sleep(exc.retry_after)
    # retry the call...
except ValidationError as exc:
    # Input rejected by the server. exc.code identifies the specific problem.
    # Common codes: "unsupported_region" (BT postcodes), "invalid_format"
    print(f"Validation error: {exc.code}")
except NotFoundError:
    print("Vehicle or postcode not found")
except AuthenticationError:
    print("Invalid or missing API key")
except ApiError as exc:
    print(f"Server error {exc.status_code}")
except NetworkError as exc:
    print(f"Connection failed: {exc.cause}")
```

| Error class | HTTP status | When |
|---|---|---|
| `AuthenticationError` | 401 | Invalid or missing API key |
| `NotFoundError` | 404 | Vehicle or postcode not found |
| `ValidationError` | 422 | Invalid input; check `exc.code` (e.g. `"unsupported_region"`, `"invalid_format"`) |
| `RateLimitError` | 429 | Per-minute rate limit exceeded; `exc.retry_after` seconds until safe to retry |
| `QuotaExhaustedError` | 429 | Monthly quota exhausted; `exc.retry_after` seconds until reset, `exc.resets` ISO 8601 datetime (None if not applicable) |
| `ApiError` | 5xx | Server error; `exc.status_code` |
| `NetworkError` | — | Connection failure or request timeout |

All error classes expose `raw_body: str` with the full response body for debugging.

## Quota

Every successful response includes a `quota` object populated from response headers:

```python
result = client.vehicle.lookup("AB12CDE")
quota = result.quota

print(quota.limit)        # monthly cap — int, or "unlimited"
print(quota.used)         # requests consumed this month
print(quota.remaining)    # requests left — int, or "unlimited"
print(quota.grace_limit)  # buffer above limit before hard block (~10%); None if not applicable
print(quota.resets)       # ISO 8601 UTC string of next reset; None for unlimited plans
```

`remaining` reaches zero at the quota limit and further requests are blocked. A small grace buffer (`grace_limit`) allows a few extra requests above the cap before the hard block kicks in.

`resets` is `None` when no monthly cap is in effect. Always check for `None` before formatting or displaying it.

To avoid hitting the limit unexpectedly in high-volume applications, read `quota.remaining` after each response and slow down before it reaches zero.

## Debug mode

Pass `debug=True` to log every request and response via the `zyfy` logger at `DEBUG` level:

```python
import logging
logging.basicConfig(level=logging.DEBUG)

client = Zyfy(api_key="...", debug=True)
```

Output looks like:

```
[zyfy] → GET https://zyfy.uk/v1/vehicle/AB12CDE (X-Api-Key: ea_live_***, attempt 1)
[zyfy] ← 200 (quota-remaining: 9958, retry-after: none)
```

The API key is always redacted — it will never appear in logs regardless of debug mode. Useful for diagnosing unexpected responses, quota consumption, or enrichment retry behaviour in development.

## Enrichment retries

Vehicle lookups may return `enrichment_pending=True` when background enrichment is still running — typically the first time a registration is seen, or when the vehicle's data is being refreshed. When pending, `signals`, `summary`, and `scores` may be `None` or incomplete. Postcode lookups are always served from a pre-loaded dataset and never set `enrichment_pending`.

**Automatic retries (default)**

The client retries automatically up to `max_enrichment_retries` times (default: 10), waiting the number of seconds in the `Retry-After` response header (typically 5 seconds) between each attempt. The final result is returned once enrichment completes or retries are exhausted — no exception is raised either way.

Override the retry limit per call:

```python
result = client.vehicle.lookup("AB12CDE", max_enrichment_retries=3)
```

**Manual retry pattern**

If you need control over retry timing — for example, in a task queue where you want to reenqueue rather than block the worker — disable auto-retries and handle `enrichment_pending` yourself:

```python
import time

client = Zyfy(api_key="...", max_enrichment_retries=0)

result = client.vehicle.lookup("AB12CDE")

if result.enrichment_pending:
    # Partial data returned — signals/summary/scores may be None.
    # The API will typically have the enriched result ready within 5 seconds.

    # Option A: wait and re-query inline
    time.sleep(5)
    result = client.vehicle.lookup("AB12CDE")

    # Option B: in a task queue, store result.registration and resubmit
    # the task after a delay rather than blocking here.

# Use whatever is available — enrichment_pending may still be True
# if enrichment is unusually slow. The data returned is always valid.
if result.enrichment_pending:
    print(f"Partial data for {result.registration} — enrichment still in progress")

recommendation = result.summary.buy_recommendation if result.summary else "pending"
print(result.registration, recommendation)
```

If auto-retries exhaust while `enrichment_pending` is still `True`, the last partial response is returned without raising. All returned fields are valid — only fields that depend on enrichment may be `None`.

## Python type reference

All response types are frozen dataclasses and exported from the package.

### `Quota`

Returned on every successful response. Populated from `X-Quota-*` response headers.

```python
@dataclass(frozen=True)
class Quota:
    limit: int | str                  # int, or "unlimited"
    used: int
    remaining: int | str              # int, or "unlimited"
    grace_limit: int | None
    resets: str | None                # ISO 8601; None for unlimited plans
```

### `VehicleResult`

```python
@dataclass(frozen=True)
class VehicleResult:
    registration: str                 # uppercase, no spaces
    make: str | None
    model: str | None
    vehicle_type: str | None          # "car" | "van" | "motorcycle" | "bus" | "hgv" | "motorhome" | "trailer" | "tractor" | "other"
    colour: str | None
    fuel_type: str | None
    engine_capacity_cc: int | None
    year_of_manufacture: int | None
    month_of_first_registration: str | None   # YYYY-MM
    vehicle_age_years: float | None
    summary: VehicleSummary | None
    signals: VehicleSignals | None
    scores: VehicleScores | None
    fleet_failure_profile: FleetFailureProfile | None
    fleet_advisory_profile: FleetAdvisoryProfile | None
    sources: VehicleSources
    schema_version: str
    enrichment_pending: bool
    data_as_of: str                   # ISO 8601
    checked_at: str                   # ISO 8601
    quota: Quota | None               # None when returned as a bulk item

@dataclass(frozen=True)
class VehicleSummary:
    buy_recommendation: str | None    # "good" | "consider" | "caution" | "avoid"
    vehicle_risk_level: str | None    # "low" | "medium" | "high"
    mot_risk_level: str | None        # "low" | "medium" | "high"
    condition_band: str | None        # "good" | "fair" | "poor" | "bad"
    maintenance_band: str | None      # "good" | "fair" | "poor" | "bad"
    mileage_anomaly_risk: str | None  # "none" | "low" | "high"
    colour_change_indicated: bool | None
    above_average_advisories: bool | None
    mot_failure_detail_available: bool

@dataclass(frozen=True)
class VehicleSignals:
    co2_emissions_g_per_km: float | None
    euro_emission_standard: str | None
    ulez_compliant: bool | None
    marked_for_export: bool
    has_outstanding_recall: bool | None
    v5c_last_issued: str | None            # YYYY-MM-DD
    tax_status: str | None
    tax_due_date: str | None               # YYYY-MM-DD
    tax_days_remaining: int | None
    ved_band: str | None
    ved_annual_cost_gbp: float | None
    mot_status: str | None
    mot_expiry_date: str | None            # YYYY-MM-DD
    mot_days_remaining: int | None
    imminent_mot: bool
    odometer_trend: str | None             # "consistent" | "increasing" | "decreasing" | "erratic"
    latest_odometer_miles: int | None
    typical_annual_mileage_miles: int | None
    odometer_vs_fleet_average: str | None  # "below_average" | "average" | "above_average"
    mot_pass_rate: float | None
    total_mot_tests: int
    total_mot_failures: int
    total_advisory_count: int
    total_failure_item_count: int
    latest_advisory_count: int
    latest_failure_item_count: int
    dangerous_defect_ever: bool
    high_failure_history: bool
    advisory_trend: str | None             # "increasing" | "stable" | "decreasing"
    advisory_momentum: str | None          # "worsening" | "stable" | "improving"
    days_since_last_failure: int | None
    failures_last_24_months: int | None
    advisories_last_3_tests: int | None
    trend_window_tests: int
    first_mot_date: str | None             # YYYY-MM-DD
    last_mot_date: str | None              # YYYY-MM-DD
    last_mot_result: str | None
    first_mot_due: str | None              # YYYY-MM-DD
    failure_clusters: list[str] | None
    repeat_failure_count: int | None
    advisory_clusters: list[str] | None
    ncap_safety_rating: NcapSafetyRating | None
    drivetrain_stress_profile: DrivetrainStressProfile | None

@dataclass(frozen=True)
class NcapSafetyRating:
    overall_stars: int | None            # 0–5
    adult_occupant: float | None         # 0–100
    child_occupant: float | None         # 0–100
    vulnerable_road_users: float | None  # 0–100
    safety_assist: float | None          # 0–100
    tested_year: int | None

@dataclass(frozen=True)
class DrivetrainStressProfile:
    likely_driving_pattern: str | None   # "short_urban" | "mixed" | "long_distance"
    dpf_risk: str | None                 # "low" | "elevated" | "high" (diesel only)

@dataclass(frozen=True)
class VehicleScores:
    mot_risk_score: float | None         # 0–1, lower is better
    condition_score: float | None        # 0–1, higher is better
    condition_percentile: float | None
    maintenance_score: float | None      # 0–1, higher is better
    maintenance_percentile: float | None
    failure_rate_ratio: float | None     # 1.0 = fleet average
    advisory_rate_ratio: float | None    # 1.0 = fleet average
    benchmark_sample_size: int | None
    avg_advisories_per_test_for_mmy: float | None
    avg_failures_per_test_for_mmy: float | None
    off_road_likelihood_score: float | None  # 0–1, higher = more likely off-road
    score_convention: str

@dataclass(frozen=True)
class FleetFailureProfile:
    mileage_band: str
    sample_size: int
    top_failures: list[FleetItem]

@dataclass(frozen=True)
class FleetAdvisoryProfile:
    mileage_band: str
    sample_size: int
    top_advisories: list[FleetItem]

@dataclass(frozen=True)
class FleetItem:
    category: str
    rate: float

@dataclass(frozen=True)
class VehicleSources:
    mot_history: str
    mutable_data: str
    safety_rating: str | None
```

### `PostcodeResult`

```python
@dataclass(frozen=True)
class PostcodeResult:
    postcode: str                     # formatted with space: "SW1A 2AA"
    outward_code: str
    inward_code: str | None           # None for outward-code-only queries
    latitude: float | None
    longitude: float | None
    eastings: int | None
    northings: int | None
    country: str | None
    region: str | None
    admin_district: str | None
    admin_county: str | None
    admin_ward: str | None
    parish: str | None
    parliamentary_constituency: str | None
    nhs_trust: str | None
    lsoa: str | None
    msoa: str | None
    rural_urban_classification: str | None
    summary: PostcodeSummary | None
    signals: PostcodeSignals | None
    scores: PostcodeScores | None
    percentiles: PostcodePercentiles | None   # Starter+ tiers only
    geography_codes: GeographyCodes
    query_point_distance_metres: float | None # nearest endpoint only
    sources: PostcodeSources
    schema_version: str
    data_as_of: str                   # ISO 8601
    checked_at: str                   # ISO 8601
    quota: Quota | None               # None when returned as a bulk item

@dataclass(frozen=True)
class PostcodeSummary:
    property_risk_level: str | None   # "low" | "medium" | "high"
    liveability_level: str | None     # "low" | "medium" | "high"
    insurance_risk_level: str | None  # "low" | "medium" | "high"
    investment_outlook: str | None    # "weak" | "fair" | "good" | "strong"
    growth_signal: str | None         # "strong_positive" | "positive" | "neutral" | "weak_negative" | "strong_negative"
    data_confidence: str              # "low" | "medium" | "high"
    area_trajectory: str | None       # "improving" | "stable" | "declining" | "mixed"
    family_suitability: str | None    # "poor" | "fair" | "good" | "excellent"
    retirement_suitability: str | None

@dataclass(frozen=True)
class PostcodeSignals:
    broadband: PostcodeBroadbandSignals | None
    flood: PostcodeFloodSignals | None
    property: PostcodePropertySignals | None
    crime: PostcodeCrimeSignals | None
    environment: PostcodeEnvironmentSignals | None
    housing: PostcodeHousingSignals | None
    political: PostcodePoliticalSignals | None
    deprivation: PostcodeDeprivationSignals | None
    demographics: PostcodeDemographicsSignals | None

@dataclass(frozen=True)
class PostcodeBroadbandSignals:
    superfast: bool | None
    ultrafast: bool | None
    gigabit: bool | None

@dataclass(frozen=True)
class PostcodeFloodSignals:
    rivers_sea: str | None            # "high" | "medium" | "low" | "very_low"
    groundwater: str | None           # "high" | "medium" | "low" | "very_low"
    rivers_sea_trend: str | None      # "worsening" | "stable" | "improving" | "insufficient_data"
    groundwater_trend: str | None

@dataclass(frozen=True)
class PostcodePropertySignals:
    average_price: float | None       # median price, last 12 months
    price_low: float | None           # 25th percentile
    price_high: float | None          # 75th percentile
    price_trend: float | None         # YoY % change, e.g. 5.2 = +5.2%
    price_trend_period_months: int
    transaction_volume: int | None    # residential transactions, last 12 months
    granularity: str | None           # "postcode" | "sector" | "district"
    trend_confidence: str | None      # "high" | "medium" | "low"

@dataclass(frozen=True)
class PostcodeCrimeSignals:
    rate_band: str | None             # "very_low" | "low" | "medium" | "high" | "very_high"
    data_granularity: PostcodeCrimeGranularity | None
    categories: PostcodeCrimeCategories | None

@dataclass(frozen=True)
class PostcodeCrimeGranularity:
    band: str | None                  # "lsoa" | "datazone"
    categories: str | None            # "lsoa" | "local_authority"

@dataclass(frozen=True)
class PostcodeCrimeCategories:
    violence: PostcodeCrimeCategory | None
    property: PostcodeCrimeCategory | None
    vehicle: PostcodeCrimeCategory | None
    antisocial: PostcodeCrimeCategory | None
    drugs: PostcodeCrimeCategory | None
    damage: PostcodeCrimeCategory | None

@dataclass(frozen=True)
class PostcodeCrimeCategory:
    band: str | None                  # "very_low" | "low" | "medium" | "high" | "very_high"
    trend: str | None                 # "increasing" | "stable" | "decreasing" | "insufficient_data"

@dataclass(frozen=True)
class PostcodeEnvironmentSignals:
    air_quality_band: str | None      # "very_low" | "low" | "moderate" | "high"
    air_quality_trend: str | None
    no2_ug_m3: float | None           # annual mean NO2 µg/m³
    pm25_ug_m3: float | None          # annual mean PM2.5 µg/m³
    radon_potential: str | None       # "very_low" | "low" | "medium" | "high" | "very_high"
    green_space_proximity_metres: float | None
    is_national_park: bool | None
    is_aonb: bool | None
    is_green_belt: bool | None

@dataclass(frozen=True)
class PostcodeHousingSignals:
    epc_average_rating: str | None         # A–G
    council_tax_band: CouncilTaxBandEstimate | None
    dominant_property_type: str | None     # "detached" | "semi_detached" | "terraced" | "flat" | "other"

@dataclass(frozen=True)
class CouncilTaxBandEstimate:
    lower: str                        # A–I
    upper: str                        # A–I; equals lower when a single band is determined
    source: str                       # "exact_nrs" | "derived_hmlr" | "derived_lsoa"

@dataclass(frozen=True)
class PostcodePoliticalSignals:
    mp_name: str | None
    mp_party: str | None
    mp_party_colour: str | None

@dataclass(frozen=True)
class PostcodeDeprivationSignals:
    imd_decile: int | None            # 1 = most deprived, 10 = least deprived; England only
    imd_trend: str | None             # "improving" | "stable" | "declining" | "insufficient_data"

@dataclass(frozen=True)
class PostcodeDemographicsSignals:
    perc_owner_occupied: float | None
    perc_private_rented: float | None
    perc_no_car_van: float | None
    median_age: float | None
    perc_economically_active: float | None

@dataclass(frozen=True)
class PostcodeScores:
    property_risk_score: float | None  # 0–1, lower is better
    liveability_score: float | None    # 0–1, higher is better
    investment_score: float | None     # 0–1, higher is better
    affordability_index: float | None  # 0–1, higher is better
    score_convention: str

@dataclass(frozen=True)
class PostcodePercentiles:
    flood_rivers: float | None
    flood_groundwater: float | None
    crime_rate: float | None
    imd: float | None
    property_price: float | None
    property_price_regional: float | None
    radon: float | None
    air_quality: float | None
    green_space_proximity: float | None
    epc: float | None

@dataclass(frozen=True)
class GeographyCodes:
    admin_district: str | None
    admin_county: str | None
    admin_ward: str | None
    parliamentary_constituency: str | None
    lsoa: str | None
    msoa: str | None

@dataclass(frozen=True)
class PostcodeSources:
    geography: str
    flood: str
    crime: str
    property: str
    deprivation: str
    broadband: str
    environment: str
    epc: str
    green_space: str
    demographics: str | None
```

### Bulk types

```python
@dataclass(frozen=True)
class BulkVehicleResult:
    total: int
    results: list[VehicleResult | VehicleBulkItemError]
    quota: Quota

@dataclass(frozen=True)
class BulkPostcodeResult:
    total: int
    results: list[PostcodeResult | PostcodeBulkItemError]
    quota: Quota

@dataclass(frozen=True)
class VehicleBulkItemError:
    registration: str
    error: str                        # "not_found" | "invalid_format"

@dataclass(frozen=True)
class PostcodeBulkItemError:
    postcode: str
    error: str                        # "not_found" | "unsupported_region" | "invalid_format"

@dataclass(frozen=True)
class BulkJobSubmitted:
    job_id: str
    status: str                       # "queued"
    total: int
    poll_url: str
    quota: Quota

@dataclass(frozen=True)
class BulkJobStatus(Generic[T]):
    job_id: str
    status: str                       # "queued" | "processing" | "complete" | "expired"
    total: int
    done: int
    created_at: str                   # ISO 8601
    completed_at: str | None          # ISO 8601; None until complete
    expires_at: str                   # ISO 8601
    results: list[T] | None           # None until status == "complete"
    quota: Quota

@dataclass(frozen=True)
class DeletedJob:
    deleted: str                      # job_id that was deleted
    quota: Quota
```

### Type guards

```python
def is_vehicle_bulk_error(
    item: VehicleResult | VehicleBulkItemError,
) -> TypeGuard[VehicleBulkItemError]: ...

def is_postcode_bulk_error(
    item: PostcodeResult | PostcodeBulkItemError,
) -> TypeGuard[PostcodeBulkItemError]: ...
```

## Examples

Runnable examples are in the [`examples/`](./examples) directory.

```bash
# Set your key first
export ZYFY_API_KEY=ea_live_...

# Single lookups
VEHICLE_REG=AB12CDE python3 examples/vehicle.py
POSTCODE="SW1A 2AA" python3 examples/postcode.py

# Geographic queries
LAT=51.508 LON=-0.1281 python3 examples/nearest.py
LAT=51.508 LON=-0.1281 RADIUS=500 python3 examples/within.py

# Bulk lookups
VEHICLE_REGS=AB12CDE,XY34FGH python3 examples/bulk_vehicle.py
POSTCODES="SW1A 2AA,M1 1AE" python3 examples/bulk_postcode.py

# Error handling scenarios
python3 examples/errors.py vehicle-invalid
python3 examples/errors.py postcode-not-found
python3 examples/errors.py postcode-ni
python3 examples/errors.py bad-auth
```

## Versioning

This library follows [SemVer](https://semver.org). See [CHANGELOG.md](./CHANGELOG.md) for version history.

`0.x.x` releases may include breaking changes between minor versions. Stability guaranteed from `1.0.0`.

---

[zyfy.uk](https://zyfy.uk) · [Docs](https://zyfy.uk/docs) · [Sign up](https://zyfy.uk/signup) · [GitHub](https://github.com/zyfy-uk/zyfy-python)
