Skip to content

Commit 628da25

Browse files
committed
Initial commit: dlt source for the UniRate API
dlt (dltHub) source exposing three free-tier resources — exchange_rates, currencies, and vat_rates — plus an optional Pro-gated historical_exchange_rates resource. Thin requests-backed client with full UniRate error mapping. 23 mock tests (pytest + requests-mock); ruff + ruff-format + mypy clean; CI matrix Python 3.10–3.13; PyPI publish via OIDC Trusted Publisher.
0 parents  commit 628da25

14 files changed

Lines changed: 1040 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags: ["v*.*.*"]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
outputs:
15+
version: ${{ steps.version.outputs.version }}
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Install uv
20+
uses: astral-sh/setup-uv@v3
21+
22+
- name: Set up Python
23+
run: uv python install 3.12
24+
25+
- name: Verify tag matches package version
26+
id: version
27+
run: |
28+
PKG_VERSION=$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "(.*)"/\1/')
29+
TAG_VERSION=${GITHUB_REF#refs/tags/v}
30+
echo "package version: $PKG_VERSION"
31+
echo "tag version: $TAG_VERSION"
32+
if [ "${{ github.event_name }}" = "push" ] && [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
33+
echo "Tag $TAG_VERSION does not match pyproject.toml version $PKG_VERSION" >&2
34+
exit 1
35+
fi
36+
echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT"
37+
38+
- name: Build sdist + wheel
39+
run: uv build
40+
41+
- name: Upload build artifacts
42+
uses: actions/upload-artifact@v4
43+
with:
44+
name: dist
45+
path: dist/
46+
47+
publish:
48+
needs: build
49+
runs-on: ubuntu-latest
50+
environment:
51+
name: pypi
52+
url: https://pypi.org/p/dlt-unirate
53+
permissions:
54+
id-token: write # Required for PyPI Trusted Publisher (OIDC)
55+
steps:
56+
- name: Download build artifacts
57+
uses: actions/download-artifact@v4
58+
with:
59+
name: dist
60+
path: dist/
61+
62+
- name: Publish to PyPI
63+
uses: pypa/gh-action-pypi-publish@release/v1

.github/workflows/test.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
runs-on: ubuntu-latest
15+
strategy:
16+
fail-fast: false
17+
matrix:
18+
python-version: ["3.10", "3.11", "3.12", "3.13"]
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: Install uv
23+
uses: astral-sh/setup-uv@v3
24+
25+
- name: Set up Python ${{ matrix.python-version }}
26+
run: uv python install ${{ matrix.python-version }}
27+
28+
- name: Install dependencies
29+
run: uv sync --all-groups --python ${{ matrix.python-version }}
30+
31+
- name: Lint
32+
run: |
33+
uv run --group lint ruff check dlt_unirate tests examples
34+
uv run --group lint ruff format dlt_unirate tests examples --diff
35+
36+
- name: Type check
37+
run: uv run --group typing mypy dlt_unirate tests examples
38+
39+
- name: Run unit tests
40+
run: uv run --group test pytest tests/ -v

.gitignore

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*.egg-info/
5+
.eggs/
6+
build/
7+
dist/
8+
.venv/
9+
venv/
10+
11+
# Tooling caches
12+
.mypy_cache/
13+
.ruff_cache/
14+
.pytest_cache/
15+
16+
# dlt runtime artifacts
17+
*.duckdb
18+
*.duckdb.wal
19+
.dlt/secrets.toml
20+
~/.dlt/
21+
22+
# Lockfile is generated in-container; keep out of the tree until a real publish.
23+
uv.lock

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Unirate Team
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# dlt-unirate
2+
3+
[![PyPI](https://img.shields.io/pypi/v/dlt-unirate.svg)](https://pypi.org/project/dlt-unirate/)
4+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5+
6+
A [dlt](https://dlthub.com) source for the [UniRate API](https://unirateapi.com)
7+
— load live currency exchange rates, the supported-currency list, and
8+
per-country VAT rates into any dlt destination (DuckDB, BigQuery, Snowflake,
9+
Postgres, filesystem, …).
10+
11+
UniRate provides 593+ fiat, crypto, and commodity exchange rates. Latest rates,
12+
currencies, and VAT rates are available on the free tier; historical rates
13+
require a Pro plan.
14+
15+
## Why this package
16+
17+
dlt has no built-in FX source, so people hand-roll a `requests` loop or a
18+
`rest_api` config every time they need exchange rates in a warehouse. This
19+
package gives you a typed, tested `@dlt.source` with three resources you can
20+
drop straight into a pipeline.
21+
22+
## Install
23+
24+
```bash
25+
pip install dlt-unirate
26+
```
27+
28+
This pulls in `dlt` and `requests`. Add a destination extra for your target,
29+
e.g. `pip install "dlt[duckdb]"`.
30+
31+
## Quick start
32+
33+
```python
34+
import dlt
35+
from dlt_unirate import unirate_source
36+
37+
pipeline = dlt.pipeline(
38+
pipeline_name="unirate",
39+
destination="duckdb",
40+
dataset_name="unirate_data",
41+
)
42+
43+
# api_key resolves from dlt secrets / the UNIRATE_API_KEY env var,
44+
# or pass it explicitly: unirate_source(api_key="...").
45+
load_info = pipeline.run(unirate_source(base_currency="USD"))
46+
print(load_info)
47+
```
48+
49+
That loads three tables: `exchange_rates`, `currencies`, and `vat_rates`.
50+
51+
## Configuring the API key
52+
53+
`unirate_source` follows dlt's config/secrets convention. Any of these work:
54+
55+
- **Env var:** `export UNIRATE_API_KEY="..."`
56+
- **dlt secrets**`.dlt/secrets.toml`:
57+
58+
```toml
59+
[sources.unirate]
60+
api_key = "..."
61+
```
62+
- **Explicit argument:** `unirate_source(api_key="...")`
63+
64+
Get a free key at [unirateapi.com](https://unirateapi.com).
65+
66+
## Resources
67+
68+
| Resource | Table columns | Notes |
69+
|---|---|---|
70+
| `exchange_rates` | `base_currency`, `target_currency`, `rate` | Every current rate for `base_currency`. `write_disposition="replace"`. |
71+
| `currencies` | `currency_code` | All supported currency codes. `replace`. |
72+
| `vat_rates` | `country_code`, `country_name`, `vat_rate` | Per-country VAT rates. `replace`. |
73+
| `historical_exchange_rates` | `date`, `base_currency`, `target_currency`, `rate` | **Pro-gated** — off by default. `merge` on `(date, base, target)`. |
74+
75+
## Source parameters
76+
77+
| Parameter | Default | Description |
78+
|---|---|---|
79+
| `api_key` | dlt secret / `UNIRATE_API_KEY` | UniRate API key. |
80+
| `base_currency` | `"USD"` | Base for `exchange_rates` (and historical). |
81+
| `base_url` | `https://api.unirateapi.com` | API base URL. |
82+
| `request_timeout` | `30` | Per-request timeout, seconds. |
83+
| `include_historical` | `False` | Also yield the Pro-gated historical resource. |
84+
| `historical_dates` | `None` | `YYYY-MM-DD` dates to pull historical rates for. |
85+
86+
### Historical rates (Pro)
87+
88+
```python
89+
source = unirate_source(
90+
base_currency="EUR",
91+
include_historical=True,
92+
historical_dates=["2024-01-01", "2024-02-01"],
93+
)
94+
pipeline.run(source)
95+
```
96+
97+
Historical endpoints return HTTP 403 on the free tier (surfaced as
98+
`ProSubscriptionError`); a UniRate Pro plan is required.
99+
100+
## Selecting a subset of resources
101+
102+
```python
103+
# Only load exchange rates and currencies.
104+
pipeline.run(unirate_source().with_resources("exchange_rates", "currencies"))
105+
```
106+
107+
## Error handling
108+
109+
Errors raised by the underlying client all inherit from `UniRateError`:
110+
111+
| HTTP | Exception |
112+
|---|---|
113+
| 401 | `AuthenticationError` |
114+
| 403 | `ProSubscriptionError` |
115+
| 404 | `InvalidCurrencyError` |
116+
| 429 | `RateLimitError` |
117+
| other non-2xx | `APIError` (carries `status_code`) |
118+
| network / transport | `UniRateError` |
119+
120+
## Related UniRate clients
121+
122+
If you want to call the API directly, there are official clients in
123+
[Python](https://github.com/UniRate-API/unirate-api-python),
124+
[Node.js](https://github.com/UniRate-API/unirate-api-nodejs),
125+
[Go](https://github.com/UniRate-API/unirate-api-go),
126+
[Rust](https://github.com/UniRate-API/unirate-api-rust),
127+
[Java](https://github.com/UniRate-API/unirate-api-java),
128+
[Ruby](https://github.com/UniRate-API/unirate-api-ruby),
129+
[PHP](https://github.com/UniRate-API/unirate-api-php),
130+
[.NET](https://github.com/UniRate-API/unirate-api-dotnet), and
131+
[Swift](https://github.com/UniRate-API/unirate-api-swift), plus data-stack
132+
integrations for [dbt](https://github.com/UniRate-API/dbt-unirate),
133+
[Airflow](https://github.com/UniRate-API/airflow-provider-unirate), and
134+
[LangChain](https://github.com/UniRate-API/langchain-unirate).
135+
136+
## License
137+
138+
MIT — see [LICENSE](LICENSE).

dlt_unirate/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""dlt source for the UniRate currency-exchange API."""
2+
3+
from dlt_unirate.client import (
4+
APIError,
5+
AuthenticationError,
6+
InvalidCurrencyError,
7+
ProSubscriptionError,
8+
RateLimitError,
9+
UniRateClient,
10+
UniRateError,
11+
)
12+
from dlt_unirate.source import unirate_source
13+
14+
__all__ = [
15+
"APIError",
16+
"AuthenticationError",
17+
"InvalidCurrencyError",
18+
"ProSubscriptionError",
19+
"RateLimitError",
20+
"UniRateClient",
21+
"UniRateError",
22+
"unirate_source",
23+
]
24+
25+
__version__ = "0.1.0"

0 commit comments

Comments
 (0)