[V5] Feature: Add JODI World Oil & Gas Database (#7593)

* add the JODI oil and gas database as a provider/router extension

* lint fixes

* add grouping to apps.json

---------

Co-authored-by: deeleeramone <>
This commit is contained in:
Danglewood
2026-09-23 12:05:12 -07:00
committed by GitHub
parent 1bace8672c
commit b215350687
46 changed files with 17453 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
name: 📦 publish openbb-jodi
# Triggers exclusively on the merge of a ``release/openbb-jodi-v*``
# branch into ``develop`` (or ``v5``). No push / tag triggers. Four
# sequential jobs — verify the version bump, build once, test the built
# source across the full cross-platform matrix, and only publish to PyPI
# if the entire matrix is green.
on:
pull_request:
types: [closed]
branches: [develop, v5]
workflow_dispatch:
permissions:
contents: read
jobs:
version-check:
name: Verify version bump
if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/openbb-jodi-v')) || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.parse.outputs.version }}
defaults:
run:
shell: bash
working-directory: openbb_platform/providers/jodi
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.ref || 'develop' }}
- name: Parse [project].version from pyproject.toml
id: parse
run: |
VERSION=$(grep -m1 -E '^version[[:space:]]*=' pyproject.toml | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')
if [ -z "$VERSION" ]; then
echo "::error::Could not parse [project].version from pyproject.toml."
exit 1
fi
echo "Local version: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Fail if version already exists on PyPI
env:
VERSION: ${{ steps.parse.outputs.version }}
run: |
STATUS=$(curl -sS -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/openbb-jodi/${VERSION}/json")
case "$STATUS" in
200)
echo "::error::openbb-jodi ${VERSION} is already published on PyPI."
echo "Bump openbb_platform/providers/jodi/pyproject.toml [project].version and re-merge a fresh release branch."
exit 1
;;
404)
echo "openbb-jodi ${VERSION} is not yet on PyPI — proceeding."
;;
*)
echo "::error::Unexpected status $STATUS from PyPI JSON API. Aborting to avoid a silent skip."
exit 1
;;
esac
- name: Verify release branch name matches pyproject version
if: github.event_name == 'pull_request'
env:
VERSION: ${{ steps.parse.outputs.version }}
BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
EXPECTED="release/openbb-jodi-v${VERSION}"
if [ "$BRANCH" != "$EXPECTED" ]; then
echo "::error::Release branch '${BRANCH}' does not match pyproject version '${VERSION}'."
echo "Expected branch name: '${EXPECTED}'."
echo "Either rename the release branch or update [project].version in pyproject.toml so they agree."
exit 1
fi
echo "Branch '${BRANCH}' matches pyproject version '${VERSION}'."
build:
name: Build sdist + wheel
needs: version-check
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/providers/jodi
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.ref || 'develop' }}
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip uv
- name: Build sdist + wheel
run: uv build --no-sources
- uses: actions/upload-artifact@v4
with:
name: openbb-jodi-dist
path: openbb_platform/providers/jodi/dist/
if-no-files-found: error
retention-days: 7
test:
name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }})
needs: build
strategy:
fail-fast: false
matrix:
include:
# Linux: full sweep across every released Python.
- { os: ubuntu-latest, python_version: "3.10" }
- { os: ubuntu-latest, python_version: "3.11" }
- { os: ubuntu-latest, python_version: "3.12" }
- { os: ubuntu-latest, python_version: "3.13" }
- { os: ubuntu-latest, python_version: "3.14" }
# macOS + Windows: min and max supported.
- { os: macos-latest, python_version: "3.10" }
- { os: macos-latest, python_version: "3.14" }
- { os: windows-latest, python_version: "3.10" }
- { os: windows-latest, python_version: "3.14" }
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
working-directory: openbb_platform/providers/jodi
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.ref || 'develop' }}
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python_version }}
allow-prereleases: true
- run: python -m pip install --upgrade pip uv
- name: Install openbb-jodi from source (+ dev group)
run: |
uv pip install --system -e .
uv pip install --system --group dev
- name: Run unit tests
run: pytest tests --cov=openbb_jodi --cov-report=term-missing
publish:
name: Publish to PyPI
needs: test
runs-on: ubuntu-latest
permissions:
id-token: write # required for PyPI OIDC trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
name: openbb-jodi-dist
path: dist
- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist

View File

@@ -0,0 +1,130 @@
name: 🧪 test openbb-jodi
# PR-test for the JODI provider. Triggers when a PR touches any file
# under ``openbb_platform/providers/jodi/`` or this workflow itself.
#
# Build-once, test-many: a single ``build`` job produces the wheel + sdist;
# the ``lint`` and ``test`` jobs install the provider editable from source
# (``[tool.uv.sources]`` pins openbb-core to the sibling tree, since
# openbb-core>=2.0.0 is not yet on PyPI) and run across every supported
# (OS, Python) combination. The provider has no build-time assets, so the
# test suite runs entirely from committed fixture samples and VCR
# cassettes — no network in CI.
on:
pull_request:
branches: [develop, main, v5, feature/v5-platform-api]
paths:
- "openbb_platform/providers/jodi/**"
- ".github/workflows/test-unit-openbb-jodi.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
# Cancel in-flight matrix runs when a new commit lands on the same PR.
group: test-openbb-jodi-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build sdist + wheel
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/providers/jodi
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip uv
- name: Build sdist + wheel
run: uv build --no-sources
- uses: actions/upload-artifact@v4
with:
name: openbb-jodi-dist
path: openbb_platform/providers/jodi/dist/
if-no-files-found: error
retention-days: 7
lint:
name: Lint (ruff + ty)
needs: build
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/providers/jodi
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip uv
- name: Install openbb-jodi in editable mode (+ dev group)
# ``-e .`` reads ``[tool.uv.sources]`` and resolves openbb-core /
# openbb-devtools from the sibling source trees. ``--group dev``
# pulls in ruff, ty, pytest, pytest-cov, pytest-recorder, etc.
run: |
uv pip install --system -e .
uv pip install --system --group dev
- name: ruff check
run: ruff check openbb_jodi tests integration
- name: ruff format --check
run: ruff format --check openbb_jodi tests integration
- name: ty check
run: ty check openbb_jodi
test:
name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }})
needs: build
strategy:
fail-fast: false
matrix:
include:
# Linux: full sweep across every released Python.
- { os: ubuntu-latest, python_version: "3.10" }
- { os: ubuntu-latest, python_version: "3.11" }
- { os: ubuntu-latest, python_version: "3.12" }
- { os: ubuntu-latest, python_version: "3.13" }
- { os: ubuntu-latest, python_version: "3.14" }
# macOS + Windows: min and max supported.
- { os: macos-latest, python_version: "3.10" }
- { os: macos-latest, python_version: "3.14" }
- { os: windows-latest, python_version: "3.10" }
- { os: windows-latest, python_version: "3.14" }
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
working-directory: openbb_platform/providers/jodi
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python_version }}
allow-prereleases: true
- run: python -m pip install --upgrade pip uv
- name: Install openbb-jodi from source (+ dev group)
run: |
uv pip install --system -e .
uv pip install --system --group dev
- name: Run unit tests
run: pytest tests --cov=openbb_jodi --cov-report=term-missing --cov-fail-under=100

View File

