Metadata-Version: 2.4
Name: barb-sdk
Version: 1.0.1
Summary: Python SDK for Barb APIs and integrations.
Author: Barb
License-Expression: MIT
Project-URL: Homepage, https://bitbucket.org/barb/pybarb
Project-URL: Documentation, https://bitbucket.org/barb/pybarb/src/main/docs/
Project-URL: Issues, https://bitbucket.org/barb/pybarb/issues
Keywords: sdk,barb,api
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.32.5
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: pandas>=3.0.1
Requires-Dist: pyarrow

# barb-sdk

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

A Python SDK for consuming the [BARB API v3](https://barb-api.co.uk), providing typed wrappers around all
metadata and metrics endpoints, optional Apache Arrow stream support for high-performance data access, and
structured error handling.

---

## Table of Contents

1. [Installation](#installation)
2. [Quick Start](#quick-start)
3. [Project Structure](#project-structure)
4. [Credentials & Configuration](#credentials--configuration)
5. [Connection](#connection)
   - [Standard API-Key Flow](#standard-api-key-flow)
   - [OAuth Browser Flow](#oauth-browser-flow)
   - [OAuth Redirect-URL Flow](#oauth-redirect-url-flow)
   - [Automatic Token Refresh](#automatic-token-refresh)
   - [make\_request — Authenticated HTTP Helper](#make_request--authenticated-http-helper)
6. [Logging](#logging)
7. [Metadata Endpoints](#metadata-endpoints)
   - [Stations](#stations)
   - [Viewing Stations](#viewing-stations)
   - [Panels](#panels)
   - [Split Station Factor](#split-station-factor)
   - [Households](#households)
   - [Panel Members](#panel-members)
   - [Panel Members Arrow](#panel-members-arrow)
   - [Spot Schedule](#spot-schedule)
   - [Spot Schedule Arrow](#spot-schedule-arrow)
   - [Programme Schedule](#programme-schedule)
   - [Target Audience Categories](#target-audience-categories)
   - [Programme Content Details](#programme-content-details)
   - [Transmission Log Programme Details](#transmission-log-programme-details)
8. [Metrics Endpoints](#metrics-endpoints)
   - [Station Audiences](#station-audiences)
   - [Programme Audiences](#programme-audiences)
   - [Spot Impact](#spot-impact)
9. [Error Reference](#error-reference)
   - [Connection Errors](#connection-errors)
   - [HTTP Status Errors](#http-status-errors)
   - [Metadata Endpoint Errors](#metadata-endpoint-errors)
   - [Metrics Endpoint Errors](#metrics-endpoint-errors)
10. [Exception Classes](#exception-classes)
11. [Contributing](#contributing)
12. [License](#license)

---

## Installation

```bash
pip install barb-sdk
```

**Requirements:** Python 3.12+

**Core dependencies:**

| Package         | Version  | Purpose                                  |
|-----------------|----------|------------------------------------------|
| `requests`      | ≥ 2.32.5 | HTTP calls to BARB API                   |
| `pandas`        | ≥ 3.0.1  | DataFrame construction and manipulation  |
| `pyarrow`       | latest   | Apache Arrow IPC stream deserialization  |
| `python-dotenv` | ≥ 1.0.0  | `.env` file support for credentials      |

---

## Quick Start

```python
from pybarb.connection.connection import Connection
from pybarb.metadata.station import Station
from pybarb.metrics.station.station_audiences import StationAudiences
from pybarb.utils import ApiError

# 1. Authenticate
conn = Connection()   # reads BARB_API_KEY from environment or .env file
conn.connect()

# 2. Look up a station code
station_client = Station(conn)
station_code = station_client.get_station_code("BBC1")

# 3. Fetch audience data as a flat DataFrame
sa = StationAudiences(conn)
df = sa.get_station_audiences_flat_dataframe(
    min_transmission_date="2023-07-20",
    max_transmission_date="2023-07-20",
    station_code=station_code,
    panel_code=50,
    time_period_length=15,
    viewing_status="VOSDAL",
)
print(df.head(5).to_string(index=False))
```

---

## Project Structure

```
pybarb/
├── connection/
│   └── connection.py          # API key authentication
├── constants/
│   ├── constants.py           # Column maps & output column lists
│   └── errors.py              # All error message dictionaries
├── metadata/
│   ├── advertisers.py
│   ├── buyers.py
│   ├── households.py
│   ├── panel_members.py
│   ├── panel_members_arrow.py
│   ├── panels.py
│   ├── programme_content_details.py
│   ├── programme_schedule.py
│   ├── split_station_factor.py
│   ├── spot_schedule.py
│   ├── spot_schedule_arrow.py
│   ├── station.py
│   ├── target_audience_categories.py
│   ├── transmission_log_programme_details.py
│   └── viewing_stations.py
├── metrics/
│   ├── spot_impact.py
│   ├── programme/
│   │   └── programme_audiences.py
│   └── station/
│       └── station_audiences.py
└── utils/
    ├── dataframe_utils.py     # to_flat_dataframe helper
    ├── exceptions.py          # ApiError
    └── logging_config.py      # Logging setup
```

---

## Credentials & Configuration

All configuration is read from environment variables or a `.env` file placed in the working directory.
Use `python-dotenv` to load the file automatically on startup (already handled inside `Connection`).

### API-Key Variables

| Variable           | Required | Description                                                               |
|--------------------|----------|---------------------------------------------------------------------------|
| `BARB_API_KEY`     | Yes      | JSON string with `client_id` and `client_secret` fields                   |
| `BARB_API_ROOT`    | No       | Override the default API root URL (e.g. dev / staging APIM gateway URL)   |
| `PYBARB_LOG_LEVEL` | No       | Logging level: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`)      |
| `BARB_LOG_LEVEL`   | No       | Alias for `PYBARB_LOG_LEVEL`                                              |

### OAuth Browser-Flow Variables

Required only when using `connect_via_browser()` or `connect_via_redirect_url()`.

| Variable                    | Required | Default                           | Description                                                                     |
|-----------------------------|----------|-----------------------------------|---------------------------------------------------------------------------------|
| `BARB_OAUTH_AUTHORIZE_URL`  | Yes      | —                                 | Microsoft `/authorize` URL — include the Azure AD tenant ID in the path         |
| `BARB_OAUTH_CLIENT_ID`      | Yes      | —                                 | Azure AD registered application (client) ID for the OAuth app                  |
| `BARB_OAUTH_SCOPE`          | Yes      | —                                 | Space-separated scopes: include `openid offline_access` plus the API scope      |
| `BARB_OAUTH_CODE_CHALLENGE` | Yes      | —                                 | PKCE S256 code challenge value                                                  |
| `BARB_OAUTH_REDIRECT_URI`   | No       | `http://localhost:54321/callback` | Redirect URI — see note below on localhost vs external URLs                     |
| `BARB_OAUTH_TOKEN_URL`      | No       | `{BARB_API_ROOT}/oauth/token`     | Environment-specific OAuth token endpoint                                       |

> **Redirect URI mode selection** (detected automatically from `BARB_OAUTH_REDIRECT_URI`):
>
> | Value | Behaviour |
> |---|---|
> | `http://localhost:<port>/...` | `connect_via_browser()` starts a local HTTP server on that port and captures `?code=` automatically — no user action needed after login. |
> | Any external URL (e.g. `https://www.jwt.io/`) | `connect_via_browser()` opens the browser and then **prompts** you to paste the full redirect URL back into the terminal. |

### Example `.env` file

```dotenv
# ── Standard API-key authentication ────────────────────────────────────────
# BARB_API_ROOT format:  https://apimgt-uks-<environment>-api-3-1.azure-api.net/api/v3/
BARB_API_ROOT=https://apimgt-uks-<environment>-api-3-1.azure-api.net/api/v3/

# BARB_API_KEY: JSON object with client_id and client_secret
BARB_API_KEY={"client_id": "<your-client-id>", "client_secret": "<your-client-secret>"}

# Logging level (optional — defaults to INFO)
PYBARB_LOG_LEVEL=INFO

# ── OAuth browser / redirect-URL flow ──────────────────────────────────────
# Required for connect_via_browser() and connect_via_redirect_url()

# Microsoft Entra ID (Azure AD) authorize endpoint
# Replace <tenant-id> with your Azure AD directory (tenant) ID
BARB_OAUTH_AUTHORIZE_URL=https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize

# Azure AD registered app (client) ID for this OAuth application
BARB_OAUTH_CLIENT_ID=<your-oauth-app-client-id>

# OAuth scopes — include openid + offline_access + the environment-specific API scope
# Scope format:  openid offline_access api://<environment>.barb.co.uk.api.v3.read/user_impersonation
BARB_OAUTH_SCOPE=openid offline_access api://<environment>.barb.co.uk.api.v3.read/user_impersonation

# PKCE S256 code challenge value (generated from your code verifier)
BARB_OAUTH_CODE_CHALLENGE=<your-pkce-code-challenge>

# Redirect URI registered in Azure AD for this app.
# Option A — localhost (automatic redirect capture):
#   BARB_OAUTH_REDIRECT_URI=http://localhost:54321/callback
# Option B — external URL (user pastes redirect URL into terminal):
#   BARB_OAUTH_REDIRECT_URI=https://www.jwt.io/
BARB_OAUTH_REDIRECT_URI=http://localhost:54321/callback

# Token endpoint — environment-specific BARB OAuth token URL
BARB_OAUTH_TOKEN_URL=https://<environment>-api-v3.barb.co.uk/api/v3/oauth/token
```

---

## Connection

`Connection` supports three authentication strategies. All three set `conn.headers` to
`{"Authorization": "Bearer <access_token>"}` and `conn.connected = True` on success.

### Methods overview

| Method                              | Returns             | Description                                                      |
|-------------------------------------|---------------------|------------------------------------------------------------------|
| `connect()`                         | `None`              | Standard API-key token exchange                                  |
| `connect_via_browser()`             | `None`              | OAuth PKCE — opens browser, captures redirect automatically or via prompt |
| `connect_via_redirect_url(url)`     | `None`              | OAuth PKCE — accepts pasted redirect URL, extracts `?code=`     |
| `get_authorization_url()`           | `str`               | Returns the OAuth authorization URL without opening a browser    |
| `ensure_token_valid()`              | `None`              | Proactively refreshes the token if expired or near expiry        |
| `make_request(method, url, **kw)`   | `requests.Response` | Authenticated HTTP call with automatic 401 refresh and retry     |
| `is_token_expired` _(property)_     | `bool`              | `True` if OAuth token has expired or expires within 30 seconds   |

---

### Standard API-Key Flow

Authenticates by POSTing client credentials to `/auth/token/`. All config is read from `.env`.

```python
Connection(api_key: str | dict | None = None, api_root: str = "https://barb-api.co.uk/api/v3/")
```

| Parameter  | Type                     | Default                            | Description                                       |
|------------|--------------------------|------------------------------------| --------------------------------------------------|
| `api_key`  | `str`, `dict`, or `None` | `None` (reads `BARB_API_KEY`)      | Client credentials as JSON string or dict         |
| `api_root` | `str`                    | `"https://barb-api.co.uk/api/v3/"` | Base URL (overridden by `BARB_API_ROOT` env var)  |

```python
from pybarb.connection.connection import Connection

# Using .env (recommended — keeps credentials out of source code)
conn = Connection()
conn.connect()

# Passing credentials directly (e.g. for scripting / testing)
conn = Connection(api_key={"client_id": "<your-id>", "client_secret": "<your-secret>"})
conn.connect()

# Pointing to a specific environment gateway
conn = Connection(api_root="https://apimgt-uks-dev-api-3-1.azure-api.net/api/v3/")
conn.connect()
```

---

### OAuth Browser Flow

Fully automated OAuth 2.0 Authorization Code + PKCE flow. Behaviour depends on
`BARB_OAUTH_REDIRECT_URI`:

#### Option A — Localhost redirect (fully automatic)

Set `BARB_OAUTH_REDIRECT_URI=http://localhost:54321/callback`.

1. Builds the authorization URL from `.env` variables.
2. Opens your default browser to the Microsoft login page.
3. After login, Microsoft redirects to `http://localhost:54321/callback?code=…`.
4. A temporary HTTP server on port `54321` captures `?code=` automatically.
5. Exchanges the code for an access token + refresh token.

```python
conn = Connection()
conn.connect_via_browser()   # browser opens → code captured automatically

from pybarb.metrics.programme.programme_audiences import ProgrammeAudiences
pa = ProgrammeAudiences(conn)
df = pa.get_programme_audiences_flat_dataframe(
    min_transmission_date="2026-05-06",
    max_transmission_date="2026-05-06",
    panel_code=50,
)
```

#### Option B — External redirect URI (prompt-based)

Set `BARB_OAUTH_REDIRECT_URI` to an external URL (e.g. `https://www.jwt.io/`).

Microsoft redirects the browser to that URL with `?code=…` in the query string.
The terminal prompts you to paste the full redirect URL back:

```
STEP 1: Open the URL below in your browser and complete login.
https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize?...

STEP 2: After login, Microsoft will redirect your browser to:
        https://www.jwt.io/

        Copy the ENTIRE URL from your browser's address bar and paste it below.

Paste the full redirect URL here: https://www.jwt.io/?code=0.AXXX...&state=12345
```

```python
conn = Connection()
conn.connect_via_browser()   # opens browser, then prompts for redirect URL
```

> **Required `.env` variables for both options:**
> `BARB_OAUTH_AUTHORIZE_URL`, `BARB_OAUTH_CLIENT_ID`, `BARB_OAUTH_SCOPE`, `BARB_OAUTH_CODE_CHALLENGE`

---

### OAuth Redirect-URL Flow

Use when you already have the full redirect URL (CI/CD, SSH sessions, scripting).

1. Call `get_authorization_url()` to get the Microsoft login URL.
2. Open the URL in any browser and complete login.
3. Copy the full redirect URL from the browser address bar (contains `?code=...`).
4. Pass it to `connect_via_redirect_url()`.

```python
conn = Connection()

# Display the login URL
auth_url = conn.get_authorization_url()
print("Open this URL in your browser:\n", auth_url)

# After login, paste the redirect URL
redirect_url = input("Paste the full redirect URL: ").strip()

# Exchange the code for a token
conn.connect_via_redirect_url(redirect_url)
# conn.headers is now populated
```

> **Note:** An authorization code is single-use. If `connect_via_redirect_url()` raises a
> `400` error, generate a fresh code by calling `get_authorization_url()` again.

---

### Automatic Token Refresh

All OAuth token responses include an `expires_in` field (e.g. `3600` seconds). `Connection`
stores the absolute expiry timestamp and refreshes automatically.

#### `is_token_expired`

```python
if conn.is_token_expired:
    print("Token has expired or expires within 30 seconds.")
```

Returns `True` when the OAuth access token has expired or will expire within a **30-second**
safety buffer. Always `False` for the standard API-key `connect()` flow.

#### `ensure_token_valid()`

Proactively refreshes before a batch of requests.

```python
conn.ensure_token_valid()   # no-op if token is still valid
```

| Scenario                                   | Action                                               |
|--------------------------------------------|------------------------------------------------------|
| Token is valid                             | No-op                                                |
| Token expired + refresh token available    | POSTs `grant_type=refresh_token`, updates `headers`  |
| Token expired + **no** refresh token       | Raises `RuntimeError` — re-authenticate required     |
| Standard API-key flow (no `expires_in`)    | No-op                                                |

> **Tip:** Include `offline_access` in `BARB_OAUTH_SCOPE` to ensure the identity provider
> returns a `refresh_token` in the code-exchange response.

```python
conn = Connection()
conn.connect_via_redirect_url(redirect_url)

# ... time passes, token nears expiry ...

conn.ensure_token_valid()   # silently refreshes if needed

pa = ProgrammeAudiences(conn)
df = pa.get_programme_audiences_flat_dataframe(...)
```

#### Token-expiry demo (live countdown)

`demo_oauth_redirect_url_flow()` in `main.py` demonstrates the full refresh cycle:

```
1. Token obtained via redirect URL flow
2. Token expiry shortened to 35 s (demo only — safe, local timestamp only)
3. Live countdown bar:
     Token expires in:  5.0s  [████░░░░░░░░░░░░░░░░░░░░░░░░░░]  expired=True
4. ensure_token_valid() fires → refresh endpoint called → new token stored
5. Two endpoint calls made with the refreshed token to confirm success
```

---

### `make_request` — Authenticated HTTP Helper

A drop-in replacement for `requests.get` / `requests.post` that injects `Authorization`
and handles token expiry transparently.

```python
response = conn.make_request(method, url, **kwargs)
```

| Parameter  | Type  | Description                                                                  |
|------------|-------|------------------------------------------------------------------------------|
| `method`   | `str` | HTTP method: `"GET"`, `"POST"`, `"PUT"`, etc.                                |
| `url`      | `str` | Full request URL                                                             |
| `**kwargs` | any   | Any `requests.request` keyword arg (`params`, `json`, `timeout`, `headers`, …) |

**Returns:** `requests.Response`

**Auto-refresh behaviour on `401`:**

| Connection type            | Action on 401                                                  |
|----------------------------|----------------------------------------------------------------|
| OAuth flow (refresh token) | Calls refresh endpoint → updates token → retries once         |
| API-key flow               | Calls `connect()` again → updates token → retries once        |

```python
conn = Connection()
conn.connect()   # or connect_via_browser() / connect_via_redirect_url()

# Instead of: requests.get(url, headers=conn.headers, params={"limit": 10})
response = conn.make_request(
    "GET",
    f"{conn.api_root.rstrip('/')}/meta/stations",
    params={"limit": 10},
)

if response.status_code == 200:
    data = response.json()

# Caller-supplied headers are merged with Authorization:
response = conn.make_request(
    "GET",
    url,
    headers={"Accept": "application/json"},
    params={"limit": 100},
    timeout=30,
)
```

---

## Logging

pybarb uses a namespaced logger (`pybarb.*`) that is lazily initialised on first use.

### Configuration

Log level is resolved from environment variables in this order:

1. `PYBARB_LOG_LEVEL`
2. `BARB_LOG_LEVEL`
3. Default: `INFO`

### Log Format

```
2026-05-18 14:21:29,729 | pybarb.metadata.station | INFO | Fetching full station payload
```

### Explicit Setup (optional)

```python
from pybarb.utils.logging_config import setup_logging

setup_logging()  # Applies env-driven log level to the pybarb namespace logger
```

---

## Metadata Endpoints

All metadata classes follow the same pattern:

- Accept a connected `Connection` object in their constructor.
- Raise `ApiError` on validation failures or bad API responses.
- Provide a `get_*()` method returning raw `list[dict]` and a `get_*_flat_dataframe()` method
  returning a normalised `pd.DataFrame`.

---

### Stations

**API endpoint:** `GET /meta/stations`

```python
from pybarb.metadata.station import Station

station_client = Station(conn)
```

| Method                             | Returns                | Description                                         |
|------------------------------------|------------------------|-----------------------------------------------------|
| `get_stations()`                   | `list[dict[str, Any]]` | Returns the full station list                       |
| `list_stations(regex_filter=None)` | `list[str]`            | Returns station names, optionally filtered by regex |
| `get_station_code(station_name)`   | `int \| str`           | Returns the station code for an exact name match    |

```python
stations_data = station_client.get_stations()
bbc_stations  = station_client.list_stations(regex_filter="^BBC")
code          = station_client.get_station_code("BBC1")
```

---

### Viewing Stations

**API endpoint:** `GET /meta/viewing_stations`

```python
from pybarb.metadata.viewing_stations import ViewingStations

vs = ViewingStations(conn)
```

| Method                                     | Returns                | Description                                        |
|--------------------------------------------|------------------------|----------------------------------------------------|
| `get_viewing_stations()`                   | `list[dict[str, Any]]` | Returns all viewing stations                       |
| `list_viewing_stations(regex_filter=None)` | `list[dict[str, Any]]` | Returns stations optionally filtered by name regex |
| `get_viewing_stations_flat_data_frame()`   | `pd.DataFrame`         | Returns all viewing stations as a flat DataFrame   |

```python
df            = vs.get_viewing_stations_flat_data_frame()
bbc1_stations = vs.list_viewing_stations("BBC1 Midlands")
```

---

### Panels

**API endpoint:** `GET /meta/panels`

```python
from pybarb.metadata.panels import Panels

panels_client = Panels(conn)
```

| Method                           | Returns                | Description                                        |
|----------------------------------|------------------------|----------------------------------------------------|
| `get_panels()`                   | `list[dict[str, Any]]` | Returns all panel records                          |
| `list_panels(regex_filter=None)` | `list[dict[str, Any]]` | Returns panels optionally filtered by region regex |
| `get_panel_code(panel_region)`   | `str`                  | Returns the panel code for an exact region match   |

```python
all_panels = panels_client.get_panels()
code       = panels_client.get_panel_code("London - ITV,C4,ITV Breakfast")
```

---

### Split Station Factor

**API endpoint:** `GET /meta/split_station_factor`

```python
from pybarb.metadata.split_station_factor import SplitStationFactor

ssf = SplitStationFactor(conn)
```

| Method                                         | Returns                | Description                                               |
|------------------------------------------------|------------------------|-----------------------------------------------------------|
| `get_split_station_factor()`                   | `list[dict[str, Any]]` | Returns all split station factor records                  |
| `list_split_station_factor(regex_filter=None)` | `list[dict[str, Any]]` | Returns records optionally filtered by station name regex |

```python
data = ssf.list_split_station_factor("ITV Border England")
```

---

### Households

**API endpoint:** `GET /meta/households`

```python
from pybarb.metadata.households import Households

hh = Households(conn)
```

| Method                                                  | Returns        | Description                                                     |
|---------------------------------------------------------|----------------|-----------------------------------------------------------------|
| `get_households(panel_start_date, panel_end_date, ...)`  | `list[dict]`   | Raw household records                                           |
| `get_households_flat_dataframe(panel_start_date, ...)`   | `pd.DataFrame` | Flattened household DataFrame (devices list expanded into rows) |

**Parameters:**

| Parameter                   | Type  | Required | Description                               |
|-----------------------------|-------|----------|-------------------------------------------|
| `panel_start_date`          | `str` | Yes      | Start date in `YYYY-MM-DD` format         |
| `panel_end_date`            | `str` | Yes      | End date in `YYYY-MM-DD` format           |
| `last_updated_greater_than` | `str` | No       | ISO datetime filter for incremental loads |
| `panel_code`                | `str` | No       | Filter by panel code                      |
| `panel_region`              | `str` | No       | Filter by panel region                    |

```python
df = hh.get_households_flat_dataframe(
    panel_start_date="2025-01-01",
    panel_end_date="2025-05-01",
)
print(df.head(5).to_string(index=False))
```

---

### Panel Members

**API endpoint:** `GET /meta/panel_members`

```python
from pybarb.metadata.panel_members import PanelMembers

pm = PanelMembers(conn)
```

| Method                                                     | Returns        | Description                                                          |
|------------------------------------------------------------|----------------|----------------------------------------------------------------------|
| `get_panel_members(panel_start_date, panel_end_date, ...)`  | `list[dict]`   | Raw panel member records                                             |
| `get_panel_members_flat_dataframe(panel_start_date, ...)`   | `pd.DataFrame` | Flattened DataFrame (`panel_member_weights` list expanded into rows) |

**Parameters:**

| Parameter                   | Type  | Required | Description                               |
|-----------------------------|-------|----------|-------------------------------------------|
| `panel_start_date`          | `str` | Yes      | Start date in `YYYY-MM-DD` format         |
| `panel_end_date`            | `str` | Yes      | End date in `YYYY-MM-DD` format           |
| `last_updated_greater_than` | `str` | No       | ISO datetime filter for incremental loads |
| `panel_code`                | `str` | No       | Filter by panel code                      |
| `panel_region`              | `str` | No       | Filter by panel region                    |

```python
df = pm.get_panel_members_flat_dataframe(
    panel_start_date="2025-01-01",
    panel_end_date="2025-01-01",
    panel_code="1",
)
```

---

### Panel Members Arrow

**API endpoint:** `GET /meta/panel_members_new` _(Apache Arrow IPC stream)_

```python
from pybarb.metadata.panel_members_arrow import PanelMembersArrow

pma = PanelMembersArrow(conn)
```

| Method                                                                    | Returns        | Description                     |
|---------------------------------------------------------------------------|----------------|---------------------------------|
| `get_panel_members(panel_start_date, panel_end_date, ...)`                | `bytes`        | Raw Arrow IPC stream bytes      |
| `get_panel_members_flat_dataframe(panel_start_date, panel_end_date, ...)` | `pd.DataFrame` | Decoded and flattened DataFrame |

Accepts the same parameters as `PanelMembers`. The Arrow variant is significantly faster for large date
ranges because it uses a binary columnar format rather than JSON.

```python
df = pma.get_panel_members_flat_dataframe(
    panel_start_date="2024-01-01",
    panel_end_date="2026-02-10",
)
```

---

### Spot Schedule

**API endpoint:** `GET /meta/spot_schedule`

```python
from pybarb.metadata.spot_schedule import SpotSchedule

ss = SpotSchedule(conn)
```

| Method                                                           | Returns        | Description                       |
|------------------------------------------------------------------|----------------|-----------------------------------|
| `get_spot_schedule(min_scheduled_date, max_scheduled_date, ...)` | `list[dict]`   | Raw spot schedule records         |
| `get_spot_schedule_flat_dataframe(min_scheduled_date, ...)`      | `pd.DataFrame` | Flattened spot schedule DataFrame |

**Parameters:**

| Parameter                   | Type  | Required | Description                               |
|-----------------------------|-------|----------|-------------------------------------------|
| `min_scheduled_date`        | `str` | Yes      | Start date in `YYYY-MM-DD` format         |
| `max_scheduled_date`        | `str` | Yes      | End date in `YYYY-MM-DD` format           |
| `station_code`              | `str` | No       | Filter by station code                    |
| `last_updated_greater_than` | `str` | No       | ISO datetime filter for incremental loads |

```python
df = ss.get_spot_schedule_flat_dataframe(
    min_scheduled_date="2025-01-01",
    max_scheduled_date="2025-01-01",
    station_code="30",
)
```

---

### Spot Schedule Arrow

**API endpoint:** `GET /meta/spot_schedule_new` _(Apache Arrow IPC stream)_

```python
from pybarb.metadata.spot_schedule_arrow import SpotScheduleArrow

ssa = SpotScheduleArrow(conn)
```

| Method                                                           | Returns        | Description                     |
|------------------------------------------------------------------|----------------|---------------------------------|
| `get_spot_schedule(min_scheduled_date, max_scheduled_date, ...)` | `bytes`        | Raw Arrow IPC stream bytes      |
| `get_spot_schedule_dataframe(min_scheduled_date, ...)`           | `pd.DataFrame` | Decoded and flattened DataFrame |

Accepts the same parameters as `SpotSchedule`.

```python
df = ssa.get_spot_schedule_dataframe(
    min_scheduled_date="2025-01-01",
    max_scheduled_date="2025-01-01",
    station_code="30",
)
```

---

### Programme Schedule

**API endpoint:** `GET /meta/programme_schedule`

```python
from pybarb.metadata.programme_schedule import ProgrammeSchedule

ps = ProgrammeSchedule(conn)
```

| Method                                                              | Returns        | Description                                                               |
|---------------------------------------------------------------------|----------------|---------------------------------------------------------------------------|
| `get_programme_schedule(max_schedule_date, min_schedule_date, ...)`  | `list[dict]`   | Raw programme schedule records                                            |
| `get_programme_schedule_flat_dataframe(max_schedule_date, ...)`      | `pd.DataFrame` | Flattened schedule DataFrame (`station_schedule` list expanded into rows) |

**Parameters:**

| Parameter                   | Type               | Required | Description                               |
|-----------------------------|--------------------|----------|-------------------------------------------|
| `max_schedule_date`         | `str`              | Yes      | End date in `YYYY-MM-DD` format           |
| `min_schedule_date`         | `str`              | Yes      | Start date in `YYYY-MM-DD` format         |
| `station_code`              | `str \| list[str]` | No       | Single code or list of codes              |
| `last_updated_greater_than` | `str`              | No       | ISO datetime filter for incremental loads |

```python
df = ps.get_programme_schedule_flat_dataframe(
    max_schedule_date="2024-01-01",
    min_schedule_date="2024-01-01",
    station_code=["10", "20"],
)
```

---

### Target Audience Categories

**API endpoint:** `GET /meta/target_audience_categories`

```python
from pybarb.metadata.target_audience_categories import TargetAudienceCategories

tac = TargetAudienceCategories(conn)
```

| Method                                                           | Returns        | Description                    |
|------------------------------------------------------------------|----------------|--------------------------------|
| `get_target_audience_categories(max_date, min_date, panel_code)` | `list[dict]`   | Raw category records           |
| `get_target_audience_categories_flat_dataframe(max_date, ...)`   | `pd.DataFrame` | Flattened categories DataFrame |

**Parameters:**

| Parameter    | Type                             | Required | Description                                    |
|--------------|----------------------------------|----------|------------------------------------------------|
| `max_date`   | `str`                            | Yes      | End date in `YYYY-MM-DD` format                |
| `min_date`   | `str`                            | Yes      | Start date in `YYYY-MM-DD` format              |
| `panel_code` | `int \| str \| list[int \| str]` | Yes      | Up to 10 panel codes (list or comma-separated) |

```python
df = tac.get_target_audience_categories_flat_dataframe(
    max_date="2025-01-01",
    min_date="2025-01-01",
    panel_code=[1, 2, 3, 4, 5],
)
```

---

### Programme Content Details

**API endpoint:** `GET /meta/programme_content_details`

```python
from pybarb.metadata.programme_content_details import ProgrammeContentDetails

pcd = ProgrammeContentDetails(conn)
```

| Method                                          | Parameters                         | Returns      | Description                                |
|-------------------------------------------------|------------------------------------|--------------|--------------------------------------------|
| `list_programme_content_details(search_string)` | `search_string: str` (min 3 chars) | `list[dict]` | Searches programme content details by name |

```python
results = pcd.list_programme_content_details("music")
```

---

### Transmission Log Programme Details

**API endpoint:** `GET /meta/transmission_log_programme_details`

```python
from pybarb.metadata.transmission_log_programme_details import TransmissionLogProgrammeDetails

tlpd = TransmissionLogProgrammeDetails(conn)
```

| Method                                                   | Parameters                         | Returns      | Description                                 |
|----------------------------------------------------------|------------------------------------|--------------|---------------------------------------------|
| `list_transmission_log_programme_details(search_string)` | `search_string: str` (min 3 chars) | `list[dict]` | Searches transmission log programme details |

```python
results = tlpd.list_transmission_log_programme_details("news")
```

---

## Metrics Endpoints

---

### Station Audiences

**API endpoint:** `GET /metrics/station/audiences`

```python
from pybarb.metrics.station.station_audiences import StationAudiences

sa = StationAudiences(conn)
```

| Method                                      | Returns          | Description                                                                    |
|---------------------------------------------|------------------|--------------------------------------------------------------------------------|
| `get_station_audiences(...)`                | `dict[str, Any]` | Raw JSON payload containing the `stations_audiences` list                      |
| `get_station_audiences_flat_dataframe(...)` | `pd.DataFrame`   | Flattened DataFrame (`audience_views` list expanded into rows per time period) |

**Parameters:**

| Parameter                   | Type          | Required | Default | Description                        |
|-----------------------------|---------------|----------|---------|------------------------------------|
| `min_transmission_date`     | `str`         | Yes      | —       | Start date in `YYYY-MM-DD` format  |
| `max_transmission_date`     | `str`         | Yes      | —       | End date in `YYYY-MM-DD` format    |
| `station_code`              | `int \| str`  | Yes      | —       | Station code                       |
| `panel_code`                | `int \| str`  | Yes      | —       | Panel code                         |
| `time_period_length`        | `int \| str`  | Yes      | —       | Time period length in minutes      |
| `viewing_status`            | `str`         | Yes      | —       | e.g. `"VOSDAL"`, `"CONSOLIDATED"` |
| `use_polling_days`          | `bool \| str` | No       | `True`  | Use polling days                   |
| `limit`                     | `int \| str`  | No       | `500`   | Page size limit                    |
| `last_updated_greater_than` | `str`         | No       | `None`  | ISO datetime filter                |

```python
df = sa.get_station_audiences_flat_dataframe(
    min_transmission_date="2023-07-20",
    max_transmission_date="2023-07-20",
    station_code=4934,
    panel_code=50,
    time_period_length=15,
    viewing_status="VOSDAL",
    limit=500,
)
print(df.head(5).to_string(index=False))
```

---

### Programme Audiences

**API endpoint:** `GET /metrics/programme/audiences`

```python
from pybarb.metrics.programme.programme_audiences import ProgrammeAudiences

pa = ProgrammeAudiences(conn)
```

| Method                                        | Returns          | Description                                                                               |
|-----------------------------------------------|------------------|-------------------------------------------------------------------------------------------|
| `get_programme_audiences(...)`                | `dict[str, Any]` | Raw JSON payload containing the `programme_audiences` list                                |
| `get_programme_audiences_flat_dataframe(...)` | `pd.DataFrame`   | Flattened DataFrame (`audience_views` expanded into rows). Empty DataFrame if no results. |

**Parameters:**

| Parameter                   | Type          | Required | Default | Description                        |
|-----------------------------|---------------|----------|---------|------------------------------------|
| `min_transmission_date`     | `str`         | Yes      | —       | Start date in `YYYY-MM-DD` format  |
| `max_transmission_date`     | `str`         | Yes      | —       | End date in `YYYY-MM-DD` format    |
| `panel_code`                | `int \| str`  | Yes      | —       | Panel code                         |
| `consolidated`              | `bool \| str` | No       | `False` | Whether to use consolidated data   |
| `limit`                     | `int \| str`  | No       | `500`   | Page size limit                    |
| `last_updated_greater_than` | `str`         | No       | `None`  | ISO datetime filter                |

```python
df = pa.get_programme_audiences_flat_dataframe(
    min_transmission_date="2026-05-06",
    max_transmission_date="2026-05-06",
    panel_code=50,
    consolidated=False,
    limit=500,
)

if not df.empty:
    print(df.head(5).to_string(index=False))
else:
    print("No data returned for this date range.")
```

---

### Spot Impact

**API endpoint:** `GET /metrics/spots/impacts`

```python
from pybarb.metrics.spot_impact import SpotImpact

si = SpotImpact(conn)
```

| Method                                | Returns                | Description                                                    |
|---------------------------------------|------------------------|----------------------------------------------------------------|
| `get_spot_impact(...)`                | `list[dict[str, Any]]` | Raw list of spot impact event records                          |
| `get_spot_impact_flat_dataframe(...)` | `pd.DataFrame`         | Flattened DataFrame (`audience_views` list expanded into rows) |

**Parameters:**

| Parameter                     | Type   | Required | Default | Description                       |
|-------------------------------|--------|----------|---------|-----------------------------------|
| `min_transmission_date`       | `str`  | Yes      | —       | Start date in `YYYY-MM-DD` format |
| `max_transmission_date`       | `str`  | Yes      | —       | End date in `YYYY-MM-DD` format   |
| `station_code`                | `str`  | No       | `None`  | Filter by station code            |
| `consolidated`                | `bool` | No       | `None`  | Whether to use consolidated data  |
| `limit`                       | `int`  | No       | `None`  | Page size limit                   |
| `is_staggercast_station_code` | `bool` | No       | `None`  | Filter staggercast stations       |

```python
df = si.get_spot_impact_flat_dataframe(
    min_transmission_date="2025-04-12",
    max_transmission_date="2025-04-13",
    station_code="30",
    consolidated=True,
    limit=500,
)
print(df.head(1).to_string(index=False))
```

---

## Error Reference

All client errors raised are instances of `ApiError` (see [Exception Classes](#exception-classes)).
Low-level connection failures raise `RuntimeError`.

---

### Connection Errors

Raised as `RuntimeError` from `Connection.connect()` and the OAuth methods.

#### API-key `connect()` errors

| Error Key                | Message                                                      | Cause                                                 |
|--------------------------|--------------------------------------------------------------|-------------------------------------------------------|
| `api_key_required`       | `API key must be provided via argument or BARB_API_KEY.`     | No API key passed and `BARB_API_KEY` env var not set  |
| `api_key_invalid`        | `API key must be a valid JSON string or dict.`               | `api_key` is not a valid JSON string or dict          |
| `missing_credentials`    | `API key (client_id/client_secret) is missing.`              | Parsed `api_key` dict is empty                        |
| `missing_api_root`       | `API root URL is missing.`                                   | `api_root` resolved to an empty value                 |
| `network_unreachable`    | `Unable to reach Barb API. Check your internet connection.`  | `RequestException` raised during auth                 |
| `network_timeout`        | `Request to Barb API timed out. Please try again.`           | `Timeout` raised during auth                          |
| `invalid_token_response` | `Invalid response from Barb API.`                            | Auth response body was not valid JSON                 |
| `missing_access_token`   | `Authentication response did not include an access token.`   | Auth JSON did not contain `access_token` or `access`  |

#### OAuth browser-flow errors

| Raised by                       | Error / Condition                                                          | Message                                                                         |
|---------------------------------|----------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| `get_authorization_url()`       | Missing required env vars                                                  | `Missing required environment variable(s) for OAuth flow: <names>`             |
| `connect_via_browser()`         | Localhost server received redirect with no `code`                          | `Authorization code extraction failed. Ensure the redirect URI is '...'`       |
| `connect_via_redirect_url(url)` | `url` is blank                                                             | `redirect_url must not be blank.`                                               |
| `connect_via_redirect_url(url)` | `url` has no `code` query param                                            | `The redirect URL does not contain a 'code' query parameter.`                  |
| `ensure_token_valid()`          | Token expired + no refresh token                                           | `OAuth access token has expired and no refresh token is available. Re-authenticate using connect_via_browser() or connect_via_redirect_url().` |
| `_exchange_oauth_refresh_token` | Network error during refresh                                               | `Unable to reach Barb API.` / `Request to Barb API timed out.`                 |
| `_exchange_oauth_refresh_token` | Non-200 response from token endpoint                                       | HTTP status error (see [HTTP Status Errors](#http-status-errors))               |

---

### HTTP Status Errors

Returned when the BARB API responds with a non-200 HTTP status code.  
Format: `"<message> (status <code>)."`.

| Status Code | Message                                   |
|-------------|-------------------------------------------|
| `400`       | `Bad request to Barb API`                 |
| `401`       | `Unauthorized: invalid API credentials`   |
| `403`       | `Forbidden: access denied by Barb API`    |
| `404`       | `Barb API endpoint not found`             |
| `429`       | `Too many requests to Barb API`           |
| `500`       | `Barb API internal server error`          |
| `502`       | `Barb API gateway error`                  |
| `503`       | `Barb API is temporarily unavailable`     |
| `504`       | `Barb API gateway timeout`                |
| _other_     | `Barb API request failed`                 |

---

### Metadata Endpoint Errors

All raised as `ApiError`.

#### General (shared across all metadata endpoints)

| Error Key          | Message                                                       | Cause                                              |
|--------------------|---------------------------------------------------------------|----------------------------------------------------|
| `headers_missing`  | `Connection headers not set. Call connect() first.`           | `.connect()` was not called before making requests |
| `network_error`    | `Unable to fetch <resource>. Check your internet connection.` | Network exception during the API call              |
| `malformed_json`   | `Invalid <resource> response from Barb API.`                  | Response body was not valid JSON                   |
| `payload_not_list` | `Unexpected <resource> payload type: <type>`                  | Response parsed but was not a list                 |
| `no_results`       | `No <resource> returned.`                                     | API returned an empty list                         |

#### Station-specific

| Error Key                  | Message                                    | Cause                                       |
|----------------------------|--------------------------------------------|---------------------------------------------|
| `invalid_station_name`     | `station_name must be a non-empty string.` | Blank or non-string station name passed     |
| `station_not_found`        | `Station name '{name}' not found.`         | No station matched the given name           |
| `station_multiple_matches` | `Multiple stations matched name '{name}'.` | More than one exact match found             |

#### Panel-specific

| Error Key                | Message                                      | Cause                       |
|--------------------------|----------------------------------------------|-----------------------------|
| `panel_region_required`  | `panel_region must be a non-empty string.`   | Blank or non-string region  |
| `panel_not_found`        | `Panel region '{region}' not found.`         | No panel matched the region |
| `panel_multiple_matches` | `Multiple panels matched region '{region}'.` | More than one exact match   |
| `invalid_regex`          | `Invalid regex pattern for panel_region.`    | Regex compilation failed    |

#### Households / Panel Members

| Error Key                   | Message                         | Cause                               |
|-----------------------------|---------------------------------|-------------------------------------|
| `panel_start_date_required` | `panel_start_date is required.` | `panel_start_date` is blank/missing |
| `panel_end_date_required`   | `panel_end_date is required.`   | `panel_end_date` is blank/missing   |

#### Spot Schedule

| Error Key                     | Message                           | Cause                                 |
|-------------------------------|-----------------------------------|---------------------------------------|
| `min_scheduled_date_required` | `min_scheduled_date is required.` | `min_scheduled_date` is blank/missing |
| `max_scheduled_date_required` | `max_scheduled_date is required.` | `max_scheduled_date` is blank/missing |

#### Programme Schedule

| Error Key                    | Message                          | Cause                                |
|------------------------------|----------------------------------|--------------------------------------|
| `min_schedule_date_required` | `min_schedule_date is required.` | `min_schedule_date` is blank/missing |
| `max_schedule_date_required` | `max_schedule_date is required.` | `max_schedule_date` is blank/missing |

#### Target Audience Categories

| Error Key             | Message                                                      | Cause                             |
|-----------------------|--------------------------------------------------------------|-----------------------------------|
| `max_date_required`   | `max_date is required.`                                      | `max_date` is blank/missing       |
| `min_date_required`   | `min_date is required.`                                      | `min_date` is blank/missing       |
| `panel_code_required` | `panel_code is required.`                                    | `panel_code` is `None`            |
| `panel_code_limit`    | `panel_code accepts a maximum of 10 comma separated values.` | More than 10 panel codes provided |

#### Search Endpoints (Programme Content / Transmission Log)

| Error Key                  | Message                                             | Cause                                  |
|----------------------------|-----------------------------------------------------|----------------------------------------|
| `search_string_required`   | `search_string may not be blank.`                   | Empty or whitespace-only search string |
| `search_string_min_length` | `search_string must be at least 3 characters long.` | Search string shorter than 3 chars     |

---

### Metrics Endpoint Errors

Applies to `StationAudiences`, `ProgrammeAudiences`, and `SpotImpact`.

| Error Key                        | Message                                                       | Cause                                    |
|----------------------------------|---------------------------------------------------------------|------------------------------------------|
| `headers_missing`                | `Connection headers not set. Call connect() first.`           | `connect()` not called                   |
| `min_transmission_date_required` | `min_transmission_date is required.`                          | `min_transmission_date` is blank/missing |
| `max_transmission_date_required` | `max_transmission_date is required.`                          | `max_transmission_date` is blank/missing |
| `station_code_required`          | `station_code is required.`                                   | `station_code` is `None` or empty        |
| `panel_code_required`            | `panel_code is required.`                                     | `panel_code` is `None` or empty          |
| `network_error`                  | `Unable to fetch <resource>. Check your internet connection.` | Network exception during the API call    |
| `malformed_json`                 | `Invalid <resource> response from Barb API.`                  | Response body was not valid JSON         |
| `payload_not_dict`               | `Unexpected <resource> payload type: <type>`                  | Response was not a dict                  |
| `no_results`                     | `No <resource> returned.`                                     | API returned no records                  |

---

## Exception Classes

### `ApiError`

```python
from pybarb.utils import ApiError
```

Custom exception raised for all BARB API business-logic and validation failures.

| Attribute       | Type          | Description                                   |
|-----------------|---------------|-----------------------------------------------|
| `message`       | `str`         | Human-readable error description              |
| `status_code`   | `int \| None` | HTTP status code if caused by an HTTP error   |
| `response_body` | `str \| None` | Raw API response body for debugging           |

### Full error handling example

```python
from pybarb.connection.connection import Connection
from pybarb.metadata.station import Station
from pybarb.metrics.station.station_audiences import StationAudiences
from pybarb.utils import ApiError

try:
    conn = Connection()
    conn.connect()

    station_client = Station(conn)
    code = station_client.get_station_code("BBC1")

    sa = StationAudiences(conn)
    df = sa.get_station_audiences_flat_dataframe(
        min_transmission_date="2023-07-20",
        max_transmission_date="2023-07-20",
        station_code=code,
        panel_code=50,
        time_period_length=15,
        viewing_status="VOSDAL",
    )
    print(df.head(5).to_string(index=False))

except ApiError as e:
    print(f"API error [{e.status_code}]: {e}")
    if e.response_body:
        print(f"Response body: {e.response_body}")
except RuntimeError as e:
    print(f"Connection error: {e}")
```

---

## Contributing

Contributions are welcome. Please open an issue or pull request on
[Bitbucket](https://bitbucket.org/barb/pybarb).

Before submitting a pull request:

1. Install dev dependencies: `pip install -r dev-requirements.txt`
2. Run the test suite: `python -m pytest`
3. Check coverage: `python -m coverage run --source=pybarb -m pytest && python -m coverage report`
4. Lint and format: `ruff check src/ && black src/`

---

## License

This project is licensed under the [MIT License](https://opensource.org/licenses/MIT).

