Metadata-Version: 2.4
Name: realtyfeed-mls-router
Version: 0.3.0
Summary: Async Python client for the RealtyFeed MLS Router (RESO Web API)
Author-email: Realtyna <chandler.p@realtyna.net>
License-Expression: Apache-2.0
Project-URL: Source, https://gitlab.com/realtyna-realtyfeed/mls-router-python-sdk
Project-URL: Issues, https://gitlab.com/realtyna-realtyfeed/mls-router-python-sdk/-/issues
Keywords: reso,real-estate,mls,odata,realtyfeed
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.7
Provides-Extra: redis
Requires-Dist: redis>=5.0; extra == "redis"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pytest-httpx>=0.30; extra == "dev"
Requires-Dist: ruff<0.17,>=0.16.2; extra == "dev"
Dynamic: license-file

# realtyfeed-mls-router

Async Python client for the **RealtyFeed MLS Router** — Realtyna's product name for
the RealtyFeed RESO Web API. It is not a separate routing service, though it does
expose one small `router-mapping` endpoint that resolves an `mls_id` to its upstream
source descriptor.

Requires Python 3.11+.

## Install

```bash
pip install realtyfeed-mls-router
```

Pin an exact version (`==`) in a deployed service rather than a range. Releases
are cut by hand and the API is pre-1.0, so a floating pin turns an unrelated
deploy into an unplanned SDK upgrade.

You will need credentials — see below; they are issued by RealtyFeed and there
is no self-serve signup.

### Working on the SDK itself

```bash
pip install -e ".[dev]"
```

## Usage

```python
from realtyfeed_mls_router import MLSRouterClient, MLSRouterConfig, Query, eq

config = MLSRouterConfig.for_environment(
    "prod",
    client_id=...,
    client_secret=...,
    api_key=...,
    origin="https://your-registered-origin",
)

async with MLSRouterClient(config) as client:
    page = await client.search_property(
        Query().select("ListingKey", "ListPrice", "City").filter(eq("City", "Austin")).top(25)
    )

    async for record in client.iter_property(Query().top(50), max_records=500):
        ...
```

## Things worth knowing before you use it

**Only `prod` has an address built in.** `for_environment("prod", ...)` resolves
to `https://api.realtyfeed.com`. The other stages (`staging`, `dev`, `devTest`)
are RealtyFeed-internal and their gateway hostnames are deliberately not shipped
in a public package, so point at them yourself:

```bash
export MLS_ROUTER_BASE_URL=https://your-gateway.example.com/dev
```

or pass `base_url=` to `for_environment`, which wins over the variable. Asking
for a non-prod stage without either raises `ConfigurationError` naming the
variable rather than failing later at the transport layer. Setting the variable
also overrides `prod`, which is the hook for pointing a whole deployment at a
mock or a gateway replica.

**Credentials are a matched set.** `client_id`, `client_secret`, `api_key` and
`origin` are issued together. The RESO authorizer requires the token's `client_id`
claim to equal the `client_id` registered against the API key, and validates
`Origin`/`Referer` against that key's allowed origins. Any mismatch — including two
individually valid halves of different pairs — is a bare 403 with an empty body.

**Tokens are cached, and that is a correctness requirement.** RF issues ~24h tokens
behind a low auth request rate and a daily ceiling on new-token grants. A client that
fetches a token per call will be cut off. The default cache is process-local; pass a
shared `TokenCache` if you run more than one worker.

**Query limits are checked locally.** `$top ≤ 200`, `$select ≤ 5 fields`,
`$filter ≤ 5 conditions`, `$orderby` in `{asc, desc}`. The server enforces these too
but reports them as a generic 400, so we raise a typed error naming the actual
problem instead.

**Filter values are escaped.** Use the `eq` / `ne` / `contains` / `is_in` helpers
rather than hand-building filter strings — they double single quotes so a value
containing an apostrophe cannot terminate the literal early.

**Pagination computes `$skip`.** The server's `@odata.nextLink` is built by joining
parameters without a leading separator, so the URL it returns is malformed. This
client ignores it and pages by offset.

**Three response envelopes exist upstream, and one hides failures behind HTTP 200.**
The metadata and `router-mapping` endpoints return `{"success": false, ...}` with a
200 status line. The transport layer raises on all three failure encodings, so a
returned payload is always a success.

**Tenant scoping is implicit.** Results are scoped server-side to the caller's
entitled MLSs. Adding an explicit `OriginatingSystemName` filter *replaces* that
scope rather than narrowing it, and naming an un-entitled MLS raises
`MLSAccessDenied`.

## Known gaps

- **`router_mapping` returns an untyped dict.** The handler returns the raw
  DynamoDB item and its attribute set is not documented in any repository. Needs a
  live read or a backend engineer before it can be modelled.
- **No field allow-lists ship by default.** Rather than guess at valid field names,
  `Query` skips field validation unless you pass `allowed_fields`. Derive those from
  `get_metadata()`.
- **Path version skew.** `router-mapping` and `$metadata` sit on the unversioned
  prefix while `Office`/`OpenHouse`/`PropertyRooms` are on `/v1`. Confirm which are
  deprecated before treating either as stable.

## Development

```bash
pytest -q          # no network; all upstream behaviour is stubbed
ruff check realtyfeed_mls_router tests
```

New dependencies go in **both** `pyproject.toml` and `requirements.txt`.

## Releasing

```bash
git tag v0.3.0 && git push origin v0.3.0
```

That is the entire release. There is no version literal to bump: `pyproject.toml`
declares the version `dynamic` and setuptools-scm reads it off the tag, so the
tag and the published version cannot disagree. CI builds the wheel and uploads
it to PyPI (see `.gitlab-ci.yml`); only tags matching `vX.Y.Z` publish.

Uploads use PyPI Trusted Publishing, so there is no API token stored anywhere —
CI's own short-lived identity token is exchanged for a PyPI token that is scoped
to this project and expires in minutes. `.gitlab-ci.yml` documents the one-time
PyPI-side setup.

Two things worth knowing:

- **A version can only be published once, and PyPI is public and permanent.**
  PyPI rejects a re-upload of an existing version, so a broken release is fixed
  by tagging the next one, not by replacing the bad one. Yanking hides a release
  from resolvers but does not remove it. This is why CI runs `twine check` first.
- **Untagged builds are visibly untagged.** A working tree between tags builds as
  `0.3.1.devN+g<sha>`, and a dirty one gets a `.dYYYYMMDD` suffix on top — a
  version string that could never have come from PyPI.

After a release, bump the pin in the consumers: `realtyfeed-mcp-server` and
`mlsguard` both pin this package exactly in `requirements.txt` *and*
`pyproject.toml`.

## License

Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
