<!-- generated by epythet -->

# odatadol

Read an OData v4 entity set as a Python `Mapping`, with the standard library only (`urllib`, `json`, `xml.etree`): zero runtime dependencies.

```default
pip install odatadol
```

```python
from odatadol import ODataReader, BasicAuth

store = ODataReader(
    'https://example.com/odata/v4/Shop', 'Products',
    key='ProductID', credentials=BasicAuth('reader', password),
)
len(store)          # one $count request
store['P0042']      # one filtered request
list(store)         # the key column only, in key order
dict(store.items()) # every record, read in bulk
```

It is read-only by construction: every request is a `GET`, and no function takes an HTTP verb.

## The functions under the facade

`ODataReader` is a thin layer over plain functions, each usable on its own. They take the service root and the entity set name, then keyword-only options.

| function                                                                        | does                                                                                                                                                                     |
|---------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `entity_count(service, entity, filter=)`                                        | `$count`, sent with `Accept: */*`                                                                                                                                        |
| `entity_records(service, entity, select=, filter=, orderby=, top=, page_size=)` | records, lazily, paged by `$top`/`$skip` or `@odata.nextLink`, annotations dropped; a read ends on an empty page, so a server that silently caps its pages loses nothing |
| `entity_columns(service, entity, select=, key=, version=, filter=, max_url=)`   | a `$select` too wide for one URL, read as column groups and joined on `key`                                                                                              |
| `entity_metadata(service, entity)`                                              | `$metadata` as `{name: {'type': 'Edm.…', 'nullable': bool, 'annotations': {...}}}`                                                                                       |
| `odata_get(url)`                                                                | the one request everything goes through                                                                                                                                  |
```python
from decimal import Decimal
from odatadol import entity_records, entity_metadata

entity_metadata(service, 'Products')['Price']
# {'type': 'Edm.Decimal', 'nullable': True}

newest = next(entity_records(
    service, 'Products', select=['ProductID', 'ModifiedAt'],
    filter='ModifiedAt ne null', orderby='ModifiedAt desc', top=1,
    parse_float=Decimal,   # keep every digit of a fractional number
))
```

## Seams

Every function takes the same keyword arguments for what a host may need to swap:

- `fetch=(url, headers) -> (status, body bytes)`: the transport. Defaults to `urllib_fetch` (use `functools.partial(urllib_fetch, timeout=...)` to change its timeout). Tests pass `odatadol.testing.FakeService`, a scripted server that records every URL it was asked for.
- `credentials=`: `None` (anonymous), `BasicAuth(account, password)`, `BearerToken(token)`, anything with a `header() -> str` method, or a zero-argument callable returning one of those, so a keychain or file lookup happens only when a request is made. Neither the password nor the `Authorization` header ever appears in an error, and credentials never follow a redirect or a `@odata.nextLink` to another origin (a cross-origin nextLink is refused). A server that would page forever (ignoring `$skip`, or repeating its nextLink) is stopped with an `ODataError`.
- `max_url=` (default 2000) and `page_size=` (default 1000).
- `parse_float=`: applied to every fractional JSON number; `decimal.Decimal` keeps every digit (default `float`).
- `literal=` (on `ODataReader`): writes a key as a `$filter` literal for `store[k]` and `k in store`; defaults to `odata_literal` (str, numbers, bool, `uuid.UUID`, `datetime` objects). For an `Edm.Guid` key that iteration returns as text, pass `literal=str`.

## Wide reads are one moment, or they raise

A front end such as IIS refuses URLs past about 2,048 characters, answering its own 404 as if the entity did not exist. A `$select` of a hundred or more fields is past that, so `entity_columns` (and `ODataReader` with a wide `select=`) splits it into groups that fit `max_url`, reads each in key order, and joins them on the key.

A join of separate reads is only a true record if every read saw the same records in the same state, so:

- `version=` names a property the server rewrites on every edit (a modification timestamp). It is read in every group and must agree per key; otherwise an edit landing between two reads would yield a record that never existed.
- With a single group, a narrow key/version read follows it, because `$skip` paging over a delete before the cursor and an insert after it skips a live record while the count still balances.
- An empty or repeated key is refused (`ODataError`): joining on it would merge different records.
- Reads that disagree raise `UnstableRead` (an `ODataError`), saying how many records were new, gone or edited. The entity set changed during the read; retrying is safe.

## Server quirks it copes with

Each was met against a real server and has a test:

- `$count` with `Accept: text/plain` refused; odatadol sends `*/*`.
- Property names with spaces must be double-quoted in `$select`/`$orderby` (`quote_name`), as FileMaker’s OData requires.
- A percent-encoded `:` inside a `$filter` timestamp is a syntax error; `:` travels bare, as RFC 3986 allows.
- Raw control characters (a bare CR) inside JSON strings; parsed with `json.loads(strict=False)` and kept verbatim.

## Writes

Not here, and not behind a flag on the reader. If writing (POST/PATCH) is ever added it will be a separate, opt-in class with its own transport seam (method, body and response headers, for ETags), so code holding a reader cannot reach a write by passing the wrong argument, and the reader’s `fetch=(url, headers) -> (status, bytes)` stays as it is. A `fetch` may do its own retries (on 429 or 503, say); the reader needs nothing more from it.

<p class="epythet-aggregates">This documentation as a single file: <a href="odatadol.md">odatadol.md</a> (Markdown, for agents).</p>