@@ -0,0 +1,46 @@
# OpenBB JODI Provider
This extension integrates the [Joint Organisations Data Initiative (JODI)](https://www.jodidata.org) data provider into the OpenBB Platform.
JODI publishes free monthly petroleum and natural gas supply, demand, and stock statistics, self-reported by over 100 countries, through the JODI-Oil and JODI-Gas World Databases.
## Installation
To install the extension:
```bash
pip install openbb-jodi
```
## Endpoints
Each endpoint returns one presentation-ready view of the raw data, as a date-indexed table, with the series assessments and units in the results metadata.
### `obb.jodi.oil` — JODI-Oil World Database (monthly, from January 2002)
- `balance` — one country and product; one column per flow of the JODI questionnaire (production, trade, stocks, refinery activity).
- `production` — one primary product; one column per reporting country.
- `demand` — one refined product; one column per reporting country.
- `demand_by_product` — one country; one column per refined product (the demand barrel).
- `imports` / `exports` — one product; one column per reporting country.
- `stocks` — closing stock levels of one product; one column per reporting country.
Units: kb/d, kbbl, kl, kmt, and the barrels/kmt conversion factor.
### `obb.jodi.gas` — JODI-Gas World Database (monthly, from January 2009)
- `balance` — one country; one column per flow of the JODI questionnaire.
- `production` — one column per reporting country.
- `demand` — gross inland deliveries (observed or calculated); one column per reporting country.
- `imports` / `exports` — total, pipeline, or LNG; one column per reporting country.
- `stocks` — closing stock levels; one column per reporting country.
Units: million m3, TJ, and kmt of LNG.
## OpenBB Workspace
The package bundles a "JODI Oil & Gas" app template (`openbb_jodi/assets/apps.json`), served at `/api/v1/jodi/apps.json` and folded into the Workspace app catalogue automatically when running `openbb-api`. It has three dashboards - Country Balances (oil balance, demand barrel, and gas balance, synchronized on one country), Oil Markets, and Gas Markets.
No API key is required. Source tables are cached under the OpenBB user cache directory, partitioned by product and flow; a table is downloaded again only when JODI publishes an update.
Documentation available [here](https://docs.openbb.co/platform/developer_guide/contributing).

View File

@@ -0,0 +1 @@
"""The JODI provider."""

View File

@@ -0,0 +1 @@
"""OpenBB JODI Provider Integration Tests."""

View File

@@ -0,0 +1,53 @@
"""Integration-test fixtures for openbb-jodi.
The API-interface tests (``test_jodi_api.py``) need a live HTTP server.
This module runs ``openbb_core.api.rest_api:app`` in a background thread for
the duration of the integration session and exposes the auth ``headers``.
"""
import base64
import threading
import time
import pytest
@pytest.fixture(scope="session")
def api_server(pytestconfig):
"""Run the OpenBB REST API on localhost:8000 for the integration session."""
if pytestconfig.getoption("markexpr") == "not integration":
yield None
return
import uvicorn
from openbb_core.api.rest_api import app
config = uvicorn.Config(app, host="127.0.0.1", port=8000, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 60
while time.time() < deadline:
if server.started:
break
time.sleep(0.25)
else: # pragma: no cover
raise RuntimeError("The OpenBB REST API did not start within 60s.")
yield server
server.should_exit = True
thread.join(timeout=15)
@pytest.fixture(scope="session")
def headers(api_server):
"""HTTP Basic auth headers; depends on api_server so the server is live."""
from openbb_core.env import Env
userpass = f"{Env().API_USERNAME}:{Env().API_PASSWORD}"
userpass_bytes = userpass.encode("ascii")
base64_bytes = base64.b64encode(userpass_bytes)
return {"Authorization": f"Basic {base64_bytes.decode('ascii')}"}

View File

@@ -0,0 +1,322 @@
"""Test the JODI API endpoints."""
import pytest
import requests
from openbb_core.provider.utils.helpers import get_querystring
BASE_URL = "http://localhost:8000/api/v1/jodi"
def run(path: str, params: dict, headers) -> None:
"""Run one endpoint and make the common assertions."""
params = {p: v for p, v in params.items() if v is not None}
query_str = get_querystring(params, [])
result = requests.get(
f"{BASE_URL}/{path}?{query_str}", headers=headers, timeout=600
)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params",
[
(
{
"country": None,
"product": None,
"unit": None,
"start_date": None,
"end_date": None,
"provider": "jodi",
"use_cache": True,
}
),
(
{
"country": "india",
"product": "gasoline",
"unit": "ktons",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_balance(params, headers):
"""Test the oil balance endpoint."""
run("oil/balance", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "saudi_arabia,iraq,united_states",
"product": "crude_oil",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_production(params, headers):
"""Test the oil production endpoint."""
run("oil/production", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "united_states,china,india",
"product": "gasoline",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_demand(params, headers):
"""Test the oil demand endpoint."""
run("oil/demand", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "japan",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_demand_by_product(params, headers):
"""Test the oil demand by product endpoint."""
run("oil/demand_by_product", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "china,india,south_korea",
"product": "crude_oil",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_imports(params, headers):
"""Test the oil imports endpoint."""
run("oil/imports", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "saudi_arabia,united_arab_emirates",
"product": "crude_oil",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_exports(params, headers):
"""Test the oil exports endpoint."""
run("oil/exports", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "united_states,japan",
"product": "crude_oil",
"unit": "kbbl",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_stocks(params, headers):
"""Test the oil stocks endpoint."""
run("oil/stocks", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": None,
"unit": None,
"start_date": None,
"end_date": None,
"provider": "jodi",
"use_cache": True,
}
),
(
{
"country": "norway",
"unit": "tj",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_balance(params, headers):
"""Test the gas balance endpoint."""
run("gas/balance", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "united_states,qatar,norway",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_production(params, headers):
"""Test the gas production endpoint."""
run("gas/production", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "germany,france,italy",
"measure": "observed",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_demand(params, headers):
"""Test the gas demand endpoint."""
run("gas/demand", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "japan,china,south_korea",
"flow": "lng",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_imports(params, headers):
"""Test the gas imports endpoint."""
run("gas/imports", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "united_states,qatar,australia",
"flow": "lng",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_exports(params, headers):
"""Test the gas exports endpoint."""
run("gas/exports", params, headers)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "germany,france,netherlands",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_stocks(params, headers):
"""Test the gas stocks endpoint."""
run("gas/stocks", params, headers)

View File

@@ -0,0 +1,342 @@
"""Test the JODI extension through the Python interface."""
import pytest
from openbb_core.app.model.obbject import OBBject
@pytest.fixture(scope="session")
def obb(pytestconfig):
"""Fixture to setup obb."""
if pytestconfig.getoption("markexpr") != "not integration":
import openbb
return openbb.obb
pytest.skip("Integration tests are not enabled.")
def run(obb, path, params):
"""Run one endpoint and make the common assertions."""
params = {p: v for p, v in params.items() if v is not None}
func = obb.jodi
for part in path.split("."):
func = getattr(func, part)
result = func(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.extra["results_metadata"]["source"]
@pytest.mark.parametrize(
"params",
[
(
{
"country": None,
"product": None,
"unit": None,
"start_date": None,
"end_date": None,
"provider": "jodi",
"use_cache": True,
}
),
(
{
"country": "india",
"product": "gasoline",
"unit": "ktons",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_balance(params, obb):
"""Test the oil balance endpoint."""
run(obb, "oil.balance", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": None,
"product": None,
"unit": None,
"start_date": None,
"end_date": None,
"provider": "jodi",
"use_cache": True,
}
),
(
{
"country": "saudi_arabia,iraq,united_states",
"product": "crude_oil",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_production(params, obb):
"""Test the oil production endpoint."""
run(obb, "oil.production", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "united_states,china,india",
"product": "gasoline",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_demand(params, obb):
"""Test the oil demand endpoint."""
run(obb, "oil.demand", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "japan",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_demand_by_product(params, obb):
"""Test the oil demand by product endpoint."""
run(obb, "oil.demand_by_product", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "china,india,south_korea",
"product": "crude_oil",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_imports(params, obb):
"""Test the oil imports endpoint."""
run(obb, "oil.imports", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "saudi_arabia,united_arab_emirates",
"product": "crude_oil",
"unit": "kbd",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_exports(params, obb):
"""Test the oil exports endpoint."""
run(obb, "oil.exports", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "united_states,japan",
"product": "crude_oil",
"unit": "kbbl",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_oil_stocks(params, obb):
"""Test the oil stocks endpoint."""
run(obb, "oil.stocks", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": None,
"unit": None,
"start_date": None,
"end_date": None,
"provider": "jodi",
"use_cache": True,
}
),
(
{
"country": "norway",
"unit": "tj",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_balance(params, obb):
"""Test the gas balance endpoint."""
run(obb, "gas.balance", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "united_states,qatar,norway",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_production(params, obb):
"""Test the gas production endpoint."""
run(obb, "gas.production", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "germany,france,italy",
"measure": "observed",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_demand(params, obb):
"""Test the gas demand endpoint."""
run(obb, "gas.demand", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "japan,china,south_korea",
"flow": "lng",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_imports(params, obb):
"""Test the gas imports endpoint."""
run(obb, "gas.imports", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "united_states,qatar,australia",
"flow": "lng",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_exports(params, obb):
"""Test the gas exports endpoint."""
run(obb, "gas.exports", params)
@pytest.mark.parametrize(
"params",
[
(
{
"country": "germany,france,netherlands",
"unit": "m3",
"start_date": "2023-01-01",
"end_date": "2023-12-31",
"provider": "jodi",
"use_cache": True,
}
),
],
)
@pytest.mark.integration
def test_jodi_gas_stocks(params, obb):
"""Test the gas stocks endpoint."""
run(obb, "gas.stocks", params)

View File

@@ -0,0 +1,42 @@
"""OpenBB JODI Provider Module."""
from openbb_core.provider.abstract.provider import Provider
from openbb_jodi.models.gas_balance import JodiGasBalanceFetcher
from openbb_jodi.models.gas_demand import JodiGasDemandFetcher
from openbb_jodi.models.gas_exports import JodiGasExportsFetcher
from openbb_jodi.models.gas_imports import JodiGasImportsFetcher
from openbb_jodi.models.gas_production import JodiGasProductionFetcher
from openbb_jodi.models.gas_stocks import JodiGasStocksFetcher
from openbb_jodi.models.oil_balance import JodiOilBalanceFetcher
from openbb_jodi.models.oil_demand import JodiOilDemandFetcher
from openbb_jodi.models.oil_demand_by_product import JodiOilDemandByProductFetcher
from openbb_jodi.models.oil_exports import JodiOilExportsFetcher
from openbb_jodi.models.oil_imports import JodiOilImportsFetcher
from openbb_jodi.models.oil_production import JodiOilProductionFetcher
from openbb_jodi.models.oil_stocks import JodiOilStocksFetcher
jodi_provider = Provider(
name="jodi",
website="https://www.jodidata.org",
description="The Joint Organisations Data Initiative (JODI) provides free monthly"
+ " petroleum and natural gas supply, demand, and stock statistics,"
+ " self-reported by over 100 countries, through the JODI-Oil and JODI-Gas"
+ " World Databases.",
fetcher_dict={
"JodiGasBalance": JodiGasBalanceFetcher,
"JodiGasDemand": JodiGasDemandFetcher,
"JodiGasExports": JodiGasExportsFetcher,
"JodiGasImports": JodiGasImportsFetcher,
"JodiGasProduction": JodiGasProductionFetcher,
"JodiGasStocks": JodiGasStocksFetcher,
"JodiOilBalance": JodiOilBalanceFetcher,
"JodiOilDemand": JodiOilDemandFetcher,
"JodiOilDemandByProduct": JodiOilDemandByProductFetcher,
"JodiOilExports": JodiOilExportsFetcher,
"JodiOilImports": JodiOilImportsFetcher,
"JodiOilProduction": JodiOilProductionFetcher,
"JodiOilStocks": JodiOilStocksFetcher,
},
repr_name="Joint Organisations Data Initiative (JODI) World Databases",
)

View File

@@ -0,0 +1,260 @@
[
{
"name": "JODI Oil & Gas",
"img": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNjAwIDkwMCI+CiAgPHJlY3Qgd2lkdGg9IjE2MDAiIGhlaWdodD0iOTAwIiBmaWxsPSIjMTAxMzE4Ii8+CiAgPHJlY3QgeD0iNDAiIHk9Ijg0NiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjExOCIgeT0iODI4IiB3aWR0aD0iNDYiIGhlaWdodD0iNTIiIHJ4PSI2IiBmaWxsPSIjOERDNjNGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iMTk2IiB5PSI4MzYiIHdpZHRoPSI0NiIgaGVpZ2h0PSI0NCIgcng9IjYiIGZpbGw9IiMxQ0IwQ0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSIyNzQiIHk9IjgxMiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjY4IiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjM1MiIgeT0iODIyIiB3aWR0aD0iNDYiIGhlaWdodD0iNTgiIHJ4PSI2IiBmaWxsPSIjMUNCMENGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iNDMwIiB5PSI3OTgiIHdpZHRoPSI0NiIgaGVpZ2h0PSI4MiIgcng9IjYiIGZpbGw9IiM4REM2M0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSI1MDgiIHk9IjgxNiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjY0IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjU4NiIgeT0iNzg0IiB3aWR0aD0iNDYiIGhlaWdodD0iOTYiIHJ4PSI2IiBmaWxsPSIjOERDNjNGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iNjY0IiB5PSI4MDYiIHdpZHRoPSI0NiIgaGVpZ2h0PSI3NCIgcng9IjYiIGZpbGw9IiMxQ0IwQ0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSI3NDIiIHk9Ijc3MiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjEwOCIgcng9IjYiIGZpbGw9IiM4REM2M0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSI4MjAiIHk9Ijc5MiIgd2lkdGg9IjQ2IiBoZWlnaHQ9Ijg4IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9Ijg5OCIgeT0iNzU4IiB3aWR0aD0iNDYiIGhlaWdodD0iMTIyIiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9Ijk3NiIgeT0iNzg0IiB3aWR0aD0iNDYiIGhlaWdodD0iOTYiIHJ4PSI2IiBmaWxsPSIjMUNCMENGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iMTA1NCIgeT0iNzQ4IiB3aWR0aD0iNDYiIGhlaWdodD0iMTMyIiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjExMzIiIHk9Ijc3NiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjEwNCIgcng9IjYiIGZpbGw9IiMxQ0IwQ0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSIxMjEwIiB5PSI3MzQiIHdpZHRoPSI0NiIgaGVpZ2h0PSIxNDYiIHJ4PSI2IiBmaWxsPSIjOERDNjNGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iMTI4OCIgeT0iNzYyIiB3aWR0aD0iNDYiIGhlaWdodD0iMTE4IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjEzNjYiIHk9IjcyMiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjE1OCIgcng9IjYiIGZpbGw9IiM4REM2M0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSIxNDQ0IiB5PSI3NTIiIHdpZHRoPSI0NiIgaGVpZ2h0PSIxMjgiIHJ4PSI2IiBmaWxsPSIjMUNCMENGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iMTUyMiIgeT0iNzEwIiB3aWR0aD0iNDYiIGhlaWdodD0iMTcwIiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC4zMiIvPgogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDc0MiwzMTgpIj4KICAgIDxwYXRoIGQ9Ik0wLC05MiBDNTAsLTIyIDY4LDE0IDY4LDQ2IEE2OCw2OCAwIDEsMSAtNjgsNDYgQy02OCwxNCAtNTAsLTIyIDAsLTkyIFoiIGZpbGw9IiMxQ0IwQ0YiLz4KICA8L2c+CiAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoODkyLDMxOCkiPgogICAgPHBhdGggZD0iTTAsLTk2IEMzNCwtNTIgNjIsLTE0IDYyLDM4IEE2Miw2NiAwIDEsMSAtNjIsMzggQy02Miw2IC00MCwtMTggLTI2LC0zOCBDLTE0LC0yMCAtOCwtOCAtMTIsMTAgQzYsLTYgOCwtNDAgMCwtOTYgWiIgZmlsbD0iIzhEQzYzRiIvPgogIDwvZz4KICA8dGV4dCB4PSI4MDAiIHk9IjU2MiIgZm9udC1mYW1pbHk9IkFyaWFsLCBIZWx2ZXRpY2EsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTY4IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjRjJGNEY3IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBsZXR0ZXItc3BhY2luZz0iMTAiPkpPREk8L3RleHQ+CiAgPHRleHQgeD0iODAwIiB5PSI2NDgiIGZvbnQtZmFtaWx5PSJBcmlhbCwgSGVsdmV0aWNhLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjUyIiBmb250LXdlaWdodD0iNjAwIiBmaWxsPSIjRjJGNEY3IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBvcGFjaXR5PSIwLjkyIj5PaWwgJmFtcDsgR2FzIFdvcmxkIERhdGFiYXNlczwvdGV4dD4KICA8dGV4dCB4PSI4MDAiIHk9IjcxNiIgZm9udC1mYW1pbHk9IkFyaWFsLCBIZWx2ZXRpY2EsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMzQiIGZpbGw9IiM5QUE0QjIiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkpvaW50IE9yZ2FuaXNhdGlvbnMgRGF0YSBJbml0aWF0aXZlPC90ZXh0Pgo8L3N2Zz4=",
"img_dark": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNjAwIDkwMCI+CiAgPHJlY3Qgd2lkdGg9IjE2MDAiIGhlaWdodD0iOTAwIiBmaWxsPSIjMTAxMzE4Ii8+CiAgPHJlY3QgeD0iNDAiIHk9Ijg0NiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjExOCIgeT0iODI4IiB3aWR0aD0iNDYiIGhlaWdodD0iNTIiIHJ4PSI2IiBmaWxsPSIjOERDNjNGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iMTk2IiB5PSI4MzYiIHdpZHRoPSI0NiIgaGVpZ2h0PSI0NCIgcng9IjYiIGZpbGw9IiMxQ0IwQ0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSIyNzQiIHk9IjgxMiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjY4IiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjM1MiIgeT0iODIyIiB3aWR0aD0iNDYiIGhlaWdodD0iNTgiIHJ4PSI2IiBmaWxsPSIjMUNCMENGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iNDMwIiB5PSI3OTgiIHdpZHRoPSI0NiIgaGVpZ2h0PSI4MiIgcng9IjYiIGZpbGw9IiM4REM2M0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSI1MDgiIHk9IjgxNiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjY0IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjU4NiIgeT0iNzg0IiB3aWR0aD0iNDYiIGhlaWdodD0iOTYiIHJ4PSI2IiBmaWxsPSIjOERDNjNGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iNjY0IiB5PSI4MDYiIHdpZHRoPSI0NiIgaGVpZ2h0PSI3NCIgcng9IjYiIGZpbGw9IiMxQ0IwQ0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSI3NDIiIHk9Ijc3MiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjEwOCIgcng9IjYiIGZpbGw9IiM4REM2M0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSI4MjAiIHk9Ijc5MiIgd2lkdGg9IjQ2IiBoZWlnaHQ9Ijg4IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9Ijg5OCIgeT0iNzU4IiB3aWR0aD0iNDYiIGhlaWdodD0iMTIyIiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9Ijk3NiIgeT0iNzg0IiB3aWR0aD0iNDYiIGhlaWdodD0iOTYiIHJ4PSI2IiBmaWxsPSIjMUNCMENGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iMTA1NCIgeT0iNzQ4IiB3aWR0aD0iNDYiIGhlaWdodD0iMTMyIiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjExMzIiIHk9Ijc3NiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjEwNCIgcng9IjYiIGZpbGw9IiMxQ0IwQ0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSIxMjEwIiB5PSI3MzQiIHdpZHRoPSI0NiIgaGVpZ2h0PSIxNDYiIHJ4PSI2IiBmaWxsPSIjOERDNjNGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iMTI4OCIgeT0iNzYyIiB3aWR0aD0iNDYiIGhlaWdodD0iMTE4IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC4zMiIvPjxyZWN0IHg9IjEzNjYiIHk9IjcyMiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjE1OCIgcng9IjYiIGZpbGw9IiM4REM2M0YiIG9wYWNpdHk9IjAuMzIiLz48cmVjdCB4PSIxNDQ0IiB5PSI3NTIiIHdpZHRoPSI0NiIgaGVpZ2h0PSIxMjgiIHJ4PSI2IiBmaWxsPSIjMUNCMENGIiBvcGFjaXR5PSIwLjMyIi8+PHJlY3QgeD0iMTUyMiIgeT0iNzEwIiB3aWR0aD0iNDYiIGhlaWdodD0iMTcwIiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC4zMiIvPgogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDc0MiwzMTgpIj4KICAgIDxwYXRoIGQ9Ik0wLC05MiBDNTAsLTIyIDY4LDE0IDY4LDQ2IEE2OCw2OCAwIDEsMSAtNjgsNDYgQy02OCwxNCAtNTAsLTIyIDAsLTkyIFoiIGZpbGw9IiMxQ0IwQ0YiLz4KICA8L2c+CiAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoODkyLDMxOCkiPgogICAgPHBhdGggZD0iTTAsLTk2IEMzNCwtNTIgNjIsLTE0IDYyLDM4IEE2Miw2NiAwIDEsMSAtNjIsMzggQy02Miw2IC00MCwtMTggLTI2LC0zOCBDLTE0LC0yMCAtOCwtOCAtMTIsMTAgQzYsLTYgOCwtNDAgMCwtOTYgWiIgZmlsbD0iIzhEQzYzRiIvPgogIDwvZz4KICA8dGV4dCB4PSI4MDAiIHk9IjU2MiIgZm9udC1mYW1pbHk9IkFyaWFsLCBIZWx2ZXRpY2EsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTY4IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjRjJGNEY3IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBsZXR0ZXItc3BhY2luZz0iMTAiPkpPREk8L3RleHQ+CiAgPHRleHQgeD0iODAwIiB5PSI2NDgiIGZvbnQtZmFtaWx5PSJBcmlhbCwgSGVsdmV0aWNhLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjUyIiBmb250LXdlaWdodD0iNjAwIiBmaWxsPSIjRjJGNEY3IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBvcGFjaXR5PSIwLjkyIj5PaWwgJmFtcDsgR2FzIFdvcmxkIERhdGFiYXNlczwvdGV4dD4KICA8dGV4dCB4PSI4MDAiIHk9IjcxNiIgZm9udC1mYW1pbHk9IkFyaWFsLCBIZWx2ZXRpY2EsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMzQiIGZpbGw9IiM5QUE0QjIiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkpvaW50IE9yZ2FuaXNhdGlvbnMgRGF0YSBJbml0aWF0aXZlPC90ZXh0Pgo8L3N2Zz4=",
"img_light": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNjAwIDkwMCI+CiAgPHJlY3Qgd2lkdGg9IjE2MDAiIGhlaWdodD0iOTAwIiBmaWxsPSIjRjdGOEZBIi8+CiAgPHJlY3QgeD0iNDAiIHk9Ijg0NiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjM0IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC40NSIvPjxyZWN0IHg9IjExOCIgeT0iODI4IiB3aWR0aD0iNDYiIGhlaWdodD0iNTIiIHJ4PSI2IiBmaWxsPSIjOERDNjNGIiBvcGFjaXR5PSIwLjQ1Ii8+PHJlY3QgeD0iMTk2IiB5PSI4MzYiIHdpZHRoPSI0NiIgaGVpZ2h0PSI0NCIgcng9IjYiIGZpbGw9IiMxQ0IwQ0YiIG9wYWNpdHk9IjAuNDUiLz48cmVjdCB4PSIyNzQiIHk9IjgxMiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjY4IiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC40NSIvPjxyZWN0IHg9IjM1MiIgeT0iODIyIiB3aWR0aD0iNDYiIGhlaWdodD0iNTgiIHJ4PSI2IiBmaWxsPSIjMUNCMENGIiBvcGFjaXR5PSIwLjQ1Ii8+PHJlY3QgeD0iNDMwIiB5PSI3OTgiIHdpZHRoPSI0NiIgaGVpZ2h0PSI4MiIgcng9IjYiIGZpbGw9IiM4REM2M0YiIG9wYWNpdHk9IjAuNDUiLz48cmVjdCB4PSI1MDgiIHk9IjgxNiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjY0IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC40NSIvPjxyZWN0IHg9IjU4NiIgeT0iNzg0IiB3aWR0aD0iNDYiIGhlaWdodD0iOTYiIHJ4PSI2IiBmaWxsPSIjOERDNjNGIiBvcGFjaXR5PSIwLjQ1Ii8+PHJlY3QgeD0iNjY0IiB5PSI4MDYiIHdpZHRoPSI0NiIgaGVpZ2h0PSI3NCIgcng9IjYiIGZpbGw9IiMxQ0IwQ0YiIG9wYWNpdHk9IjAuNDUiLz48cmVjdCB4PSI3NDIiIHk9Ijc3MiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjEwOCIgcng9IjYiIGZpbGw9IiM4REM2M0YiIG9wYWNpdHk9IjAuNDUiLz48cmVjdCB4PSI4MjAiIHk9Ijc5MiIgd2lkdGg9IjQ2IiBoZWlnaHQ9Ijg4IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC40NSIvPjxyZWN0IHg9Ijg5OCIgeT0iNzU4IiB3aWR0aD0iNDYiIGhlaWdodD0iMTIyIiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC40NSIvPjxyZWN0IHg9Ijk3NiIgeT0iNzg0IiB3aWR0aD0iNDYiIGhlaWdodD0iOTYiIHJ4PSI2IiBmaWxsPSIjMUNCMENGIiBvcGFjaXR5PSIwLjQ1Ii8+PHJlY3QgeD0iMTA1NCIgeT0iNzQ4IiB3aWR0aD0iNDYiIGhlaWdodD0iMTMyIiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC40NSIvPjxyZWN0IHg9IjExMzIiIHk9Ijc3NiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjEwNCIgcng9IjYiIGZpbGw9IiMxQ0IwQ0YiIG9wYWNpdHk9IjAuNDUiLz48cmVjdCB4PSIxMjEwIiB5PSI3MzQiIHdpZHRoPSI0NiIgaGVpZ2h0PSIxNDYiIHJ4PSI2IiBmaWxsPSIjOERDNjNGIiBvcGFjaXR5PSIwLjQ1Ii8+PHJlY3QgeD0iMTI4OCIgeT0iNzYyIiB3aWR0aD0iNDYiIGhlaWdodD0iMTE4IiByeD0iNiIgZmlsbD0iIzFDQjBDRiIgb3BhY2l0eT0iMC40NSIvPjxyZWN0IHg9IjEzNjYiIHk9IjcyMiIgd2lkdGg9IjQ2IiBoZWlnaHQ9IjE1OCIgcng9IjYiIGZpbGw9IiM4REM2M0YiIG9wYWNpdHk9IjAuNDUiLz48cmVjdCB4PSIxNDQ0IiB5PSI3NTIiIHdpZHRoPSI0NiIgaGVpZ2h0PSIxMjgiIHJ4PSI2IiBmaWxsPSIjMUNCMENGIiBvcGFjaXR5PSIwLjQ1Ii8+PHJlY3QgeD0iMTUyMiIgeT0iNzEwIiB3aWR0aD0iNDYiIGhlaWdodD0iMTcwIiByeD0iNiIgZmlsbD0iIzhEQzYzRiIgb3BhY2l0eT0iMC40NSIvPgogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDc0MiwzMTgpIj4KICAgIDxwYXRoIGQ9Ik0wLC05MiBDNTAsLTIyIDY4LDE0IDY4LDQ2IEE2OCw2OCAwIDEsMSAtNjgsNDYgQy02OCwxNCAtNTAsLTIyIDAsLTkyIFoiIGZpbGw9IiMxQ0IwQ0YiLz4KICA8L2c+CiAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoODkyLDMxOCkiPgogICAgPHBhdGggZD0iTTAsLTk2IEMzNCwtNTIgNjIsLTE0IDYyLDM4IEE2Miw2NiAwIDEsMSAtNjIsMzggQy02Miw2IC00MCwtMTggLTI2LC0zOCBDLTE0LC0yMCAtOCwtOCAtMTIsMTAgQzYsLTYgOCwtNDAgMCwtOTYgWiIgZmlsbD0iIzhEQzYzRiIvPgogIDwvZz4KICA8dGV4dCB4PSI4MDAiIHk9IjU2MiIgZm9udC1mYW1pbHk9IkFyaWFsLCBIZWx2ZXRpY2EsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTY4IiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSIjMTYxQjIyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBsZXR0ZXItc3BhY2luZz0iMTAiPkpPREk8L3RleHQ+CiAgPHRleHQgeD0iODAwIiB5PSI2NDgiIGZvbnQtZmFtaWx5PSJBcmlhbCwgSGVsdmV0aWNhLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjUyIiBmb250LXdlaWdodD0iNjAwIiBmaWxsPSIjMTYxQjIyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBvcGFjaXR5PSIwLjkyIj5PaWwgJmFtcDsgR2FzIFdvcmxkIERhdGFiYXNlczwvdGV4dD4KICA8dGV4dCB4PSI4MDAiIHk9IjcxNiIgZm9udC1mYW1pbHk9IkFyaWFsLCBIZWx2ZXRpY2EsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMzQiIGZpbGw9IiM1QjY1NzIiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkpvaW50IE9yZ2FuaXNhdGlvbnMgRGF0YSBJbml0aWF0aXZlPC90ZXh0Pgo8L3N2Zz4=",
"description": "Monthly world petroleum and natural gas supply, demand, and stock statistics, self-reported by over 100 countries through the Joint Organisations Data Initiative (JODI) World Databases.",
"allowCustomization": true,
"tabs": {
"country_balances": {
"id": "country_balances",
"name": "Country Balances",
"layout": [
{
"i": "jodi_oil_balance_jodi_obb",
"x": 0,
"y": 0,
"w": 40,
"h": 14,
"groups": [
"Group 1"
]
},
{
"i": "jodi_oil_demand_by_product_jodi_obb",
"x": 0,
"y": 14,
"w": 20,
"h": 13,
"groups": [
"Group 1"
]
},
{
"i": "jodi_gas_balance_jodi_obb",
"x": 20,
"y": 14,
"w": 20,
"h": 13,
"groups": [
"Group 1"
]
}
]
},
"oil_markets": {
"id": "oil_markets",
"name": "Oil Markets",
"layout": [
{
"i": "jodi_oil_production_jodi_obb",
"x": 0,
"y": 0,
"w": 20,
"h": 13,
"groups": [
"Group 2",
"Group 4"
]
},
{
"i": "jodi_oil_demand_jodi_obb",
"x": 20,
"y": 0,
"w": 20,
"h": 13,
"groups": [
"Group 2",
"Group 4"
]
},
{
"i": "jodi_oil_imports_jodi_obb",
"x": 0,
"y": 13,
"w": 20,
"h": 13,
"groups": [
"Group 2",
"Group 3",
"Group 4"
]
},
{
"i": "jodi_oil_exports_jodi_obb",
"x": 20,
"y": 13,
"w": 20,
"h": 13,
"groups": [
"Group 2",
"Group 3",
"Group 4"
]
},
{
"i": "jodi_oil_stocks_jodi_obb",
"x": 0,
"y": 26,
"w": 40,
"h": 13,
"groups": [
"Group 2",
"Group 3"
]
}
]
},
"gas_markets": {
"id": "gas_markets",
"name": "Gas Markets",
"layout": [
{
"i": "jodi_gas_production_jodi_obb",
"x": 0,
"y": 0,
"w": 20,
"h": 13,
"groups": [
"Group 5",
"Group 6"
]
},
{
"i": "jodi_gas_demand_jodi_obb",
"x": 20,
"y": 0,
"w": 20,
"h": 13,
"groups": [
"Group 5",
"Group 6"
]
},
{
"i": "jodi_gas_imports_jodi_obb",
"x": 0,
"y": 13,
"w": 20,
"h": 13,
"groups": [
"Group 5",
"Group 6",
"Group 7"
]
},
{
"i": "jodi_gas_exports_jodi_obb",
"x": 20,
"y": 13,
"w": 20,
"h": 13,
"groups": [
"Group 5",
"Group 6",
"Group 7"
]
},
{
"i": "jodi_gas_stocks_jodi_obb",
"x": 0,
"y": 26,
"w": 40,
"h": 13,
"groups": [
"Group 5",
"Group 6"
]
}
]
}
},
"groups": [
{
"name": "Group 1",
"type": "param",
"paramName": "country",
"defaultValue": "united_states",
"widgetIds": [
"jodi_oil_balance_jodi_obb",
"jodi_oil_demand_by_product_jodi_obb",
"jodi_gas_balance_jodi_obb"
]
},
{
"name": "Group 2",
"type": "param",
"paramName": "country",
"defaultValue": "united_states,saudi_arabia,russia,china,india",
"widgetIds": [
"jodi_oil_production_jodi_obb",
"jodi_oil_demand_jodi_obb",
"jodi_oil_imports_jodi_obb",
"jodi_oil_exports_jodi_obb",
"jodi_oil_stocks_jodi_obb"
]
},
{
"name": "Group 3",
"type": "param",
"paramName": "product",
"defaultValue": "crude_oil",
"widgetIds": [
"jodi_oil_imports_jodi_obb",
"jodi_oil_exports_jodi_obb",
"jodi_oil_stocks_jodi_obb"
]
},
{
"name": "Group 4",
"type": "param",
"paramName": "unit",
"defaultValue": "kbd",
"widgetIds": [
"jodi_oil_production_jodi_obb",
"jodi_oil_demand_jodi_obb",
"jodi_oil_imports_jodi_obb",
"jodi_oil_exports_jodi_obb"
]
},
{
"name": "Group 5",
"type": "param",
"paramName": "country",
"defaultValue": "united_states,qatar,norway,china,japan",
"widgetIds": [
"jodi_gas_production_jodi_obb",
"jodi_gas_demand_jodi_obb",
"jodi_gas_imports_jodi_obb",
"jodi_gas_exports_jodi_obb",
"jodi_gas_stocks_jodi_obb"
]
},
{
"name": "Group 6",
"type": "param",
"paramName": "unit",
"defaultValue": "m3",
"widgetIds": [
"jodi_gas_production_jodi_obb",
"jodi_gas_demand_jodi_obb",
"jodi_gas_imports_jodi_obb",
"jodi_gas_exports_jodi_obb",
"jodi_gas_stocks_jodi_obb"
]
},
{
"name": "Group 7",
"type": "param",
"paramName": "flow",
"defaultValue": "total",
"widgetIds": [
"jodi_gas_imports_jodi_obb",
"jodi_gas_exports_jodi_obb"
]
}
],
"prompts": []
}
]

View File

@@ -0,0 +1,183 @@
"""JODI Gas Router."""
from openbb_core.app.model.command_context import CommandContext
from openbb_core.app.model.example import APIEx
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.provider_interface import (
ExtraParams,
ProviderChoices,
StandardParams,
)
from openbb_core.app.query import Query as OpenBBQuery
from openbb_core.app.router import Router
router = Router(
prefix="/gas",
description="JODI-Gas World Database."
+ " Monthly data from January 2009 to the most recent month.",
)
@router.command(
model="JodiGasBalance",
examples=[
APIEx(
description="Get the United States natural gas balance - one column per questionnaire flow.",
parameters={"provider": "jodi"},
),
APIEx(
description="Get Norway's natural gas balance, in terajoules.",
parameters={
"provider": "jodi",
"country": "norway",
"unit": "tj",
"start_date": "2020-01-01",
},
),
],
)
async def balance(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Get a country's monthly natural gas balance - production, pipeline and LNG trade, deliveries, and stocks, one column per flow of the JODI questionnaire."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiGasProduction",
examples=[
APIEx(
description="Get monthly natural gas production - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare natural gas production in the United States, Qatar, and Norway.",
parameters={
"provider": "jodi",
"country": "united_states,qatar,norway",
"start_date": "2020-01-01",
},
),
],
)
async def production(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly natural gas production - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiGasDemand",
examples=[
APIEx(
description="Get monthly natural gas demand - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare natural gas demand in Germany, France, and Italy.",
parameters={
"provider": "jodi",
"country": "germany,france,italy",
"start_date": "2020-01-01",
},
),
],
)
async def demand(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly natural gas demand, as gross inland deliveries - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiGasImports",
examples=[
APIEx(
description="Get monthly natural gas imports - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare LNG imports of Japan, China, and South Korea.",
parameters={
"provider": "jodi",
"country": "japan,china,south_korea",
"flow": "lng",
"start_date": "2020-01-01",
},
),
],
)
async def imports(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly natural gas imports - total, pipeline, or LNG - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiGasExports",
examples=[
APIEx(
description="Get monthly natural gas exports - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare LNG exports from the United States, Qatar, and Australia.",
parameters={
"provider": "jodi",
"country": "united_states,qatar,australia",
"flow": "lng",
"start_date": "2020-01-01",
},
),
],
)
async def exports(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly natural gas exports - total, pipeline, or LNG - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiGasStocks",
examples=[
APIEx(
description="Get monthly natural gas closing stock levels - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare natural gas stocks in Germany, France, and the Netherlands.",
parameters={
"provider": "jodi",
"country": "germany,france,netherlands",
"start_date": "2020-01-01",
},
),
],
)
async def stocks(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly natural gas closing stock levels - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))

View File

@@ -0,0 +1,39 @@
"""JODI Router."""
from contextlib import asynccontextmanager
from openbb_core.app.router import Router
from openbb_jodi.gas_router import router as gas_router
from openbb_jodi.oil_router import router as oil_router
@asynccontextmanager
async def lifespan(_):
"""Warm the JODI table cache in the background at application startup."""
from openbb_jodi.utils.helpers import start_prefetch
start_prefetch()
yield
router = Router(
prefix="",
description="Monthly petroleum and natural gas supply, demand, and stock"
+ " statistics, self-reported by over 100 countries, from the Joint"
+ " Organisations Data Initiative (JODI) World Databases.",
)
router.api_router.lifespan_context = lifespan
router.include_router(oil_router)
router.include_router(gas_router)
@router.api_router.get("/apps.json", include_in_schema=False)
async def jodi_apps() -> list:
"""Serve the bundled JODI dashboard template (``assets/apps.json``)."""
import json # noqa
from pathlib import Path
apps_json = Path(__file__).parent / "assets" / "apps.json"
return json.loads(apps_json.read_text(encoding="utf-8"))

View File

@@ -0,0 +1 @@
"""OpenBB JODI Provider Models."""

View File

@@ -0,0 +1,607 @@
"""JODI Country Series Data Model."""
from datetime import date as dateType
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.utils.descriptions import DATA_DESCRIPTIONS
from pydantic import Field
class JodiCountryData(Data):
"""JODI Country Series Data.
One column per reporting country, in alphabetical order. Countries
without an observation for the period and measure are None.
"""
date: dateType = Field(description=DATA_DESCRIPTIONS.get("date", ""))
albania: float | None = Field(
default=None,
description="Monthly value reported by Albania.",
json_schema_extra={"x-widget_config": {"headerName": "Albania"}},
)
algeria: float | None = Field(
default=None,
description="Monthly value reported by Algeria.",
json_schema_extra={"x-widget_config": {"headerName": "Algeria"}},
)
angola: float | None = Field(
default=None,
description="Monthly value reported by Angola.",
json_schema_extra={"x-widget_config": {"headerName": "Angola"}},
)
argentina: float | None = Field(
default=None,
description="Monthly value reported by Argentina.",
json_schema_extra={"x-widget_config": {"headerName": "Argentina"}},
)
armenia: float | None = Field(
default=None,
description="Monthly value reported by Armenia.",
json_schema_extra={"x-widget_config": {"headerName": "Armenia"}},
)
australia: float | None = Field(
default=None,
description="Monthly value reported by Australia.",
json_schema_extra={"x-widget_config": {"headerName": "Australia"}},
)
austria: float | None = Field(
default=None,
description="Monthly value reported by Austria.",
json_schema_extra={"x-widget_config": {"headerName": "Austria"}},
)
azerbaijan: float | None = Field(
default=None,
description="Monthly value reported by Azerbaijan.",
json_schema_extra={"x-widget_config": {"headerName": "Azerbaijan"}},
)
bahrain: float | None = Field(
default=None,
description="Monthly value reported by Bahrain.",
json_schema_extra={"x-widget_config": {"headerName": "Bahrain"}},
)
bangladesh: float | None = Field(
default=None,
description="Monthly value reported by Bangladesh.",
json_schema_extra={"x-widget_config": {"headerName": "Bangladesh"}},
)
barbados: float | None = Field(
default=None,
description="Monthly value reported by Barbados.",
json_schema_extra={"x-widget_config": {"headerName": "Barbados"}},
)
belarus: float | None = Field(
default=None,
description="Monthly value reported by Belarus.",
json_schema_extra={"x-widget_config": {"headerName": "Belarus"}},
)
belgium: float | None = Field(
default=None,
description="Monthly value reported by Belgium.",
json_schema_extra={"x-widget_config": {"headerName": "Belgium"}},
)
belize: float | None = Field(
default=None,
description="Monthly value reported by Belize.",
json_schema_extra={"x-widget_config": {"headerName": "Belize"}},
)
bermuda: float | None = Field(
default=None,
description="Monthly value reported by Bermuda.",
json_schema_extra={"x-widget_config": {"headerName": "Bermuda"}},
)
bolivia: float | None = Field(
default=None,
description="Monthly value reported by Bolivia.",
json_schema_extra={"x-widget_config": {"headerName": "Bolivia"}},
)
brazil: float | None = Field(
default=None,
description="Monthly value reported by Brazil.",
json_schema_extra={"x-widget_config": {"headerName": "Brazil"}},
)
brunei: float | None = Field(
default=None,
description="Monthly value reported by Brunei.",
json_schema_extra={"x-widget_config": {"headerName": "Brunei"}},
)
bulgaria: float | None = Field(
default=None,
description="Monthly value reported by Bulgaria.",
json_schema_extra={"x-widget_config": {"headerName": "Bulgaria"}},
)
canada: float | None = Field(
default=None,
description="Monthly value reported by Canada.",
json_schema_extra={"x-widget_config": {"headerName": "Canada"}},
)
chile: float | None = Field(
default=None,
description="Monthly value reported by Chile.",
json_schema_extra={"x-widget_config": {"headerName": "Chile"}},
)
china: float | None = Field(
default=None,
description="Monthly value reported by China.",
json_schema_extra={"x-widget_config": {"headerName": "China"}},
)
colombia: float | None = Field(
default=None,
description="Monthly value reported by Colombia.",
json_schema_extra={"x-widget_config": {"headerName": "Colombia"}},
)
costa_rica: float | None = Field(
default=None,
description="Monthly value reported by Costa Rica.",
json_schema_extra={"x-widget_config": {"headerName": "Costa Rica"}},
)
croatia: float | None = Field(
default=None,
description="Monthly value reported by Croatia.",
json_schema_extra={"x-widget_config": {"headerName": "Croatia"}},
)
cuba: float | None = Field(
default=None,
description="Monthly value reported by Cuba.",
json_schema_extra={"x-widget_config": {"headerName": "Cuba"}},
)
cyprus: float | None = Field(
default=None,
description="Monthly value reported by Cyprus.",
json_schema_extra={"x-widget_config": {"headerName": "Cyprus"}},
)
czechia: float | None = Field(
default=None,
description="Monthly value reported by Czechia.",
json_schema_extra={"x-widget_config": {"headerName": "Czechia"}},
)
denmark: float | None = Field(
default=None,
description="Monthly value reported by Denmark.",
json_schema_extra={"x-widget_config": {"headerName": "Denmark"}},
)
dominican_republic: float | None = Field(
default=None,
description="Monthly value reported by Dominican Republic.",
json_schema_extra={"x-widget_config": {"headerName": "Dominican Republic"}},
)
ecuador: float | None = Field(
default=None,
description="Monthly value reported by Ecuador.",
json_schema_extra={"x-widget_config": {"headerName": "Ecuador"}},
)
egypt: float | None = Field(
default=None,
description="Monthly value reported by Egypt.",
json_schema_extra={"x-widget_config": {"headerName": "Egypt"}},
)
el_salvador: float | None = Field(
default=None,
description="Monthly value reported by El Salvador.",
json_schema_extra={"x-widget_config": {"headerName": "El Salvador"}},
)
equatorial_guinea: float | None = Field(
default=None,
description="Monthly value reported by Equatorial Guinea.",
json_schema_extra={"x-widget_config": {"headerName": "Equatorial Guinea"}},
)
estonia: float | None = Field(
default=None,
description="Monthly value reported by Estonia.",
json_schema_extra={"x-widget_config": {"headerName": "Estonia"}},
)
eswatini: float | None = Field(
default=None,
description="Monthly value reported by Eswatini.",
json_schema_extra={"x-widget_config": {"headerName": "Eswatini"}},
)
finland: float | None = Field(
default=None,
description="Monthly value reported by Finland.",
json_schema_extra={"x-widget_config": {"headerName": "Finland"}},
)
france: float | None = Field(
default=None,
description="Monthly value reported by France.",
json_schema_extra={"x-widget_config": {"headerName": "France"}},
)
gabon: float | None = Field(
default=None,
description="Monthly value reported by Gabon.",
json_schema_extra={"x-widget_config": {"headerName": "Gabon"}},
)
gambia: float | None = Field(
default=None,
description="Monthly value reported by Gambia.",
json_schema_extra={"x-widget_config": {"headerName": "Gambia"}},
)
georgia: float | None = Field(
default=None,
description="Monthly value reported by Georgia.",
json_schema_extra={"x-widget_config": {"headerName": "Georgia"}},
)
germany: float | None = Field(
default=None,
description="Monthly value reported by Germany.",
json_schema_extra={"x-widget_config": {"headerName": "Germany"}},
)
greece: float | None = Field(
default=None,
description="Monthly value reported by Greece.",
json_schema_extra={"x-widget_config": {"headerName": "Greece"}},
)
grenada: float | None = Field(
default=None,
description="Monthly value reported by Grenada.",
json_schema_extra={"x-widget_config": {"headerName": "Grenada"}},
)
guatemala: float | None = Field(
default=None,
description="Monthly value reported by Guatemala.",
json_schema_extra={"x-widget_config": {"headerName": "Guatemala"}},
)
guyana: float | None = Field(
default=None,
description="Monthly value reported by Guyana.",
json_schema_extra={"x-widget_config": {"headerName": "Guyana"}},
)
haiti: float | None = Field(
default=None,
description="Monthly value reported by Haiti.",
json_schema_extra={"x-widget_config": {"headerName": "Haiti"}},
)
honduras: float | None = Field(
default=None,
description="Monthly value reported by Honduras.",
json_schema_extra={"x-widget_config": {"headerName": "Honduras"}},
)
hong_kong: float | None = Field(
default=None,
description="Monthly value reported by Hong Kong.",
json_schema_extra={"x-widget_config": {"headerName": "Hong Kong"}},
)
hungary: float | None = Field(
default=None,
description="Monthly value reported by Hungary.",
json_schema_extra={"x-widget_config": {"headerName": "Hungary"}},
)
iceland: float | None = Field(
default=None,
description="Monthly value reported by Iceland.",
json_schema_extra={"x-widget_config": {"headerName": "Iceland"}},
)
india: float | None = Field(
default=None,
description="Monthly value reported by India.",
json_schema_extra={"x-widget_config": {"headerName": "India"}},
)
indonesia: float | None = Field(
default=None,
description="Monthly value reported by Indonesia.",
json_schema_extra={"x-widget_config": {"headerName": "Indonesia"}},
)
iran: float | None = Field(
default=None,
description="Monthly value reported by Iran.",
json_schema_extra={"x-widget_config": {"headerName": "Iran"}},
)
iraq: float | None = Field(
default=None,
description="Monthly value reported by Iraq.",
json_schema_extra={"x-widget_config": {"headerName": "Iraq"}},
)
ireland: float | None = Field(
default=None,
description="Monthly value reported by Ireland.",
json_schema_extra={"x-widget_config": {"headerName": "Ireland"}},
)
italy: float | None = Field(
default=None,
description="Monthly value reported by Italy.",
json_schema_extra={"x-widget_config": {"headerName": "Italy"}},
)
jamaica: float | None = Field(
default=None,
description="Monthly value reported by Jamaica.",
json_schema_extra={"x-widget_config": {"headerName": "Jamaica"}},
)
japan: float | None = Field(
default=None,
description="Monthly value reported by Japan.",
json_schema_extra={"x-widget_config": {"headerName": "Japan"}},
)
kazakhstan: float | None = Field(
default=None,
description="Monthly value reported by Kazakhstan.",
json_schema_extra={"x-widget_config": {"headerName": "Kazakhstan"}},
)
kuwait: float | None = Field(
default=None,
description="Monthly value reported by Kuwait.",
json_schema_extra={"x-widget_config": {"headerName": "Kuwait"}},
)
latvia: float | None = Field(
default=None,
description="Monthly value reported by Latvia.",
json_schema_extra={"x-widget_config": {"headerName": "Latvia"}},
)
libya: float | None = Field(
default=None,
description="Monthly value reported by Libya.",
json_schema_extra={"x-widget_config": {"headerName": "Libya"}},
)
lithuania: float | None = Field(
default=None,
description="Monthly value reported by Lithuania.",
json_schema_extra={"x-widget_config": {"headerName": "Lithuania"}},
)
luxembourg: float | None = Field(
default=None,
description="Monthly value reported by Luxembourg.",
json_schema_extra={"x-widget_config": {"headerName": "Luxembourg"}},
)
malaysia: float | None = Field(
default=None,
description="Monthly value reported by Malaysia.",
json_schema_extra={"x-widget_config": {"headerName": "Malaysia"}},
)
malta: float | None = Field(
default=None,
description="Monthly value reported by Malta.",
json_schema_extra={"x-widget_config": {"headerName": "Malta"}},
)
mauritius: float | None = Field(
default=None,
description="Monthly value reported by Mauritius.",
json_schema_extra={"x-widget_config": {"headerName": "Mauritius"}},
)
mexico: float | None = Field(
default=None,
description="Monthly value reported by Mexico.",
json_schema_extra={"x-widget_config": {"headerName": "Mexico"}},
)
moldova: float | None = Field(
default=None,
description="Monthly value reported by Moldova.",
json_schema_extra={"x-widget_config": {"headerName": "Moldova"}},
)
morocco: float | None = Field(
default=None,
description="Monthly value reported by Morocco.",
json_schema_extra={"x-widget_config": {"headerName": "Morocco"}},
)
myanmar: float | None = Field(
default=None,
description="Monthly value reported by Myanmar.",
json_schema_extra={"x-widget_config": {"headerName": "Myanmar"}},
)
nepal: float | None = Field(
default=None,
description="Monthly value reported by Nepal.",
json_schema_extra={"x-widget_config": {"headerName": "Nepal"}},
)
netherlands: float | None = Field(
default=None,
description="Monthly value reported by Netherlands.",
json_schema_extra={"x-widget_config": {"headerName": "Netherlands"}},
)
new_zealand: float | None = Field(
default=None,
description="Monthly value reported by New Zealand.",
json_schema_extra={"x-widget_config": {"headerName": "New Zealand"}},
)
nicaragua: float | None = Field(
default=None,
description="Monthly value reported by Nicaragua.",
json_schema_extra={"x-widget_config": {"headerName": "Nicaragua"}},
)
niger: float | None = Field(
default=None,
description="Monthly value reported by Niger.",
json_schema_extra={"x-widget_config": {"headerName": "Niger"}},
)
nigeria: float | None = Field(
default=None,
description="Monthly value reported by Nigeria.",
json_schema_extra={"x-widget_config": {"headerName": "Nigeria"}},
)
north_macedonia: float | None = Field(
default=None,
description="Monthly value reported by North Macedonia.",
json_schema_extra={"x-widget_config": {"headerName": "North Macedonia"}},
)
norway: float | None = Field(
default=None,
description="Monthly value reported by Norway.",
json_schema_extra={"x-widget_config": {"headerName": "Norway"}},
)
oman: float | None = Field(
default=None,
description="Monthly value reported by Oman.",
json_schema_extra={"x-widget_config": {"headerName": "Oman"}},
)
panama: float | None = Field(
default=None,
description="Monthly value reported by Panama.",
json_schema_extra={"x-widget_config": {"headerName": "Panama"}},
)
papua_new_guinea: float | None = Field(
default=None,
description="Monthly value reported by Papua New Guinea.",
json_schema_extra={"x-widget_config": {"headerName": "Papua New Guinea"}},
)
paraguay: float | None = Field(
default=None,
description="Monthly value reported by Paraguay.",
json_schema_extra={"x-widget_config": {"headerName": "Paraguay"}},
)
peru: float | None = Field(
default=None,
description="Monthly value reported by Peru.",
json_schema_extra={"x-widget_config": {"headerName": "Peru"}},
)
philippines: float | None = Field(
default=None,
description="Monthly value reported by Philippines.",
json_schema_extra={"x-widget_config": {"headerName": "Philippines"}},
)
poland: float | None = Field(
default=None,
description="Monthly value reported by Poland.",
json_schema_extra={"x-widget_config": {"headerName": "Poland"}},
)
portugal: float | None = Field(
default=None,
description="Monthly value reported by Portugal.",
json_schema_extra={"x-widget_config": {"headerName": "Portugal"}},
)
qatar: float | None = Field(
default=None,
description="Monthly value reported by Qatar.",
json_schema_extra={"x-widget_config": {"headerName": "Qatar"}},
)
romania: float | None = Field(
default=None,
description="Monthly value reported by Romania.",
json_schema_extra={"x-widget_config": {"headerName": "Romania"}},
)
russia: float | None = Field(
default=None,
description="Monthly value reported by Russia.",
json_schema_extra={"x-widget_config": {"headerName": "Russia"}},
)
saudi_arabia: float | None = Field(
default=None,
description="Monthly value reported by Saudi Arabia.",
json_schema_extra={"x-widget_config": {"headerName": "Saudi Arabia"}},
)
serbia: float | None = Field(
default=None,
description="Monthly value reported by Serbia.",
json_schema_extra={"x-widget_config": {"headerName": "Serbia"}},
)
singapore: float | None = Field(
default=None,
description="Monthly value reported by Singapore.",
json_schema_extra={"x-widget_config": {"headerName": "Singapore"}},
)
slovakia: float | None = Field(
default=None,
description="Monthly value reported by Slovakia.",
json_schema_extra={"x-widget_config": {"headerName": "Slovakia"}},
)
slovenia: float | None = Field(
default=None,
description="Monthly value reported by Slovenia.",
json_schema_extra={"x-widget_config": {"headerName": "Slovenia"}},
)
south_africa: float | None = Field(
default=None,
description="Monthly value reported by South Africa.",
json_schema_extra={"x-widget_config": {"headerName": "South Africa"}},
)
south_korea: float | None = Field(
default=None,
description="Monthly value reported by South Korea.",
json_schema_extra={"x-widget_config": {"headerName": "South Korea"}},
)
spain: float | None = Field(
default=None,
description="Monthly value reported by Spain.",
json_schema_extra={"x-widget_config": {"headerName": "Spain"}},
)
sudan: float | None = Field(
default=None,
description="Monthly value reported by Sudan.",
json_schema_extra={"x-widget_config": {"headerName": "Sudan"}},
)
suriname: float | None = Field(
default=None,
description="Monthly value reported by Suriname.",
json_schema_extra={"x-widget_config": {"headerName": "Suriname"}},
)
sweden: float | None = Field(
default=None,
description="Monthly value reported by Sweden.",
json_schema_extra={"x-widget_config": {"headerName": "Sweden"}},
)
switzerland: float | None = Field(
default=None,
description="Monthly value reported by Switzerland.",
json_schema_extra={"x-widget_config": {"headerName": "Switzerland"}},
)
syria: float | None = Field(
default=None,
description="Monthly value reported by Syria.",
json_schema_extra={"x-widget_config": {"headerName": "Syria"}},
)
taiwan: float | None = Field(
default=None,
description="Monthly value reported by Taiwan.",
json_schema_extra={"x-widget_config": {"headerName": "Taiwan"}},
)
tajikistan: float | None = Field(
default=None,
description="Monthly value reported by Tajikistan.",
json_schema_extra={"x-widget_config": {"headerName": "Tajikistan"}},
)
thailand: float | None = Field(
default=None,
description="Monthly value reported by Thailand.",
json_schema_extra={"x-widget_config": {"headerName": "Thailand"}},
)
trinidad_and_tobago: float | None = Field(
default=None,
description="Monthly value reported by Trinidad and Tobago.",
json_schema_extra={"x-widget_config": {"headerName": "Trinidad and Tobago"}},
)
tunisia: float | None = Field(
default=None,
description="Monthly value reported by Tunisia.",
json_schema_extra={"x-widget_config": {"headerName": "Tunisia"}},
)
turkiye: float | None = Field(
default=None,
description="Monthly value reported by Turkiye.",
json_schema_extra={"x-widget_config": {"headerName": "Turkiye"}},
)
ukraine: float | None = Field(
default=None,
description="Monthly value reported by Ukraine.",
json_schema_extra={"x-widget_config": {"headerName": "Ukraine"}},
)
united_arab_emirates: float | None = Field(
default=None,
description="Monthly value reported by United Arab Emirates.",
json_schema_extra={"x-widget_config": {"headerName": "United Arab Emirates"}},
)
united_kingdom: float | None = Field(
default=None,
description="Monthly value reported by United Kingdom.",
json_schema_extra={"x-widget_config": {"headerName": "United Kingdom"}},
)
united_states: float | None = Field(
default=None,
description="Monthly value reported by United States.",
json_schema_extra={"x-widget_config": {"headerName": "United States"}},
)
uruguay: float | None = Field(
default=None,
description="Monthly value reported by Uruguay.",
json_schema_extra={"x-widget_config": {"headerName": "Uruguay"}},
)
venezuela: float | None = Field(
default=None,
description="Monthly value reported by Venezuela.",
json_schema_extra={"x-widget_config": {"headerName": "Venezuela"}},
)
vietnam: float | None = Field(
default=None,
description="Monthly value reported by Vietnam.",
json_schema_extra={"x-widget_config": {"headerName": "Vietnam"}},
)
yemen: float | None = Field(
default=None,
description="Monthly value reported by Yemen.",
json_schema_extra={"x-widget_config": {"headerName": "Yemen"}},
)

View File

@@ -0,0 +1,210 @@
"""JODI Gas Balance Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import (
DATA_DESCRIPTIONS,
QUERY_DESCRIPTIONS,
)
from pydantic import Field, field_validator
from openbb_jodi.utils.constants import (
COUNTRY_SCHEMA_SINGLE,
COUNTRY_SINGLE_DESCRIPTION,
GAS_BALANCE_FIELDS,
GAS_FLOW_DEFINITIONS,
GAS_UNIT_DESCRIPTION,
GAS_UNIT_LABELS,
GAS_UNITS,
USE_CACHE_DESCRIPTION,
GasUnitType,
)
class JodiGasBalanceQueryParams(QueryParams):
"""JODI Gas Balance Query.
Natural gas is a mixture of gaseous hydrocarbons, primarily methane,
but generally also including ethane, propane, and higher hydrocarbons
in much smaller amounts, and some non-combustible gases such as
nitrogen and carbon dioxide. It includes both non-associated and
associated gas. Colliery gas, coal seam gas, and shale gas are
included, while manufactured gas and biogas are excluded except when
blended with natural gas for final consumption. Natural gas liquids
are excluded.
Source: https://www.jodidata.org/gas/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_SINGLE,
"unit": {"choices": list(GAS_UNITS)},
}
country: str = Field(
default="united_states",
description=COUNTRY_SINGLE_DESCRIPTION,
)
unit: GasUnitType = Field(
default="m3",
description=GAS_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2009-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=False, default="united_states")
@field_validator("unit", mode="before", check_fields=False)
@classmethod
def _normalize_unit(cls, v):
"""Normalize unit."""
from openbb_jodi.utils.helpers import normalize_token
return normalize_token(v, "m3")
class JodiGasBalanceData(Data):
"""JODI Gas Balance Data.
One column per flow of the JODI-Gas questionnaire, in published order.
"""
date: dateType = Field(description=DATA_DESCRIPTIONS.get("date", ""))
production: float | None = Field(
default=None,
description="Production. " + GAS_FLOW_DEFINITIONS["production"],
)
from_other_sources: float | None = Field(
default=None,
description="Receipts from other sources. "
+ GAS_FLOW_DEFINITIONS["from_other_sources"],
)
imports: float | None = Field(
default=None,
description="Total imports. " + GAS_FLOW_DEFINITIONS["imports"],
)
pipeline_imports: float | None = Field(
default=None,
description="Of which: pipeline imports. "
+ GAS_FLOW_DEFINITIONS["pipeline_imports"],
)
lng_imports: float | None = Field(
default=None,
description="Of which: LNG imports. " + GAS_FLOW_DEFINITIONS["lng_imports"],
)
exports: float | None = Field(
default=None,
description="Total exports. " + GAS_FLOW_DEFINITIONS["exports"],
)
pipeline_exports: float | None = Field(
default=None,
description="Of which: pipeline exports. "
+ GAS_FLOW_DEFINITIONS["pipeline_exports"],
)
lng_exports: float | None = Field(
default=None,
description="Of which: LNG exports. " + GAS_FLOW_DEFINITIONS["lng_exports"],
)
stock_change: float | None = Field(
default=None,
description="Stock change. " + GAS_FLOW_DEFINITIONS["stock_change"],
)
demand_calculated: float | None = Field(
default=None,
description=GAS_FLOW_DEFINITIONS["demand_calculated"],
)
statistical_difference: float | None = Field(
default=None,
description="Statistical difference. "
+ GAS_FLOW_DEFINITIONS["statistical_difference"],
)
demand: float | None = Field(
default=None,
description=GAS_FLOW_DEFINITIONS["demand"],
)
electricity_and_heat_generation: float | None = Field(
default=None,
description="Of which: electricity and heat generation. "
+ GAS_FLOW_DEFINITIONS["electricity_and_heat_generation"],
)
closing_stocks: float | None = Field(
default=None,
description="Closing stocks. " + GAS_FLOW_DEFINITIONS["closing_stocks"],
)
class JodiGasBalanceFetcher(
Fetcher[JodiGasBalanceQueryParams, list[JodiGasBalanceData]]
):
"""JODI Gas Balance Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiGasBalanceQueryParams:
"""Transform the query params."""
return JodiGasBalanceQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiGasBalanceQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Gas world database file."""
from openbb_jodi.utils.constants import COUNTRIES, GAS_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_gas, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, GAS_START_YEAR
)
return await get_filtered_gas(
countries={COUNTRIES[query.country]},
flow_codes=set(GAS_BALANCE_FIELDS),
unit_codes={GAS_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiGasBalanceQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiGasBalanceData]]:
"""Transform the raw data into the balance table."""
from openbb_jodi.utils.constants import COUNTRY_LABELS
from openbb_jodi.utils.helpers import build_assessments, build_field_table
rows = build_field_table(data, "FLOW_BREAKDOWN", GAS_BALANCE_FIELDS)
code = data[0]["REF_AREA"]
metadata = {
"country": COUNTRY_LABELS.get(code, code),
"product": "Natural gas",
"unit": GAS_UNIT_LABELS[query.unit],
"assessments": build_assessments(
data, "FLOW_BREAKDOWN", GAS_BALANCE_FIELDS
),
"source": "JODI-Gas World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiGasBalanceData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,160 @@
"""JODI Gas Demand Model."""
from datetime import date as dateType
from typing import Any, Literal
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
GAS_FLOW_DEFINITIONS,
GAS_UNIT_DESCRIPTION,
GAS_UNIT_LABELS,
GAS_UNITS,
USE_CACHE_DESCRIPTION,
GasUnitType,
)
MEASURE_CODES = {"observed": "TOTDEMO", "calculated": "TOTDEMC"}
MEASURE_LABELS = {
"observed": "Gross inland deliveries (observed)",
"calculated": "Gross inland deliveries (calculated)",
}
class JodiGasDemandQueryParams(QueryParams):
"""JODI Gas Demand Query.
Natural gas is a mixture of gaseous hydrocarbons, primarily methane,
but generally also including ethane, propane, and higher hydrocarbons
in much smaller amounts, and some non-combustible gases such as
nitrogen and carbon dioxide. It includes both non-associated and
associated gas. Colliery gas, coal seam gas, and shale gas are
included, while manufactured gas and biogas are excluded except when
blended with natural gas for final consumption. Natural gas liquids
are excluded.
Source: https://www.jodidata.org/gas/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"measure": {"choices": list(MEASURE_CODES)},
"unit": {"choices": list(GAS_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
measure: Literal["observed", "calculated"] = Field(
default="observed",
description="The demand measure. JODI questionnaire definitions:\n"
+ " observed: "
+ GAS_FLOW_DEFINITIONS["demand"]
+ "\n calculated: "
+ GAS_FLOW_DEFINITIONS["demand_calculated"],
)
unit: GasUnitType = Field(
default="m3",
description=GAS_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2009-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("measure", "unit", mode="before", check_fields=False)
@classmethod
def _normalize_tokens(cls, v, info):
"""Normalize token parameters."""
from openbb_jodi.utils.helpers import normalize_token
defaults = {"measure": "observed", "unit": "m3"}
return normalize_token(v, defaults[str(info.field_name)])
class JodiGasDemandData(JodiCountryData):
"""JODI Gas Demand Data.
One column per reporting country. Values are gross inland deliveries
of natural gas, as selected by the measure parameter.
"""
class JodiGasDemandFetcher(Fetcher[JodiGasDemandQueryParams, list[JodiGasDemandData]]):
"""JODI Gas Demand Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiGasDemandQueryParams:
"""Transform the query params."""
return JodiGasDemandQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiGasDemandQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Gas world database file."""
from openbb_jodi.utils.constants import COUNTRIES, GAS_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_gas, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, GAS_START_YEAR
)
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_gas(
countries=countries,
flow_codes={MEASURE_CODES[query.measure]},
unit_codes={GAS_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiGasDemandQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiGasDemandData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": "Natural gas",
"flow": MEASURE_LABELS[query.measure],
"unit": GAS_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Gas World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiGasDemandData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,167 @@
"""JODI Gas Exports Model."""
from datetime import date as dateType
from typing import Any, Literal
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
GAS_FLOW_DEFINITIONS,
GAS_UNIT_DESCRIPTION,
GAS_UNIT_LABELS,
GAS_UNITS,
USE_CACHE_DESCRIPTION,
GasUnitType,
)
FLOW_CODES = {"total": "TOTEXPSB", "pipeline": "EXPPIP", "lng": "EXPLNG"}
FLOW_LABELS = {
"total": "Exports",
"pipeline": "Pipeline exports",
"lng": "LNG exports",
}
class JodiGasExportsQueryParams(QueryParams):
"""JODI Gas Exports Query.
Natural gas is a mixture of gaseous hydrocarbons, primarily methane,
but generally also including ethane, propane, and higher hydrocarbons
in much smaller amounts, and some non-combustible gases such as
nitrogen and carbon dioxide. It includes both non-associated and
associated gas. Colliery gas, coal seam gas, and shale gas are
included, while manufactured gas and biogas are excluded except when
blended with natural gas for final consumption. Natural gas liquids
are excluded.
Source: https://www.jodidata.org/gas/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"flow": {"choices": list(FLOW_CODES)},
"unit": {"choices": list(GAS_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
flow: Literal["total", "pipeline", "lng"] = Field(
default="total",
description="The exports breakdown. JODI questionnaire definitions:\n"
+ " total: "
+ GAS_FLOW_DEFINITIONS["exports"]
+ "\n pipeline: "
+ GAS_FLOW_DEFINITIONS["pipeline_exports"]
+ "\n lng: "
+ GAS_FLOW_DEFINITIONS["lng_exports"],
)
unit: GasUnitType = Field(
default="m3",
description=GAS_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2009-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("flow", "unit", mode="before", check_fields=False)
@classmethod
def _normalize_tokens(cls, v, info):
"""Normalize token parameters."""
from openbb_jodi.utils.helpers import normalize_token
defaults = {"flow": "total", "unit": "m3"}
return normalize_token(v, defaults[str(info.field_name)])
class JodiGasExportsData(JodiCountryData):
"""JODI Gas Exports Data.
One column per reporting country. Exports are amounts that have
crossed the physical boundaries of the country, whether customs
clearance has taken place or not; deliveries for international
bunkers are excluded.
"""
class JodiGasExportsFetcher(
Fetcher[JodiGasExportsQueryParams, list[JodiGasExportsData]]
):
"""JODI Gas Exports Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiGasExportsQueryParams:
"""Transform the query params."""
return JodiGasExportsQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiGasExportsQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Gas world database file."""
from openbb_jodi.utils.constants import COUNTRIES, GAS_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_gas, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, GAS_START_YEAR
)
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_gas(
countries=countries,
flow_codes={FLOW_CODES[query.flow]},
unit_codes={GAS_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiGasExportsQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiGasExportsData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": "Natural gas",
"flow": FLOW_LABELS[query.flow],
"unit": GAS_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Gas World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiGasExportsData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,167 @@
"""JODI Gas Imports Model."""
from datetime import date as dateType
from typing import Any, Literal
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
GAS_FLOW_DEFINITIONS,
GAS_UNIT_DESCRIPTION,
GAS_UNIT_LABELS,
GAS_UNITS,
USE_CACHE_DESCRIPTION,
GasUnitType,
)
FLOW_CODES = {"total": "TOTIMPSB", "pipeline": "IMPPIP", "lng": "IMPLNG"}
FLOW_LABELS = {
"total": "Imports",
"pipeline": "Pipeline imports",
"lng": "LNG imports",
}
class JodiGasImportsQueryParams(QueryParams):
"""JODI Gas Imports Query.
Natural gas is a mixture of gaseous hydrocarbons, primarily methane,
but generally also including ethane, propane, and higher hydrocarbons
in much smaller amounts, and some non-combustible gases such as
nitrogen and carbon dioxide. It includes both non-associated and
associated gas. Colliery gas, coal seam gas, and shale gas are
included, while manufactured gas and biogas are excluded except when
blended with natural gas for final consumption. Natural gas liquids
are excluded.
Source: https://www.jodidata.org/gas/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"flow": {"choices": list(FLOW_CODES)},
"unit": {"choices": list(GAS_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
flow: Literal["total", "pipeline", "lng"] = Field(
default="total",
description="The imports breakdown. JODI questionnaire definitions:\n"
+ " total: "
+ GAS_FLOW_DEFINITIONS["imports"]
+ "\n pipeline: "
+ GAS_FLOW_DEFINITIONS["pipeline_imports"]
+ "\n lng: "
+ GAS_FLOW_DEFINITIONS["lng_imports"],
)
unit: GasUnitType = Field(
default="m3",
description=GAS_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2009-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("flow", "unit", mode="before", check_fields=False)
@classmethod
def _normalize_tokens(cls, v, info):
"""Normalize token parameters."""
from openbb_jodi.utils.helpers import normalize_token
defaults = {"flow": "total", "unit": "m3"}
return normalize_token(v, defaults[str(info.field_name)])
class JodiGasImportsData(JodiCountryData):
"""JODI Gas Imports Data.
One column per reporting country. Imports are amounts that have
crossed the physical boundaries of the country, whether customs
clearance has taken place or not; deliveries for international
bunkers are excluded.
"""
class JodiGasImportsFetcher(
Fetcher[JodiGasImportsQueryParams, list[JodiGasImportsData]]
):
"""JODI Gas Imports Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiGasImportsQueryParams:
"""Transform the query params."""
return JodiGasImportsQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiGasImportsQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Gas world database file."""
from openbb_jodi.utils.constants import COUNTRIES, GAS_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_gas, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, GAS_START_YEAR
)
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_gas(
countries=countries,
flow_codes={FLOW_CODES[query.flow]},
unit_codes={GAS_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiGasImportsQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiGasImportsData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": "Natural gas",
"flow": FLOW_LABELS[query.flow],
"unit": GAS_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Gas World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiGasImportsData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,149 @@
"""JODI Gas Production Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
GAS_UNIT_DESCRIPTION,
GAS_UNIT_LABELS,
GAS_UNITS,
USE_CACHE_DESCRIPTION,
GasUnitType,
)
class JodiGasProductionQueryParams(QueryParams):
"""JODI Gas Production Query.
Natural gas is a mixture of gaseous hydrocarbons, primarily methane,
but generally also including ethane, propane, and higher hydrocarbons
in much smaller amounts, and some non-combustible gases such as
nitrogen and carbon dioxide. It includes both non-associated and
associated gas. Colliery gas, coal seam gas, and shale gas are
included, while manufactured gas and biogas are excluded except when
blended with natural gas for final consumption. Natural gas liquids
are excluded.
Source: https://www.jodidata.org/gas/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"unit": {"choices": list(GAS_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
unit: GasUnitType = Field(
default="m3",
description=GAS_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2009-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("unit", mode="before", check_fields=False)
@classmethod
def _normalize_unit(cls, v):
"""Normalize unit."""
from openbb_jodi.utils.helpers import normalize_token
return normalize_token(v, "m3")
class JodiGasProductionData(JodiCountryData):
"""JODI Gas Production Data.
One column per reporting country. Production is dry marketable
production within national boundaries, including offshore production,
measured after purification and extraction of NGL and sulphur; it
excludes quantities reinjected, extraction losses, and quantities
vented or flared, and includes quantities used within the natural gas
industry.
"""
class JodiGasProductionFetcher(
Fetcher[JodiGasProductionQueryParams, list[JodiGasProductionData]]
):
"""JODI Gas Production Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiGasProductionQueryParams:
"""Transform the query params."""
return JodiGasProductionQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiGasProductionQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Gas world database file."""
from openbb_jodi.utils.constants import COUNTRIES, GAS_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_gas, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, GAS_START_YEAR
)
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_gas(
countries=countries,
flow_codes={"INDPROD"},
unit_codes={GAS_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiGasProductionQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiGasProductionData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": "Natural gas",
"flow": "Production",
"unit": GAS_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Gas World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiGasProductionData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,144 @@
"""JODI Gas Stocks Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
GAS_UNIT_DESCRIPTION,
GAS_UNIT_LABELS,
GAS_UNITS,
USE_CACHE_DESCRIPTION,
GasUnitType,
)
class JodiGasStocksQueryParams(QueryParams):
"""JODI Gas Stocks Query.
Natural gas is a mixture of gaseous hydrocarbons, primarily methane,
but generally also including ethane, propane, and higher hydrocarbons
in much smaller amounts, and some non-combustible gases such as
nitrogen and carbon dioxide. It includes both non-associated and
associated gas. Colliery gas, coal seam gas, and shale gas are
included, while manufactured gas and biogas are excluded except when
blended with natural gas for final consumption. Natural gas liquids
are excluded.
Source: https://www.jodidata.org/gas/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"unit": {"choices": list(GAS_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
unit: GasUnitType = Field(
default="m3",
description=GAS_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2009-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("unit", mode="before", check_fields=False)
@classmethod
def _normalize_unit(cls, v):
"""Normalize unit."""
from openbb_jodi.utils.helpers import normalize_token
return normalize_token(v, "m3")
class JodiGasStocksData(JodiCountryData):
"""JODI Gas Stocks Data.
One column per reporting country. Closing stocks are the stock level
held on the national territory on the last day of the reference
month.
"""
class JodiGasStocksFetcher(Fetcher[JodiGasStocksQueryParams, list[JodiGasStocksData]]):
"""JODI Gas Stocks Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiGasStocksQueryParams:
"""Transform the query params."""
return JodiGasStocksQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiGasStocksQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Gas world database file."""
from openbb_jodi.utils.constants import COUNTRIES, GAS_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_gas, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, GAS_START_YEAR
)
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_gas(
countries=countries,
flow_codes={"CLOSTLV"},
unit_codes={GAS_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiGasStocksQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiGasStocksData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": "Natural gas",
"flow": "Closing stocks",
"unit": GAS_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Gas World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiGasStocksData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,225 @@
"""JODI Oil Balance Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import (
DATA_DESCRIPTIONS,
QUERY_DESCRIPTIONS,
)
from pydantic import Field, field_validator
from openbb_jodi.utils.constants import (
COUNTRY_SCHEMA_SINGLE,
COUNTRY_SINGLE_DESCRIPTION,
OIL_FLOW_DEFINITIONS,
OIL_PRODUCT_CHOICES_DESCRIPTION,
OIL_PRODUCT_LABELS,
OIL_PRODUCTS,
OIL_UNIT_DESCRIPTION,
OIL_UNIT_LABELS,
OIL_UNITS,
USE_CACHE_DESCRIPTION,
OilProductType,
OilUnitType,
)
class JodiOilBalanceQueryParams(QueryParams):
"""JODI Oil Balance Query.
Source: https://www.jodidata.org/oil/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_SINGLE,
"product": {"choices": list(OIL_PRODUCTS)},
"unit": {"choices": list(OIL_UNITS)},
}
country: str = Field(
default="united_states",
description=COUNTRY_SINGLE_DESCRIPTION,
)
product: OilProductType = Field(
default="crude_oil",
description="The petroleum product to get the balance for."
+ " The first four are primary products, the rest are secondary"
+ " products. JODI questionnaire definitions:\n"
+ OIL_PRODUCT_CHOICES_DESCRIPTION,
)
unit: OilUnitType = Field(
default="kbd",
description=OIL_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2002-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=False, default="united_states")
@field_validator("product", "unit", mode="before", check_fields=False)
@classmethod
def _normalize_tokens(cls, v, info):
"""Normalize token parameters."""
from openbb_jodi.utils.helpers import normalize_token
defaults = {"product": "crude_oil", "unit": "kbd"}
return normalize_token(v, defaults[str(info.field_name)])
class JodiOilBalanceData(Data):
"""JODI Oil Balance Data.
One column per flow of the JODI-Oil questionnaire, in published order.
Primary-only flows are None for secondary products, and vice versa.
"""
date: dateType = Field(description=DATA_DESCRIPTIONS.get("date", ""))
production: float | None = Field(
default=None,
description="Production (primary products). "
+ OIL_FLOW_DEFINITIONS["production"],
)
refinery_output: float | None = Field(
default=None,
description="Refinery output (secondary products). "
+ OIL_FLOW_DEFINITIONS["refinery_output"],
)
from_other_sources: float | None = Field(
default=None,
description="From other sources (primary products). "
+ OIL_FLOW_DEFINITIONS["from_other_sources"],
)
receipts: float | None = Field(
default=None,
description="Receipts (secondary products). "
+ OIL_FLOW_DEFINITIONS["receipts"],
)
imports: float | None = Field(
default=None,
description="Imports. " + OIL_FLOW_DEFINITIONS["imports"],
)
exports: float | None = Field(
default=None,
description="Exports. " + OIL_FLOW_DEFINITIONS["exports"],
)
products_transferred: float | None = Field(
default=None,
description="Products transferred. "
+ OIL_FLOW_DEFINITIONS["products_transferred"],
)
interproduct_transfers: float | None = Field(
default=None,
description="Interproduct transfers (secondary products). "
+ OIL_FLOW_DEFINITIONS["interproduct_transfers"],
)
direct_use: float | None = Field(
default=None,
description="Direct use (primary products). "
+ OIL_FLOW_DEFINITIONS["direct_use"],
)
stock_change: float | None = Field(
default=None,
description="Stock change. " + OIL_FLOW_DEFINITIONS["stock_change"],
)
statistical_difference: float | None = Field(
default=None,
description="Statistical difference. "
+ OIL_FLOW_DEFINITIONS["statistical_difference"],
)
refinery_intake: float | None = Field(
default=None,
description="Refinery intake (primary products). "
+ OIL_FLOW_DEFINITIONS["refinery_intake"],
)
demand: float | None = Field(
default=None,
description="Demand (secondary products). " + OIL_FLOW_DEFINITIONS["demand"],
)
closing_stocks: float | None = Field(
default=None,
description="Closing stocks. " + OIL_FLOW_DEFINITIONS["closing_stocks"],
)
class JodiOilBalanceFetcher(
Fetcher[JodiOilBalanceQueryParams, list[JodiOilBalanceData]]
):
"""JODI Oil Balance Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiOilBalanceQueryParams:
"""Transform the query params."""
return JodiOilBalanceQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiOilBalanceQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Oil annual CSV files."""
from openbb_jodi.utils.constants import (
COUNTRIES,
OIL_BALANCE_FIELDS,
OIL_START_YEAR,
)
from openbb_jodi.utils.helpers import get_filtered_oil, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, OIL_START_YEAR
)
product_code, table = OIL_PRODUCTS[query.product]
return await get_filtered_oil(
tables=[table],
countries={COUNTRIES[query.country]},
product_codes={product_code},
flow_codes=set(OIL_BALANCE_FIELDS),
unit_codes={OIL_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiOilBalanceQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiOilBalanceData]]:
"""Transform the raw data into the balance table."""
from openbb_jodi.utils.constants import COUNTRY_LABELS, OIL_BALANCE_FIELDS
from openbb_jodi.utils.helpers import build_assessments, build_field_table
rows = build_field_table(data, "FLOW_BREAKDOWN", OIL_BALANCE_FIELDS)
code = data[0]["REF_AREA"]
metadata = {
"country": COUNTRY_LABELS.get(code, code),
"product": OIL_PRODUCT_LABELS[OIL_PRODUCTS[query.product][0]],
"unit": OIL_UNIT_LABELS[query.unit],
"assessments": build_assessments(
data, "FLOW_BREAKDOWN", OIL_BALANCE_FIELDS
),
"source": "JODI-Oil World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiOilBalanceData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,154 @@
"""JODI Oil Demand Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
OIL_PRODUCT_FIELDS,
OIL_PRODUCT_LABELS,
OIL_PRODUCTS,
OIL_SECONDARY_PRODUCT_CHOICES_DESCRIPTION,
OIL_UNIT_DESCRIPTION,
OIL_UNIT_LABELS,
OIL_UNITS,
USE_CACHE_DESCRIPTION,
OilSecondaryProductType,
OilUnitType,
)
SECONDARY_PRODUCTS = [OIL_PRODUCT_FIELDS[code] for code in OIL_PRODUCT_FIELDS]
class JodiOilDemandQueryParams(QueryParams):
"""JODI Oil Demand Query.
Source: https://www.jodidata.org/oil/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"product": {"choices": SECONDARY_PRODUCTS},
"unit": {"choices": list(OIL_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
product: OilSecondaryProductType = Field(
default="total_products",
description="The refined petroleum product to get demand of."
+ " Demand is reported for secondary products only."
+ " JODI questionnaire definitions:\n"
+ OIL_SECONDARY_PRODUCT_CHOICES_DESCRIPTION,
)
unit: OilUnitType = Field(
default="kbd",
description=OIL_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2002-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("product", "unit", mode="before", check_fields=False)
@classmethod
def _normalize_tokens(cls, v, info):
"""Normalize token parameters."""
from openbb_jodi.utils.helpers import normalize_token
defaults = {"product": "total_products", "unit": "kbd"}
return normalize_token(v, defaults[str(info.field_name)])
class JodiOilDemandData(JodiCountryData):
"""JODI Oil Demand Data.
One column per reporting country. Demand is deliveries or sales to
the inland market (domestic consumption) plus refinery fuel plus
international marine and aviation bunkers.
"""
class JodiOilDemandFetcher(Fetcher[JodiOilDemandQueryParams, list[JodiOilDemandData]]):
"""JODI Oil Demand Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiOilDemandQueryParams:
"""Transform the query params."""
return JodiOilDemandQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiOilDemandQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Oil annual CSV files."""
from openbb_jodi.utils.constants import COUNTRIES, OIL_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_oil, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, OIL_START_YEAR
)
product_code, table = OIL_PRODUCTS[query.product]
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_oil(
tables=[table],
countries=countries,
product_codes={product_code},
flow_codes={"TOTDEMO"},
unit_codes={OIL_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiOilDemandQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiOilDemandData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": OIL_PRODUCT_LABELS[OIL_PRODUCTS[query.product][0]],
"flow": "Demand",
"unit": OIL_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Oil World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiOilDemandData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,184 @@
"""JODI Oil Demand By Product Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import (
DATA_DESCRIPTIONS,
QUERY_DESCRIPTIONS,
)
from pydantic import Field, field_validator
from openbb_jodi.utils.constants import (
COUNTRY_SCHEMA_SINGLE,
COUNTRY_SINGLE_DESCRIPTION,
OIL_PRODUCT_DEFINITIONS,
OIL_PRODUCT_FIELDS,
OIL_UNIT_DESCRIPTION,
OIL_UNIT_LABELS,
OIL_UNITS,
USE_CACHE_DESCRIPTION,
OilUnitType,
)
class JodiOilDemandByProductQueryParams(QueryParams):
"""JODI Oil Demand By Product Query.
Source: https://www.jodidata.org/oil/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_SINGLE,
"unit": {"choices": list(OIL_UNITS)},
}
country: str = Field(
default="united_states",
description=COUNTRY_SINGLE_DESCRIPTION,
)
unit: OilUnitType = Field(
default="kbd",
description=OIL_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2002-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=False, default="united_states")
@field_validator("unit", mode="before", check_fields=False)
@classmethod
def _normalize_unit(cls, v):
"""Normalize unit."""
from openbb_jodi.utils.helpers import normalize_token
return normalize_token(v, "kbd")
class JodiOilDemandByProductData(Data):
"""JODI Oil Demand By Product Data.
One column per refined product, in published order. Demand is deliveries
or sales to the inland market (domestic consumption) plus refinery fuel
plus international marine and aviation bunkers.
"""
date: dateType = Field(description=DATA_DESCRIPTIONS.get("date", ""))
lpg: float | None = Field(
default=None,
description="LPG demand. " + OIL_PRODUCT_DEFINITIONS["lpg"],
)
naphtha: float | None = Field(
default=None,
description="Naphtha demand. " + OIL_PRODUCT_DEFINITIONS["naphtha"],
)
gasoline: float | None = Field(
default=None,
description="Gasoline demand. " + OIL_PRODUCT_DEFINITIONS["gasoline"],
)
kerosene: float | None = Field(
default=None,
description="Kerosenes demand. " + OIL_PRODUCT_DEFINITIONS["kerosene"],
)
jet_fuel: float | None = Field(
default=None,
description="Jet fuel demand. " + OIL_PRODUCT_DEFINITIONS["jet_fuel"],
)
gas_diesel_oil: float | None = Field(
default=None,
description="Gas/diesel oil demand. "
+ OIL_PRODUCT_DEFINITIONS["gas_diesel_oil"],
)
fuel_oil: float | None = Field(
default=None,
description="Fuel oil demand. " + OIL_PRODUCT_DEFINITIONS["fuel_oil"],
)
other_products: float | None = Field(
default=None,
description="Other oil products demand. "
+ OIL_PRODUCT_DEFINITIONS["other_products"]
+ " Includes direct use of crude oil, NGL, and other primary hydrocarbons.",
)
total_products: float | None = Field(
default=None,
description="Total oil products demand. "
+ OIL_PRODUCT_DEFINITIONS["total_products"],
)
class JodiOilDemandByProductFetcher(
Fetcher[JodiOilDemandByProductQueryParams, list[JodiOilDemandByProductData]]
):
"""JODI Oil Demand By Product Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiOilDemandByProductQueryParams:
"""Transform the query params."""
return JodiOilDemandByProductQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiOilDemandByProductQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Oil annual CSV files."""
from openbb_jodi.utils.constants import COUNTRIES, OIL_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_oil, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, OIL_START_YEAR
)
return await get_filtered_oil(
tables=["secondary"],
countries={COUNTRIES[query.country]},
product_codes=set(OIL_PRODUCT_FIELDS),
flow_codes={"TOTDEMO"},
unit_codes={OIL_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiOilDemandByProductQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiOilDemandByProductData]]:
"""Transform the raw data into the by-product table."""
from openbb_jodi.utils.constants import COUNTRY_LABELS
from openbb_jodi.utils.helpers import build_assessments, build_field_table
rows = build_field_table(data, "ENERGY_PRODUCT", OIL_PRODUCT_FIELDS)
code = data[0]["REF_AREA"]
metadata = {
"country": COUNTRY_LABELS.get(code, code),
"flow": "Demand",
"unit": OIL_UNIT_LABELS[query.unit],
"assessments": build_assessments(
data, "ENERGY_PRODUCT", OIL_PRODUCT_FIELDS
),
"source": "JODI-Oil World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiOilDemandByProductData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,152 @@
"""JODI Oil Exports Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
OIL_PRODUCT_CHOICES_DESCRIPTION,
OIL_PRODUCT_LABELS,
OIL_PRODUCTS,
OIL_UNIT_DESCRIPTION,
OIL_UNIT_LABELS,
OIL_UNITS,
USE_CACHE_DESCRIPTION,
OilProductType,
OilUnitType,
)
class JodiOilExportsQueryParams(QueryParams):
"""JODI Oil Exports Query.
Source: https://www.jodidata.org/oil/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"product": {"choices": list(OIL_PRODUCTS)},
"unit": {"choices": list(OIL_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
product: OilProductType = Field(
default="crude_oil",
description="The petroleum product to get exports of."
+ " JODI questionnaire definitions:\n"
+ OIL_PRODUCT_CHOICES_DESCRIPTION,
)
unit: OilUnitType = Field(
default="kbd",
description=OIL_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2002-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("product", "unit", mode="before", check_fields=False)
@classmethod
def _normalize_tokens(cls, v, info):
"""Normalize token parameters."""
from openbb_jodi.utils.helpers import normalize_token
defaults = {"product": "crude_oil", "unit": "kbd"}
return normalize_token(v, defaults[str(info.field_name)])
class JodiOilExportsData(JodiCountryData):
"""JODI Oil Exports Data.
One column per reporting country. Exports are goods having physically
crossed the international boundaries, excluding transit trade and
international marine and aviation bunkers.
"""
class JodiOilExportsFetcher(
Fetcher[JodiOilExportsQueryParams, list[JodiOilExportsData]]
):
"""JODI Oil Exports Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiOilExportsQueryParams:
"""Transform the query params."""
return JodiOilExportsQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiOilExportsQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Oil annual CSV files."""
from openbb_jodi.utils.constants import COUNTRIES, OIL_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_oil, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, OIL_START_YEAR
)
product_code, table = OIL_PRODUCTS[query.product]
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_oil(
tables=[table],
countries=countries,
product_codes={product_code},
flow_codes={"TOTEXPSB"},
unit_codes={OIL_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiOilExportsQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiOilExportsData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": OIL_PRODUCT_LABELS[OIL_PRODUCTS[query.product][0]],
"flow": "Exports",
"unit": OIL_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Oil World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiOilExportsData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,152 @@
"""JODI Oil Imports Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
OIL_PRODUCT_CHOICES_DESCRIPTION,
OIL_PRODUCT_LABELS,
OIL_PRODUCTS,
OIL_UNIT_DESCRIPTION,
OIL_UNIT_LABELS,
OIL_UNITS,
USE_CACHE_DESCRIPTION,
OilProductType,
OilUnitType,
)
class JodiOilImportsQueryParams(QueryParams):
"""JODI Oil Imports Query.
Source: https://www.jodidata.org/oil/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"product": {"choices": list(OIL_PRODUCTS)},
"unit": {"choices": list(OIL_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
product: OilProductType = Field(
default="crude_oil",
description="The petroleum product to get imports of."
+ " JODI questionnaire definitions:\n"
+ OIL_PRODUCT_CHOICES_DESCRIPTION,
)
unit: OilUnitType = Field(
default="kbd",
description=OIL_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2002-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("product", "unit", mode="before", check_fields=False)
@classmethod
def _normalize_tokens(cls, v, info):
"""Normalize token parameters."""
from openbb_jodi.utils.helpers import normalize_token
defaults = {"product": "crude_oil", "unit": "kbd"}
return normalize_token(v, defaults[str(info.field_name)])
class JodiOilImportsData(JodiCountryData):
"""JODI Oil Imports Data.
One column per reporting country. Imports are goods having physically
crossed the international boundaries, excluding transit trade and
international marine and aviation bunkers.
"""
class JodiOilImportsFetcher(
Fetcher[JodiOilImportsQueryParams, list[JodiOilImportsData]]
):
"""JODI Oil Imports Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiOilImportsQueryParams:
"""Transform the query params."""
return JodiOilImportsQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiOilImportsQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Oil annual CSV files."""
from openbb_jodi.utils.constants import COUNTRIES, OIL_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_oil, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, OIL_START_YEAR
)
product_code, table = OIL_PRODUCTS[query.product]
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_oil(
tables=[table],
countries=countries,
product_codes={product_code},
flow_codes={"TOTIMPSB"},
unit_codes={OIL_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiOilImportsQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiOilImportsData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": OIL_PRODUCT_LABELS[OIL_PRODUCTS[query.product][0]],
"flow": "Imports",
"unit": OIL_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Oil World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiOilImportsData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,154 @@
"""JODI Oil Production Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
OIL_PRIMARY_PRODUCT_CHOICES_DESCRIPTION,
OIL_PRODUCT_LABELS,
OIL_PRODUCTS,
OIL_UNIT_DESCRIPTION,
OIL_UNIT_LABELS,
OIL_UNITS,
USE_CACHE_DESCRIPTION,
OilPrimaryProductType,
OilUnitType,
)
PRIMARY_PRODUCTS = ["crude_oil", "ngl", "other_crude", "total_crude"]
class JodiOilProductionQueryParams(QueryParams):
"""JODI Oil Production Query.
Source: https://www.jodidata.org/oil/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"product": {"choices": PRIMARY_PRODUCTS},
"unit": {"choices": list(OIL_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
product: OilPrimaryProductType = Field(
default="crude_oil",
description="The primary petroleum product to get production of."
+ " JODI questionnaire definitions:\n"
+ OIL_PRIMARY_PRODUCT_CHOICES_DESCRIPTION,
)
unit: OilUnitType = Field(
default="kbd",
description=OIL_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2002-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("product", "unit", mode="before", check_fields=False)
@classmethod
def _normalize_tokens(cls, v, info):
"""Normalize token parameters."""
from openbb_jodi.utils.helpers import normalize_token
defaults = {"product": "crude_oil", "unit": "kbd"}
return normalize_token(v, defaults[str(info.field_name)])
class JodiOilProductionData(JodiCountryData):
"""JODI Oil Production Data.
One column per reporting country. Production is marketed production,
after removal of impurities but including quantities consumed by the
producer in the production process.
"""
class JodiOilProductionFetcher(
Fetcher[JodiOilProductionQueryParams, list[JodiOilProductionData]]
):
"""JODI Oil Production Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiOilProductionQueryParams:
"""Transform the query params."""
return JodiOilProductionQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiOilProductionQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Oil annual CSV files."""
from openbb_jodi.utils.constants import COUNTRIES, OIL_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_oil, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, OIL_START_YEAR
)
product_code, table = OIL_PRODUCTS[query.product]
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_oil(
tables=[table],
countries=countries,
product_codes={product_code},
flow_codes={"INDPROD"},
unit_codes={OIL_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiOilProductionQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiOilProductionData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": OIL_PRODUCT_LABELS[OIL_PRODUCTS[query.product][0]],
"flow": "Production",
"unit": OIL_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Oil World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiOilProductionData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,151 @@
"""JODI Oil Stocks Model."""
from datetime import date as dateType
from typing import Any
from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field, field_validator
from openbb_jodi.models.country_data import JodiCountryData
from openbb_jodi.utils.constants import (
COUNTRY_MULTI_DESCRIPTION,
COUNTRY_SCHEMA_MULTI,
OIL_PRODUCT_CHOICES_DESCRIPTION,
OIL_PRODUCT_LABELS,
OIL_PRODUCTS,
OIL_UNIT_DESCRIPTION,
OIL_UNIT_LABELS,
OIL_UNITS,
USE_CACHE_DESCRIPTION,
OilProductType,
OilUnitType,
)
class JodiOilStocksQueryParams(QueryParams):
"""JODI Oil Stocks Query.
Source: https://www.jodidata.org/oil/database/data-downloads.aspx
"""
__json_schema_extra__ = {
"country": COUNTRY_SCHEMA_MULTI,
"product": {"choices": list(OIL_PRODUCTS)},
"unit": {"choices": list(OIL_UNITS)},
}
country: str | None = Field(
default=None,
description=COUNTRY_MULTI_DESCRIPTION,
)
product: OilProductType = Field(
default="crude_oil",
description="The petroleum product to get closing stock levels of."
+ " JODI questionnaire definitions:\n"
+ OIL_PRODUCT_CHOICES_DESCRIPTION,
)
unit: OilUnitType = Field(
default="kbbl",
description=OIL_UNIT_DESCRIPTION,
)
start_date: dateType | None = Field(
default=None,
description=QUERY_DESCRIPTIONS.get("start_date", "")
+ " Defaults to the first data period, 2002-01-01.",
)
end_date: dateType | None = Field(
default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
use_cache: bool = Field(default=True, description=USE_CACHE_DESCRIPTION)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def _validate_country(cls, v):
"""Validate country."""
from openbb_jodi.utils.helpers import validate_country_param
return validate_country_param(v, multiple=True, default=None)
@field_validator("product", "unit", mode="before", check_fields=False)
@classmethod
def _normalize_tokens(cls, v, info):
"""Normalize token parameters."""
from openbb_jodi.utils.helpers import normalize_token
defaults = {"product": "crude_oil", "unit": "kbbl"}
return normalize_token(v, defaults[str(info.field_name)])
class JodiOilStocksData(JodiCountryData):
"""JODI Oil Stocks Data.
One column per reporting country. Closing stocks are the primary
stock level at the end of the month within national territories,
including stocks held by importers, refiners, stock-holding
organisations, and governments.
"""
class JodiOilStocksFetcher(Fetcher[JodiOilStocksQueryParams, list[JodiOilStocksData]]):
"""JODI Oil Stocks Fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> JodiOilStocksQueryParams:
"""Transform the query params."""
return JodiOilStocksQueryParams(**params)
@staticmethod
async def aextract_data(
query: JodiOilStocksQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Extract the data from the JODI-Oil annual CSV files."""
from openbb_jodi.utils.constants import COUNTRIES, OIL_START_YEAR
from openbb_jodi.utils.helpers import get_filtered_oil, resolve_date_range
start_date, end_date = resolve_date_range(
query.start_date, query.end_date, OIL_START_YEAR
)
product_code, table = OIL_PRODUCTS[query.product]
countries = (
{COUNTRIES[country] for country in query.country.split(",")}
if query.country
else None
)
return await get_filtered_oil(
tables=[table],
countries=countries,
product_codes={product_code},
flow_codes={"CLOSTLV"},
unit_codes={OIL_UNITS[query.unit]},
start_date=start_date,
end_date=end_date,
use_cache=query.use_cache,
)
@staticmethod
def transform_data(
query: JodiOilStocksQueryParams,
data: list[dict],
**kwargs: Any,
) -> AnnotatedResult[list[JodiOilStocksData]]:
"""Transform the raw data into the by-country table."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
from openbb_jodi.utils.helpers import build_assessments, build_country_table
rows = build_country_table(data)
metadata = {
"product": OIL_PRODUCT_LABELS[OIL_PRODUCTS[query.product][0]],
"flow": "Closing stocks",
"unit": OIL_UNIT_LABELS[query.unit],
"assessments": build_assessments(data, "REF_AREA", CODE_TO_COUNTRY),
"source": "JODI-Oil World Database (www.jodidata.org)",
}
return AnnotatedResult(
result=[JodiOilStocksData.model_validate(r) for r in rows],
metadata=metadata,
)

View File

@@ -0,0 +1,214 @@
"""JODI Oil Router."""
from openbb_core.app.model.command_context import CommandContext
from openbb_core.app.model.example import APIEx
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.provider_interface import (
ExtraParams,
ProviderChoices,
StandardParams,
)
from openbb_core.app.query import Query as OpenBBQuery
from openbb_core.app.router import Router
router = Router(
prefix="/oil",
description="JODI-Oil World Database."
+ " Monthly data from January 2002 to the most recent month.",
)
@router.command(
model="JodiOilBalance",
examples=[
APIEx(
description="Get the United States crude oil balance - one column per questionnaire flow.",
parameters={"provider": "jodi"},
),
APIEx(
description="Get India's gasoline balance, in thousand metric tons.",
parameters={
"provider": "jodi",
"country": "india",
"product": "gasoline",
"unit": "ktons",
"start_date": "2020-01-01",
},
),
],
)
async def balance(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Get a country's monthly petroleum balance for one product - production, trade, stocks, and refinery activity, one column per flow of the JODI questionnaire."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiOilProduction",
examples=[
APIEx(
description="Get monthly crude oil production - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare crude oil production in Saudi Arabia, Iraq, and the United States.",
parameters={
"provider": "jodi",
"country": "saudi_arabia,iraq,united_states",
"start_date": "2020-01-01",
},
),
],
)
async def production(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly production of a primary petroleum product - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiOilDemand",
examples=[
APIEx(
description="Get monthly total petroleum products demand - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare gasoline demand in the United States, China, and India.",
parameters={
"provider": "jodi",
"country": "united_states,china,india",
"product": "gasoline",
"start_date": "2020-01-01",
},
),
],
)
async def demand(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly demand for a refined petroleum product - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiOilDemandByProduct",
examples=[
APIEx(
description="Get monthly United States demand - one column per refined product.",
parameters={"provider": "jodi"},
),
APIEx(
description="Get Japan's demand barrel, in thousand barrels per day.",
parameters={
"provider": "jodi",
"country": "japan",
"unit": "kbd",
"start_date": "2020-01-01",
},
),
],
)
async def demand_by_product(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Get a country's monthly petroleum demand - one column per refined product, in published order."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiOilImports",
examples=[
APIEx(
description="Get monthly crude oil imports - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare crude oil imports of China, India, and South Korea.",
parameters={
"provider": "jodi",
"country": "china,india,south_korea",
"start_date": "2020-01-01",
},
),
],
)
async def imports(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly imports of a petroleum product - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiOilExports",
examples=[
APIEx(
description="Get monthly crude oil exports - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare fuel oil exports from Russia and Saudi Arabia.",
parameters={
"provider": "jodi",
"country": "russia,saudi_arabia",
"product": "fuel_oil",
"start_date": "2020-01-01",
},
),
],
)
async def exports(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly exports of a petroleum product - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))
@router.command(
model="JodiOilStocks",
examples=[
APIEx(
description="Get monthly crude oil closing stock levels - one column per reporting country.",
parameters={"provider": "jodi"},
),
APIEx(
description="Compare gasoline stocks in the United States and Japan, in thousand barrels.",
parameters={
"provider": "jodi",
"country": "united_states,japan",
"product": "gasoline",
"unit": "kbbl",
"start_date": "2020-01-01",
},
),
],
)
async def stocks(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Monthly closing stock levels of a petroleum product - one column per reporting country."""
return await OBBject.from_query(OpenBBQuery(**locals()))

View File

View File

@@ -0,0 +1 @@
"""OpenBB JODI Provider Utilities."""

View File

@@ -0,0 +1,540 @@
"""OpenBB JODI Provider Module Constants."""
from typing import Literal
OilProductType = Literal[
"crude_oil",
"ngl",
"other_crude",
"total_crude",
"lpg",
"naphtha",
"gasoline",
"kerosene",
"jet_fuel",
"gas_diesel_oil",
"fuel_oil",
"other_products",
"total_products",
]
OilPrimaryProductType = Literal["crude_oil", "ngl", "other_crude", "total_crude"]
OilSecondaryProductType = Literal[
"lpg",
"naphtha",
"gasoline",
"kerosene",
"jet_fuel",
"gas_diesel_oil",
"fuel_oil",
"other_products",
"total_products",
]
OilUnitType = Literal["kbd", "kbbl", "kl", "ktons", "conversion_factor"]
GasUnitType = Literal["m3", "tj", "ktons"]
OIL_TABLE_URLS = {
"primary": "https://www.jodidata.org/_resources/files/downloads/oil-data/world_Primary_CSV.zip",
"secondary": "https://www.jodidata.org/_resources/files/downloads/oil-data/world_Secondary_CSV.zip",
}
GAS_FILES_URL = "https://api.publisher.jodidata.org/web/files/gas"
GAS_DOWNLOAD_URL = (
"https://www.jodidata.org/jodi-publisher/gas/{publication_id}/{filename}"
)
OIL_START_YEAR = 2002
GAS_START_YEAR = 2009
# Item codes and full names per 'A Guide on JODI-Oil World Database Item Names' (2017)
# and 'A Guide on JODI-Gas World Database Item Names' (2025).
OIL_PRODUCTS = {
"crude_oil": ("CRUDEOIL", "primary"),
"ngl": ("NGL", "primary"),
"other_crude": ("OTHERCRUDE", "primary"),
"total_crude": ("TOTCRUDE", "primary"),
"lpg": ("LPG", "secondary"),
"naphtha": ("NAPHTHA", "secondary"),
"gasoline": ("GASOLINE", "secondary"),
"kerosene": ("KEROSENE", "secondary"),
"jet_fuel": ("JETKERO", "secondary"),
"gas_diesel_oil": ("GASDIES", "secondary"),
"fuel_oil": ("RESFUEL", "secondary"),
"other_products": ("ONONSPEC", "secondary"),
"total_products": ("TOTPRODS", "secondary"),
}
OIL_PRODUCT_LABELS = {
"CRUDEOIL": "Crude oil",
"NGL": "NGL",
"OTHERCRUDE": "Other primary",
"TOTCRUDE": "Total primary",
"LPG": "Liquefied petroleum gases",
"NAPHTHA": "Naphtha",
"GASOLINE": "Motor and aviation gasoline",
"KEROSENE": "Kerosenes",
"JETKERO": "Kerosene type jet fuel",
"GASDIES": "Gas/diesel oil",
"RESFUEL": "Fuel oil",
"ONONSPEC": "Other oil products",
"TOTPRODS": "Total oil products",
}
OIL_FLOWS = {
"production": {"primary": "INDPROD"},
"from_other_sources": {"primary": "OSOURCES"},
"imports": {"primary": "TOTIMPSB", "secondary": "TOTIMPSB"},
"exports": {"primary": "TOTEXPSB", "secondary": "TOTEXPSB"},
"products_transferred": {"primary": "TRANSBAK", "secondary": "PTRANSF"},
"direct_use": {"primary": "DIRECUSE"},
"stock_change": {"primary": "STOCKCH", "secondary": "STOCKCH"},
"statistical_difference": {"primary": "STATDIFF", "secondary": "STATDIFF"},
"refinery_intake": {"primary": "REFINOBS"},
"refinery_output": {"secondary": "REFGROUT"},
"receipts": {"secondary": "RECEIPTS"},
"interproduct_transfers": {"secondary": "IPTRANSF"},
"demand": {"secondary": "TOTDEMO"},
"closing_stocks": {"primary": "CLOSTLV", "secondary": "CLOSTLV"},
}
OIL_FLOW_LABELS = {
"INDPROD": "Production",
"OSOURCES": "From other sources",
"TOTIMPSB": "Imports",
"TOTEXPSB": "Exports",
"TRANSBAK": "Products transferred/Backflows",
"PTRANSF": "Products transferred",
"DIRECUSE": "Direct use",
"STOCKCH": "Stock change",
"STATDIFF": "Statistical difference",
"REFINOBS": "Refinery intake",
"REFGROUT": "Refinery output",
"RECEIPTS": "Receipts",
"IPTRANSF": "Interproduct transfers",
"TOTDEMO": "Demand",
"CLOSTLV": "Closing stocks",
}
OIL_UNITS = {
"kbd": "KBD",
"kbbl": "KBBL",
"kl": "KL",
"ktons": "KTONS",
"conversion_factor": "CONVBBL",
}
OIL_UNIT_LABELS = {
"kbd": "Thousand barrels per day",
"kbbl": "Thousand barrels",
"kl": "Thousand kilolitres",
"ktons": "Thousand metric tons",
"conversion_factor": "Conversion factor barrels/ktons",
}
GAS_FLOWS = {
"production": "INDPROD",
"from_other_sources": "OSOURCES",
"imports": "TOTIMPSB",
"lng_imports": "IMPLNG",
"pipeline_imports": "IMPPIP",
"exports": "TOTEXPSB",
"lng_exports": "EXPLNG",
"pipeline_exports": "EXPPIP",
"stock_change": "STOCKCH",
"demand": "TOTDEMO",
"demand_calculated": "TOTDEMC",
"electricity_and_heat_generation": "MAINTOT",
"statistical_difference": "STATDIFF",
"closing_stocks": "CLOSTLV",
}
GAS_FLOW_LABELS = {
"INDPROD": "Production",
"OSOURCES": "Receipts from other sources",
"TOTIMPSB": "Imports",
"IMPLNG": "LNG imports",
"IMPPIP": "Pipeline imports",
"TOTEXPSB": "Exports",
"EXPLNG": "LNG exports",
"EXPPIP": "Pipeline exports",
"STOCKCH": "Stock change",
"TOTDEMO": "Gross inland deliveries (observed)",
"TOTDEMC": "Gross inland deliveries (calculated)",
"MAINTOT": "Of which: electricity and heat generation",
"STATDIFF": "Statistical difference",
"CLOSTLV": "Closing stocks",
}
GAS_UNITS = {
"m3": "M3",
"tj": "TJ",
"ktons": "KTONS",
}
GAS_UNIT_LABELS = {
"m3": "Million cubic metres",
"tj": "Terajoules",
"ktons": "Thousand metric tons (LNG)",
}
ASSESSMENT_LABELS = {
"1": "Reasonable levels of comparability",
"2": "Consult metadata/use with caution",
"3": "Not assessed",
"4": "Under verification",
}
# Definitions per the JODI-Oil Monthly Questionnaire Short Definitions and the
# JODI-Gas Questionnaire brief reporting guide (www.jodidata.org).
NATURAL_GAS_DEFINITION = (
"Natural gas is a mixture of gaseous hydrocarbons, primarily methane, but"
" generally also including ethane, propane, and higher hydrocarbons in much"
" smaller amounts, and some non-combustible gases such as nitrogen and carbon"
" dioxide. It includes both non-associated and associated gas. Colliery gas,"
" coal seam gas, and shale gas are included, while manufactured gas and biogas"
" are excluded except when blended with natural gas for final consumption."
" Natural gas liquids are excluded."
)
OIL_PRODUCT_DEFINITIONS = {
"crude_oil": "Crude oil, including lease condensate and excluding NGL.",
"ngl": "Liquid or liquefied hydrocarbons recovered from gas separation"
" plants and gas processing facilities.",
"other_crude": "Refinery feedstocks, additives/oxygenates, and other hydrocarbons.",
"total_crude": "The sum of the primary categories: crude oil + NGL + other.",
"lpg": "Liquefied petroleum gases, comprising propane and butane.",
"naphtha": "Naphtha used as feedstock for producing high octane gasoline"
" and as feedstock for the chemical/petrochemical industries.",
"gasoline": "Motor gasoline and aviation gasoline; motor gasoline includes"
" biogasoline, e.g. ethanol blends.",
"kerosene": "Kerosenes, comprising jet kerosene and other kerosene.",
"jet_fuel": "Kerosene type jet fuel: aviation fuel used for aviation"
" turbine power units; a subset of the amount reported under kerosenes.",
"gas_diesel_oil": "Gas/diesel oil, for automotive and other purposes;"
" biodiesel is included.",
"fuel_oil": "Heavy residual oil/boiler oil, including bunker oil.",
"other_products": "Refinery gas, ethane, petroleum coke, lubricants, white"
" spirit and SBP, bitumen, paraffin waxes, and other petroleum products.",
"total_products": "The sum of all secondary product categories; demand for"
" total products includes direct use of crude oil, NGL, and other"
" hydrocarbons.",
}
OIL_FLOW_DEFINITIONS = {
"production": "Marketed production, after removal of impurities but"
" including quantities consumed by the producer in the production process.",
"refinery_output": "Gross refinery output of finished products, including"
" refinery fuel.",
"from_other_sources": "Inputs of additives, biofuels, and other"
" hydrocarbons produced from non-oil sources such as coal, natural gas,"
" and renewable energy.",
"receipts": "Primary product receipts (quantities of oil used directly"
" without processing in a refinery) plus recycled products. Receipts for"
" other oil products include direct use of crude oil and NGL.",
"imports": "Goods having physically crossed the international boundaries,"
" excluding transit trade and international marine and aviation bunkers.",
"exports": "Goods having physically crossed the international boundaries,"
" excluding transit trade and international marine and aviation bunkers.",
"products_transferred": "For primary products, the sum of products"
" transferred and backflows from the petrochemical industry; for secondary"
" products, imported petroleum products reclassified as feedstocks for"
" further processing in the refinery, without delivery to final consumers.",
"interproduct_transfers": "Reclassification of products because their"
" specification has changed or because they are blended into another"
" product; a negative entry indicates a product to be reclassified, a"
" positive entry a reclassified product. Interproduct transfers for other"
" oil products include interproduct transfers of crude oil and NGL.",
"direct_use": "Crude oil, NGL, and other hydrocarbons used directly"
" without being processed in oil refineries, for example crude oil burned"
" for electricity generation.",
"stock_change": "Closing minus opening stock level; a positive number"
" corresponds to a stock build, a negative number to a stock draw.",
"statistical_difference": "Differences between observed supply flows and"
" refinery intake or demand.",
"refinery_intake": "Observed refinery throughputs.",
"demand": "Deliveries or sales to the inland market (domestic consumption)"
" plus refinery fuel plus international marine and aviation bunkers."
" Demand for other oil products includes direct use of crude oil, NGL,"
" and other primary hydrocarbons.",
"closing_stocks": "The primary stock level at the end of the month within"
" national territories; includes stocks held by importers, refiners,"
" stock-holding organisations, and governments.",
}
GAS_FLOW_DEFINITIONS = {
"production": "Dry marketable production within national boundaries,"
" including offshore production, measured after purification and extraction"
" of NGL and sulphur; excludes quantities reinjected, extraction losses,"
" and quantities vented or flared; includes quantities used within the"
" natural gas industry, in gas extraction, pipeline systems, and processing"
" plants.",
"from_other_sources": "Gas from energy products already accounted for in"
" the production of other energy products, for example petroleum gases or"
" biogases blended with natural gas.",
"imports": "Amounts that have crossed the physical boundaries of the"
" country, whether customs clearance has taken place or not; excludes goods"
" in transit and goods temporarily admitted/withdrawn, includes re-imports;"
" deliveries for international bunkers are excluded.",
"pipeline_imports": "Imports of gaseous natural gas through pipelines.",
"lng_imports": "Imports of liquefied natural gas through ocean tankers, on"
" a re-gasified equivalent basis.",
"exports": "Amounts that have crossed the physical boundaries of the"
" country, whether customs clearance has taken place or not; excludes goods"
" in transit and goods temporarily admitted/withdrawn, includes re-exports;"
" deliveries for international bunkers are excluded.",
"pipeline_exports": "Exports of gaseous natural gas through pipelines.",
"lng_exports": "Exports of liquefied natural gas through ocean tankers, on"
" a re-gasified equivalent basis.",
"stock_change": "The difference between the closing and opening stock"
" level of recoverable gas already extracted; a stock build is shown as a"
" positive number, a stock draw as a negative number.",
"demand_calculated": "Gross inland deliveries (calculated): production +"
" receipts from other sources + imports - exports - stock change.",
"statistical_difference": "The difference between the calculated and"
" observed gross inland deliveries.",
"demand": "Gross inland deliveries (observed): deliveries of marketable"
" gas to the inland market, including gas used by the gas industry for"
" heating and operation of equipment, losses in distribution, and"
" deliveries to international marine and aviation bunkers.",
"electricity_and_heat_generation": "Deliveries of natural gas for the"
" generation of electricity and heat in power plants, including both"
" main-activity and autoproducer plants.",
"closing_stocks": "The stock level held on the national territory on the"
" last day of the reference month.",
}
# Flow code -> balance-view field name, in questionnaire order.
OIL_BALANCE_FIELDS = {
"INDPROD": "production",
"REFGROUT": "refinery_output",
"OSOURCES": "from_other_sources",
"RECEIPTS": "receipts",
"TOTIMPSB": "imports",
"TOTEXPSB": "exports",
"TRANSBAK": "products_transferred",
"PTRANSF": "products_transferred",
"IPTRANSF": "interproduct_transfers",
"DIRECUSE": "direct_use",
"STOCKCH": "stock_change",
"STATDIFF": "statistical_difference",
"REFINOBS": "refinery_intake",
"TOTDEMO": "demand",
"CLOSTLV": "closing_stocks",
}
GAS_BALANCE_FIELDS = {
"INDPROD": "production",
"OSOURCES": "from_other_sources",
"TOTIMPSB": "imports",
"IMPPIP": "pipeline_imports",
"IMPLNG": "lng_imports",
"TOTEXPSB": "exports",
"EXPPIP": "pipeline_exports",
"EXPLNG": "lng_exports",
"STOCKCH": "stock_change",
"TOTDEMC": "demand_calculated",
"STATDIFF": "statistical_difference",
"TOTDEMO": "demand",
"MAINTOT": "electricity_and_heat_generation",
"CLOSTLV": "closing_stocks",
}
# Product code -> demand-by-product field name, in published order.
OIL_PRODUCT_FIELDS = {
"LPG": "lpg",
"NAPHTHA": "naphtha",
"GASOLINE": "gasoline",
"KEROSENE": "kerosene",
"JETKERO": "jet_fuel",
"GASDIES": "gas_diesel_oil",
"RESFUEL": "fuel_oil",
"ONONSPEC": "other_products",
"TOTPRODS": "total_products",
}
# ISO 3166-1 alpha-2 codes observed in the JODI world databases.
COUNTRIES = {
"albania": "AL",
"algeria": "DZ",
"angola": "AO",
"argentina": "AR",
"armenia": "AM",
"australia": "AU",
"austria": "AT",
"azerbaijan": "AZ",
"bahrain": "BH",
"bangladesh": "BD",
"barbados": "BB",
"belarus": "BY",
"belgium": "BE",
"belize": "BZ",
"bermuda": "BM",
"bolivia": "BO",
"brazil": "BR",
"brunei": "BN",
"bulgaria": "BG",
"canada": "CA",
"chile": "CL",
"china": "CN",
"colombia": "CO",
"costa_rica": "CR",
"croatia": "HR",
"cuba": "CU",
"cyprus": "CY",
"czechia": "CZ",
"denmark": "DK",
"dominican_republic": "DO",
"ecuador": "EC",
"egypt": "EG",
"el_salvador": "SV",
"equatorial_guinea": "GQ",
"estonia": "EE",
"eswatini": "SZ",
"finland": "FI",
"france": "FR",
"gabon": "GA",
"gambia": "GM",
"georgia": "GE",
"germany": "DE",
"greece": "GR",
"grenada": "GD",
"guatemala": "GT",
"guyana": "GY",
"haiti": "HT",
"honduras": "HN",
"hong_kong": "HK",
"hungary": "HU",
"iceland": "IS",
"india": "IN",
"indonesia": "ID",
"iran": "IR",
"iraq": "IQ",
"ireland": "IE",
"italy": "IT",
"jamaica": "JM",
"japan": "JP",
"kazakhstan": "KZ",
"kuwait": "KW",
"latvia": "LV",
"libya": "LY",
"lithuania": "LT",
"luxembourg": "LU",
"malaysia": "MY",
"malta": "MT",
"mauritius": "MU",
"mexico": "MX",
"moldova": "MD",
"morocco": "MA",
"myanmar": "MM",
"nepal": "NP",
"netherlands": "NL",
"new_zealand": "NZ",
"nicaragua": "NI",
"niger": "NE",
"nigeria": "NG",
"north_macedonia": "MK",
"norway": "NO",
"oman": "OM",
"panama": "PA",
"papua_new_guinea": "PG",
"paraguay": "PY",
"peru": "PE",
"philippines": "PH",
"poland": "PL",
"portugal": "PT",
"qatar": "QA",
"romania": "RO",
"russia": "RU",
"saudi_arabia": "SA",
"serbia": "RS",
"singapore": "SG",
"slovakia": "SK",
"slovenia": "SI",
"south_africa": "ZA",
"south_korea": "KR",
"spain": "ES",
"sudan": "SD",
"suriname": "SR",
"sweden": "SE",
"switzerland": "CH",
"syria": "SY",
"taiwan": "TW",
"tajikistan": "TJ",
"thailand": "TH",
"trinidad_and_tobago": "TT",
"tunisia": "TN",
"turkiye": "TR",
"ukraine": "UA",
"united_arab_emirates": "AE",
"united_kingdom": "GB",
"united_states": "US",
"uruguay": "UY",
"venezuela": "VE",
"vietnam": "VN",
"yemen": "YE",
}
CODE_TO_COUNTRY = {v: k for k, v in COUNTRIES.items()}
COUNTRY_LABELS = {
code: name.replace("_", " ").title().replace(" And ", " and ")
for name, code in COUNTRIES.items()
}
COUNTRY_WIDGET_OPTIONS = [
{"label": COUNTRY_LABELS[code], "value": name} for name, code in COUNTRIES.items()
]
# 'choices' is never set for country params: the static package rejects None defaults
# when choices are set, and choices would reject ISO code input before validators run.
COUNTRY_SCHEMA_MULTI = {
"multiple_items_allowed": True,
"x-widget_config": {
"options": COUNTRY_WIDGET_OPTIONS,
"style": {"popupWidth": 450},
},
}
COUNTRY_SCHEMA_SINGLE = {
"x-widget_config": {
"options": COUNTRY_WIDGET_OPTIONS,
"style": {"popupWidth": 450},
},
}
COUNTRY_MULTI_DESCRIPTION = (
"The country or countries to get data for, by name or ISO 3166-1 alpha-2 code."
+ " If None, all reporting countries are returned."
)
COUNTRY_SINGLE_DESCRIPTION = (
"The country to get data for, by name or ISO 3166-1 alpha-2 code."
)
OIL_UNIT_DESCRIPTION = (
"The unit of measurement."
+ " kbd: thousand barrels per day; kbbl: thousand barrels;"
+ " kl: thousand kilolitres; ktons: thousand metric tons;"
+ " conversion_factor: barrels per thousand metric tons."
)
GAS_UNIT_DESCRIPTION = (
"The unit of measurement."
+ " m3: million cubic metres (at 15C, 760 mm Hg);"
+ " tj: terajoules, on a gross calorific value basis;"
+ " ktons: thousand metric tons, on a re-gasified equivalent basis,"
+ " reported only for LNG trade amounts."
)
PRIMARY_PRODUCTS = ["crude_oil", "ngl", "other_crude", "total_crude"]
SECONDARY_PRODUCTS = [name for name in OIL_PRODUCTS if name not in PRIMARY_PRODUCTS]
OIL_PRODUCT_CHOICES_DESCRIPTION = "\n".join(
f" {name}: {OIL_PRODUCT_DEFINITIONS[name]}" for name in OIL_PRODUCTS
)
OIL_PRIMARY_PRODUCT_CHOICES_DESCRIPTION = "\n".join(
f" {name}: {OIL_PRODUCT_DEFINITIONS[name]}" for name in PRIMARY_PRODUCTS
)
OIL_SECONDARY_PRODUCT_CHOICES_DESCRIPTION = "\n".join(
f" {name}: {OIL_PRODUCT_DEFINITIONS[name]}" for name in SECONDARY_PRODUCTS
)
USE_CACHE_DESCRIPTION = (
"If True, the source tables are cached under the user cache directory"
+ " and revalidated against the source at most once per day,"
+ " downloading again only when JODI has published an update."
)

View File

@@ -0,0 +1,934 @@
"""OpenBB JODI Provider Module Helpers."""
import weakref
from contextlib import contextmanager
from typing import TYPE_CHECKING, cast
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_jodi.utils.constants import (
GAS_DOWNLOAD_URL,
GAS_FILES_URL,
OIL_TABLE_URLS,
)
if TYPE_CHECKING:
import asyncio # noqa
from collections.abc import Iterator, Mapping # noqa
from datetime import date as dateType # noqa
from pathlib import Path # noqa
from typing import IO # noqa
from pandas import DataFrame # noqa
REVALIDATE_INTERVAL = 86400
PROBE_TIMEOUT = 60
MAX_RANGE_CONNECTIONS = 16
MIN_RANGE_CHUNK = 2 * 1024 * 1024
INGEST_CHUNK_ROWS = 1_000_000
PARTITION_ROW_GROUP = 32768
EXPECTED_COLUMNS: list = [
"REF_AREA",
"TIME_PERIOD",
"ENERGY_PRODUCT",
"FLOW_BREAKDOWN",
"UNIT_MEASURE",
"OBS_VALUE",
"ASSESSMENT_CODE",
]
# Dimension columns are categorical: the full tables have millions of rows.
CSV_DTYPES: dict = {
"REF_AREA": "category",
"TIME_PERIOD": "category",
"ENERGY_PRODUCT": "category",
"FLOW_BREAKDOWN": "category",
"UNIT_MEASURE": "category",
"OBS_VALUE": str,
"ASSESSMENT_CODE": "category",
}
# TIME_PERIOD stays a plain string in partitions so parquet range filters
# compare lexicographically ("YYYY-MM" sorts chronologically).
PARTITION_DTYPES: dict = {**CSV_DTYPES, "TIME_PERIOD": str, "OBS_VALUE": float}
# Per-event-loop download locks and prefetch tasks: locks coalesce concurrent
# requests for the same table, and asyncio primitives cannot cross loops.
# Weak keys drop entries when a loop is garbage collected.
_DOWNLOAD_LOCKS: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
_PREFETCH_TASKS: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
def _download_lock(table: str) -> "asyncio.Lock":
"""Get the download lock for a table on the running event loop."""
import asyncio
loop = asyncio.get_running_loop()
locks = _DOWNLOAD_LOCKS.setdefault(loop, {})
if table not in locks:
locks[table] = asyncio.Lock()
return locks[table]
def _lock_handle_nt(handle) -> None: # pragma: no cover - Windows-only
"""Take an exclusive byte lock, waiting for a concurrent holder."""
import msvcrt # noqa
from time import sleep
handle.seek(0)
while True:
try:
msvcrt.locking( # ty: ignore[unresolved-attribute]
handle.fileno(),
msvcrt.LK_NBLCK, # ty: ignore[unresolved-attribute]
1,
)
return
except OSError:
sleep(0.05)
def _unlock_handle_nt(handle) -> None: # pragma: no cover - Windows-only
"""Release the exclusive byte lock."""
import msvcrt
handle.seek(0)
msvcrt.locking( # ty: ignore[unresolved-attribute]
handle.fileno(),
msvcrt.LK_UNLCK, # ty: ignore[unresolved-attribute]
1,
)
def _lock_handle_posix(handle) -> None: # pragma: no cover - POSIX-only
"""Take an exclusive flock, waiting for a concurrent holder."""
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX) # ty: ignore[unresolved-attribute]
def _unlock_handle_posix(handle) -> None: # pragma: no cover - POSIX-only
"""Release the exclusive flock."""
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN) # ty: ignore[unresolved-attribute]
def acquire_table_lock(table: str) -> "IO[bytes]":
"""Acquire the cross-process lock for a table; released by release_table_lock.
The asyncio locks only coalesce work within one event loop; this file lock
serializes refreshes of the same table across processes and loops, and is
released by the OS if the holder dies.
"""
import os
lock = _lock_handle_nt if os.name == "nt" else _lock_handle_posix
path = get_cache_directory() / f"{table}.lock"
handle = open(path, "a+b") # noqa: SIM115
try:
lock(handle)
except BaseException:
handle.close()
raise
return handle
def release_table_lock(handle: "IO[bytes]") -> None:
"""Release a cross-process table lock."""
import os
unlock = _unlock_handle_nt if os.name == "nt" else _unlock_handle_posix
try:
unlock(handle)
finally:
handle.close()
def clear_table_temp(table: str) -> None:
"""Remove staging leftovers and spent archives; the caller holds the table lock."""
import shutil
for path in get_cache_directory().glob(f"{table}.*.tmp"):
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
path.unlink(missing_ok=True)
# A raw archive left by an interrupted run, or by an older version.
(get_cache_directory() / f"{table}.zip").unlink(missing_ok=True)
def get_cache_directory() -> "Path":
"""Get the JODI cache directory, creating it if needed."""
from pathlib import Path # noqa
from openbb_core.app.utils import get_user_cache_directory
cache_dir = Path(get_user_cache_directory()) / "jodi"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def table_directory(table: str) -> "Path":
"""Get the partition directory for a table."""
return get_cache_directory() / table
def partition_filename(name: str) -> str:
"""Get a partition's file name; parquet is portable across pandas versions."""
return f"{name}.parquet"
def write_cached_raw(filename: str, content: bytes) -> None:
"""Write bytes to a path under the cache directory, atomically."""
import os # noqa
from secrets import token_hex
path = get_cache_directory() / filename
path.parent.mkdir(parents=True, exist_ok=True)
temp_path = path.with_name(f"{path.name}.{os.getpid()}{token_hex(4)}.tmp")
temp_path.write_bytes(content)
os.replace(temp_path, path)
async def request_range(
url: str,
timeout: int,
start: int | None = None,
end: int | None = None,
validator: str | None = None,
) -> "tuple[int, Mapping, bytes]":
"""Make one request, optionally for a byte range guarded by a validator."""
from openbb_core.provider.utils.helpers import amake_request
# The JODI web server responds with a 406 error without a browser-like User-Agent.
headers = {"User-Agent": "Mozilla/5.0"}
if start is not None:
headers["Range"] = f"bytes={start}-{end}"
if validator:
# A mid-transfer file replacement returns 200 instead of a torn 206.
headers["If-Range"] = validator
async def response_callback(response, _):
"""Read the status, response headers, and body."""
return (response.status, response.headers, await response.read())
response = await amake_request(
url, timeout=timeout, response_callback=response_callback, headers=headers
)
return cast("tuple[int, Mapping, bytes]", response)
def source_meta(status: int, headers: "Mapping") -> dict:
"""Extract the cache validators from response headers."""
total_text = str(headers.get("Content-Range", "")).rpartition("/")[2]
if not total_text.isdigit() and status == 200:
total_text = str(headers.get("Content-Length", ""))
return {
"etag": headers.get("ETag"),
"last_modified": headers.get("Last-Modified"),
"size": int(total_text) if total_text.isdigit() else 0,
}
def _range_part_ok(part, start: int, end: int) -> bool:
"""Check one gathered range response for usability."""
if isinstance(part, BaseException):
return False
status, _, body = part
return status == 206 and len(body) == end - start + 1
async def download(url: str, timeout: int = 600) -> "tuple[bytes, dict]":
"""Download a file with its cache validators, using concurrent byte ranges.
The JODI web server throttles per connection; segmented requests multiply
the download throughput. Any unusable or failed segment falls back to one
plain request.
"""
import asyncio # noqa
from math import ceil
status, headers, body = await request_range(url, timeout, 0, 0)
if status == 200:
# Ranges are not supported: the probe returned the complete file.
return body, source_meta(status, headers)
if status != 206:
raise OpenBBError(f"Failed to download {url} -> Status Code: {status}")
meta = source_meta(status, headers)
total = meta["size"]
if total:
validator = meta["etag"] or meta["last_modified"] or None
chunk_size = ceil(
total / min(MAX_RANGE_CONNECTIONS, max(1, ceil(total / MIN_RANGE_CHUNK)))
)
offsets = [
(start, min(start + chunk_size, total) - 1)
for start in range(0, total, chunk_size)
]
parts = await asyncio.gather(
*(
request_range(url, timeout, start, end, validator)
for start, end in offsets
),
return_exceptions=True,
)
if all(
_range_part_ok(part, start, end)
for part, (start, end) in zip(parts, offsets)
):
return (
b"".join(cast("tuple", part)[2] for part in parts),
meta,
)
# The range responses were unusable: fall back to one full request.
status, headers, body = await request_range(url, timeout)
if status != 200:
raise OpenBBError(f"Failed to download {url} -> Status Code: {status}")
return body, source_meta(status, headers)
async def probe_source(url: str) -> dict | None:
"""Get the source file's current cache validators, None when unreachable."""
try:
status, headers, _ = await request_range(url, PROBE_TIMEOUT, 0, 0)
except Exception:
# The revalidation probe must never break a cached read.
return None
if status in (200, 206):
return source_meta(status, headers)
return {}
def source_unchanged(cached: dict, probed: dict) -> bool:
"""Compare cache validators, strongest first."""
for key in ("etag", "last_modified", "size"):
ours, theirs = cached.get(key), probed.get(key)
if ours and theirs:
return ours == theirs
return False
def read_manifest(table: str) -> dict | None:
"""Read a table's cache manifest, None when missing or unreadable."""
import json
path = table_directory(table) / "manifest.json"
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if (
not isinstance(manifest, dict)
or "source" not in manifest
or "partitions" not in manifest
):
return None
return manifest
def write_manifest(table: str, manifest: dict) -> None:
"""Write a table's cache manifest."""
import json
write_cached_raw(f"{table}/manifest.json", json.dumps(manifest).encode("utf-8"))
def manifest_is_fresh(manifest: dict) -> bool:
"""Check whether a manifest was validated within the revalidation interval."""
from time import time
return time() - manifest.get("checked_at", 0) <= REVALIDATE_INTERVAL
def bump_checked_at(table: str, snapshot: dict) -> dict:
"""Refresh a manifest's checked_at without clobbering a concurrent refresh."""
from time import time
handle = acquire_table_lock(table)
try:
current = read_manifest(table)
if current is not None and current.get("source") != snapshot.get("source"):
# A concurrent refresh installed a newer manifest: keep it.
return current
manifest = current or snapshot
manifest["checked_at"] = time()
write_manifest(table, manifest)
return manifest
finally:
release_table_lock(handle)
@contextmanager
def open_archive_csv(path: "Path") -> "Iterator":
"""Open the CSV stream inside a raw source file, unzipping when needed."""
import zipfile
with open(path, "rb") as handle:
if handle.read(4) == b"PK\x03\x04":
handle.seek(0)
try:
archive = zipfile.ZipFile(handle)
except zipfile.BadZipFile as exc:
raise OpenBBError(f"The JODI archive is unreadable. -> {exc}") from exc
with archive:
members = [n for n in archive.namelist() if n.lower().endswith(".csv")]
if not members:
raise OpenBBError("No CSV member was found in the JODI archive.")
with archive.open(members[0]) as stream:
yield stream
else:
handle.seek(0)
yield handle
def clean_chunk(chunk: "DataFrame") -> "DataFrame":
"""Validate a parsed CSV chunk and drop the non-numeric observations."""
from pandas import to_numeric
if list(chunk.columns) != EXPECTED_COLUMNS:
raise OpenBBError(
"Unexpected JODI file format."
+ f" Expected columns: {EXPECTED_COLUMNS} Got: {list(chunk.columns)}"
)
chunk["OBS_VALUE"] = to_numeric(chunk["OBS_VALUE"], errors="coerce")
return chunk[chunk["OBS_VALUE"].notna()]
def ingest_table(table: str, source_path: "Path") -> dict[str, list[str]]:
"""Split a raw source file into per-product-and-flow partition files.
The CSV is streamed in chunks: the full table is never held in memory.
The caller holds the table lock.
"""
import os # noqa
import shutil
from secrets import token_hex
from time import sleep
from pandas import read_csv
directory = table_directory(table)
staging = directory.with_name(f"{directory.name}.{os.getpid()}{token_hex(4)}.tmp")
staging.mkdir(parents=True)
partitions: dict[str, list[str]] = {}
try:
with open_archive_csv(source_path) as stream:
try:
for chunk in read_csv(
stream, dtype=CSV_DTYPES, chunksize=INGEST_CHUNK_ROWS
):
groups = clean_chunk(chunk).groupby(
["ENERGY_PRODUCT", "FLOW_BREAKDOWN"],
observed=True,
sort=False,
)
for key, rows in groups:
product, flow = cast("tuple[str, str]", key)
name = f"{product}_{flow}"
rows.to_csv(
staging / f"{name}.csv",
mode="a",
header=name not in partitions,
index=False,
)
partitions[name] = [str(product), str(flow)]
except ValueError as exc:
raise OpenBBError(f"Unexpected JODI file format. -> {exc}") from exc
if not partitions:
# Committing an empty table would destroy the existing good cache.
raise OpenBBError("The JODI source file contains no observations.")
for name in partitions:
csv_path = staging / f"{name}.csv"
# Sorting groups each country into contiguous row groups, so the
# parquet statistics prune non-matching row groups at read time.
read_csv(csv_path, dtype=PARTITION_DTYPES).sort_values(
["REF_AREA", "TIME_PERIOD"]
).to_parquet(
staging / partition_filename(name),
engine="pyarrow",
index=False,
row_group_size=PARTITION_ROW_GROUP,
)
csv_path.unlink()
for attempt in range(3):
try:
if directory.exists():
shutil.rmtree(directory)
os.replace(staging, directory)
break
except OSError:
# A reader may hold a partition file open (Windows).
if attempt == 2:
raise
sleep(0.25)
except BaseException:
shutil.rmtree(staging, ignore_errors=True)
raise
return partitions
def store_table(table: str, url: str, raw: bytes, meta: dict) -> dict:
"""Ingest downloaded bytes into partitions and write the manifest.
The caller holds the table lock. The raw archive is deleted after ingest:
the partitions are the cache.
"""
import os # noqa
from secrets import token_hex
from time import time
directory = get_cache_directory()
temp_path = directory / f"{table}.zip.{os.getpid()}{token_hex(4)}.tmp"
temp_path.write_bytes(raw)
try:
partitions = ingest_table(table, temp_path)
finally:
temp_path.unlink(missing_ok=True)
manifest = {
"source": {"url": url, **meta},
"checked_at": time(),
"partitions": partitions,
}
write_manifest(table, manifest)
return manifest
async def resolve_source_url(table: str) -> str:
"""Get a table's current download URL; the gas URL rotates per publication."""
import json
if table != "gas_world":
return OIL_TABLE_URLS[table.removeprefix("oil_")]
listing, _ = await download(GAS_FILES_URL)
info = json.loads(listing)
files = [
f
for f in info.get("files", [])
if f.get("format") == "CSV" and not f.get("ignore")
]
if not files:
raise OpenBBError("No CSV file is listed by the JODI-Gas publisher API.")
return GAS_DOWNLOAD_URL.format(
publication_id=info.get("publicationId"), filename=files[0].get("filename")
)
async def source_changed(table: str, manifest: dict) -> bool | None:
"""Check a cached table against the source: True/False, or None if unknowable."""
url = manifest["source"]["url"]
if table == "gas_world":
try:
url = await resolve_source_url(table)
except (OpenBBError, ValueError):
return None
if url != manifest["source"]["url"]:
return True # a new publication is listed
probed = await probe_source(url)
if not probed:
# Unreachable, or an HTTP error status: keep serving the cache.
return None
return not source_unchanged(manifest["source"], probed)
async def refresh_table(table: str, use_cache: bool = True) -> dict:
"""Download a table's source archive and rebuild its partitioned cache.
Ingest and file I/O run in a thread so the event loop stays responsive;
the cross-process table lock is held for the whole refresh.
"""
import asyncio
handle = await asyncio.to_thread(acquire_table_lock, table)
try:
# A concurrent process may have refreshed while this one waited.
manifest = read_manifest(table) if use_cache else None
if manifest is not None and manifest_is_fresh(manifest):
return manifest
await asyncio.to_thread(clear_table_temp, table)
url = await resolve_source_url(table)
raw, meta = await download(url)
return await asyncio.to_thread(store_table, table, url, raw, meta)
finally:
await asyncio.to_thread(release_table_lock, handle)
async def ensure_table(table: str, use_cache: bool = True) -> dict:
"""Get a table's cache manifest, revalidating or downloading as needed.
A cached table is revalidated against the source's validators at most once
per day; the archive is downloaded again only when the source has changed.
A failed revalidation or refresh serves the cached table instead of erroring.
"""
import asyncio
manifest = read_manifest(table) if use_cache else None
if manifest is not None and manifest_is_fresh(manifest):
return manifest
async with _download_lock(table):
# A concurrent request may have refreshed the table while waiting.
manifest = read_manifest(table) if use_cache else None
if manifest is not None and manifest_is_fresh(manifest):
return manifest
if manifest is not None:
changed = await source_changed(table, manifest)
if not changed:
# Unchanged, or the source could not be checked: keep the cache.
return await asyncio.to_thread(bump_checked_at, table, manifest)
try:
return await refresh_table(table, use_cache)
except Exception:
if manifest is not None:
# The refresh failed but a readable table is on disk: serve it.
return await asyncio.to_thread(bump_checked_at, table, manifest)
raise
def partition_filters(
countries: set[str] | None,
units: set[str] | None,
start_date: "dateType | None",
end_date: "dateType | None",
) -> list | None:
"""Build parquet predicate-pushdown filters for the in-file dimensions."""
filters: list = []
if countries is not None:
filters.append(("REF_AREA", "in", sorted(countries)))
if units is not None:
filters.append(("UNIT_MEASURE", "in", sorted(units)))
if start_date is not None:
filters.append(("TIME_PERIOD", ">=", start_date.strftime("%Y-%m")))
if end_date is not None:
filters.append(("TIME_PERIOD", "<=", end_date.strftime("%Y-%m")))
return filters or None
def load_partitions(
table: str,
product_codes: set[str] | None,
flow_codes: set[str] | None,
manifest: dict,
countries: set[str] | None = None,
units: set[str] | None = None,
start_date: "dateType | None" = None,
end_date: "dateType | None" = None,
) -> "DataFrame | None":
"""Read the partition slices for the requested codes, None on a broken cache.
The partition files select the product and flow; the country, unit, and
period predicates push down into the parquet reads.
"""
from pandas import DataFrame, concat, read_parquet
filters = partition_filters(countries, units, start_date, end_date)
frames: list[DataFrame] = []
for name, (product, flow) in manifest["partitions"].items():
if product_codes is not None and product not in product_codes:
continue
if flow_codes is not None and flow not in flow_codes:
continue
try:
frame = read_parquet(
table_directory(table) / partition_filename(name),
engine="pyarrow",
filters=filters,
)
except Exception:
# Any unreadable partition invalidates the table cache.
return None
if not frame.empty:
frames.append(frame)
if not frames:
return DataFrame({column: [] for column in EXPECTED_COLUMNS})
return concat(frames, ignore_index=True) if len(frames) > 1 else frames[0]
async def get_table_slice(
table: str,
product_codes: set[str] | None,
flow_codes: set[str] | None,
use_cache: bool = True,
countries: set[str] | None = None,
units: set[str] | None = None,
start_date: "dateType | None" = None,
end_date: "dateType | None" = None,
) -> "DataFrame":
"""Get the requested slices of one JODI table, with predicate pushdown."""
import asyncio
from functools import partial
load = partial(
load_partitions,
table,
product_codes,
flow_codes,
countries=countries,
units=units,
start_date=start_date,
end_date=end_date,
)
manifest = await ensure_table(table, use_cache)
df = await asyncio.to_thread(load, manifest)
if df is not None:
return df
async with _download_lock(table):
# A concurrent request may have healed the table while waiting.
manifest = read_manifest(table) or manifest
df = await asyncio.to_thread(load, manifest)
if df is not None:
return df
# Self-heal: the raw archive is not kept, so download fresh.
healed = await refresh_table(table, use_cache=False)
df = await asyncio.to_thread(load, healed)
if df is None:
raise OpenBBError(f"The cached JODI table '{table}' could not be read.")
return df
async def prefetch_tables() -> None:
"""Warm the partitioned cache for every JODI table."""
import asyncio
await asyncio.gather(
ensure_table("oil_primary"),
ensure_table("oil_secondary"),
ensure_table("gas_world"),
return_exceptions=True,
)
def start_prefetch() -> None:
"""Start one background prefetch of all tables per event loop."""
import asyncio
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = _PREFETCH_TASKS.get(loop)
if task is None or task.done():
_PREFETCH_TASKS[loop] = loop.create_task(prefetch_tables())
def apply_filters(
df: "DataFrame",
countries: set[str] | None,
products: set[str] | None,
flows: set[str] | None,
units: set[str] | None,
start_date: "dateType",
end_date: "dateType",
) -> "DataFrame":
"""Filter a JODI DataFrame by dimension codes and period."""
if countries is not None:
df = df[df["REF_AREA"].isin(countries)]
if products is not None:
df = df[df["ENERGY_PRODUCT"].isin(products)]
if flows is not None:
df = df[df["FLOW_BREAKDOWN"].isin(flows)]
if units is not None:
df = df[df["UNIT_MEASURE"].isin(units)]
# The period filter runs on the reduced frame: TIME_PERIOD is categorical.
period = df["TIME_PERIOD"].astype(str)
df = df[
(period >= start_date.strftime("%Y-%m"))
& (period <= end_date.strftime("%Y-%m"))
]
return df.reset_index(drop=True)
def normalize_country_names(v) -> list[str]:
"""Normalize country input into snake names, accepting names or ISO codes."""
from openbb_jodi.utils.constants import CODE_TO_COUNTRY, COUNTRIES
items = v if isinstance(v, list) else str(v).split(",")
result: list[str] = []
for item in items:
country = str(item).strip().lower().replace(" ", "_")
if country in COUNTRIES:
result.append(country)
elif country.upper() in CODE_TO_COUNTRY:
result.append(CODE_TO_COUNTRY[country.upper()])
else:
raise OpenBBError(
f"Invalid country: '{item}'. Valid values are: {list(COUNTRIES)}"
)
return list(dict.fromkeys(result))
def validate_country_param(v, multiple: bool, default: str | None) -> str | None:
"""Validate a country query parameter."""
if not v:
return default
names = normalize_country_names(v)
if not multiple and len(names) > 1:
raise OpenBBError("Only one country can be selected for this endpoint.")
return ",".join(names)
def normalize_token(v, default: str) -> str:
"""Normalize a single-select token parameter."""
if not v:
return default
return str(v).strip().lower().replace(" ", "_")
def resolve_date_range(start_date, end_date, start_year: int) -> tuple:
"""Apply the full-history default date range and check the order."""
from datetime import date
start_date = start_date or date(start_year, 1, 1)
end_date = end_date or date.today()
if start_date > end_date:
raise OpenBBError("The start_date must be on, or before, the end_date.")
return start_date, end_date
def check_oil_dates(start_date, end_date) -> None:
"""Check the date range against the JODI-Oil data coverage."""
from openbb_jodi.utils.constants import OIL_START_YEAR
if end_date.year < OIL_START_YEAR:
raise OpenBBError(
f"No JODI-Oil data is available between {start_date} and {end_date}."
+ f" The data starts in {OIL_START_YEAR}."
)
def check_gas_dates(start_date, end_date) -> None:
"""Check the date range against the JODI-Gas data coverage."""
from openbb_jodi.utils.constants import GAS_START_YEAR
if end_date.year < GAS_START_YEAR:
raise OpenBBError(
f"No JODI-Gas data is available between {start_date} and {end_date}."
+ f" The data starts in {GAS_START_YEAR}."
)
async def get_filtered_oil(
tables: list[str],
countries: set[str] | None,
product_codes: set[str],
flow_codes: set[str],
unit_codes: set[str],
start_date,
end_date,
use_cache: bool = True,
) -> list[dict]:
"""Get filtered JODI-Oil records for the requested view."""
import asyncio # noqa
from openbb_core.provider.utils.errors import EmptyDataError
from pandas import concat
check_oil_dates(start_date, end_date)
frames = await asyncio.gather(
*(
get_table_slice(
f"oil_{table}",
product_codes,
flow_codes,
use_cache,
countries=countries,
units=unit_codes,
start_date=start_date,
end_date=end_date,
)
for table in tables
)
)
frames = [frame for frame in frames if not frame.empty] or frames[:1]
df = frames[0] if len(frames) == 1 else concat(frames, ignore_index=True)
df = apply_filters(
df, countries, product_codes, flow_codes, unit_codes, start_date, end_date
)
if df.empty:
raise EmptyDataError("No results were found with the given query parameters.")
return df.to_dict(orient="records")
async def get_filtered_gas(
countries: set[str] | None,
flow_codes: set[str],
unit_codes: set[str],
start_date,
end_date,
use_cache: bool = True,
) -> list[dict]:
"""Get filtered JODI-Gas records for the requested view."""
from openbb_core.provider.utils.errors import EmptyDataError
check_gas_dates(start_date, end_date)
df = await get_table_slice(
"gas_world",
None,
flow_codes,
use_cache,
countries=countries,
units=unit_codes,
start_date=start_date,
end_date=end_date,
)
df = apply_filters(
df, countries, None, flow_codes, unit_codes, start_date, end_date
)
if df.empty:
raise EmptyDataError("No results were found with the given query parameters.")
return df.to_dict(orient="records")
def _to_wide(df, columns: str, column_order: list[str]) -> list[dict]:
"""Pivot filtered records into date rows with the given column order."""
from datetime import date
wide = (
df.pivot_table(
index="TIME_PERIOD", columns=columns, values="OBS_VALUE", aggfunc="first"
)
.reindex(columns=[c for c in column_order if c in df[columns].unique()])
.sort_index()
.reset_index()
)
wide = wide.astype(object).where(wide.notna(), other=None)
records = wide.to_dict(orient="records")
for row in records:
row["date"] = date.fromisoformat(f"{row.pop('TIME_PERIOD')}-01")
return [{"date": row.pop("date"), **row} for row in records]
def build_field_table(
records: list[dict], code_column: str, field_map: dict[str, str]
) -> list[dict]:
"""Build date rows with one typed field per item code."""
from pandas import DataFrame
df = DataFrame(records)
df["field"] = df[code_column].map(field_map)
return _to_wide(df, "field", list(dict.fromkeys(field_map.values())))
def build_country_table(records: list[dict]) -> list[dict]:
"""Build date rows with one field per country, alphabetical."""
from pandas import DataFrame
from openbb_jodi.utils.constants import CODE_TO_COUNTRY
df = DataFrame(records)
df["country"] = df["REF_AREA"].map(lambda c: CODE_TO_COUNTRY.get(c, c))
return _to_wide(df, "country", sorted(df["country"].unique()))
def build_assessments(
records: list[dict], code_column: str, name_map: dict[str, str]
) -> dict[str, str]:
"""Get the most recent JODI assessment label for each series."""
from openbb_jodi.utils.constants import ASSESSMENT_LABELS
assessments: dict[str, str] = {}
for row in sorted(records, key=lambda r: str(r["TIME_PERIOD"])):
key = name_map.get(row[code_column], row[code_column])
label = ASSESSMENT_LABELS.get(str(row["ASSESSMENT_CODE"]))
if label:
assessments[key] = label
return assessments

View File

@@ -0,0 +1,91 @@
[project]
name = "openbb-jodi"
version = "2.0.0"
description = "Joint Organisations Data Initiative (JODI) data integration for OpenBB Platform."
authors = [{ name = "OpenBB Team", email = "hello@openbb.co" }]
license = { text = "AGPL-3.0-only" }
readme = "README.md"
requires-python = ">=3.10,<4"
dependencies = [
"openbb-core[pandas]>=2.0.0",
"pyarrow>=17.0.0",
]
[project.entry-points."openbb_provider_extension"]
jodi = "openbb_jodi:jodi_provider"
[project.entry-points."openbb_core_extension"]
jodi = "openbb_jodi.jodi_router:router"
[project.urls]
Homepage = "https://openbb.co"
Repository = "https://github.com/OpenBB-finance/OpenBB"
[dependency-groups]
dev = ["openbb-devtools>=2.0.0"]
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["openbb_jodi"]
[tool.uv.sources]
openbb-core = { path = "../../core", editable = true }
openbb-devtools = { path = "../../extensions/devtools", editable = true }
[tool.ruff]
line-length = 88
target-version = "py310"
fix = true
[tool.ruff.lint]
select = ["D", "E", "W", "F", "S", "UP", "I", "PLC", "PLE", "PLR", "PLW", "SIM", "T20"]
ignore = [
"PLR2004", "PLR0913", "PLR0915", "PLC0415",
"E402", "E501",
"D202", "D203", "D205", "D206", "D210", "D212", "D213", "D214",
"D215", "D300", "D400", "D404", "D405", "D406", "D407", "D408",
"D409", "D410", "D411", "D413", "D415", "D416", "D417",
]
[tool.ruff.lint.per-file-ignores]
"openbb_jodi/models/*.py" = ["ARG002", "PLC0415"]
"openbb_jodi/*_router.py" = ["ARG001", "PLC0415"]
"openbb_jodi/utils/helpers.py" = ["PLR0911", "PLR0912"]
"tests/**" = ["S101", "S108", "D", "SIM105", "SIM117"]
"integration/**" = ["S101", "S108", "D", "SIM105", "SIM117", "PLC0415", "ARG001"]
"**/__init__.py" = ["F401"]
[tool.ruff.lint.isort]
combine-as-imports = true
force-wrap-aliases = true
[tool.ruff.lint.pydocstyle]
convention = "numpy"
[tool.ty]
[tool.ty.environment]
python-version = "3.10"
[tool.ty.src]
include = ["openbb_jodi"]
exclude = ["tests"]
[tool.ty.rules]
unresolved-import = "warn"
# The platform-gated stdlib lock modules (msvcrt/fcntl) each need their
# ty ignores on one platform, which read as unused on the other.
unused-ignore-comment = "ignore"
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@overload"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"
markers = [
"record_http: VCR-backed HTTP cassette tests",
"integration: end-to-end tests that hit live services (excluded from the coverage gate)",
]

View File

@@ -0,0 +1 @@
"""OpenBB JODI Provider Tests."""

View File

@@ -0,0 +1,203 @@
"""Shared fixtures for the openbb_jodi test suite."""
import json
import zipfile
from io import BytesIO
from pathlib import Path
import pytest
import yaml
FIXTURES = Path(__file__).parent / "fixtures"
# The fetcher tests cannot use record_http: their HTTP interactions are the
# bulk source archives (tens of MB), which must never be recorded. They use
# the jodi_cassette fixture instead, which replays an in-memory cassette
# synthesized from the trimmed fixtures. Each cassette holds exactly the
# interactions its test performs; strict replay fails on anything else.
CASSETTE_TESTS = {
"test_jodi_oil_balance_fetcher": ("oil_primary",),
"test_jodi_oil_production_fetcher": ("oil_primary",),
"test_jodi_oil_demand_fetcher": ("oil_secondary",),
"test_jodi_oil_demand_by_product_fetcher": ("oil_secondary",),
"test_jodi_oil_imports_fetcher": ("oil_primary",),
"test_jodi_oil_exports_fetcher": ("oil_primary",),
"test_jodi_oil_stocks_fetcher": ("oil_primary",),
"test_jodi_gas_balance_fetcher": ("gas_listing", "gas_archive"),
"test_jodi_gas_production_fetcher": ("gas_listing", "gas_archive"),
"test_jodi_gas_demand_fetcher": ("gas_listing", "gas_archive"),
"test_jodi_gas_imports_fetcher": ("gas_listing", "gas_archive"),
"test_jodi_gas_exports_fetcher": ("gas_listing", "gas_archive"),
"test_jodi_gas_stocks_fetcher": ("gas_listing", "gas_archive"),
}
def zip_bytes(csv_path: Path) -> bytes:
"""Wrap a CSV fixture in a single-member zip, like the JODI archives."""
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr(f"{csv_path.stem}.csv", csv_path.read_bytes())
return buffer.getvalue()
def _interaction(url: str, body, status: int, reason: str, headers: dict) -> dict:
"""Build one vcr cassette interaction serving the given body."""
return {
"request": {"body": None, "headers": {}, "method": "GET", "uri": url},
"response": {
"body": {"string": body},
"headers": {"Content-Length": [str(len(body))], **headers},
"status": {"code": status, "message": reason},
},
}
def _archive_interaction(url: str, body: bytes) -> dict:
"""A served archive: binary body with the IIS cache validators."""
return _interaction(
url,
body,
200,
"OK",
{
"Accept-Ranges": ["none"],
"Content-Type": ["application/x-zip-compressed"],
"ETag": [f'"{url.rsplit("/", 1)[-1]}"'],
"Last-Modified": ["Wed, 24 Jun 2026 10:56:04 GMT"],
},
)
def _interaction_library() -> dict[str, dict]:
"""Build every synthesized JODI source interaction, keyed for CASSETTE_TESTS."""
from openbb_jodi.utils.constants import (
GAS_DOWNLOAD_URL,
GAS_FILES_URL,
OIL_TABLE_URLS,
)
listing_text = (FIXTURES / "files_gas.json").read_text(encoding="utf-8")
listing = json.loads(listing_text)
gas_file = next(
f for f in listing["files"] if f.get("format") == "CSV" and not f.get("ignore")
)
gas_url = GAS_DOWNLOAD_URL.format(
publication_id=listing["publicationId"], filename=gas_file["filename"]
)
return {
"oil_primary": _archive_interaction(
OIL_TABLE_URLS["primary"], zip_bytes(FIXTURES / "oil_primary_sample.csv")
),
"oil_secondary": _archive_interaction(
OIL_TABLE_URLS["secondary"],
zip_bytes(FIXTURES / "oil_secondary_sample.csv"),
),
"gas_listing": _interaction(
GAS_FILES_URL,
listing_text,
200,
"OK",
{"Content-Type": ["application/json"]},
),
"gas_archive": _archive_interaction(
gas_url, (FIXTURES / "gas_sample.zip").read_bytes()
),
}
class MemoryPersister:
"""A vcr persister that plays one in-memory cassette and never records."""
def __init__(self, document: str):
self.document = document
def load_cassette(self, cassette_path, serializer):
"""Deserialize the in-memory cassette; the path is only a label."""
from vcr.serialize import deserialize
return deserialize(self.document, serializer)
@staticmethod
def save_cassette(cassette_path, cassette_dict, serializer):
"""Recording is disabled: the cassettes are synthesized from fixtures."""
raise AssertionError(
"The JODI source archives are never recorded;"
" the cassettes are synthesized from the trimmed fixtures."
)
@pytest.fixture
def jodi_cassette(request, monkeypatch):
"""Replay an in-memory cassette synthesized from the trimmed fixtures.
The full pipeline executes against vcr's aiohttp interception; nothing is
downloaded, no cassette file is stored, and live sockets are refused.
"""
import socket
import vcr
def refuse(*args, **kwargs):
raise AssertionError("jodi_cassette tests replay offline; no live sockets.")
monkeypatch.setattr(socket, "getaddrinfo", refuse)
library = _interaction_library()
document = yaml.safe_dump(
{
"interactions": [library[key] for key in CASSETTE_TESTS[request.node.name]],
"version": 1,
}
)
vcr_object = vcr.VCR(record_mode="none")
vcr_object.register_persister(MemoryPersister(document))
with vcr_object.use_cassette(request.node.name) as cassette:
yield cassette
@pytest.fixture(autouse=True)
def isolate_cache(tmp_path, monkeypatch):
"""Point the JODI cache at a temporary path and return the original getter."""
from openbb_jodi.utils import helpers
original = helpers.get_cache_directory
monkeypatch.setattr(helpers, "get_cache_directory", lambda: tmp_path)
state = (helpers._DOWNLOAD_LOCKS, helpers._PREFETCH_TASKS)
for store in state:
store.clear()
yield original
for store in state:
store.clear()
@pytest.fixture
def mock_download(monkeypatch):
"""Serve trimmed samples of the live JODI files instead of downloading."""
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_jodi.utils import helpers
files = {
"world_Primary_CSV.zip": zip_bytes(FIXTURES / "oil_primary_sample.csv"),
"world_Secondary_CSV.zip": zip_bytes(FIXTURES / "oil_secondary_sample.csv"),
"web/files/gas": (FIXTURES / "files_gas.json").read_bytes(),
"GAS_world_NewFormat.zip": (FIXTURES / "gas_sample.zip").read_bytes(),
}
calls: list[str] = []
async def fake_download(url: str, timeout: int = 600) -> tuple:
import asyncio
await asyncio.sleep(0) # yield, so concurrent requests contend for the lock
calls.append(url)
for suffix, content in files.items():
if url.endswith(suffix):
meta = {
"etag": f'"{suffix}"',
"last_modified": "Wed, 24 Jun 2026 10:56:04 GMT",
"size": len(content),
}
return content, meta
raise OpenBBError(f"Failed to download {url} -> Status Code: 404")
monkeypatch.setattr(helpers, "download", fake_download)
return calls

View File

@@ -0,0 +1 @@
{"publicationId":23,"files":[{"filename":"GAS_world_NewFormat.zip","format":"CSV","ignore":false},{"filename":"ivt-merged.zip","format":"IVT Merged","ignore":false},{"filename":"ivt.zip","format":"IVT","ignore":false},{"filename":"jodi-data-availability-by-country.csv","format":"Availability","ignore":false}]}

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
interactions:
- request:
body: null
headers:
Range:
- bytes=0-0
User-Agent:
- Mozilla/5.0
method: GET
uri: https://www.jodidata.org/_resources/files/downloads/oil-data/annual-csv/primary/1990.csv
response:
body:
string: "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">\r\n<html
xmlns=\"http://www.w3.org/1999/xhtml\">\r\n<head>\r\n<meta http-equiv=\"Content-Type\"
content=\"text/html; charset=iso-8859-1\"/>\r\n<title>404 - File or directory
not found.</title>\r\n<style type=\"text/css\">\r\n<!--\r\nbody{margin:0;font-size:.7em;font-family:Verdana,
Arial, Helvetica, sans-serif;background:#EEEEEE;}\r\nfieldset{padding:0 15px
10px 15px;} \r\nh1{font-size:2.4em;margin:0;color:#FFF;}\r\nh2{font-size:1.7em;margin:0;color:#CC0000;}
\r\nh3{font-size:1.2em;margin:10px 0 0 0;color:#000000;} \r\n#header{width:96%;margin:0
0 0 0;padding:6px 2% 6px 2%;font-family:\"trebuchet MS\", Verdana, sans-serif;color:#FFF;\r\nbackground-color:#555555;}\r\n#content{margin:0
0 0 2%;position:relative;}\r\n.content-container{background:#FFF;width:96%;margin-top:8px;padding:10px;position:relative;}\r\n-->\r\n</style>\r\n</head>\r\n<body>\r\n<div
id=\"header\"><h1>Server Error</h1></div>\r\n<div id=\"content\">\r\n <div
class=\"content-container\"><fieldset>\r\n <h2>404 - File or directory not
found.</h2>\r\n <h3>The resource you are looking for might have been removed,
had its name changed, or is temporarily unavailable.</h3>\r\n </fieldset></div>\r\n</div>\r\n</body>\r\n</html>\r\n"
headers:
Content-Length:
- '1245'
Content-Type:
- text/html
Date:
- Wed, 15 Jul 2026 17:01:33 GMT
Server:
- Microsoft-IIS/10.0
X-Powered-By:
- ASP.NET
status:
code: 404
message: Not Found
version: 1

View File

@@ -0,0 +1,29 @@
interactions:
- request:
body: null
headers:
Range:
- bytes=0-0
User-Agent:
- Mozilla/5.0
method: GET
uri: https://api.publisher.jodidata.org/web/files/gas
response:
body:
string: '{"publicationId":23,"files":[{"filename":"GAS_world_NewFormat.zip","format":"CSV","ignore":false},{"filename":"ivt-merged.zip","format":"IVT
Merged","ignore":false},{"filename":"ivt.zip","format":"IVT","ignore":false},{"filename":"jodi-data-availability-by-country.csv","format":"Availability","ignore":false}]}'
headers:
Content-Type:
- application/json; charset=utf-8
Date:
- Wed, 15 Jul 2026 17:01:32 GMT
Server:
- Microsoft-IIS/10.0
Transfer-Encoding:
- chunked
X-Powered-By:
- ASP.NET
status:
code: 200
message: OK
version: 1

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
"""Test the JODI fetchers against cassettes synthesized from trimmed fixtures.
Every test executes the full pipeline against a cold cache: the download
replays offline from an in-memory cassette, then ingests and queries.
"""
from datetime import date
import pytest
from openbb_core.app.service.user_service import UserService
from openbb_jodi.models.gas_balance import JodiGasBalanceFetcher
from openbb_jodi.models.gas_demand import JodiGasDemandFetcher
from openbb_jodi.models.gas_exports import JodiGasExportsFetcher
from openbb_jodi.models.gas_imports import JodiGasImportsFetcher
from openbb_jodi.models.gas_production import JodiGasProductionFetcher
from openbb_jodi.models.gas_stocks import JodiGasStocksFetcher
from openbb_jodi.models.oil_balance import JodiOilBalanceFetcher
from openbb_jodi.models.oil_demand import JodiOilDemandFetcher
from openbb_jodi.models.oil_demand_by_product import JodiOilDemandByProductFetcher
from openbb_jodi.models.oil_exports import JodiOilExportsFetcher
from openbb_jodi.models.oil_imports import JodiOilImportsFetcher
from openbb_jodi.models.oil_production import JodiOilProductionFetcher
from openbb_jodi.models.oil_stocks import JodiOilStocksFetcher
test_credentials = UserService().default_user_settings.credentials.model_dump(
mode="json"
)
DATES = {"start_date": date(2024, 1, 1), "end_date": date(2024, 3, 31)}
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_oil_balance_fetcher(credentials=test_credentials):
"""Test the JODI oil balance fetcher."""
params = {
"country": "united_states",
"product": "crude_oil",
"unit": "kbbl",
**DATES,
}
fetcher = JodiOilBalanceFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_oil_production_fetcher(credentials=test_credentials):
"""Test the JODI oil production fetcher."""
params = {"country": "united_states,saudi_arabia", "unit": "kbd", **DATES}
fetcher = JodiOilProductionFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_oil_demand_fetcher(credentials=test_credentials):
"""Test the JODI oil demand fetcher."""
params = {"country": "united_states", "product": "gasoline", **DATES}
fetcher = JodiOilDemandFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_oil_demand_by_product_fetcher(credentials=test_credentials):
"""Test the JODI oil demand by product fetcher."""
params = {"country": "united_states", "unit": "kbd", **DATES}
fetcher = JodiOilDemandByProductFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_oil_imports_fetcher(credentials=test_credentials):
"""Test the JODI oil imports fetcher."""
params = {"country": "united_states", "product": "crude_oil", **DATES}
fetcher = JodiOilImportsFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_oil_exports_fetcher(credentials=test_credentials):
"""Test the JODI oil exports fetcher."""
params = {"country": "saudi_arabia,united_states", "product": "crude_oil", **DATES}
fetcher = JodiOilExportsFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_oil_stocks_fetcher(credentials=test_credentials):
"""Test the JODI oil stocks fetcher."""
params = {"country": "united_states", "unit": "kbbl", **DATES}
fetcher = JodiOilStocksFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_gas_balance_fetcher(credentials=test_credentials):
"""Test the JODI gas balance fetcher."""
params = {"country": "united_states", "unit": "m3", **DATES}
fetcher = JodiGasBalanceFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_gas_production_fetcher(credentials=test_credentials):
"""Test the JODI gas production fetcher."""
params = {"country": "united_states,norway", "unit": "m3", **DATES}
fetcher = JodiGasProductionFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_gas_demand_fetcher(credentials=test_credentials):
"""Test the JODI gas demand fetcher."""
params = {"country": "united_states", "measure": "observed", **DATES}
fetcher = JodiGasDemandFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_gas_imports_fetcher(credentials=test_credentials):
"""Test the JODI gas imports fetcher."""
params = {"country": "united_states", "flow": "pipeline", **DATES}
fetcher = JodiGasImportsFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_gas_exports_fetcher(credentials=test_credentials):
"""Test the JODI gas exports fetcher."""
params = {"country": "qatar", "flow": "lng", **DATES}
fetcher = JodiGasExportsFetcher()
result = fetcher.test(params, credentials)
assert result is None
@pytest.mark.usefixtures("jodi_cassette")
def test_jodi_gas_stocks_fetcher(credentials=test_credentials):
"""Test the JODI gas stocks fetcher."""
params = {"country": "united_states", "unit": "m3", **DATES}
fetcher = JodiGasStocksFetcher()
result = fetcher.test(params, credentials)
assert result is None

View File

@@ -0,0 +1,159 @@
"""Tests for the JODI router commands."""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from openbb_jodi import gas_router, oil_router
COMMANDS = [
(oil_router, oil_router.balance),
(oil_router, oil_router.production),
(oil_router, oil_router.demand),
(oil_router, oil_router.demand_by_product),
(oil_router, oil_router.imports),
(oil_router, oil_router.exports),
(oil_router, oil_router.stocks),
(gas_router, gas_router.balance),
(gas_router, gas_router.production),
(gas_router, gas_router.demand),
(gas_router, gas_router.imports),
(gas_router, gas_router.exports),
(gas_router, gas_router.stocks),
]
@pytest.mark.parametrize("module, command", COMMANDS)
def test_model_backed_commands(module, command):
"""Each command delegates to OBBject.from_query(OpenBBQuery(...))."""
sentinel = object()
with (
patch.object(module, "OBBject") as mock_obbject,
patch.object(module, "OpenBBQuery") as mock_query,
):
mock_obbject.from_query = AsyncMock(return_value=sentinel)
result = asyncio.run(
command(
cc=None,
provider_choices=None,
standard_params=None,
extra_params=None,
)
)
assert result is sentinel
assert mock_query.called
mock_obbject.from_query.assert_awaited_once()
WIDGET_IDS = {
"jodi_oil_balance_jodi_obb",
"jodi_oil_production_jodi_obb",
"jodi_oil_demand_jodi_obb",
"jodi_oil_demand_by_product_jodi_obb",
"jodi_oil_imports_jodi_obb",
"jodi_oil_exports_jodi_obb",
"jodi_oil_stocks_jodi_obb",
"jodi_gas_balance_jodi_obb",
"jodi_gas_production_jodi_obb",
"jodi_gas_demand_jodi_obb",
"jodi_gas_imports_jodi_obb",
"jodi_gas_exports_jodi_obb",
"jodi_gas_stocks_jodi_obb",
}
def test_apps_json():
"""The served app template is a valid Workspace apps.json fragment."""
from openbb_jodi.jodi_router import jodi_apps
apps = asyncio.run(jodi_apps())
assert isinstance(apps, list)
for app in apps:
assert app["name"]
assert app["description"]
assert app["img"]
assert isinstance(app["allowCustomization"], bool)
assert isinstance(app["groups"], list)
assert isinstance(app["prompts"], list)
laid_out: set[str] = set()
for tab_id, tab in app["tabs"].items():
assert tab["id"] == tab_id
assert "name" in tab
occupied: set[tuple[int, int]] = set()
for item in tab["layout"]:
assert item["i"] in WIDGET_IDS
laid_out.add(item["i"])
assert item["x"] >= 0 and item["x"] + item["w"] <= 40
assert item["y"] >= 0 and item["h"] >= 4
cells = {
(col, row)
for col in range(item["x"], item["x"] + item["w"])
for row in range(item["y"], item["y"] + item["h"])
}
assert not cells & occupied, f"widgets overlap in tab {tab_id}"
occupied |= cells
assert laid_out == WIDGET_IDS # every view is on a dashboard
for group in app["groups"]:
assert group["name"].startswith("Group ")
assert group["type"] in {"param", "endpointParam", "ticker"}
for widget_id in group["widgetIds"]:
assert widget_id in laid_out
item = next(
item
for tab in app["tabs"].values()
for item in tab["layout"]
if item["i"] == widget_id
)
assert group["name"] in item.get("groups", [])
def test_router_lifespan(monkeypatch):
"""The router lifespan starts the cache prefetch at application startup."""
from fastapi import FastAPI
from openbb_jodi.jodi_router import router
from openbb_jodi.utils import helpers
prefetches: list[bool] = []
monkeypatch.setattr(helpers, "start_prefetch", lambda: prefetches.append(True))
app = FastAPI()
# include_router merges the jodi lifespan into the application lifespan.
app.include_router(router.api_router)
async def startup():
async with app.router.lifespan_context(app):
assert prefetches == [True]
asyncio.run(startup())
def test_router_paths():
"""Each view is mounted under its oil or gas sub-router."""
from openbb_jodi.jodi_router import router
# The oil and gas sub-routers, plus the apps.json route.
assert len(router.api_router.routes) == 3
paths = {
route.path
for sub_router in (oil_router.router, gas_router.router)
for route in sub_router.api_router.routes
}
assert paths == {
"/oil/balance",
"/oil/production",
"/oil/demand",
"/oil/demand_by_product",
"/oil/imports",
"/oil/exports",
"/oil/stocks",
"/gas/balance",
"/gas/production",
"/gas/demand",
"/gas/imports",
"/gas/exports",
"/gas/stocks",
}

View File

@@ -0,0 +1,218 @@
"""Test the JODI model validators, views, and error branches."""
import asyncio
from datetime import date
import pytest
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.provider.utils.errors import EmptyDataError
from pydantic import ValidationError
from openbb_jodi.models.gas_balance import JodiGasBalanceFetcher
from openbb_jodi.models.gas_demand import JodiGasDemandFetcher, JodiGasDemandQueryParams
from openbb_jodi.models.gas_imports import JodiGasImportsFetcher
from openbb_jodi.models.oil_balance import (
JodiOilBalanceFetcher,
JodiOilBalanceQueryParams,
)
from openbb_jodi.models.oil_demand_by_product import JodiOilDemandByProductFetcher
from openbb_jodi.models.oil_production import (
JodiOilProductionFetcher,
JodiOilProductionQueryParams,
)
DATES = {"start_date": date(2024, 1, 1), "end_date": date(2024, 3, 31)}
@pytest.mark.parametrize(
"value, expected",
[
(None, "united_states"),
("", "united_states"),
("SA", "saudi_arabia"),
("Saudi Arabia", "saudi_arabia"),
],
)
def test_single_country_validator(value, expected):
assert JodiOilBalanceQueryParams(country=value).country == expected
def test_single_country_validator_rejects_multiple():
with pytest.raises(OpenBBError, match="Only one country"):
JodiOilBalanceQueryParams(country="united_states,saudi_arabia")
@pytest.mark.parametrize(
"value, expected",
[
(None, None),
("", None),
("US", "united_states"),
(["US", "Saudi Arabia"], "united_states,saudi_arabia"),
("united_states,us", "united_states"),
],
)
def test_multi_country_validator(value, expected):
assert JodiOilProductionQueryParams(country=value).country == expected
def test_country_validator_invalid():
with pytest.raises(OpenBBError, match="Invalid country: 'atlantis'"):
JodiOilProductionQueryParams(country="atlantis")
def test_token_normalization():
params = JodiOilBalanceQueryParams(product="Crude Oil", unit="KBD")
assert params.product == "crude_oil"
assert params.unit == "kbd"
params = JodiGasDemandQueryParams(measure=None, unit=None)
assert params.measure == "observed"
assert params.unit == "m3"
def test_token_invalid():
with pytest.raises(ValidationError):
JodiOilBalanceQueryParams(unit="furlongs")
with pytest.raises(ValidationError):
JodiOilProductionQueryParams(product="gasoline") # secondary-only product
def _fetch(fetcher, params: dict):
return asyncio.run(fetcher.fetch_data(params, {}))
def test_start_after_end():
with pytest.raises(OpenBBError, match="start_date must be"):
_fetch(
JodiOilBalanceFetcher,
{"start_date": date(2024, 2, 1), "end_date": date(2024, 1, 1)},
)
def test_oil_dates_before_data():
with pytest.raises(OpenBBError, match="The data starts in 2002"):
_fetch(
JodiOilProductionFetcher,
{"start_date": date(2000, 1, 1), "end_date": date(2001, 12, 31)},
)
def test_gas_dates_before_data():
with pytest.raises(OpenBBError, match="The data starts in 2009"):
_fetch(
JodiGasBalanceFetcher,
{"start_date": date(2007, 1, 1), "end_date": date(2008, 12, 31)},
)
@pytest.mark.usefixtures("mock_download")
def test_empty_result():
with pytest.raises(EmptyDataError):
_fetch(
JodiOilProductionFetcher,
{"start_date": date(2024, 4, 1), "end_date": date(2024, 12, 31)},
)
@pytest.mark.usefixtures("mock_download")
def test_oil_balance_view():
result = _fetch(
JodiOilBalanceFetcher,
{"country": "united_states", "product": "crude_oil", "unit": "kbbl", **DATES},
)
assert len(result.result) == 3
row = result.result[0]
assert row.production is not None
assert row.closing_stocks is not None
assert row.refinery_output is None # secondary-only flow
assert result.metadata["country"] == "United States"
assert result.metadata["product"] == "Crude oil"
assert result.metadata["unit"] == "Thousand barrels"
assert result.metadata["assessments"]["production"] is not None
@pytest.mark.usefixtures("mock_download")
def test_oil_balance_secondary_product():
result = _fetch(
JodiOilBalanceFetcher,
{"country": "united_states", "product": "gasoline", "unit": "kbd", **DATES},
)
row = result.result[0]
assert row.refinery_output is not None
assert row.demand is not None
assert row.refinery_intake is None # primary-only flow
@pytest.mark.usefixtures("mock_download")
def test_oil_production_comparison_view():
result = _fetch(JodiOilProductionFetcher, {**DATES})
row = result.result[0].model_dump(exclude_none=True)
# Columns are countries, alphabetical, after the date.
# Russia is in the sample but reports no kb/d production values.
assert list(row) == ["date", "saudi_arabia", "united_states"]
assert result.metadata["flow"] == "Production"
assert result.metadata["assessments"]["saudi_arabia"] is not None
@pytest.mark.usefixtures("mock_download")
def test_gas_empty_result():
with pytest.raises(EmptyDataError):
_fetch(
JodiGasBalanceFetcher,
{
"country": "norway",
"start_date": date(2024, 4, 1),
"end_date": date(2024, 12, 31),
},
)
@pytest.mark.usefixtures("mock_download")
def test_oil_demand_by_product_view():
result = _fetch(
JodiOilDemandByProductFetcher, {"country": "united_states", **DATES}
)
row = result.result[0]
assert row.gasoline is not None
assert row.total_products is not None
assert result.metadata["flow"] == "Demand"
@pytest.mark.usefixtures("mock_download")
def test_gas_balance_view():
result = _fetch(JodiGasBalanceFetcher, {"country": "norway", **DATES})
row = result.result[0]
assert row.production is not None
assert row.pipeline_exports is not None
assert result.metadata["product"] == "Natural gas"
@pytest.mark.usefixtures("mock_download")
def test_gas_demand_measures_differ():
observed = _fetch(
JodiGasDemandFetcher,
{"country": "united_states", "measure": "observed", **DATES},
)
calculated = _fetch(
JodiGasDemandFetcher,
{"country": "united_states", "measure": "calculated", **DATES},
)
assert observed.metadata["flow"] == "Gross inland deliveries (observed)"
assert calculated.metadata["flow"] == "Gross inland deliveries (calculated)"
obs = observed.result[0].model_dump(exclude_none=True)["united_states"]
calc = calculated.result[0].model_dump(exclude_none=True)["united_states"]
assert obs != calc
@pytest.mark.usefixtures("mock_download")
def test_gas_imports_flow_selection():
total = _fetch(JodiGasImportsFetcher, {"country": "united_states", **DATES})
pipeline = _fetch(
JodiGasImportsFetcher,
{"country": "united_states", "flow": "pipeline", **DATES},
)
assert total.metadata["flow"] == "Imports"
assert pipeline.metadata["flow"] == "Pipeline imports"
total_value = total.result[0].model_dump(exclude_none=True)["united_states"]
pipeline_value = pipeline.result[0].model_dump(exclude_none=True)["united_states"]
assert pipeline_value <= total_value

3581
openbb_platform/providers/jodi/uv.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff