Metadata-Version: 2.5
Name: addrly-api
Version: 0.2.0
Summary: Belgian address and company API: autocomplete, validation, normalisation, BCE/KBO company data.
Project-URL: Homepage, https://addrly.be
Project-URL: Documentation, https://addrly.be/documentation/python
Project-URL: Source, https://github.com/Hachard-Victor/addrly/tree/main/sdk/python
Project-URL: Changelog, https://github.com/Hachard-Victor/addrly/blob/main/sdk/python/CHANGELOG.md
Author-email: Addrly <support@addrly.be>
License: MIT
License-File: LICENSE
Keywords: address,addrly,autocomplete,bce,belgium,bosa,company,geocoding,kbo
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
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: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.23
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# addrly-api

The official Python client for the [Addrly](https://addrly.be) API: Belgian
addresses (BOSA Best-Address) and Belgian companies (BCE/KBO, NBB annual
accounts, Moniteur belge).

```bash
pip install addrly-api
```

```python
from addrly_api import Addrly

client = Addrly()  # reads ADDRLY_API_KEY
# or Addrly("ak_...") / Addrly(api_key=key), from your own settings

for hit in client.autocomplete("rue de la loi", limit=5, type="address"):
    print(hit.score, hit.label)

address = client.normalize("16 rue de la loi 1000 brux")
if address and address.match_similarity >= 0.9:
    print(address.normalized.one_line())  # Rue de la Loi 16, 1000 Bruxelles

company = client.company("BE 0123.456.749")  # any format, checked locally
print(company.display_name, company.seat().one_line())
```

Requires Python 3.9+. The only dependency is `httpx`.

## Get a key

Sign up at [addrly.be](https://addrly.be), then create a key on the
**API keys** page. Read it from the environment, never from source:

```bash
export ADDRLY_API_KEY=ak_...
```

`Addrly()` reads the environment variable. When your application already
holds the key in its own settings, pass it to the constructor instead:
`Addrly("ak_...")` or `Addrly(api_key=key)`. Same for `AsyncAddrly`.

A key is server-to-server. Do not ship one to a browser or a mobile app.

## What you can call

| Method | Endpoint | Cost |
| --- | --- | --- |
| `account()` | `GET /account` | **free** |
| `autocomplete(q, limit=, type=)` | `GET /autocomplete` | 1 address request |
| `normalize(text)` | `POST /normalize` | 1 address request |
| `validate_address(**fields)` | `POST /validate/address` | 1 address request |
| `validate_components(**fields)` | `POST /validate/components` | 1 address request |
| `reverse_geocode(lat, lon, limit=)` | `GET /geocode/reverse` | 1 address request |
| `route(origin, dest)` | `GET /routing/route` | 1 address request |
| `company(number)` | `GET /company/{n}` | 1 request |
| `dossier(number)` | `GET /company/{n}/enrich` | 1 dossier, once per company per period |
| `match_companies(rows)` | `POST /enrich/company` | 1 request per row |
| `normalize_many` / `companies` / `dossiers` | `POST /batch` | the unitary price per item |

### Know before you spend

`account()` is the only free call. It answers the plan, the feature slugs, the
rate limit and what is left of **both** meters, and the server answers it even
while billing is suspended, so it is also where you find out why everything
else is refusing you:

```python
account = client.account()

if account.frozen:
    raise SystemExit("billing suspended, resolve it in the app")
if not account.has_feature("company_api_bulk"):
    raise SystemExit("this plan does not include the bulk matcher")
if account.requests_remaining < len(rows):
    rows = rows[: int(account.requests_remaining)]

print(account.plan.name, account.credits_remaining, "dossiers left")
print("watching", account.watch.used, "of", account.watch.limit, "companies")
print("resets in", account.period.resets_in_seconds, "s")
```

A budget guard never refuses it either: a status call you cannot make when you
are out of everything is a status call for nothing.

Two meters, and they are separate: requests (every call that does not open
a dossier, company identity reads and matched rows included) and dossiers (one
company's full dossier, charged once per company per billing period). An
answer carries only the meter it charged (`X-RateLimit-*` for a request,
`X-Credit-*` for a dossier), so `client.last_usage` keeps the best
known state of **both**, while `response.usage` stays strictly what that one
response said:

```python
client.normalize("rue de la loi 16")
print(client.last_usage.address.remaining)  # 993

client.dossier("0123456749")
print(client.last_usage.credits.remaining)  # 249: dossiers left
print(client.last_usage.address.remaining)  # 993, still known

answer = client.autocomplete("exampleco")
print(answer.usage.credits.is_empty)  # True: that call charged a request
```

## Bulk: one bad row never loses the good ones

`normalize_many`, `companies` and `dossiers` chunk your list to the API's
ceilings (100 rows, 25 for full dossiers) and give back one `Outcome` per
input, in input order. A malformed row is a 422 on **that item**, not on the
call:

```python
for outcome in client.normalize_many(rows):
    if outcome:
        save(outcome.value.normalized)
    else:
        log(outcome.index, outcome.error.code)
```

`match_companies` is the CRM/ERP import endpoint: messy names in, the best
Belgian company match out, with the scored runners-up.

```python
for row in client.match_companies(
    ["acme sofware", {"name": "globex", "address": "1000 Bruxelles"}]
):
    if row and row.score >= 0.9:
        apply(row.enterprise_number)
```

## Errors: branch on the code, never on the status

Every refusal is a typed exception carrying the API's own `error` code, the
message, the `request_id` and whatever fields that code documents.

```python
from addrly_api import CreditsExhausted, FeatureNotInPlan, RateLimited, APIError

try:
    dossier = client.dossier("0123456749")
except CreditsExhausted as exc:
    print("out of credits, resets in", exc.reset_in_seconds, "s")
except FeatureNotInPlan as exc:
    print("the plan does not include", exc.feature)
except APIError as exc:
    print(exc.code, exc.request_id)
```

| Exception | Status / code |
| --- | --- |
| `AuthenticationError` | 401 `missing_credentials`, `invalid_api_key`, `invalid_token`, `session_expired` |
| `AccountFrozen` | 402 `account_frozen` |
| `PermissionDenied`, `FeatureNotInPlan` | 403 `account_inactive`, `api_key_disabled`, `feature_not_in_plan` |
| `NotFound`, `CompanyNotFound` | 404 |
| `InvalidRequest`, `InvalidEnterpriseNumber` | 422 |
| `RateLimited` | 429 `rate_limited` (the per-second cap) |
| `QuotaExceeded`, `CreditsExhausted` | 429 `quota_exceeded`, `credits_exhausted` (the month) |
| `ServerError`, `ServiceUnavailable`, `Maintenance` | 5xx |
| `TransportError` | the request never came back |

## Retries: what is repeated, and what must not be

Two 429s mean opposite things, so the client treats them differently.

- **Retried**, with exponential backoff plus jitter, honouring `Retry-After`:
  `rate_limited`, 5xx, and connection failures.
- **Never retried**: `quota_exceeded` and `credits_exhausted`. A monthly
  allowance does not come back in 500 ms, and a loop on it only buys overage.
  Also never retried: 401, 402, 403, 404, 422. The request is wrong, or the
  plan is, and sending it again changes neither.
- **A priced POST** (`/normalize`, `/batch`, `/enrich/company`) is metered
  upfront, so it is only retried when the connection provably never opened.
  A read timeout there might already have been billed, and the API has no
  idempotency key yet.

```python
client = Addrly(max_retries=3, retry_backoff=0.5, timeout=10.0)
```

`max_retries=0` disables it.

## Never spend more than you meant to

A migration script is the one place a paid API gets expensive by accident.
`budget()` counts the declared price of each call and refuses the one that
would cross the cap, **before** sending it:

```python
from addrly_api import BudgetExceeded

try:
    with client.budget(credits=50) as spent:
        for number in numbers:
            enrich(client.dossier(number))
except BudgetExceeded:
    print("stopped at", spent.credits_spent, "credits")
```

## Async

Same methods, same models, same retry policy:

```python
import asyncio
from addrly_api import AsyncAddrly


async def main():
    async with AsyncAddrly() as client:
        answer = await client.autocomplete("rue de la loi")
        print(len(answer))


asyncio.run(main())
```

## Command line

The package installs an `addrly` command (and `addrly-api`, same thing, for a
machine where the short name is taken), so the API can be tried in thirty
seconds:

```bash
addrly account
addrly autocomplete "rue de la loi"
addrly normalize "rue de la loi 16 brux"
addrly company 0123456749
addrly dossier 0123456749
addrly reverse 50.8467 4.3499
addrly match "acme sofware" "globex grup"

# The job most integrations write first: an address column, cleaned.
addrly clean clients.csv --column address -o clean.csv
```

`clean` adds `addrly_street`, `addrly_house_number`, `addrly_box`,
`addrly_postcode`, `addrly_municipality`, `addrly_lat`, `addrly_lon`,
`addrly_similarity` and `addrly_status` (`ok`, `review`, `not_found`,
`empty`). It sends 100 rows per round trip and never spends a request on an
empty cell.

## Enterprise numbers, checked without a round trip

```python
from addrly_api import is_valid_enterprise_number, normalize_enterprise_number

normalize_enterprise_number("BE 0123.456.749")  # "0123.456.749"
is_valid_enterprise_number("0123456748")  # False (mod-97)
```

Every company method accepts any format and validates locally first, so a
typo raises `InvalidEnterpriseNumber` instead of costing a call.

## Forward compatible on purpose

Models are plain dataclasses and every one keeps the payload it was built
from in `.raw`. An unknown field never raises, so a field the API adds
tomorrow is readable today:

```python
company.raw["a_field_shipped_after_this_release"]
```

Only the stable shapes are typed. The deep dossier tree (annual-account
snapshots, gazette events, participations) stays as plain dicts under typed
entry points: those grow with the pipelines behind them, and a class per leaf
would be wrong one release later.

## Configuration

| Argument | Default | Environment |
| --- | --- | --- |
| `api_key` | - | `ADDRLY_API_KEY` |
| `base_url` | `https://addrly.be/api/v1` | `ADDRLY_BASE_URL` |
| `timeout` | `10.0` | - |
| `max_retries` | `2` | - |
| `retry_backoff` | `0.5` | - |

One client per process, shared between threads: it holds a pooled TLS
connection, which is most of the latency of a first call.

## Links

- API documentation: <https://addrly.be/documentation>
- Python guide: <https://addrly.be/documentation/python>
- `llms.txt` (paste into an AI coding assistant): <https://addrly.be/llms.txt>

MIT licensed. Belgium only, on purpose.
