Metadata-Version: 2.5
Name: htag-sdk
Version: 2.0.1
Summary: Official Python SDK for the HtAG Location Intelligence API — address search, property data, and market analytics for Australia
Project-URL: Homepage, https://developer.htagai.com
Project-URL: Documentation, https://developer.htagai.com
Project-URL: Repository, https://github.com/HtaG-Analytics/htag-sdk-python
Project-URL: Issues, https://github.com/HtaG-Analytics/htag-sdk-python/issues
Author-email: Sasa Savic <sasa.savic@htag.com.au>
License-Expression: MIT
Keywords: address,api,australia,htag,location-intelligence,market-data,property,real-estate,sdk
Classifier: Development Status :: 4 - Beta
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: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.0
Description-Content-Type: text/markdown

# htag-sdk

The official Python SDK for the [HtAG](https://htagai.com) Location Intelligence API.

Provides typed, ergonomic access to Australian address data, property valuations, sales records, and market analytics with both synchronous and asynchronous clients.

```python
from htag_sdk import HtAgApi

client = HtAgApi(api_key="sk-...", environment="prod")

results = client.address.geocode("100 George St Sydney")
for r in results.results:
    print(f"{r.address_label}  ({r.address_key})")
```

## 2.0.1 patch

Sold-search responses now accept fractional `land_area` values (square metres)
without rejecting the entire page. Both synchronous and asynchronous clients
preserve the fractional value; whole-number and null areas remain supported.
Request filters and the 2.0.0 migration requirements below are unchanged.

## Migrating to 2.0.0

2.0.0 is a **major** release: public bedroom-filter types are narrower and
unsupported inputs can now fail locally before a request is sent. Review the
migration changes below before upgrading from 1.9.6.

### Breaking changes in this package

| Change | 1.9.6 | 2.0.0 | What to do |
|--------|-------|-------|------------|
| `bedrooms` annotation on public bedroom-filtered trends and `markets.summary` | `Optional[Union[str, List[str]]]` | `Optional[str]` | Pass one value. `bedrooms=["3"]` is still accepted and unwrapped at runtime; `bedrooms=["3", "4"]` is not. |
| `markets.trends.price/rent/yield_history/years_to_own`, `markets.summary` with two bedroom values | repeated the key on the wire; the API silently kept whichever value arrived last | raises `InvalidParameterError` before sending | Issue one request per bedroom count and combine the results. |
| `markets.trends.demand_profile(property_type=…)` (also `period_end_min` / `period_end_max`) | issued a request; the API did not apply the filter | raises `InvalidParameterError` before sending | Remove the unsupported argument; it does not provide server-side filtering. |
| `intent_hub.list_event_types(category=…)`, `internal.address.insights(street_loc_pid=…)` | silently ignored by the API | raise `InvalidParameterError` | Drop the argument. |

`InvalidParameterError` subclasses both `HtAgError` and `ValueError`, so an
existing `except ValueError:` guard keeps working.

### Request and page-size changes

* **Sold-search filter names are corrected in this package.**
  `property.sold_search` sends the API's canonical camelCase filter names,
  including `startDate`, `endDate`, `saleValueMin` and `saleValueMax`.
  Public SDK parameter names are unchanged. `address_key` stays snake_case;
  rented-search and other endpoint mappings are unchanged by this correction.
* Internal AVM `address` and `address_key` lists are sent as repeated keys,
  preserving commas inside each address. Other Python list parameters already
  used repeated keys in the checked 1.9.6 package.
* **`property.rented_search` no longer pins `limit=500`.** Omitting it lets
  the server choose the page size (100 in the reviewed search contract).
  Pass an explicit limit of 500 to preserve the old requested page size.

### Compatibility and verification scope

The sold wire correction targets both the pre-alias route source `6d549400`
and current-main route source `9877c67e`: both declare the canonical camelCase
filter names. This removes the package's dependency on the newer server aliases.
Local real-handler replay tests cover these two source baselines; they are not
authenticated live DEV or production tests, and `6d549400` is **not** a verified
production API image identifier.

Compatibility checks also cover a captured set of valid calls from the published
1.9.6 npm and PyPI packages. Those tested calls are not rejected by the newer
unknown-parameter guard. This is not a guarantee for every 1.9.6 input, older
versions, or custom HTTP clients. Existing installed clients do not receive this
package correction until upgraded.

### Server-side changes are separate

PR #381 adds sold-search snake_case aliases and rejects unknown query names on
both sold and rented search. Its recorded deployment is DEV-only; installing
this package does not deploy these server changes or establish their live status.
The sold wire correction above works with either reviewed route version.

* The pre-alias sold route declares camelCase filters; undeclared snake_case
  filters can be ignored. The newer route accepts both and prefers camelCase
  when both are supplied.
* The reviewed rented route already accepts both naming styles. It is still
  affected by the newer **unknown-parameter validation**, like sold search.
* With the newer validation, unknown names return HTTP 400 before repository
  work. Without it, an undeclared name can be ignored. Check your target
  environment's API contract before relying on this server-side validation.

### Known limitations

* `own_status` on address keys is separate work (PR #265), not part of this release.
* The reviewed API accepts `limit` and `offset` on six reference concordance
  `*-to-h3` routes; those parameters were absent from the reviewed spec.
* Client-side refusals of unsupported filters are package behavior, not evidence
  of a server deployment. No live test or registry-publication outcome is implied
  by these migration notes.

## Installation

```bash
pip install htag-sdk
```

Or with your preferred package manager:

```bash
uv add htag-sdk
poetry add htag-sdk
```

Requires Python 3.9+.

## Quick Start

### 1. Get an API Key

Sign up at [developer.htagai.com](https://developer.htagai.com) and create an API key from the Settings page.

### 2. Create a Client

```python
from htag_sdk import HtAgApi

client = HtAgApi(
    api_key="sk-org--your-org-id-your-key-value",
    environment="prod",   # "dev" or "prod"
)
```

Or use a custom base URL:

```python
client = HtAgApi(api_key="sk-...", base_url="https://api.staging.htagai.com")
```

### 3. Make Requests

```python
# Geocode an address
results = client.address.geocode("15 Miranda Court Noble Park")
print(results.total, "matches")

# Get property estimates
est = client.property.estimates(address_key="15MIRANDACOURTNOBLEPARKvic3174")
for record in est.results:
    print(f"Price estimate: ${record.price_estimate:,}")
    print(f"Last sold: ${record.last_sold_price:,} on {record.last_sold_date}")

# Close when done (or use a context manager)
client.close()
```

## Usage

### Address Geocode

Resolve a free-text address to structured location data with geographic identifiers.

```python
results = client.address.geocode(
    "100 Hickox St Traralgon",
    limit=5,         # max results (1 - 50)
)

for match in results.results:
    print(f"{match.address_label}")
    print(f"  Key: {match.address_key}")
    print(f"  Location: {match.lat}, {match.lon}")
```

### Address Standardisation

Standardise raw address strings into structured, canonical components.

```python
result = client.address.standardise([
    "12 / 100-102 HICKOX STR TRARALGON, VIC 3844",
    "15a smith st fitzroy vic 3065",
])

for item in result.results:
    if item.error:
        print(f"Failed: {item.input_address} -- {item.error}")
    else:
        addr = item.standardised_address
        print(f"{item.input_address}")
        print(f"  -> {addr.street_number} {addr.street_name} {addr.street_type}")
        print(f"     {addr.suburb_or_locality} {addr.state} {addr.postcode}")
        print(f"  Key: {item.address_key}")
```

### Address Environment

Retrieve environmental risk data for an address including flood, bushfire, heritage, and zoning.

```python
env = client.address.environment(address="15 Miranda Court, Noble Park VIC 3174")
for record in env.results:
    print(f"Bushfire: {record.bushfire}, Flood: {record.flood}")
    print(f"Heritage: {record.heritage}, Zoning: {record.zoning}")
```

### Address Demographics

Retrieve socio-economic indices (SEIFA) and housing tenure data.

```python
demo = client.address.demographics(address="15 Miranda Court, Noble Park VIC 3174")
for record in demo.results:
    print(f"IRSAD: {record.IRSAD}, IER: {record.IER}")
```

### Property Summary

Retrieve physical property attributes for an address.

```python
summary = client.property.summary(address_key="100102HICKOXSTREETTRARALGONVIC3844")
for record in summary.results:
    print(f"Type: {record.property_type}")
    print(f"Beds: {record.beds}, Baths: {record.baths}, Parking: {record.parking}")
    print(f"Land: {record.lot_size} sqm, Floor: {record.floor_area} sqm")
```

### Property Estimates

Retrieve valuation estimates and transaction history for an address.

```python
est = client.property.estimates(address_key="100102HICKOXSTREETTRARALGONVIC3844")
for record in est.results:
    print(f"Price estimate: ${record.price_estimate:,}")
    print(f"Rent estimate: ${record.rent_estimate}/wk")
    print(f"Last sold: ${record.last_sold_price:,} on {record.last_sold_date}")
```

### Property Market

Retrieve market position indicators for an address.

```python
mkt = client.property.market(address_key="100102HICKOXSTREETTRARALGONVIC3844")
for record in mkt.results:
    print(f"Rental %: {record.rental_percentage:.0%}")
    print(f"Years to own: {record.years_to_own}")
    print(f"Hold period: {record.hold_period} years")
```

### Sold Property Search

Search for recently sold properties near an address or coordinates.

```python
sold = client.property.sold_search(
    address="100 George St, Sydney NSW 2000",
    radius=2000,              # metres
    property_type="house",
    sale_value_min=500_000,
    sale_value_max=2_000_000,
    bedrooms_min=3,
    start_date="2024-01-01",
)

print(f"{sold.total} properties found")
for prop in sold.results:
    price = f"${prop.sold_price:,.0f}" if prop.sold_price else "undisclosed"
    print(f"  {prop.street_address}, {prop.suburb} -- {price} ({prop.sold_date})")
```

All filter parameters are optional:

| Parameter | Type | Description |
|-----------|------|-------------|
| `address` | str | Free-text address to centre the search on |
| `address_key` | str | GNAF address key |
| `lat`, `lon` | float | Coordinates for point-based search |
| `radius` | int | Search radius in metres (default 2000, max 5000) |
| `proximity` | str | `"any"`, `"sameStreet"`, or `"sameSuburb"` |
| `property_type` | str | `"house"`, `"unit"`, `"townhouse"`, `"land"`, `"rural"` |
| `sale_value_min`, `sale_value_max` | float | Price range filter (AUD) |
| `bedrooms_min`, `bedrooms_max` | int | Bedroom count range |
| `bathrooms_min`, `bathrooms_max` | int | Bathroom count range |
| `car_spaces_min`, `car_spaces_max` | int | Car space range |
| `start_date`, `end_date` | str | Date range (ISO 8601, e.g. `"2024-01-01"`) |
| `land_area_min`, `land_area_max` | int | Land area in sqm |
| `limit` | int | Rows per page (1-1000). Omitted → server default **100** |
| `offset` | int | Rows to skip before `limit` (>= 0). Omitted → **0** |

> Sold-search filters are sent using the API's canonical camelCase names.
> The public Python parameter names above are unchanged; `address_key` remains snake_case.

#### Pagination

`limit` and `offset` work the same way on `sold_search()` and
`rented_search()`. Both default to `limit=100, offset=0` **on the server** —
the SDK sends nothing when you omit them, so you always get the documented
behaviour. `offset` is a *row* offset applied after ordering, not a page
number: `limit=25, offset=50` returns rows 51-75.

`total` is the number of rows in the page you just received — not the count of
all matching records — and it is the billable row count, so rows skipped by
`offset` are never charged.

```python
limit, offset = 100, 0
while True:
    page = client.property.sold_search(
        address_key="100102HICKOXSTREETTRARALGONVIC3844",
        limit=limit,
        offset=offset,
    )
    for prop in page.results:
        ...
    if page.total < limit:      # short page → last page
        break
    offset += limit
```

> **Changed in this release:** the server default for `limit` on both searches
> is now **100** (previously 500 on rented search, and `offset` was ignored
> entirely on sold search). Pass `limit=500` explicitly if you relied on the
> old page size.

### Market Summary

Get headline market metrics at suburb or LGA level.

```python
summary = client.markets.summary(
    level="suburb",
    area_id=["SAL10001"],
    property_type=["house"],
)

for record in summary.results:
    print(f"{record.suburb} ({record.state_name})")
    print(f"  Typical price: ${record.typical_price:,}")
    print(f"  Rent: ${record.rent}/wk")
```

### Market Growth

Retrieve cumulative or annualised growth rates for price, rent, and yield.

```python
growth = client.markets.growth_cumulative(
    level="suburb",
    area_id=["SAL10001"],
    property_type=["house"],
)

for record in growth.results:
    print(f"1Y price growth: {record.one_y_price_growth:.1%}")
    print(f"5Y price growth: {record.five_y_price_growth:.1%}")
```

### Market Trends

Access historical trend data via `client.markets.trends`. All trend methods share the same parameter signature:

```python
# Price history
prices = client.markets.trends.price(
    level="suburb",
    area_id=["SAL10001"],
    property_type=["house"],
    period_end_min="2020-01-01",
    limit=50,
)
for p in prices.results:
    print(f"{p.period_end}: ${p.typical_price:,} ({p.sales} sales)")

# Rent history
rents = client.markets.trends.rent(level="suburb", area_id=["SAL10001"])

# Yield history
yields = client.markets.trends.yield_history(level="suburb", area_id=["SAL10001"])

# Search interest index (buy/rent search indices)
search = client.markets.trends.search_index(level="suburb", area_id=["SAL10001"])

# Hold period
hold = client.markets.trends.hold_period(level="suburb", area_id=["SAL10001"])

# Growth rates (price, rent, yield changes)
growth = client.markets.trends.growth_rates(level="suburb", area_id=["SAL10001"])

# Demand profile (sales by dwelling type and bedrooms)
demand = client.markets.trends.demand_profile(level="suburb", area_id=["SAL10001"])

# Stock on market
som = client.markets.trends.stock_on_market(level="suburb", area_id=["SAL10001"])

# Days on market
dom = client.markets.trends.days_on_market(level="suburb", area_id=["SAL10001"])

# Clearance rate
cr = client.markets.trends.clearance_rate(level="suburb", area_id=["SAL10001"])

# Vacancy rate
vac = client.markets.trends.vacancy(level="suburb", area_id=["SAL10001"])
```

Common trend parameters:

| Parameter | Type | Description |
|-----------|------|-------------|
| `level` | str | `"suburb"` or `"lga"` (required) |
| `area_id` | list[str] | Area identifiers (required) |
| `property_type` | list[str] | `["house"]`, `["unit"]`, etc. |
| `period_end_min` | str | Filter from this date |
| `period_end_max` | str | Filter up to this date |
| `bedrooms` | str or list[str] | Bedroom filter |
| `limit` | int | Max results (default 100, max 1000) |
| `offset` | int | Pagination offset |

## Internal API

Some endpoints require the `internal_api` scope on your API key. These are accessed via the `client.internal` namespace:

```python
# Address search (trigram similarity matching)
results = client.internal.address.search("100 George St Sydney")

# Address insights (enriched address data)
insights = client.internal.address.insights(
    address="15 Miranda Court, Noble Park VIC 3174"
)

# Automated Valuation Model (batch, up to 50 properties)
avm = client.internal.property.avm(
    address_key=["100102HICKOXSTREETTRARALGONVIC3844"]
)

# Market snapshots with filtering
snapshots = client.internal.markets.snapshots(
    level="suburb",
    property_type=["house"],
    area_id=["SAL10001"],
)

# Advanced market query with logical filters
results = client.internal.markets.query({
    "level": "suburb",
    "mode": "search",
    "property_types": ["house"],
    "typical_price_min": 500_000,
    "logic": {
        "and": [
            {"field": "one_y_price_growth", "gte": 0.05},
            {"field": "vacancy_rate", "lte": 0.03},
        ]
    },
})

# Internal trend endpoints
supply = client.internal.markets.trends.supply_demand(
    level="suburb", area_id=["SAL10001"]
)
perf = client.internal.markets.trends.performance(
    level="suburb", area_id=["SAL10001"]
)
```

If you call an internal method without the required scope, the API will return a 403 error.

## Async Usage

Every method is available as an async equivalent:

```python
import asyncio
from htag_sdk import AsyncHtAgApi

async def main():
    client = AsyncHtAgApi(api_key="sk-...", environment="prod")

    # All the same methods, just with await
    results = await client.address.geocode("100 George St Sydney")
    est = await client.property.estimates(address_key="...")
    sold = await client.property.sold_search(address="100 George St Sydney")
    prices = await client.markets.trends.price(level="suburb", area_id=["SAL10001"])

    # Internal methods also available
    insights = await client.internal.address.insights(address="100 George St Sydney")

    await client.close()

asyncio.run(main())
```

### Context Manager

Both clients support context managers for automatic cleanup:

```python
# Sync
with HtAgApi(api_key="sk-...") as client:
    results = client.address.geocode("Sydney")

# Async
async with AsyncHtAgApi(api_key="sk-...") as client:
    results = await client.address.geocode("Sydney")
```

## Parameter Semantics

Behaviour that is easy to get wrong, and what this SDK does about it.

### List parameters are sent as repeated keys

`area_id=["VIC3121", "VIC3141"]` goes on the wire as
`?area_id=VIC3121&area_id=VIC3141`. This client always did that for list parameters; what
changed is the internal AVM methods, which used to comma-join `address_key`
and `address` into one string. Commas inside a free-text address are now
preserved, so two addresses stay two addresses.

### `bedrooms` is a single value on the public API

`markets.trends.price` / `rent` / `yield_history` / `years_to_own` and
`markets.summary` filter on one bedroom count. The published client always
wrapped the value in a list, so httpx repeated the key and the API silently
kept whichever value arrived last. Passing two now raises
`InvalidParameterError` before the request is sent:

```python
client.markets.trends.price("suburb", ["SAL10001"], bedrooms="3")        # ok
client.markets.trends.price("suburb", ["SAL10001"], bedrooms=["3", "4"])  # raises
```

Issue one request per bedroom count and combine the results. A one-element
list is still accepted and unwrapped at runtime, but type-checked callers
should pass a scalar. The **internal** trend endpoints genuinely do accept a list and
still do.

### Filters the API does not implement

The checked older SDK accepted these filters, but the reviewed API routes do
not implement them. They now raise `InvalidParameterError` before any request:

| Method | Parameter | Why |
|--------|-----------|-----|
| `markets.trends.demand_profile` | `property_type`, `period_end_min`, `period_end_max` | the endpoint filters on `level`, `areaId`, `limit`, `offset` only |
| `intent_hub.list_event_types` | `category` | the route declares no query parameters |
| `internal.address.insights` | `street_loc_pid` | the route accepts `address`, `address_keys`, `legal_parcel_id`, `mb_category_2021` |

Omitting them, or passing `None` / `[]`, is still a no-op.
`InvalidParameterError` also subclasses `ValueError`, so an existing
`except ValueError` guard keeps working.

### Sold and rented search

These rules describe the reviewed API source contract; package installation is
not a server deployment. See [verification scope](#compatibility-and-verification-scope).

* `property.sold_search` sends canonical camelCase sold filters; `address_key`
  remains snake_case. The newer server additionally accepts snake_case aliases,
  with explicit camelCase values (including false and zero) taking precedence.
  Rented search accepts both spellings in both reviewed route versions.
* Omitting `start_date` applies a **default** window of 90 days before the
  effective `end_date`. It is not a maximum lookback; an explicit earlier
  date is passed through for server-side filtering.
* Sale price bounds are inclusive and exclude unknown prices.
  `include_sale_value_unknown` applies only when no price bound is set.
* The newer server rejects unknown query names on **both** searches with
  HTTP 400 `unknown_query_parameters`, before repository work. For example,
  `bedrooms=3` is not a sold-search filter; use `bedroomsMin`/`bedroomsMax`.
  Typoed names are not automatically corrected. Older route versions may
  silently ignore unknown names. This server behavior is separate from the
  SDK's client-side validation.
* `total` counts rows in the returned page, not all matches. Page until
  `total < limit`, using the same explicit limit and filters for each request.

### Staying on an older release

Upgrading to 2.0.0 applies the sold wire correction. If you need direct REST
instead, use canonical camelCase sold filter names, accepted by both reviewed
source versions. For example, this is the request path and query (authentication
must be configured separately in your client):

```text
/v1/property/sold/search?address_key=AK&startDate=2025-09-10&endDate=2026-08-26&saleValueMin=600000&propertyType=house&propertyType=unit
```

Repeat list parameters rather than comma-joining them. Do not assume a naming
style supported by one endpoint is accepted by every endpoint.

## Error Handling

The SDK raises typed exceptions for API errors:

```python
from htag_sdk import (
    HtAgApi,
    AuthenticationError,
    RateLimitError,
    ValidationError,
    ServerError,
    ConnectionError,
)

client = HtAgApi(api_key="sk-...")

try:
    results = client.address.geocode("Syd")
except AuthenticationError as e:
    # 401 or 403 -- bad API key or insufficient scope
    print(f"Auth failed: {e.message}")
except RateLimitError as e:
    # 429 -- throttled (after exhausting retries)
    print(f"Rate limited. Retry after: {e.retry_after}s")
except ValidationError as e:
    # 400 or 422 -- bad request params
    print(f"Invalid request: {e.message}")
    print(f"Details: {e.body}")
except ServerError as e:
    # 5xx -- upstream failure (after exhausting retries)
    print(f"Server error: {e.status_code}")
except ConnectionError as e:
    # Network/DNS/TLS failure
    print(f"Connection failed: {e.message}")
```

All exceptions carry:
- `message` -- human-readable description
- `status_code` -- HTTP status (if applicable)
- `body` -- raw response body
- `request_id` -- request identifier (if returned by the API)

## Retries

The SDK automatically retries transient failures:

- **Retried statuses**: 429, 500, 502, 503, 504
- **Max retries**: 3 (configurable)
- **Backoff**: exponential (0.5s base, 2x multiplier, 25% jitter, 30s cap)
- **429 handling**: respects `Retry-After` header

Configure retry behaviour:

```python
client = HtAgApi(
    api_key="sk-...",
    max_retries=5,    # default is 3
    timeout=120.0,    # request timeout in seconds (default 60)
)
```

## Configuration Reference

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `api_key` | str | required | Your HtAG API key |
| `environment` | str | `"prod"` | `"dev"` or `"prod"` |
| `base_url` | str | -- | Custom base URL (overrides environment) |
| `timeout` | float | `60.0` | Request timeout in seconds |
| `max_retries` | int | `3` | Maximum retry attempts |

## Requirements

- Python >= 3.9
- [httpx](https://www.python-httpx.org/) >= 0.27
- [Pydantic](https://docs.pydantic.dev/) >= 2.0

## License

MIT
