diff --git a/.codespell.skip b/.codespell.skip
index aa33a1b0c55..833490cbdc7 100644
--- a/.codespell.skip
+++ b/.codespell.skip
@@ -23,6 +23,7 @@
./openbb_platform/providers/imf/openbb_imf/utils/constants.py
./openbb_platform/extensions/economy/openbb_economy/economy_views.py
./openbb_platform/providers/fmp/openbb_fmp/models/calendar_ipo.py
+./openbb_platform/providers/imf/openbb_imf/models/consumer_price_index.py
./desktop/src-tauri/src/tauri_handlers/startup.rs
./desktop/src/routes/environments.tsx
./desktop/src/tests/routes/installation-progress.test.tsx
diff --git a/cli/poetry.lock b/cli/poetry.lock
index 17a573116a1..07b00e5e237 100644
--- a/cli/poetry.lock
+++ b/cli/poetry.lock
@@ -329,7 +329,7 @@ description = "Timeout context manager for asyncio programs"
optional = false
python-versions = ">=3.8"
groups = ["main"]
-markers = "python_version == \"3.10\""
+markers = "python_full_version < \"3.11.3\""
files = [
{file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"},
{file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
@@ -349,14 +349,14 @@ files = [
[[package]]
name = "authlib"
-version = "1.6.5"
+version = "1.6.6"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a"},
- {file = "authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b"},
+ {file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"},
+ {file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"},
]
[package.dependencies]
@@ -377,24 +377,41 @@ files = [
[package.extras]
dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
+[[package]]
+name = "backports-tarfile"
+version = "1.2.0"
+description = "Backport of CPython tarfile module"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+markers = "python_version < \"3.12\""
+files = [
+ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"},
+ {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"]
+
[[package]]
name = "beartype"
-version = "0.22.8"
+version = "0.22.9"
description = "Unbearably fast near-real-time pure-Python runtime-static type-checker."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "beartype-0.22.8-py3-none-any.whl", hash = "sha256:b832882d04e41a4097bab9f63e6992bc6de58c414ee84cba9b45b67314f5ab2e"},
- {file = "beartype-0.22.8.tar.gz", hash = "sha256:b19b21c9359722ee3f7cc433f063b3e13997b27ae8226551ea5062e621f61165"},
+ {file = "beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2"},
+ {file = "beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f"},
]
[package.extras]
-dev = ["autoapi (>=0.9.0)", "celery", "click", "coverage (>=5.5)", "docutils (>=0.22.0)", "equinox ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "fastmcp ; python_version < \"3.14.0\"", "jax[cpu] ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "jaxtyping ; sys_platform == \"linux\"", "langchain ; python_version < \"3.14.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "numba ; python_version < \"3.14.0\"", "numpy ; python_version < \"3.15.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera (>=0.26.0) ; python_version < \"3.14.0\"", "poetry", "polars ; python_version < \"3.14.0\"", "pydata-sphinx-theme (<=0.7.2)", "pygments", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "setuptools", "sphinx", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)", "sqlalchemy", "torch ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "tox (>=3.20.1)", "typer", "typing-extensions (>=3.10.0.0)", "xarray ; python_version < \"3.15.0\""]
+dev = ["autoapi (>=0.9.0)", "celery", "click", "coverage (>=5.5)", "docutils (>=0.22.0)", "equinox ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "fastmcp ; python_version < \"3.14.0\"", "jax[cpu] ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "jaxtyping ; sys_platform == \"linux\"", "langchain ; python_version < \"3.14.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "numba ; python_version < \"3.14.0\"", "numpy ; python_version < \"3.15.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera (>=0.26.0) ; python_version < \"3.14.0\"", "poetry", "polars ; python_version < \"3.14.0\"", "pydata-sphinx-theme (<=0.7.2)", "pygments", "pyinstaller", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "setuptools", "sphinx", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)", "sqlalchemy", "torch ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "tox (>=3.20.1)", "typer", "typing-extensions (>=3.10.0.0)", "xarray ; python_version < \"3.15.0\""]
doc-ghp = ["mkdocs-material[imaging] (>=9.6.0)", "mkdocstrings-python (>=1.16.0)", "mkdocstrings-python-xref (>=1.16.0)"]
doc-rtd = ["autoapi (>=0.9.0)", "pydata-sphinx-theme (<=0.7.2)", "setuptools", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)"]
-test = ["celery", "click", "coverage (>=5.5)", "docutils (>=0.22.0)", "equinox ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "fastmcp ; python_version < \"3.14.0\"", "jax[cpu] ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "jaxtyping ; sys_platform == \"linux\"", "langchain ; python_version < \"3.14.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "numba ; python_version < \"3.14.0\"", "numpy ; python_version < \"3.15.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera (>=0.26.0) ; python_version < \"3.14.0\"", "poetry", "polars ; python_version < \"3.14.0\"", "pygments", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "sphinx", "sqlalchemy", "torch ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "tox (>=3.20.1)", "typer", "typing-extensions (>=3.10.0.0)", "xarray ; python_version < \"3.15.0\""]
-test-tox = ["celery", "click", "docutils (>=0.22.0)", "equinox ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "fastmcp ; python_version < \"3.14.0\"", "jax[cpu] ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "jaxtyping ; sys_platform == \"linux\"", "langchain ; python_version < \"3.14.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "numba ; python_version < \"3.14.0\"", "numpy ; python_version < \"3.15.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera (>=0.26.0) ; python_version < \"3.14.0\"", "poetry", "polars ; python_version < \"3.14.0\"", "pygments", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "sphinx", "sqlalchemy", "torch ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "typer", "typing-extensions (>=3.10.0.0)", "xarray ; python_version < \"3.15.0\""]
+test = ["celery", "click", "coverage (>=5.5)", "docutils (>=0.22.0)", "equinox ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "fastmcp ; python_version < \"3.14.0\"", "jax[cpu] ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "jaxtyping ; sys_platform == \"linux\"", "langchain ; python_version < \"3.14.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "numba ; python_version < \"3.14.0\"", "numpy ; python_version < \"3.15.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera (>=0.26.0) ; python_version < \"3.14.0\"", "poetry", "polars ; python_version < \"3.14.0\"", "pygments", "pyinstaller", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "sphinx", "sqlalchemy", "torch ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "tox (>=3.20.1)", "typer", "typing-extensions (>=3.10.0.0)", "xarray ; python_version < \"3.15.0\""]
+test-tox = ["celery", "click", "docutils (>=0.22.0)", "equinox ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "fastmcp ; python_version < \"3.14.0\"", "jax[cpu] ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "jaxtyping ; sys_platform == \"linux\"", "langchain ; python_version < \"3.14.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "numba ; python_version < \"3.14.0\"", "numpy ; python_version < \"3.15.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera (>=0.26.0) ; python_version < \"3.14.0\"", "poetry", "polars ; python_version < \"3.14.0\"", "pygments", "pyinstaller", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "sphinx", "sqlalchemy", "torch ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "typer", "typing-extensions (>=3.10.0.0)", "xarray ; python_version < \"3.15.0\""]
test-tox-coverage = ["coverage (>=5.5)"]
[[package]]
@@ -420,126 +437,16 @@ charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
-[[package]]
-name = "brotli"
-version = "1.2.0"
-description = "Python bindings for the Brotli compression library"
-optional = false
-python-versions = "*"
-groups = ["main"]
-files = [
- {file = "brotli-1.2.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8"},
- {file = "brotli-1.2.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a"},
- {file = "brotli-1.2.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92"},
- {file = "brotli-1.2.0-cp27-cp27m-win32.whl", hash = "sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb"},
- {file = "brotli-1.2.0-cp27-cp27m-win_amd64.whl", hash = "sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f"},
- {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f"},
- {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46"},
- {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e"},
- {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984"},
- {file = "brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de"},
- {file = "brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947"},
- {file = "brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2"},
- {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84"},
- {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d"},
- {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1"},
- {file = "brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997"},
- {file = "brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196"},
- {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744"},
- {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f"},
- {file = "brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd"},
- {file = "brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe"},
- {file = "brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a"},
- {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b"},
- {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3"},
- {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae"},
- {file = "brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03"},
- {file = "brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24"},
- {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84"},
- {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b"},
- {file = "brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d"},
- {file = "brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca"},
- {file = "brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f"},
- {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28"},
- {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7"},
- {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036"},
- {file = "brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161"},
- {file = "brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44"},
- {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab"},
- {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c"},
- {file = "brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f"},
- {file = "brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6"},
- {file = "brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c"},
- {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48"},
- {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18"},
- {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5"},
- {file = "brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a"},
- {file = "brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8"},
- {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21"},
- {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac"},
- {file = "brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e"},
- {file = "brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7"},
- {file = "brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63"},
- {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b"},
- {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361"},
- {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888"},
- {file = "brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d"},
- {file = "brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3"},
- {file = "brotli-1.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518"},
- {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69"},
- {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e"},
- {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7"},
- {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0"},
- {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea"},
- {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502"},
- {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64"},
- {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533"},
- {file = "brotli-1.2.0-cp36-cp36m-win32.whl", hash = "sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96"},
- {file = "brotli-1.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13"},
- {file = "brotli-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8"},
- {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8"},
- {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc"},
- {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6"},
- {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190"},
- {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a"},
- {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12"},
- {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3"},
- {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a"},
- {file = "brotli-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982"},
- {file = "brotli-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16"},
- {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8"},
- {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990"},
- {file = "brotli-1.2.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526"},
- {file = "brotli-1.2.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2"},
- {file = "brotli-1.2.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675"},
- {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d"},
- {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5"},
- {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7"},
- {file = "brotli-1.2.0-cp38-cp38-win32.whl", hash = "sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c"},
- {file = "brotli-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470"},
- {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1"},
- {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17"},
- {file = "brotli-1.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971"},
- {file = "brotli-1.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e"},
- {file = "brotli-1.2.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8"},
- {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a"},
- {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b"},
- {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4"},
- {file = "brotli-1.2.0-cp39-cp39-win32.whl", hash = "sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49"},
- {file = "brotli-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937"},
- {file = "brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a"},
-]
-
[[package]]
name = "cachetools"
-version = "6.2.2"
+version = "6.2.4"
description = "Extensible memoizing collections and decorators"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace"},
- {file = "cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6"},
+ {file = "cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51"},
+ {file = "cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607"},
]
[[package]]
@@ -816,6 +723,18 @@ files = [
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
+[[package]]
+name = "cloudpickle"
+version = "3.1.2"
+description = "Pickler class to extend the standard pickle.Pickler functionality"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a"},
+ {file = "cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414"},
+]
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -927,45 +846,26 @@ ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
-[[package]]
-name = "curl-adapter"
-version = "1.1.0"
-description = "A curl HTTP adapter switch for requests library — make browser-like requests with custom TLS fingerprints."
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "curl_adapter-1.1.0-py3-none-any.whl", hash = "sha256:1bfdd73a692391fa6b8d40fd20ae2784ca1c336730849f0f484a206c31be5990"},
- {file = "curl_adapter-1.1.0.tar.gz", hash = "sha256:17d00115b8286f3d781fc457cecd2bcff1a602f9b523b045a5df460a352bac1d"},
-]
-
-[package.dependencies]
-brotli = "*"
-curl_cffi = ">=0.11.0"
-pycurl = ">=7.45.5"
-requests = "*"
-
-[package.extras]
-test = ["pytest"]
-
[[package]]
name = "curl-cffi"
-version = "0.13.0"
+version = "0.14.0"
description = "libcurl ffi bindings for Python, with impersonation support."
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db"},
- {file = "curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7"},
- {file = "curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076"},
- {file = "curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541"},
- {file = "curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f"},
- {file = "curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51"},
- {file = "curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e"},
- {file = "curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3"},
- {file = "curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8"},
- {file = "curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e"},
+ {file = "curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c"},
+ {file = "curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f"},
]
[package.dependencies]
@@ -974,20 +874,20 @@ cffi = ">=1.12.0"
[package.extras]
build = ["cibuildwheel", "wheel"]
-dev = ["charset_normalizer (>=3.3.2,<4.0)", "coverage (>=6.4.1,<7.0)", "cryptography (>=42.0.5,<43.0)", "httpx (==0.23.1)", "mypy (>=1.9.0,<2.0)", "pytest (>=8.1.1,<9.0)", "pytest-asyncio (>=0.23.6,<1.0)", "pytest-trio (>=0.8.0,<1.0)", "ruff (>=0.3.5,<1.0)", "trio (>=0.25.0,<1.0)", "trustme (>=1.1.0,<2.0)", "typing_extensions", "uvicorn (>=0.29.0,<1.0)", "websockets (>=12.0,<13.0)"]
+dev = ["charset_normalizer (>=3.3.2,<4.0)", "coverage (>=6.4.1,<7.0)", "cryptography (>=42.0.5,<43.0)", "httpx (==0.23.1)", "mypy (>=1.9.0,<2.0)", "pytest (>=8.1.1,<9.0)", "pytest-asyncio (>=0.23.6,<1.0)", "pytest-trio (>=0.8.0,<1.0)", "ruff (>=0.3.5,<1.0)", "trio (>=0.25.0,<1.0)", "trustme (>=1.1.0,<2.0)", "typing_extensions", "uvicorn (>=0.29.0,<1.0)", "websockets (>=14.0)"]
extra = ["lxml_html_clean", "markdownify (>=1.1.0)", "readability-lxml (>=0.8.1)"]
-test = ["charset_normalizer (>=3.3.2,<4.0)", "cryptography (>=42.0.5,<43.0)", "fastapi (==0.110.0)", "httpx (==0.23.1)", "proxy.py (>=2.4.3,<3.0)", "pytest (>=8.1.1,<9.0)", "pytest-asyncio (>=0.23.6,<1.0)", "pytest-trio (>=0.8.0,<1.0)", "python-multipart (>=0.0.9,<1.0)", "trio (>=0.25.0,<1.0)", "trustme (>=1.1.0,<2.0)", "typing_extensions", "uvicorn (>=0.29.0,<1.0)", "websockets (>=12.0,<13.0)"]
+test = ["charset_normalizer (>=3.3.2,<4.0)", "cryptography (>=42.0.5,<43.0)", "fastapi (>=0.110.0,<1.0)", "httpx (==0.23.1)", "proxy.py (>=2.4.3,<3.0)", "pytest (>=8.1.1,<9.0)", "pytest-asyncio (>=0.23.6,<1.0)", "pytest-trio (>=0.8.0,<1.0)", "python-multipart (>=0.0.9,<1.0)", "trio (>=0.25.0,<1.0)", "trustme (>=1.1.0,<2.0)", "typing_extensions", "uvicorn (>=0.29.0,<1.0)", "websockets (>=14.0)"]
[[package]]
name = "cyclopts"
-version = "4.3.0"
+version = "4.4.1"
description = "Intuitive, easy CLIs based on type hints."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "cyclopts-4.3.0-py3-none-any.whl", hash = "sha256:91a30b69faf128ada7cfeaefd7d9649dc222e8b2a8697f1fc99e4ee7b7ca44f3"},
- {file = "cyclopts-4.3.0.tar.gz", hash = "sha256:e95179cd0a959ce250ecfb2f0262a5996a92c1f9467bccad2f3d829e6833cef5"},
+ {file = "cyclopts-4.4.1-py3-none-any.whl", hash = "sha256:67500e9fde90f335fddbf9c452d2e7c4f58209dffe52e7abb1e272796a963bde"},
+ {file = "cyclopts-4.4.1.tar.gz", hash = "sha256:368a404926b46a49dc328a33ccd7e55ba879296a28e64a42afe2f6667704cecf"},
]
[package.dependencies]
@@ -1000,59 +900,60 @@ typing-extensions = {version = ">=4.8.0", markers = "python_version < \"3.11\""}
[package.extras]
debug = ["ipdb (>=0.13.9)", "line-profiler (>=3.5.1)"]
-dev = ["coverage[toml] (>=5.1)", "pre-commit (>=2.16.0)", "pydantic (>=2.11.2,<3.0.0)", "pytest (>=8.2.0)", "pytest-cov (>=3.0.0)", "pytest-mock (>=3.7.0)", "pyyaml (>=6.0.1)", "toml (>=0.10.2,<1.0.0)", "trio (>=0.10.0)"]
+dev = ["coverage[toml] (>=5.1)", "mkdocs (>=1.4.0)", "pre-commit (>=2.16.0)", "pydantic (>=2.11.2,<3.0.0)", "pytest (>=8.2.0)", "pytest-cov (>=3.0.0)", "pytest-mock (>=3.7.0)", "pyyaml (>=6.0.1)", "syrupy (>=4.0.0)", "toml (>=0.10.2,<1.0.0)", "trio (>=0.10.0)"]
docs = ["gitpython (>=3.1.31)", "myst-parser[linkify] (>=3.0.1,<5.0.0)", "sphinx (>=7.4.7,<8.2.0)", "sphinx-autodoc-typehints (>=1.25.2,<4.0.0)", "sphinx-copybutton (>=0.5,<1.0)", "sphinx-rtd-dark-mode (>=1.3.0,<2.0.0)", "sphinx-rtd-theme (>=3.0.0,<4.0.0)"]
+mkdocs = ["markdown (>=3.3)", "mkdocs (>=1.4.0)", "pymdown-extensions (>=10.0)"]
toml = ["tomli (>=2.0.0) ; python_version < \"3.11\""]
trio = ["trio (>=0.10.0)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "cython"
-version = "3.2.2"
+version = "3.2.3"
description = "The Cython compiler for writing C extensions in the Python language."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "cython-3.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b5afac4e77e71a9010dc7fd3191ced00f9b12b494dd7525c140781054ce63a73"},
- {file = "cython-3.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd2ede6af225499ad22888dbfb13b92d71fc1016f401ee637559a5831b177c2"},
- {file = "cython-3.2.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c9265b3e84ae2d999b7c3165c683e366bbbbbe4346468055ca2366fe013f2df"},
- {file = "cython-3.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:d7b3447b2005dffc5f276d420a480d2b57d15091242652d410b6a46fb00ed251"},
- {file = "cython-3.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d140c2701cbb8cf960300cf1b67f3b4fa9d294d32e51b85f329bff56936a82fd"},
- {file = "cython-3.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50bbaabee733fd2780985e459fc20f655e02def83e8eff10220ad88455a34622"},
- {file = "cython-3.2.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9509f1e9c41c86b790cff745bb31927bbc861662a3b462596d71d3d2a578abb"},
- {file = "cython-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:034ab96cb8bc8e7432bc27491f8d66f51e435b1eb21ddc03aa844be8f21ad847"},
- {file = "cython-3.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:692a41c8fe06fb2dc55ca2c8d71c80c469fd16fe69486ed99f3b3cbb2d3af83f"},
- {file = "cython-3.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:098590c1dc309f8a0406ade031963a95a87714296b425539f9920aebf924560d"},
- {file = "cython-3.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3898c076e9c458bcb3e4936187919fda5f5365fe4c567d35d2b003444b6f3fe"},
- {file = "cython-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:2b910b89a2a71004064c5e890b9512a251eda63fae252caa0feb9835057035f9"},
- {file = "cython-3.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa24cd0bdab27ca099b2467806c684404add597c1108e07ddf7b6471653c85d7"},
- {file = "cython-3.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60f4aa425e1ff98abf8d965ae7020f06dd2cbc01dbd945137d2f9cca4ff0524a"},
- {file = "cython-3.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a473df474ba89e9fee81ee82b31062a267f9e598096b222783477e56d02ad12c"},
- {file = "cython-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b4df52101209817fde7284cf779156f79142fb639b1d7840f11680ff4bb30604"},
- {file = "cython-3.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:177faf4d61e9f2d4d2db61194ac9ec16d3fe3041c1b6830f871a01935319eeb3"},
- {file = "cython-3.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8db28aef793c81dc69383b619ca508668998aaf099cd839d3cbae85184cce744"},
- {file = "cython-3.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3de43a5786033a27fae1c882feb5ff0d023c38b83356e6800c1be0bcd6cf9f11"},
- {file = "cython-3.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fed44d0ab2d36f1b0301c770b0dafec23bcb9700d58e7769cd6d9136b3304c11"},
- {file = "cython-3.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e7200309b81f4066cf36a96efeec646716ca74afd73d159045169263db891133"},
- {file = "cython-3.2.2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e72ee88a9a5381d30a6da116a3c8352730b9b038a49ed9bc5c3d0ed6d69b06c"},
- {file = "cython-3.2.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e35ff0f1bb3a7a5c40afb8fb540e4178b6551909f10748bf39d323f8140ccf3"},
- {file = "cython-3.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:b223c1f84c3420c24f6a4858e979524bd35a79437a5839e29d41201c87ed119d"},
- {file = "cython-3.2.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a6387e3ad31342443916db9a419509935fddd8d4cbac34aab9c895ae55326a56"},
- {file = "cython-3.2.2-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:436eb562d0affbc0b959f62f3f9c1ed251b9499e4f29c1d19514ae859894b6bf"},
- {file = "cython-3.2.2-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f560ff3aea5b5df93853ec7bf1a1e9623d6d511f4192f197559aca18fca43392"},
- {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d8c93fe128b58942832b1fcac96e48f93c2c69b569eff0d38d30fb5995fecfa0"},
- {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b4fe499eed7cd70b2aa4e096b9ce2588f5e6fdf049b46d40a5e55efcde6e4904"},
- {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:14432d7f207245a3c35556155873f494784169297b28978a6204f1c60d31553e"},
- {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:820c4a99dbf6b3e6c0300be42b4040b501eff0e1feeb80cfa52c48a346fb0df2"},
- {file = "cython-3.2.2-cp39-abi3-win32.whl", hash = "sha256:826cad0ad43ab05a26e873b5d625f64d458dc739ec6fdeecab848b60a91c4252"},
- {file = "cython-3.2.2-cp39-abi3-win_arm64.whl", hash = "sha256:5f818d40bbcf17e2089e2de7840f0de1c0ca527acf9b044aba79d5f5d8a5bdba"},
- {file = "cython-3.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff07e784ea748225bbdea07fec0ac451379e9e41a0a84cb57b36db19dd01ae71"},
- {file = "cython-3.2.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff11412ed5fc78bd8b148621f4d1034fcad6cfcba468c20cd9f327b4f61ec3e"},
- {file = "cython-3.2.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca18d9d53c0e2f0c9347478b37532b46e0dc34c704e052ab1b0d8b21a290fc0f"},
- {file = "cython-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:86b1d39a1ea974dd16fe3bcef0df7b64dadd0bd38d05a339f287b48d37cb109f"},
- {file = "cython-3.2.2-py3-none-any.whl", hash = "sha256:13b99ecb9482aff6a6c12d1ca6feef6940c507af909914b49f568de74fa965fb"},
- {file = "cython-3.2.2.tar.gz", hash = "sha256:c3add3d483acc73129a61d105389344d792c17e7c1cee24863f16416bd071634"},
+ {file = "cython-3.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:55c0157a5940fbf0b054508207fe0fc5cc796d0532af492c0fa35b5b41a883f7"},
+ {file = "cython-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51fd1a56d0fc682c05ecc44f11927dbe28dd2867c30148557b62d7d1017a13d8"},
+ {file = "cython-3.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1309bdce06f767e8514377f44b3a5b9e5b91e58af1348010cca10b572e1852ad"},
+ {file = "cython-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:6b6dd6b7aca8447b2a6779b314cc402f1e4990754507a88477e535b3c8b41ad1"},
+ {file = "cython-3.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c041f7e338cca2422e0924716b04fabeda57636214324fc1941396acce99e7c7"},
+ {file = "cython-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:283262b8f902323ceb6ed3b643f275a2a963e7ab059f0714a467933383cbc56d"},
+ {file = "cython-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22a624290c2883387b2c2cfb5224c15bff21432c6a2cf0c23ac8df3dcbd45e96"},
+ {file = "cython-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:26404441f733fd1cfb0dd9c45477f501437e7d51fad05bb402bd2feb4e127aa3"},
+ {file = "cython-3.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf210228c15b5c625824d8e31d43b6fea25f9e13c81dac632f2f7d838e0229a5"},
+ {file = "cython-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5bf0cebeb4147e172a114437d3fce5a507595d8fdd821be792b1bb25c691514"},
+ {file = "cython-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1f8700ba89c977438744f083890d87187f15709507a5489e0f6d682053b7fa0"},
+ {file = "cython-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:25732f3981a93407826297f4423206e5e22c3cfccfc74e37bf444453bbdc076f"},
+ {file = "cython-3.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1d097ad4686b58b8c03d760d08eca28f79878d404ef7452c49636170571654e0"},
+ {file = "cython-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a18f2e3bcd018416157d0a83446e29b4a31437ab79061fe5504c077e70389d0"},
+ {file = "cython-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73afc824896ffaf22bf8122d0a7107f0120e3188a353bdcfa92317fc0d9a87ce"},
+ {file = "cython-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:9aa1a8abf3d8bb53cc19cfaa21c004afad8d4ccb17513f8aa11a788d1f525abd"},
+ {file = "cython-3.2.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80f20369d7aaf4e76cfef902025256918a5cc6eb0aed6d8783e4b1c563e4f6c4"},
+ {file = "cython-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d19376252722241a3d3ec8a695c5cae4deb053486d2e5f9a40cb569a0cf984"},
+ {file = "cython-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4293f1861480b397809a6f021a6c12e15e918feae1c7add80c99d07af206578"},
+ {file = "cython-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:84330e7c8bf220a82b633678b9f99e10227c8f4c406d67c5552449ab2afedef8"},
+ {file = "cython-3.2.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:18b5e738c9dbb1cb488b51e35c3a1b9aa7363166cce40158fe6ccb454f6498ac"},
+ {file = "cython-3.2.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c102388cf7604ff5647164d5041fd5de2393198a87883fdb50f6c078fc2f4f8e"},
+ {file = "cython-3.2.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e31f52ea7e35463b21410f150006eb07b663a0b3ceea8ee8f35e0b8e02b75f50"},
+ {file = "cython-3.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:228f11be8406015d1ce84a25bf4f96167ff6fc18b834deca5716d95ae15a818c"},
+ {file = "cython-3.2.3-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:74f482da8b605c61b4df6ff716d013f20131949cb2fa59b03e63abd36ef5bac0"},
+ {file = "cython-3.2.3-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a75a04688875b275a6c875565e672325bae04327dd6ec2fc25aeb5c6cf82fce"},
+ {file = "cython-3.2.3-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b01b36c9eb1b68c25bddbeef7379f7bfc37f7c9afc044e71840ffab761a2dd0"},
+ {file = "cython-3.2.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3829f99d611412288f44ff543e9d2b5c0c83274998b2a6680bbe5cca3539c1fd"},
+ {file = "cython-3.2.3-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c2365a0c79ab9c0fa86d30a4a6ba7e37fc1be9537c48b79b9d63ee7e08bf2fef"},
+ {file = "cython-3.2.3-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3141734fb15f8b5e9402b9240f8da8336edecae91742b41c85678c31ab68f66d"},
+ {file = "cython-3.2.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9a24cc653fad3adbd9cbaa638d80df3aa08a1fe27f62eb35850971c70be680df"},
+ {file = "cython-3.2.3-cp39-abi3-win32.whl", hash = "sha256:b39dff92db70cbd95528f3b81d70e06bd6d3fc9c1dd91321e4d3b999ece3bceb"},
+ {file = "cython-3.2.3-cp39-abi3-win_arm64.whl", hash = "sha256:18edc858e6a52de47fe03ffa97ea14dadf450e20069de0a8aef531006c4bbd93"},
+ {file = "cython-3.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0145e64485f0a9741c9314ae176282dffeb48d2ef30ae315d7224698867c317f"},
+ {file = "cython-3.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c5ac7201d8382c0da69e4a119c14be6acd7c286615a0383ffbd1dbfdbaa3df7"},
+ {file = "cython-3.2.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fabe2f37acb63a5521d16193effb15acd082e35458f30f1c1f3e506271fee8a7"},
+ {file = "cython-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4f40efa83c93060a271046930d2322ec045abb9722d25a377702cb57aa823c2b"},
+ {file = "cython-3.2.3-py3-none-any.whl", hash = "sha256:06a1317097f540d3bb6c7b81ed58a0d8b9dbfa97abf39dfd4c22ee87a6c7241e"},
+ {file = "cython-3.2.3.tar.gz", hash = "sha256:f13832412d633376ffc08d751cc18ed0d7d00a398a4065e2871db505258748a6"},
]
[[package]]
@@ -1166,14 +1067,14 @@ test = ["pytest"]
[[package]]
name = "docutils"
-version = "0.22.3"
+version = "0.22.4"
description = "Docutils -- Python Documentation Utilities"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb"},
- {file = "docutils-0.22.3.tar.gz", hash = "sha256:21486ae730e4ca9f622677b1412b879af1791efcfba517e4c6f60be543fc8cdd"},
+ {file = "docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de"},
+ {file = "docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968"},
]
[[package]]
@@ -1242,6 +1143,32 @@ pyluach = ">=2.3.0"
toolz = ">=1.0.0"
tzdata = ">=2025.2"
+[[package]]
+name = "fakeredis"
+version = "2.33.0"
+description = "Python implementation of redis API, can be used for testing purposes."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965"},
+ {file = "fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770"},
+]
+
+[package.dependencies]
+lupa = {version = ">=2.1", optional = true, markers = "extra == \"lua\""}
+redis = {version = ">=4.3", markers = "python_version > \"3.8\""}
+sortedcontainers = ">=2"
+typing-extensions = {version = ">=4.7,<5.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+bf = ["pyprobables (>=0.6)"]
+cf = ["pyprobables (>=0.6)"]
+json = ["jsonpath-ng (>=1.6)"]
+lua = ["lupa (>=2.1)"]
+probabilistic = ["pyprobables (>=0.6)"]
+valkey = ["valkey (>=6) ; python_version >= \"3.8\""]
+
[[package]]
name = "fastapi"
version = "0.120.4"
@@ -1282,14 +1209,14 @@ devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benc
[[package]]
name = "fastmcp"
-version = "2.13.2"
+version = "2.14.1"
description = "The fast, Pythonic way to build MCP servers and clients."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "fastmcp-2.13.2-py3-none-any.whl", hash = "sha256:300c59eb970c235bb9d0575883322922e4f2e2468a3d45e90cbfd6b23b7be245"},
- {file = "fastmcp-2.13.2.tar.gz", hash = "sha256:2a206401a6579fea621974162674beba85b467ad72c70c1a3752a31951dff7f0"},
+ {file = "fastmcp-2.14.1-py3-none-any.whl", hash = "sha256:fb3e365cc1d52573ab89caeba9944dd4b056149097be169bce428e011f0a57e5"},
+ {file = "fastmcp-2.14.1.tar.gz", hash = "sha256:132725cbf77b68fa3c3d165eff0cfa47e40c1479457419e6a2cfda65bd84c8d6"},
]
[package.dependencies]
@@ -1298,11 +1225,12 @@ cyclopts = ">=4.0.0"
exceptiongroup = ">=1.2.2"
httpx = ">=0.28.1"
jsonschema-path = ">=0.3.4"
-mcp = ">=1.19.0,<1.21.1 || >1.21.1,<2.0.0"
+mcp = ">=1.24.0"
openapi-pydantic = ">=0.5.1"
platformdirs = ">=4.0.0"
-py-key-value-aio = {version = ">=0.2.8,<0.4.0", extras = ["disk", "memory"]}
+py-key-value-aio = {version = ">=0.3.0,<0.4.0", extras = ["disk", "keyring", "memory"]}
pydantic = {version = ">=2.11.7", extras = ["email"]}
+pydocket = ">=0.15.5"
pyperclip = ">=1.9.0"
python-dotenv = ">=1.1.0"
rich = ">=13.9.4"
@@ -1310,6 +1238,7 @@ uvicorn = ">=0.35"
websockets = ">=15.0.1"
[package.extras]
+anthropic = ["anthropic (>=0.40.0)"]
openai = ["openai (>=1.102.0)"]
[[package]]
@@ -1749,14 +1678,14 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2
[[package]]
name = "importlib-metadata"
-version = "8.7.0"
+version = "8.7.1"
description = "Read metadata from Python packages"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"},
- {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"},
+ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"},
+ {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"},
]
[package.dependencies]
@@ -1766,10 +1695,10 @@ zipp = ">=3.20"
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-enabler = ["pytest-enabler (>=2.2)"]
+enabler = ["pytest-enabler (>=3.4)"]
perf = ["ipython"]
-test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
-type = ["pytest-mypy"]
+test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
+type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"]
[[package]]
name = "inflection"
@@ -1838,16 +1767,98 @@ files = [
{file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"},
]
+[[package]]
+name = "jaraco-classes"
+version = "3.4.0"
+description = "Utility functions for Python class constructs"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"},
+ {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"},
+]
+
+[package.dependencies]
+more-itertools = "*"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
+
+[[package]]
+name = "jaraco-context"
+version = "6.0.2"
+description = "Useful decorators and context managers"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "jaraco_context-6.0.2-py3-none-any.whl", hash = "sha256:55fc21af4b4f9ca94aa643b6ee7fe13b1e4c01abf3aeb98ca4ad9c80b741c786"},
+ {file = "jaraco_context-6.0.2.tar.gz", hash = "sha256:953ae8dddb57b1d791bf72ea1009b32088840a7dd19b9ba16443f62be919ee57"},
+]
+
+[package.dependencies]
+"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""}
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=3.4)"]
+test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"]
+type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"]
+
+[[package]]
+name = "jaraco-functools"
+version = "4.4.0"
+description = "Functools like those found in stdlib"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176"},
+ {file = "jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb"},
+]
+
+[package.dependencies]
+more_itertools = "*"
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=3.4)"]
+test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"]
+type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"]
+
+[[package]]
+name = "jeepney"
+version = "0.9.0"
+description = "Low-level, pure Python DBus protocol wrapper."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+markers = "sys_platform == \"linux\""
+files = [
+ {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"},
+ {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"},
+]
+
+[package.extras]
+test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
+trio = ["trio"]
+
[[package]]
name = "joblib"
-version = "1.5.2"
+version = "1.5.3"
description = "Lightweight pipelining with Python functions"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241"},
- {file = "joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55"},
+ {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"},
+ {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"},
]
[[package]]
@@ -1940,6 +1951,36 @@ files = [
[package.dependencies]
lxml = {version = ">=4.4.2", extras = ["html-clean"]}
+[[package]]
+name = "keyring"
+version = "25.7.0"
+description = "Store and access your passwords safely."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"},
+ {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"},
+]
+
+[package.dependencies]
+importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""}
+"jaraco.classes" = "*"
+"jaraco.context" = "*"
+"jaraco.functools" = "*"
+jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""}
+pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
+SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+completion = ["shtab (>=1.1.0)"]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=3.4)"]
+test = ["pyfakefs", "pytest (>=6,!=8.1.*)"]
+type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"]
+
[[package]]
name = "korean-lunar-calendar"
version = "0.3.1"
@@ -2034,6 +2075,104 @@ files = [
{file = "llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb"},
]
+[[package]]
+name = "lupa"
+version = "2.6"
+description = "Python wrapper around Lua and LuaJIT"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "lupa-2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b3dabda836317e63c5ad052826e156610f356a04b3003dfa0dbe66b5d54d671"},
+ {file = "lupa-2.6-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8726d1c123bbe9fbb974ce29825e94121824e66003038ff4532c14cc2ed0c51c"},
+ {file = "lupa-2.6-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:f4e159e7d814171199b246f9235ca8961f6461ea8c1165ab428afa13c9289a94"},
+ {file = "lupa-2.6-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:202160e80dbfddfb79316692a563d843b767e0f6787bbd1c455f9d54052efa6c"},
+ {file = "lupa-2.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5deede7c5b36ab64f869dae4831720428b67955b0bb186c8349cf6ea121c852b"},
+ {file = "lupa-2.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86f04901f920bbf7c0cac56807dc9597e42347123e6f1f3ca920f15f54188ce5"},
+ {file = "lupa-2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6deef8f851d6afb965c84849aa5b8c38856942df54597a811ce0369ced678610"},
+ {file = "lupa-2.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:21f2b5549681c2a13b1170a26159d30875d367d28f0247b81ca347222c755038"},
+ {file = "lupa-2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:66eea57630eab5e6f49fdc5d7811c0a2a41f2011be4ea56a087ea76112011eb7"},
+ {file = "lupa-2.6-cp310-cp310-win32.whl", hash = "sha256:60a403de8cab262a4fe813085dd77010effa6e2eb1886db2181df803140533b1"},
+ {file = "lupa-2.6-cp310-cp310-win_amd64.whl", hash = "sha256:e4656a39d93dfa947cf3db56dc16c7916cb0cc8024acd3a952071263f675df64"},
+ {file = "lupa-2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d988c0f9331b9f2a5a55186701a25444ab10a1432a1021ee58011499ecbbdd5"},
+ {file = "lupa-2.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ebe1bbf48259382c72a6fe363dea61a0fd6fe19eab95e2ae881e20f3654587bf"},
+ {file = "lupa-2.6-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:a8fcee258487cf77cdd41560046843bb38c2e18989cd19671dd1e2596f798306"},
+ {file = "lupa-2.6-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:561a8e3be800827884e767a694727ed8482d066e0d6edfcbf423b05e63b05535"},
+ {file = "lupa-2.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af880a62d47991cae78b8e9905c008cbfdc4a3a9723a66310c2634fc7644578c"},
+ {file = "lupa-2.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b22923aa4023c86c0097b235615f89d469a0c4eee0489699c494d3367c4c85"},
+ {file = "lupa-2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:153d2cc6b643f7efb9cfc0c6bb55ec784d5bac1a3660cfc5b958a7b8f38f4a75"},
+ {file = "lupa-2.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3fa8777e16f3ded50b72967dc17e23f5a08e4f1e2c9456aff2ebdb57f5b2869f"},
+ {file = "lupa-2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8dbdcbe818c02a2f56f5ab5ce2de374dab03e84b25266cfbaef237829bc09b3f"},
+ {file = "lupa-2.6-cp311-cp311-win32.whl", hash = "sha256:defaf188fde8f7a1e5ce3a5e6d945e533b8b8d547c11e43b96c9b7fe527f56dc"},
+ {file = "lupa-2.6-cp311-cp311-win_amd64.whl", hash = "sha256:9505ae600b5c14f3e17e70f87f88d333717f60411faca1ddc6f3e61dce85fa9e"},
+ {file = "lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56"},
+ {file = "lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58"},
+ {file = "lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5"},
+ {file = "lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d"},
+ {file = "lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31"},
+ {file = "lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9"},
+ {file = "lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323"},
+ {file = "lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8"},
+ {file = "lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c"},
+ {file = "lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce"},
+ {file = "lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f"},
+ {file = "lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310"},
+ {file = "lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380"},
+ {file = "lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e"},
+ {file = "lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685"},
+ {file = "lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff"},
+ {file = "lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203"},
+ {file = "lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be"},
+ {file = "lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a"},
+ {file = "lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772"},
+ {file = "lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75"},
+ {file = "lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9"},
+ {file = "lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1"},
+ {file = "lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18"},
+ {file = "lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe"},
+ {file = "lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad"},
+ {file = "lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8"},
+ {file = "lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6"},
+ {file = "lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134"},
+ {file = "lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3"},
+ {file = "lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9"},
+ {file = "lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f"},
+ {file = "lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67"},
+ {file = "lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf"},
+ {file = "lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142"},
+ {file = "lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953"},
+ {file = "lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983"},
+ {file = "lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa"},
+ {file = "lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7"},
+ {file = "lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f"},
+ {file = "lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c"},
+ {file = "lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80"},
+ {file = "lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291"},
+ {file = "lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52"},
+ {file = "lupa-2.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9591700991e333b70dd92b48f152eb4731b8b24af671a9f6f721b74d68ed4499"},
+ {file = "lupa-2.6-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:ef8dfa7fe08bc3f4591411b8945bbeb15af8512c3e7ad5e9b1e3a9036cdbbce7"},
+ {file = "lupa-2.6-cp38-cp38-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:728c466e91174dad238f8a9c1cbdb8e69ffe559df85f87ee76edac3395300949"},
+ {file = "lupa-2.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c781170bc7134704ae317a66204d30688b41d3e471e17e659987ea4947e11f20"},
+ {file = "lupa-2.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241f4ddab33b9a686fc76667241bebc39a06b74ec40d79ec222f5add9000fe57"},
+ {file = "lupa-2.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:c17f6b6193ced33cc7ca0b2b08b319a1b3501b014a3a3f9999c01cafc04c40f5"},
+ {file = "lupa-2.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:fa6c1379e83d4104065c151736250a09f3a99e368423c7a20f9c59b15945e9fc"},
+ {file = "lupa-2.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aef1a8bc10c50695e1a33a07dbef803b93eb97fc150fdb19858d704a603a67dd"},
+ {file = "lupa-2.6-cp38-cp38-win32.whl", hash = "sha256:10c191bc1d5565e4360d884bea58320975ddb33270cdf9a9f55d1a1efe79aa03"},
+ {file = "lupa-2.6-cp38-cp38-win_amd64.whl", hash = "sha256:05681f8ffb41f0c7fbb9ca859cc3a7e4006e9c6350d25358b535c5295c6a9928"},
+ {file = "lupa-2.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8897dc6c3249786b2cdf2f83324febb436193d4581b6a71dea49f77bf8b19bb0"},
+ {file = "lupa-2.6-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:4446396ca3830be0c106c70db4b4f622c37b2d447874c07952cafb9c57949a4a"},
+ {file = "lupa-2.6-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:5826e687c89995a6eaafeae242071ba16448eec1a9ee8e17ed48551b5d1e21c2"},
+ {file = "lupa-2.6-cp39-cp39-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:5871935cb36d1d22f9c04ac0db75c06751bd95edcfa0d9309f732de908e297a9"},
+ {file = "lupa-2.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43eb6e43ea8512d0d65b995d36dd9d77aa02598035e25b84c23a1b58700c9fb2"},
+ {file = "lupa-2.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:559714053018d9885cc8c36a33c5b7eb9aad30fb6357719cac3ce4dc6b39157e"},
+ {file = "lupa-2.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:57ac88a00ce59bd9d4ddcd4fca8e02564765725f5068786b011c9d1be3de20c5"},
+ {file = "lupa-2.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:b683fbd867c2e54c44a686361b75eee7e7a790da55afdbe89f1f23b106de0274"},
+ {file = "lupa-2.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d2f656903a2ed2e074bf2b7d300968028dfa327a45b055be8e3b51ef0b82f9bf"},
+ {file = "lupa-2.6-cp39-cp39-win32.whl", hash = "sha256:bf28f68ae231b72008523ab5ac23835ba0f76e0e99ec38b59766080a84eb596a"},
+ {file = "lupa-2.6-cp39-cp39-win_amd64.whl", hash = "sha256:b4b2e9b3795a9897cf6cfcc58d08210fdc0d13ab47c9a0e13858c68932d8353c"},
+ {file = "lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9"},
+]
+
[[package]]
name = "lxml"
version = "5.4.0"
@@ -2227,14 +2366,14 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
[[package]]
name = "mcp"
-version = "1.23.3"
+version = "1.25.0"
description = "Model Context Protocol SDK"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "mcp-1.23.3-py3-none-any.whl", hash = "sha256:32768af4b46a1b4f7df34e2bfdf5c6011e7b63d7f1b0e321d0fdef4cd6082031"},
- {file = "mcp-1.23.3.tar.gz", hash = "sha256:b3b0da2cc949950ce1259c7bfc1b081905a51916fcd7c8182125b85e70825201"},
+ {file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"},
+ {file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"},
]
[package.dependencies]
@@ -2466,14 +2605,14 @@ files = [
[[package]]
name = "narwhals"
-version = "2.13.0"
+version = "2.14.0"
description = "Extremely lightweight compatibility layer between dataframe libraries"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "narwhals-2.13.0-py3-none-any.whl", hash = "sha256:9b795523c179ca78204e3be53726da374168f906e38de2ff174c2363baaaf481"},
- {file = "narwhals-2.13.0.tar.gz", hash = "sha256:ee94c97f4cf7cfeebbeca8d274784df8b3d7fd3f955ce418af998d405576fdd9"},
+ {file = "narwhals-2.14.0-py3-none-any.whl", hash = "sha256:b56796c9a00179bd757d15282c540024e1d5c910b19b8c9944d836566c030acf"},
+ {file = "narwhals-2.14.0.tar.gz", hash = "sha256:98be155c3599db4d5c211e565c3190c398c87e7bf5b3cdb157dece67641946e0"},
]
[package.extras]
@@ -3405,19 +3544,19 @@ urllib3 = ">1.26.16"
[[package]]
name = "openbb-platform-api"
-version = "1.2.1"
-description = "OpenBB Platform API: Launch script and widgets builder for the OpenBB Platform API and Workspace Backend Connector."
+version = "1.2.2"
+description = "OpenBB Platform API: Launch script and widgets builder for the Open Data Platform REST API and Workspace Backend Connector."
optional = false
-python-versions = "<3.14,>=3.9.21"
+python-versions = "<3.14,>=3.10"
groups = ["main"]
files = [
- {file = "openbb_platform_api-1.2.1-py3-none-any.whl", hash = "sha256:2c5d8aa0bec218b31c814cad21d52fdb10eb1ad8f92295ea74329d47a6ed26f6"},
- {file = "openbb_platform_api-1.2.1.tar.gz", hash = "sha256:f532d6ee697ebb2117685139dd205068fec6eb1fe014e87dcb788b4fadd00493"},
+ {file = "openbb_platform_api-1.2.2-py3-none-any.whl", hash = "sha256:df5d8db50794d2d6e0d58f3496075df28e5280e8dace55704927eec86196d6bc"},
+ {file = "openbb_platform_api-1.2.2.tar.gz", hash = "sha256:a2c50a650ac47ace5db6c4b1e1a7de93dce49cffc2898e3cb8d8e6503ce8abb6"},
]
[package.dependencies]
deepdiff = ">=8.6.1"
-openbb-core = ">=1.5.1,<2.0.0"
+openbb-core = ">=1.5.6,<2.0.0"
[[package]]
name = "openbb-polygon"
@@ -3634,20 +3773,19 @@ openbb-core = ">=1.5.1,<2.0.0"
[[package]]
name = "openbb-yfinance"
-version = "1.5.0"
+version = "1.5.1"
description = "yfinance extension for OpenBB"
optional = false
-python-versions = "<3.14,>=3.9.21"
+python-versions = "<3.14,>=3.10"
groups = ["main"]
files = [
- {file = "openbb_yfinance-1.5.0-py3-none-any.whl", hash = "sha256:5782aff12244bf77ed31ccf94dbbda537cec8653fa4245e3d3214b7a6f3e8514"},
- {file = "openbb_yfinance-1.5.0.tar.gz", hash = "sha256:63bb3ed1943ce93a83cf436edec55862e239466a761d2106d0d52b3869a45dda"},
+ {file = "openbb_yfinance-1.5.1-py3-none-any.whl", hash = "sha256:c3dea441c22c744d87adc0c42484f651f2a893121f413ed45b83a27d7dd33243"},
+ {file = "openbb_yfinance-1.5.1.tar.gz", hash = "sha256:ef5ec4f7c36662d151cfa9ae2305559db820834c5f802b705d61d8d59c428ba2"},
]
[package.dependencies]
-curl-adapter = ">=1.1.0"
-openbb-core = ">=1.5.1,<2.0.0"
-yfinance = "0.2.58"
+openbb-core = ">=1.5.6,<2.0.0"
+yfinance = ">=0.2.66,<0.3.0"
[[package]]
name = "openpyxl"
@@ -3664,6 +3802,90 @@ files = [
[package.dependencies]
et-xmlfile = "*"
+[[package]]
+name = "opentelemetry-api"
+version = "1.39.1"
+description = "OpenTelemetry Python API"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"},
+ {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"},
+]
+
+[package.dependencies]
+importlib-metadata = ">=6.0,<8.8.0"
+typing-extensions = ">=4.5.0"
+
+[[package]]
+name = "opentelemetry-exporter-prometheus"
+version = "0.60b1"
+description = "Prometheus Metric Exporter for OpenTelemetry"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd"},
+ {file = "opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b"},
+]
+
+[package.dependencies]
+opentelemetry-api = ">=1.12,<2.0"
+opentelemetry-sdk = ">=1.39.1,<1.40.0"
+prometheus-client = ">=0.5.0,<1.0.0"
+
+[[package]]
+name = "opentelemetry-instrumentation"
+version = "0.60b1"
+description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d"},
+ {file = "opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a"},
+]
+
+[package.dependencies]
+opentelemetry-api = ">=1.4,<2.0"
+opentelemetry-semantic-conventions = "0.60b1"
+packaging = ">=18.0"
+wrapt = ">=1.0.0,<2.0.0"
+
+[[package]]
+name = "opentelemetry-sdk"
+version = "1.39.1"
+description = "OpenTelemetry Python SDK"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"},
+ {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"},
+]
+
+[package.dependencies]
+opentelemetry-api = "1.39.1"
+opentelemetry-semantic-conventions = "0.60b1"
+typing-extensions = ">=4.5.0"
+
+[[package]]
+name = "opentelemetry-semantic-conventions"
+version = "0.60b1"
+description = "OpenTelemetry Semantic Conventions"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"},
+ {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"},
+]
+
+[package.dependencies]
+opentelemetry-api = "1.39.1"
+typing-extensions = ">=4.5.0"
+
[[package]]
name = "orderly-set"
version = "5.5.0"
@@ -3929,6 +4151,21 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
+[[package]]
+name = "prometheus-client"
+version = "0.23.1"
+description = "Python client for the Prometheus monitoring system."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99"},
+ {file = "prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce"},
+]
+
+[package.extras]
+twisted = ["twisted"]
+
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
@@ -4076,6 +4313,26 @@ files = [
{file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"},
]
+[[package]]
+name = "protobuf"
+version = "6.33.2"
+description = ""
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d"},
+ {file = "protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4"},
+ {file = "protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43"},
+ {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e"},
+ {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872"},
+ {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f"},
+ {file = "protobuf-6.33.2-cp39-cp39-win32.whl", hash = "sha256:7109dcc38a680d033ffb8bf896727423528db9163be1b6a02d6a49606dcadbfe"},
+ {file = "protobuf-6.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:2981c58f582f44b6b13173e12bb8656711189c2a70250845f264b877f00b1913"},
+ {file = "protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c"},
+ {file = "protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4"},
+]
+
[[package]]
name = "py-key-value-aio"
version = "0.3.0"
@@ -4092,8 +4349,10 @@ files = [
beartype = ">=0.20.0"
cachetools = {version = ">=5.0.0", optional = true, markers = "extra == \"memory\""}
diskcache = {version = ">=5.0.0", optional = true, markers = "extra == \"disk\""}
+keyring = {version = ">=25.6.0", optional = true, markers = "extra == \"keyring\""}
pathvalidate = {version = ">=3.3.1", optional = true, markers = "extra == \"disk\""}
py-key-value-shared = "0.3.0"
+redis = {version = ">=4.3.0", optional = true, markers = "extra == \"redis\""}
[package.extras]
disk = ["diskcache (>=5.0.0)", "pathvalidate (>=3.3.1)"]
@@ -4142,59 +4401,6 @@ files = [
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
]
-[[package]]
-name = "pycurl"
-version = "7.45.7"
-description = "PycURL -- A Python Interface To The cURL library"
-optional = false
-python-versions = ">=3.5"
-groups = ["main"]
-files = [
- {file = "pycurl-7.45.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c21c3262244f7b2af27636bc1052cb861a70df933b8bc188a7fb8fe3c895b608"},
- {file = "pycurl-7.45.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9903eabe2578143487d2387947d28323e12df49e142ffb0e3b5709fc6c942c47"},
- {file = "pycurl-7.45.7-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:fb97f8f3f7754a5a00da450248de79d83e34938db04486efd26db81526dc25b4"},
- {file = "pycurl-7.45.7-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:26fcc5b982fdc7c49ab9dd84910e986ef2631f1b57e02ecefd5393366f7acbe6"},
- {file = "pycurl-7.45.7-cp310-cp310-win32.whl", hash = "sha256:38492a48bd51252b70d6008b2f38a62b8ddb55ed7d4c905717eca290c94ef125"},
- {file = "pycurl-7.45.7-cp310-cp310-win_amd64.whl", hash = "sha256:2101c343f425545d8329004971d3549a1c553fb2b027c23ddfffebdd7b3fb309"},
- {file = "pycurl-7.45.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f17264adc95a7f92a148d17c55eee885da640cbd98cf9169e643029d79bb18c7"},
- {file = "pycurl-7.45.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0bcfeaf766372ec40e3830d4ad6ebc3064e8a0bd145214d4291d17ed6017f3e3"},
- {file = "pycurl-7.45.7-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:788df7ab964f03894c616e742e088f1bf209a54465f61541ba77901a9e1cd701"},
- {file = "pycurl-7.45.7-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3d19b4a94c2e20976cbc7773e5398bae19e3bc65a0894fcd01d8477bfc5fdb2f"},
- {file = "pycurl-7.45.7-cp311-cp311-win32.whl", hash = "sha256:d5d7bc6e01d74548e93d5b9b3e38c3a91b6f54a36f2fe13407cac035b57f0c1f"},
- {file = "pycurl-7.45.7-cp311-cp311-win_amd64.whl", hash = "sha256:94a3c02381b62b2f0498f4ea3a3bd8b96ae2a6a905fcfd198547805894793a5b"},
- {file = "pycurl-7.45.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd69340f5a49d6e4f67723db6ea4459c554d2210c3dd4da99950d6d405ddafb4"},
- {file = "pycurl-7.45.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:752a5bbbd1b148b61d69be718e2c1c89a1b16de27a21e6382d28c8d2a33e9bf1"},
- {file = "pycurl-7.45.7-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fa5afd86d86e156051c7d7d6de34f6c03a7a64e4794ac0d72379bee8fcafafd"},
- {file = "pycurl-7.45.7-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f463a7f7c12b322b4f72af908f863b4da1f71dae344dd75f50d30c33ff0f197a"},
- {file = "pycurl-7.45.7-cp312-cp312-win32.whl", hash = "sha256:83c217e0c7aea28bbd9512e27fb6f9ef9b9f398dff57ebe223ecaa043bcd08fa"},
- {file = "pycurl-7.45.7-cp312-cp312-win_amd64.whl", hash = "sha256:150b1d2e98bcf760878b3891e6f2f937a4b2a90c559ffc6add65aeb5844bc66a"},
- {file = "pycurl-7.45.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d39cc233c7c3303072758b088ad7ccde0a5dcb30ce752f2f8a31428a07faac1"},
- {file = "pycurl-7.45.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:824a785d01500da47fe6935dbadc4b0a14577405c25d01327a94717578dd2b1d"},
- {file = "pycurl-7.45.7-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:998335e6b69691c097e3a74214901c71773e0a7a8d3d0cc87c60d6969eb8b584"},
- {file = "pycurl-7.45.7-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:00824c3c64d5e935d0325e6a000bb0242622cd763d1e202c1a8df455bc58da58"},
- {file = "pycurl-7.45.7-cp313-cp313-win32.whl", hash = "sha256:bfe49668ba0a7fba183cf0e189bb8da515793da8f383d8d157ac8647143dc516"},
- {file = "pycurl-7.45.7-cp313-cp313-win_amd64.whl", hash = "sha256:3d9d718c366983bc65b149f0947f016929b4001f604f2523e6de5a49907f4474"},
- {file = "pycurl-7.45.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6157ac0daf30ffc28446c54ebbd073c8c11b45cba9c45256619e218a07a31312"},
- {file = "pycurl-7.45.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:09ac9a855273a77f7c6a72f05be430646c47a298d403a75876c2188ea5d77534"},
- {file = "pycurl-7.45.7-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0263e94d2ea3cd25b5fcf96527f521dc29b8a73a9b19e71223b6e2452f5cb35a"},
- {file = "pycurl-7.45.7-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:54cffa56a5ead14915f1f0d3e8fabf6fcea3056a8e5f971fc1caebb9715bd9b1"},
- {file = "pycurl-7.45.7-cp314-cp314-win32.whl", hash = "sha256:09e426cf5e61e5b37c30903734204f75023baa6e4bfe574e037f12e53ec3f0ba"},
- {file = "pycurl-7.45.7-cp314-cp314-win_amd64.whl", hash = "sha256:8eab3a83670d83966c7a0df4fa02cf2272499966066bfaa810444c427cb653b0"},
- {file = "pycurl-7.45.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:69f16d6709f3305da1043ee9edb5e1715212b75011779d58c4bd8fc9ad9fc88a"},
- {file = "pycurl-7.45.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:435f140afa59eb0cece57e6364348977af768add452d49a8547a26fc90dc2bfa"},
- {file = "pycurl-7.45.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ff50a3d787c3d059f330d7cb7325b40416bcb0f43f5006b396a6e61871df0ebf"},
- {file = "pycurl-7.45.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5ed0cab4e05558ba112111585ae2a85627ab48d6185547fc4ba8ec8e2cfecd0d"},
- {file = "pycurl-7.45.7-cp314-cp314t-win32.whl", hash = "sha256:e79f90c0af413933dedc79a1717f49076f08b32ff4a42776d6c38ba37125717e"},
- {file = "pycurl-7.45.7-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb72c53445b1c09315940c81a14a3635dfde6710aea703976ce9617209830e7"},
- {file = "pycurl-7.45.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ff1afa5e0a0cb6d64703c476a106474bec335958d6d445b02a6be3da4c4acfb"},
- {file = "pycurl-7.45.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e39d6503d5a62dcd73f5de40042b20111f829fea0057fed95f38f06f367b14c"},
- {file = "pycurl-7.45.7-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:614eff94e9ef4b90540fdcc6d520fcf5f9b7560e8a1cdd5e8a3644f12cefbc74"},
- {file = "pycurl-7.45.7-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:255cd5510bf6dc82bf00e08711e3d8f0ed317ef5fa2f70d70d15192bdfe48593"},
- {file = "pycurl-7.45.7-cp39-cp39-win32.whl", hash = "sha256:40c83986431e9e685d04f2a0c12efafbf7decfc358b3421aeaecec97458d9663"},
- {file = "pycurl-7.45.7-cp39-cp39-win_amd64.whl", hash = "sha256:87140865a8661cedcd2d057e43ddc3a7c6b2c5919e0f47a0aff111d125733951"},
- {file = "pycurl-7.45.7.tar.gz", hash = "sha256:9d43013002eab2fd6d0dcc671cd1e9149e2fc1c56d5e796fad94d076d6cb69ef"},
-]
-
[[package]]
name = "pydantic"
version = "2.12.5"
@@ -4376,6 +4582,33 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
+[[package]]
+name = "pydocket"
+version = "0.16.3"
+description = "A distributed background task system for Python functions"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "pydocket-0.16.3-py3-none-any.whl", hash = "sha256:e2b50925356e7cd535286255195458ac7bba15f25293356651b36d223db5dd7c"},
+ {file = "pydocket-0.16.3.tar.gz", hash = "sha256:78e9da576de09e9f3f410d2471ef1c679b7741ddd21b586c97a13872b69bd265"},
+]
+
+[package.dependencies]
+cloudpickle = ">=3.1.1"
+exceptiongroup = {version = ">=1.2.0", markers = "python_version < \"3.11\""}
+fakeredis = {version = ">=2.32.1", extras = ["lua"]}
+opentelemetry-api = ">=1.33.0"
+opentelemetry-exporter-prometheus = ">=0.60b0"
+opentelemetry-instrumentation = ">=0.60b0"
+prometheus-client = ">=0.21.1"
+py-key-value-aio = {version = ">=0.3.0", extras = ["memory", "redis"]}
+python-json-logger = ">=2.0.7"
+redis = ">=5"
+rich = ">=13.9.4"
+typer = ">=0.15.1"
+typing-extensions = ">=4.12.0"
+
[[package]]
name = "pygments"
version = "2.19.2"
@@ -4530,6 +4763,21 @@ files = [
[package.extras]
cli = ["click (>=5.0)"]
+[[package]]
+name = "python-json-logger"
+version = "4.0.0"
+description = "JSON Log Formatter for the Python Logging Package"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2"},
+ {file = "python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f"},
+]
+
+[package.extras]
+dev = ["backports.zoneinfo ; python_version < \"3.9\"", "black", "build", "freezegun", "mdx_truly_sane_lists", "mike", "mkdocs", "mkdocs-awesome-pages-plugin", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-material (>=8.5)", "mkdocstrings[python]", "msgspec ; implementation_name != \"pypy\"", "mypy", "orjson ; implementation_name != \"pypy\"", "pylint", "pytest", "tzdata", "validate-pyproject[all]"]
+
[[package]]
name = "python-multipart"
version = "0.0.20"
@@ -4585,6 +4833,19 @@ files = [
{file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"},
]
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+markers = "sys_platform == \"win32\""
+files = [
+ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"},
+ {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
+]
+
[[package]]
name = "pywry"
version = "0.6.2"
@@ -4700,6 +4961,27 @@ files = [
{file = "random_user_agent-1.0.1.tar.gz", hash = "sha256:8f8ca26ec8cb1d24ad1758d8b8f700d154064d641dbe9a255cfec42960fbd012"},
]
+[[package]]
+name = "redis"
+version = "7.1.0"
+description = "Python client for Redis database and key-value store"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b"},
+ {file = "redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c"},
+]
+
+[package.dependencies]
+async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""}
+
+[package.extras]
+circuit-breaker = ["pybreaker (>=1.4.0)"]
+hiredis = ["hiredis (>=3.2.0)"]
+jwt = ["pyjwt (>=2.9.0)"]
+ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"]
+
[[package]]
name = "referencing"
version = "0.36.2"
@@ -5271,6 +5553,23 @@ dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodest
doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.0.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"]
test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"]
+[[package]]
+name = "secretstorage"
+version = "3.5.0"
+description = "Python bindings to FreeDesktop.org Secret Service API"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "sys_platform == \"linux\""
+files = [
+ {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"},
+ {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"},
+]
+
+[package.dependencies]
+cryptography = ">=2.0"
+jeepney = ">=0.6"
+
[[package]]
name = "setproctitle"
version = "1.3.7"
@@ -5424,6 +5723,18 @@ docs = ["entangled-cli (>=2.0,<3.0)", "mkdocs", "mkdocs-entangled-plugin", "mkdo
rich = ["rich"]
test = ["build", "pytest", "rich", "typing-extensions ; python_version < \"3.11\"", "wheel"]
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+description = "Tool to Detect Surrounding Shell"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
+ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
+]
+
[[package]]
name = "six"
version = "1.17.0"
@@ -5436,36 +5747,49 @@ files = [
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
+]
+
[[package]]
name = "soupsieve"
-version = "2.8"
+version = "2.8.1"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"},
- {file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"},
+ {file = "soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434"},
+ {file = "soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350"},
]
[[package]]
name = "sse-starlette"
-version = "3.0.3"
+version = "3.1.1"
description = "SSE plugin for Starlette"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431"},
- {file = "sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971"},
+ {file = "sse_starlette-3.1.1-py3-none-any.whl", hash = "sha256:bb38f71ae74cfd86b529907a9fda5632195dfa6ae120f214ea4c890c7ee9d436"},
+ {file = "sse_starlette-3.1.1.tar.gz", hash = "sha256:bffa531420c1793ab224f63648c059bcadc412bf9fdb1301ac8de1cf9a67b7fb"},
]
[package.dependencies]
anyio = ">=4.7.0"
+starlette = ">=0.49.1"
[package.extras]
daphne = ["daphne (>=4.2.0)"]
-examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.49.1)", "uvicorn (>=0.34.0)"]
+examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "uvicorn (>=0.34.0)"]
granian = ["granian (>=2.3.1)"]
uvicorn = ["uvicorn (>=0.34.0)"]
@@ -5676,6 +6000,24 @@ files = [
docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"]
+[[package]]
+name = "typer"
+version = "0.21.0"
+description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6"},
+ {file = "typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+rich = ">=10.11.0"
+shellingham = ">=1.3.0"
+typing-extensions = ">=3.7.4.3"
+
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -5705,14 +6047,14 @@ typing-extensions = ">=4.12.0"
[[package]]
name = "tzdata"
-version = "2025.2"
+version = "2025.3"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main"]
files = [
- {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
- {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
+ {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"},
+ {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"},
]
[[package]]
@@ -5750,14 +6092,14 @@ six = "*"
[[package]]
name = "urllib3"
-version = "2.6.1"
+version = "2.6.2"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"},
- {file = "urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f"},
+ {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
+ {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
]
[package.extras]
@@ -5903,124 +6245,95 @@ files = [
[[package]]
name = "wrapt"
-version = "2.0.1"
+version = "1.17.3"
description = "Module for decorators, wrappers and monkey patching."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "wrapt-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64b103acdaa53b7caf409e8d45d39a8442fe6dcfec6ba3f3d141e0cc2b5b4dbd"},
- {file = "wrapt-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91bcc576260a274b169c3098e9a3519fb01f2989f6d3d386ef9cbf8653de1374"},
- {file = "wrapt-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab594f346517010050126fcd822697b25a7031d815bb4fbc238ccbe568216489"},
- {file = "wrapt-2.0.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:36982b26f190f4d737f04a492a68accbfc6fa042c3f42326fdfbb6c5b7a20a31"},
- {file = "wrapt-2.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23097ed8bc4c93b7bf36fa2113c6c733c976316ce0ee2c816f64ca06102034ef"},
- {file = "wrapt-2.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bacfe6e001749a3b64db47bcf0341da757c95959f592823a93931a422395013"},
- {file = "wrapt-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8ec3303e8a81932171f455f792f8df500fc1a09f20069e5c16bd7049ab4e8e38"},
- {file = "wrapt-2.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3f373a4ab5dbc528a94334f9fe444395b23c2f5332adab9ff4ea82f5a9e33bc1"},
- {file = "wrapt-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f49027b0b9503bf6c8cdc297ca55006b80c2f5dd36cecc72c6835ab6e10e8a25"},
- {file = "wrapt-2.0.1-cp310-cp310-win32.whl", hash = "sha256:8330b42d769965e96e01fa14034b28a2a7600fbf7e8f0cc90ebb36d492c993e4"},
- {file = "wrapt-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1218573502a8235bb8a7ecaed12736213b22dcde9feab115fa2989d42b5ded45"},
- {file = "wrapt-2.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:eda8e4ecd662d48c28bb86be9e837c13e45c58b8300e43ba3c9b4fa9900302f7"},
- {file = "wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590"},
- {file = "wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6"},
- {file = "wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7"},
- {file = "wrapt-2.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0b6d3b95932809c5b3fecc18fda0f1e07452d05e2662a0b35548985f256e28"},
- {file = "wrapt-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da7384b0e5d4cae05c97cd6f94faaf78cc8b0f791fc63af43436d98c4ab37bb"},
- {file = "wrapt-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec65a78fbd9d6f083a15d7613b2800d5663dbb6bb96003899c834beaa68b242c"},
- {file = "wrapt-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7de3cc939be0e1174969f943f3b44e0d79b6f9a82198133a5b7fc6cc92882f16"},
- {file = "wrapt-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fb1a5b72cbd751813adc02ef01ada0b0d05d3dcbc32976ce189a1279d80ad4a2"},
- {file = "wrapt-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3fa272ca34332581e00bf7773e993d4f632594eb2d1b0b162a9038df0fd971dd"},
- {file = "wrapt-2.0.1-cp311-cp311-win32.whl", hash = "sha256:fc007fdf480c77301ab1afdbb6ab22a5deee8885f3b1ed7afcb7e5e84a0e27be"},
- {file = "wrapt-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:47434236c396d04875180171ee1f3815ca1eada05e24a1ee99546320d54d1d1b"},
- {file = "wrapt-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:837e31620e06b16030b1d126ed78e9383815cbac914693f54926d816d35d8edf"},
- {file = "wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c"},
- {file = "wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841"},
- {file = "wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62"},
- {file = "wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf"},
- {file = "wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9"},
- {file = "wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b"},
- {file = "wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba"},
- {file = "wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684"},
- {file = "wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb"},
- {file = "wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9"},
- {file = "wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75"},
- {file = "wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b"},
- {file = "wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9"},
- {file = "wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f"},
- {file = "wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218"},
- {file = "wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9"},
- {file = "wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c"},
- {file = "wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db"},
- {file = "wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233"},
- {file = "wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2"},
- {file = "wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b"},
- {file = "wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7"},
- {file = "wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3"},
- {file = "wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8"},
- {file = "wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3"},
- {file = "wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1"},
- {file = "wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d"},
- {file = "wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7"},
- {file = "wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3"},
- {file = "wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b"},
- {file = "wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10"},
- {file = "wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf"},
- {file = "wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e"},
- {file = "wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c"},
- {file = "wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92"},
- {file = "wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f"},
- {file = "wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1"},
- {file = "wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55"},
- {file = "wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0"},
- {file = "wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509"},
- {file = "wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1"},
- {file = "wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970"},
- {file = "wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c"},
- {file = "wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41"},
- {file = "wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed"},
- {file = "wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0"},
- {file = "wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c"},
- {file = "wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e"},
- {file = "wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b"},
- {file = "wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec"},
- {file = "wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa"},
- {file = "wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815"},
- {file = "wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa"},
- {file = "wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef"},
- {file = "wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747"},
- {file = "wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f"},
- {file = "wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349"},
- {file = "wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c"},
- {file = "wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395"},
- {file = "wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad"},
- {file = "wrapt-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:90897ea1cf0679763b62e79657958cd54eae5659f6360fc7d2ccc6f906342183"},
- {file = "wrapt-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:50844efc8cdf63b2d90cd3d62d4947a28311e6266ce5235a219d21b195b4ec2c"},
- {file = "wrapt-2.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49989061a9977a8cbd6d20f2efa813f24bf657c6990a42967019ce779a878dbf"},
- {file = "wrapt-2.0.1-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:09c7476ab884b74dce081ad9bfd07fe5822d8600abade571cb1f66d5fc915af6"},
- {file = "wrapt-2.0.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1a8a09a004ef100e614beec82862d11fc17d601092c3599afd22b1f36e4137e"},
- {file = "wrapt-2.0.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:89a82053b193837bf93c0f8a57ded6e4b6d88033a499dadff5067e912c2a41e9"},
- {file = "wrapt-2.0.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f26f8e2ca19564e2e1fdbb6a0e47f36e0efbab1acc31e15471fad88f828c75f6"},
- {file = "wrapt-2.0.1-cp38-cp38-win32.whl", hash = "sha256:115cae4beed3542e37866469a8a1f2b9ec549b4463572b000611e9946b86e6f6"},
- {file = "wrapt-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:c4012a2bd37059d04f8209916aa771dfb564cccb86079072bdcd48a308b6a5c5"},
- {file = "wrapt-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:68424221a2dc00d634b54f92441914929c5ffb1c30b3b837343978343a3512a3"},
- {file = "wrapt-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd1a18f5a797fe740cb3d7a0e853a8ce6461cc62023b630caec80171a6b8097"},
- {file = "wrapt-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fb3a86e703868561c5cad155a15c36c716e1ab513b7065bd2ac8ed353c503333"},
- {file = "wrapt-2.0.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5dc1b852337c6792aa111ca8becff5bacf576bf4a0255b0f05eb749da6a1643e"},
- {file = "wrapt-2.0.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c046781d422f0830de6329fa4b16796096f28a92c8aef3850674442cdcb87b7f"},
- {file = "wrapt-2.0.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f73f9f7a0ebd0db139253d27e5fc8d2866ceaeef19c30ab5d69dcbe35e1a6981"},
- {file = "wrapt-2.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b667189cf8efe008f55bbda321890bef628a67ab4147ebf90d182f2dadc78790"},
- {file = "wrapt-2.0.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:a9a83618c4f0757557c077ef71d708ddd9847ed66b7cc63416632af70d3e2308"},
- {file = "wrapt-2.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e9b121e9aeb15df416c2c960b8255a49d44b4038016ee17af03975992d03931"},
- {file = "wrapt-2.0.1-cp39-cp39-win32.whl", hash = "sha256:1f186e26ea0a55f809f232e92cc8556a0977e00183c3ebda039a807a42be1494"},
- {file = "wrapt-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:bf4cb76f36be5de950ce13e22e7fdf462b35b04665a12b64f3ac5c1bbbcf3728"},
- {file = "wrapt-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:d6cc985b9c8b235bd933990cdbf0f891f8e010b65a3911f7a55179cd7b0fc57b"},
- {file = "wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca"},
- {file = "wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f"},
+ {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
+ {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
+ {file = "wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c"},
+ {file = "wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775"},
+ {file = "wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd"},
+ {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05"},
+ {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418"},
+ {file = "wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390"},
+ {file = "wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6"},
+ {file = "wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18"},
+ {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7"},
+ {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85"},
+ {file = "wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f"},
+ {file = "wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311"},
+ {file = "wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1"},
+ {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5"},
+ {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2"},
+ {file = "wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89"},
+ {file = "wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77"},
+ {file = "wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a"},
+ {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0"},
+ {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba"},
+ {file = "wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd"},
+ {file = "wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828"},
+ {file = "wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9"},
+ {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396"},
+ {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc"},
+ {file = "wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe"},
+ {file = "wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c"},
+ {file = "wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6"},
+ {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0"},
+ {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77"},
+ {file = "wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7"},
+ {file = "wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277"},
+ {file = "wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d"},
+ {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa"},
+ {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050"},
+ {file = "wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8"},
+ {file = "wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb"},
+ {file = "wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16"},
+ {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39"},
+ {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235"},
+ {file = "wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c"},
+ {file = "wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b"},
+ {file = "wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa"},
+ {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7"},
+ {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4"},
+ {file = "wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10"},
+ {file = "wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6"},
+ {file = "wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58"},
+ {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a"},
+ {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067"},
+ {file = "wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454"},
+ {file = "wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e"},
+ {file = "wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f"},
+ {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056"},
+ {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804"},
+ {file = "wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977"},
+ {file = "wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116"},
+ {file = "wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6"},
+ {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:70d86fa5197b8947a2fa70260b48e400bf2ccacdcab97bb7de47e3d1e6312225"},
+ {file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df7d30371a2accfe4013e90445f6388c570f103d61019b6b7c57e0265250072a"},
+ {file = "wrapt-1.17.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:caea3e9c79d5f0d2c6d9ab96111601797ea5da8e6d0723f77eabb0d4068d2b2f"},
+ {file = "wrapt-1.17.3-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:758895b01d546812d1f42204bd443b8c433c44d090248bf22689df673ccafe00"},
+ {file = "wrapt-1.17.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56"},
+ {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:656873859b3b50eeebe6db8b1455e99d90c26ab058db8e427046dbc35c3140a5"},
+ {file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a9a2203361a6e6404f80b99234fe7fb37d1fc73487b5a78dc1aa5b97201e0f22"},
+ {file = "wrapt-1.17.3-cp38-cp38-win32.whl", hash = "sha256:55cbbc356c2842f39bcc553cf695932e8b30e30e797f961860afb308e6b1bb7c"},
+ {file = "wrapt-1.17.3-cp38-cp38-win_amd64.whl", hash = "sha256:ad85e269fe54d506b240d2d7b9f5f2057c2aa9a2ea5b32c66f8902f768117ed2"},
+ {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc"},
+ {file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9"},
+ {file = "wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d"},
+ {file = "wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a"},
+ {file = "wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139"},
+ {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df"},
+ {file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b"},
+ {file = "wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81"},
+ {file = "wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f"},
+ {file = "wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f"},
+ {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"},
+ {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"},
]
-[package.extras]
-dev = ["pytest", "setuptools"]
-
[[package]]
name = "xlrd"
version = "2.0.2"
@@ -6197,14 +6510,14 @@ propcache = ">=0.2.1"
[[package]]
name = "yfinance"
-version = "0.2.58"
+version = "0.2.66"
description = "Download market data from Yahoo! Finance API"
optional = false
python-versions = "*"
groups = ["main"]
files = [
- {file = "yfinance-0.2.58-py2.py3-none-any.whl", hash = "sha256:b8572ac086ae24259e6b3d967b949bf4e6783e72fda9ea5d0926b69b8b410852"},
- {file = "yfinance-0.2.58.tar.gz", hash = "sha256:4bf61714544aa57f82b9c157c17f40ede53ec70ce9a0ec170661a9cba737cbe2"},
+ {file = "yfinance-0.2.66-py2.py3-none-any.whl", hash = "sha256:511a1a40a687f277aae3a02543009a8aeaa292fce5509671f58915078aebb5c7"},
+ {file = "yfinance-0.2.66.tar.gz", hash = "sha256:fae354cc1649109444b2c84194724afcc52c2a7799551ce44c739424ded6af9c"},
]
[package.dependencies]
@@ -6216,8 +6529,10 @@ numpy = ">=1.16.5"
pandas = ">=1.3.0"
peewee = ">=3.16.2"
platformdirs = ">=2.0.0"
+protobuf = ">=3.19.0"
pytz = ">=2022.5"
requests = ">=2.31"
+websockets = ">=13.0"
[package.extras]
nospam = ["requests_cache (>=1.0)", "requests_ratelimiter (>=0.3.1)"]
diff --git a/openbb_platform/core/openbb_core/provider/standard_models/consumer_price_index.py b/openbb_platform/core/openbb_core/provider/standard_models/consumer_price_index.py
index ee604c0daed..935af9038dc 100644
--- a/openbb_platform/core/openbb_core/provider/standard_models/consumer_price_index.py
+++ b/openbb_platform/core/openbb_core/provider/standard_models/consumer_price_index.py
@@ -9,7 +9,7 @@ from openbb_core.provider.utils.descriptions import (
DATA_DESCRIPTIONS,
QUERY_DESCRIPTIONS,
)
-from pydantic import Field, field_validator
+from pydantic import Field
class ConsumerPriceIndexQueryParams(QueryParams):
@@ -19,16 +19,13 @@ class ConsumerPriceIndexQueryParams(QueryParams):
description=QUERY_DESCRIPTIONS.get("country"),
default="united_states",
)
- transform: Literal["index", "yoy", "period"] = Field(
- description="Transformation of the CPI data. Period represents the change since previous."
- + " Defaults to change from one year ago (yoy).",
+ transform: str = Field(
+ description="Transformation of the CPI data.",
default="yoy",
- json_schema_extra={"choices": ["index", "yoy", "period"]},
)
frequency: Literal["annual", "quarter", "monthly"] = Field(
default="monthly",
description=QUERY_DESCRIPTIONS.get("frequency"),
- json_schema_extra={"choices": ["annual", "quarter", "monthly"]},
)
harmonized: bool = Field(
default=False, description="If true, returns harmonized data."
@@ -40,12 +37,6 @@ class ConsumerPriceIndexQueryParams(QueryParams):
default=None, description=QUERY_DESCRIPTIONS.get("end_date")
)
- @field_validator("country", mode="before", check_fields=False)
- @classmethod
- def to_lower(cls, v):
- """Convert country to lower case."""
- return v.replace(" ", "_").lower()
-
class ConsumerPriceIndexData(Data):
"""CPI data."""
diff --git a/openbb_platform/core/openbb_core/provider/standard_models/economic_indicators.py b/openbb_platform/core/openbb_core/provider/standard_models/economic_indicators.py
index 04610ac0b54..d28b19eda70 100644
--- a/openbb_platform/core/openbb_core/provider/standard_models/economic_indicators.py
+++ b/openbb_platform/core/openbb_core/provider/standard_models/economic_indicators.py
@@ -14,23 +14,27 @@ from pydantic import Field
class EconomicIndicatorsQueryParams(QueryParams):
"""Economic Indicators Query."""
+ symbol: str = Field(description=QUERY_DESCRIPTIONS.get("symbol", ""))
country: str | None = Field(
- default=None,
- description=QUERY_DESCRIPTIONS.get("country", "")
- + " The country represented by the indicator, if available.",
+ default=None, description=QUERY_DESCRIPTIONS.get("country", "")
+ )
+ frequency: str | None = Field(
+ default=None, description=QUERY_DESCRIPTIONS.get("frequency", "")
)
start_date: dateType | None = Field(
- description=QUERY_DESCRIPTIONS.get("start_date", ""), default=None
+ default=None, description=QUERY_DESCRIPTIONS.get("start_date", "")
)
end_date: dateType | None = Field(
- description=QUERY_DESCRIPTIONS.get("end_date", ""), default=None
+ default=None, description=QUERY_DESCRIPTIONS.get("end_date", "")
)
class EconomicIndicatorsData(Data):
"""Economic Indicators Data."""
- date: dateType = Field(description=DATA_DESCRIPTIONS.get("date", ""))
+ date: dateType | None = Field(
+ default=None, description=DATA_DESCRIPTIONS.get("date", "")
+ )
symbol_root: str | None = Field(
default=None, description="The root symbol for the indicator (e.g. GDP)."
)
diff --git a/openbb_platform/extensions/economy/integration/test_economy_api.py b/openbb_platform/extensions/economy/integration/test_economy_api.py
index f07405a646a..c3679f581bb 100644
--- a/openbb_platform/extensions/economy/integration/test_economy_api.py
+++ b/openbb_platform/extensions/economy/integration/test_economy_api.py
@@ -103,6 +103,19 @@ def test_economy_calendar(params, headers):
"expenditure": "transport",
}
),
+ (
+ {
+ "country": "portugal,spain",
+ "transform": "yoy",
+ "frequency": "quarter",
+ "harmonized": False,
+ "start_date": "2020-01-01",
+ "end_date": "2023-06-06",
+ "provider": "imf",
+ "expenditure": "transport",
+ "limit": None,
+ }
+ ),
],
)
@pytest.mark.integration
@@ -550,21 +563,15 @@ def test_economy_fred_regional(params, headers):
(
{
"provider": "imf",
- "country": "us,uk,jp",
- "symbol": "gold_reserves",
- "start_date": "2022-01-01",
- "end_date": "2023-12-31",
- "frequency": "annual",
- }
- ),
- (
- {
- "provider": "imf",
- "country": "all",
- "symbol": "derivative_assets",
- "start_date": "2022-01-01",
- "end_date": "2023-12-31",
- "frequency": "annual",
+ "country": "*",
+ "symbol": "IL::RGV_REVS",
+ "start_date": "2025-09-30",
+ "end_date": None,
+ "frequency": "month",
+ "transform": None,
+ "dimension_values": None,
+ "limit": 1,
+ "pivot": False,
}
),
],
@@ -585,7 +592,15 @@ def test_economy_indicators(params, headers):
"params",
[
({"provider": "econdb", "use_cache": False}),
- ({"provider": "imf", "query": "balance sheet;households;debt"}),
+ (
+ {
+ "provider": "imf",
+ "query": "gold+volume",
+ "dataflows": None,
+ "keywords": None,
+ "symbol": None,
+ }
+ ),
],
)
@pytest.mark.integration
@@ -1287,11 +1302,13 @@ def test_economy_shipping_chokepoint_info(params, headers):
@pytest.mark.parametrize(
"params",
[
- (
- {
- "provider": "imf",
- }
- ),
+ {
+ "provider": "imf",
+ "port_code": None,
+ "country": None,
+ "continent": None,
+ "limit": None,
+ }
],
)
@pytest.mark.integration
diff --git a/openbb_platform/extensions/economy/integration/test_economy_python.py b/openbb_platform/extensions/economy/integration/test_economy_python.py
index dad7eaf5898..1b94a01f619 100644
--- a/openbb_platform/extensions/economy/integration/test_economy_python.py
+++ b/openbb_platform/extensions/economy/integration/test_economy_python.py
@@ -62,40 +62,45 @@ def test_economy_calendar(params, obb):
@pytest.mark.parametrize(
"params",
[
- (
- {
- "country": "spain",
- "transform": "yoy",
- "frequency": "annual",
- "harmonized": False,
- "start_date": "2020-01-01",
- "end_date": "2023-06-06",
- "provider": "fred",
- }
- ),
- (
- {
- "country": "portugal,spain",
- "transform": "period",
- "frequency": "monthly",
- "harmonized": True,
- "start_date": "2023-01-01",
- "end_date": "2023-06-06",
- "provider": "fred",
- }
- ),
- (
- {
- "country": "portugal,spain",
- "transform": "yoy",
- "frequency": "quarter",
- "harmonized": False,
- "start_date": "2020-01-01",
- "end_date": "2023-06-06",
- "provider": "oecd",
- "expenditure": "transport",
- }
- ),
+ {
+ "country": "spain",
+ "transform": "yoy",
+ "frequency": "annual",
+ "harmonized": False,
+ "start_date": "2020-01-01",
+ "end_date": "2023-06-06",
+ "provider": "fred",
+ },
+ {
+ "country": "portugal,spain",
+ "transform": "period",
+ "frequency": "monthly",
+ "harmonized": True,
+ "start_date": "2023-01-01",
+ "end_date": "2023-06-06",
+ "provider": "fred",
+ },
+ {
+ "country": "portugal,spain",
+ "transform": "yoy",
+ "frequency": "quarter",
+ "harmonized": False,
+ "start_date": "2020-01-01",
+ "end_date": "2023-06-06",
+ "provider": "oecd",
+ "expenditure": "transport",
+ },
+ {
+ "country": "portugal,spain",
+ "transform": "yoy",
+ "frequency": "quarter",
+ "harmonized": False,
+ "start_date": "2020-01-01",
+ "end_date": "2023-06-06",
+ "provider": "imf",
+ "expenditure": "transport",
+ "limit": None,
+ },
],
)
@pytest.mark.integration
@@ -527,7 +532,15 @@ def test_economy_country_profile(params, obb):
"params",
[
({"provider": "econdb", "use_cache": False}),
- ({"provider": "imf", "query": "balance sheet;households;debt"}),
+ (
+ {
+ "provider": "imf",
+ "query": "gold+volume",
+ "dataflows": None,
+ "keywords": None,
+ "symbol": None,
+ }
+ ),
],
)
@pytest.mark.integration
@@ -571,21 +584,15 @@ def test_economy_available_indicators(params, obb):
(
{
"provider": "imf",
- "country": "us,uk,jp",
- "symbol": "gold_reserves",
- "start_date": "2022-01-01",
- "end_date": "2023-12-31",
- "frequency": "annual",
- }
- ),
- (
- {
- "provider": "imf",
- "country": "all",
- "symbol": "derivative_assets",
- "start_date": "2022-01-01",
- "end_date": "2023-12-31",
- "frequency": "annual",
+ "country": "*",
+ "symbol": "IL::RGV_REVS",
+ "start_date": "2025-09-30",
+ "end_date": None,
+ "frequency": "month",
+ "transform": None,
+ "dimension_values": None,
+ "limit": 1,
+ "pivot": False,
}
),
],
@@ -1148,13 +1155,13 @@ def test_economy_fomc_documents(params, obb):
@pytest.mark.parametrize(
"params",
[
- (
- {
- "provider": "imf",
- "country": "CRI",
- "continent": None,
- }
- ),
+ {
+ "provider": "imf",
+ "port_code": None,
+ "country": None,
+ "continent": None,
+ "limit": None,
+ }
],
)
@pytest.mark.integration
diff --git a/openbb_platform/extensions/economy/openbb_economy/economy_router.py b/openbb_platform/extensions/economy/openbb_economy/economy_router.py
index 6753321d0d7..3b1553395e9 100644
--- a/openbb_platform/extensions/economy/openbb_economy/economy_router.py
+++ b/openbb_platform/extensions/economy/openbb_economy/economy_router.py
@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import Body
from openbb_core.app.model.command_context import CommandContext
-from openbb_core.app.model.example import APIEx
+from openbb_core.app.model.example import APIEx, PythonEx
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.provider_interface import (
ExtraParams,
@@ -79,6 +79,14 @@ async def calendar(
"provider": "oecd",
},
),
+ PythonEx(
+ description="Get the latest reported weightings of a country's CPI basket, from IMF.",
+ code=[
+ "res = obb.economy.cpi("
+ + "provider='imf', country='CAN', transform='weight_percent', expenditure='all', limit=1)",
+ "print(res.model_dump(include='results')['results'])",
+ ],
+ ),
],
)
async def cpi(
@@ -87,10 +95,7 @@ async def cpi(
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
- """Get Consumer Price Index (CPI).
-
- Returns either the rescaled index value, or a rate of change (inflation).
- """
+ """Get Consumer Price Index (CPI) data by country."""
return await OBBject.from_query(Query(**locals()))
@@ -346,7 +351,7 @@ async def available_indicators(
APIEx(parameters={"provider": "econdb", "symbol": "PCOCO"}),
APIEx(
description="Enter the country as the full name, or iso code."
- + " Use `available_indicators()` to get a list of supported indicators from EconDB.",
+ + " Use `/economy/available_indicators` to get a list of supported indicators from EconDB.",
parameters={
"symbol": "CPI",
"country": "united_states,jp",
@@ -358,44 +363,29 @@ async def available_indicators(
parameters={"provider": "econdb", "symbol": "main", "country": "eu"},
),
APIEx(
- description="When the provider is 'imf', the absence of a symbol will default to 'irfcl_top_lines'."
- + " Use 'IRFCL' to get all the data from the set of indicators.",
- parameters={"provider": "imf"},
- ),
- APIEx(
- description="When the provider is 'imf', complete tables are returned by using a 'preset'."
- + " Refer to the function's docstring for descriptions of each preset."
- + " When no country is supplied, the data is returned for all countries.",
- parameters={"provider": "imf", "symbol": "gold_reserves"},
- ),
- APIEx(
- description="When the provider is 'imf', multiple countries and symbols can be supplied."
- + " Enter countries as a two-letter ISO country code, or the country name in lower_snake_case.",
+ description="IMF indicators are identified by their dataflow and indicator code."
+ + " Use `/economy/available_indicators` to get and search a list of supported indicators symbols."
+ + " This example gets gold reserves held by countries, measured in Fine Troy Ounces.",
parameters={
"provider": "imf",
- "symbol": "RAFA_USD,RAPFA_USD,RAFA_RAPFA_RO",
- "country": "us,china,jp,4f,gb",
- "start_date": "2010-01-01",
- "end_date": "2020-12-31",
- "frequency": "annual",
+ "symbol": "IL::RGV_REVS",
+ "country": "*",
+ "frequency": "month",
+ "limit": 1,
+ "start_date": "2025-09-30",
},
),
APIEx(
- description=(
- "When the provider is 'imf', additional presets return the core Financial Soundness Indicators."
- "\n 'fsi_core' - Core FSIs"
- "\n 'fsi_encouraged_set' - Encouraged Set of FSIs,"
- "\n 'fsi_core_underlying' - Underlying data for the Core FSIs."
- "\n 'fsi_other' - Additional/Other FSIs that are not in the Core or Encouraged Set."
- "\n 'fsi_all' - all FSI data for a single country."
- ),
+ description="IMF symbols can also be used for retrieving entire presentation tables."
+ + " This example gets the Direct Investment Position (DIP) table."
+ + " Use `/imf_utils/list_tables` to get a list of supported presentation table symbols.",
parameters={
"provider": "imf",
- "symbol": "fsi_encouraged_set",
- "country": "us,fr,gb",
- "start_date": "2022-01-01",
- "end_date": "2023-12-31",
+ "symbol": "DIP::H_DIP_INDICATOR",
+ "country": "BRA",
"frequency": "annual",
+ "limit": 2,
+ "pivot": True,
},
),
],
diff --git a/openbb_platform/extensions/platform_api/openbb_platform_api/assets/default_apps.json b/openbb_platform/extensions/platform_api/openbb_platform_api/assets/default_apps.json
index d4f4ac287d8..3263776a75e 100644
--- a/openbb_platform/extensions/platform_api/openbb_platform_api/assets/default_apps.json
+++ b/openbb_platform/extensions/platform_api/openbb_platform_api/assets/default_apps.json
@@ -466,179 +466,101 @@
"groups": []
},
{
- "name": "Example IMF App",
- "img": "https://www.imf.org/-/media/Images/IMF/About/Factsheets/2022/Hero/imf-logo.ashx",
- "description": "Global economic and financial data, including GDP, inflation, trade, and fiscal balances (WEO), financial stability risks (GFSR), and public debt (GFS, QPSD). It tracks exchange rates, interest rates, and money supply (IFS), balance of payments and trade flows (BOPS, DOTS), and banking sector health (FSIs).",
- "allowCustomization": true,
- "tabs": {
- "DirectionOfTrade": {
- "id": "DirectionOfTrade",
- "name": "Direction Of Trade",
- "layout": [
- {
- "i": "economy_direction_of_trade_imf_obb",
- "x": 0,
- "y": 2,
- "w": 40,
- "h": 25,
- "state": {
- "params": {
- "country": "us",
- "counterpart": "canada,mexico",
- "direction": "all",
- "start_date": "2010-01-01"
- },
- "chartView": {
- "enabled": false,
- "chartType": "line"
- },
- "columnState": {
- "default_": {
- "columnPinning": {
- "leftColIds": [
- "date",
- "symbol",
- "title"
- ],
- "rightColIds": []
- }
+ "name": "The United States Congress",
+ "img": "https://www.congress.gov/img/opengraph1200by630.jpg",
+ "img_dark": "https://www.congress.gov/img/opengraph1200by630.jpg",
+ "img_light": "https://www.congress.gov/img/opengraph1200by630.jpg",
+ "description": "Find and view US federal legislative information and complete bill text.",
+ "allowCustomization": true,
+ "tabs": {
+ "": {
+ "id": "",
+ "name": "",
+ "layout": [
+ {
+ "i": "uscongress_bills_congress_gov_obb",
+ "x": 0,
+ "y": 0,
+ "w": 40,
+ "h": 11,
+ "state": {
+ "params": {
+ "bill_url": "119/hr/1"
+ },
+ "chartView": {
+ "enabled": false,
+ "chartType": "line"
+ },
+ "columnState": {
+ "default": {
+ "columnVisibility": {
+ "hiddenColIds": [
+ "origin_chamber_code"
+ ]
+ },
+ "columnOrder": {
+ "orderedColIds": [
+ "update_date",
+ "latest_action_date",
+ "bill_url",
+ "congress",
+ "bill_number",
+ "origin_chamber",
+ "origin_chamber_code",
+ "bill_type",
+ "title",
+ "latest_action",
+ "update_date_including_text"
+ ]
+ },
+ "focusedCell": {
+ "colId": "bill_url",
+ "rowIndex": 2,
+ "rowPinned": null
}
}
}
}
- ]
- },
- "Indicators": {
- "id": "Indicators",
- "name": "Indicators",
- "layout": [
- {
- "i": "economy_available_indicators_imf_obb",
- "x": 0,
- "y": 2,
- "w": 40,
- "h": 10,
- "state": {
- "chartView": {
- "enabled": false,
- "chartType": "line"
- }
- }
- },
- {
- "i": "economy_indicators_imf_obb",
- "x": 0,
- "y": 12,
- "w": 40,
- "h": 15,
- "state": {
- "chartView": {
- "enabled": false,
- "chartType": "line"
- }
+ },
+ {
+ "i": "uscongress_bill_text_congress_gov_obb",
+ "x": 16,
+ "y": 11,
+ "w": 24,
+ "h": 41,
+ "state": {
+ "params": {
+ "bill_url": "119/hr/1"
}
}
- ]
- }
- },
- "groups": []
+ },
+ {
+ "i": "uscongress_bill_info_congress_gov_obb",
+ "x": 0,
+ "y": 11,
+ "w": 16,
+ "h": 41,
+ "state": {
+ "params": {
+ "bill_url": "119/hr/1"
+ }
+ }
+ }
+ ]
+ }
},
- {
- "name": "The United States Congress",
- "img": "https://www.congress.gov/img/opengraph1200by630.jpg",
- "img_dark": "https://www.congress.gov/img/opengraph1200by630.jpg",
- "img_light": "https://www.congress.gov/img/opengraph1200by630.jpg",
- "description": "Find and view US federal legislative information and complete bill text.",
- "allowCustomization": true,
- "tabs": {
- "": {
- "id": "",
- "name": "",
- "layout": [
- {
- "i": "uscongress_bills_congress_gov_obb",
- "x": 0,
- "y": 0,
- "w": 40,
- "h": 11,
- "state": {
- "params": {
- "bill_url": "119/hr/1"
- },
- "chartView": {
- "enabled": false,
- "chartType": "line"
- },
- "columnState": {
- "default": {
- "columnVisibility": {
- "hiddenColIds": [
- "origin_chamber_code"
- ]
- },
- "columnOrder": {
- "orderedColIds": [
- "update_date",
- "latest_action_date",
- "bill_url",
- "congress",
- "bill_number",
- "origin_chamber",
- "origin_chamber_code",
- "bill_type",
- "title",
- "latest_action",
- "update_date_including_text"
- ]
- },
- "focusedCell": {
- "colId": "bill_url",
- "rowIndex": 2,
- "rowPinned": null
- }
- }
- }
- }
- },
- {
- "i": "uscongress_bill_text_congress_gov_obb",
- "x": 16,
- "y": 11,
- "w": 24,
- "h": 41,
- "state": {
- "params": {
- "bill_url": "119/hr/1"
- }
- }
- },
- {
- "i": "uscongress_bill_info_congress_gov_obb",
- "x": 0,
- "y": 11,
- "w": 16,
- "h": 41,
- "state": {
- "params": {
- "bill_url": "119/hr/1"
- }
- }
- }
- ]
- }
- },
- "groups": [
- {
- "name": "Group 1",
- "type": "param",
- "paramName": "bill_url",
- "defaultValue": "119/hr/1",
- "widgetIds": [
- "uscongress_bills_congress_gov_obb",
- "uscongress_bill_text_congress_gov_obb",
- "uscongress_bill_info_congress_gov_obb"
- ]
- }
- ]
- }
+ "groups": [
+ {
+ "name": "Group 1",
+ "type": "param",
+ "paramName": "bill_url",
+ "defaultValue": "119/hr/1",
+ "widgetIds": [
+ "uscongress_bills_congress_gov_obb",
+ "uscongress_bill_text_congress_gov_obb",
+ "uscongress_bill_info_congress_gov_obb"
+ ]
+ }
+ ]
+ }
]
diff --git a/openbb_platform/extensions/platform_api/tests/mock_widgets.json b/openbb_platform/extensions/platform_api/tests/mock_widgets.json
index ff3195c53c6..7e90de3abae 100644
--- a/openbb_platform/extensions/platform_api/tests/mock_widgets.json
+++ b/openbb_platform/extensions/platform_api/tests/mock_widgets.json
@@ -959,7 +959,6 @@
},
{
"field": "name",
- "pinned": "left",
"formatterFn": null,
"headerName": "Name",
"headerTooltip": "The name of the series.",
@@ -1901,7 +1900,6 @@
},
{
"field": "series_id",
- "pinned": "left",
"formatterFn": null,
"headerName": "Series ID",
"headerTooltip": "The series ID for the item in the release.",
@@ -1909,7 +1907,6 @@
},
{
"field": "name",
- "pinned": "left",
"formatterFn": null,
"headerName": "Name",
"headerTooltip": "The name of the release.",
@@ -2618,7 +2615,6 @@
},
{
"field": "name",
- "pinned": "left",
"formatterFn": null,
"headerName": "Name",
"headerTooltip": "The name of the series.",
@@ -2953,7 +2949,6 @@
},
{
"field": "series_id",
- "pinned": "left",
"formatterFn": null,
"headerName": "Series ID",
"headerTooltip": "The individual series ID for the region.",
@@ -3289,7 +3284,6 @@
},
{
"field": "name",
- "pinned": "left",
"formatterFn": null,
"headerName": "Name",
"headerTooltip": "The name of the series.",
diff --git a/openbb_platform/extensions/tests/utils/integration_tests_generator.py b/openbb_platform/extensions/tests/utils/integration_tests_generator.py
index 9a7fd2a8238..58c2d8245b6 100644
--- a/openbb_platform/extensions/tests/utils/integration_tests_generator.py
+++ b/openbb_platform/extensions/tests/utils/integration_tests_generator.py
@@ -44,6 +44,7 @@ def find_extensions(filter_chart: bool | None = True):
extensions.extend(
[x for x in (ROOT_DIR / "obbject_extensions").iterdir() if x.is_dir()]
)
+ extensions.extend([x for x in (ROOT_DIR / "providers").iterdir() if x.is_dir()])
extensions = [x for x in extensions if x.name not in filter_ext]
return extensions
diff --git a/openbb_platform/extensions/tests/utils/integration_tests_testers.py b/openbb_platform/extensions/tests/utils/integration_tests_testers.py
index b4329a702eb..dcd017d705b 100644
--- a/openbb_platform/extensions/tests/utils/integration_tests_testers.py
+++ b/openbb_platform/extensions/tests/utils/integration_tests_testers.py
@@ -34,6 +34,8 @@ def get_integration_tests(
for extension in find_extensions(filter_charting_ext):
integration_folder = os.path.join(extension, "integration")
+ if not os.path.exists(integration_folder):
+ continue
for file in os.listdir(integration_folder):
if file.endswith(file_end):
file_path = os.path.join(integration_folder, file)
@@ -314,7 +316,11 @@ def check_missing_integration_tests(test_type: Literal["api", "python"]) -> list
if route not in tested_functions:
# TODO: See how to handle edge cases that are excluded from the schema
# on purpose. This is currently on the econometrics router.
- if test_type == "api" and "econometrics" in route:
+ if (
+ test_type == "api"
+ and "econometrics" in route
+ or route.endswith(".json")
+ ):
continue
missing_integration_tests.append(
f"Missing {test_type} integration test for route {route}"
diff --git a/openbb_platform/providers/econdb/openbb_econdb/models/economic_indicators.py b/openbb_platform/providers/econdb/openbb_econdb/models/economic_indicators.py
index 426717ccf79..407369934df 100644
--- a/openbb_platform/providers/econdb/openbb_econdb/models/economic_indicators.py
+++ b/openbb_platform/providers/econdb/openbb_econdb/models/economic_indicators.py
@@ -3,7 +3,7 @@
# pylint: disable=unused-argument
from datetime import datetime
-from typing import Any, Literal
+from typing import Any
from warnings import warn
from openbb_core.app.model.abstract.error import OpenBBError
@@ -24,6 +24,8 @@ class EconDbEconomicIndicatorsQueryParams(EconomicIndicatorsQueryParams):
__json_schema_extra__ = {
"symbol": {"multiple_items_allowed": True},
"country": {"multiple_items_allowed": True},
+ "transform": {"choices": ["toya", "tpop", "tusd", "tpgp"]},
+ "frequency": {"choices": ["annual", "quarter", "month"]},
}
symbol: str = Field(
@@ -32,7 +34,12 @@ class EconDbEconomicIndicatorsQueryParams(EconomicIndicatorsQueryParams):
+ " Use `available_indicators()` to get a list of available symbols.",
)
- transform: None | Literal["toya", "tpop", "tusd", "tpgp"] = Field(
+ country: str | None = Field(
+ default=None,
+ description=QUERY_DESCRIPTIONS.get("country", "")
+ + " ISO country codes or country names.",
+ )
+ transform: None | str = Field(
default=None,
description="The transformation to apply to the data, default is None."
+ "\n"
@@ -49,7 +56,7 @@ class EconDbEconomicIndicatorsQueryParams(EconomicIndicatorsQueryParams):
+ " and the original units and scale differ between entities."
+ "\n `tusd` should only be used where values are currencies.",
)
- frequency: Literal["annual", "quarter", "month"] = Field(
+ frequency: str = Field(
default="quarter",
description="The frequency of the data, default is 'quarter'."
+ " Only valid when 'symbol' is 'main'.",
diff --git a/openbb_platform/providers/fred/openbb_fred/models/consumer_price_index.py b/openbb_platform/providers/fred/openbb_fred/models/consumer_price_index.py
index 7787f0e18bd..e35c833984f 100644
--- a/openbb_platform/providers/fred/openbb_fred/models/consumer_price_index.py
+++ b/openbb_platform/providers/fred/openbb_fred/models/consumer_price_index.py
@@ -10,12 +10,10 @@ from openbb_core.provider.standard_models.consumer_price_index import (
ConsumerPriceIndexData,
ConsumerPriceIndexQueryParams,
)
-from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from openbb_core.provider.utils.errors import EmptyDataError
-from openbb_core.provider.utils.helpers import check_item
from openbb_fred.models.series import FredSeriesFetcher
-from openbb_fred.utils.fred_helpers import CPI_COUNTRIES, CpiCountries, all_cpi_options
-from pydantic import Field, field_validator
+from openbb_fred.utils.fred_helpers import CPI_COUNTRIES, all_cpi_options
+from pydantic import field_validator
class FREDConsumerPriceIndexQueryParams(ConsumerPriceIndexQueryParams):
@@ -26,13 +24,11 @@ class FREDConsumerPriceIndexQueryParams(ConsumerPriceIndexQueryParams):
"multiple_items_allowed": True,
"choices": CPI_COUNTRIES,
},
+ "transform": {
+ "choices": ["index", "yoy", "period"],
+ },
}
- country: CpiCountries | str = Field(
- description=QUERY_DESCRIPTIONS.get("country"),
- default="united_states",
- )
-
@field_validator("country", mode="before", check_fields=False)
@classmethod
def validate_country(cls, c: str):
@@ -40,10 +36,24 @@ class FREDConsumerPriceIndexQueryParams(ConsumerPriceIndexQueryParams):
result: list = []
values = c.replace(" ", "_").split(",")
for v in values:
- check_item(v.lower(), CPI_COUNTRIES)
+ if v.lower() not in CPI_COUNTRIES:
+ raise ValueError(
+ f"Invalid country: {v}. Available countries are: {', '.join(CPI_COUNTRIES)}"
+ )
result.append(v.lower())
return ",".join(result)
+ @field_validator("transform", mode="before", check_fields=False)
+ @classmethod
+ def validate_transform(cls, t: str):
+ """Validate transform."""
+ valid_transforms = ["index", "yoy", "period"]
+ if t.lower() not in valid_transforms:
+ raise ValueError(
+ f"Invalid transform: {t}. Available transforms are: {', '.join(valid_transforms)}"
+ )
+ return t.lower()
+
class FREDConsumerPriceIndexData(ConsumerPriceIndexData):
"""FRED Consumer Price Index Data."""
@@ -92,8 +102,8 @@ class FREDConsumerPriceIndexFetcher(
)
results: dict = {}
temp = await FredSeriesFetcher.fetch_data(item_query, credentials)
- result = [d.model_dump() for d in temp.result]
- results["metadata"] = {country_map.get(k): v for k, v in temp.metadata.items()}
+ result = [d.model_dump() for d in temp.result] # type: ignore
+ results["metadata"] = {country_map.get(k): v for k, v in temp.metadata.items()} # type: ignore
results["data"] = [
{country_map.get(k, k): v for k, v in d.items()} for d in result
]
diff --git a/openbb_platform/providers/imf/README.md b/openbb_platform/providers/imf/README.md
index 46bc8bc071e..0d97ac8283e 100644
--- a/openbb_platform/providers/imf/README.md
+++ b/openbb_platform/providers/imf/README.md
@@ -1,6 +1,9 @@
# OpenBB IMF Provider Extension
-This package adds the `openbb-imf` provider extension to the OpenBB Platform.
+This package adds the `openbb-imf` provider extension to the Open Data Platform by OpenBB.
+
+It provides everything you need - endpoints, tools, and metadata - to access and explore the entirety of
+https://data.imf.org, without any previous experience working with it.
## Installation
@@ -10,32 +13,140 @@ Install from PyPI with:
pip install openbb-imf
```
-## Implementation
+Then build the Python static assets by running:
-The extension utilizes the JSON RESTful Web Service ((https://datahelp.imf.org/knowledgebase/articles/630877-data-services)[https://datahelp.imf.org/knowledgebase/articles/630877-data-services])
+```sh
+openbb-build
+```
-No authorization is required to use, but IP addresses are bound by the limitations described in the link above.
+## Quick Start
+
+The fastest way to get started is by connecting to the OpenBB Workspace as a custom backend.
+
+### Start Server
+
+```sh
+openbb-api
+```
+
+This starts the FastAPI server over localhost on port 6900.
+
+### Add to Workspace
+
+See the documentation [here](https://docs.openbb.co/python/quickstart/workspace) for more details.
+
+### Click to Open App
+
+Once added, click on the app to open the dashboard.
+
+The dashboard contains widgets with metadata and information, as well ones for exploring and retrieving the data.
+
+## Implementation Details
+
+IMF is a SDMX API, and they organize their data by "dataflows". You can think of these as databases.
+Each one has their own definitions for parameters and output. Some definitions are shared, others are domain-specific.
+In SDMX, query models are expressed as dimensions, and data models as attributes.
+
+The extension comes with a base level of metadata used for normalizing, translating, and validating inputs and outputs.
+When mapping requires a code list that is not included, a network request is made to retrieve it.
+
+User input is validated by calling the constraints API for each dimension.
+The cached metadata contains all the potential values for parameters,
+but the availability of each is determined by other choices - country, frequency, etc.
+When making the actual request for data, parameters are tested for compatibility in the sequence defined by the data structure definition.
+Invalid parameter combinations are returned as helpful error messages with descriptions of what went wrong and what the valid choices are.
+
+The output converts ID codes into human-readable labels, and includes dataset and series metadata in a separate object.
+
+### Indicators
+
+In this library, we refer to indicators as "indicator-like" dimensions within individual dataflows.
+It includes dimensions like `COICOP_1999`, so items such as CPI All Items, or Clothing and footwear,
+are considered.
+
+The IMF codes for these values - `_T`, `CP01`, etc. - are used to construct ticker-like symbols.
+
+### Presentation Tables
+
+Presentation tables are built from hierarchical code lists defining the parent-child relationship
+between individual series.
+
+Internally, table IDs are prefixed with `H_` - i.e, `H_BOP_BOP_AGG_STANDARD_PRESENTATION`.
+
+The symbology allows entering references to tables, or indicators.
+
+### Symbology
+
+The Open Data Platform generally refers to all time series IDs as a `symbol`.
+Requesting time series data or presentation tables requires a symbol constructed from
+the dataflow ID and the indicator-like dimensions, split with `::`.
+
+`CPI::CPI` returns all series for `CPI` under the `TYPE_OF_INDEX` dimension.
+
+`CPI::CPI_CP01` returns just component of CPI, Food and non-alcoholic beverages.
+
+The symbol mapper matches the items after `::` intelligently to its corresponding dimension.
+Entering, `CPI::CP01`, gets the same result as, `CPI::CPI_CP01`.
+
+`CPI::CPI__T` returns just the top-level, All Items.
+
+`CPI::H_CPI_BY_COMPONENT` returns the entire presentation table, Consumer Price Index (CPI) by Component.
+
+Use, `obb.imf_utils.list_tables()`, for a list of tables and their symbol.
+
+Use, `obb.economy.available_indicators(provider='imf', query=')`, to search for, or list all, individual time series and symbols.
+
+This example lists all the indicators in the CPI and PI dataflows.
+
+```python
+from openbb import obb
+
+indicators = obb.economy.available_indicators(provider="imf", dataflows="CPI,PI")
+
+print(indicators.to_dict("records"))
+```
+
+```sh
+...
+ {'symbol_root': 'CP01',
+ 'symbol': 'CPI::CP01',
+ 'description': 'Food and non-alcoholic beverages',
+ 'agency_id': 'IMF.STA',
+ 'dataflow_id': 'CPI',
+ 'dataflow_name': 'Consumer Price Index (CPI)',
+ 'structure_id': 'DSD_CPI',
+ 'dimension_id': 'COICOP_1999',
+ 'long_description': 'Food and non-alcholoic beverages consumer price index is produced using prices related to food items and non-alcoholic beverages such as fresh produce, packaged foods, and beverages excluding alcoholic drinks, aggregated by their respective consumer expenditure weights.',
+ 'member_of': ['CPI::H_CPI_BY_COMPONENT']},
+...
+```
## Coverage
-- Databases:
- - International Reserves and Foreign Currency Liquidity
- - Direction of Trade Statistics
- - Financial Soundness Indicators
- - Port Watch
+All data available from https://data.imf.org/en/Data-Explorer can be retrieved, via `obb.economy.indicators(provider='imf', **kwargs)`.
-Coverage:
- - All IRFCL tables.
- - Individual, or multiple, time series from single or multiple countries.
- - Core and Encouraged Set tables, plus all individual underlying series.
- - Daily Port and Chokepoints data, with charts for metadata and average annual statistics.
+Additionally, there are endpoints for some Port Watch items (not part of the Data Explorer).
+
+The extension creates a router path, `imf_utils`, that exposes utility functions for UI integrations and metadata lookup.
### Endpoints
- `obb.economy.available_indicators`
- `obb.economy.indicators`
+- `obb.economy.cpi`
- `obb.economy.direction_of_trade`
- `obb.economy.shipping.chokepoint_info`
- `obb.economy.shipping.chokepoint_volume`
- `obb.economy.shipping.port_info`
- `obb.economy.shipping.port_volume`
+- `obb.imf_utils.get_dataflow_dimensions`
+- `obb.imf_utils.list_dataflow_choices`
+- `obb.imf_utils.list_dataflows`
+- `obb.imf_utils.list_indicators_by_dataflow`
+- `obb.imf_utils.list_port_id_choices`
+- `obb.imf_utils.list_table_choices`
+- `obb.imf_utils.list_tables`
+- `obb.imf_utils.presentation_table`
+- `obb.imf_utils.presentation_table_choices`
+
+"Choices" endpoints are utilized by OpenBB Workspace to populate widget dropdown menus.
diff --git a/openbb_platform/providers/imf/integration/__init__.py b/openbb_platform/providers/imf/integration/__init__.py
new file mode 100644
index 00000000000..476099ec1f7
--- /dev/null
+++ b/openbb_platform/providers/imf/integration/__init__.py
@@ -0,0 +1 @@
+"""IMF Utilities Router Module Integration Tests."""
diff --git a/openbb_platform/providers/imf/integration/test_imf_utils_api.py b/openbb_platform/providers/imf/integration/test_imf_utils_api.py
new file mode 100644
index 00000000000..7220f97e3b2
--- /dev/null
+++ b/openbb_platform/providers/imf/integration/test_imf_utils_api.py
@@ -0,0 +1,211 @@
+"""Test IMF Utils API endpoints."""
+
+import base64
+
+import pytest
+import requests
+from openbb_core.env import Env
+from openbb_core.provider.utils.helpers import get_querystring
+
+# pylint: disable=redefined-outer-name
+
+
+@pytest.fixture(scope="session")
+def headers():
+ """Get the headers for the API request."""
+ 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')}"}
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "output_format": "json",
+ }
+ ],
+)
+@pytest.mark.integration
+def test_imf_utils_list_dataflows(params, headers):
+ """Test imf_utils_list_dataflows endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ query_str = get_querystring(params, [])
+ url = f"http://0.0.0.0:8000/api/v1/imf_utils/list_dataflows?{query_str}"
+ result = requests.get(url, headers=headers, timeout=10)
+ assert isinstance(result, requests.Response)
+ assert result.status_code == 200
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "dataflow_id": "CPI",
+ "output_format": "json",
+ },
+ ],
+)
+@pytest.mark.integration
+def test_imf_utils_get_dataflow_dimensions(params, headers):
+ """Test imf_utils_get_dataflow_dimensions endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ query_str = get_querystring(params, [])
+ url = f"http://0.0.0.0:8000/api/v1/imf_utils/get_dataflow_dimensions?{query_str}"
+ result = requests.get(url, headers=headers, timeout=10)
+ assert isinstance(result, requests.Response)
+ assert result.status_code == 200
+
+
+@pytest.mark.parametrize("params", [{}])
+@pytest.mark.integration
+def test_imf_utils_list_port_id_choices(params, headers):
+ """Test imf_utils_list_port_id_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ query_str = get_querystring(params, [])
+ url = f"http://0.0.0.0:8000/api/v1/imf_utils/list_port_id_choices?{query_str}"
+ result = requests.get(url, headers=headers, timeout=10)
+ assert isinstance(result, requests.Response)
+ assert result.status_code == 200
+
+
+@pytest.mark.parametrize("params", [{}])
+@pytest.mark.integration
+def test_imf_utils_list_tables(params, headers):
+ """Test imf_utils_list_tables endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ query_str = get_querystring(params, [])
+ url = f"http://0.0.0.0:8000/api/v1/imf_utils/list_tables?{query_str}"
+ result = requests.get(url, headers=headers, timeout=10)
+ assert isinstance(result, requests.Response)
+ assert result.status_code == 200
+
+
+@pytest.mark.parametrize("params", [{}])
+@pytest.mark.integration
+def test_imf_utils_list_table_choices(params, headers):
+ """Test imf_utils_list_table_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ query_str = get_querystring(params, [])
+ url = f"http://0.0.0.0:8000/api/v1/imf_utils/list_table_choices?{query_str}"
+ result = requests.get(url, headers=headers, timeout=10)
+ assert isinstance(result, requests.Response)
+ assert result.status_code == 200
+
+
+@pytest.mark.parametrize("params", [{}])
+@pytest.mark.integration
+def test_imf_utils_list_dataflow_choices(params, headers):
+ """Test imf_utils_list_dataflow_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ query_str = get_querystring(params, [])
+ url = f"http://0.0.0.0:8000/api/v1/imf_utils/list_dataflow_choices?{query_str}"
+ result = requests.get(url, headers=headers, timeout=10)
+ assert isinstance(result, requests.Response)
+ assert result.status_code == 200
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "dataflow_group": None,
+ "table": None,
+ "country": None,
+ "frequency": None,
+ },
+ {
+ "dataflow_group": "cpi",
+ "table": None,
+ "country": None,
+ "frequency": None,
+ },
+ {
+ "dataflow_group": "cpi",
+ "table": "cpi",
+ "country": None,
+ "frequency": None,
+ },
+ {
+ "dataflow_group": "cpi",
+ "table": "cpi",
+ "country": "JPN",
+ "frequency": None,
+ },
+ ],
+)
+@pytest.mark.integration
+def test_imf_utils_presentation_table_choices(params, headers):
+ """Test imf_utils_presentation_table_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ query_str = get_querystring(params, [])
+ url = f"http://0.0.0.0:8000/api/v1/imf_utils/presentation_table_choices?{query_str}"
+ result = requests.get(url, headers=headers, timeout=10)
+ assert isinstance(result, requests.Response)
+ assert result.status_code == 200
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "symbol": "CPI::CPI__T",
+ "country": None,
+ "frequency": None,
+ "transform": None,
+ "dimension_values": None,
+ },
+ {
+ "symbol": "CPI::CPI__T",
+ "country": "JPN",
+ "frequency": None,
+ "transform": None,
+ "dimension_values": None,
+ },
+ ],
+)
+@pytest.mark.integration
+def test_imf_utils_indicator_choices(params, headers):
+ """Test imf_utils_indicator_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ query_str = get_querystring(params, [])
+ url = f"http://0.0.0.0:8000/api/v1/imf_utils/indicator_choices?{query_str}"
+ result = requests.get(url, headers=headers, timeout=10)
+ assert isinstance(result, requests.Response)
+ assert result.status_code == 200
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "dataflow_group": "cpi",
+ "table": "cpi",
+ "country": "JPN",
+ "frequency": "A",
+ "dimension_values": None,
+ "limit": 1,
+ "raw": True,
+ }
+ ],
+)
+@pytest.mark.integration
+def test_imf_utils_presentation_table(params, headers):
+ """Test imf_utils_presentation_table endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ query_str = get_querystring(params, [])
+ url = f"http://0.0.0.0:8000/api/v1/imf_utils/presentation_table?{query_str}"
+ result = requests.get(url, headers=headers, timeout=10)
+ assert isinstance(result, requests.Response)
+ assert result.status_code == 200
diff --git a/openbb_platform/providers/imf/integration/test_imf_utils_python.py b/openbb_platform/providers/imf/integration/test_imf_utils_python.py
new file mode 100644
index 00000000000..f836f6189cf
--- /dev/null
+++ b/openbb_platform/providers/imf/integration/test_imf_utils_python.py
@@ -0,0 +1,179 @@
+"""IMF Utilities module integration tests."""
+
+import pytest
+from openbb_core.app.model.obbject import OBBject
+
+# pylint: disable=redefined-outer-name
+
+
+@pytest.fixture(scope="session")
+def obb(pytestconfig): # pylint: disable=inconsistent-return-statements
+ """Fixture to setup obb."""
+
+ if pytestconfig.getoption("markexpr") != "not integration":
+ import openbb # pylint: disable=import-outside-toplevel
+
+ return openbb.obb
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "output_format": "json",
+ }
+ ],
+)
+@pytest.mark.integration
+def test_imf_utils_list_dataflows(params, obb):
+ """Test imf_utils_list_dataflows endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ result = obb.imf_utils.list_dataflows(**params)
+ assert result
+ assert isinstance(result, OBBject)
+ assert len(result.results) > 0 # type: ignore
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "dataflow_id": "CPI",
+ "output_format": "json",
+ },
+ ],
+)
+@pytest.mark.integration
+def test_imf_utils_get_dataflow_dimensions(params, obb):
+ """Test imf_utils_get_dataflow_dimensions endpoint."""
+ params = {p: v for p, v in params.items() if v}
+
+ result = obb.imf_utils.get_dataflow_dimensions(**params)
+ assert result
+ assert isinstance(result, OBBject)
+ assert result.results
+ assert isinstance(result.results, dict)
+
+
+@pytest.mark.parametrize("params", [{}])
+@pytest.mark.integration
+def test_imf_utils_list_port_id_choices(params, obb):
+ """Test imf_utils_list_port_id_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+ result = obb.imf_utils.list_port_id_choices(**params)
+ assert result
+ assert isinstance(result, list)
+
+
+@pytest.mark.parametrize("params", [{}])
+@pytest.mark.integration
+def test_imf_utils_list_tables(params, obb):
+ """Test imf_utils_list_tables endpoint."""
+ params = {p: v for p, v in params.items() if v}
+ result = obb.imf_utils.list_tables(**params)
+ assert result
+ assert isinstance(result, OBBject)
+ assert len(result.results) > 0 # type: ignore
+
+
+@pytest.mark.parametrize("params", [{}])
+@pytest.mark.integration
+def test_imf_utils_list_table_choices(params, obb):
+ """Test imf_utils_list_table_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+ result = obb.imf_utils.list_table_choices(**params)
+ assert result
+ assert isinstance(result, list)
+ assert len(result) > 0
+
+
+@pytest.mark.parametrize("params", [{}])
+@pytest.mark.integration
+def test_imf_utils_list_dataflow_choices(params, obb):
+ """Test imf_utils_list_dataflow_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+ result = obb.imf_utils.list_dataflow_choices(**params)
+ assert result
+ assert isinstance(result, list)
+ assert len(result) > 0
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "dataflow_group": None,
+ "table": None,
+ "country": None,
+ "frequency": None,
+ },
+ {
+ "dataflow_group": "cpi",
+ "table": None,
+ "country": None,
+ "frequency": None,
+ },
+ ],
+)
+@pytest.mark.integration
+def test_imf_utils_presentation_table_choices(params, obb):
+ """Test imf_utils_presentation_table_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+ result = obb.imf_utils.presentation_table_choices(**params)
+ assert result
+ assert isinstance(result, list)
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "dataflow_group": "cpi",
+ "table": "cpi",
+ "country": "JPN",
+ "frequency": "A",
+ "dimension_values": None,
+ "limit": 1,
+ "raw": True,
+ }
+ ],
+)
+@pytest.mark.integration
+def test_imf_utils_presentation_table(params, obb):
+ """Test imf_utils_presentation_table endpoint."""
+ params = {p: v for p, v in params.items() if v}
+ result = obb.imf_utils.presentation_table(**params)
+ assert result
+ assert isinstance(result, list)
+ assert len(result) > 1 # type: ignore
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {
+ "symbol": "CPI::CPI__T",
+ "country": None,
+ "frequency": None,
+ "transform": None,
+ "dimension_values": None,
+ },
+ {
+ "symbol": "CPI::CPI__T",
+ "country": "JPN",
+ "frequency": None,
+ "transform": None,
+ "dimension_values": None,
+ },
+ ],
+)
+@pytest.mark.integration
+@pytest.mark.skip(reason="Not included in Python interface")
+def test_imf_utils_indicator_choices(params, obb):
+ """Test imf_utils_indicator_choices endpoint."""
+ params = {p: v for p, v in params.items() if v}
+ result = obb.imf_utils.indicator_choices(**params)
+ assert result
+ assert isinstance(result, list)
+ assert len(result) > 0
diff --git a/openbb_platform/providers/imf/openbb_imf/__init__.py b/openbb_platform/providers/imf/openbb_imf/__init__.py
index 6abeab642bc..e9a53ef59a0 100644
--- a/openbb_platform/providers/imf/openbb_imf/__init__.py
+++ b/openbb_platform/providers/imf/openbb_imf/__init__.py
@@ -2,6 +2,7 @@
from openbb_core.provider.abstract.provider import Provider
from openbb_imf.models.available_indicators import ImfAvailableIndicatorsFetcher
+from openbb_imf.models.consumer_price_index import ImfConsumerPriceIndexFetcher
from openbb_imf.models.direction_of_trade import ImfDirectionOfTradeFetcher
from openbb_imf.models.economic_indicators import ImfEconomicIndicatorsFetcher
from openbb_imf.models.maritime_chokepoint_info import ImfMaritimeChokePointInfoFetcher
@@ -17,6 +18,7 @@ imf_provider = Provider(
description="Access International Monetary Fund (IMF) data APIs.",
fetcher_dict={
"AvailableIndicators": ImfAvailableIndicatorsFetcher,
+ "ConsumerPriceIndex": ImfConsumerPriceIndexFetcher,
"DirectionOfTrade": ImfDirectionOfTradeFetcher,
"EconomicIndicators": ImfEconomicIndicatorsFetcher,
"MaritimeChokePointInfo": ImfMaritimeChokePointInfoFetcher,
diff --git a/openbb_platform/providers/imf/openbb_imf/apps.json b/openbb_platform/providers/imf/openbb_imf/apps.json
new file mode 100644
index 00000000000..5ee8605e857
--- /dev/null
+++ b/openbb_platform/providers/imf/openbb_imf/apps.json
@@ -0,0 +1,488 @@
+[{
+ "name": "IMF Explorer",
+ "img": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/IMF-Seal_ENG_RGB.svg/500px-IMF-Seal_ENG_RGB.svg.png",
+ "img_dark": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/IMF-Seal_ENG_RGB.svg/500px-IMF-Seal_ENG_RGB.svg.png",
+ "img_light": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/IMF-Seal_ENG_RGB.svg/500px-IMF-Seal_ENG_RGB.svg.png",
+ "description": "Explore economic indicators and presentation tables published by the IMF. Disclaimer: This application is neither affiliated with nor endorsed by the IMF. See https://data.imf.org/ for terms and conditions.",
+ "allowCustomization": true,
+ "tabs": {
+ "dataflows": {
+ "id": "dataflows",
+ "name": "Dataflows",
+ "layout": [
+ {
+ "i": "imf_utils_get_dataflow_dimensions_custom_obb",
+ "x": 0,
+ "y": 2,
+ "w": 16,
+ "h": 29,
+ "state": {
+ "params": {
+ "dataflow_id": "CTOT"
+ }
+ },
+ "groups": []
+ },
+ {
+ "i": "imf_utils_list_dataflows_custom_obb",
+ "x": 16,
+ "y": 2,
+ "w": 24,
+ "h": 29,
+ "groups": []
+ }
+ ]
+ },
+ "indicators": {
+ "id": "indicators",
+ "name": "Indicators",
+ "layout": [
+ {
+ "i": "economy_available_indicators_imf_obb",
+ "x": 0,
+ "y": 2,
+ "w": 40,
+ "h": 15,
+ "state": {
+ "params": {
+ "dataflows": "CTOT",
+ "symbol": "CTOT::CEMPI_CTOTNX_TT"
+ },
+ "chartView": {
+ "enabled": false,
+ "chartType": "line"
+ },
+ "columnState": {
+ "default": {
+ "columnPinning": {
+ "leftColIds": [
+ "symbol"
+ ],
+ "rightColIds": []
+ },
+ "columnOrder": {
+ "orderedColIds": [
+ "symbol_root",
+ "symbol",
+ "description",
+ "agency_id",
+ "dataflow_id",
+ "dataflow_name",
+ "structure_id",
+ "dimension_id",
+ "long_description",
+ "member_of"
+ ]
+ }
+ }
+ }
+ },
+ "groups": [
+ "Group 1"
+ ]
+ },
+ {
+ "i": "economy_indicators_imf_obb",
+ "x": 0,
+ "y": 17,
+ "w": 40,
+ "h": 21,
+ "state": {
+ "params": {
+ "symbol": "CTOT::CEPI_CTOTX_GDP",
+ "country": [
+ "",
+ "AUS"
+ ],
+ "frequency": "M",
+ "limit": "1",
+ "pivot": "false"
+ },
+ "chartModel": {
+ "modelType": "range",
+ "chartType": "customCombo",
+ "chartOptions": {
+ "line": {
+ "legend": {
+ "enabled": false
+ },
+ "axes": {
+ "category": {}
+ }
+ }
+ },
+ "cellRange": {
+ "columns": [
+ "date",
+ "value"
+ ]
+ },
+ "suppressChartRanges": true,
+ "seriesChartTypes": [
+ {
+ "colId": "value",
+ "chartType": "groupedColumn",
+ "secondaryAxis": false
+ },
+ {
+ "colId": "unit_multiplier",
+ "chartType": "line",
+ "secondaryAxis": false
+ }
+ ]
+ },
+ "chartView": {
+ "enabled": false,
+ "chartType": "customCombo"
+ },
+ "columnState": {
+ "default": {
+ "columnPinning": {
+ "leftColIds": [
+ "title",
+ "country",
+ "date",
+ "value"
+ ],
+ "rightColIds": []
+ },
+ "columnVisibility": {
+ "hiddenColIds": [
+ "description"
+ ]
+ },
+ "columnOrder": {
+ "orderedColIds": [
+ "title",
+ "country",
+ "unit",
+ "scale",
+ "date",
+ "symbol_root",
+ "symbol",
+ "value",
+ "unit_multiplier",
+ "order",
+ "level",
+ "indicator",
+ "description",
+ "country_code",
+ "wgt_type",
+ "wgt_type_code",
+ "frequency",
+ "frequency_code"
+ ]
+ }
+ }
+ }
+ },
+ "groups": [
+ "Group 1"
+ ]
+ }
+ ]
+ },
+ "presentation-tables": {
+ "id": "presentation-tables",
+ "name": "Presentation Tables",
+ "layout": [
+ {
+ "i": "imf_utils_presentation_table_custom_obb",
+ "x": 0,
+ "y": 2,
+ "w": 40,
+ "h": 31,
+ "state": {
+ "params": {
+ "dataflow_group": "qgfs",
+ "table": "qgfs_balance",
+ "country": [
+ "BRA"
+ ],
+ "frequency": "Q",
+ "limit": "3"
+ },
+ "columnState": {
+ "default": {
+ "columnOrder": {
+ "orderedColIds": [
+ "country",
+ "title",
+ "2024-12-31",
+ "2023-12-31",
+ "2022-12-31"
+ ]
+ }
+ }
+ }
+ },
+ "groups": []
+ }
+ ]
+ },
+ "direction-of-trade": {
+ "id": "direction-of-trade",
+ "name": "Direction of Trade",
+ "layout": [
+ {
+ "i": "economy_direction_of_trade_imf_obb",
+ "x": 0,
+ "y": 2,
+ "w": 40,
+ "h": 27,
+ "state": {
+ "params": {
+ "start_date": "2010-01-01",
+ "frequency": "quarter"
+ },
+ "chartModel": {
+ "modelType": "range",
+ "chartType": "line",
+ "chartOptions": {},
+ "suppressChartRanges": true
+ },
+ "chartView": {
+ "enabled": false,
+ "chartType": "line"
+ },
+ "columnState": {
+ "default": {
+ "columnPinning": {
+ "leftColIds": [
+ "date",
+ "symbol"
+ ],
+ "rightColIds": []
+ },
+ "columnVisibility": {
+ "hiddenColIds": [
+ "symbol",
+ "country_code",
+ "counterpart_code",
+ "unit_multiplier"
+ ]
+ },
+ "columnOrder": {
+ "orderedColIds": [
+ "date",
+ "symbol",
+ "country",
+ "counterpart",
+ "title",
+ "value",
+ "scale",
+ "unit",
+ "country_code",
+ "counterpart_code",
+ "unit_multiplier"
+ ]
+ }
+ }
+ }
+ },
+ "groups": []
+ }
+ ]
+ },
+ "port-watch": {
+ "id": "port-watch",
+ "name": "Port Watch",
+ "layout": [
+ {
+ "i": "economy_shipping_chokepoint_volume_imf_obb",
+ "x": 0,
+ "y": 2,
+ "w": 40,
+ "h": 16,
+ "state": {
+ "params": {
+ "start_date": "2024-01-01",
+ "chokepoint": [
+ "chokepoint2"
+ ]
+ },
+ "chartView": {
+ "enabled": false,
+ "chartType": "line"
+ },
+ "columnState": {
+ "default": {
+ "columnPinning": {
+ "leftColIds": [
+ "date",
+ "chokepoint"
+ ],
+ "rightColIds": []
+ },
+ "columnOrder": {
+ "orderedColIds": [
+ "date",
+ "chokepoint",
+ "vessels_total",
+ "vessels_cargo",
+ "vessels_tanker",
+ "vessels_container",
+ "vessels_general_cargo",
+ "vessels_dry_bulk",
+ "vessels_roro",
+ "capacity_total",
+ "capacity_cargo",
+ "capacity_tanker",
+ "capacity_container",
+ "capacity_general_cargo",
+ "capacity_dry_bulk",
+ "capacity_roro"
+ ]
+ }
+ }
+ }
+ },
+ "groups": []
+ },
+ {
+ "i": "economy_shipping_port_info_imf_obb",
+ "x": 0,
+ "y": 18,
+ "w": 40,
+ "h": 14,
+ "state": {
+ "params": {
+ "country": "USA",
+ "port_code": "port786"
+ },
+ "chartView": {
+ "enabled": false,
+ "chartType": "line"
+ },
+ "columnState": {
+ "default": {
+ "columnPinning": {
+ "leftColIds": [
+ "port_code",
+ "continent",
+ "country",
+ "port_full_name"
+ ],
+ "rightColIds": []
+ },
+ "columnVisibility": {
+ "hiddenColIds": [
+ "country_code",
+ "port_name"
+ ]
+ },
+ "columnOrder": {
+ "orderedColIds": [
+ "port_code",
+ "continent",
+ "country",
+ "country_code",
+ "port_name",
+ "port_full_name",
+ "latitude",
+ "longitude",
+ "vessel_count_total",
+ "vessel_count_tanker",
+ "vessel_count_container",
+ "vessel_count_general_cargo",
+ "vessel_count_dry_bulk",
+ "vessel_count_roro",
+ "share_country_maritime_import",
+ "share_country_maritime_export",
+ "industry_top1",
+ "industry_top2",
+ "industry_top3"
+ ]
+ }
+ }
+ }
+ },
+ "groups": [
+ "Group 2"
+ ]
+ },
+ {
+ "i": "economy_shipping_port_volume_imf_obb",
+ "x": 0,
+ "y": 32,
+ "w": 40,
+ "h": 15,
+ "state": {
+ "params": {
+ "start_date": "2024-01-01",
+ "country": "DEU"
+ },
+ "chartView": {
+ "enabled": false,
+ "chartType": "line"
+ },
+ "columnState": {
+ "default": {
+ "columnPinning": {
+ "leftColIds": [
+ "date",
+ "port_code",
+ "port_name",
+ "country"
+ ],
+ "rightColIds": []
+ },
+ "columnVisibility": {
+ "hiddenColIds": [
+ "country_code"
+ ]
+ },
+ "columnOrder": {
+ "orderedColIds": [
+ "date",
+ "port_code",
+ "port_name",
+ "country",
+ "country_code",
+ "portcalls",
+ "portcalls_tanker",
+ "portcalls_container",
+ "portcalls_general_cargo",
+ "portcalls_dry_bulk",
+ "portcalls_roro",
+ "imports",
+ "imports_cargo",
+ "imports_tanker",
+ "imports_container",
+ "imports_general_cargo",
+ "imports_dry_bulk",
+ "imports_roro",
+ "exports",
+ "exports_cargo",
+ "exports_tanker",
+ "exports_container",
+ "exports_general_cargo",
+ "exports_dry_bulk",
+ "exports_roro"
+ ]
+ }
+ }
+ }
+ },
+ "groups": [
+ "Group 2"
+ ]
+ }
+ ]
+ }
+ },
+ "groups": [
+ {
+ "name": "Group 1",
+ "type": "param",
+ "paramName": "symbol",
+ "defaultValue": "CTOT::CEPI_CTOTX_GDP"
+ },
+ {
+ "name": "Group 2",
+ "type": "param",
+ "paramName": "port_code",
+ "defaultValue": "port664"
+ }
+ ]
+}]
\ No newline at end of file
diff --git a/openbb_platform/providers/imf/openbb_imf/assets/imf_cache.pkl.gz b/openbb_platform/providers/imf/openbb_imf/assets/imf_cache.pkl.gz
new file mode 100644
index 00000000000..d164b8bc5d9
Binary files /dev/null and b/openbb_platform/providers/imf/openbb_imf/assets/imf_cache.pkl.gz differ
diff --git a/openbb_platform/providers/imf/openbb_imf/assets/imf_country_map.json b/openbb_platform/providers/imf/openbb_imf/assets/imf_country_map.json
deleted file mode 100644
index 7c66b52de90..00000000000
--- a/openbb_platform/providers/imf/openbb_imf/assets/imf_country_map.json
+++ /dev/null
@@ -1,260 +0,0 @@
-{
- "AF": "Afghanistan",
- "AL": "Albania",
- "DZ": "Algeria",
- "AS": "American Samoa",
- "AO": "Angola",
- "AI": "Anguilla",
- "AG": "Antigua and Barbuda",
- "AR": "Argentina",
- "AM": "Armenia",
- "AW": "Aruba",
- "S19": "Asia not allocated",
- "AU": "Australia",
- "AT": "Austria",
- "AZ": "Azerbaijan",
- "BS": "Bahamas",
- "BH": "Bahrain",
- "BD": "Bangladesh",
- "BB": "Barbados",
- "BY": "Belarus",
- "BE": "Belgium",
- "R1": "Belgo-Luxembourg Economic Union",
- "BZ": "Belize",
- "BJ": "Benin",
- "BM": "Bermuda",
- "BT": "Bhutan",
- "BO": "Bolivia",
- "BA": "Bosnia and Herzegovina",
- "BW": "Botswana",
- "BR": "Brazil",
- "BN": "Brunei Darussalam",
- "BG": "Bulgaria",
- "BF": "Burkina Faso",
- "BI": "Burundi",
- "CV": "Cabo Verde",
- "KH": "Cambodia",
- "CM": "Cameroon",
- "CA": "Canada",
- "5Y": "Eastern Caribbean Currency Union",
- "CF": "Central African Republic",
- "TD": "Chad",
- "CL": "Chile",
- "HK": "Hong Kong",
- "MO": "Macao",
- "CN": "China",
- "CO": "Colombia",
- "KM": "Comoros",
- "CD": "Democratic Republic of the Congo",
- "CG": "Congo",
- "CR": "Costa Rica",
- "CI": "Ivory Coast",
- "HR": "Croatia",
- "CU": "Cuba",
- "CW": "Curacao",
- "CY": "Cyprus",
- "CZ": "Czech Republic",
- "CSH": "Former Czechoslovakia",
- "DK": "Denmark",
- "DJ": "Djibouti",
- "DM": "Dominica",
- "DO": "Dominican Republic",
- "DE2": "East Germany",
- "EC": "Ecuador",
- "EG": "Egypt",
- "SV": "El Salvador",
- "GQ": "Equatorial Guinea",
- "ER": "Eritrea",
- "EE": "Estonia",
- "SZ": "Eswatini",
- "ET": "Ethiopia",
- "E19": "Europe not allocated",
- "FK": "Falkland Islands (Malvinas)",
- "FO": "Faroe Islands",
- "FJ": "Fiji",
- "FI": "Finland",
- "FR": "France",
- "PF": "French Polynesia",
- "GA": "Gabon",
- "GM": "Gambia",
- "GE": "Georgia",
- "DE": "Germany",
- "GH": "Ghana",
- "GI": "Gibraltar",
- "GR": "Greece",
- "GL": "Greenland",
- "GD": "Grenada",
- "GU": "Guam",
- "GT": "Guatemala",
- "GN": "Guinea",
- "GW": "Guinea-Bissau",
- "GY": "Guyana",
- "HT": "Haiti",
- "VA": "Vatican City State",
- "HN": "Honduras",
- "HU": "Hungary",
- "IS": "Iceland",
- "IN": "India",
- "ID": "Indonesia",
- "IR": "Iran",
- "IQ": "Iraq",
- "IE": "Ireland",
- "IL": "Israel",
- "IT": "Italy",
- "JM": "Jamaica",
- "JP": "Japan",
- "JO": "Jordan",
- "KZ": "Kazakhstan",
- "KE": "Kenya",
- "KI": "Kiribati",
- "KP": "North Korea",
- "KR": "South Korea",
- "XK": "Kosovo",
- "KW": "Kuwait",
- "KG": "Kyrgyzstan",
- "LA": "Lao",
- "LV": "Latvia",
- "LB": "Lebanon",
- "LS": "Lesotho",
- "LR": "Liberia",
- "LY": "Libya",
- "LT": "Lithuania",
- "LU": "Luxembourg",
- "MG": "Madagascar",
- "MW": "Malawi",
- "MY": "Malaysia",
- "1C_554": "West Malaysia",
- "MV": "Maldives",
- "ML": "Mali",
- "MT": "Malta",
- "MH": "Marshall islands",
- "MR": "Mauritania",
- "MU": "Mauritius",
- "MX": "Mexico",
- "FM": "Micronesia",
- "F979": "Middle East and Central Asia not specified",
- "MD": "Moldova",
- "MN": "Mongolia",
- "ME": "Montenegro",
- "MS": "Montserrat",
- "MA": "Morocco",
- "MZ": "Mozambique",
- "MM": "Myanmar",
- "NA": "Namibia",
- "NR": "Nauru",
- "NP": "Nepal",
- "AN": "Antilles",
- "NL": "Netherlands",
- "NC": "New Caledonia",
- "NZ": "New Zealand",
- "NI": "Nicaragua",
- "NE": "Niger",
- "NG": "Nigeria",
- "MK": "North Macedonia",
- "1C_958": "North Vietnam",
- "NO": "Norway",
- "OM": "Oman",
- "PK": "Pakistan",
- "PW": "Palau",
- "PA": "Panama",
- "PG": "Papua New Guinea",
- "PY": "Paraguay",
- "PE": "Peru",
- "PH": "Philippines",
- "PL": "Poland",
- "PT": "Portugal",
- "QA": "Qatar",
- "RO": "Romania",
- "RU": "Russia",
- "RW": "Rwanda",
- "WS": "Samoa",
- "SM": "San Marino",
- "ST": "Sao Tome and Principe",
- "SA": "Saudi Arabia",
- "SN": "Senegal",
- "CS": "Serbia and Montenegro",
- "RS": "Serbia",
- "SC": "Seychelles",
- "SL": "Sierra Leone",
- "SG": "Singapore",
- "SX": "Sint Maarten (Dutch part)",
- "SK": "Slovakia",
- "SI": "Slovenia",
- "SB": "Solomon Islands",
- "SO": "Somalia",
- "ZA": "South Africa",
- "1C_198": "South African Common Customs Area",
- "SS": "South Sudan",
- "ES": "Spain",
- "LK": "Sri Lanka",
- "KN": "Saint Kitts and Nevis",
- "LC": "Saint Lucia",
- "VC": "Saint Vincent and the Grenadines",
- "SD": "Sudan",
- "SR": "Suriname",
- "SE": "Sweden",
- "CH": "Switzerland",
- "SY": "Syria",
- "TW": "Taiwan",
- "TJ": "Tajikistan",
- "TZ": "Tanzania",
- "TH": "Thailand",
- "TL": "Timor-Leste",
- "TG": "Togo",
- "TO": "Tonga",
- "TT": "Trinidad and Tobago",
- "TN": "Tunisia",
- "TR": "Turkey",
- "TM": "Turkmenistan",
- "TV": "Tuvalu",
- "UG": "Uganda",
- "UA": "Ukraine",
- "AE": "United Arab Emirates",
- "GB": "United Kingdom",
- "US": "United States",
- "UY": "Uruguay",
- "SUH": "Former USSR",
- "UZ": "Uzbekistan",
- "VU": "Vanuatu",
- "VE": "Venezuela",
- "VN": "Viet Nam",
- "PS": "Palestine",
- "YE": "Yemen",
- "YUC": "Former Yugoslavia",
- "ZM": "Zambia",
- "ZW": "Zimbabwe",
- "R14": "Community of Independent States",
- "U2": "Euro Area",
- "E1": "Europe",
- "B0": "European Union",
- "F97": "Middle East",
- "XS5": "Middle East and Central Asia",
- "X88": "Other Countries n.i.e. (IMF)",
- "F1": "Africa",
- "F6": "Sub-Saharan Africa",
- "F19": "Africa not allocated",
- "A10": "Western Hemisphere",
- "A109": "Western Hemisphere not allocated",
- "W00": "World",
- "XR99": "Special Categories and Economic Zones",
- "XS25": "Developing Asia (IMF)",
- "XR43": "Emerging and Developing Countries",
- "XR29": "Advanced Economies (IMF)",
- "1C_ALLC": "All Countries",
- "1C_ALL": "All Countries and Country Groups",
- "1C_ALLG": "All Country Groups",
- "1C_903": "Emerging and Developing Europe",
- "1C_080": "Export Earnings: Fuel",
- "1C_092": "Export Earnings: Nonfuel",
- "1C_440": "Middle East, North Africa, Afghanistan, and Pakistan",
- "1C_473": "Yemen Arab Rep",
- "1C_459": "Yemen P.D. Rep",
- "1C_All_Reporting_Countries_Data": "All Reporting Countries, Data",
- "1C_All_Reporting_Countries_Metadata": "All Reporting Countries, Metadata",
- "1C_Daily_Reporters": "Daily Reporters",
- "1C_Dual_Reporters": "Dual Reporters",
- "1C_Monthly_Reporters": "Monthly Reporters",
- "1C_Single_Reporters": "Single Reporters",
- "1C_Single_Reporters_USA_and_Peru": "Single Reporters - USA and Peru",
- "_X": "Unspecified"
-}
\ No newline at end of file
diff --git a/openbb_platform/providers/imf/openbb_imf/imf_router.py b/openbb_platform/providers/imf/openbb_imf/imf_router.py
new file mode 100644
index 00000000000..31451c30f7e
--- /dev/null
+++ b/openbb_platform/providers/imf/openbb_imf/imf_router.py
@@ -0,0 +1,1130 @@
+"""IMF Utilities Router."""
+
+from typing import Annotated, Any, Literal
+
+from fastapi import Query
+from fastapi.responses import HTMLResponse
+from openbb_core.app.model.abstract.error import OpenBBError
+from openbb_core.app.model.example import APIEx, PythonEx
+from openbb_core.app.model.obbject import OBBject
+from openbb_core.app.router import Router
+from openbb_core.app.service.system_service import SystemService
+from openbb_imf.models.indicator_metadata import ImfTableMetadata
+from openbb_imf.utils.constants import (
+ PRESENTATION_TABLES,
+ table_dataflow_choices,
+ table_dataflow_map,
+ table_name_map,
+)
+from openbb_imf.utils.metadata import ImfMetadata
+
+# pylint: disable=unused-argument,protected-access,too-many-positional-arguments,too-many-lines,too-many-locals,too-many-branches
+
+router = Router(prefix="", description="Utilities for IMF provider.")
+api_prefix = SystemService().system_settings.api_settings.prefix
+
+
+@router.command(
+ methods=["GET"],
+ widget_config={
+ "description": "Descriptions for all IMF dataflows.",
+ "params": [
+ {
+ "paramName": "output_format",
+ "label": "Output Format",
+ "value": "markdown",
+ "description": "Output format: 'json' or 'markdown'.",
+ "show": False,
+ }
+ ],
+ "type": "markdown",
+ "data": {"dataKey": "results"},
+ "gridData": {"w": 40, "h": 15},
+ "refetchInterval": False,
+ "name": "IMF Dataflows",
+ "source": ["IMF"],
+ "category": "IMF Utilities",
+ "subCategory": "Metadata",
+ },
+ examples=[
+ APIEx(
+ description="Lists all known dataflows available from the IMF in JSON format.",
+ parameters={"output_format": "json"},
+ ),
+ APIEx(
+ description="Return the content as a markdown-formatted summary instead of a JSON table.",
+ parameters={"output_format": "markdown"},
+ ),
+ PythonEx(
+ description="Lists all known dataflows available from the IMF.",
+ code=[
+ "imf_dataflows = obb.imf.utils.list_dataflows()",
+ "print(imf_dataflows)",
+ ],
+ ),
+ ],
+)
+async def list_dataflows(
+ output_format: Literal["json", "markdown"] = "json",
+) -> OBBject:
+ """List all available IMF dataflows.
+
+ Returns an OBBject containing either a JSON dictionary of dataflows
+ or a markdown string under the 'results' attribute.
+ """
+ metadata = ImfMetadata()
+ dataflows = metadata.dataflows
+
+ if output_format == "markdown":
+ all_tables = metadata.list_all_dataflow_tables()
+ md_text = ""
+
+ for dataflow_id in sorted(dataflows.keys()):
+ details = dataflows[dataflow_id]
+ indicators = metadata.get_indicators_in(dataflow_id)
+ params = metadata.get_dataflow_parameters(dataflow_id)
+ md_text += f"## `{dataflow_id}` - {details.get('name', '')}\n\n"
+
+ if indicators:
+ md_text += f"**Number of Series:** {len(indicators)}\n\n"
+
+ if params:
+ escaped_params = [f"`{param}`" for param in list(params)]
+ md_text += f"**Dimensions:** {', '.join(escaped_params)}\n\n"
+
+ presentations = all_tables.get(dataflow_id, [])
+
+ if presentations:
+ md_text += "### Presentation Tables\n\n"
+ seen_names: set[str] = set()
+
+ for pres in presentations:
+ pres_name = pres.get("name", "").strip()
+
+ if pres_name in seen_names:
+ continue
+
+ seen_names.add(pres_name)
+
+ pres_id = pres.get("id", "")
+ pres_desc = pres.get("description", "").strip()
+ friendly_name = pres.get("friendly_name", "")
+ symbol = f"{dataflow_id}::{pres_id}"
+ md_text += f"#### {pres_name}\n\n"
+
+ if friendly_name:
+ md_text += f"**Friendly Name:** `{friendly_name}`\n\n"
+
+ md_text += f"**Symbol:** `{symbol}`\n\n"
+
+ if pres_desc and pres_desc != pres_name:
+ md_text += f"{pres_desc}\n\n"
+
+ md_text += f"{details.get('description', '').strip()}\n\n"
+ md_text += "---\n\n"
+
+ return OBBject(results=md_text)
+
+ return OBBject(results=dataflows)
+
+
+@router.command(
+ methods=["GET"],
+ widget_config={
+ "name": "IMF Dataflow Parameters",
+ "type": "markdown",
+ "params": [
+ {
+ "paramName": "dataflow_id",
+ "label": "Dataflow",
+ "value": "CPI",
+ "description": "The IMF dataflow to display.",
+ "type": "endpoint",
+ "optionsEndpoint": f"{api_prefix}/imf_utils/list_dataflow_choices",
+ "style": {"popupWidth": 700},
+ },
+ {
+ "paramName": "output_format",
+ "value": "markdown",
+ "show": False,
+ },
+ ],
+ "source": ["IMF"],
+ "category": "IMF Utilities",
+ "subCategory": "Metadata",
+ },
+ examples=[
+ PythonEx(
+ description="Get parameters for the 'CPI' dataflow.",
+ code=[
+ "imf_params = obb.imf.utils.get_dataflow_dimensions('CPI')",
+ "print(imf_params.results)",
+ ],
+ ),
+ APIEx(
+ description="Get parameters for the 'GFS_BS' dataflow in markdown format.",
+ parameters={"dataflow_id": "GFS_BS", "output_format": "markdown"},
+ ),
+ APIEx(
+ description="Get parameters for the 'IL' dataflow in JSON format.",
+ parameters={"dataflow_id": "IL", "output_format": "json"},
+ ),
+ ],
+)
+async def get_dataflow_dimensions(
+ dataflow_id: Annotated[
+ str,
+ Query(
+ title="Dataflow",
+ description="The IMF dataflow ID. Use `list_dataflows()` to see available dataflows.",
+ ),
+ ],
+ output_format: Literal["json", "markdown"] = "json",
+) -> OBBject:
+ """Dataflow parameters and possible values.
+
+ Returns an OBBject containing either a JSON dictionary of parameters
+ and their options, or a markdown string under the 'results' attribute.
+ """
+ metadata = ImfMetadata()
+ params_str = ""
+
+ try:
+ parameters = metadata.get_dataflow_parameters(dataflow_id)
+ except ValueError as e:
+ raise e from e
+
+ if output_format == "json":
+ return OBBject(results=parameters)
+
+ for dim, options in parameters.items():
+ params_str += f"### `{dim}`\n\n"
+ for option in options:
+ params_str += f"- `{option.get('value', '')}` : {option.get('label')}\n\n"
+ params_str += "---\n\n"
+
+ return OBBject(results=params_str)
+
+
+@router.command(
+ methods=["GET"],
+ widget_config={"exclude": True},
+ examples=[
+ APIEx(
+ description="Get port ID choices for IMF Port Watch.",
+ parameters={},
+ )
+ ],
+)
+async def list_port_id_choices() -> list[dict[str, str]]:
+ """
+ Get port ID choices for IMF Port Watch.
+
+ Returns
+ -------
+ list[dict[str, str]]
+ A list of dictionaries with 'label' and 'value' for each port ID.
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.port_watch_helpers import get_port_id_choices
+
+ choices = get_port_id_choices()
+ return choices
+
+
+@router.command(
+ methods=["GET"],
+ examples=[
+ APIEx(
+ description="Get the list of available presentation tables.",
+ parameters={},
+ )
+ ],
+ widget_config={
+ "name": "IMF Presentation Tables List",
+ "description": "Presentation tables from the IMF database.",
+ "params": [
+ {
+ "paramName": "symbol",
+ "label": "Table Symbol",
+ "value": None,
+ "description": "Dummy parameter to allow grouping in the UI.",
+ "type": "text",
+ "show": False,
+ }
+ ],
+ "refetchInterval": False,
+ "source": ["IMF"],
+ "category": "IMF Utilities",
+ "subCategory": "Metadata",
+ },
+)
+async def list_tables() -> OBBject[list[ImfTableMetadata]]:
+ """Get the list of presentation tables available from the IMF."""
+ metadata = ImfMetadata()
+ tables: list[ImfTableMetadata] = []
+ dataflows = metadata.list_all_dataflow_tables()
+
+ for dataflow_id, presentations in dataflows.items():
+ for pres in presentations:
+ table_id = pres.get("id", "")
+ unique_key = f"{dataflow_id}::{table_id}"
+
+ table: dict = {}
+ table["name"] = pres.get("name", "")
+ table["description"] = pres.get("description", "").strip()
+ table["symbol"] = unique_key
+ table["agency_id"] = pres.get("agency_id", "")
+ table["dataflow_id"] = dataflow_id
+ table["codelist_id"] = pres.get("codelist_id", "")
+ tables.append(ImfTableMetadata(**table))
+
+ return OBBject(results=tables)
+
+
+@router.command(
+ methods=["GET"],
+ widget_config={"exclude": True},
+ examples=[
+ APIEx(
+ description="Get presentation table choices for IMF data retrieval.",
+ parameters={},
+ ),
+ ],
+)
+async def list_table_choices() -> list[dict[str, str]]:
+ """Get presentation table choices for IMF data retrieval.
+
+ Returns
+ -------
+ list[dict[str, str]]
+ A list of dictionaries with 'label' and 'value' for each presentation table.
+ """
+ metadata = ImfMetadata()
+ table_choices = metadata.list_all_dataflow_tables()
+ choices: list[dict[str, str]] = []
+ for dataflow_id, presentations in table_choices.items():
+ for pres in presentations:
+ table_id = pres.get("id", "")
+ unique_key = f"{dataflow_id}::{table_id}"
+ choices.append(
+ {
+ "label": pres.get("name", ""),
+ "value": unique_key,
+ }
+ )
+
+ return choices
+
+
+@router.command(
+ methods=["GET"],
+ widget_config={"exclude": True},
+ examples=[
+ APIEx(
+ description="Get dataflow choices for IMF data retrieval.",
+ parameters={},
+ )
+ ],
+)
+async def list_dataflow_choices() -> list[dict[str, str]]:
+ """Get dataflow choices for IMF data retrieval.
+
+ Returns
+ -------
+ list[dict[str, str]]
+ A list of dictionaries with 'label' and 'value' for each presentation table.
+ """
+ metadata = ImfMetadata()
+ dataflows = metadata.dataflows
+ choices: list[dict[str, str]] = []
+ for dataflow_id, details in dataflows.items():
+ choices.append(
+ {
+ "label": details.get("name", ""),
+ "value": dataflow_id,
+ }
+ )
+
+ return sorted(choices, key=lambda x: x["label"])
+
+
+@router.command(
+ methods=["GET"],
+ widget_config={"exclude": True},
+ examples=[
+ APIEx(
+ description="Get the dataflow group choices.",
+ parameters={},
+ ),
+ APIEx(
+ description="Get table choices for the 'cpi' dataflow group.",
+ parameters={
+ "dataflow_group": "cpi",
+ },
+ ),
+ APIEx(
+ description="Get country choices for the 'cpi' dataflow group and 'cpi' table.",
+ parameters={
+ "dataflow_group": "cpi",
+ "table": "cpi",
+ },
+ ),
+ APIEx(
+ description="Get frequency choices for the 'cpi' dataflow group, 'cpi' table, and 'JPN' country.",
+ parameters={
+ "dataflow_group": "cpi",
+ "table": "cpi",
+ "country": "JPN",
+ },
+ ),
+ ],
+)
+async def presentation_table_choices(
+ dataflow_group: str | None = None,
+ table: str | None = None,
+ country: str | None = None,
+ frequency: str | None = None,
+) -> list[dict[str, str]]:
+ """Get presentation table choices for IMF data retrieval.
+
+ This endpoint provides dynamic choices for IMF presentation tables based on selected parameters.
+ It is intended to be used by the OpenBB Workspace UI to populate dropdowns.
+
+ For manual API calls, use `economy/indicators` instead with a `symbol` from `list_tables()`.
+
+ Parameters
+ ----------
+ dataflow_group : str | None
+ The IMF dataflow group. Show all groups if None.
+ table : str | None
+ The IMF presentation table ID. Enter a dataflow_group to see table choices.
+ country : str | None
+ Enter a dataflow_group and table to see country choices.
+ frequency : str | None
+ Enter a dataflow_group, table, and country to see frequency choices.
+
+ Returns
+ -------
+ list[dict[str, str]]
+ A list of dictionaries with 'label' and 'value' for each presentation table.
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ choices: list[dict[str, str]] = []
+
+ if dataflow_group is None:
+ return table_dataflow_choices
+
+ metadata = ImfMetadata()
+
+ if dataflow_group is not None and table is None:
+
+ table_names = table_dataflow_map.get(dataflow_group, [])
+
+ for t in table_names:
+ choices.append(
+ {
+ "label": table_name_map.get(t, t),
+ "value": t,
+ }
+ )
+
+ return choices
+
+ if dataflow_group is not None and table is not None and country is None:
+ table_id = PRESENTATION_TABLES.get(table, "")
+ dataflow_id = table_id.split("::")[0]
+ params = metadata.get_dataflow_parameters(dataflow_id)
+ country_dim = (
+ "COUNTRY"
+ if "COUNTRY" in params
+ else "JURISDICTION" if "JURISDICTION" in params else "REF_AREA"
+ )
+ countries = params.get(country_dim, [])
+
+ return sorted(countries, key=lambda x: x["label"])
+
+ if dataflow_group is not None and table is not None and country is not None:
+ table_id = PRESENTATION_TABLES.get(table, "")
+ dataflow_id = table_id.split("::")[0]
+ hierarchy_id = table_id.split("::", 1)[1] if "::" in table_id else None
+ params = metadata.get_dataflow_parameters(dataflow_id)
+ country_dim = "COUNTRY" if "COUNTRY" in params else "REF_AREA"
+ freq_dim = "FREQUENCY" if "FREQUENCY" in params else "FREQ"
+
+ # Get table structure and extract dimension codes (same as table_builder.get_table)
+ table_structure = metadata.get_dataflow_table_structure(
+ dataflow_id, hierarchy_id
+ )
+ dimension_codes: dict[str, list[str]] = {}
+ for entry in table_structure.get("indicators", []):
+ indicator_code = entry.get("indicator_code")
+ dimension_id = entry.get("dimension_id")
+ if indicator_code and dimension_id:
+ if dimension_id not in dimension_codes:
+ dimension_codes[dimension_id] = []
+ if indicator_code not in dimension_codes[dimension_id]:
+ dimension_codes[dimension_id].append(indicator_code)
+
+ pb = ImfParamsBuilder(dataflow_id=dataflow_id)
+ dims_in_order = pb._get_dimensions_in_order()
+
+ # Set dimensions in order, using table's indicator codes
+ for dim_id in dims_in_order:
+ if dim_id in dimension_codes:
+ codes = dimension_codes[dim_id]
+ joined = "+".join(codes)
+ if len(joined) > 800:
+ # Truncate to avoid URL length issues
+ joined = "+".join(codes[:20])
+ if len(joined) > 800:
+ joined = "*"
+ pb.set_dimension((dim_id, joined))
+ elif dim_id == country_dim:
+ pb.set_dimension((dim_id, str(country).replace(",", "+")))
+
+ options = pb.get_options_for_dimension(freq_dim) if freq_dim else []
+
+ return options
+
+ if (
+ dataflow_group is not None
+ and table is not None
+ and country is not None
+ and frequency is not None
+ ):
+ table_id = PRESENTATION_TABLES.get(table, "")
+ dataflow_id = table_id.split("::")[0]
+ hierarchy_id = table_id.split("::", 1)[1] if "::" in table_id else None
+ params = metadata.get_dataflow_parameters(dataflow_id)
+ country_dim = "COUNTRY" if "COUNTRY" in params else "REF_AREA"
+ freq_dim = "FREQUENCY" if "FREQUENCY" in params else "FREQ"
+
+ # Get table structure and extract dimension codes (same as table_builder.get_table)
+ table_structure = metadata.get_dataflow_table_structure(
+ dataflow_id, hierarchy_id
+ )
+ dimension_codes = {}
+ for entry in table_structure.get("indicators", []):
+ indicator_code = entry.get("indicator_code")
+ dimension_id = entry.get("dimension_id")
+ if indicator_code and dimension_id:
+ if dimension_id not in dimension_codes:
+ dimension_codes[dimension_id] = []
+ if indicator_code not in dimension_codes[dimension_id]:
+ dimension_codes[dimension_id].append(indicator_code)
+
+ pb = ImfParamsBuilder(dataflow_id=dataflow_id)
+ dims_in_order = pb._get_dimensions_in_order()
+
+ # Set dimensions in order, using table's indicator codes
+ for dim_id in dims_in_order:
+ if dim_id in dimension_codes:
+ codes = dimension_codes[dim_id]
+ joined = "+".join(codes)
+ if len(joined) > 800:
+ joined = "+".join(codes[:20])
+ if len(joined) > 800:
+ joined = "*"
+ pb.set_dimension((dim_id, joined))
+ elif dim_id == country_dim:
+ pb.set_dimension((dim_id, str(country).replace(",", "+")))
+ elif dim_id == freq_dim:
+ pb.set_dimension((dim_id, frequency))
+
+ transform_dim = (
+ "TYPE_OF_TRANSFORMATION" if "TYPE_OF_TRANSFORMATION" in params else None
+ )
+ options = pb.get_options_for_dimension(transform_dim) if transform_dim else []
+
+ return options
+
+ return choices
+
+
+@router.command(
+ methods=["GET"],
+ widget_config={
+ "title": "IMF Presentation Table",
+ "type": "html",
+ "params": [
+ {
+ "paramName": "dataflow_group",
+ "label": "Dataflow",
+ "value": None,
+ "type": "endpoint",
+ "optionsEndpoint": f"{api_prefix}/imf_utils/presentation_table_choices",
+ "description": "The IMF dataflow group.",
+ },
+ {
+ "paramName": "table",
+ "label": "Table",
+ "type": "endpoint",
+ "optionsEndpoint": f"{api_prefix}/imf_utils/presentation_table_choices",
+ "optionsParams": {
+ "dataflow_group": "$dataflow_group",
+ },
+ "description": "The IMF presentation table.",
+ },
+ {
+ "paramName": "country",
+ "label": "Country",
+ "description": "Country or region for the table.",
+ "type": "endpoint",
+ "multiSelect": True,
+ "optionsEndpoint": f"{api_prefix}/imf_utils/presentation_table_choices",
+ "optionsParams": {
+ "dataflow_group": "$dataflow_group",
+ "table": "$table",
+ "dimension_values": "$dimension_values",
+ },
+ },
+ {
+ "paramName": "frequency",
+ "label": "Frequency",
+ "type": "endpoint",
+ "optionsEndpoint": f"{api_prefix}/imf_utils/presentation_table_choices",
+ "optionsParams": {
+ "dataflow_group": "$dataflow_group",
+ "table": "$table",
+ "country": "$country",
+ "dimension_values": "$dimension_values",
+ },
+ "description": "The data frequency.",
+ },
+ {
+ "paramName": "dimension_values",
+ "label": "Dimension Values",
+ "type": "text",
+ "value": None,
+ "description": "Dimension selection for filtering. Format: 'DIM_ID1:VAL1+VAL2.'",
+ "multiple": True,
+ "multiSelect": False,
+ },
+ {
+ "paramName": "limit",
+ "label": "Limit",
+ "value": 3,
+ "description": "Most recent N records to retrieve per series.",
+ "type": "number",
+ },
+ {
+ "paramName": "raw",
+ "show": False,
+ },
+ ],
+ "raw": True,
+ "refetchInterval": False,
+ "name": "IMF Presentation Table",
+ "description": "Presentation tables from the IMF database.",
+ "source": ["IMF"],
+ "category": "IMF Utilities",
+ "subCategory": "Presentation Tables",
+ },
+ examples=[
+ APIEx(
+ description="Get the most recent Balance of Payments table for Japan.",
+ parameters={
+ "dataflow_group": "bop",
+ "table": "bop_standard",
+ "country": "JPN",
+ "frequency": "Q",
+ "limit": 4,
+ },
+ )
+ ],
+)
+async def presentation_table(
+ dataflow_group: Annotated[
+ str | None,
+ Query(
+ title="Dataflow Group",
+ description="The IMF dataflow group."
+ + " See presentation_table_choices() for options.",
+ ),
+ ] = None,
+ table: Annotated[
+ str | None,
+ Query(
+ title="Table",
+ description="The IMF presentation table ID."
+ + " See presentation_table_choices() for options.",
+ ),
+ ] = None,
+ country: Annotated[
+ str | None,
+ Query(
+ title="Country",
+ description="Country code to filter the data."
+ + " Enter multiple codes by joining on '+'. See presentation_table_choices() for options."
+ + " Typical values are ISO3 country codes.",
+ ),
+ ] = None,
+ frequency: Annotated[
+ str | None,
+ Query(
+ title="Frequency",
+ description="The data frequency. See presentation_table_choices() for options."
+ + " Typical values are 'A' (annual), 'Q' (quarter), 'M' (month), or 'D' (day).",
+ ),
+ ] = None,
+ dimension_values: Annotated[
+ list[str] | str | None,
+ Query(
+ title="Dimension Values",
+ description="Dimension selection for filtering. Format: 'DIM_ID1:VAL1+VAL2.'"
+ + " See presentation_table_choices() and list_dataflow_choices() for available dimensions and values.",
+ ),
+ ] = None,
+ limit: Annotated[
+ int,
+ Query(
+ title="Limit",
+ description="Maximum number of records to retrieve per series.",
+ ),
+ ] = 1,
+ raw: Annotated[
+ bool,
+ Query(
+ title="Raw Output",
+ description="Return presentation table as raw JSON data if True.",
+ ),
+ ] = False,
+) -> Any:
+ """Get a formatted presentation table from the IMF database. Returns as HTML or JSON list."""
+ # pylint: disable=import-outside-toplevel
+ import html as html_module
+
+ from openbb_imf.models.economic_indicators import ImfEconomicIndicatorsFetcher
+ from pandas import DataFrame
+
+ if dataflow_group is None or table is None:
+ raise OpenBBError(ValueError("Please enter a dataflow group and a table."))
+
+ if country is None or frequency is None:
+ raise OpenBBError(ValueError("Please enter a country and frequency."))
+
+ freq_map = {"A": "annual", "Q": "quarter", "M": "month", "D": "day"}
+ symbol = PRESENTATION_TABLES.get(table, "")
+ params = {
+ "symbol": symbol,
+ "country": country,
+ "limit": limit,
+ "frequency": freq_map.get(frequency, frequency),
+ "dimension_values": dimension_values,
+ "pivot": True,
+ }
+ results = await ImfEconomicIndicatorsFetcher.fetch_data(params, {})
+ results_json = [d.model_dump(mode="json", exclude_none=True) for d in results.result] # type: ignore
+
+ if raw is True:
+ return results_json
+
+ df = DataFrame(results_json).set_index(["title", "country"]).reset_index()
+ # Preserve leading whitespace by replacing double spaces with non-breaking spaces
+ df["title"] = df["title"].apply(
+ lambda x: x.replace(" ", "\u00a0\u00a0") if isinstance(x, str) else x
+ )
+
+ columns = df.columns.tolist()
+ header_cells = "".join(
+ f"
{html_module.escape(str(col))} | " for col in columns
+ )
+
+ def format_number(value):
+ """Format large numbers with K, M, B suffixes for readability."""
+ if isinstance(value, (int, float)):
+ abs_value = abs(value)
+ if abs_value >= 1_000_000_000:
+ return f"{value / 1_000_000_000:.2f}".rstrip("0").rstrip(".") + "B"
+ if abs_value >= 1_000_000:
+ return f"{value / 1_000_000:.2f}".rstrip("0").rstrip(".") + "M"
+ if abs_value >= 1_000:
+ return f"{value / 1_000:.2f}".rstrip("0").rstrip(".") + "K"
+ if isinstance(value, float):
+ return f"{value:.2f}".rstrip("0").rstrip(".")
+ return str(value)
+ return str(value)
+
+ # Build body rows
+ body_rows = ""
+ for _, row in df.iterrows():
+ cells = "".join(
+ f"{html_module.escape(format_number(row[col]))} | " for col in columns
+ )
+ body_rows += f"{cells}
"
+
+ interactive_html = f"""
+
+
+
+
+ IMF Presentation Table
+
+
+
+
+
+
+ {header_cells}
+ {body_rows}
+
+
+
+"""
+
+ return HTMLResponse(content=interactive_html)
+
+
+@router.command(
+ methods=["GET"],
+ widget_config={"exclude": True},
+ include_in_schema=False,
+ examples=[
+ APIEx(
+ description="Get progressive indicator choices for IMF data retrieval."
+ + " Start with the 'symbol' parameter to get all available countries."
+ + " Then progressively add parameters to narrow down choices.",
+ parameters={
+ "symbol": "QGFS::F4_L_T_XDC",
+ },
+ ),
+ ],
+)
+async def indicator_choices( # noqa: PLR0912
+ symbol: str | None = None,
+ country: str | None = None,
+ frequency: str | None = None,
+ transform: str | None = None,
+ dimension_values: list[str] | None = None,
+) -> list[dict[str, str]]:
+ """Get progressive indicator choices for IMF data retrieval.
+
+ This endpoint works progressively starting with the 'symbol' parameter,
+ which is required and in the format 'dataflow::indicator'.
+
+ Function is not intended to be used directly;
+ it is used by the OpenBB Workspace for progressive parameter selection.
+
+ For manual inspection, use the `get_dataflow_dimensions` endpoint instead.
+
+ Parameters
+ ----------
+ symbol : str | None
+ The IMF dataflow and indicator code in the format 'dataflow::indicator'.
+ No symbol will return an empty list. Use `economy/available_indicators` to see available symbols.
+ country : str | None
+ Enter a symbol and leave country as None to see country choices.
+ frequency : str | None
+ Enter a symbol and country to see frequency choices.
+ transform : str | None
+ Enter a symbol, country, and frequency to see transform choices.
+ dimension_values : list[str] | None
+ Additional dimension filters in 'DIM_ID:VALUE' format to constrain choices.
+
+ Returns
+ -------
+ list[dict[str, str]]
+ A list of dictionaries with 'label' and 'value' for each choice.
+ """
+ # pylint: disable=import-outside-toplevel
+ from urllib.parse import unquote
+
+ from openbb_imf.utils.helpers import detect_transform_dimension
+
+ metadata = ImfMetadata()
+
+ # Symbol is required and in format dataflow::indicator
+ if symbol is None:
+ return []
+
+ # URL-decode the symbol parameter and handle multiple comma-separated symbols
+ symbol = unquote(symbol)
+
+ # Parse multiple symbols (comma-separated): "QGFS::F4_L_T_XDC,QGFS::F12_L_T_XDC"
+ symbols = [s.strip() for s in symbol.split(",") if s.strip()]
+ if not symbols:
+ return []
+
+ # Extract unique dataflows and all indicator codes
+ dataflows_seen: set[str] = set()
+ indicator_codes: list[str] = []
+
+ for sym in symbols:
+ if "::" in sym:
+ df_id = sym.split("::")[0].strip()
+ ind_code = sym.split("::", 1)[1].strip()
+ dataflows_seen.add(df_id)
+ if ind_code:
+ indicator_codes.append(ind_code)
+ else:
+ # Just a dataflow ID with no indicator
+ dataflows_seen.add(sym.strip())
+
+ # For now, only support single dataflow (use first one)
+ dataflow_id = list(dataflows_seen)[0] if dataflows_seen else None
+ indicator_code = "+".join(indicator_codes) if indicator_codes else None
+
+ if not dataflow_id:
+ return []
+
+ # Get dimension order for this dataflow
+ df_obj = metadata.dataflows.get(dataflow_id, {})
+
+ if not df_obj:
+ return []
+
+ dsd_id = df_obj.get("structureRef", {}).get("id")
+ dsd = metadata.datastructures.get(dsd_id, {})
+ dimensions = dsd.get("dimensions", [])
+
+ # Sort by position
+ sorted_dims = sorted(
+ [d for d in dimensions if d.get("id") != "TIME_PERIOD"],
+ key=lambda x: int(x.get("position", 0)),
+ )
+ dim_order = [d["id"] for d in sorted_dims]
+
+ # Get codelist labels for all dimensions
+ params = metadata.get_dataflow_parameters(dataflow_id)
+
+ # Identify dimension types
+ country_dim = "COUNTRY" if "COUNTRY" in dim_order else "REF_AREA"
+ freq_dim = "FREQUENCY" if "FREQUENCY" in dim_order else "FREQ"
+ transform_dim, unit_dim, _, _ = detect_transform_dimension(dataflow_id)
+ # Use UNIT dimension as fallback for transform if no transform dimension exists
+ effective_transform_dim = transform_dim or unit_dim
+
+ # Parse dimension_values into a dict of DIM_ID -> VALUE
+ # Input format: list of "DIM_ID:VALUE" strings
+ extra_dimensions: dict[str, str] = {}
+ if dimension_values:
+ for dv in dimension_values:
+ if not dv or not isinstance(dv, str):
+ continue
+ if ":" in dv:
+ dim_id, dim_value = dv.split(":", 1)
+ extra_dimensions[dim_id.strip().upper()] = dim_value.strip().upper()
+
+ # dimension_values OVERRIDES parameter values for country/frequency/transform
+ # Check if any country dimension is in extra_dimensions
+ for cdim in ("COUNTRY", "REF_AREA", "JURISDICTION", "AREA"):
+ if cdim in extra_dimensions:
+ country = extra_dimensions.pop(cdim)
+ break
+ # Check if frequency dimension is in extra_dimensions
+ for fdim in ("FREQUENCY", "FREQ"):
+ if fdim in extra_dimensions:
+ frequency = extra_dimensions.pop(fdim)
+ break
+ # Check if transform dimension is in extra_dimensions
+ for tdim in ("UNIT_MEASURE", "UNIT", "TRANSFORMATION"):
+ if tdim in extra_dimensions:
+ transform = extra_dimensions.pop(tdim)
+ break
+
+ # Find indicator dimension - check which dimension contains the indicator_code
+ # This list should include all possible indicator-type dimensions across dataflows
+ indicator_dims = [
+ "INDICATOR",
+ "INDEX_TYPE",
+ "COICOP_1999",
+ "SERIES",
+ "ITEM",
+ "BOP_ACCOUNTING_ENTRY",
+ "ACCOUNTING_ENTRY",
+ "PRODUCTION_INDEX",
+ ]
+
+ # If we have indicator_code(s), find which dimension they belong to
+ # indicator_code may be "+" joined (e.g., "F4_L_T_XDC+F12_L_T_XDC")
+ indicator_dim = None
+ first_indicator = indicator_code.split("+")[0] if indicator_code else None
+ if first_indicator:
+ for dim_id in indicator_dims:
+ if dim_id in dim_order:
+ dim_values = {p.get("value") for p in params.get(dim_id, [])}
+ if first_indicator in dim_values:
+ indicator_dim = dim_id
+ break
+
+ # If still not found, search ALL dimensions for the indicator code
+ if indicator_dim is None:
+ for dim_id in dim_order:
+ if dim_id in (
+ country_dim,
+ freq_dim,
+ transform_dim,
+ unit_dim,
+ "TIME_PERIOD",
+ ):
+ continue # Skip known non-indicator dimensions
+ dim_values = {p.get("value") for p in params.get(dim_id, [])}
+ if first_indicator in dim_values:
+ indicator_dim = dim_id
+ break
+
+ # Fallback to first available indicator dimension if not found
+ if indicator_dim is None:
+ indicator_dim = next((d for d in indicator_dims if d in dim_order), None)
+
+ def build_key_with_indicator(target_dim: str) -> str:
+ """Build constraint key with indicator always set, targeting a specific dimension.
+
+ This builds a full key for all dimensions, with the target dimension as wildcard
+ and the indicator dimension set to the indicator code (if available).
+ This allows querying for available values of the target dimension filtered by indicator.
+ """
+ key_parts: list[str] = []
+ for dim_id in dim_order:
+ if dim_id == target_dim:
+ # Target dimension gets wildcard - we want to know available values
+ key_parts.append("*")
+ elif dim_id == country_dim:
+ key_parts.append(str(country).replace(",", "+") if country else "*")
+ elif dim_id == indicator_dim:
+ # Always include indicator code if available
+ key_parts.append(indicator_code if indicator_code else "*")
+ elif dim_id == freq_dim:
+ key_parts.append(str(frequency) if frequency else "*")
+ elif dim_id in (transform_dim, unit_dim):
+ key_parts.append(
+ str(transform) if transform and transform != "true" else "*"
+ )
+ elif dim_id in extra_dimensions:
+ # Use value from dimension_values if provided
+ key_parts.append(extra_dimensions[dim_id])
+ else:
+ key_parts.append("*")
+
+ return ".".join(key_parts)
+
+ def get_choices_for_dim(dim_id: str) -> list:
+ """Get available choices for a dimension using constraints API."""
+ key = build_key_with_indicator(dim_id)
+ constraints = metadata.get_available_constraints(
+ dataflow_id=dataflow_id,
+ key=key,
+ component_id=dim_id,
+ )
+ # Get labels from params
+ labels = {opt["value"]: opt["label"] for opt in params.get(dim_id, [])}
+ # Also try to get labels from codelist
+ codelist_labels: dict = {}
+ dim_meta: dict = next((d for d in sorted_dims if d.get("id") == dim_id), {})
+
+ if dim_meta:
+ codelist_id = metadata._resolve_codelist_id(
+ dataflow_id, dsd_id, dim_id, dim_meta
+ )
+
+ if codelist_id and codelist_id in metadata._codelist_cache:
+ codelist_labels = metadata._codelist_cache.get(codelist_id, {})
+
+ choices: list = []
+
+ for kv in constraints.get("key_values", []):
+ if kv.get("id") == dim_id:
+ for value in kv.get("values", []):
+ # Try params first, then codelist, then fall back to value
+ label = labels.get(value) or codelist_labels.get(value) or value
+ choices.append({"label": label, "value": value})
+
+ return choices
+
+ # Step 1: No country selected - return country choices filtered by indicator
+ if country == "true" and country_dim:
+ choices = get_choices_for_dim(country_dim)
+ choices = sorted(choices, key=lambda x: x["label"])
+ choices.insert(0, {"label": "All Countries", "value": "*"})
+ return choices
+
+ # Step 2: Country selected, no frequency - return frequency choices
+ if frequency == "true" and freq_dim:
+ return get_choices_for_dim(freq_dim)
+
+ # Step 3: Frequency selected, no transform - return transform choices
+ if transform == "true" and effective_transform_dim:
+ choices = get_choices_for_dim(effective_transform_dim)
+ # Add "all" option at the beginning if there are choices
+ if choices:
+ choices.insert(0, {"label": "All", "value": "all"})
+ return choices
+
+ # All parameters set - no more choices needed
+ return []
+
+
+async def get_imf_utils_apps_json() -> list[dict[str, Any]]:
+ """Get the IMF apps.json file.
+
+ This endpoint serves the apps.json file containing OpenBB Workspace app configurations
+ related to IMF data and utilities.
+
+ It is automatically merged with any existing apps.json files in the Workspace and API.
+
+ Returns
+ -------
+ list[dict[str, Any]]
+ A list of OpenBB Workspace app configurations.
+ """
+ # pylint: disable=import-outside-toplevel
+ import json
+ from pathlib import Path
+
+ apps_file = Path(__file__).parent / "apps.json"
+
+ try:
+ with apps_file.open("r", encoding="utf-8") as f:
+ apps_json = json.load(f)
+ return apps_json
+ except Exception:
+ return []
+
+
+router._api_router.add_api_route(
+ path="/apps.json",
+ endpoint=get_imf_utils_apps_json,
+ methods=["GET"],
+ include_in_schema=False,
+)
diff --git a/openbb_platform/providers/imf/openbb_imf/models/available_indicators.py b/openbb_platform/providers/imf/openbb_imf/models/available_indicators.py
index 555420cd191..d4cae71285f 100644
--- a/openbb_platform/providers/imf/openbb_imf/models/available_indicators.py
+++ b/openbb_platform/providers/imf/openbb_imf/models/available_indicators.py
@@ -10,18 +10,57 @@ from openbb_core.provider.standard_models.available_indicators import (
AvailableIndicatorsData,
AvailableIndicesQueryParams,
)
-from pydantic import Field
+from pydantic import ConfigDict, Field
class ImfAvailableIndicatorsQueryParams(AvailableIndicesQueryParams):
"""IMF Available Indicators Query Parameters."""
- __json_schema_extra__ = {"query": {"multiple_items_allowed": True}}
+ __json_schema_extra__ = {
+ "query": {"multiple_items_allowed": True},
+ "dataflows": {
+ "multiple_items_allowed": True,
+ "x-widget_config": {
+ "type": "endpoint",
+ "optionsEndpoint": "/api/v1/imf_utils/list_dataflow_choices",
+ "multiSelect": False,
+ "style": {"popupWidth": 950},
+ },
+ },
+ "symbol": {
+ "multiple_items_allowed": True,
+ "x-widget_config": {
+ "type": "text",
+ "multiSelect": False,
+ "style": {"width": 400},
+ "multiple": True,
+ },
+ },
+ "keywords": {"multiple_items_allowed": True},
+ }
query: str | None = Field(
default=None,
- description="The query string to search through the available indicators."
- + " Use semicolons to separate multiple terms.",
+ description="The search query string. Multiple search phrases can be separated by semicolons."
+ + " Each phrase can use AND (+) and OR (|) operators, as well as quoted phrases."
+ + " Semicolon separation allows commas to be used within search phrases.",
+ )
+ dataflows: str | list[str] | None = Field(
+ default=None,
+ description="List of IMF dataflow IDs to filter the indicators."
+ + " Use semicolons to separate multiple dataflow IDs.",
+ )
+ keywords: str | list[str] | None = Field(
+ default=None,
+ description="List of keywords to filter results. Each keyword is a single word that must"
+ + " appear in the indicator's label or description. Keywords prefixed with 'not'"
+ + " will exclude indicators containing that word (e.g., 'not USD' excludes indicators"
+ + " with 'USD' in them).",
+ )
+ symbol: str | None = Field(
+ default=None,
+ exclude=True,
+ description="Dummy field to allow grouping by symbol.",
)
@@ -29,32 +68,46 @@ class ImfAvailableIndicatorsData(AvailableIndicatorsData):
"""IMF Available Indicators Data."""
__alias_dict__ = {
- "symbol_root": "parent",
- "description": "title",
+ "description": "label",
+ "symbol": "series_id",
+ "symbol_root": "indicator",
+ "long_description": "description",
}
- dataset: str | None = Field(
- default=None,
- description="The IMF dataset associated with the symbol.",
+
+ model_config = ConfigDict(
+ extra="ignore",
+ json_schema_extra={
+ "symbol": {
+ "x-widget_config": {
+ "renderFn": "cellOnClick",
+ "renderFnParams": {
+ "actionType": "groupBy",
+ "groupByParamName": "symbol",
+ },
+ }
+ }
+ },
)
- table: str | None = Field(
- default=None,
- description="The name of the table associated with the symbol.",
+
+ agency_id: str = Field(description="The agency ID responsible for the indicator.")
+ dataflow_id: str = Field(
+ description="The IMF dataflow ID associated with the indicator."
)
- level: int | None = Field(
- default=None,
- description="The indentation level of the data, relative to the table and symbol_root",
+ dataflow_name: str = Field(
+ description="The name of the IMF dataflow (symbol root)."
)
- order: int | float | None = Field(
- default=None,
- description="Order of the data, relative to the table.",
+ structure_id: str = Field(
+ description="The data structure ID associated with the indicator."
)
- children: str | None = Field(
- default=None,
- description="The symbol of the child data, if any.",
+ dimension_id: str = Field(
+ description="The dimension ID of the indicator in the data structure."
)
- unit: str | None = Field(
- default=None,
- description="The unit of the data.",
+ long_description: str | None = Field(
+ default=None, description="Detailed description of the indicator."
+ )
+ member_of: list[str] = Field(
+ default_factory=list,
+ description="List of table symbols (dataflow_id::table_id) this indicator belongs to.",
)
@@ -76,45 +129,38 @@ class ImfAvailableIndicatorsFetcher(
) -> list[dict]:
"""Fetch the data."""
# pylint: disable=import-outside-toplevel
- from numpy import nan
from openbb_core.provider.utils.errors import EmptyDataError
- from openbb_imf.utils.constants import load_symbols
- from pandas import DataFrame, Series
+ from openbb_imf.utils.metadata import ImfMetadata
+
+ metadata = ImfMetadata()
+
+ if isinstance(query.dataflows, str):
+ dataflows = query.dataflows.split(",")
+ elif isinstance(query.dataflows, list):
+ dataflows = query.dataflows
+ else:
+ dataflows = None
+
+ if isinstance(query.keywords, str):
+ keywords = query.keywords.split(",")
+ elif isinstance(query.keywords, list):
+ keywords = query.keywords
+ else:
+ keywords = None
try:
- all_symbols = load_symbols("all")
- except OpenBBError as e:
- raise OpenBBError(f"Failed to load IMF symbols static file: {e}") from e
+ results = metadata.search_indicators(
+ query=query.query.replace(",", ", ") if query.query else "",
+ dataflows=dataflows,
+ keywords=keywords,
+ )
+ except Exception as e: # pylint: disable=broad-except
+ raise OpenBBError(e) from e
- terms = [term.strip() for term in query.query.split(";")] if query.query else []
+ if not results:
+ raise EmptyDataError("No indicators found for the given query.")
- df = (
- DataFrame(all_symbols)
- .T.reset_index()
- .rename(columns={"index": "symbol"})
- .replace({nan: None})
- )
-
- if not terms:
- records = df.to_dict(orient="records")
- else:
- combined_mask = Series([True] * len(df))
- for term in terms:
- mask = df.apply(
- lambda row, term=term: row.astype(str).str.contains(
- term, case=False, regex=True, na=False
- )
- ).any(axis=1)
- combined_mask &= mask
-
- matches = df[combined_mask]
-
- if matches.empty:
- raise EmptyDataError("No results found for the provided query.")
-
- records = matches.to_dict(orient="records")
-
- return records
+ return results
@staticmethod
def transform_data(
@@ -123,4 +169,11 @@ class ImfAvailableIndicatorsFetcher(
**kwargs: Any,
) -> list[ImfAvailableIndicatorsData]:
"""Transform the data."""
- return [ImfAvailableIndicatorsData.model_validate(d) for d in data]
+ results = []
+ for d in data:
+ # Build the ready-to-use symbol: dataflow_id::indicator_code
+ dataflow_id = d.get("dataflow_id", "")
+ indicator_code = d.get("indicator", "")
+ d["symbol"] = f"{dataflow_id}::{indicator_code}"
+ results.append(ImfAvailableIndicatorsData.model_validate(d))
+ return results
diff --git a/openbb_platform/providers/imf/openbb_imf/models/consumer_price_index.py b/openbb_platform/providers/imf/openbb_imf/models/consumer_price_index.py
new file mode 100644
index 00000000000..58d6a11bf15
--- /dev/null
+++ b/openbb_platform/providers/imf/openbb_imf/models/consumer_price_index.py
@@ -0,0 +1,588 @@
+"""IMF CPI Data."""
+
+# pylint: disable=unused-argument
+
+from typing import Any
+
+from openbb_core.app.model.abstract.error import OpenBBError
+from openbb_core.provider.abstract.annotated_result import AnnotatedResult
+from openbb_core.provider.abstract.fetcher import Fetcher
+from openbb_core.provider.standard_models.consumer_price_index import (
+ ConsumerPriceIndexData,
+ ConsumerPriceIndexQueryParams,
+)
+from openbb_imf.utils.helpers import normalize_country_label
+from openbb_imf.utils.query_builder import ImfQueryBuilder
+from pydantic import Field, field_validator
+
+CpiCountries = [
+ {"label": "Afghanistan", "value": "AFG"},
+ {"label": "Albania", "value": "ALB"},
+ {"label": "Algeria", "value": "DZA"},
+ {"label": "Angola", "value": "AGO"},
+ {"label": "Anguilla, United Kingdom-British Overseas Territory", "value": "AIA"},
+ {"label": "Antigua and Barbuda", "value": "ATG"},
+ {"label": "Argentina", "value": "ARG"},
+ {"label": "Armenia, Republic of", "value": "ARM"},
+ {"label": "Aruba, Kingdom of the Netherlands", "value": "ABW"},
+ {"label": "Australia", "value": "AUS"},
+ {"label": "Austria", "value": "AUT"},
+ {"label": "Azerbaijan, Republic of", "value": "AZE"},
+ {"label": "Bahamas, The", "value": "BHS"},
+ {"label": "Bahrain", "value": "BHR"},
+ {"label": "Bangladesh", "value": "BGD"},
+ {"label": "Barbados", "value": "BRB"},
+ {"label": "Belarus, Republic of", "value": "BLR"},
+ {"label": "Belgium", "value": "BEL"},
+ {"label": "Belize", "value": "BLZ"},
+ {"label": "Benin", "value": "BEN"},
+ {"label": "Bhutan", "value": "BTN"},
+ {"label": "Bolivia", "value": "BOL"},
+ {"label": "Bosnia and Herzegovina", "value": "BIH"},
+ {"label": "Botswana", "value": "BWA"},
+ {"label": "Brazil", "value": "BRA"},
+ {"label": "British Virgin Islands", "value": "VGB"},
+ {"label": "Brunei Darussalam", "value": "BRN"},
+ {"label": "Bulgaria", "value": "BGR"},
+ {"label": "Burkina Faso", "value": "BFA"},
+ {"label": "Burundi", "value": "BDI"},
+ {"label": "Cabo Verde", "value": "CPV"},
+ {"label": "Cambodia", "value": "KHM"},
+ {"label": "Cameroon", "value": "CMR"},
+ {"label": "Canada", "value": "CAN"},
+ {"label": "Cayman Islands", "value": "CYM"},
+ {"label": "Central African Republic", "value": "CAF"},
+ {"label": "Chad", "value": "TCD"},
+ {"label": "Chile", "value": "CHL"},
+ {"label": "China", "value": "CHN"},
+ {"label": "Colombia", "value": "COL"},
+ {"label": "Comoros", "value": "COM"},
+ {"label": "Congo", "value": "COG"},
+ {"label": "Costa Rica", "value": "CRI"},
+ {"label": "Croatia, Republic of", "value": "HRV"},
+ {"label": "Curacao", "value": "CUW"},
+ {"label": "Cyprus", "value": "CYP"},
+ {"label": "Czech Republic", "value": "CZE"},
+ {"label": "Democratic Republic of the Congo", "value": "COD"},
+ {"label": "Denmark", "value": "DNK"},
+ {"label": "Djibouti", "value": "DJI"},
+ {"label": "Dominica", "value": "DMA"},
+ {"label": "Dominican Republic", "value": "DOM"},
+ {"label": "Ecuador", "value": "ECU"},
+ {"label": "Egypt", "value": "EGY"},
+ {"label": "El Salvador", "value": "SLV"},
+ {"label": "Equatorial Guinea, Republic of", "value": "GNQ"},
+ {"label": "Estonia, Republic of", "value": "EST"},
+ {"label": "Eswatini, Kingdom of", "value": "SWZ"},
+ {"label": "Ethiopia", "value": "ETH"},
+ {"label": "Euro Area (EA)", "value": "G163"},
+ {"label": "Fiji, Republic of", "value": "FJI"},
+ {"label": "Finland", "value": "FIN"},
+ {"label": "France", "value": "FRA"},
+ {"label": "Gabon", "value": "GAB"},
+ {"label": "Gambia", "value": "GMB"},
+ {"label": "Georgia", "value": "GEO"},
+ {"label": "Germany", "value": "DEU"},
+ {"label": "Ghana", "value": "GHA"},
+ {"label": "Greece", "value": "GRC"},
+ {"label": "Greenland", "value": "GRL"},
+ {"label": "Grenada", "value": "GRD"},
+ {"label": "Guadeloupe", "value": "GLP"},
+ {"label": "Guatemala", "value": "GTM"},
+ {"label": "Guinea", "value": "GIN"},
+ {"label": "Guinea-Bissau", "value": "GNB"},
+ {"label": "Guyana", "value": "GUY"},
+ {"label": "Haiti", "value": "HTI"},
+ {"label": "Honduras", "value": "HND"},
+ {"label": "Hong Kong", "value": "HKG"},
+ {"label": "Hungary", "value": "HUN"},
+ {"label": "Iceland", "value": "ISL"},
+ {"label": "India", "value": "IND"},
+ {"label": "Indonesia", "value": "IDN"},
+ {"label": "Iran", "value": "IRN"},
+ {"label": "Iraq", "value": "IRQ"},
+ {"label": "Ireland", "value": "IRL"},
+ {"label": "Israel", "value": "ISR"},
+ {"label": "Italy", "value": "ITA"},
+ {"label": "Ivory Coast", "value": "CIV"},
+ {"label": "Jamaica", "value": "JAM"},
+ {"label": "Japan", "value": "JPN"},
+ {"label": "Jordan", "value": "JOR"},
+ {"label": "Kazakhstan", "value": "KAZ"},
+ {"label": "Kenya", "value": "KEN"},
+ {"label": "Kiribati", "value": "KIR"},
+ {"label": "Kosovo", "value": "KOS"},
+ {"label": "Kuwait", "value": "KWT"},
+ {"label": "Kyrgyz Republic", "value": "KGZ"},
+ {"label": "Lao", "value": "LAO"},
+ {"label": "Latvia, Republic of", "value": "LVA"},
+ {"label": "Lebanon", "value": "LBN"},
+ {"label": "Lesotho", "value": "LSO"},
+ {"label": "Liberia", "value": "LBR"},
+ {"label": "Libya", "value": "LBY"},
+ {"label": "Lithuania, Republic of", "value": "LTU"},
+ {"label": "Luxembourg", "value": "LUX"},
+ {"label": "Macao", "value": "MAC"},
+ {"label": "Madagascar", "value": "MDG"},
+ {"label": "Malawi", "value": "MWI"},
+ {"label": "Malaysia", "value": "MYS"},
+ {"label": "Maldives", "value": "MDV"},
+ {"label": "Mali", "value": "MLI"},
+ {"label": "Malta", "value": "MLT"},
+ {"label": "Martinique", "value": "MTQ"},
+ {"label": "Mauritania", "value": "MRT"},
+ {"label": "Mauritius", "value": "MUS"},
+ {"label": "Mexico", "value": "MEX"},
+ {"label": "Micronesia, Federated States of", "value": "FSM"},
+ {"label": "Moldova, Republic of", "value": "MDA"},
+ {"label": "Mongolia", "value": "MNG"},
+ {"label": "Montenegro", "value": "MNE"},
+ {"label": "Montserrat", "value": "MSR"},
+ {"label": "Morocco", "value": "MAR"},
+ {"label": "Mozambique", "value": "MOZ"},
+ {"label": "Myanmar", "value": "MMR"},
+ {"label": "Namibia", "value": "NAM"},
+ {"label": "Nauru, Republic of", "value": "NRU"},
+ {"label": "Nepal", "value": "NPL"},
+ {"label": "Netherlands", "value": "NLD"},
+ {"label": "Netherlands Antilles", "value": "ANT"},
+ {"label": "New Caledonia", "value": "NCL"},
+ {"label": "New Zealand", "value": "NZL"},
+ {"label": "Nicaragua", "value": "NIC"},
+ {"label": "Niger", "value": "NER"},
+ {"label": "Nigeria", "value": "NGA"},
+ {"label": "North Macedonia", "value": "MKD"},
+ {"label": "Norway", "value": "NOR"},
+ {"label": "Oman", "value": "OMN"},
+ {"label": "Pakistan", "value": "PAK"},
+ {"label": "Palau", "value": "PLW"},
+ {"label": "Palestine", "value": "WBG"},
+ {"label": "Panama", "value": "PAN"},
+ {"label": "Papua New Guinea", "value": "PNG"},
+ {"label": "Paraguay", "value": "PRY"},
+ {"label": "Peru", "value": "PER"},
+ {"label": "Philippines", "value": "PHL"},
+ {"label": "Poland", "value": "POL"},
+ {"label": "Portugal", "value": "PRT"},
+ {"label": "Qatar", "value": "QAT"},
+ {"label": "Romania", "value": "ROU"},
+ {"label": "Russia", "value": "RUS"},
+ {"label": "Rwanda", "value": "RWA"},
+ {"label": "Saint Kitts and Nevis", "value": "KNA"},
+ {"label": "Saint Lucia", "value": "LCA"},
+ {"label": "Saint Vincent and the Grenadines", "value": "VCT"},
+ {"label": "Samoa", "value": "WSM"},
+ {"label": "San Marino", "value": "SMR"},
+ {"label": "Sao Tome and Principe", "value": "STP"},
+ {"label": "Saudi Arabia", "value": "SAU"},
+ {"label": "Senegal", "value": "SEN"},
+ {"label": "Serbia", "value": "SRB"},
+ {"label": "Seychelles", "value": "SYC"},
+ {"label": "Sierra Leone", "value": "SLE"},
+ {"label": "Singapore", "value": "SGP"},
+ {"label": "Sint Maarten (Dutch part)", "value": "SXM"},
+ {"label": "Slovak Republic", "value": "SVK"},
+ {"label": "Slovenia, Republic of", "value": "SVN"},
+ {"label": "Solomon Islands", "value": "SLB"},
+ {"label": "Somalia", "value": "SOM"},
+ {"label": "South Africa", "value": "ZAF"},
+ {"label": "South Korea", "value": "KOR"},
+ {"label": "South Sudan", "value": "SSD"},
+ {"label": "Spain", "value": "ESP"},
+ {"label": "Sri Lanka", "value": "LKA"},
+ {"label": "Sudan", "value": "SDN"},
+ {"label": "Suriname", "value": "SUR"},
+ {"label": "Sweden", "value": "SWE"},
+ {"label": "Switzerland", "value": "CHE"},
+ {"label": "Syria", "value": "SYR"},
+ {"label": "Tajikistan", "value": "TJK"},
+ {"label": "Tanzania", "value": "TZA"},
+ {"label": "Thailand", "value": "THA"},
+ {"label": "Timor-Leste", "value": "TLS"},
+ {"label": "Togo", "value": "TGO"},
+ {"label": "Tonga", "value": "TON"},
+ {"label": "Trinidad and Tobago", "value": "TTO"},
+ {"label": "Tunisia", "value": "TUN"},
+ {"label": "Turkey", "value": "TUR"},
+ {"label": "Tuvalu", "value": "TUV"},
+ {"label": "Uganda", "value": "UGA"},
+ {"label": "Ukraine", "value": "UKR"},
+ {"label": "United Arab Emirates", "value": "ARE"},
+ {"label": "United Kingdom", "value": "GBR"},
+ {"label": "United States", "value": "USA"},
+ {"label": "Uruguay", "value": "URY"},
+ {"label": "Uzbekistan", "value": "UZB"},
+ {"label": "Vanuatu", "value": "VUT"},
+ {"label": "Venezuela", "value": "VEN"},
+ {"label": "Viet Nam", "value": "VNM"},
+ {"label": "Yemen", "value": "YEM"},
+ {"label": "Zambia", "value": "ZMB"},
+ {"label": "Zimbabwe", "value": "ZWE"},
+ {"label": "All", "value": "*"},
+]
+CPI_LABEL_TO_CODE: dict[str, str] = {
+ normalize_country_label(item["label"]): item["value"] for item in CpiCountries
+}
+CPI_CODE_TO_LABEL: dict[str, str] = {
+ item["value"]: normalize_country_label(item["label"]) for item in CpiCountries
+}
+CPI_CODE_SET: set[str] = {item["value"] for item in CpiCountries}
+transformation_map: dict = {
+ "index": "IX",
+ "period": "POP_PCH_PA_PT",
+ "yoy": "YOY_PCH_PA_PT",
+ "ref_index": "SRP_IX",
+ "ref_period": "SRP_POP_PCH_PA_PT",
+ "ref_yoy": "SRP_YOY_PCH_PA_PT",
+ "weight": "WGT",
+ "weight_percent": "WGT_PT",
+}
+transformation_choices = [
+ "index",
+ "period",
+ "yoy",
+ "ref_index",
+ "ref_period",
+ "ref_yoy",
+ "weight",
+ "weight_percent",
+]
+expenditure_dict_rev: dict = {
+ "_T": "total",
+ "CP01": "food_non_alcoholic_beverages",
+ "CP02": "alcoholic_beverages_tobacco_narcotics",
+ "CP03": "clothing_footwear",
+ "CP04": "housing_water_electricity_gas",
+ "CP05": "furniture_household_equipment",
+ "CP06": "health",
+ "CP07": "transport",
+ "CP08": "communication",
+ "CP09": "recreation_culture",
+ "CP10": "education",
+ "CP11": "restaurants_hotels",
+ "CP12": "miscellaneous_goods_services",
+ "*": "all",
+}
+expenditure_dict: dict = {v: k for k, v in expenditure_dict_rev.items()}
+
+# Order mapping for sorting expenditure categories (COICOP codes)
+# _T (total) comes first, then CP01-CP12 in numerical order
+expenditure_order: dict[str, int] = {
+ "_T": 0,
+ "CP01": 1,
+ "CP02": 2,
+ "CP03": 3,
+ "CP04": 4,
+ "CP05": 5,
+ "CP06": 6,
+ "CP07": 7,
+ "CP08": 8,
+ "CP09": 9,
+ "CP10": 10,
+ "CP11": 11,
+ "CP12": 12,
+ "CP13": 13,
+ "CP14": 14,
+}
+expenditure_choices = [
+ "total",
+ "all",
+ "food_non_alcoholic_beverages",
+ "alcoholic_beverages_tobacco_narcotics",
+ "clothing_footwear",
+ "housing_water_electricity_gas",
+ "furniture_household_equipment",
+ "health",
+ "transport",
+ "communication",
+ "recreation_culture",
+ "education",
+ "restaurants_hotels",
+ "miscellaneous_goods_services",
+]
+
+
+class ImfConsumerPriceIndexQueryParams(ConsumerPriceIndexQueryParams):
+ """IMF CPI Query Params Model."""
+
+ __json_schema_extra__ = {
+ "country": {
+ "multiple_items_allowed": True,
+ "choices": list(CPI_LABEL_TO_CODE),
+ },
+ "transform": {
+ "multiple_items_allowed": False,
+ "choices": transformation_choices,
+ },
+ "expenditure": {
+ "multiple_items_allowed": True,
+ "choices": expenditure_choices,
+ },
+ }
+
+ expenditure: str = Field(
+ default="total", description="Expenditure component of CPI."
+ )
+
+ limit: int | None = Field(
+ default=None,
+ description="Maximum number of records to retrieve per series and country."
+ + " If None, retrieves all available records.",
+ )
+
+ @field_validator("country", mode="before", check_fields=False)
+ @classmethod
+ def validate_country(cls, c):
+ """Validate country.
+
+ Accepts both ISO3 codes (e.g., "USA") and snake_case country names
+ (e.g., "united_states"). Converts names to ISO3 codes.
+ """
+ result: list = []
+ values = c.replace(" ", "_").split(",")
+ for v in values:
+ v_upper = v.upper()
+ v_lower = v.lower()
+ # Check if it's a valid ISO3 code
+ if v_upper in CPI_CODE_SET:
+ result.append(v_upper)
+ # Check if it's a valid snake_case country name
+ elif v_lower in CPI_LABEL_TO_CODE:
+ result.append(CPI_LABEL_TO_CODE[v_lower])
+ else:
+ raise ValueError(
+ f"Country '{v}' is not a valid IMF country code (ISO3) or country name."
+ )
+
+ return ",".join(result)
+
+ @field_validator("expenditure", mode="before", check_fields=False)
+ @classmethod
+ def validate_expenditure(cls, v):
+ """Validate expenditure."""
+ expenditures = v.replace(" ", "_").split(",")
+ new_expenditures: list[str] = []
+ for e in expenditures:
+ if (
+ e.lower() not in expenditure_choices
+ and e.upper() not in expenditure_dict_rev
+ ):
+ raise ValueError(
+ f"Expenditure '{e}' is not a valid choice. Valid choices:\n\n{expenditure_choices}"
+ )
+ new_expenditures.append(e.lower())
+ return ",".join(new_expenditures)
+
+
+class ImfConsumerPriceIndexData(ConsumerPriceIndexData):
+ """IMF CPI Data Model."""
+
+ unit: str = Field(
+ description="Unit of measurement.",
+ )
+ unit_multiplier: int | float = Field(
+ description="Unit multiplier for the observation value.",
+ )
+ country_code: str = Field(
+ description="ISO3 country code.",
+ )
+ series_id: str = Field(
+ description="IMF series identifier.",
+ )
+ expenditure: str = Field(
+ description="Expenditure category.",
+ )
+ title: str = Field(
+ description="Complete reference title for the series.",
+ )
+ order: int | None = Field(
+ default=None,
+ description="Sort order for expenditure categories and table presentations.",
+ )
+
+
+class ImfConsumerPriceIndexFetcher(
+ Fetcher[ImfConsumerPriceIndexQueryParams, list[ImfConsumerPriceIndexData]]
+):
+ """IMF CPI Fetcher."""
+
+ @staticmethod
+ def transform_query(
+ params: dict[str, Any],
+ ) -> ImfConsumerPriceIndexQueryParams:
+ """Transform query."""
+ return ImfConsumerPriceIndexQueryParams(**params)
+
+ @staticmethod
+ async def aextract_data(
+ query: ImfConsumerPriceIndexQueryParams,
+ credentials: dict[str, str] | None,
+ **kwargs: Any,
+ ) -> dict:
+ """Extract data."""
+ countries = query.country.split(",")
+ countries_str = (
+ "*" if "*" in countries else "+".join([c.upper() for c in countries])
+ )
+ index_type = "HICP" if query.harmonized is True else "CPI"
+ expenditures = query.expenditure.split(",") if query.expenditure else ["total"]
+ expenditures_str = (
+ "*"
+ if "all" in expenditures
+ else "+".join(
+ [
+ (
+ e.upper()
+ if e.upper() in expenditure_dict_rev
+ else expenditure_dict[e]
+ )
+ for e in expenditures
+ ]
+ )
+ )
+ parameters: dict = {
+ "COUNTRY": countries_str,
+ "INDEX_TYPE": index_type,
+ "COICOP_1999": expenditures_str,
+ "TYPE_OF_TRANSFORMATION": transformation_map[query.transform],
+ "FREQUENCY": query.frequency[0].upper(),
+ }
+ query_builder = ImfQueryBuilder()
+
+ # Mappings from IMF dimension codes to user-friendly parameter names
+ dim_to_param = {
+ "COUNTRY": "country",
+ "INDEX_TYPE": "harmonized",
+ "COICOP_1999": "expenditure",
+ "TYPE_OF_TRANSFORMATION": "transform",
+ "FREQUENCY": "frequency",
+ }
+ # Reverse mappings for values
+ transformation_rev = {v: k for k, v in transformation_map.items()}
+ frequency_map = {"A": "annual", "Q": "quarter", "M": "monthly"}
+
+ if query.limit is not None:
+ parameters["lastNObservations"] = query.limit
+
+ try:
+ data = query_builder.fetch_data(
+ dataflow="CPI",
+ start_date=(
+ query.start_date.strftime("%Y-%m-%d") if query.start_date else None
+ ),
+ end_date=(
+ query.end_date.strftime("%Y-%m-%d") if query.end_date else None
+ ),
+ **parameters,
+ )
+ except ValueError as e:
+ # Translate dimension codes to user-friendly parameter names in error message
+ error_msg = str(e)
+ for dim_code, param_name in dim_to_param.items():
+ error_msg = error_msg.replace(f"'{dim_code}'", f"'{param_name}'")
+ error_msg = error_msg.replace(f'"{dim_code}"', f'"{param_name}"')
+ # Translate transformation values
+ for api_val, user_val in transformation_rev.items():
+ error_msg = error_msg.replace(f"'{api_val}'", f"'{user_val}'")
+ # Translate frequency values
+ for api_val, user_val in frequency_map.items():
+ error_msg = error_msg.replace(f"'{api_val}'", f"'{user_val}'")
+ # Translate expenditure values
+ for api_val, user_val in expenditure_dict_rev.items():
+ error_msg = error_msg.replace(f"'{api_val}'", f"'{user_val}'")
+ # Translate INDEX_TYPE values
+ error_msg = error_msg.replace("'CPI'", "'False'")
+ error_msg = error_msg.replace("'HICP'", "'True'")
+ # Translate country codes back to user-friendly names
+ for code, label in CPI_CODE_TO_LABEL.items():
+ error_msg = error_msg.replace(f"'{code}'", f"'{label}'")
+ raise OpenBBError(error_msg) from e
+ except OpenBBError as e:
+ raise OpenBBError(e) from e
+
+ return data
+
+ @staticmethod
+ def transform_data(
+ query: ImfConsumerPriceIndexQueryParams,
+ data: dict,
+ **kwargs: Any,
+ ) -> AnnotatedResult[list[ImfConsumerPriceIndexData]]:
+ """Transform data and validate the model."""
+ row_data = data.get("data", [])
+ result: list[ImfConsumerPriceIndexData] = []
+ metadata: dict = data.get("metadata", {})
+ dataset_info: dict = metadata.pop("dataset", {})
+ table_info: dict = (
+ metadata.pop("IMF_STA_CPI_CPI", {})
+ or metadata.pop("IMF_STA_CPI_HICP", {})
+ or {}
+ )
+ dataset_info["index_type"] = table_info.get("indicator", "")
+ dataset_info["index_description"] = table_info.get("description", "")
+
+ if not row_data:
+ raise OpenBBError("No data returned for the given query parameters.")
+
+ for item in row_data:
+ # Filter by date range here because IMF API date filtering can be inconsistent
+ item_date = item.get("TIME_PERIOD", None)
+ if (
+ query.start_date
+ and item_date
+ and item_date < query.start_date.strftime("%Y-%m-%d")
+ ):
+ continue
+ if (
+ query.end_date
+ and item_date
+ and item_date > query.end_date.strftime("%Y-%m-%d")
+ ):
+ continue
+
+ # Get translated labels (these are now human-readable)
+ frequency = (item.get("FREQUENCY") or "").strip()
+ index_type = (item.get("INDEX_TYPE") or "").strip()
+ expenditure = (item.get("COICOP_1999") or item.get("title") or "").strip()
+ expenditure_code = (item.get("COICOP_1999_code") or "").strip()
+ transformation = (item.get("TYPE_OF_TRANSFORMATION") or "").strip()
+ # Build title from translated values
+ title = f"{frequency} {index_type} - {expenditure} - {transformation}"
+ # Get unit from transformation (use last part if comma-separated)
+ unit = (transformation.rsplit(", ", maxsplit=1)[-1] or "").strip()
+ # Get sort order from expenditure code
+ order = expenditure_order.get(expenditure_code, 99)
+ obs_value = item.get("OBS_VALUE", None)
+ multiplier = item.get("UNIT_MULT", 1)
+
+ if "percent" in unit.lower() and obs_value is not None:
+ obs_value = obs_value / 100.0
+ multiplier = 100
+ symbol = item.get("series_id", "").strip().split("IMF_STA_CPI_")[-1]
+ symbol = f"CPI::{symbol}"
+ new_row = {
+ "date": item_date,
+ "country": (item.get("COUNTRY") or "").strip() or None,
+ "country_code": (item.get("country_code") or "").strip() or None,
+ "series_id": symbol,
+ "expenditure": expenditure or None,
+ "title": title.strip(),
+ "unit": unit,
+ "unit_multiplier": multiplier,
+ "value": obs_value,
+ "order": order,
+ }
+ result.append(ImfConsumerPriceIndexData.model_validate(new_row))
+
+ # Sort by date, then country, then order (expenditure)
+ result.sort(
+ key=lambda x: (
+ x.date,
+ x.country or "",
+ x.order if x.order is not None else 99,
+ )
+ )
+
+ return AnnotatedResult(
+ result=result, metadata={"dataset": dataset_info, "series": metadata}
+ )
diff --git a/openbb_platform/providers/imf/openbb_imf/models/direction_of_trade.py b/openbb_platform/providers/imf/openbb_imf/models/direction_of_trade.py
index 49d9c87c7ad..4862d0508b3 100644
--- a/openbb_platform/providers/imf/openbb_imf/models/direction_of_trade.py
+++ b/openbb_platform/providers/imf/openbb_imf/models/direction_of_trade.py
@@ -2,10 +2,11 @@
# pylint: disable=unused-argument
-from datetime import datetime
+from datetime import date as dateType
from typing import Any
from openbb_core.app.model.abstract.error import OpenBBError
+from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.standard_models.direction_of_trade import (
DirectionOfTradeData,
@@ -13,22 +14,16 @@ from openbb_core.provider.standard_models.direction_of_trade import (
)
from openbb_core.provider.utils.errors import EmptyDataError
from openbb_imf.utils.dot_helpers import (
- load_country_map,
- load_country_to_code_map,
- validate_countries,
+ get_label_to_code_map,
+ resolve_country_input,
)
+from pydantic import ConfigDict, Field, field_validator
dot_indicators_dict = {
- "exports": "TXG_FOB_USD",
- "imports": "TMG_CIF_USD",
+ "exports": "XG_FOB_USD",
+ "imports": "MG_CIF_USD",
"balance": "TBG_USD",
- "all": "TXG_FOB_USD+TMG_CIF_USD+TBG_USD",
-}
-
-dot_titles_map = {
- "TXG_FOB_USD": "Goods, Value of Exports, Free on board (FOB), US Dollars",
- "TMG_CIF_USD": "Goods, Value of Imports, Cost, Insurance, Freight (CIF), US Dollars",
- "TBG_USD": "Goods, Value of Trade Balance, US Dollars",
+ "all": "*",
}
@@ -38,18 +33,99 @@ class ImfDirectionOfTradeQueryParams(DirectionOfTradeQueryParams):
__json_schema_extra__ = {
"country": {
"multiple_items_allowed": True,
- "choices": ["all"] + sorted(list(load_country_to_code_map())),
+ "choices": list(get_label_to_code_map()),
+ "x-widget_config": {"value": "united_states", "style": {"popupWidth": 600}},
},
"counterpart": {
"multiple_items_allowed": True,
- "choices": ["all"] + sorted(list(load_country_to_code_map())),
+ "choices": ["all"] + list(get_label_to_code_map()),
+ "x-widget_config": {"value": "world", "style": {"popupWidth": 600}},
},
}
+ limit: int | None = Field(
+ default=None,
+ description="Limit the number of results returned, the most recent data points first.",
+ )
+
+ @field_validator("country", "counterpart", mode="before")
+ @classmethod
+ def _validate_country_fields(cls, v):
+ """Validate country and counterpart fields.
+
+ Accepts both ISO3 codes (e.g., 'USA') and snake_case country names
+ (e.g., 'united_states'). Converts names to ISO3 codes.
+ """
+ if not v:
+ raise ValueError("Required parameter for IMF provider not supplied.")
+
+ if isinstance(v, str) and v.lower() in ["all", "*"]:
+ return "*"
+
+ # Split by comma if string
+ values = (
+ v.split(",")
+ if isinstance(v, str) and "," in v
+ else [v] if isinstance(v, str) else v
+ )
+
+ result: list[str] = []
+ for item in values:
+ item_stripped = item.strip()
+ if item_stripped.lower() in ["all", "*"]:
+ if len(values) > 1:
+ raise ValueError(
+ "'all' cannot be used with other country codes in a list."
+ )
+ return "*"
+ # Resolve the country input (handles both codes and names)
+ resolved = resolve_country_input(item_stripped)
+ result.append(resolved)
+
+ return ",".join(result)
+
class ImfDirectionOfTradeData(DirectionOfTradeData):
"""IMF Direction Of Trade Data."""
+ model_config = ConfigDict(extra="ignore")
+ __alias_dict__ = {
+ "date": "TIME_PERIOD",
+ "symbol": "series_id",
+ "country": "COUNTRY",
+ "counterpart": "COUNTERPART_COUNTRY",
+ "counterpart_code": "counterpart_country_code",
+ "value": "OBS_VALUE",
+ "scale": "SCALE",
+ "frequency": "FREQUENCY",
+ }
+
+ date: dateType | int = Field(description="The date of the data.")
+ country: str = Field(description="The country or region to the trade.")
+ unit: str | None = Field(default=None, description="Unit of the value.")
+ country_code: str = Field(description="IMF country code.")
+ counterpart: str = Field(description="Counterpart country or region to the trade.")
+ counterpart_code: str = Field(description="IMF counterpart country code.")
+ symbol: str | None = Field(
+ default=None,
+ description="Symbol representing the entity requested in the data. Concatenated series identifier.",
+ )
+ title: str | None = Field(
+ default=None, description="Title corresponding to the symbol."
+ )
+ value: float = Field(description="Trade value.")
+ scale: str | None = Field(default=None, description="Scale of the value.")
+ unit_multiplier: int | None = Field(
+ default=None, description="Unit multiplier of the value."
+ )
+
+ @field_validator("symbol", mode="before")
+ @classmethod
+ def _validate_symbol(cls, v):
+ """Format symbol to indicators format."""
+ symbol = v.split("IMTS_")[-1]
+ return f"IMTS::{symbol}"
+
class ImfDirectionOfTradeFetcher(
Fetcher[ImfDirectionOfTradeQueryParams, list[ImfDirectionOfTradeData]]
@@ -59,35 +135,6 @@ class ImfDirectionOfTradeFetcher(
@staticmethod
def transform_query(params: dict[str, Any]) -> ImfDirectionOfTradeQueryParams:
"""Transform query parameters."""
- countries = params.get("country", "")
- countries = countries.split(",") if countries else "all"
- if countries != "all":
- countries = validate_countries(countries)
- counterparts = params.get("counterpart", "")
- counterparts = counterparts.split(",") if params.get("counterpart") else "all"
- if counterparts != "all":
- counterparts = validate_countries(counterparts)
- now = datetime.now().date()
-
- if countries == "all" and counterparts == "all":
- raise OpenBBError(
- "Both 'country' and 'counterpart' cannot be None, or 'all'."
- + " Please supply lowercase country names or two-letter ISO codes."
- )
- if countries == counterparts:
- raise OpenBBError("The 'country' and 'counterpart' cannot be the same.")
-
- params["country"] = countries
- params["counterpart"] = counterparts
-
- if not params.get("end_date"):
- params["end_date"] = now.replace(month=12, day=31).strftime("%Y-%m-%d")
-
- if (countries == "all" or counterparts == "all") and not params.get(
- "start_date"
- ):
- params["start_date"] = now.replace(year=now.year - 1).strftime("%Y-%m-%d")
-
return ImfDirectionOfTradeQueryParams(**params)
@staticmethod
@@ -95,180 +142,47 @@ class ImfDirectionOfTradeFetcher(
query: ImfDirectionOfTradeQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
- ) -> list[dict]:
+ ) -> dict:
"""Extract the data from the IMF API."""
# pylint: disable=import-outside-toplevel
- from aiohttp.client_exceptions import ContentTypeError # noqa
- from json import JSONDecodeError
- from openbb_core.provider.utils.helpers import amake_request
- from pandas import to_datetime
- from pandas.tseries import offsets
+ from openbb_imf.utils.dot_helpers import imts_query
- start_date = query.start_date
- end_date = query.end_date
- frequency = query.frequency[0].upper()
- country = query.country if query.country != "all" else ""
- counterpart = query.counterpart if query.counterpart != "all" else ""
- indicator = dot_indicators_dict[query.direction]
- # Adjust the dates to the date relative to frequency.
- # The API does not accept arbitrary dates, so we need to adjust them.
- if start_date:
- start_date = to_datetime(start_date)
- if frequency == "Q":
- start_date = offsets.QuarterBegin(startingMonth=1).rollback(start_date)
- elif frequency == "A":
- start_date = offsets.YearBegin().rollback(start_date)
- else:
- start_date = offsets.MonthBegin().rollback(start_date)
- start_date = start_date.strftime("%Y-%m-%d") # type: ignore
-
- if end_date:
- end_date = to_datetime(end_date)
- if frequency == "Q":
- end_date = offsets.QuarterEnd().rollforward(end_date)
- elif frequency == "A":
- end_date = offsets.YearEnd().rollforward(end_date)
- else:
- end_date = offsets.MonthEnd().rollforward(end_date)
- end_date = end_date.strftime("%Y-%m-%d") # type: ignore
-
- date_range = ( # type: ignore
- f"?startPeriod={start_date}&endPeriod={end_date}"
- if start_date and end_date
- else ""
- )
- base_url = "http://dataservices.imf.org/REST/SDMX_JSON.svc/"
- key = f"CompactData/DOT/{frequency}.{country}.{indicator}.{counterpart}"
- url = f"{base_url}{key}{date_range}"
+ if query.limit:
+ kwargs = {"lastNObservations": query.limit}
try:
- response = await amake_request(url, timeout=20)
- except (JSONDecodeError, ContentTypeError) as e:
- raise OpenBBError(
- "Error fetching data; This might be rate-limiting. Try again later."
- ) from e
-
- if "ErrorDetails" in response:
- raise OpenBBError(
- f"{response['ErrorDetails'].get('Code')} -> {response['ErrorDetails'].get('Message')}" # type: ignore
+ return imts_query(
+ country=query.country or "",
+ counterpart=query.counterpart or "",
+ indicator=dot_indicators_dict.get(query.direction, "*"),
+ freq=query.frequency[0].upper(),
+ start_date=(
+ query.start_date.strftime("%Y-%m-%d") if query.start_date else None
+ ),
+ end_date=(
+ query.end_date.strftime("%Y-%m-%d") if query.end_date else None
+ ),
+ **kwargs,
)
-
- series = response.get("CompactData", {}).get("DataSet", {}).pop("Series", {}) # type: ignore
-
- if not series:
- raise OpenBBError(f"No time series data found -> {url} -> {response}")
-
- # If there is only one series, they ruturn a dict instead of a list.
- if series and isinstance(series, dict):
- series = [series]
-
- return series
+ except (ValueError, OpenBBError) as e:
+ raise OpenBBError(e) from e
@staticmethod
def transform_data(
query: ImfDirectionOfTradeQueryParams,
- data: list[dict],
+ data: dict,
**kwargs: Any,
- ) -> list[ImfDirectionOfTradeData]:
+ ) -> AnnotatedResult[list[ImfDirectionOfTradeData]]:
"""Transform the data."""
- # pylint: disable=import-outside-toplevel
- from openbb_imf.utils.constants import UNIT_MULTIPLIERS_MAP # noqa
- from pandas import Categorical, DataFrame, to_datetime
- from pandas.tseries import offsets
+ meta = data.get("metadata", {})
+ records = data.get("data", [])
- if not data:
- raise EmptyDataError()
+ if not records:
+ raise EmptyDataError("No data found for the given query parameters.")
- dot_code_to_country = load_country_map()
- series = data
- results: list = []
-
- for s in series:
- if "Obs" not in s:
- continue
- meta = {
- k.replace("@", "").lower(): (
- UNIT_MULTIPLIERS_MAP.get(str(v), v) if k == "@UNIT_MULT" else v
- )
- for k, v in s.items()
- if k != "Obs"
- }
- _symbol = meta.get("indicator", "")
- _title = None
-
- _data = s.pop("Obs", [])
-
- if isinstance(_data, dict):
- _data = [_data]
-
- for d in _data:
- _date = d.pop("@TIME_PERIOD", None)
- val: float | None = d.pop("@OBS_VALUE", None)
- _ = d.pop("@OBS_STATUS", None)
- val = float(val) if val else None
- if not val:
- continue
-
- if _date:
- offset = (
- offsets.QuarterEnd
- if "Q" in _date
- else (
- offsets.YearEnd
- if len(str(_date)) == 4
- else offsets.MonthEnd
- )
- )
- _date = to_datetime(_date)
- _date = _date + offset(0)
- _date = _date.strftime("%Y-%m-%d")
- vals = {
- k: v
- for k, v in {
- "date": _date,
- "symbol": _symbol,
- "country": dot_code_to_country.get(
- meta.get("ref_area"), meta.get("ref_area")
- ),
- "counterpart": dot_code_to_country.get(
- meta.get("counterpart_area"), meta.get("counterpart_area")
- ),
- "title": dot_titles_map.get(_symbol),
- "scale": meta.get("unit_mult"),
- "value": val,
- }.items()
- if v
- }
-
- if (
- vals.get("value")
- and vals.get("date")
- and vals.get("country") != vals.get("counterpart")
- ):
- d.update(vals)
-
- if _data:
- results.extend([d for d in _data if d])
-
- df = DataFrame(results)
- df["symbol"] = Categorical(
- df["symbol"],
- categories=list(dot_titles_map),
- ordered=True,
+ return AnnotatedResult(
+ result=[
+ ImfDirectionOfTradeData.model_validate(record) for record in records
+ ],
+ metadata=meta,
)
- df["country"] = Categorical(
- df["country"],
- categories=sorted(df.country.unique().tolist()),
- ordered=True,
- )
- df["counterpart"] = Categorical(
- df["counterpart"],
- categories=sorted(df.counterpart.unique().tolist()),
- ordered=True,
- )
- df = df.sort_values(by=["date", "country", "counterpart"])
-
- return [
- ImfDirectionOfTradeData.model_validate(r)
- for r in df.to_dict(orient="records")
- ]
diff --git a/openbb_platform/providers/imf/openbb_imf/models/economic_indicators.py b/openbb_platform/providers/imf/openbb_imf/models/economic_indicators.py
index 4c7dd491374..8a36246467e 100644
--- a/openbb_platform/providers/imf/openbb_imf/models/economic_indicators.py
+++ b/openbb_platform/providers/imf/openbb_imf/models/economic_indicators.py
@@ -1,11 +1,13 @@
"""IMF Economic Indicators Model."""
-# pylint: disable=unused-argument
+# pylint: disable=unused-argument,protected-access,too-many-branches,too-many-statements,too-many-locals,too-many-lines
+# flake8: noqa: PLR0912
-from datetime import datetime
-from typing import Any, Literal
+from typing import Any
from openbb_core.app.model.abstract.error import OpenBBError
+from openbb_core.app.service.system_service import SystemService
+from openbb_core.provider.abstract.annotated_result import AnnotatedResult
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.standard_models.economic_indicators import (
EconomicIndicatorsData,
@@ -13,115 +15,632 @@ from openbb_core.provider.standard_models.economic_indicators import (
)
from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS
from openbb_core.provider.utils.errors import EmptyDataError
-from openbb_imf.utils.constants import (
- FSI_PRESETS,
- IRFCL_PRESET,
- IRFCL_TABLES,
- load_symbols,
+from openbb_imf.utils.helpers import (
+ detect_indicator_dimensions,
+ detect_transform_dimension,
+ parse_time_period,
+ translate_error_message,
)
-from openbb_imf.utils.irfcl_helpers import (
- load_country_to_code_map,
- validate_countries,
-)
-from pydantic import Field, field_validator
+from pydantic import ConfigDict, Field, PrivateAttr, field_validator, model_validator
+
+api_prefix = SystemService().system_settings.api_settings.prefix
class ImfEconomicIndicatorsQueryParams(EconomicIndicatorsQueryParams):
- """IMF Economic Indicators Query."""
+ """IMF Economic Indicators Query.
+
+ Symbol format: 'dataflow::identifier' where identifier is either:
+ - A table ID (starts with 'H_') for hierarchical table data
+ - An indicator code for individual indicator data
+
+ Examples:
+ - 'BOP::H_BOP_BOP_AGG_STANDARD_PRESENTATION' - Balance of Payments table
+ - 'BOP_AGG::GS_CD,BOP_AGG::GS_DB' - Multiple BOP_AGG indicators (Goods & Services)
+ - 'IL::RGV_REVS' - Gold reserves in millions of fine troy ounces
+ - 'WEO::NGDP_RPCH' - Real GDP growth (annual only)
+ - 'WEO::POILBRE' - Brent crude oil price (use country='G001' for world)
+ - 'PCPS::PGOLD' - Gold price per troy ounce (monthly/quarterly available)
+
+ Use `obb.economy.available_indicators(provider='imf')` to discover symbols.
+ Use `obb.economy.imf_utils.list_tables()` to see available tables.
+ """
__json_schema_extra__ = {
"symbol": {
"multiple_items_allowed": True,
+ "x-widget_config": {
+ "label": "Indicator Symbol",
+ # "description": "The IMF indicator series ID.",
+ "multiSelect": False,
+ "multiple": True,
+ "type": "text",
+ },
},
"country": {
"multiple_items_allowed": True,
- "choices": ["all"] + list(list(load_country_to_code_map())),
+ "x-widget_config": {
+ "label": "Country",
+ "description": "Country or region for the table.",
+ "type": "endpoint",
+ "multiSelect": True,
+ "multiple": False,
+ "optionsEndpoint": f"{api_prefix}/imf_utils/indicator_choices",
+ "optionsParams": {
+ "symbol": "$symbol",
+ "country": "true",
+ "dimension_values": "$dimension_values",
+ },
+ "style": {"popupWidth": 600},
+ },
},
"frequency": {
- "choices": ["annual", "quarter", "month"],
+ "x-widget_config": {
+ "label": "Frequency",
+ "type": "endpoint",
+ "optionsEndpoint": f"{api_prefix}/imf_utils/indicator_choices",
+ "optionsParams": {
+ "symbol": "$symbol",
+ "country": "$country",
+ "frequency": "true",
+ "dimension_values": "$dimension_values",
+ },
+ "description": "The data frequency.",
+ },
+ },
+ "transform": {
+ "x-widget_config": {
+ "label": "Transform",
+ "type": "endpoint",
+ "optionsEndpoint": f"{api_prefix}/imf_utils/indicator_choices",
+ "optionsParams": {
+ "symbol": "$symbol",
+ "country": "$country",
+ "frequency": "$frequency",
+ "transform": "true",
+ "dimension_values": "$dimension_values",
+ },
+ "description": "Transformation to apply to the data.",
+ "style": {"popupWidth": 600},
+ },
+ },
+ "dimension_values": {
+ "x-widget_config": {
+ "label": "Dimension Filters",
+ "type": "text",
+ "value": None,
+ "description": "Additional dimension filters in 'DIM_ID:DIM_VALUE' format."
+ + " See IMF Dataflow Parameters widget for all possible combinations.",
+ "multiple": True,
+ },
+ },
+ "limit": {
+ "x-widget_config": {
+ "label": "Limit",
+ "value": None,
+ "description": "Most recent N records to retrieve per series.",
+ "type": "number",
+ },
},
}
- symbol: str = Field(
- default="irfcl_top_lines",
+
+ symbol: str | None = Field(
description=QUERY_DESCRIPTIONS.get("symbol", "")
- + " Use `available_indicators()` to get the list of available symbols."
- + " Use 'IRFCL' to get all the data from International Reserves & Foreign Currency Liquidity indicators."
- + " Use 'core_fsi' to get the core Financial Soundness Indicators."
- + " Use 'core_fsi_underlying' to include underlying data for the core Financial Soundness Indicators."
- + " Complete tables are available only by single country, and are keyed as described below."
- + " The default is 'irfcl_top_lines'. Available presets not listed in `available_indicators()` are:"
- + """\n
- 'IRFCL': All the data from the set of indicators. Not compatible with multiple countries.
- 'irfcl_top_lines': The default, top line items from the IRFCL data. Compatible with multiple countries.
- 'reserve_assets_and_other_fx_assets': Table I of the IRFCL data. Not compatible with multiple countries.
- 'predetermined_drains_on_fx_assets': Table II of the IRFCL data. Not compatible with multiple countries.
- 'contingent_drains_fx_assets': Table III of the IRFCL data. Not compatible with multiple countries.
- 'memorandum_items': The memorandum items table of the IRFCL data. Not compatible with multiple countries.
- 'gold_reserves': Gold reserves as value in USD and Fine Troy Ounces. Compatible with multiple countries.
- 'derivative_assets': Net derivative assets as value in USD. Compatible with multipile countries.
- 'fsi_core': The core Financial Soundness Indicators. Compatible with multiple countries.
- 'fsi_core_underlying': The core FSIs underlying series data. Not compatible with country='all'.
- 'fsi_encouraged_set': The encouraged set of Financial Soundness Indicators. Not compatible with country='all'.
- 'fsi_other': The other Financial Soundness Indicators. Not compatible with country='all'.
- 'fsi_balance_sheets': Data categorized as Balance Sheets and Income Statements. Not compatible with country='all'.
- 'fsi_all': All the Financial Soundness Indicators. Not compatible with multiple countries.
- """,
- )
- frequency: Literal["annual", "quarter", "month"] = Field(
- default="quarter",
- description="Frequency of the data, default is 'quarter'.",
+ + " Symbol format: 'dataflow::identifier' where identifier is either:"
+ + "\n- A table ID (starts with 'H_') for hierarchical table data"
+ + "\n- An indicator code for individual indicator data"
+ + "\n\n"
+ + "Examples:"
+ + "\n - 'BOP::H_BOP_BOP_AGG_STANDARD_PRESENTATION' - Balance of Payments table"
+ + "\n - 'BOP_AGG::GS_CD,BOP_AGG::GS_DB' - Multiple BOP_AGG indicators (Goods & Services)"
+ + "\n - 'IL::RGV_REVS' - Gold reserves in millions of fine troy ounces"
+ + "\n - 'WEO::NGDP_RPCH' - Real GDP growth (annual only)"
+ + "\n - 'WEO::POILBRE' - Brent crude oil price (use country='G001' for world)"
+ + "\n - 'PCPS::PGOLD' - Gold price per troy ounce (monthly/quarterly available)"
+ + "\n\n"
+ + "Use `obb.economy.available_indicators(provider='imf')` to discover symbols."
+ + " Use `obb.economy.imf_utils.list_tables()` to see available tables."
)
- @field_validator("symbol", mode="before", check_fields=False)
+ country: str | None = Field(
+ default=None,
+ description="ISO3 country code(s). Use comma-separated values for multiple countries. "
+ + "Validated against the dataflow's available countries via constraint API.",
+ )
+
+ frequency: str | None = Field(
+ default=None,
+ description="The frequency of the data. Choices vary by indicator and country."
+ + " Common options: 'annual', 'quarter', 'month'."
+ + " Use 'all' or '*' to return all available frequencies."
+ + " Direct IMF codes (e.g., 'A', 'Q', 'M') are also accepted.",
+ )
+
+ transform: str | None = Field(
+ default=None,
+ description="Transformation to apply to the data. "
+ + "User-friendly options: 'index' (raw values), 'yoy' (year-over-year %), 'period' (period-over-period %). "
+ + "Use 'all' or '*' to return all available transformations. "
+ + "Direct IMF codes (e.g., 'USD', 'IX') are also accepted.",
+ )
+
+ dimension_values: list[str] | None = Field(
+ default=None,
+ description="List of additional dimension filters in 'DIM_ID:DIM_VALUE' format."
+ + " Parameter can be entered multiple times.",
+ )
+
+ limit: int | None = Field(
+ default=None, description="Maximum number of records to retrieve per series."
+ )
+
+ pivot: bool = Field(
+ default=False,
+ description="If True, pivots the data to presentation view with"
+ + " 'indicator' and 'country' as the index, date as values.",
+ )
+
+ _is_table: bool = PrivateAttr(default=False)
+ _dataflow: str | None = PrivateAttr(default=None)
+ _table_id: str | None = PrivateAttr(default=None)
+ _indicator_codes: list[str] = PrivateAttr(default_factory=list)
+ _indicators_by_dataflow: dict = PrivateAttr(default_factory=dict)
+
+ @field_validator("country", mode="before", check_fields=False)
@classmethod
- def _count_presets(cls, v):
- """Validate the symbol."""
+ def validate_country(cls, v):
+ """
+ Normalize country inputs to ISO3 codes.
+
+ Accepts:
+ - ISO3 codes: "USA", "JPN", "GBR"
+ - Country names: "United States", "Japan", "United Kingdom"
+ - Snake_case names: "united_states", "japan", "united_kingdom"
+ - Wildcards: "*" or "all" to include all countries
+ - None: Allowed if dimension_values contains a country dimension (validated in model_validator)
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.metadata import ImfMetadata
+
+ # Allow None - will be validated in model_validator with dimension_values check
if not v:
- return v
- presets = list(IRFCL_PRESET) + FSI_PRESETS
- n_preset = 0
- symbols = v.split(",")
- for symbol in symbols:
- n_preset += 1 if symbol in presets else 0
- if n_preset > 1:
- raise ValueError("only one preset symbol can be used at a time.")
- return v
+ return None
+
+ # Split by comma, handling potential spaces and filtering empty strings
+ items = [c.strip() for c in v.split(",") if c.strip()]
+
+ if not items:
+ return None
+
+ # Check for wildcards - return early without metadata lookup
+ if len(items) == 1 and items[0].lower() in ("*", "all"):
+ return "*"
+
+ metadata = ImfMetadata()
+ country_codes = metadata._codelist_cache.get("CL_COUNTRY", {})
+
+ # Build lookup tables
+ code_set = set(country_codes.keys())
+ name_to_code: dict[str, str] = {}
+ for code, name in country_codes.items():
+ # Add lowercase name
+ name_to_code[name.lower()] = code
+ # Add snake_case version (replace spaces with underscores)
+ snake_name = (
+ name.lower()
+ .replace(" ", "_")
+ .replace(",", "")
+ .replace(".", "")
+ .replace("'", "")
+ )
+ name_to_code[snake_name] = code
+
+ result: list[str] = []
+
+ for item in items:
+ item_upper = item.upper().strip()
+ item_lower = item.lower().strip().replace(" ", "_")
+
+ # Handle wildcards in mixed input
+ if item_lower in ("*", "all"):
+ return "*" # Wildcard overrides everything
+
+ # Check if it's already an ISO3 code
+ if item_upper in code_set:
+ result.append(item_upper)
+ # Check if it's a name (with spaces or snake_case)
+ elif item_lower in name_to_code:
+ result.append(name_to_code[item_lower])
+ # Try with original casing as lowercase lookup
+ elif item.lower() in name_to_code:
+ result.append(name_to_code[item.lower()])
+ else:
+ # Not found - pass through as uppercase (will fail later with clearer error)
+ result.append(item_upper)
+
+ return ",".join(result)
+
+ @model_validator(mode="after")
+ def parse_and_validate_symbols(self):
+ """Parse symbols and validate table/indicator constraints."""
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.metadata import ImfMetadata
+
+ if not self.symbol:
+ raise ValueError("symbol is required.")
+
+ country_dimensions = {"COUNTRY", "REF_AREA", "JURISDICTION", "AREA"}
+ frequency_dimensions = {"FREQUENCY", "FREQ"}
+ transform_dimensions = {
+ "UNIT_MEASURE",
+ "UNIT",
+ "TRANSFORMATION",
+ "TYPE_OF_TRANSFORMATION",
+ }
+
+ remaining_dimension_values: list[str] = []
+
+ if self.dimension_values:
+ for dv in self.dimension_values:
+ if ":" not in dv:
+ remaining_dimension_values.append(dv)
+ continue
+ dim_id, dim_value = dv.split(":", 1)
+ dim_id_upper = dim_id.strip().upper()
+ dim_value = dim_value.strip()
+
+ # dimension_values OVERRIDES the country parameter
+ if dim_id_upper in country_dimensions:
+ object.__setattr__(self, "country", dim_value)
+ # dimension_values OVERRIDES the frequency parameter
+ elif dim_id_upper in frequency_dimensions:
+ object.__setattr__(self, "frequency", dim_value)
+ # dimension_values OVERRIDES the transform parameter
+ elif dim_id_upper in transform_dimensions:
+ object.__setattr__(self, "transform", dim_value)
+ # Keep dimension_values that are not consumed by country/frequency/transform
+ else:
+ remaining_dimension_values.append(dv)
+
+ # Update dimension_values to only contain non-consumed dimensions
+ object.__setattr__(
+ self,
+ "dimension_values",
+ remaining_dimension_values if remaining_dimension_values else None,
+ )
+
+ # Validate country requirement - must have country by now
+ if not self.country:
+ raise ValueError(
+ "Country is required. Provide via 'country' parameter or include a country "
+ "dimension (COUNTRY, REF_AREA, JURISDICTION, AREA) in 'dimension_values'."
+ )
+
+ symbols = [
+ s.strip()
+ for s in self.symbol.split(",") # type: ignore # pylint: disable=E1101
+ ]
+ tables: list[str] = []
+ indicators: list[tuple[str, str]] = []
+ dataflows_seen: set[str] = set()
+
+ for sym in symbols:
+ if "::" not in sym:
+ raise ValueError(
+ f"Invalid symbol format '{sym}'. Expected 'dataflow::identifier'. "
+ "Use `available_indicators()` or `list_tables()` to find valid symbols."
+ )
+
+ parts = sym.split("::", 1)
+ dataflow = parts[0].strip().upper()
+ identifier = parts[1].strip()
+
+ if not identifier:
+ raise ValueError(
+ f"Invalid symbol format '{sym}'. Identifier cannot be empty. "
+ "Expected 'dataflow::identifier'."
+ )
+
+ dataflows_seen.add(dataflow)
+ # Tables can start with H_ or be any valid hierarchy ID for the dataflow
+ is_table = False
+
+ if identifier.startswith("H_"):
+ is_table = True
+ else:
+ metadata = ImfMetadata()
+ try:
+ hierarchies = metadata.get_dataflow_hierarchies(dataflow)
+ hierarchy_ids = {h.get("id") for h in hierarchies}
+ if identifier in hierarchy_ids:
+ is_table = True
+ except Exception: # noqa
+ pass # If we can't check, assume it's an indicator
+
+ if is_table:
+ tables.append(sym)
+ else:
+ indicators.append((dataflow, identifier))
+
+ if tables and indicators:
+ raise ValueError(
+ "Cannot mix tables and indicators in the same request. "
+ f"Got tables: {tables} and indicators: {[f'{d}::{c}' for d, c in indicators]}"
+ )
+
+ if len(tables) > 1:
+ raise ValueError(
+ f"Only one table can be requested at a time. Got: {tables}"
+ )
+
+ if tables:
+ self._is_table = True
+ parts = tables[0].split("::", 1)
+ self._dataflow = parts[0].upper()
+ self._table_id = parts[1]
+ else:
+ self._is_table = False
+ indicators_by_df: dict[str, list[str]] = {}
+
+ for dataflow, code in indicators:
+ if dataflow not in indicators_by_df:
+ indicators_by_df[dataflow] = []
+
+ indicators_by_df[dataflow].append(code)
+
+ self._indicators_by_dataflow = indicators_by_df
+
+ if len(dataflows_seen) == 1:
+ self._dataflow = list(dataflows_seen)[0]
+ self._indicator_codes = [code for _, code in indicators]
+ else:
+ self._dataflow = None # Multiple dataflows
+ self._indicator_codes = []
+
+ # Validate country, frequency, and transform using constraints API
+ self._validate_indicator_params()
+
+ return self
+
+ def _validate_indicator_params(self):
+ """Validate country, frequency, and transform using the constraints API."""
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.metadata import ImfMetadata
+
+ metadata = ImfMetadata()
+
+ def build_key_up_to(target_dim: str) -> str:
+ """Build constraint key up to (and including) target dimension."""
+ key_parts: list[str] = []
+ countries = self.country.split(",") if self.country else [] # type: ignore # pylint: disable=E1101
+ countries_str = (
+ "*"
+ if countries in ["*", "all"]
+ else "+".join([c.upper() for c in countries])
+ )
+
+ for dim_id in dim_order:
+ if dim_id == target_dim:
+ key_parts.append("*")
+ break
+ if dim_id == country_dim:
+ key_parts.append(countries_str if countries_str else "*")
+ elif dim_id == indicator_dim:
+ # Use all indicator codes for this dataflow
+ key_parts.append(
+ "+".join(indicator_codes) if indicator_codes else "*"
+ )
+ elif dim_id == freq_dim:
+ freq_map = {
+ "annual": "A",
+ "quarter": "Q",
+ "month": "M",
+ "day": "D",
+ }
+ freq_val = freq_map.get(str(self.frequency).lower(), self.frequency)
+ key_parts.append(str(freq_val) if self.frequency else "*")
+ elif dim_id == transform_dim:
+ key_parts.append(str(self.transform) if self.transform else "*")
+ else:
+ key_parts.append("*")
+ return ".".join(key_parts)
+
+ def get_available_values(dim_id: str, dataflow_id: str) -> list[str]:
+ """Get available values for a dimension using constraints API."""
+ key = build_key_up_to(dim_id)
+ constraints = metadata.get_available_constraints(
+ dataflow_id=dataflow_id,
+ key=key,
+ component_id=dim_id,
+ )
+ for kv in constraints.get("key_values", []):
+ if kv.get("id") == dim_id:
+ return kv.get("values", [])
+ return []
+
+ # For each dataflow, validate the parameters
+ for dataflow_id, indicator_codes in self._indicators_by_dataflow.items():
+ df_obj = metadata.dataflows.get(dataflow_id, {})
+
+ if not df_obj:
+ continue
+
+ dsd_id = df_obj.get("structureRef", {}).get("id")
+ dsd = metadata.datastructures.get(dsd_id, {})
+ dimensions = dsd.get("dimensions", [])
+ sorted_dims = sorted(
+ [d for d in dimensions if d.get("id") != "TIME_PERIOD"],
+ key=lambda x: int(x.get("position", 0)),
+ )
+ dim_order = [d["id"] for d in sorted_dims]
+ country_dim = (
+ "COUNTRY"
+ if "COUNTRY" in dim_order
+ else "JURISDICTION" if "JURISDICTION" in dim_order else "REF_AREA"
+ )
+ freq_dim = "FREQUENCY" if "FREQUENCY" in dim_order else "FREQ"
+
+ transform_dim, _, _, _ = detect_transform_dimension(dataflow_id)
+ indicator_dim_candidates = [
+ "INDICATOR",
+ "COICOP_1999",
+ "SERIES",
+ "ITEM",
+ "BOP_ACCOUNTING_ENTRY",
+ "ACTIVITY",
+ ]
+ indicator_dim = next(
+ (d for d in indicator_dim_candidates if d in dim_order), None
+ )
+
+ # Validate country
+ if self.country and country_dim in dim_order:
+ available_countries = get_available_values(country_dim, dataflow_id)
+ if available_countries:
+ countries = [c.strip().upper() for c in self.country.split(",")] # type: ignore # pylint: disable=E1101
+ invalid = [
+ c
+ for c in countries
+ if c not in available_countries and c not in ("*", "all")
+ ]
+ if invalid:
+ raise ValueError(
+ f"Invalid value(s) for dimension 'country': {invalid}. "
+ + f"Given prior selections {{'indicator': '{','.join(indicator_codes)}'}}, "
+ + f"available values are: {sorted(available_countries)}"
+ )
+
+ if (
+ self.frequency
+ and self.frequency.lower() not in ("all", "*") # type: ignore # pylint: disable=E1101
+ and freq_dim in dim_order
+ ):
+ freq_map = {"annual": "A", "quarter": "Q", "month": "M", "day": "D"}
+ freq_val = freq_map.get(str(self.frequency).lower(), self.frequency)
+ available_freqs = get_available_values(freq_dim, dataflow_id)
+ if available_freqs and freq_val not in available_freqs:
+ indicator_str = ",".join(indicator_codes)
+ raise ValueError(
+ f"Invalid value(s) for dimension 'frequency': ['{freq_val}']. "
+ f"Given prior selections {{'country': '{self.country}', "
+ f"'indicator': '{indicator_str}'}}, "
+ f"available values are: {available_freqs}"
+ )
+
+ # Validate transform (skip if 'all' or '*')
+ if (
+ self.transform
+ and self.transform.lower() not in ("all", "*") # type: ignore # pylint: disable=E1101
+ and transform_dim
+ and transform_dim in dim_order
+ ):
+ _, _, transform_lookup, unit_lookup = detect_transform_dimension(
+ dataflow_id
+ )
+ # Resolve user-friendly name to code
+ transform_val = self.transform.strip().lower() # type: ignore # pylint: disable=E1101
+ resolved_code = transform_lookup.get(
+ transform_val, unit_lookup.get(transform_val, self.transform)
+ )
+ available_transforms = get_available_values(transform_dim, dataflow_id)
+
+ if available_transforms and resolved_code not in available_transforms:
+ indicator_str = ",".join(indicator_codes)
+ raise ValueError(
+ f"Invalid value(s) for dimension 'transform': ['{resolved_code}']. "
+ f"Given prior selections {{'country': '{self.country}', "
+ f"'indicator': '{indicator_str}'}}, "
+ f"available values are: {available_transforms}"
+ )
class ImfEconomicIndicatorsData(EconomicIndicatorsData):
"""IMF Economic Indicators Data."""
- __alias_dict__ = {
- "symbol_root": "parent",
+ __alias__dict__ = {
+ "title": "Indicator",
+ "country": "Country",
+ "symbol_root": "parent_code",
}
+ @field_validator("scale", "unit", "title", "description", "value", mode="before")
+ @classmethod
+ def _convert_nan_to_none(cls, v):
+ """Convert NaN float values to None for string fields."""
+ if v is None or str(v).lower() == "nan":
+ return None
+ return v
+
+ model_config = ConfigDict(
+ extra="allow", # Allow dynamic dimension fields from any dataflow
+ json_schema_extra={
+ "x-widget_config": {
+ "$.name": "IMF Indicators",
+ "$.refetchInterval": False,
+ "$.data": {
+ "table": {
+ "columnsDefs": [
+ {
+ "field": "title",
+ "headerName": "Title",
+ "renderFn": "hoverCard",
+ "renderFnParams": {
+ "hoverCard": {
+ "cellField": "title",
+ "markdown": "{description}",
+ }
+ },
+ },
+ {
+ "field": "description",
+ "hide": True,
+ },
+ {
+ "field": "symbol",
+ "pinned": False,
+ },
+ {
+ "field": "value",
+ "pinned": "left",
+ },
+ ]
+ }
+ },
+ },
+ },
+ )
+
unit: str | None = Field(
default=None,
- description="The unit of the value.",
+ description="The unit of measurement.",
+ )
+ unit_multiplier: int | float | None = Field(
+ default=None,
+ description="The multiplier for the unit.",
)
scale: str | None = Field(
default=None,
- description="The scale of the value.",
- )
- table: str | None = Field(
- default=None,
- description="The name of the table associated with the symbol.",
- )
- level: int | None = Field(
- default=None,
- description="The indentation level of the data, relative to the table and symbol_root",
+ description="The scale/multiplier of the value.",
)
order: int | float | None = Field(
default=None,
- description="Order of the data, relative to the table.",
+ description="Sort order within the table hierarchy.",
)
- reference_sector: str | None = Field(
+ level: int | None = Field(
default=None,
- description="The reference sector for the data.",
+ description="Indentation level in the table hierarchy.",
)
title: str | None = Field(
default=None,
- description="The title of the series associated with the symbol.",
+ description="Human-readable title of the series.",
+ alias="Indicator",
+ )
+ description: str | None = Field(
+ default=None,
+ description="Description of the indicator.",
+ )
+ country_code: str | None = Field(
+ default=None,
+ description="ISO3 country code.",
)
@@ -133,171 +652,480 @@ class ImfEconomicIndicatorsFetcher(
@staticmethod
def transform_query(params: dict[str, Any]) -> ImfEconomicIndicatorsQueryParams:
"""Transform the query."""
- symbols = params.get("symbol", "")
- countries = params.get("country")
- now = datetime.now().date()
- symbols = (
- "IRFCL"
- if (("all" in symbols or "IRFCL" in symbols) and "fsi_all" not in symbols)
- else symbols if symbols else "irfcl_top_lines"
- )
- incompatible = (
- "fsi_other" in symbols
- or "fsi_encouraged_set" in symbols
- or "fsi_all" in symbols
- or "fsi_core_underlying" in symbols
- or "fsi_balance_sheets" in symbols
- )
- if (symbols == "IRFCL" or incompatible) and not (
- countries or countries == "all"
- ):
- raise OpenBBError(
- f"The selected symbol(s), {params.get('symbol')}, is not compatible with the all-countries group."
- " Please provide country names or two-letter ISO country codes."
- )
-
- if countries:
- params["country"] = validate_countries(countries)
-
- if symbols and symbols in IRFCL_PRESET:
- params["symbol"] = IRFCL_PRESET[symbols]
- if symbols in IRFCL_TABLES and countries and countries.split(",") > 1:
- raise OpenBBError(
- f"Symbol '{symbols}' is a table and can only be used with one country."
- )
- elif symbols:
- params["symbol"] = symbols
-
- if not params.get("start_date") and (not countries or countries == "all"):
- params["start_date"] = now.replace(
- year=now.year - 1, month=1, day=1
- ).strftime("%Y-%m-%d")
-
- if not params.get("end_date"):
- params["end_date"] = now.replace(month=12, day=31).strftime("%Y-%m-%d")
-
- if (not symbols or symbols == "all") and not params.get("start_date"):
- params["start_date"] = now.replace(year=now.year - 1).strftime("%Y-%m-%d")
-
- return ImfEconomicIndicatorsQueryParams(**params)
+ try:
+ return ImfEconomicIndicatorsQueryParams(**params)
+ except Exception as e: # noqa
+ raise OpenBBError(e) from e
@staticmethod
async def aextract_data(
query: ImfEconomicIndicatorsQueryParams,
credentials: dict[str, Any] | None = None,
**kwargs: Any,
- ) -> list[dict]:
+ ) -> dict:
"""Extract the data."""
- # pylint: disable = import-outside-toplevel
- from openbb_imf.utils.fsi_helpers import _get_fsi_data # noqa
- from openbb_imf.utils.irfcl_helpers import _get_irfcl_data
- from warnings import warn
+ # pylint: disable=import-outside-toplevel
+ from datetime import datetime # noqa
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+ from openbb_imf.utils.table_builder import ImfTableBuilder
- fsi_symbols = load_symbols("FSI")
- irfcl_symbols = load_symbols("IRFCL")
- symbols = query.symbol.split(",")
- new_symbols_irfcl: list | str = []
- new_symbols_fsi: list | str = []
- for symbol in symbols:
- if symbol in list(IRFCL_PRESET) + ["all", "IRFCL"]:
- new_symbols_irfcl = symbol
- elif symbol in FSI_PRESETS:
- new_symbols_fsi = symbol
- elif symbol.upper() in fsi_symbols:
- new_symbols_fsi.append(symbol.upper()) # type: ignore
- elif symbol.upper() in irfcl_symbols:
- new_symbols_irfcl.append(symbol.upper()) # type: ignore
+ countries = query.country.split(",") # type: ignore
+ countries_str = "+".join([c.upper() for c in countries])
+ frequency_map = {"annual": "A", "quarter": "Q", "month": "M", "day": "D"}
+ frequency = frequency_map.get(query.frequency) or query.frequency # type: ignore
+ start_date = query.start_date.strftime("%Y-%m-%d") if query.start_date else None
+ end_date = query.end_date.strftime("%Y-%m-%d") if query.end_date else None
- if not new_symbols_irfcl and not new_symbols_fsi:
- raise OpenBBError(
- f"No valid symbols found -> {query.symbol} -> "
- "Use 'available_indicators(provider='imf')' to get the list of available symbols."
- )
-
- results: list = []
- exceptions: list = []
- try:
- try:
- if new_symbols_irfcl:
- _kwargs = query.model_dump(exclude_none=True)
- _kwargs["symbol"] = new_symbols_irfcl
- results.extend(await _get_irfcl_data(**_kwargs))
- except (EmptyDataError, OpenBBError) as e:
- if new_symbols_fsi:
- exceptions.append(
- f"IRFCL dataset error -> {e.__class__.__name__}: {e}"
- )
- else:
- raise
- if new_symbols_fsi:
- try:
- _kwargs = query.model_dump(exclude_none=True)
- _kwargs["symbol"] = new_symbols_fsi
- results.extend(await _get_fsi_data(**_kwargs))
- except (EmptyDataError, OpenBBError) as e:
- if new_symbols_irfcl and len(results) > 0:
- exceptions.append(
- f"FSI dataset error -> {e.__class__.__name__}: {e}"
+ # Parse dimension_values into a dict of DIM_ID -> DIM_VALUE
+ # Input format: list of "DIM_ID:VALUE" strings
+ # Example: ["SECTOR:S13", "GFS_GRP:XDC"] -> {"SECTOR": "S13", "GFS_GRP": "XDC"}
+ # Handles lowercase inputs: "unit:xdc" -> {"UNIT": "XDC"}
+ extra_dimensions: dict[str, str] = {}
+ if query.dimension_values:
+ for dv in query.dimension_values:
+ # Each dv should be "DIMENSION:VALUE"
+ if not dv or not isinstance(dv, str):
+ continue
+ # Handle comma-separated pairs in a single string
+ pairs = [p.strip() for p in dv.split(",") if p.strip()]
+ for pair in pairs:
+ if ":" in pair:
+ dim_id, dim_value = pair.split(":", 1)
+ # Uppercase both dimension ID and value for IMF API
+ extra_dimensions[dim_id.strip().upper()] = (
+ dim_value.strip().upper()
)
- elif not new_symbols_irfcl:
- raise
- except OpenBBError as exc:
- raise exc from exc
- if not results:
- raise EmptyDataError("No results returned for the query.")
+ if query._is_table:
+ # Table mode: use ImfTableBuilder (single dataflow only)
+ dataflow = query._dataflow
+ if not dataflow:
+ raise OpenBBError("Could not determine dataflow from symbol.")
- if results and exceptions:
- msgs = "\n".join(exceptions)
- warn("An error occurred while fetching the data -> " + msgs)
+ params: dict[str, Any] = {
+ "COUNTRY": countries_str,
+ "FREQUENCY": frequency,
+ }
- return results
+ # Handle special dimensions for certain dataflows
+ # GFS dataflows: GFS_BS, GFS_SOO, GFS_COFOG, GFS_SFCP, GFS_SSUC, QGFS
+ if dataflow.startswith("GFS_") or dataflow == "QGFS":
+ params["SECTOR"] = "*"
+ params["GFS_GRP"] = "*"
+ elif dataflow.startswith("FSIC") or dataflow == "IRFCL":
+ params["SECTOR"] = "*"
+ elif dataflow.startswith("BOP") or dataflow == "DIP":
+ params["TYPE_OF_TRANSFORMATION"] = "*"
+ elif dataflow == "ISORA_LATEST_DATA_PUB":
+ params["INDICATOR"] = "*"
+
+ # Apply user-specified dimension filters (overrides defaults above)
+ if extra_dimensions:
+ params.update(extra_dimensions)
+
+ # Handle transform/unit for table mode
+ if query.transform:
+ transform_val = query.transform.strip().lower()
+ transform_dim, unit_dim, transform_lookup, unit_lookup = (
+ detect_transform_dimension(dataflow)
+ )
+ applied = False
+ resolved_code = None
+
+ # Try transform dimension first
+ if transform_dim:
+ if transform_val in ("all", "*"):
+ params[transform_dim] = "*"
+ applied = True
+ elif transform_val in transform_lookup:
+ resolved_code = transform_lookup[transform_val]
+ params[transform_dim] = resolved_code
+ applied = True
+
+ # Try unit dimension if not applied to transform
+ if not applied and unit_dim:
+ if transform_val in ("all", "*"):
+ params[unit_dim] = "*"
+ applied = True
+ elif transform_val in unit_lookup:
+ resolved_code = unit_lookup[transform_val]
+ params[unit_dim] = resolved_code
+ applied = True
+
+ # Raise error if transform value is not valid for dataflow
+ if not applied:
+ available = []
+ if transform_lookup:
+ available.extend(
+ sorted(
+ set(transform_lookup.keys())
+ - set(transform_lookup.values())
+ )
+ )
+ if unit_lookup:
+ available.extend(
+ sorted(set(unit_lookup.keys()) - set(unit_lookup.values()))
+ )
+ if not transform_dim and not unit_dim:
+ raise OpenBBError(
+ f"Dataflow '{dataflow}' does not support transform/unit parameter."
+ )
+ raise OpenBBError(
+ f"Invalid transform value '{query.transform}' for dataflow '{dataflow}'. "
+ f"Available options: {', '.join(available) if available else 'none'}"
+ )
+
+ # We request one extra period to ensure value carry-forward for STATUS="NA" obs.
+ if query.limit is not None and start_date is None:
+ current_year = datetime.now().year
+ if frequency == "A":
+ start_year = current_year - query.limit - 1
+ start_date = str(start_year) # Just year for annual
+ elif frequency == "Q":
+ years_back = (query.limit + 7) // 4 + 1
+ start_year = current_year - years_back
+ start_date = str(start_year)
+ elif frequency == "M":
+ years_back = (query.limit + 23) // 12 + 1
+ start_year = current_year - years_back
+ start_date = str(start_year)
+
+ table_builder = ImfTableBuilder()
+
+ try:
+ result = table_builder.get_table(
+ dataflow=dataflow,
+ table_id=query._table_id,
+ start_date=start_date,
+ end_date=end_date,
+ **params,
+ )
+ return {
+ "mode": "table",
+ "data": result.get("data", []),
+ "table_metadata": result.get("table_metadata", {}),
+ "series_metadata": result.get("series_metadata", {}),
+ }
+ except (ValueError, OpenBBError) as e:
+ # Translate IMF dimension codes to user-friendly parameter names
+ raise OpenBBError(translate_error_message(str(e))) from e
+
+ else:
+ # Indicator mode: support multiple dataflows
+ query_builder = ImfQueryBuilder()
+ all_data: list[dict] = []
+ all_metadata: dict = {}
+ indicators_by_df = query._indicators_by_dataflow
+
+ if not indicators_by_df:
+ raise OpenBBError("No indicators specified.")
+
+ # Fetch data for each dataflow
+ for dataflow, indicator_codes in indicators_by_df.items():
+ params = {
+ "COUNTRY": countries_str,
+ "FREQUENCY": frequency,
+ }
+
+ # Apply user-specified dimension filters
+ if extra_dimensions:
+ params.update(extra_dimensions)
+
+ # Handle transform/unit parameter per dataflow
+ if query.transform:
+ transform_val = query.transform.strip().lower()
+ transform_dim, unit_dim, transform_lookup, unit_lookup = (
+ detect_transform_dimension(dataflow)
+ )
+ applied = False
+ resolved_code = None
+
+ # Try transform dimension first
+ if transform_dim:
+ if transform_val in ("all", "*"):
+ params[transform_dim] = "*"
+ applied = True
+ elif transform_val in transform_lookup:
+ resolved_code = transform_lookup[transform_val]
+ params[transform_dim] = resolved_code
+ applied = True
+
+ # Try unit dimension if not applied to transform
+ if not applied and unit_dim:
+ if transform_val in ("all", "*"):
+ params[unit_dim] = "*"
+ applied = True
+ elif transform_val in unit_lookup:
+ resolved_code = unit_lookup[transform_val]
+ params[unit_dim] = resolved_code
+ applied = True
+
+ # Raise error if transform value is not valid for dataflow
+ if not applied:
+ available = []
+ if transform_lookup:
+ available.extend(
+ sorted(
+ set(transform_lookup.keys())
+ - set(transform_lookup.values())
+ )
+ )
+ if unit_lookup:
+ available.extend(
+ sorted(
+ set(unit_lookup.keys()) - set(unit_lookup.values())
+ )
+ )
+ if not transform_dim and not unit_dim:
+ raise OpenBBError(
+ f"Dataflow '{dataflow}' does not support transform/unit parameter."
+ )
+ raise OpenBBError(
+ f"Invalid transform value '{query.transform}' for dataflow '{dataflow}'. "
+ f"Available options: {', '.join(available) if available else 'none'}"
+ )
+
+ if query.limit is not None:
+ params["lastNObservations"] = query.limit
+
+ # Detect indicator dimensions for this dataflow
+ dimension_codes = detect_indicator_dimensions(
+ dataflow, indicator_codes, query_builder.metadata
+ )
+
+ # Add indicator codes to appropriate dimensions
+ for dim_id, codes in dimension_codes.items():
+ params[dim_id] = "+".join(codes)
+
+ try:
+ result = query_builder.fetch_data(
+ dataflow=dataflow,
+ start_date=start_date,
+ end_date=end_date,
+ **params,
+ )
+ # Add dataflow info to each record
+ for record in result.get("data", []):
+ record["_dataflow"] = dataflow
+ all_data.extend(result.get("data", []))
+ all_metadata[dataflow] = result.get("metadata", {})
+ except ValueError as e:
+ # Translate IMF codes to user-friendly names and raise as OpenBBError
+ raise OpenBBError(translate_error_message(str(e))) from e
+
+ return {
+ "mode": "indicator",
+ "data": all_data,
+ "metadata": all_metadata,
+ }
@staticmethod
def transform_data(
query: ImfEconomicIndicatorsQueryParams,
- data: list[dict],
+ data: dict,
**kwargs: Any,
- ) -> list[ImfEconomicIndicatorsData]:
+ ) -> AnnotatedResult[list[ImfEconomicIndicatorsData]]:
"""Transform the data."""
- # pylint: disable = import-outside-toplevel
- from numpy import nan
- from pandas import Categorical, DataFrame
+ mode = data.get("mode", "indicator")
+ row_data = data.get("data", [])
- if not data:
- raise EmptyDataError("The data is empty.")
+ if not row_data:
+ raise EmptyDataError("No data returned for the given query parameters.")
- all_symbols = {
- **load_symbols("all"),
- }
- df = DataFrame(data)
+ result: list = []
+ metadata: dict = {}
- if df.empty:
- raise EmptyDataError("The data is empty.")
+ if mode == "table":
+ metadata = {
+ "table": data.get("table_metadata", {}),
+ "series": data.get("series_metadata", {}),
+ }
+ else:
+ metadata = data.get("metadata", {})
- df = df[df["symbol"].isin(all_symbols)]
+ for item in row_data:
+ # Filter by date range if needed (IMF API date filtering can be inconsistent)
+ item_date = item.get("TIME_PERIOD") or item.get("date")
- if len(df) == 0:
- raise OpenBBError("The data has a length of 0.")
+ # Normalize date format for comparison and storage
+ if item_date:
+ item_date = parse_time_period(item_date)
- df["symbol"] = Categorical(
- df["symbol"],
- categories=all_symbols,
- ordered=True,
+ if (
+ query.start_date
+ and item_date
+ and item_date < query.start_date.strftime("%Y-%m-%d")
+ ):
+ continue
+ if (
+ query.end_date
+ and item_date
+ and item_date > query.end_date.strftime("%Y-%m-%d")
+ ):
+ continue
+
+ # Extract indicator code from various possible fields
+ symbol = (
+ item.get("series_id") # Prefer full series_id (dataflow::codes)
+ or item.get("INDICATOR_code")
+ or item.get("BOP_ACCOUNTING_ENTRY_code")
+ or item.get("SERIES_code")
+ or item.get("ITEM_code")
+ or item.get("indicator_code")
+ or item.get("symbol")
+ )
+ # Get country info (ISORA uses JURISDICTION instead of COUNTRY)
+ country = (
+ item.get("COUNTRY") or item.get("JURISDICTION") or item.get("country")
+ )
+ country_code = (
+ item.get("country_code")
+ or item.get("COUNTRY_code")
+ or item.get("JURISDICTION_code")
+ )
+ # Get hierarchy info (for table mode)
+ order = item.get("order")
+ level = item.get("level")
+ parent_id = item.get("parent_id")
+ is_category_header = item.get("is_category_header", False)
+ # Get title/label - use the title from table_builder which is the indicator name
+ # For data rows, this is the specific indicator (e.g., "Direct investment, Equity...")
+ # For headers, this is the category name with units (e.g., "Goods (Millions, US Dollar)")
+ title = item.get("title") or item.get("INDICATOR") or item.get("label")
+ # Get value - use explicit None check to handle 0 correctly
+ value = item.get("OBS_VALUE")
+
+ if value is None:
+ value = item.get("value")
+
+ # Sanitize scale - convert nan/None to None, ensure string type
+ scale_val = item.get("scale") or item.get("SCALE")
+ if scale_val is not None:
+ if str(scale_val).lower() == "nan":
+ scale_val = None
+ elif not isinstance(scale_val, str):
+ scale_val = str(scale_val) if scale_val else None
+
+ # Sanitize unit - convert nan/None to None, ensure string type
+ unit_val = (
+ item.get("unit")
+ or item.get("UNIT")
+ or item.get("TYPE_OF_TRANSFORMATION")
+ )
+ if unit_val is not None:
+ if str(unit_val).lower() == "nan":
+ unit_val = None
+ elif not isinstance(unit_val, str):
+ unit_val = str(unit_val) if unit_val else None
+
+ new_row = {
+ "date": item_date,
+ "symbol": symbol,
+ "country": country,
+ "country_code": country_code,
+ "value": value,
+ "unit": unit_val,
+ "unit_multiplier": item.get("unit_multiplier") or item.get("UNIT_MULT"),
+ "scale": scale_val,
+ "order": order,
+ "level": level,
+ "symbol_root": parent_id, # Map to symbol_root for base class
+ "parent_id": parent_id, # Also keep as parent_id
+ "parent_code": item.get(
+ "parent_code"
+ ), # Resolved parent indicator code
+ "hierarchy_node_id": item.get(
+ "hierarchy_node_id"
+ ), # Hierarchy node ID for parent tracing
+ "title": title,
+ "description": item.get("description"),
+ "series_id": item.get("series_id"),
+ "is_category_header": is_category_header,
+ }
+
+ # Dynamically add ALL dimension fields from the raw data
+ # This captures any dimension like SECTOR, PRICE_TYPE, DV_TYPE,
+ # COUNTERPART_COUNTRY, etc. with both label and code
+ for key, val in item.items():
+ # Skip fields we've already handled
+ if key in new_row:
+ continue
+ # Skip internal/metadata fields
+ if key in {
+ "TIME_PERIOD",
+ "OBS_VALUE",
+ "value",
+ "indicator_codes",
+ "COUNTRY",
+ "country_code",
+ "SCALE",
+ "UNIT",
+ "unit_multiplier",
+ }:
+ continue
+ # Include dimension fields (UPPERCASE) and their _code variants
+ if key.isupper() or key.endswith("_code"):
+ # Convert to snake_case for the field name
+ field_name = key.lower()
+ new_row[field_name] = val
+
+ result.append(new_row)
+
+ # Check if all records were filtered out
+ if not result:
+ raise EmptyDataError(
+ "No data remaining after applying date filters. "
+ "Try adjusting start_date and end_date parameters."
+ )
+
+ result.sort(
+ key=lambda x: (
+ x["order"] if x.get("order") is not None else 9999,
+ x["date"] if x.get("date") else "",
+ x["country"] or "",
+ )
)
- df["parent"] = Categorical(
- df["parent"],
- categories=all_symbols,
- ordered=True,
- )
- df = df.sort_values(
- by=["date", "parent", "symbol", "value"],
- ascending=[True, True, True, False],
- ).reset_index(drop=True)
+ to_exclude = [
+ "is_category_header",
+ "hierarchy_node_id",
+ "parent_id",
+ "indicator_code",
+ "parent_code",
+ "series_id",
+ ]
+ # Non-pivot mode: return flat list
+ if not query.pivot:
+ new_data: list = []
+ for row in result:
+ if not row.get("date"):
+ continue
+ # Exclude internal fields
+ row["symbol"] = row.get("series_id")
+ for field in to_exclude:
+ _ = row.pop(field, None)
+ new_data.append(ImfEconomicIndicatorsData.model_validate(row))
- df.loc[:, "title"] = df.symbol.apply(
- lambda x: all_symbols.get(x, {}).get("title")
- )
- records = df.replace({nan: None}).to_dict(orient="records")
+ return AnnotatedResult(
+ result=new_data,
+ metadata=metadata,
+ )
- return [ImfEconomicIndicatorsData.model_validate(r) for r in records]
+ # Pivot mode: use the table_presentation utility module
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.table_presentation import pivot_table_data
+
+ result_df = pivot_table_data(
+ result=result,
+ country=query.country, # type: ignore
+ limit=query.limit,
+ metadata=metadata,
+ )
+ result_df = result_df.fillna(0).reset_index()
+
+ return AnnotatedResult(
+ result=[
+ ImfEconomicIndicatorsData.model_validate(r)
+ for r in result_df.to_dict(orient="records")
+ ],
+ metadata=metadata,
+ )
diff --git a/openbb_platform/providers/imf/openbb_imf/models/indicator_metadata.py b/openbb_platform/providers/imf/openbb_imf/models/indicator_metadata.py
new file mode 100644
index 00000000000..7bc45d861c6
--- /dev/null
+++ b/openbb_platform/providers/imf/openbb_imf/models/indicator_metadata.py
@@ -0,0 +1,82 @@
+"""Miscellaneous metadata models for IMF provider."""
+
+from openbb_core.provider.abstract.data import Data
+from openbb_core.provider.abstract.query_params import QueryParams
+from pydantic import Field
+
+
+class ImfIndicatorMetadata(Data):
+ """Model representing an IMF Indicator."""
+
+ symbol: str = Field(
+ description="Concatenated series identifier for the indicator.",
+ validation_alias="series_id",
+ serialization_alias="symbol",
+ json_schema_extra={
+ "x-widget_config": {
+ "renderFn": "cellOnClick",
+ "renderFnParams": {
+ "actionType": "groupBy",
+ "groupBy": {
+ "paramName": "symbol",
+ "valueField": "symbol",
+ },
+ },
+ }
+ },
+ )
+ label: str = Field(description="Human-readable label for the indicator.")
+ description: str | None = Field(
+ default=None, description="Detailed description of the indicator."
+ )
+ dimension_id: str = Field(
+ description="The dimension ID of the indicator in the data structure."
+ )
+ indicator: str = Field(description="Indicator code.")
+ agency_id: str = Field(description="The agency ID responsible for the indicator.")
+ structure_id: str = Field(
+ description="The data structure ID associated with the indicator."
+ )
+ dataflow_id: str = Field(
+ description="The IMF dataflow ID the indicator belongs to."
+ )
+ dataflow_name: str = Field(description="The name of the IMF dataflow.")
+
+
+class ImfTableMetadata(Data):
+ """Model representing an IMF Table."""
+
+ name: str = Field(description="The name of the IMF table.")
+ symbol: str = Field(description="The table symbol.")
+ description: str = Field(description="Description of the IMF table.")
+ agency_id: str = Field(description="The agency ID responsible for the table.")
+ dataflow_id: str = Field(description="The IMF dataflow ID the table belongs to.")
+ codelist_id: str = Field(description="The codelist ID associated with the table.")
+
+
+class ImfPresentationTableQuery(QueryParams):
+ """Query parameters for IMF presentation table metadata."""
+
+ dataflow: str | None = Field(
+ default=None,
+ description="The IMF dataflow ID. See list_dataflows() for options.",
+ )
+ table: str | None = Field(
+ default=None, description="The IMF table ID. See list_tables() for options."
+ )
+ country: str | None = Field(
+ default=None, description="Country code to filter the data."
+ )
+ frequency: str | None = Field(default=None, description="Frequency of the data.")
+ dimension_values: str | None = Field(
+ default=None,
+ description="Dimension selection for filtering. Format: 'DIM_ID1:VAL1+VAL2.'"
+ + " See presentation_table_choices() and list_dataflow_choices() for available dimensions and values.",
+ )
+ limit: int = Field(
+ default=4, description="Maximum number of records to retrieve per series."
+ )
+ raw: bool = Field(
+ default=False,
+ description="Return presentation table as raw JSON data if True.",
+ )
diff --git a/openbb_platform/providers/imf/openbb_imf/models/port_info.py b/openbb_platform/providers/imf/openbb_imf/models/port_info.py
index 2f6ea49d07e..0d92b99caf9 100644
--- a/openbb_platform/providers/imf/openbb_imf/models/port_info.py
+++ b/openbb_platform/providers/imf/openbb_imf/models/port_info.py
@@ -38,19 +38,27 @@ class ImfPortInfoQueryParams(PortInfoQueryParams):
"style": {"popupWidth": 350},
}
},
+ "port_code": {
+ "x-widget_config": {
+ "description": "Dummy parameter for widget grouping.",
+ }
+ },
}
continent: PortContinents | None = Field(
default=None,
description="Filter by continent. This parameter is ignored when a `country` is provided.",
)
-
country: PortCountries | None = Field(
default=None,
description="Country to focus on. Enter as a 3-letter ISO country code."
+ " This parameter supersedes `continent` if both are provided.",
)
-
+ port_code: str | None = Field(
+ default=None,
+ exclude=True,
+ description="This is a dummy parameter to allow grouping in OpenBB Workspace widgets.",
+ )
limit: int | None = Field(
default=None,
description="Limit the number of results returned."
@@ -72,7 +80,8 @@ class ImfPortInfoData(PortInfoData):
"$.gridData": {
"h": 25,
"w": 25,
- }
+ },
+ "$.description": "General information and statistics about global ports.",
}
},
)
@@ -91,6 +100,16 @@ class ImfPortInfoData(PortInfoData):
port_code: str = Field(
description="Unique ID assigned to the port.",
title="Port ID",
+ json_schema_extra={
+ "x-widget_config": {
+ "description": "Unique ID assigned to the port. Click on a value to change the widget group value.",
+ "renderFn": "cellOnClick",
+ "renderFnParams": {
+ "actionType": "groupBy",
+ "groupByParamName": "port_code",
+ },
+ }
+ },
)
continent: str = Field(
description="Continent where the port is located.",
diff --git a/openbb_platform/providers/imf/openbb_imf/models/port_volume.py b/openbb_platform/providers/imf/openbb_imf/models/port_volume.py
index 8647a4ba182..dc6783096a8 100644
--- a/openbb_platform/providers/imf/openbb_imf/models/port_volume.py
+++ b/openbb_platform/providers/imf/openbb_imf/models/port_volume.py
@@ -6,6 +6,7 @@ from datetime import date as dateType
from typing import Any
from openbb_core.app.model.abstract.error import OpenBBError
+from openbb_core.app.service.system_service import SystemService
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.standard_models.port_volume import (
PortVolumeData,
@@ -22,6 +23,8 @@ from openbb_imf.utils.port_watch_helpers import (
)
from pydantic import ConfigDict, Field, field_validator, model_validator
+api_prefix = SystemService().system_settings.api_settings.prefix
+
class ImfPortVolumeQueryParams(PortVolumeQueryParams):
"""IMF Port Volume Query Parameters.
@@ -37,7 +40,7 @@ class ImfPortVolumeQueryParams(PortVolumeQueryParams):
"port_code": {
"multiple_items_allowed": True,
"x-widget_config": {
- "options": get_port_id_choices(),
+ "optionsEndpoint": f"{api_prefix}/imf_utils/list_port_id_choices",
"style": {"popupWidth": 350},
},
},
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/constants.py b/openbb_platform/providers/imf/openbb_imf/utils/constants.py
index de8014f49d5..686ebcf9dca 100644
--- a/openbb_platform/providers/imf/openbb_imf/utils/constants.py
+++ b/openbb_platform/providers/imf/openbb_imf/utils/constants.py
@@ -455,39 +455,6 @@ PortCountries = Literal[
]
-FSI_PRESETS = [
- "fsi_core",
- "fsi_core_underlying",
- "fsi_other",
- "fsi_encouraged_set",
- "fsi_balance_sheets",
- "fsi_all",
-]
-
-IRFCL_HEADLINE = "RAF_USD,RAFA_USD,RAFAFX_USD,RAOFA_USD,RAPFA_USD,RAFAIMF_USD,RAFASDR_USD,RAFAGOLD_USD,RACFA_USD,RAMDCD_USD,RAMFIFC_USD,RAMSR_USD"
-
-RESERVE_ASSETS_AND_OTHER_FX_ASSETS = "RAF_USD,RAFA_USD,RAFAFX_USD,RAFAFXS_USD,RAFAFXSI_USD,RAFAFXCD_USD,RAFAFXCDN_USD,RAFAFXCDBI_USD,RAFAFXCDBIA_USD,RAFAFXCDBO_USD,RAFAFXCDBOA_USD,RAFAIMF_USD,RAFASDR_USD,RAFAGOLD_USD,RAFAGOLDV_OZT,RAFAO_USD,RAFAOF_USD,RAFAOL_USD,RAFAOO_USD,RAOFA_USD,RAOFAS_USD,RAOFAD_USD,RAOFAL_USD,RAOFAF_USD,RAOFAG_USD"
-
-PREDETERMINED_DRAINS_ON_FX_ASSETS = "RAPFA_USD,RAPFALSD_USD,RAPFALSD_1M_USD,RAPFALSD_1M_3M_USD,RAPFALSD_3M_1Y_USD,RAPFALSDOP_USD,RAPFALSDOP_1M_USD,RAPFALSDOP_1M_3M_USD,RAPFALSDOP_3M_1Y_USD,RAPFALSDOI_USD,RAPFALSDOI_1M_USD,RAPFALSDOI_1M_3M_USD,RAPFALSDOI_3M_1Y_USD,RAPFALSDIP_USD,RAPFALSDIP_1M_USD,RAPFALSDIP_1M_3M_USD,RAPFALSDIP_3M_1Y_USD,RAPFALSDII_USD,RAPFALSDII_1M_USD,RAPFALSDII_1M_3M_USD,RAPFALSDII_3M_1Y_USD,RAPFAFFS_USD,RAPFAFFS_1M_USD,RAPFAFFS_1M_3M_USD,RAPFAFFS_3M_1Y_USD,RAPFAFFL_USD,RAPFAFFL_1M_USD,RAPFAFFL_1M_3M_USD,RAPFAFFL_3M_1Y_USD,RAPFAO_USD,RAPFAO_1M_USD,RAPFAO_1M_3M_USD,RAPFAO_3M_1Y_USD,RAPFAOOR_USD,RAPFAOOR_1M_USD,RAPFAOOR_1M_3M_USD,RAPFAOOR_3M_1Y_USD,RAPFAOIRR_USD,RAPFAOIRR_1M_USD,RAPFAOIRR_1M_3M_USD,RAPFAOIRR_3M_1Y_USD,RAPFAOOC_USD,RAPFAOOC_1M_USD,RAPFAOOC_1M_3M_USD,RAPFAOOC_3M_1Y_USD,RAPFAOIC_USD,RAPFAOIC_1M_USD,RAPFAOIC_1M_3M_USD,RAPFAOIC_3M_1Y_USD,RAPFAOOP_USD,RAPFAOOP_1M_USD,RAPFAOOP_1M_3M_USD,RAPFAOOP_3M_1Y_USD,RAPFAOIR_USD,RAPFAOIR_1M_USD,RAPFAOIR_1M_3M_USD,RAPFAOIR_3M_1Y_USD,RAFA_RAPFA_RO"
-
-CONTINGENT_DRAINS_FX_ASSETS = "RACFA_USD,RACFAL_USD,RACFAL_1M_USD,RACFAL_1M_3M_USD,RACFAL_3M_1Y_USD,RACFALG_USD,RACFALG_1M_USD,RACFALG_1M_3M_USD,RACFALO_USD,RACFALO_1M_USD,RACFALO_1M_3M_USD,RACFALO_3M_1Y_USD,RACFAS_USD,RACFACB_USD,RACFACB_1M_USD,RACFACB_1M_3M_USD,RACFACB_3M_1Y_USD,RACFACBA_USD,RACFACBA_1M_USD,RACFACBA_1M_3M_USD,RACFACBA_3M_1Y_USD,RACFACBAOI_USD,RACFACBAOI_1M_USD,RACFACBAOI_1M_3M_USD,RACFACBAON_USD,RACFACBAON_1M_USD,RACFACBAON_1M_3M_USD,RACFACBAON_3M_1Y_USD,RACFACBABIS_USD,RACFACBAIMF_1M_USD,RACFACBAIMF_1M_3M_USD,RACFACBAIMF_3M_1Y_USD,RACFACBAIMF_USD,RACFACBFIR_USD,RACFACBFIR_1M_USD,RACFACBFIR_1M_3M_USD,RACFACBFIR_3M_1Y_USD,RACFACBFIO_USD,RACFACBFIO_1M_USD,RACFACBFIO_1M_3M_USD,RACFACBFIO_3M_1Y_USD,RACFACT_USD,RACFACT_1M_USD,RACFACT_1M_3M_USD,RACFACT_3M_1Y_USD,RACFACTA_USD,RACFACTA_1M_USD,RACFACTA_1M_3M_USD,RACFACTA_3M_1Y_USD,RACFACTAOI_USD,RACFACTAOI_1M_USD,RACFACTAOI_1M_3M_USD,RACFACTAOI_3M_1Y_USD,RACFACTAON_USD,RACFACTAON_1M_USD,RACFACTAON_1M_3M_USD,RACFACTAON_3M_1Y_USD,RACFACTABIS_USD,RACFACTABIS_1M_USD,RACFACTABIS_1M_3M_USD,RACFACTABIS_3M_1Y_USD,RACFACTAIMF_USD,RACFACTAIMF_1M_USD,RACFACTFIR_USD,RACFACTFIR_1M_USD,RACFACTFIR_1M_3M_USD,RACFACTFIR_3M_1Y_USD,RACFACTFIO_USD,RACFACTFIO_1M_USD,RACFACTFIO_1M_3M_USD,RACFACTFIO_3M_1Y_USD,RACFAPPS_USD,RACFAPPS_1M_USD,RACFAPPS_1M_3M_USD,RACFAPPS_3M_1Y_USD,RACFAPPSBP_USD,RACFAPPSBP_1M_USD,RACFAPPSBP_1M_3M_USD,RACFAPPSBP_3M_1Y_USD,RACFAPPSWC_USD,RACFAPPSWC_1M_USD,RACFAPPSWC_1M_3M_USD,RACFAPPSWC_3M_1Y_USD,RACFAPPL_USD,RACFAPPL_1M_USD,RACFAPPL_1M_3M_USD,RACFAPPL_3M_1Y_USD,RACFAPPLBC_USD,RACFAPPLBC_1M_USD,RACFAPPLBC_1M_3M_USD,RACFAPPLBC_3M_1Y_USD,RACFAPPLWP_USD,RACFAPPLWP_1M_USD,RACFAPPLWP_1M_3M_USD,RACFAPPLWP_3M_1Y_USD,RACFAMPAS_USD,RACFAMPAS_1M_USD,RACFAMPAS_1M_3M_USD,RACFAMPAS_3M_1Y_USD,RACFAMPAL_USD,RACFAMPAL_1M_USD,RACFAMPAL_1M_3M_USD,RACFAMPAL_3M_1Y_USD,RACFAMPBS_USD,RACFAMPBS_1M_USD,RACFAMPBS_1M_3M_USD,RACFAMPBS_3M_1Y_USD,RACFAMPBL_USD,RACFAMPBL_1M_USD,RACFAMPBL_1M_3M_USD,RACFAMPBL_3M_1Y_USD,RACFAMPCS_USD,RACFAMPCS_1M_USD,RACFAMPCS_1M_3M_USD,RACFAMPCS_3M_1Y_USD,RACFAMPCL_USD,RACFAMPCL_1M_USD,RACFAMPCL_1M_3M_USD,RACFAMPCL_3M_1Y_USD,RACFAMPDS_USD,RACFAMPDS_1M_USD,RACFAMPDS_1M_3M_USD,RACFAMPDS_3M_1Y_USD,RACFAMPDL_USD,RACFAMPDL_1M_USD,RACFAMPDL_1M_3M_USD,RACFAMPDL_3M_1Y_USD,RACFAMPES_USD,RACFAMPES_1M_USD,RACFAMPES_1M_3M_USD,RACFAMPES_3M_1Y_USD,RACFAMPEL_USD,RACFAMPEL_1M_USD,RACFAMPEL_1M_3M_USD,RACFAMPEL_3M_1Y_USD,RACFAMPFS_USD,RACFAMPFS_1M_USD,RACFAMPFS_1M_3M_USD,RACFAMPFS_3M_1Y_USD,RACFAMPFL_USD,RACFAMPFL_1M_USD,RACFAMPFL_1M_3M_USD,RACFAMPFL_3M_1Y_USD"
-
-IRFCL_MEMORANDUM_ITEMS = "RAMDCD_USD,RAMFIFC_USD,RAMPA_USD,RAMFFS_USD,RAMPAOA_USD,RAMSR_USD,RAMSRLRI_USD,RAMSRLRN_USD,RAMSRBRI_USD,RAMSRBAN_USD,RAMFDA_USD,RAMFDAF_USD,RAMFDAU_USD,RAMFDAW_USD,RAMFDAP_USD,RAMFDAO_USD,RAMFFL_USD,RAMPPS_USD,RAMPPSBP_USD,RAMPPSWC_USD,RAMPPL_USD,RAMPPLBP_USD,RAMPPLWC_USD,RAMCR_USD,RAMCRISDR_USD,RAMCRIC_USD_USD,RAMCRIC_EUR_USD,RAMCRIC_CNY_USD,RAMCRIC_JPY_USD,RAMCRIC_GBP_USD,RAMCROSDR_USD"
-
-IRFCL_TABLES = {
- "reserve_assets_and_other_fx_assets": RESERVE_ASSETS_AND_OTHER_FX_ASSETS,
- "predetermined_drains_on_fx_assets": PREDETERMINED_DRAINS_ON_FX_ASSETS,
- "contingent_drains_fx_assets": CONTINGENT_DRAINS_FX_ASSETS,
- "memorandum_items": IRFCL_MEMORANDUM_ITEMS,
-}
-
-IRFCL_PRESET = {
- "irfcl_top_lines": IRFCL_HEADLINE,
- **IRFCL_TABLES,
- "gold_reserves": "RAFAGOLD_USD,RAFAGOLDV_OZT",
- "derivative_assets": "RAMFDA_USD",
-}
-
FREQUENCY_DICT = {
"month": "M",
"quarter": "Q",
@@ -553,25 +520,143 @@ REF_SECTOR_MAP = {
"AllSectorsIncludingAllSectors": "All Sectors Including All Sectors",
}
+PRESENTATION_TABLES: dict[str, str] = {
+ "bop_standard": "BOP::H_BOP_BOP_AGG_STANDARD_PRESENTATION",
+ "bop_analytic": "BOP::H_BOP_BOP_AGG_ANALYTIC_PRESENTATION",
+ "bop_international_transactions": "BOP_AGG::H_BOP_AGG_SUMMARY_OF_INTERNATIONAL_TRANSACTIONS",
+ "bop_global_discrepancies": "BOP_AGG::H_BOP_AGG_GLOBAL_DISCREPANCIES",
+ "bop_global_discrepancies_percent": "BOP_AGG::H_BOP_AGG_GLOBAL_DISCREPANCIES_PERCENTAGE",
+ "dip": "DIP::H_DIP_INDICATOR",
+ "iip_aggregated": "IIP::H_BOP_IIP_AGG",
+ "iip_currency_composition": "IIPCC::H_BOP_IIPCC_CURRENCY_COMPOSITION",
+ "eer": "EER::H_EER_INDICATOR_HIERARCHY",
+ "irfcl_reserve_assets": "IRFCL::H_CL_IRFCL_INDICATOR_DEFAULT_PUB:CL_IRFCL_DEFAULT_INDICATOR_PUB2_1",
+ "irfcl_predetermined_drains": "IRFCL::H_CL_IRFCL_INDICATOR_DEFAULT_PUB:CL_IRFCL_DEFAULT_INDICATOR_PUB2_29",
+ "irfcl_contingent_drains": "IRFCL::H_CL_IRFCL_INDICATOR_DEFAULT_PUB:CL_IRFCL_DEFAULT_INDICATOR_PUB2_91",
+ "irfcl_memorandum": "IRFCL::H_CL_IRFCL_INDICATOR_DEFAULT_PUB:CL_IRFCL_DEFAULT_INDICATOR_PUB2_273",
+ "fsi_core_and_additional": "FSIC::H_FSIC_INDICATOR",
+ "fsi_indicators_and_underlying": "FSIC::H_FSIC_FSD",
+ "fsi_concentration_distribution_measures": "FSICDM::H_FSI_FSICDM_CDM",
+ "mfs_monetary_aggs": "MFS_MA::H_CL_MFS_MA_INDICATOR_5SR",
+ "mfs_central_bank": "MFS_CBS::H_CL_MFS_CBS_SRFS_1SG_VIEW",
+ "mfs_other_depository_corps": "MFS_ODC::H_CL_MFS_ODC_SRFS_2SG_VIEW",
+ "mfs_depository_corps": "MFS_DC::H_CL_MFS_DCS_SRFS_3SG_VIEW",
+ "mfs_other_financial_corps": "MFS_OFC::H_CL_MFS_OFC_SRFS_4SG_VIEW",
+ "mfs_financial_corps": "MFS_FC::H_CL_MFS_FCS_SRFS_5SG_VIEW",
+ "gfs_balance": "GFS_BS::H_GFS_BS",
+ "gfs_operations": "GFS_SOO::H_GFS_SOO",
+ "gfs_expenditures": "GFS_COFOG::H_GFS_COFOG",
+ "gfs_stocks_and_flows": "GFS_SFCP::H_GFS_SFCP",
+ "gfs_sources_and_uses": "GFS_SSUC::H_GFS_SSUC",
+ "qgfs_balance": "QGFS::H_QGFS_BALANCE",
+ "qgfs_operations": "QGFS::H_QGFS_STATGO",
+ "qgfs_sources_and_uses": "QGFS::H_QGFS_GSUC",
+ "gdp_annual_expenditure": "ANEA::H_NEA_GDP_BY_EXPENDITURE",
+ "gdp_quarterly_expenditure": "QNEA::H_NEA_GDP_BY_EXPENDITURE",
+ "cpi": "CPI::H_CPI_BY_COMPONENT",
+ "fas_indicator_by_country": "FAS::H_FAS_INDICATOR_BY_COUNTRY",
+ "fas_data_by_country": "FAS::H_FAS_BY_COUNTRY",
+ "isora_indicators_by_topic": "ISORA_LATEST_DATA_PUB::H_CL_INDICATORS_BY_TOPIC",
+}
-def load_symbols(dataset: str) -> dict:
- """Load IMF symbol list."""
- # pylint: disable=import-outside-toplevel
- import json # noqa
- from json.decoder import JSONDecodeError
- from pathlib import Path
- from openbb_core.app.model.abstract.error import OpenBBError
+table_dataflow_map: dict[str, list[str]] = {
+ "bop": [
+ "bop_standard",
+ "bop_analytic",
+ "bop_international_transactions",
+ "bop_global_discrepancies",
+ "bop_global_discrepancies_percent",
+ ],
+ "dip": ["dip"],
+ "iip": ["iip_aggregated", "iip_currency_composition"],
+ "eer": ["eer"],
+ "irfcl": [
+ "irfcl_reserve_assets",
+ "irfcl_predetermined_drains",
+ "irfcl_contingent_drains",
+ "irfcl_memorandum",
+ ],
+ "fsi": [
+ "fsi_core_and_additional",
+ "fsi_indicators_and_underlying",
+ "fsi_concentration_distribution_measures",
+ ],
+ "mfs": [
+ "mfs_monetary_aggs",
+ "mfs_central_bank",
+ "mfs_other_depository_corps",
+ "mfs_depository_corps",
+ "mfs_other_financial_corps",
+ "mfs_financial_corps",
+ ],
+ "gfs": [
+ "gfs_balance",
+ "gfs_operations",
+ "gfs_expenditures",
+ "gfs_stocks_and_flows",
+ "gfs_sources_and_uses",
+ ],
+ "qgfs": ["qgfs_balance", "qgfs_operations", "qgfs_sources_and_uses"],
+ "gdp": ["gdp_annual_expenditure", "gdp_quarterly_expenditure"],
+ "cpi": ["cpi"],
+ "fas": ["fas_indicator_by_country", "fas_data_by_country"],
+ "isora": ["isora_indicators_by_topic"],
+}
- try:
- symbols_file = Path(__file__).parents[1].joinpath("assets", "imf_symbols.json")
- with symbols_file.open(encoding="utf-8") as file:
- symbols = json.load(file)
- except (FileNotFoundError, JSONDecodeError) as e:
- raise OpenBBError(
- f"Failed to load IMF symbols from the static file: {e}"
- ) from e
+table_dataflow_choices: list[dict[str, str]] = [
+ {"label": "Balance of Payments", "value": "bop"},
+ {"label": "Direct Investment Position", "value": "dip"},
+ {"label": "Effective Exchange Rates", "value": "eer"},
+ {"label": "International Investment Position", "value": "iip"},
+ {
+ "label": "International Reserves and Foreign Currency Liquidity",
+ "value": "irfcl",
+ },
+ {"label": "Financial Soundness Indicators", "value": "fsi"},
+ {"label": "Monetary and Financial Statistics", "value": "mfs"},
+ {"label": "Government Finance Statistics", "value": "gfs"},
+ {"label": "Quarterly Government Finance Statistics", "value": "qgfs"},
+ {"label": "Gross Domestic Product", "value": "gdp"},
+ {"label": "Consumer Price Index", "value": "cpi"},
+ {"label": "Financial Access Survey", "value": "fas"},
+ {"label": "ISORA Indicators", "value": "isora"},
+]
- if dataset == "all":
- return symbols
-
- return {k: v for k, v in symbols.items() if v["dataset"] == dataset}
+table_name_map: dict[str, str] = {
+ "bop_standard": "Standard Presentation",
+ "bop_analytic": "Analytic Presentation",
+ "bop_international_transactions": "Summary of International Transactions",
+ "bop_global_discrepancies": "Global Discrepancies",
+ "bop_global_discrepancies_percent": "Global Discrepancies Percentage",
+ "dip": "Direct Investment Position",
+ "iip_aggregated": "Aggregated International Investment Position",
+ "iip_currency_composition": "Currency Composition of International Investment Position",
+ "eer": "Effective Exchange Rates",
+ "irfcl_reserve_assets": "Reserve Assets",
+ "irfcl_predetermined_drains": "Predetermined Drains",
+ "irfcl_contingent_drains": "Contingent Drains",
+ "irfcl_memorandum": "Memorandum Items",
+ "fsi_core_and_additional": "Core and Additional Financial Soundness Indicators",
+ "fsi_indicators_and_underlying": "Financial Soundness Indicators and Underlying Data",
+ "fsi_concentration_distribution_measures": "Concentration and Distribution Measures",
+ "mfs_monetary_aggs": "Monetary Aggregates",
+ "mfs_central_bank": "Central Bank",
+ "mfs_other_depository_corps": "Other Depository Corporations",
+ "mfs_depository_corps": "Depository Corporations",
+ "mfs_financial_corps": "Financial Corporations",
+ "mfs_other_financial_corps": "Other Financial Corporations",
+ "gfs_balance": "Government Balance Sheet",
+ "gfs_operations": "Statement of Government Operations",
+ "gfs_expenditures": "Expenditures by Function (COFOG)",
+ "gfs_stocks_and_flows": "Stocks and Flows of Central Government",
+ "gfs_sources_and_uses": "Sources and Uses of Central Government Funds",
+ "qgfs_balance": "Quarterly Government Finance Statistics Balance Sheet",
+ "qgfs_operations": "Quarterly Government Finance Statistics Statement of Operations",
+ "qgfs_sources_and_uses": "Quarterly Government Finance Statistics Sources and Uses of Funds",
+ "gdp_annual_expenditure": "Annual GDP by Expenditure",
+ "gdp_quarterly_expenditure": "Quarterly GDP by Expenditure",
+ "cpi": "Consumer Price Index by Component",
+ "fas_indicator_by_country": "Financial Access Survey Indicators by Country",
+ "fas_data_by_country": "Financial Access Survey Data by Country",
+ "isora_indicators_by_topic": "ISORA Indicators by Topic",
+}
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/dot_helpers.py b/openbb_platform/providers/imf/openbb_imf/utils/dot_helpers.py
index 0ae7a336157..43671100172 100644
--- a/openbb_platform/providers/imf/openbb_imf/utils/dot_helpers.py
+++ b/openbb_platform/providers/imf/openbb_imf/utils/dot_helpers.py
@@ -1,87 +1,230 @@
"""Direction Of Trade Utilities."""
-from openbb_core.app.model.abstract.error import OpenBBError
+# pylint: disable=R0917,R0913,R0914,R0801
-def load_country_map() -> dict:
+def load_country_choices() -> list[dict[str, str]]:
"""Load IMF IRFCL country map."""
# pylint: disable=import-outside-toplevel
- import json # noqa
- from json.decoder import JSONDecodeError
- from pathlib import Path
+ from openbb_imf.utils.metadata import ImfMetadata
- try:
- country_map_file = (
- Path(__file__).parents[1].joinpath("assets", "imf_country_map.json")
- )
- with country_map_file.open(encoding="utf-8") as file:
- country_map_dict = json.load(file)
- except (FileNotFoundError, JSONDecodeError) as e:
- raise OpenBBError(f"Failed to load IMF DOT country map: {e}") from e
+ data = ImfMetadata().get_dataflow_parameters("IMTS")["COUNTRY"]
+ countries_list: list = []
+ g_regions: list = []
+ tx_groups: list = []
- return country_map_dict
-
-
-def load_country_to_code_map() -> dict:
- """Load a map of lowercase country name to 2-letter ISO symbol."""
- # pylint: disable=import-outside-toplevel
- from warnings import warn # noqa
-
- try:
- return {
- (
- "euro_area"
- if k == "U2"
- else v.lower()
- .replace(".", "")
- .replace(",", "")
- .replace(":", "")
- .split("(")[0]
- .strip()
- .replace(" ", "_")
- ): k
- for k, v in load_country_map().items()
- if not k.startswith("1C_ALL")
- and "Report" not in v
- and k not in ("GW", "_X")
- }
- except OpenBBError:
- warn(f"Failed to load country to code map. -> {OpenBBError}")
- return {}
-
-
-def validate_countries(countries: list[str]) -> str:
- """Validate the list of countries."""
- # pylint: disable=import-outside-toplevel
- from warnings import warn
-
- try:
- country_to_code_map = load_country_to_code_map()
- except OpenBBError as e:
- raise OpenBBError(f"Failed to load country to code map: {e}") from e
- if not country_to_code_map:
- raise OpenBBError("Failed to load country to code map.")
-
- new_countries: list = []
-
- for country in countries:
- if country == "all":
- return country
- if country in country_to_code_map:
- new_countries.append(country_to_code_map.get(country))
- elif country.upper() in country_to_code_map.values():
- new_countries.append(country.upper())
- elif country.lower() == "eu":
- new_countries.append(country_to_code_map.get("european_union"))
- elif country.lower() == "ea":
- new_countries.append(country_to_code_map.get("euro_area"))
+ for item in data:
+ if item["value"].startswith("G") and (
+ item["value"][1:].isdigit()
+ or (item["value"][1] == "X" and item["value"][2:].isdigit())
+ ):
+ g_regions.append(item)
+ elif item["value"].startswith("TX"):
+ tx_groups.append(item)
else:
- warn(f"Invalid country {country}")
- continue
+ countries_list.append(item)
- if not new_countries:
- raise OpenBBError(
- f"No valid countries found. Please choose from {list(country_to_code_map)}"
- )
+ # Sort each group alphabetically by label
+ countries_sorted = sorted(countries_list, key=lambda x: x["label"])
+ g_regions_sorted = sorted(g_regions, key=lambda x: x["label"])
+ tx_groups_sorted = sorted(tx_groups, key=lambda x: x["label"])
- return "+".join(new_countries)
+ return countries_sorted + g_regions_sorted + tx_groups_sorted
+
+
+def list_country_choices() -> list[str]:
+ """List IMF IRFCL country choices."""
+ countries = load_country_choices()
+ return [item["value"] for item in countries]
+
+
+def get_label_to_code_map() -> dict[str, str]:
+ """Get a mapping from normalized labels to country codes.
+
+ Normalizes labels to lower_snake_case for flexible matching.
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.helpers import normalize_country_label
+
+ countries = load_country_choices()
+ return {normalize_country_label(item["label"]): item["value"] for item in countries}
+
+
+def get_code_to_label_map() -> dict[str, str]:
+ """Get a mapping from country codes to normalized labels."""
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.helpers import normalize_country_label
+
+ countries = load_country_choices()
+ return {item["value"]: normalize_country_label(item["label"]) for item in countries}
+
+
+def resolve_country_input(value: str) -> str:
+ """Resolve a country input to its ISO code.
+
+ Accepts both ISO3 codes (e.g., 'USA') and snake_case country names
+ (e.g., 'united_states'). Returns the ISO code.
+
+ Parameters
+ ----------
+ value : str
+ Country code or name to resolve.
+
+ Returns
+ -------
+ str
+ The resolved ISO country code.
+
+ Raises
+ ------
+ ValueError
+ If the input cannot be resolved to a valid country code.
+ """
+ if not value:
+ raise ValueError("Country value cannot be empty.")
+
+ # Handle wildcards
+ if value.lower() in ["all", "*"]:
+ return "*"
+
+ # Common aliases for frequently used regions
+ common_aliases = {
+ "world": "G001",
+ "euro_area": "G163",
+ "eurozone": "G163",
+ "eu": "G998",
+ "european_union": "G998",
+ "europe": "GX170",
+ }
+
+ v_lower = value.lower().replace(" ", "_")
+
+ # Check common aliases first
+ if v_lower in common_aliases:
+ return common_aliases[v_lower]
+
+ code_set = set(list_country_choices())
+ label_to_code = get_label_to_code_map()
+
+ v_upper = value.upper()
+
+ # Check if it's a valid ISO code
+ if v_upper in code_set:
+ return v_upper
+
+ # Check if it's a valid snake_case country name
+ if v_lower in label_to_code:
+ return label_to_code[v_lower]
+
+ # Not found - raise error with helpful message
+ raise ValueError(
+ f"Country '{value}' is not a valid IMF country code or country name. "
+ f"Use ISO3 codes (e.g., 'USA', 'DEU') or snake_case names (e.g., 'united_states', 'germany')."
+ )
+
+
+def imts_query(
+ country: str | list[str],
+ counterpart: str | list[str],
+ indicator: str | list[str],
+ freq: str = "A",
+ start_date: str | None = None,
+ end_date: str | None = None,
+ **kwargs,
+) -> dict:
+ """Query the Direction of Trade (IMTS) dataset.
+ This function handles input validation for countries and counterparts.
+
+ Parameters
+ ----------
+ country : str | list[str]
+ The country or countries to fetch data for.
+ counterpart : str | list[str]
+ The counterpart country or countries. Use "*" for all.
+ indicator : str | list[str]
+ The indicator or indicators to fetch. Use "*" for all.
+ freq : str | None
+ The frequency of the data, by default "A" (annual).
+ start_date : str | None
+ The start date of the data, by default None.
+ end_date : str | None
+ The end date of the data, by default None.
+ **kwargs : dict
+ Additional query parameters to pass to the API.
+
+ Returns
+ -------
+ dict
+ A dictionary with keys: 'data' containing the fetched data,
+ and 'metadata' containing the related metadata.
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ if not country or not counterpart:
+ raise ValueError("Country and counterpart parameters cannot be empty.")
+
+ freq = freq[0].upper()
+
+ if freq and freq not in ["A", "Q", "M"]:
+ raise ValueError("Frequency must be one of 'A', 'Q', or 'M'.")
+
+ query_builder = ImfQueryBuilder()
+ dataflow_id = "IMTS"
+ params = query_builder.metadata.get_dataflow_parameters(dataflow_id)
+ country_values = {item["value"] for item in params.get("COUNTRY", [])}
+ counterpart_values = {
+ item["value"]
+ for item in params.get("COUNTERPART_COUNTRY", params.get("COUNTRY", []))
+ }
+
+ def _validate_selection(selection, valid_values, name):
+ """Validate country or counterpart selection."""
+ if not valid_values:
+ return selection
+
+ # Handle wildcards - return "*" as-is
+ if selection == "*":
+ return "*"
+
+ # Parse the selection into a list
+ if isinstance(selection, str):
+ # Check if it contains commas (comma-separated list)
+ selection_list = (
+ [item.strip() for item in selection.split(",")]
+ if "," in selection
+ else [selection]
+ )
+ else:
+ selection_list = selection
+
+ # Check if any item is a wildcard
+ if "*" in selection_list:
+ return "*"
+
+ invalid = [item for item in selection_list if item not in valid_values]
+ if invalid:
+ raise ValueError(f"Invalid {name}(s): {', '.join(invalid)}")
+ return selection_list if len(selection_list) > 1 else selection_list[0]
+
+ validated_country = _validate_selection(country, country_values, "country")
+ validated_counterpart = _validate_selection(
+ counterpart, counterpart_values, "counterpart"
+ )
+
+ # For indicator, handle wildcards and comma-separated values the same way
+ if isinstance(indicator, str) and "," in indicator:
+ validated_indicator = [item.strip() for item in indicator.split(",")]
+ else:
+ validated_indicator = indicator if indicator != "*" else "*" # type: ignore
+
+ return query_builder.fetch_data(
+ dataflow=dataflow_id,
+ start_date=start_date,
+ end_date=end_date,
+ FREQUENCY=freq,
+ COUNTRY=validated_country,
+ COUNTERPART_COUNTRY=validated_counterpart,
+ INDICATOR=validated_indicator,
+ **kwargs,
+ )
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/fsi_helpers.py b/openbb_platform/providers/imf/openbb_imf/utils/fsi_helpers.py
deleted file mode 100644
index dc573cc276e..00000000000
--- a/openbb_platform/providers/imf/openbb_imf/utils/fsi_helpers.py
+++ /dev/null
@@ -1,245 +0,0 @@
-"""IMF FSI Data Set Helpers."""
-
-
-def load_fsi_symbols() -> dict:
- """Load IMF FSI symbols."""
- # pylint: disable=import-outside-toplevel
- from openbb_imf.utils.constants import load_symbols
-
- return load_symbols("FSI")
-
-
-def validate_symbols(symbols) -> str:
- """Validate the IMF FSI symbols."""
- # pylint: disable=import-outside-toplevel
- from warnings import warn # noqa
- from openbb_core.app.model.abstract.error import OpenBBError
- from openbb_imf.utils.constants import FSI_PRESETS
-
- fsi_symbols = load_fsi_symbols()
-
- if isinstance(symbols, str):
- symbols = symbols.split(",")
- elif not isinstance(symbols, list):
- raise OpenBBError("Invalid symbols list.")
-
- new_symbols: list = []
-
- for symbol in symbols:
- if symbol in FSI_PRESETS:
- return symbol
- if symbol.upper() not in fsi_symbols:
- warn(f"Unsupported IMF FSI symbol: {symbol}")
- new_symbols.append(symbol.upper())
-
- return "+".join(new_symbols) if len(new_symbols) > 1 else new_symbols[0]
-
-
-# pylint: disable=too-many-branches,too-many-statements,too-many-locals
-async def _get_fsi_data(**kwargs) -> list[dict]: # noqa:PLR0912
- """Get IMF FSI data.
- This function is not intended to be called directly,
- but through the `ImfEconomicIndicatorsFetcher` class.
- """
- # pylint: disable=import-outside-toplevel
- from datetime import date # noqa
- from openbb_core.app.model.abstract.error import OpenBBError
- from openbb_imf.utils.helpers import get_data
- from openbb_imf.utils.irfcl_helpers import load_country_to_code_map
- from openbb_imf.utils import constants
- from pandas import to_datetime
- from pandas.tseries import offsets
-
- countries = kwargs.get("country", "")
- countries = (
- "" if countries == "all" else countries.replace(",", "+") if countries else ""
- )
- frequency = constants.FREQUENCY_DICT.get(kwargs.get("frequency", "quarter"), "Q")
- start_date = kwargs.get("start_date", "")
- end_date = kwargs.get("end_date", "")
- all_symbols = load_fsi_symbols()
- core_only = [k for k, v in all_symbols.items() if v.get("table") == "fsi_core"]
- encouraged_set = [
- k for k, v in all_symbols.items() if v.get("table") == "fsi_encouraged_set"
- ]
- indicator = kwargs.get("symbol")
- indicators = (
- "+".join(core_only if indicator == "fsi_core" else encouraged_set)
- if indicator in ["fsi_core", "fsi_encouraged_set"]
- else (
- ""
- if indicator
- in ["fsi_other", "fsi_all", "fsi_core_underlying", "fsi_balance_sheets"]
- else validate_symbols(indicator)
- )
- )
-
- if not indicators and not countries:
- raise OpenBBError(
- "All countries not supported for this group of indicators. Please supply a country."
- )
-
- if not start_date and not end_date and not countries:
- start_date = (
- date.today().replace(year=date.today().year - 1).strftime("%Y-%m-%d")
- )
-
- if start_date and not end_date:
- end_date = date.today().strftime("%Y-%m-%d")
-
- # Adjust the dates to the date relative to frequency.
- # The API does not accept arbitrary dates, so we need to adjust them.
- if start_date:
- start_date = to_datetime(start_date)
- if frequency == "Q":
- start_date = offsets.QuarterBegin(startingMonth=1).rollback(start_date)
- elif frequency == "A":
- start_date = offsets.YearBegin().rollback(start_date)
- else:
- start_date = offsets.MonthBegin().rollback(start_date)
- start_date = start_date.strftime("%Y-%m-%d")
-
- if end_date:
- end_date = to_datetime(end_date)
- if frequency == "Q":
- end_date = offsets.QuarterEnd().rollforward(end_date)
- elif frequency == "A":
- end_date = offsets.YearEnd().rollforward(end_date)
- else:
- end_date = offsets.MonthEnd().rollforward(end_date)
- end_date = end_date.strftime("%Y-%m-%d")
-
- date_range = (
- f"?startPeriod={start_date}&endPeriod={end_date}"
- if start_date and end_date
- else ""
- )
- base_url = "http://dataservices.imf.org/REST/SDMX_JSON.svc/"
- key = f"CompactData/FSI/{frequency}.{countries}.{indicators}"
- url = f"{base_url}{key}{date_range}"
- series = await get_data(url)
- data: list = []
- all_symbols = load_fsi_symbols()
-
- if indicator in ["fsi_core", "fsi_encouraged_set"]:
- all_symbols = {
- k: v for k, v in all_symbols.items() if v.get("table") == indicator
- }
- elif indicator == "fsi_core_underlying":
- all_symbols = {
- k: v
- for k, v in all_symbols.items()
- if "core set" in v.get("title", "").lower() and v.get("unit") != "Percent"
- }
- elif indicator == "fsi_balance_sheets":
- all_symbols = {
- k: v
- for k, v in all_symbols.items()
- if "balance sheets" in v.get("title", "").lower()
- }
- elif indicator == "fsi_other":
- all_symbols = {
- k: v
- for k, v in all_symbols.items()
- if "Additional FSIs" in v.get("title", "")
- }
-
- country_map_dict = {
- v: k.replace("_", " ").title().replace("Ecb", "ECB")
- for k, v in load_country_to_code_map().items()
- }
- # Iterate over the series to extract observations and map the metadata.
- for s in series:
- if "Obs" not in s:
- continue
- meta = {
- k.replace("@", "").lower(): (
- constants.UNIT_MULTIPLIERS_MAP.get(str(v), v)
- if k == "@UNIT_MULT"
- else v
- )
- for k, v in s.items()
- if k != "Obs"
- }
- _symbol = meta.get("indicator")
- _parent: str | None = None
- _order: str | None = None
- _level: str | None = None
- _table: str | None = None
- _title: str | None = None
- _unit: str | None = None
-
- if _symbol not in all_symbols:
- continue
-
- _table = all_symbols.get(_symbol, {}).get("table", "")
- if _table and indicator == "fsi_core_underlying":
- _table = _table.replace("fsi_other", "fsi_core_underlying") # type: ignore
- _parent = all_symbols.get(_symbol, {}).get("parent", "")
- _order = all_symbols.get(_symbol, {}).get("order", "")
- _level = all_symbols.get(_symbol, {}).get("level", "")
- _title = all_symbols.get(_symbol, {}).get("title", "")
- if _title:
- _title = " - ".join(_title.split(", ")[1:-1]).replace(
- "Financial Soundness Indicators - ", ""
- )
- _unit = all_symbols.get(_symbol, {}).get("unit", "")
-
- _data = s.pop("Obs", [])
-
- if isinstance(_data, dict):
- _data = [_data]
-
- for d in _data:
- _date = d.pop("@TIME_PERIOD", None)
- val = d.pop("@OBS_VALUE", None)
- _ = d.pop("@OBS_STATUS", None)
- if not val:
- continue
- val = float(val)
- if _unit and _unit.lower() == "percent":
- val = val / 100
- if _date:
- offset = (
- offsets.QuarterEnd
- if "Q" in _date
- else offsets.YearEnd if len(str(_date)) == 4 else offsets.MonthEnd
- )
- _date = to_datetime(_date)
- _date = _date + offset(0)
- _date = _date.strftime("%Y-%m-%d")
- vals = {
- k: v
- for k, v in {
- "date": _date,
- "table": _table,
- "symbol": _symbol,
- "parent": _parent if _parent else _symbol,
- "level": _level,
- "order": _order,
- "country": country_map_dict.get(
- meta.get("ref_area"), meta.get("ref_area")
- ),
- "reference_sector": None,
- "title": _title,
- "value": val,
- "unit": (
- _unit.upper() if _unit and _unit in ["usd", "eur"] else _unit
- ),
- "scale": (
- "Basis Points"
- if _unit and _unit.lower() == "percent"
- else meta.get("unit_mult")
- ),
- }.items()
- if v
- }
-
- if vals.get("value") and vals.get("date"):
- d.update(vals)
- data.append(d)
-
- if not data:
- raise OpenBBError(f"No data found for, '{indicator}', in, '{countries}'.")
-
- return data
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/helpers.py b/openbb_platform/providers/imf/openbb_imf/utils/helpers.py
index 8c607f59653..5993eec6cde 100644
--- a/openbb_platform/providers/imf/openbb_imf/utils/helpers.py
+++ b/openbb_platform/providers/imf/openbb_imf/utils/helpers.py
@@ -1,34 +1,892 @@
"""IMF Helper Utilities."""
+# pylint: disable=R0912,W0212,R0914,R0915,R0916,R1702,W0718
+# flake8: noqa: PLR0912
-async def get_data(url: str) -> list[dict]:
- """Get data from the IMF API."""
+from typing import Any
+
+from openbb_core.app.model.abstract.error import OpenBBError
+
+
+def normalize_country_label(label: str) -> str:
+ """Normalize country label to lower_snake_case.
+
+ Examples
+ --------
+ >>> _normalize_country_label("United States")
+ 'united_states'
+ >>> _normalize_country_label("Armenia, Republic of")
+ 'armenia'
+ >>> _normalize_country_label("Euro Area (EA)")
+ 'euro_area'
+ >>> _normalize_country_label("Guinea-Bissau")
+ 'guinea_bissau'
+ """
# pylint: disable=import-outside-toplevel
+ import re
- from aiohttp.client_exceptions import ContentTypeError # noqa
- from json.decoder import JSONDecodeError
- from openbb_core.provider.utils.helpers import amake_request
- from openbb_core.app.model.abstract.error import OpenBBError
+ # Remove all parenthetical content (handles nested parens by repeating)
+ prev_label = None
+ while prev_label != label:
+ prev_label = label
+ label = re.sub(r"\s*\([^()]*\)", "", label)
+ # Remove everything after comma (handles ", Republic of", ", The", etc.)
+ label = label.split(",")[0].strip()
+ # Replace hyphens and spaces with underscores, convert to lowercase
+ return label.replace("-", "_").replace(" ", "_").lower()
+
+
+def resolve_country_code(country: str, metadata) -> str:
+ """
+ Resolve a country name or code to an ISO3 country code.
+
+ Uses the CL_COUNTRY codelist to resolve names like "Japan" to "JPN".
+ Returns the input unchanged if it's already an ISO3 code or can't be resolved.
+ """
+ country_upper = country.upper().strip()
+ country_lower = country.lower().strip()
+
+ # Check if it's already a valid code
+ if "CL_COUNTRY" in metadata._codelist_cache:
+ codes = metadata._codelist_cache["CL_COUNTRY"]
+ if country_upper in codes:
+ return country_upper
+
+ # Try to resolve by name
+ for code, name in codes.items():
+ if name.lower() == country_lower:
+ return code
+
+ return country_upper # Return as-is (uppercase)
+
+
+def _parse_compound_code(
+ code: str, code_to_dimension: dict[str, str]
+) -> tuple[list[tuple[str, str]], list[str]]:
+ """
+ Parse a compound code like 'S13_G1_G23_T' into dimension matches.
+
+ Uses greedy matching to find longest combinations first.
+ ALL parts must match a dimension, otherwise returns unmatched parts.
+
+ Returns tuple of:
+ - list of (dimension_id, matched_code) tuples
+ - list of unmatched parts (empty if all matched)
+ """
+ parts = code.split("_")
+ matched_parts: list[tuple[str, str]] = []
+ unmatched_parts: list[str] = []
+
+ # Try to match longest combinations first (greedy matching)
+ i = 0
+ while i < len(parts):
+ matched = False
+ # Try longest possible combination first
+ for j in range(len(parts), i, -1):
+ combined = "_".join(parts[i:j])
+ if combined in code_to_dimension:
+ dim_id = code_to_dimension[combined]
+ # Don't match same dimension twice
+ if not any(m[0] == dim_id for m in matched_parts):
+ matched_parts.append((dim_id, combined))
+ i = j
+ matched = True
+ break
+ if not matched:
+ # Track unmatched part
+ unmatched_parts.append(parts[i])
+ i += 1
+
+ return matched_parts, unmatched_parts
+
+
+def _build_dimension_lookups(
+ dataflow: str, metadata
+) -> tuple[dict[str, str], dict[str, set[str]], list[str]]:
+ """
+ Build lookups for mapping codes to dimensions.
+
+ Returns (code_to_dimension, codes_by_dimension, dimension_order) tuple.
+ - code_to_dimension: maps any valid code to its dimension ID (for parsing)
+ - codes_by_dimension: maps dimension ID to valid individual codes (for validation messages)
+ - dimension_order: list of dimension IDs in their proper order from DSD
+ """
+ # pylint: disable=import-outside-toplevel
+ from collections import defaultdict
+
+ code_to_dimension: dict[str, str] = {}
+ codes_by_dimension: dict[str, set[str]] = defaultdict(set)
+ dimension_order: list[str] = []
+
+ # Get ALL dimension codes from parameters API (authoritative source)
+ # This provides the actual valid values for each dimension
+ try:
+ all_params = metadata.get_dataflow_parameters(dataflow)
+ # Exclude time-related dimensions and country/area dimensions from indicator lookup
+ # Country is handled separately via the 'country' parameter
+ exclude_dims = {"FREQUENCY", "TIME_PERIOD", "COUNTRY", "REF_AREA"}
+ for dim_id, values in all_params.items():
+ if dim_id in exclude_dims:
+ continue
+ for v in values:
+ code = v.get("value")
+ if code:
+ # Add to codes_by_dimension (authoritative source for validation)
+ codes_by_dimension[dim_id].add(code)
+ # Add to code_to_dimension for matching
+ if code not in code_to_dimension:
+ code_to_dimension[code] = dim_id
+ except Exception: # noqa
+ pass # Continue with indicator-only lookup if params unavailable
+
+ # Get dimension order from DSD
+ # Keep COUNTRY/REF_AREA in order (they define the first segment position)
+ # Exclude trailing dimensions that are always separate parameters
+ try:
+ df_obj = metadata.dataflows.get(dataflow, {})
+ dsd_id = df_obj.get("structureRef", {}).get("id")
+ if dsd_id and dsd_id in metadata.datastructures:
+ dsd = metadata.datastructures[dsd_id]
+ # Only exclude trailing parameter dimensions
+ trailing_dims = {
+ "FREQUENCY",
+ "TIME_PERIOD",
+ "TYPE_OF_TRANSFORMATION",
+ "TRANSFORMATION",
+ }
+ for dim in dsd.get("dimensions", []):
+ dim_id = dim.get("id", "")
+ if (
+ dim_id
+ and dim_id.upper() not in trailing_dims
+ and "TRANSFORM" not in dim_id.upper()
+ ):
+ dimension_order.append(dim_id)
+ except Exception: # noqa
+ pass
+
+ return code_to_dimension, dict(codes_by_dimension), dimension_order
+
+
+def detect_indicator_dimensions(
+ dataflow: str, indicator_codes: list[str], metadata
+) -> dict[str, list[str]]:
+ """
+ Detect which dimension each indicator code belongs to.
+
+ Different dataflows use different dimension names for indicators:
+ - INDICATOR (most common)
+ - BOP_ACCOUNTING_ENTRY (Balance of Payments)
+ - SERIES (some datasets)
+ - ITEM (some datasets)
+ etc.
+
+ Supports compound codes like 'HICP_CP01' which are split into multiple
+ dimensions (e.g., INDEX_TYPE=HICP, COICOP_1999=CP01).
+
+ Also supports compound codes with non-indicator dimensions like 'S13_G1_G23_T'
+ which combines SECTOR + GFS_GRP + INDICATOR.
+
+ Returns a dict mapping dimension_id -> list of indicator codes.
+
+ Raises
+ ------
+ OpenBBError
+ If any indicator code is not valid for the dataflow.
+ """
+ # pylint: disable=import-outside-toplevel
+ from collections import defaultdict
+
+ dimension_codes: dict[str, list[str]] = defaultdict(list)
try:
- response = await amake_request(url, timeout=20)
- except (JSONDecodeError, ContentTypeError) as e:
- raise OpenBBError(
- "Error fetching data; This might be rate-limiting. Try again later."
- ) from e
-
- if "ErrorDetails" in response:
- raise OpenBBError(
- f"{response['ErrorDetails'].get('Code')} -> {response['ErrorDetails'].get('Message')}" # type: ignore
+ code_to_dimension, codes_by_dimension, dimension_order = (
+ _build_dimension_lookups(dataflow, metadata)
)
- series = response.get("CompactData", {}).get("DataSet", {}).pop("Series", {}) # type: ignore
+ invalid_codes: list[tuple[str, list[str]]] = [] # (code, unmatched_parts)
+ for code in indicator_codes:
+ if code == "*":
+ # Handle wildcard - prefer INDICATOR dimension if available
+ if "INDICATOR" in codes_by_dimension:
+ dimension_codes["INDICATOR"].append(code)
+ elif code_to_dimension:
+ primary_dim = next(iter(code_to_dimension.values()))
+ dimension_codes[primary_dim].append(code)
+ else:
+ dimension_codes["INDICATOR"].append(code)
+ elif code in code_to_dimension:
+ dimension_codes[code_to_dimension[code]].append(code)
+ else:
+ # Try to parse compound codes
+ matched_parts, unmatched = _parse_compound_code(code, code_to_dimension)
+ if matched_parts and not unmatched:
+ # All parts matched - valid compound code
+ for dim_id, code_part in matched_parts:
+ if code_part not in dimension_codes[dim_id]:
+ dimension_codes[dim_id].append(code_part)
+ else:
+ # Either no matches or some parts didn't match
+ invalid_codes.append((code, unmatched))
- if not series:
- raise OpenBBError(f"No time series data found -> {response}")
+ if invalid_codes:
+ # Build detailed error message using dimension order
+ error_parts: list[str] = []
- # If there is only one series, they ruturn a dict instead of a list.
- if series and isinstance(series, dict):
- series = [series]
+ # Country-like dimensions to exclude unless explicitly matched
+ country_dims = {"COUNTRY", "REF_AREA"}
- return series
+ for code, unmatched in invalid_codes:
+ if unmatched:
+ # First pass: identify all segments and their matches
+ parts = code.split("_")
+ segments: list[tuple[str, str | None]] = (
+ []
+ ) # (segment, dim_id or None)
+
+ i = 0
+ while i < len(parts):
+ # Try greedy matching (longest first)
+ matched = False
+ for j in range(len(parts), i, -1):
+ combined = "_".join(parts[i:j])
+ if combined in code_to_dimension:
+ segments.append((combined, code_to_dimension[combined]))
+ i = j
+ matched = True
+ break
+ if not matched:
+ segments.append((parts[i], None))
+ i += 1
+
+ # Check if any segment matched a country dimension
+ has_country_match = any(
+ dim_id in country_dims for _, dim_id in segments if dim_id
+ )
+
+ # Build effective dimension order - exclude country if not matched
+ effective_dim_order = (
+ dimension_order
+ if has_country_match
+ else [d for d in dimension_order if d not in country_dims]
+ )
+
+ # Find the first matched dimension to anchor our position
+ first_matched_idx: Any = None
+ first_matched_dim_pos: Any = None
+ for idx, (seg, dim_id) in enumerate(segments): # type: ignore
+ if dim_id and dim_id in effective_dim_order:
+ first_matched_idx = idx
+ first_matched_dim_pos = effective_dim_order.index(dim_id)
+ break
+
+ # Build error messages
+ segment_errors: list = []
+ for idx, (seg, dim_id) in enumerate(segments): # type: ignore
+ if dim_id is None:
+ # Calculate expected dimension based on position relative to first match
+ if (
+ first_matched_idx is not None
+ and first_matched_dim_pos is not None
+ ):
+ expected_pos = first_matched_dim_pos - (
+ first_matched_idx - idx
+ )
+ else:
+ expected_pos = idx
+
+ if 0 <= expected_pos < len(effective_dim_order):
+ expected_dim = effective_dim_order[expected_pos]
+ sample = sorted(
+ codes_by_dimension.get(expected_dim, set())
+ )[:5]
+ segment_errors.append(
+ f"'{seg}' is invalid for {expected_dim} (valid: {', '.join(sample)})"
+ )
+ else:
+ segment_errors.append(f"'{seg}' is unrecognized")
+
+ error_parts.append(f"'{code}': {'; '.join(segment_errors)}")
+ else:
+ error_parts.append(f"'{code}'")
+
+ raise OpenBBError(
+ f"Invalid indicator code(s) for dataflow '{dataflow}': "
+ f"{'; '.join(error_parts)}. "
+ f"Use `obb.economy.available_indicators(provider='imf', dataflows='{dataflow}')` to see all valid codes."
+ )
+
+ except OpenBBError:
+ raise
+ except Exception:
+ # Fallback: put all codes in INDICATOR dimension (can't validate)
+ dimension_codes["INDICATOR"] = indicator_codes
+
+ return dict(dimension_codes)
+
+
+def detect_transform_dimension(
+ dataflow: str,
+) -> tuple[str | None, str | None, dict[str, str], dict[str, str]]:
+ """
+ Detect transformation and unit dimensions for a dataflow.
+
+ Dynamically finds dimensions containing 'TRANSFORM' or 'UNIT' in their names.
+
+ Returns:
+ tuple of (transform_dim, unit_dim, transform_lookup, unit_lookup) where:
+ - transform_dim: name of transform dimension (or None)
+ - unit_dim: name of unit dimension (or None)
+ - transform_lookup: maps user-friendly names (index, yoy, period) to IMF codes
+ - unit_lookup: maps user-friendly names (usd, eur, index, local) to IMF codes
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.metadata import ImfMetadata
+
+ transform_dim: str | None = None
+ unit_dim: str | None = None
+ transform_lookup: dict[str, str] = {}
+ unit_lookup: dict[str, str] = {}
+
+ try:
+ m = ImfMetadata()
+ params = m.get_dataflow_parameters(dataflow)
+
+ for dim, values in params.items():
+ dim_upper = dim.upper()
+
+ # Handle TRANSFORM dimension
+ if "TRANSFORM" in dim_upper:
+ transform_dim = dim
+ for v in values:
+ code = v.get("value", "")
+ label = v.get("label", "").lower()
+
+ # Prefer simpler codes (shorter, no prefix like SRP_, WGT, SA_)
+ is_simple = (
+ not code.startswith("SRP_")
+ and not code.startswith("WGT")
+ and not code.startswith("SA_")
+ )
+
+ # Index: just "Index" without change/percent
+ if (
+ label == "index"
+ or (
+ "index" in label
+ and "change" not in label
+ and "percent" not in label
+ )
+ ) and ("index" not in transform_lookup or is_simple):
+ transform_lookup["index"] = code
+
+ # YOY: year-over-year or year ago
+ if (
+ "year-over-year" in label
+ or "yoy" in label
+ or "year ago" in label
+ ) and ("yoy" not in transform_lookup or is_simple):
+ transform_lookup["yoy"] = code
+
+ # Period: period-over-period (not year-over-year)
+ if (
+ "period-over-period" in label
+ or (
+ "period" in label
+ and "change" in label
+ and "year" not in label
+ )
+ ) and ("period" not in transform_lookup or is_simple):
+ transform_lookup["period"] = code
+
+ if ("percent of gdp" in label or "% of gdp" in label) and (
+ "percent_gdp" not in transform_lookup or is_simple
+ ):
+ transform_lookup["percent_gdp"] = code
+
+ # Currency / Domestic currency (for GFS dataflows)
+ if ("domestic currency" in label or label == "currency") and (
+ "currency" not in transform_lookup or is_simple
+ ):
+ transform_lookup["currency"] = code
+
+ # Also allow direct code access (case-insensitive)
+ transform_lookup[code.lower()] = code
+
+ # Handle UNIT dimension
+ elif dim_upper == "UNIT":
+ unit_dim = dim
+ for v in values:
+ code = v.get("value", "")
+ label = v.get("label", "").lower()
+
+ # Map common unit names
+ if "us dollar" in label or label == "usd":
+ unit_lookup["usd"] = code
+ if "euro" in label or label == "eur":
+ unit_lookup["eur"] = code
+ if label == "index" or "index" in label:
+ unit_lookup["index"] = code
+ if "local" in label or "national" in label or "domestic" in label:
+ unit_lookup["local"] = code
+ if "percent" in label or "%" in label:
+ unit_lookup["percent"] = code
+
+ # Also allow direct code access (case-insensitive)
+ unit_lookup[code.lower()] = code
+
+ except (KeyError, ValueError):
+ pass
+
+ return transform_dim, unit_dim, transform_lookup, unit_lookup
+
+
+def parse_time_period(time_str: str) -> str:
+ """Convert IMF time period formats to valid date strings (period ending).
+
+ Examples:
+ '2025-M03' -> '2025-03-31'
+ '2025-Q1' -> '2025-03-31'
+ '2025' -> '2025-12-31'
+ """
+ # pylint: disable=import-outside-toplevel
+ import calendar
+ from datetime import datetime
+
+ if not time_str:
+ return time_str
+
+ try:
+ # Handle monthly format (YYYY-MXX)
+ if "-M" in time_str:
+ parts = time_str.split("-M")
+ if len(parts) == 2:
+ year = int(parts[0])
+ month = int(parts[1])
+
+ # Get the last day of the month using calendar module
+ last_day = calendar.monthrange(year, month)[1]
+
+ # Create date object and format it
+ date_obj = datetime(year, month, last_day)
+ return date_obj.strftime("%Y-%m-%d")
+
+ # Handle quarterly format (YYYY-QX)
+ elif "-Q" in time_str:
+ parts = time_str.split("-Q")
+ if len(parts) == 2:
+ year = int(parts[0])
+ quarter = int(parts[1])
+
+ # Map quarters to their last month
+ quarter_last_month = {1: 3, 2: 6, 3: 9, 4: 12}
+ month = quarter_last_month.get(quarter, 12)
+
+ # Get the last day of the quarter's last month
+ last_day = calendar.monthrange(year, month)[1]
+
+ # Create date object and format it
+ date_obj = datetime(year, month, last_day)
+ return date_obj.strftime("%Y-%m-%d")
+
+ # Handle yearly format (YYYY)
+ elif len(time_str) == 4 and time_str.isdigit():
+ year = int(time_str)
+ # Last day of the year is always December 31
+ date_obj = datetime(year, 12, 31)
+ return date_obj.strftime("%Y-%m-%d")
+
+ # Return as-is if it's already in a valid format or unrecognized
+ return time_str
+
+ except (ValueError, KeyError):
+ # If parsing fails, return the original string
+ return time_str
+
+
+def parse_agency_from_urn(code_urn: str) -> str | None:
+ """
+ Parse agency ID from hierarchicalCode's code URN.
+
+ Examples
+ --------
+ >>> parse_agency_from_urn("urn:sdmx:org.sdmx.infomodel.codelist.Code=ISORA:CL_RAFIT_LABELS(1.0+.0).CL_TOPIC_1")
+ 'ISORA'
+ >>> parse_agency_from_urn("urn:sdmx:org.sdmx.infomodel.codelist.Code=IMF.STA:CL_BOP_INDICATOR(10.0+.0).CAB")
+ 'IMF.STA'
+ """
+ if not code_urn or "=" not in code_urn or ":" not in code_urn:
+ return None
+ try:
+ # Extract everything after the "=" and before the last ":"
+ # Example: "ISORA:CL_RAFIT_LABELS(1.0+.0).CL_TOPIC_1"
+ after_equals = code_urn.split("=")[-1]
+ # Split by ":" to get agency (first part) and codelist info (second part)
+ parts = after_equals.split(":")
+ if len(parts) >= 2:
+ return parts[0]
+ return None
+ except Exception:
+ return None
+
+
+def parse_indicator_code_from_urn(code_urn: str) -> str | None:
+ """
+ Parse indicator code from hierarchicalCode's code URN.
+
+ Examples
+ --------
+ >>> parse_indicator_code_from_urn(
+ ... "urn:sdmx:org.sdmx.infomodel.codelist.Code=IMF.STA:CL_FSIBSIS_INDICATOR(4.0+.0).INTINC"
+ ... )
+ 'INTINC'
+ """
+ if not code_urn or "." not in code_urn:
+ return None
+ return code_urn.split(".")[-1]
+
+
+def parse_codelist_id_from_urn(code_urn: str) -> str | None:
+ """
+ Parse codelist ID from hierarchicalCode's code URN.
+
+ Examples
+ --------
+ >>> parse_codelist_id_from_urn("urn:sdmx:org.sdmx.infomodel.codelist.Code=IMF.STA:CL_BOP_INDICATOR(10.0+.0).CAB")
+ 'CL_BOP_INDICATOR'
+ """
+ if not code_urn or ":" not in code_urn or "(" not in code_urn:
+ return None
+ try:
+ # Extract everything after the last colon
+ after_colon = code_urn.split(":")[-1]
+ # Extract everything before the opening parenthesis
+ codelist_id = after_colon.split("(")[0]
+ return codelist_id
+ except Exception:
+ return None
+
+
+def parse_search_query(query: str) -> list[list[str]]:
+ """
+ Parse a search query string into OR-groups of AND-terms.
+
+ Supports:
+ - Quoted phrases: "exact phrase"
+ - OR operator: term1 | term2
+ - AND operator (implicit or +): term1 term2, term1+term2
+ - Stop words are filtered out (of, the, a, an, is, are, in, on, for, with, and, or)
+
+ Examples
+ --------
+ >>> parse_search_query('inflation | "consumer price"')
+ [['inflation'], ['consumer price']]
+ >>> parse_search_query('gdp growth')
+ [['gdp', 'growth']]
+ """
+ # pylint: disable=import-outside-toplevel
+ import string as string_module
+
+ STOP_WORDS = { # pylint: disable=C0103
+ "of",
+ "the",
+ "a",
+ "an",
+ "is",
+ "are",
+ "in",
+ "on",
+ "for",
+ "with",
+ "and",
+ "or",
+ }
+ or_groups: list = []
+ parts_by_or = [p.strip() for p in query.split("|")]
+
+ for or_part in parts_by_or:
+ if not or_part:
+ continue
+
+ current_and_group: list = []
+ in_quote = False
+ current_term: list = []
+
+ # Add a space at the end to ensure the last term is processed
+ for char in or_part + " ":
+ if char == '"':
+ if in_quote:
+ # End of a quoted term
+ if current_term:
+ term = "".join(current_term).lower()
+ current_and_group.append(term)
+ current_term = []
+ in_quote = False
+ else:
+ # Start of a quoted term
+ if current_term: # process term before quote
+ term = (
+ "".join(current_term)
+ .lower()
+ .strip(string_module.punctuation)
+ )
+ if term and term not in STOP_WORDS:
+ current_and_group.append(term)
+ current_term = []
+ in_quote = True
+ elif (char == "+" or char.isspace()) and not in_quote:
+ # End of a non-quoted term
+ if current_term:
+ term = (
+ "".join(current_term).lower().strip(string_module.punctuation)
+ )
+ if term and term not in STOP_WORDS:
+ current_and_group.append(term)
+ current_term = []
+ else:
+ current_term.append(char)
+
+ if current_and_group:
+ or_groups.append(current_and_group)
+
+ return or_groups
+
+
+def build_time_period_params(
+ constraints_response: dict | None,
+) -> tuple[list[dict], str | None]:
+ """
+ Build time period parameters from a constraints API response.
+
+ Extracts start date, end date, and series count from annotations.
+
+ Parameters
+ ----------
+ constraints_response : dict | None
+ The response from get_available_constraints
+
+ Returns
+ -------
+ tuple[list[dict], str | None]
+ A tuple of (options list, series_count) where options contains
+ start/end date dicts with 'label' and 'value' keys.
+ """
+ if not constraints_response:
+ return [], None
+
+ full_response = constraints_response.get("full_response", {})
+ content_constraints = full_response.get("data", {}).get("contentConstraints", [])
+ annotations = (
+ content_constraints[0].get("annotations", []) if content_constraints else []
+ )
+
+ start = end = series_count = None
+ for annotation in annotations:
+ ann_id = annotation.get("id")
+ if ann_id == "time_period_start":
+ start = annotation.get("title")
+ elif ann_id == "time_period_end":
+ end = annotation.get("title")
+ elif ann_id == "series_count":
+ series_count = annotation.get("title")
+
+ options: list[dict] = []
+ if start:
+ options.append({"label": f"Start Date: {start}", "value": start})
+ if end:
+ options.append({"label": f"End Date: {end}", "value": end})
+
+ return options, series_count
+
+
+def parse_codelist_urn(urn: str) -> str | None:
+ """
+ Parse codelist ID from owningCodelistUrn.
+
+ Examples
+ --------
+ >>> parse_codelist_urn(
+ ... "urn:sdmx:com.epam.quanthub.sdmxplus.infomodel.Glossary=IMF.STA:CL_FSIBSIS_INDICATOR(4.0+.0)"
+ ... )
+ 'CL_FSIBSIS_INDICATOR'
+ """
+ if not urn or "=" not in urn:
+ return None
+
+ try:
+ parts = urn.split("=")[-1]
+ codelist_with_version = parts.split(":")[-1]
+ codelist_id = codelist_with_version.split("(")[0]
+ return codelist_id
+ except Exception:
+ return None
+
+
+def extract_all_codelists_from_hierarchy(hierarchy: dict) -> set[str]:
+ """
+ Recursively extract ALL codelist IDs from a hierarchy's hierarchicalCodes.
+
+ This ensures that hierarchies containing multiple codelists (e.g., labels,
+ table levels, AND indicator codelists) are properly indexed for reverse lookup.
+
+ Parameters
+ ----------
+ hierarchy : dict
+ The hierarchy object containing hierarchicalCodes.
+
+ Returns
+ -------
+ set[str]
+ Set of all codelist IDs found in the hierarchy.
+ """
+ codelists: set[str] = set()
+
+ def scan_codes(codes: list) -> None:
+ for code in codes:
+ code_urn = code.get("code", "")
+ if code_urn:
+ codelist_id = parse_codelist_id_from_urn(code_urn)
+ if codelist_id:
+ codelists.add(codelist_id)
+ # Recurse into nested hierarchicalCodes
+ nested = code.get("hierarchicalCodes", [])
+ if nested:
+ scan_codes(nested)
+
+ hcodes = hierarchy.get("hierarchicalCodes", [])
+ if hcodes and isinstance(hcodes, list):
+ scan_codes(hcodes)
+
+ return codelists
+
+
+def build_hierarchy_to_codelist_map(hierarchies: dict) -> dict[str, str]:
+ """
+ Build mapping from hierarchy ID to codelist ID.
+
+ Parameters
+ ----------
+ hierarchies : dict
+ Dictionary of hierarchy_id -> hierarchy data.
+
+ Returns
+ -------
+ dict[str, str]
+ Mapping from hierarchy ID to its primary codelist ID.
+ """
+ mapping: dict[str, str] = {}
+ for hierarchy_id, hierarchy in hierarchies.items():
+ codelist_id = None
+
+ # Method 1: Check owningCodelistUrn annotation
+ annotations = hierarchy.get("annotations", [])
+ for annotation in annotations:
+ if annotation.get("id") == "owningCodelistUrn":
+ urn = annotation.get("text", "")
+ codelist_id = parse_codelist_urn(urn)
+ if codelist_id:
+ break
+
+ # Method 2: Infer from first hierarchicalCode's code URN
+ # Handles hierarchies like H_QGFS_* that don't have owningCodelistUrn
+ if not codelist_id:
+ hcodes = hierarchy.get("hierarchicalCodes", [])
+ if hcodes and isinstance(hcodes, list) and hcodes:
+ first_code = hcodes[0]
+ code_urn = first_code.get("code", "")
+ if code_urn:
+ codelist_id = parse_codelist_id_from_urn(code_urn)
+
+ if codelist_id:
+ mapping[hierarchy_id] = codelist_id
+
+ return mapping
+
+
+def build_codelist_to_hierarchies_map(hierarchies: dict) -> dict[str, list[str]]:
+ """
+ Build reverse mapping from codelist ID to list of hierarchy IDs.
+
+ Unlike build_hierarchy_to_codelist_map (which maps to the primary codelist),
+ this function indexes ALL codelists contained in each hierarchy. This ensures
+ that hierarchies with nested codelists (e.g., H_FSIC_INDICATOR containing
+ CL_FSI_LABELS, CL_FSIC_INDICATOR_LABELS, AND CL_FSIC_INDICATOR) are found
+ when looking up any of their contained codelists.
+
+ Parameters
+ ----------
+ hierarchies : dict
+ Dictionary of hierarchy_id -> hierarchy data.
+
+ Returns
+ -------
+ dict[str, list[str]]
+ Mapping from codelist ID to list of hierarchy IDs containing it.
+ """
+ mapping: dict[str, list[str]] = {}
+ for hierarchy_id, hierarchy in hierarchies.items():
+ # Extract ALL codelists from this hierarchy (not just the first one)
+ all_codelists = extract_all_codelists_from_hierarchy(hierarchy)
+ for codelist_id in all_codelists:
+ if codelist_id not in mapping:
+ mapping[codelist_id] = []
+ if hierarchy_id not in mapping[codelist_id]:
+ mapping[codelist_id].append(hierarchy_id)
+ return mapping
+
+
+def translate_error_message(error_msg: str) -> str:
+ """Translate IMF dimension codes to user-friendly parameter names in error messages.
+
+ Maps internal IMF dimension names like COUNTRY, FREQUENCY, INDICATOR
+ to user-facing parameter names like country, frequency, symbol/indicator.
+ Also translates common IMF code values to user-friendly equivalents.
+ """
+ # Dimension code to parameter name mappings
+ dim_to_param = {
+ "COUNTRY": "country",
+ "REF_AREA": "country",
+ "JURISDICTION": "country",
+ "COUNTERPART_AREA": "counterpart_country",
+ "FREQUENCY": "frequency",
+ "INDICATOR": "indicator",
+ "CLASSIFICATION": "indicator",
+ "SERIES": "indicator",
+ "ITEM": "indicator",
+ "SECTOR": "sector",
+ "BOP_ACCOUNTING_ENTRY": "accounting_entry",
+ "ACCOUNTING_ENTRY": "accounting_entry",
+ "TYPE_OF_TRANSFORMATION": "transform",
+ "PRICE_TYPE": "price_type",
+ "S_ADJUSTMENT": "seasonal_adjustment",
+ "UNIT_MEASURE": "unit",
+ "UNIT_MULT": "unit_multiplier",
+ "TIME_PERIOD": "time_period",
+ }
+
+ # Frequency value mappings
+ frequency_map = {
+ "'A'": "'annual'",
+ "'Q'": "'quarter'",
+ "'M'": "'month'",
+ '"A"': '"annual"',
+ '"Q"': '"quarter"',
+ '"M"': '"month"',
+ }
+
+ # Apply dimension translations
+ for dim_code, param_name in dim_to_param.items():
+ error_msg = error_msg.replace(f"'{dim_code}'", f"'{param_name}'")
+ error_msg = error_msg.replace(f'"{dim_code}"', f'"{param_name}"')
+ # Also handle "dimension 'X'" pattern without quotes around dimension
+ error_msg = error_msg.replace(
+ f"dimension '{dim_code}'", f"'{param_name}' parameter"
+ )
+ # Handle dimension X: pattern
+ error_msg = error_msg.replace(f"{dim_code} codes", f"{param_name} codes")
+
+ # Apply frequency value translations
+ for api_val, user_val in frequency_map.items():
+ error_msg = error_msg.replace(api_val, user_val)
+
+ return error_msg
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/irfcl_helpers.py b/openbb_platform/providers/imf/openbb_imf/utils/irfcl_helpers.py
deleted file mode 100644
index 6a692b0b099..00000000000
--- a/openbb_platform/providers/imf/openbb_imf/utils/irfcl_helpers.py
+++ /dev/null
@@ -1,281 +0,0 @@
-"""IMF IRFCL Data Set Helpers."""
-
-
-def load_irfcl_symbols() -> dict:
- """Load IMF IRFCL symbols."""
- # pylint: disable=import-outside-toplevel
- from openbb_imf.utils.constants import load_symbols
-
- return load_symbols("IRFCL")
-
-
-def load_country_map() -> dict:
- """Load IMF IRFCL country map."""
- # pylint: disable=import-outside-toplevel
- import json # noqa
- from json.decoder import JSONDecodeError
- from pathlib import Path
- from openbb_core.app.model.abstract.error import OpenBBError
-
- try:
- country_map_file = (
- Path(__file__).parents[1].joinpath("assets", "imf_country_map.json")
- )
- with country_map_file.open(encoding="utf-8") as file:
- country_map_dict = json.load(file)
- except (FileNotFoundError, JSONDecodeError) as e:
- raise OpenBBError(f"Failed to load IMF IRFCL country map: {e}") from e
-
- return {
- k: v.split(",")[0].split("_(")[0]
- for k, v in country_map_dict.items()
- if len(k) == 2
- and k[0] not in ("5", "1", "7")
- and k not in ("X0", "R1", "GW", "F1", "F6")
- }
-
-
-def load_country_to_code_map() -> dict:
- """Load a map of lowercase country name to 2-letter ISO symbol."""
- return {
- (
- "euro_area"
- if k == "U2"
- else v.lower()
- .replace(" ", "_")
- .replace("`", "")
- .split(",")[0]
- .split("_(")[0]
- ): k
- for k, v in load_country_map().items()
- }
-
-
-def validate_countries(countries) -> str:
- """Validate the country and convert to a 2-letter ISO country code."""
- # pylint: disable=import-outside-toplevel
- from warnings import warn # noqa
- from openbb_core.app.model.abstract.error import OpenBBError
-
- country_map_dict = load_country_to_code_map()
-
- if isinstance(countries, str):
- countries = countries.split(",")
- elif not isinstance(countries, list):
- raise OpenBBError("Invalid countries list.")
-
- new_countries: list = []
-
- if "all" in countries or "ALL" in countries:
- return "all"
-
- for country in countries:
- if country.lower() not in country_map_dict and country.upper() not in list(
- country_map_dict.values()
- ):
- warn(f"Invalid IMF IRFCL country: {country}")
- continue
-
- if country.upper() in list(country_map_dict.values()):
- new_countries.append(country.upper())
- else:
- new_countries.append(country_map_dict.get(country, country).upper())
-
- new_countries = [c for c in new_countries if c]
-
- if not new_countries:
- raise OpenBBError("No valid countries found in the supplied list.")
-
- return ",".join(new_countries)
-
-
-def validate_symbols(symbols) -> str:
- """Validate the IMF IRFCL symbols."""
- # pylint: disable=import-outside-toplevel
- from warnings import warn # noqa
- from openbb_core.app.model.abstract.error import OpenBBError
- from openbb_imf.utils.constants import IRFCL_PRESET
-
- irfcl_symbols = load_irfcl_symbols()
-
- if isinstance(symbols, str):
- symbols = symbols.split(",")
- elif not isinstance(symbols, list):
- raise OpenBBError("Invalid symbols list.")
-
- if "IRFCL" in symbols or "all" in symbols:
- return "all"
-
- new_symbols: list = []
-
- for symbol in symbols:
- if symbol in IRFCL_PRESET:
- return IRFCL_PRESET[symbol].replace(",", "+")
- if symbol.upper() not in irfcl_symbols:
- warn(f"Invalid IMF IRFCL symbol: {symbol}")
- new_symbols.append(symbol.upper())
-
- return "+".join(new_symbols) if len(new_symbols) > 1 else new_symbols[0]
-
-
-# We use this as a helper to allow future expansion of the supported IMF indicators.
-# Each database has its own nuances with URL construction and schemas.
-
-
-# pylint: disable=too-many-branches,too-many-statements,too-many-locals
-async def _get_irfcl_data(**kwargs) -> list[dict]:
- """Get IMF IRFCL data.
- This function is not intended to be called directly,
- but through the `ImfEconomicIndicatorsFetcher` class.
- """
- # pylint: disable=import-outside-toplevel
- from openbb_core.app.model.abstract.error import OpenBBError
- from openbb_imf.utils import constants
- from openbb_imf.utils.helpers import get_data
- from pandas import to_datetime
- from pandas.tseries import offsets
-
- countries = kwargs.get("country", "")
- countries = (
- "" if countries == "all" else countries.replace(",", "+") if countries else ""
- )
-
- frequency = constants.FREQUENCY_DICT.get(kwargs.get("frequency", "quarter"), "Q")
- sector = kwargs.get("sector", "monetary_authorities")
- sector = constants.REF_SECTORS_DICT.get(sector, "")
- start_date = kwargs.get("start_date", "")
- end_date = kwargs.get("end_date", "")
-
- # Adjust the dates to the date relative to frequency.
- # The API does not accept arbitrary dates, so we need to adjust them.
- if start_date:
- start_date = to_datetime(start_date)
- if frequency == "Q":
- start_date = offsets.QuarterBegin(startingMonth=1).rollback(start_date)
- elif frequency == "A":
- start_date = offsets.YearBegin().rollback(start_date)
- else:
- start_date = offsets.MonthBegin().rollback(start_date)
- start_date = start_date.strftime("%Y-%m-%d")
-
- if end_date:
- end_date = to_datetime(end_date)
- if frequency == "Q":
- end_date = offsets.QuarterEnd().rollforward(end_date)
- elif frequency == "A":
- end_date = offsets.YearEnd().rollforward(end_date)
- else:
- end_date = offsets.MonthEnd().rollforward(end_date)
- end_date = end_date.strftime("%Y-%m-%d")
-
- indicator = kwargs.get("symbol")
- indicators = validate_symbols(indicator) if indicator else ""
- indicators = "" if indicators == "all" else indicators
-
- if not indicators and not countries:
- raise OpenBBError("Country is required when returning the complete dataset.")
-
- date_range = (
- f"?startPeriod={start_date}&endPeriod={end_date}"
- if start_date and end_date
- else ""
- )
- base_url = "http://dataservices.imf.org/REST/SDMX_JSON.svc/"
- key = f"CompactData/IRFCL/{frequency}.{countries}.{indicators}.{sector}"
- url = f"{base_url}{key}{date_range}"
-
- series = await get_data(url)
-
- data: list = []
- all_symbols = load_irfcl_symbols()
- country_map_dict = {
- v: k.replace("_", " ").title().replace("Ecb", "ECB")
- for k, v in load_country_to_code_map().items()
- }
- # Iterate over the series to extract observations and map the metadata.
- for s in series:
- if "Obs" not in s:
- continue
- meta = {
- k.replace("@", "").lower(): (
- constants.UNIT_MULTIPLIERS_MAP.get(str(v), v)
- if k == "@UNIT_MULT"
- else v
- )
- for k, v in s.items()
- if k != "Obs"
- }
- _symbol = meta.get("indicator")
- _parent: str | None = None
- _order: str | None = None
- _level: str | None = None
- _table: str | None = None
- _title: str | None = None
- _unit: str | None = None
-
- if _symbol not in all_symbols:
- continue
-
- _table = all_symbols.get(_symbol, {}).get("table")
- _parent = all_symbols.get(_symbol, {}).get("parent", "")
- _order = all_symbols.get(_symbol, {}).get("order", "")
- _level = all_symbols.get(_symbol, {}).get("level", "")
- _title = all_symbols.get(_symbol, {}).get("title", "").replace(", ", " - ")
- _unit = all_symbols.get(_symbol, {}).get("unit", "")
-
- if _title:
- _title = " - ".join(_title.split(", ")[:-1])
-
- _data = s.pop("Obs", [])
-
- if isinstance(_data, dict):
- _data = [_data]
-
- for d in _data:
- _date = d.pop("@TIME_PERIOD", None)
- val = d.pop("@OBS_VALUE", None)
- _ = d.pop("@OBS_STATUS", None)
-
- if not val:
- continue
-
- if _date:
- offset = (
- offsets.QuarterEnd
- if "Q" in _date
- else offsets.YearEnd if len(str(_date)) == 4 else offsets.MonthEnd
- )
- _date = to_datetime(_date)
- _date = _date + offset(0)
- _date = _date.strftime("%Y-%m-%d")
- vals = {
- k: v
- for k, v in {
- "date": _date,
- "table": _table,
- "symbol": _symbol,
- "parent": _parent,
- "order": _order,
- "level": _level,
- "country": country_map_dict.get(
- meta.get("ref_area"), meta.get("ref_area")
- ),
- "reference_sector": constants.SECTOR_MAP.get(
- meta.get("ref_sector"), meta.get("ref_sector")
- ),
- "title": _title,
- "value": float(val) if val else None,
- "unit": _unit,
- "scale": meta.get("unit_mult"),
- }.items()
- if v
- }
-
- if vals.get("value") and vals.get("date"):
- d.update(vals)
- data.append(d)
-
- if not data:
- raise OpenBBError(f"No data found for '{indicator}' in '{countries}'.")
-
- return data
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/metadata.py b/openbb_platform/providers/imf/openbb_imf/utils/metadata.py
new file mode 100644
index 00000000000..d0e8f886ece
--- /dev/null
+++ b/openbb_platform/providers/imf/openbb_imf/utils/metadata.py
@@ -0,0 +1,2170 @@
+""" "IMF Metadata utilities."""
+
+# pylint: disable=C0301,C0302,R0902,R0911,R0912,R0913,R0914,R0915,R0917,R1702,W0718
+# flake8: noqa: PLR0911,PLR0912,PLR0913,PLR0917
+
+import threading
+import warnings
+
+from openbb_core.app.model.abstract.error import OpenBBError
+from openbb_core.app.model.abstract.warning import OpenBBWarning
+from openbb_imf.utils.helpers import (
+ build_codelist_to_hierarchies_map,
+ build_hierarchy_to_codelist_map,
+ build_time_period_params,
+ extract_all_codelists_from_hierarchy,
+ parse_agency_from_urn,
+ parse_codelist_id_from_urn,
+ parse_codelist_urn,
+ parse_indicator_code_from_urn,
+ parse_search_query,
+)
+
+
+class ImfMetadata:
+ """Singleton class to manage IMF metadata and caching."""
+
+ _instance = None
+ _lock = threading.Lock()
+ _codelist_lock = threading.Lock()
+ _constraints_lock = threading.Lock()
+ _initialized = None
+
+ def __new__(cls):
+ """Initialize the singleton instance."""
+ if cls._instance is None:
+ with cls._lock:
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ """Initialize the IMF metadata singleton."""
+ if self._initialized:
+ return
+
+ with self._lock:
+ if self._initialized:
+ return
+
+ self._codelist_cache: dict = {}
+ self._codelist_descriptions: dict = {}
+ self._metadata_cache: dict = {}
+ self._constraints_cache: dict = {}
+ self.hierarchies = {}
+ self._hierarchy_to_codelist_map = {}
+ self._codelist_to_hierarchies_map = {}
+ _ = self._load_from_cache()
+ self._initialized = True
+
+ def _load_from_cache(self) -> bool:
+ """Load metadata from the local cache file."""
+ # pylint: disable=import-outside-toplevel
+ import gzip
+ import pickle
+ from pathlib import Path
+
+ cache_path = Path(__file__).parent.parent / "assets" / "imf_cache.pkl.gz"
+ cache: dict = {}
+
+ if not cache_path.exists():
+ return False
+
+ try:
+ with gzip.open(cache_path, "rb") as f:
+ cache = pickle.load(f) # noqa: S301
+
+ self.dataflows = cache.get("dataflows", {})
+ self.datastructures = cache.get("datastructures", {})
+ self.conceptschemes = cache.get("conceptschemes", {})
+ self.dataflow_groups = cache.get("dataflow_groups", {})
+ self._metadata_cache = cache.get("metadata_cache", {})
+ self._constraints_cache = cache.get("constraints_cache", {})
+ self._codelist_cache = cache.get("codelist_cache", {})
+ self._codelist_descriptions = cache.get("codelist_descriptions", {})
+ self._dataflow_parameters_cache = cache.get("dataflow_parameters", {})
+ self._dataflow_indicators_cache = cache.get("dataflow_indicators", {})
+ self.hierarchies = cache.get("hierarchies", {})
+ # Build quick lookup maps
+ self._hierarchy_to_codelist_map = self._build_hierarchy_to_codelist_map()
+ self._codelist_to_hierarchies_map = (
+ self._build_codelist_to_hierarchies_map()
+ )
+
+ return True
+
+ except Exception as e:
+ warnings.warn(f"Error loading cache: {e}", OpenBBWarning)
+ return False
+
+ def list_dataflows(self) -> list[dict]:
+ """List available dataflows."""
+ dfs: list = []
+ keys = sorted(list(self.dataflows.keys()))
+
+ for key in keys:
+ label = self.dataflows[key].get("name", key)
+ value = self.dataflows[key].get("id", key)
+ dfs.append({"label": label.strip(), "value": value.strip()})
+
+ return dfs
+
+ def search_dataflows(self, query: str) -> list[dict]:
+ """Search dataflows based on a query string.
+
+ Parameters
+ ----------
+ query : str
+ The search query string, which can include AND (+) and OR (|) operators,
+ as well as quoted phrases for exact matches.
+ Returns
+ -------
+ list[dict]
+ A list of matching dataflows, grouped by their structureRef ID.
+ """
+ grouped_results: dict = {}
+ parsed_query = self._parse_query(query)
+
+ if not parsed_query:
+ raise OpenBBError(
+ ValueError(f"Query string is empty or invalid -> '{query}'")
+ )
+
+ for dataflow_obj in self.dataflows.values():
+ dataflow_id = dataflow_obj.get("id", "").lower()
+ dataflow_name = dataflow_obj.get("name", "").lower()
+ dataflow_description = dataflow_obj.get("description", "").lower()
+ dataflow_matches = False
+
+ for or_group in parsed_query:
+ or_group_matches_all_and_terms = True
+
+ for and_term in or_group:
+ if not (
+ and_term in dataflow_id
+ or and_term in dataflow_name
+ or and_term in dataflow_description
+ ):
+ or_group_matches_all_and_terms = False
+ break
+
+ if or_group_matches_all_and_terms:
+ dataflow_matches = True
+ break
+
+ if dataflow_matches:
+ structure_ref_id = dataflow_obj.get("structureRef", {}).get("id")
+ if structure_ref_id:
+ if structure_ref_id not in grouped_results:
+ grouped_results[structure_ref_id] = []
+
+ grouped_results[structure_ref_id].append(
+ {
+ "id": dataflow_obj.get("id"),
+ "name": dataflow_obj.get("name"),
+ "description": dataflow_obj.get("description", ""),
+ }
+ )
+
+ final_results = [
+ {"group_id": group_id, "dataflows": dataflows}
+ for group_id, dataflows in grouped_results.items()
+ ]
+
+ return final_results
+
+ def search_indicators(
+ self,
+ query: str,
+ dataflows: list[str] | str | None = None,
+ keywords: list[str] | None = None,
+ ) -> list[dict]:
+ """Search indicators based on a query string and optional keyword filters.
+
+ Parameters
+ ----------
+ query : str
+ The search query string. Multiple search phrases can be separated by semicolons (;).
+ Each phrase can use AND (+) and OR (|) operators, as well as quoted phrases.
+ Semicolon separation allows commas to be used within search phrases.
+ Examples:
+ "inflation rate;+consumer price" - searches for "inflation rate" OR "consumer price"
+ "gdp+growth;|employment" - searches for "gdp AND growth" OR "employment"
+ dataflows : list[str] | str | None, optional
+ A dataflow ID or list of dataflow IDs to search within. If None, all
+ dataflows will be searched, which can be slow.
+ keywords : list[str] | None, optional
+ List of keywords to filter results. Each keyword is a single word that must
+ appear in the indicator's label or description. Keywords prefixed with "not "
+ will exclude indicators containing that word (e.g., "not USD" excludes indicators
+ with "USD" in them).
+ Returns
+ -------
+ list[dict]
+ A list of matching indicators with table/hierarchy information included.
+ """
+ target_dataflow_ids: list = []
+ if dataflows:
+ target_dataflow_ids = (
+ [dataflows] if isinstance(dataflows, str) else dataflows
+ )
+ else:
+ if not query and not keywords:
+ raise OpenBBError(
+ "A query must be provided when no dataflows and keywords are specified."
+ )
+ target_dataflow_ids = list(self.dataflows.keys())
+
+ if not target_dataflow_ids:
+ raise OpenBBError(
+ "No valid dataflows found to search indicators in."
+ "This might be due to incorrect dataflow IDs."
+ )
+
+ # Build a map of indicators to their tables for enrichment
+ indicator_to_tables: dict[str, list[dict]] = {}
+ # Also build searchable text for each indicator from their tables
+ indicator_table_text: dict[str, str] = {}
+
+ for df_id in set(target_dataflow_ids):
+ try:
+ hierarchies = self.get_dataflow_hierarchies(df_id)
+ for hierarchy in hierarchies:
+ try:
+ structure = self.get_dataflow_table_structure(
+ df_id, hierarchy["id"]
+ )
+ # Build searchable table text
+ table_search_text = (
+ hierarchy.get("name", "").lower()
+ + " "
+ + hierarchy.get("description", "").lower()
+ )
+
+ for ind in structure.get("indicators", []):
+ if ind.get("is_group"):
+ continue
+ indicator_code = ind.get("indicator_code") or ind.get(
+ "code"
+ )
+ if indicator_code:
+ key = f"{df_id}_{indicator_code}"
+ if key not in indicator_to_tables:
+ indicator_to_tables[key] = []
+ indicator_table_text[key] = ""
+
+ table_entry = {
+ "table_id": hierarchy["id"],
+ "table_name": hierarchy["name"],
+ }
+ if table_entry not in indicator_to_tables[key]:
+ indicator_to_tables[key].append(table_entry)
+ indicator_table_text[key] += " " + table_search_text
+ except Exception: # noqa: S110
+ pass
+ except Exception: # noqa: S110
+ pass
+
+ # Get indicators for target dataflows
+ all_indicators: list = []
+ for df_id in set(target_dataflow_ids):
+ try:
+ indicators = self.get_indicators_in(df_id)
+ # Enrich each indicator with table information
+ for ind in indicators:
+ key = f"{df_id}_{ind['indicator']}"
+ ind["tables"] = indicator_to_tables.get(key, [])
+ # Build member_of as list of dataflow_id::table_id strings
+ ind["member_of"] = [
+ f"{df_id}::{t['table_id']}" for t in ind["tables"]
+ ]
+ # Add table text for searching (will be removed before return)
+ ind["_table_search_text"] = indicator_table_text.get(key, "")
+ all_indicators.extend(indicators)
+ except (KeyError, ValueError, OpenBBError) as e:
+ warnings.warn(
+ f"Could not retrieve indicators for dataflow '{df_id}': {e}",
+ OpenBBWarning,
+ )
+ continue
+
+ # Filter indicators by query
+ # Split query on semicolon to allow commas within search phrases
+ if not query:
+ search_results = all_indicators
+ else:
+ # Split on semicolon to get separate phrases
+ phrases = [phrase.strip() for phrase in query.split(";") if phrase.strip()]
+
+ if not phrases:
+ search_results = all_indicators
+ else:
+ filtered_by_query: list = []
+ for indicator in all_indicators:
+ text_to_search = (
+ indicator.get("label", "").lower()
+ + " "
+ + indicator.get("description", "").lower()
+ + " "
+ + indicator.get("dataflow_name", "").lower()
+ + " "
+ + indicator.get("dataflow_id", "").lower()
+ + " "
+ + indicator.get("indicator", "").lower()
+ + " "
+ + indicator.get("_table_search_text", "")
+ )
+
+ match = False
+ for phrase in phrases:
+ # This handles AND (+) and OR (|) operators within the phrase
+ parsed_phrase = self._parse_query(phrase)
+
+ if not parsed_phrase:
+ # If parsing fails, treat as simple substring search
+ if phrase.lower() in text_to_search:
+ match = True
+ break
+ else:
+ phrase_match = False
+
+ for or_group in parsed_phrase:
+ if all(term in text_to_search for term in or_group):
+ phrase_match = True
+ break
+
+ if phrase_match:
+ match = True
+ break
+
+ if match:
+ filtered_by_query.append(indicator)
+ search_results = filtered_by_query
+
+ # Apply keyword filters
+ if not keywords:
+ # Clean up internal search field before returning
+ for indicator in search_results:
+ indicator.pop("_table_search_text", None)
+ return search_results
+
+ filtered_results: list = []
+ for indicator in search_results:
+ indicator_text = (
+ indicator.get("indicator", "")
+ + " "
+ + indicator.get("label", "")
+ + " "
+ + indicator.get("description", "")
+ + " "
+ + indicator.get("_table_search_text", "")
+ ).lower()
+
+ # Check each keyword
+ include = True
+ for keyword in keywords:
+ kw = keyword.strip()
+ if kw.lower().startswith("not "):
+ # Exclusion keyword - if the word is present, exclude this indicator
+ exclude_word = kw[4:].lower() # Remove "not " prefix
+ if exclude_word and exclude_word in indicator_text:
+ include = False
+ break
+ elif kw.lower() not in indicator_text:
+ include = False
+ break
+
+ if include:
+ filtered_results.append(indicator)
+
+ # Clean up internal search field before returning
+ for indicator in filtered_results:
+ indicator.pop("_table_search_text", None)
+
+ return filtered_results
+
+ def _parse_query(self, query: str) -> list[list[str]]:
+ """Parse a search query string into OR-groups of AND-terms."""
+ return parse_search_query(query)
+
+ def get_dataflow_parameters(self, dataflow_id: str) -> dict[str, list[dict]]:
+ """Get available parameters for a given dataflow."""
+ if dataflow_id not in self.dataflows:
+ raise ValueError(f"Dataflow '{dataflow_id}' not found.")
+
+ if (
+ hasattr(self, "_dataflow_parameters_cache")
+ and dataflow_id in self._dataflow_parameters_cache
+ ):
+ return self._dataflow_parameters_cache[dataflow_id]
+
+ df_obj = self.dataflows[dataflow_id]
+ agency_id = df_obj.get("agencyID")
+ dsd_id = df_obj.get("structureRef", {}).get("id")
+ dsd = self.datastructures.get(dsd_id, {})
+ if not dsd:
+ return {}
+
+ dimensions_metadata = {
+ dim["id"]: dim for dim in dsd.get("dimensions", []) if dim.get("id")
+ }
+
+ constraints_response = self.get_available_constraints(
+ dataflow_id=dataflow_id,
+ key="all",
+ component_id="all",
+ mode="available",
+ references="all",
+ )
+ key_values = constraints_response.get("key_values", [])
+ constrained_values_map = {kv["id"]: kv.get("values", []) for kv in key_values}
+
+ parameters: dict[str, list[dict]] = {}
+ dimension_codes_cache: dict[str, dict] = {}
+
+ def _get_codes(dim_id: str) -> dict:
+ if dim_id in dimension_codes_cache:
+ return dimension_codes_cache[dim_id]
+
+ dim_meta = dimensions_metadata.get(dim_id, {})
+ codelist_id = self._resolve_codelist_id(
+ dataflow_id, dsd_id, dim_id, dim_meta
+ )
+ if codelist_id:
+ codes = (
+ self._get_codelist_map(
+ codelist_id, agency_id, dataflow_id, include_descriptions=False
+ )
+ or {}
+ )
+ dimension_codes_cache[dim_id] = codes
+ return codes
+ return {}
+
+ for dim_id in dimensions_metadata:
+ if dim_id == "TIME_PERIOD":
+ continue
+
+ # Get the codelist ID for this dimension upfront
+ dim_meta = dimensions_metadata.get(dim_id, {})
+ codelist_id = self._resolve_codelist_id(
+ dataflow_id, dsd_id, dim_id, dim_meta
+ )
+
+ if not codelist_id:
+ continue
+
+ # Get the full codelist from cache
+ full_codes = self._codelist_cache.get(codelist_id, {})
+ if not full_codes:
+ # Try to fetch it if not in cache
+ codes_map = _get_codes(dim_id)
+ if not codes_map:
+ continue
+ full_codes = codes_map
+
+ value_ids_to_use = (
+ constrained_values_map[dim_id]
+ if dim_id in constrained_values_map
+ else list(full_codes.keys())
+ )
+
+ options: list = []
+ for val_id in value_ids_to_use:
+ # Look up the label from the full codes
+ label = full_codes.get(val_id, val_id)
+
+ # If it's a dict (from _get_codes with descriptions), extract the name
+ if isinstance(label, dict):
+ label = label.get("name", val_id)
+
+ # Ensure we have a string label
+ if not label or label == val_id:
+ # If still no proper label, use the code itself
+ label = val_id
+
+ options.append({"label": label, "value": val_id.strip()})
+
+ if options:
+ parameters[dim_id] = options
+
+ time_period_options, _ = self._build_time_period_parameters(
+ constraints_response
+ )
+ if time_period_options:
+ parameters["TIME_PERIOD"] = time_period_options
+
+ if hasattr(self, "_dataflow_parameters_cache"):
+ self._dataflow_parameters_cache[dataflow_id] = parameters
+
+ return parameters
+
+ def _parse_agency_from_urn(self, code_urn: str) -> str | None:
+ """Parse agency ID from hierarchicalCode's code URN."""
+ return parse_agency_from_urn(code_urn)
+
+ def _fetch_single_codelist(self, agency_id: str, codelist_id: str) -> bool:
+ """
+ Fetch a single codelist from the API and cache it.
+
+ Parameters
+ ----------
+ agency_id : str
+ The agency ID (e.g., "ISORA", "IMF.STA")
+ codelist_id : str
+ The codelist ID (e.g., "CL_RAFIT_LABELS")
+
+ Returns
+ -------
+ bool
+ True if successfully fetched and cached, False otherwise.
+ """
+ # pylint: disable=import-outside-toplevel
+ import json
+
+ from openbb_core.provider.utils.helpers import make_request
+ from requests.exceptions import RequestException
+
+ if codelist_id in self._codelist_cache and self._codelist_cache.get(
+ codelist_id
+ ):
+ return True
+
+ url = f"https://api.imf.org/external/sdmx/3.0/structure/codelist/{agency_id}/{codelist_id}?detail=full&references=none"
+ headers = {"Accept": "application/json"}
+
+ try:
+ response = make_request(url, headers=headers, timeout=5)
+ if response.status_code != 200:
+ # Mark as failed to avoid repeated attempts
+ # self._codelist_cache[codelist_id] = {}
+ return False
+ json_response: dict = response.json()
+ except (json.JSONDecodeError, RequestException):
+ # Mark as failed to avoid repeated attempts
+ # self._codelist_cache[codelist_id] = {}
+ return False
+
+ codelists_in_response = json_response.get("data", {}).get("codelists", [])
+
+ if not codelists_in_response:
+ return False
+
+ with self._codelist_lock:
+ for codelist_obj in codelists_in_response:
+ cl_id = codelist_obj.get("id")
+ if not cl_id:
+ continue
+
+ current_codelist_map = {}
+ current_descriptions_map = {}
+ for code in codelist_obj.get("codes", []):
+ code_id = code.get("id")
+ code_name_obj = (
+ code.get("names", {}).get("en") or code.get("name") or code_id
+ )
+ code_description = (
+ code.get("descriptions", {}).get("en", "")
+ or code.get("description", "")
+ or ""
+ )
+ if not code_description and code_name_obj:
+ code_description = code_name_obj
+
+ if code_id:
+ current_codelist_map[code_id] = code_name_obj
+ current_descriptions_map[code_id] = code_description
+
+ self._codelist_cache[cl_id] = current_codelist_map
+ self._codelist_descriptions[cl_id] = current_descriptions_map
+
+ return codelist_id in self._codelist_cache
+
+ def _bulk_fetch_and_cache_codelists(self, agency_id: str, dataflow_id: str):
+ """Fetch all codelists for a given agency and dataflow and caches them."""
+ # pylint: disable=import-outside-toplevel
+ import json
+
+ from openbb_core.provider.utils.helpers import make_request
+ from requests.exceptions import RequestException
+
+ url = f"https://api.imf.org/external/sdmx/3.0/structure/codelist/{agency_id},{dataflow_id}/all?detail=full&references=none"
+ headers = {"Accept": "application/json"}
+
+ try:
+ response = make_request(url, headers=headers)
+ json_response: dict = response.json()
+ except (json.JSONDecodeError, RequestException) as e:
+ warnings.warn(
+ f"Could not bulk fetch codelists for {agency_id}/{dataflow_id}: {e} -> {url}",
+ OpenBBWarning,
+ )
+ return
+
+ codelists_in_response = json_response.get("data", {}).get("codelists", [])
+
+ with self._codelist_lock:
+ for codelist_obj in codelists_in_response:
+ codelist_id = codelist_obj.get("id")
+ if not codelist_id:
+ continue
+
+ current_codelist_map = {}
+ current_descriptions_map = {}
+ for code in codelist_obj.get("codes", []):
+ code_id = code.get("id")
+ code_name_obj = (
+ code.get("names", {}).get("en") or code.get("name") or code_id
+ )
+ code_description = (
+ code.get("descriptions", {}).get("en", "")
+ or code.get("description", "")
+ or ""
+ )
+ if not code_description and code_name_obj:
+ code_description = code_name_obj
+
+ if code_id:
+ current_codelist_map[code_id] = code_name_obj
+ current_descriptions_map[code_id] = code_description
+
+ self._codelist_cache[codelist_id] = current_codelist_map
+ self._codelist_descriptions[codelist_id] = current_descriptions_map
+
+ def _get_codelist_map(
+ self,
+ codelist_id: str,
+ agency_id: str,
+ dataflow_id: str,
+ include_descriptions: bool = False,
+ ) -> dict:
+ """Download and cache the codelist map for a given codelist ID."""
+ with self._codelist_lock:
+ if codelist_id in self._codelist_cache:
+ if include_descriptions and codelist_id in self._codelist_descriptions:
+ result = {}
+ for code_id, code_name in self._codelist_cache[codelist_id].items():
+ result[code_id] = {
+ "name": code_name,
+ "description": self._codelist_descriptions[codelist_id].get(
+ code_id, ""
+ ),
+ }
+ return result
+ return self._codelist_cache[codelist_id].copy()
+
+ # If not in cache, try to bulk fetch and cache
+ self._bulk_fetch_and_cache_codelists(agency_id, dataflow_id)
+
+ # Try again from cache
+ with self._codelist_lock:
+ if codelist_id in self._codelist_cache:
+ if include_descriptions and codelist_id in self._codelist_descriptions:
+ result = {}
+ for code_id, code_name in self._codelist_cache[codelist_id].items():
+ result[code_id] = {
+ "name": code_name,
+ "description": self._codelist_descriptions[codelist_id].get(
+ code_id, ""
+ ),
+ }
+ return result
+ return self._codelist_cache[codelist_id].copy()
+
+ warnings.warn(f"Codelist '{codelist_id}' not found.", OpenBBWarning)
+ return {}
+
+ def get_available_constraints(
+ self,
+ dataflow_id: str,
+ key: str,
+ component_id: str | None = None,
+ mode: str | None = None,
+ references: str | None = None,
+ **kwargs,
+ ) -> dict:
+ """Fetch available constraints for a given dataflow and parameters."""
+ # pylint: disable=import-outside-toplevel
+ import json
+
+ from openbb_core.provider.utils.helpers import make_request
+ from requests.exceptions import RequestException
+
+ if dataflow_id not in self.dataflows:
+ raise ValueError(f"Dataflow '{dataflow_id}' not found.")
+
+ kwargs_sorted = sorted(kwargs.items())
+ kwargs_tuple = tuple(kwargs_sorted)
+
+ cache_key = (
+ f"{dataflow_id}:{key}:{component_id}:{mode}:{references}:{kwargs_tuple}"
+ )
+
+ with self._constraints_lock:
+ if cached_constraints := self._constraints_cache.get(cache_key):
+ return cached_constraints
+
+ df = self.dataflows[dataflow_id]
+ agency_id = df.get("agencyID")
+
+ if not agency_id:
+ raise ValueError(f"Agency ID not found for dataflow '{dataflow_id}'.")
+
+ # Note: URL length is now primarily managed by table_builder.py which limits
+ # constraint keys to depth 0-1 codes when there are many indicators.
+ # This fallback is kept as a safety net for edge cases.
+ processed_key = key
+
+ base_url = (
+ f"https://api.imf.org/external/sdmx/3.0/availability/dataflow/"
+ f"{agency_id}/{dataflow_id}/%2B/{processed_key}/{component_id or 'all'}"
+ )
+ query_params = {
+ "mode": mode,
+ "references": references,
+ }
+ c_params = {f"c[{k}]": v for k, v in kwargs.items() if v}
+ query_params.update(c_params)
+
+ query_params = {k: v for k, v in query_params.items() if v is not None}
+ url = (
+ base_url + "?" + "&".join(f"{k}={v}" for k, v in query_params.items())
+ if query_params
+ else base_url
+ )
+ json_response: dict = {}
+ try:
+ headers = {
+ "Accept": "application/json",
+ "User-Agent": "Open Data Platform - IMF Metadata Utility",
+ }
+ response = make_request(url, headers=headers)
+ response.raise_for_status()
+ json_response = response.json()
+ except json.JSONDecodeError as e:
+ raise OpenBBError(
+ f"Unexpected response format when fetching constraints {dataflow_id}: {e}"
+ + f" -> {url}"
+ ) from None
+ except RequestException as e:
+ raise OpenBBError(
+ f"An error occurred while fetching constraints {dataflow_id}: {e} -> {url}"
+ ) from None
+
+ extracted_values: dict = {}
+ json_data = json_response.get("data", {})
+ data_constraints = json_data.get("dataConstraints", [])
+
+ for constraint in data_constraints:
+ for region in constraint.get("cubeRegions", []):
+ for kv in region.get("keyValues", []):
+ dim_id = kv.get("id")
+ if dim_id:
+ if dim_id not in extracted_values:
+ extracted_values[dim_id] = []
+ for val in kv.get("values", []):
+ if isinstance(val, dict):
+ extracted_values[dim_id].append(val.get("value"))
+ else:
+ extracted_values[dim_id].append(val)
+ for comp in region.get("components", []):
+ dim_id = comp.get("id")
+ if dim_id:
+ if dim_id not in extracted_values:
+ extracted_values[dim_id] = []
+ for val in comp.get("values", []):
+ if isinstance(val, dict):
+ extracted_values[dim_id].append(val.get("value"))
+ else:
+ extracted_values[dim_id].append(val)
+
+ for dim_id, values in list(extracted_values.items()):
+ # Remove falsy values, deduplicate.
+ unique_values = {v for v in values if v}
+ extracted_values[dim_id] = list(unique_values)
+
+ key_values = [{"id": k, "values": v} for k, v in extracted_values.items()]
+
+ result = {"key_values": key_values, "full_response": json_response}
+
+ with self._constraints_lock:
+ self._constraints_cache[cache_key] = result
+
+ return result
+
+ def get_indicators_in(self, dataflow_id: str) -> list:
+ """Get indicators available in a given dataflow."""
+ if dataflow_id not in self.dataflows:
+ raise ValueError(f"Dataflow '{dataflow_id}' not found.")
+
+ dataflow_obj = self.dataflows[dataflow_id]
+ dataflow_name = dataflow_obj.get("name", "").replace("\\xa0", "").strip()
+ structure_ref = dataflow_obj.get("structureRef", {})
+ structure_id = structure_ref.get("id", "")
+ agency_id = dataflow_obj.get("agencyID", structure_ref.get("agencyID", "IMF"))
+ dsd_id = structure_ref.get("id", "")
+
+ if not dsd_id or dsd_id not in self.datastructures:
+ raise ValueError(f"Data structure not found for dataflow '{dataflow_id}'.")
+
+ dsd = self.datastructures[dsd_id]
+ all_dims = dsd.get("dimensions", [])
+
+ # Get valid codes from parameters API
+ try:
+ params = self.get_dataflow_parameters(dataflow_id)
+ except Exception: # noqa: BLE001
+ params = {}
+
+ full_indicator_list = []
+
+ indicator_id_candidates = [
+ "INDICATOR",
+ "PRODUCTION_INDEX",
+ "COICOP_1999",
+ "INDEX_TYPE",
+ "ACTIVITY",
+ "PRODUCT",
+ "SERIES",
+ "ITEM",
+ "BOP_ACCOUNTING_ENTRY",
+ "ACCOUNTING_ENTRY",
+ ]
+
+ for dim in all_dims:
+ dim_id = dim.get("id")
+
+ is_indicator_candidate = dim_id in indicator_id_candidates
+ if not is_indicator_candidate and any(
+ keyword in dim_id
+ for keyword in ["INDICATOR", "ACCOUNTING_ENTRY", "ENTRY"]
+ ):
+ is_indicator_candidate = True
+
+ if not is_indicator_candidate:
+ continue
+
+ # Get valid codes with labels from parameters API
+ dim_params = params.get(dim_id, [])
+ if not dim_params:
+ continue
+
+ # Resolve codelist ID for this dimension to look up descriptions
+ codelist_id = self._resolve_codelist_id(dataflow_id, dsd_id, dim_id, dim)
+ descriptions_map: dict = {}
+ if codelist_id:
+ # Try to get cached descriptions
+ descriptions_map = self._codelist_descriptions.get(codelist_id, {})
+ # If not cached, try to load the codelist to populate descriptions
+ if not descriptions_map and codelist_id not in self._codelist_cache:
+ try:
+ self._get_codelist_map(codelist_id, agency_id, dataflow_id)
+ descriptions_map = self._codelist_descriptions.get(
+ codelist_id, {}
+ )
+ except Exception: # noqa
+ pass # Codelist not available, continue without descriptions
+
+ # Parameters API already provides labels - use them directly
+ for param in dim_params:
+ code_id = param["value"]
+ code_label = param.get("label", code_id)
+ series_id = f"{dataflow_id}::{code_id}"
+
+ # Look up description from codelist, fall back to label if not found
+ description = descriptions_map.get(code_id, "")
+
+ indicator_entry = {
+ "dataflow_id": dataflow_id,
+ "dataflow_name": dataflow_name,
+ "structure_id": structure_id,
+ "agency_id": agency_id,
+ "dimension_id": dim_id,
+ "indicator": code_id,
+ "label": code_label,
+ "description": description,
+ "series_id": series_id,
+ }
+ full_indicator_list.append(indicator_entry)
+
+ # Check for activity-related codelists
+ dim_ids = {d.get("id") for d in all_dims if d.get("id")}
+ if "ACTIVITY" in dim_ids:
+ activity_codelist_id = f"CL_{dataflow_id}_ACTIVITY"
+ if activity_codelist_id in self._codelist_cache:
+ codes_map = self._get_codelist_map(
+ activity_codelist_id, agency_id, dataflow_id
+ )
+ descriptions_map = self._codelist_descriptions.get(
+ activity_codelist_id, {}
+ )
+ for code_id, code_name in codes_map.items():
+ series_id = f"{dataflow_id}::{code_id}"
+ entry = {
+ "dataflow_id": dataflow_id,
+ "dataflow_name": dataflow_name,
+ "structure_id": structure_id,
+ "agency_id": agency_id,
+ "dimension_id": "ACTIVITY",
+ "indicator": code_id,
+ "label": code_name,
+ "description": descriptions_map.get(code_id, ""),
+ "series_id": series_id,
+ }
+ full_indicator_list.append(entry)
+
+ if not full_indicator_list:
+ raise KeyError(
+ f"Could not find an indicator-like dimension for dataflow '{dataflow_id}'."
+ )
+
+ return full_indicator_list
+
+ def _resolve_codelist_id(
+ self, dataflow_id: str, dsd_id: str | None, dim_id: str, dim_meta: dict
+ ) -> str | None:
+ if not dim_id:
+ return None
+
+ # Check for explicit codelist reference first
+ representation = dim_meta.get("representation", {})
+ codelist_ref = representation.get("codelist")
+ if isinstance(codelist_ref, dict):
+ return codelist_ref.get("id")
+ if isinstance(codelist_ref, str):
+ return codelist_ref
+
+ candidates: list[str] = []
+ seen = set()
+
+ def add_candidate(candidate: str):
+ if candidate and candidate not in seen:
+ candidates.append(candidate)
+ seen.add(candidate)
+
+ concept_ref = dim_meta.get("conceptRef") or {}
+ concept_id = concept_ref.get("id")
+
+ # For country-like dimensions (JURISDICTION, REF_AREA, COUNTRY, etc.),
+ # prioritize dataflow-specific ISO country codelists first.
+ country_dims = {"JURISDICTION", "REF_AREA", "COUNTRY", "AREA"}
+ is_country_dim = dim_id.upper() in country_dims or (
+ concept_id and concept_id.upper() in {"COUNTRY", "REF_AREA"}
+ )
+
+ if is_country_dim and dataflow_id:
+ # Try dataflow-specific ISO country codelist first
+ base_dataflow = dataflow_id.split("_")[0]
+ add_candidate(f"CL_{base_dataflow}_ISO_COUNTRY")
+ add_candidate(f"CL_{dataflow_id}_COUNTRY")
+ add_candidate(f"CL_{base_dataflow}_COUNTRY")
+
+ # Priority 1: Dataflow-specific patterns (most specific first)
+ if dataflow_id:
+ add_candidate(f"CL_{dataflow_id}_{dim_id}")
+ add_candidate(f"CL_{dataflow_id}_{dim_id}_PUB") # _PUB suffix variant
+ if "COUNTRY" in dim_id:
+ add_candidate(f"CL_{dataflow_id}_COUNTRY")
+ add_candidate(f"CL_{dataflow_id}_COUNTRY_PUB")
+ if "_" in dataflow_id:
+ base_dataflow = dataflow_id.split("_")[0]
+ add_candidate(f"CL_{base_dataflow}_{dim_id}")
+ add_candidate(f"CL_{base_dataflow}_{dim_id}_PUB")
+
+ # Priority 2: DSD patterns
+ if dsd_id:
+ dsd_base = dsd_id.replace("DSD_", "")
+ add_candidate(f"CL_{dsd_base}_{dim_id}")
+
+ # Priority 3: Parent scheme patterns
+ parent_scheme_id = concept_ref.get("maintainableParentID")
+ if parent_scheme_id:
+ scheme_base = parent_scheme_id.replace("CS_", "CL_", 1)
+ add_candidate(f"{scheme_base}_{dim_id}")
+ add_candidate(scheme_base)
+
+ # Priority 4: Direct/generic matches (fallback)
+ add_candidate(f"CL_{dim_id}")
+ if concept_id:
+ add_candidate(f"CL_{concept_id}")
+
+ # Check cache for exact matches first
+ for candidate in candidates:
+ if candidate in self._codelist_cache:
+ return candidate
+
+ # Case-insensitive fallback for dataflow-specific codelists
+ # IMF has inconsistent casing (e.g., CL_LS_TYPE_OF_TRANSFORMAtION)
+ cache_keys_upper = {k.upper(): k for k in self._codelist_cache}
+ for candidate in candidates:
+ actual_key = cache_keys_upper.get(candidate.upper())
+ if actual_key:
+ return actual_key
+
+ # Consolidated mapping for common variations
+ # This combines the old variations dict and generic_dimensions list
+ common_mappings = {
+ # Geographic dimensions
+ (
+ "REF_AREA",
+ "AREA",
+ "COUNTRY",
+ "JURISDICTION",
+ "GEOGRAPHICAL_AREA",
+ ): "AREA",
+ ("COUNTERPART_COUNTRY",): "COUNTRY",
+ # Statistical dimensions
+ ("COMPOSITE_BREAKDOWN", "COMP_BREAKDOWN"): "COMPOSITE_BREAKDOWN",
+ ("DISABILITY_STATUS", "DISABILITY"): "DISABILITY",
+ ("INCOME_WEALTH_QUANTILE", "QUANTILE"): "QUANTILE",
+ ("TYPE_OF_TRANSFORMATION", "TRANSFORMATION"): "TRANSFORMATION",
+ ("WGT_TYPE", "WEIGHT_TYPE", "CTOT_WEIGHT_TYPE"): "WEIGHT_TYPE",
+ ("INDICATOR", "INDICATORS"): "INDICATOR",
+ ("UNIT", "UNIT_MEASURE", "UNIT_MULT"): "UNIT",
+ }
+
+ # Check if dimension matches any common pattern
+ for patterns, base_name in common_mappings.items():
+ if any(pattern in dim_id.upper() for pattern in patterns):
+ # Try generic first
+ generic_cl = f"CL_{base_name}"
+ if generic_cl in self._codelist_cache:
+ return generic_cl
+ # Then try dataflow-specific
+ specific_cl = f"CL_{dataflow_id}_{base_name}"
+ if specific_cl in self._codelist_cache:
+ return specific_cl
+
+ # Special handling for counterpart dimensions
+ if "COUNTERPART_" in dim_id:
+ base_dim_id = dim_id.replace("COUNTERPART_", "")
+ if dsd_id and dsd_id in self.datastructures:
+ dsd = self.datastructures[dsd_id]
+ for d in dsd.get("dimensions", []):
+ if d.get("id") == base_dim_id:
+ return self._resolve_codelist_id(
+ dataflow_id, dsd_id, base_dim_id, d
+ )
+
+ # Activity/Product fallbacks
+ if "ACTIVITY" in dim_id.upper() or "PRODUCTION_INDEX" in dim_id.upper():
+ activity_candidates = [
+ "CL_PPI_ACTIVITY",
+ "CL_MCDREO_ACTIVITY",
+ "CL_ACTIVITY_ISIC4",
+ "CL_NEA_ACTIVITY",
+ "CL_ACTIVITY",
+ ]
+ for candidate in activity_candidates:
+ if candidate in self._codelist_cache:
+ return candidate
+
+ if "COICOP" in dim_id.upper():
+ coicop_candidates = ["CL_COICOP_1999", "CL_COICOP_2018"]
+ for candidate in coicop_candidates:
+ if candidate in self._codelist_cache:
+ return candidate
+
+ # Fuzzy matching as last resort
+ dim_upper = dim_id.upper()
+
+ # Try exact substring match
+ for cache_key in self._codelist_cache:
+ if cache_key.startswith("CL_MASTER"):
+ continue
+ if dim_upper in cache_key.upper():
+ return cache_key
+
+ # Try matching significant parts
+ dim_parts = [p for p in dim_id.split("_") if len(p) > 2]
+ if len(dim_parts) > 1:
+ for cache_key in self._codelist_cache:
+ if cache_key.startswith("CL_MASTER"):
+ continue
+ cache_key_upper = cache_key.upper()
+ if all(part.upper() in cache_key_upper for part in dim_parts):
+ return cache_key
+
+ # Return first candidate or None
+ return candidates[0] if candidates else None
+
+ def _build_time_period_parameters(
+ self, constraints_response: dict | None
+ ) -> tuple[list[dict], str | None]:
+ """Build time period parameters from a constraints API response."""
+ return build_time_period_params(constraints_response)
+
+ def _parse_codelist_urn(self, urn: str) -> str | None:
+ """Parse codelist ID from owningCodelistUrn."""
+ return parse_codelist_urn(urn)
+
+ def _parse_indicator_code_from_urn(self, code_urn: str) -> str | None:
+ """Parse indicator code from hierarchicalCode's code URN."""
+ return parse_indicator_code_from_urn(code_urn)
+
+ def _parse_codelist_id_from_urn(self, code_urn: str) -> str | None:
+ """Parse codelist ID from hierarchicalCode's code URN."""
+ return parse_codelist_id_from_urn(code_urn)
+
+ def _get_dimension_for_codelist(
+ self, dataflow_id: str, codelist_id: str
+ ) -> str | None:
+ """
+ Find which dimension uses the given codelist ID.
+
+ Parameters
+ ----------
+ dataflow_id : str
+ The dataflow ID
+ codelist_id : str
+ The codelist ID (e.g., "CL_BOP_INDICATOR")
+
+ Returns
+ -------
+ str | None
+ The dimension ID that uses this codelist, or None if not found.
+ """
+ if dataflow_id not in self.dataflows:
+ return None
+
+ df_obj = self.dataflows[dataflow_id]
+ dsd_id = df_obj.get("structureRef", {}).get("id")
+ if not dsd_id or dsd_id not in self.datastructures:
+ return None
+
+ dsd = self.datastructures[dsd_id]
+ dimensions = dsd.get("dimensions", [])
+
+ # First pass: exact match
+ for dim in dimensions:
+ dim_id = dim.get("id")
+ if not dim_id:
+ continue
+
+ # Resolve the codelist for this dimension
+ resolved_codelist = self._resolve_codelist_id(
+ dataflow_id, dsd_id, dim_id, dim
+ )
+ if resolved_codelist == codelist_id:
+ return dim_id
+
+ # Second pass: fuzzy match by dimension name appearing anywhere in codelist ID
+ # e.g., CL_IRFCL_DEFAULT_INDICATOR_PUB2 should match INDICATOR dimension
+ # Split codelist into segments and check if any segment matches a dimension
+ codelist_segments = set(seg.upper() for seg in codelist_id.split("_"))
+ for dim in dimensions:
+ dim_id = dim.get("id")
+ if dim_id and dim_id.upper() in codelist_segments:
+ return dim_id
+
+ # Third pass: check if dimension name is a substring of codelist ID
+ # Handles cases like CL_IRFCL_DEFAULT_INDICATOR_PUB2 -> INDICATOR
+ codelist_upper = codelist_id.upper()
+ for dim in dimensions:
+ dim_id = dim.get("id")
+ if dim_id and dim_id.upper() in codelist_upper:
+ return dim_id
+
+ return None
+
+ def _extract_all_codelists_from_hierarchy(self, hierarchy: dict) -> set[str]:
+ """Recursively extract ALL codelist IDs from a hierarchy's hierarchicalCodes."""
+ return extract_all_codelists_from_hierarchy(hierarchy)
+
+ def _build_hierarchy_to_codelist_map(self) -> dict[str, str]:
+ """Build mapping from hierarchy ID to codelist ID."""
+ return build_hierarchy_to_codelist_map(self.hierarchies)
+
+ def _build_codelist_to_hierarchies_map(self) -> dict[str, list[str]]:
+ """Build reverse mapping from codelist ID to list of hierarchy IDs."""
+ return build_codelist_to_hierarchies_map(self.hierarchies)
+
+ def _validate_hierarchy_queryable(self, dataflow_id: str, codes: list) -> bool:
+ """
+ Check if a hierarchy's codes can actually be queried (map to dimensions, not just attributes).
+
+ Parameters
+ ----------
+ dataflow_id : str
+ The dataflow ID
+ codes : list
+ List of hierarchical code entries
+
+ Returns
+ -------
+ bool
+ True if at least some codes map to queryable dimensions
+ """
+ if not codes:
+ return False
+
+ # Sample a few codes to check if they map to dimensions
+ sample_size = min(10, len(codes))
+ valid_count = 0
+
+ for code_entry in codes[:sample_size]:
+ code_urn = code_entry.get("code", "")
+ if not code_urn:
+ continue
+
+ codelist_id = self._parse_codelist_id_from_urn(code_urn)
+ if not codelist_id:
+ continue
+
+ # Check if this codelist maps to a dimension (not an attribute)
+ dimension_id = self._get_dimension_for_codelist(dataflow_id, codelist_id)
+ if dimension_id:
+ valid_count += 1
+
+ # Consider hierarchy queryable if at least 50% of sampled codes map to dimensions
+ return valid_count >= (sample_size * 0.5)
+
+ def get_dataflow_hierarchies(self, dataflow_id: str) -> list[dict]:
+ """
+ Get all hierarchies (presentation tables) available for a dataflow.
+
+ This supports two types of presentations:
+ 1. Hierarchies from hierarchy.json (45 codelists)
+ 2. Presentations embedded in dataflow metadata (31 dataflows)
+
+ Parameters
+ ----------
+ dataflow_id : str
+ The dataflow ID (e.g., "FSIBSIS", "BOP_AGG", "IRFCL")
+
+ Returns
+ -------
+ list[dict]
+ List of hierarchy/presentation metadata dicts.
+ """
+ if dataflow_id not in self.dataflows:
+ raise ValueError(f"Dataflow '{dataflow_id}' not found.")
+
+ dataflow_obj = self.dataflows[dataflow_id]
+ result = []
+
+ # First, check for hierarchies from hierarchy.json (these have actual structure)
+ dsd_id = dataflow_obj.get("structureRef", {}).get("id")
+
+ if dsd_id and dsd_id in self.datastructures:
+ dsd = self.datastructures[dsd_id]
+ dimensions = dsd.get("dimensions", [])
+
+ indicator_codelist_id = None
+ # Priority-ordered list of dimension names to check for indicators
+ indicator_candidates = [
+ "INDICATOR",
+ "COICOP_1999",
+ "PRODUCTION_INDEX",
+ "ACTIVITY",
+ "PRODUCT",
+ "SERIES",
+ "ITEM",
+ "ACCOUNTING_ENTRY",
+ "SECTOR",
+ ]
+
+ dim_lookup = {d.get("id", ""): d for d in dimensions}
+ # Check candidates in priority order (not dimension order)
+ for candidate in indicator_candidates:
+ if candidate in dim_lookup:
+ dim = dim_lookup[candidate]
+ indicator_codelist_id = self._resolve_codelist_id(
+ dataflow_id, dsd_id, candidate, dim
+ )
+ if indicator_codelist_id:
+ # Check if this codelist actually has hierarchies
+ if self._codelist_to_hierarchies_map.get(indicator_codelist_id):
+ break
+ # If no hierarchies, continue to next candidate
+ indicator_codelist_id = None
+
+ # Fallback: check for any dimension with "INDICATOR" in its name
+ if not indicator_codelist_id:
+ for dim in dimensions:
+ dim_id = dim.get("id", "")
+ if "INDICATOR" in dim_id and dim_id not in indicator_candidates:
+ indicator_codelist_id = self._resolve_codelist_id(
+ dataflow_id, dsd_id, dim_id, dim
+ )
+ if indicator_codelist_id:
+ break
+
+ if indicator_codelist_id:
+ hierarchy_ids = self._codelist_to_hierarchies_map.get(
+ indicator_codelist_id, []
+ )
+ # Get available indicator values for this dataflow to filter hierarchies
+ available_indicator_values: set[str] = set()
+
+ try:
+ params = self.get_dataflow_parameters(dataflow_id)
+
+ if "INDICATOR" in params:
+ available_indicator_values.update(
+ opt.get("value", "") for opt in params["INDICATOR"]
+ )
+ except Exception: # noqa: S110
+ pass # If we can't get parameters, include all hierarchies
+
+ for hier_id in hierarchy_ids:
+ hier_obj = self.hierarchies.get(hier_id)
+ hier_code_values: set[str] = set()
+ if hier_obj:
+ # If we have available values, check if this hierarchy is compatible
+ if available_indicator_values:
+ hier_codes_raw = hier_obj.get("hierarchicalCodes", [])
+
+ def _extract_codes(codes_list):
+ for c in codes_list:
+ # Extract actual code from URN like:
+ # urn:sdmx:...CL_BOP_INDICATOR(10.0+.0).NIIP_AFR
+ code_urn = c.get("code", "")
+ # Only extract codes from INDICATOR codelists
+ if (
+ code_urn
+ and "INDICATOR" in code_urn
+ and "." in code_urn
+ ):
+ actual_code = code_urn.rsplit(".", 1)[-1]
+ if actual_code:
+ hier_code_values.add( # pylint: disable=W0640
+ actual_code
+ )
+ # Recurse into nested codes
+ nested = c.get("hierarchicalCodes", [])
+ if nested:
+ _extract_codes(nested) # pylint: disable=W0640
+
+ _extract_codes(hier_codes_raw)
+
+ # Check if ANY of the hierarchy's codes exist in the dataflow
+ # Use prefix matching since dataflow codes may have unit suffixes
+ # e.g., hierarchy code "FSI687_TREGK" should match "FSI687_TREGK_USD"
+ if hier_code_values:
+ has_match = False
+ # First try exact match (fast path)
+ if hier_code_values & available_indicator_values:
+ has_match = True
+ else:
+ # Prefix matching: check if any available indicator
+ # starts with any hierarchy code
+ for hier_code in hier_code_values:
+ for avail_code in available_indicator_values:
+ if avail_code.startswith(hier_code):
+ has_match = True
+ break
+ if has_match:
+ break
+ if not has_match:
+ # No overlap - skip this hierarchy for this dataflow
+ continue
+
+ # Check if hierarchy has multiple top-level codes
+ top_level_codes = hier_obj.get("hierarchicalCodes", [])
+
+ if len(top_level_codes) > 1 and dataflow_id == "IRFCL":
+ # Split into separate tables - one per top-level code
+ for idx, top_code in enumerate(top_level_codes):
+ top_code_id = top_code.get("id", "")
+ top_code_urn = top_code.get("code", "")
+ # Extract actual code from URN
+ actual_code = (
+ top_code_urn.rsplit(".", 1)[-1]
+ if "." in top_code_urn
+ else top_code_id
+ )
+ # Get label from the codelist specified in the URN
+ urn_codelist_id = self._parse_codelist_id_from_urn(
+ top_code_urn
+ )
+ table_label = self._codelist_cache.get(
+ urn_codelist_id or indicator_codelist_id, {}
+ ).get(actual_code, actual_code)
+ # Check for SECTION codelist for better labels
+ section_codelist_id = f"CL_{dataflow_id}_SECTION"
+ section_codes = self._codelist_cache.get(
+ section_codelist_id, {}
+ )
+ # Match section by prefix in actual_code
+ for (
+ section_code,
+ section_label,
+ ) in section_codes.items():
+ if actual_code.startswith(section_code):
+ table_label = section_label
+ break
+
+ result.append(
+ {
+ "id": f"{hier_id}:{top_code_id}",
+ "name": table_label,
+ "description": "",
+ "codelist_id": indicator_codelist_id,
+ "agency_id": hier_obj.get("agencyID", ""),
+ "version": hier_obj.get("version", ""),
+ "type": "hierarchy",
+ "table_index": idx,
+ "top_level_code_id": top_code_id,
+ "indicator_code": actual_code,
+ }
+ )
+ else:
+ # Single top-level code - return as single table
+ name = hier_obj.get("name", "")
+ descriptions = hier_obj.get("descriptions", {})
+ desc = descriptions.get("en", "") if descriptions else ""
+ result.append(
+ {
+ "id": hier_id,
+ "name": name,
+ "description": desc,
+ "codelist_id": indicator_codelist_id,
+ "agency_id": hier_obj.get("agencyID", ""),
+ "version": hier_obj.get("version", ""),
+ "type": "hierarchy",
+ }
+ )
+
+ return result
+
+ def get_dataflow_table_structure(
+ self, dataflow_id: str, table_id: str | None = None
+ ) -> dict:
+ """
+ Get the presentation table structure for a dataflow.
+
+ Handles both hierarchy-based (hierarchy.json) and embedded presentations.
+
+ Parameters
+ ----------
+ dataflow_id : str
+ The dataflow ID
+ table_id : str | None
+ The specific hierarchy/table ID. If None, uses the first available.
+
+ Returns
+ -------
+ dict
+ Structure with hierarchy metadata and nested indicators.
+ """
+ available_hierarchies = self.get_dataflow_hierarchies(dataflow_id)
+
+ if not available_hierarchies:
+ raise ValueError(
+ f"No presentation hierarchies found for dataflow '{dataflow_id}'"
+ )
+
+ # Track if this is a split table (one top-level code from a multi-code hierarchy)
+ top_level_code_filter: str | None = None
+ base_hierarchy_id: str | None = None
+
+ if table_id:
+ # Find the specific table
+ selected_table = None
+ for h in available_hierarchies:
+ if h["id"] == table_id:
+ selected_table = h
+ break
+ if not selected_table:
+ raise ValueError(
+ f"Hierarchy '{table_id}' not found. "
+ f"Available: {[h['id'] for h in available_hierarchies]}"
+ )
+ # Check if this is a split table (format: "base_id:top_level_code_id")
+ if ":" in table_id:
+ base_hierarchy_id, top_level_code_filter = table_id.split(":", 1)
+ else:
+ base_hierarchy_id = table_id
+ else:
+ selected_table = available_hierarchies[0]
+ table_id = selected_table.get("id", "")
+ if table_id and ":" in table_id:
+ base_hierarchy_id, top_level_code_filter = table_id.split(":", 1)
+ else:
+ base_hierarchy_id = table_id
+
+ # Handle hierarchy-based presentations
+ # Use base_hierarchy_id to look up the actual hierarchy object
+ hierarchy = self.hierarchies.get(base_hierarchy_id or table_id)
+ if not hierarchy:
+ raise ValueError(
+ f"Hierarchy '{base_hierarchy_id or table_id}' not found in cache"
+ )
+
+ if not table_id:
+ raise ValueError("table_id cannot be None")
+
+ codelist_id = self._hierarchy_to_codelist_map.get(base_hierarchy_id or table_id)
+
+ dataflow_obj = self.dataflows.get(dataflow_id, {})
+ agency_id = dataflow_obj.get("agencyID", "IMF")
+ agency_clean = agency_id.replace(".", "_")
+
+ structure_ref = dataflow_obj.get("structureRef", {})
+ dsd_id = structure_ref.get("id")
+ dsd_obj = self.datastructures.get(dsd_id, {}) if dsd_id else {}
+ dimensions = dsd_obj.get("dimensions", []) if isinstance(dsd_obj, dict) else []
+
+ indicator_dimension_order: dict[str, int] = {}
+ indicator_id_candidates = [
+ "INDICATOR",
+ "PRODUCTION_INDEX",
+ "COICOP_1999",
+ "ACTIVITY",
+ "PRODUCT",
+ "SERIES",
+ "ITEM",
+ "BOP_ACCOUNTING_ENTRY",
+ "ACCOUNTING_ENTRY",
+ ]
+
+ for idx, dim in enumerate(dimensions):
+ dim_id = dim.get("id", "")
+ if not dim_id:
+ continue
+ is_indicator_candidate = dim_id in indicator_id_candidates or any(
+ keyword in dim_id
+ for keyword in ["INDICATOR", "ACCOUNTING_ENTRY", "ENTRY"]
+ )
+ if is_indicator_candidate:
+ indicator_dimension_order[dim_id] = idx
+
+ codelist_dimension_cache: dict[str, str | None] = {}
+ # Cache for per-codelist label and description lookups
+ # Hierarchies can mix codes from multiple codelists (e.g., CL_BOP_INDICATOR + CL_BOP_ACCOUNTING_ENTRY)
+ codelist_labels_cache: dict[str, dict] = {}
+ codelist_desc_cache: dict[str, dict] = {}
+
+ def process_hierarchical_codes(
+ codes: list,
+ parent_id: str | None = None,
+ depth: int = 0,
+ parent_codes: list | None = None,
+ parent_dimension_codes: dict[str, str] | None = None,
+ order_counter: list | None = None,
+ parent_full_label: str | None = None,
+ ancestor_labels: list | None = None,
+ ) -> list[dict]:
+ """Recursively process hierarchical codes into flat indicator list."""
+ # pylint: disable=import-outside-toplevel
+ import re
+
+ indicators = []
+
+ if parent_codes is None:
+ parent_codes = []
+ if parent_dimension_codes is None:
+ parent_dimension_codes = {}
+ if order_counter is None:
+ order_counter = [0]
+ if ancestor_labels is None:
+ ancestor_labels = []
+
+ for code_entry in codes:
+ code_id = code_entry.get("id")
+ code_urn = code_entry.get("code", "")
+ level = code_entry.get("level", "0")
+ indicator_code = self._parse_indicator_code_from_urn(code_urn)
+ codelist_id_for_code = self._parse_codelist_id_from_urn(code_urn)
+ dimension_id = None
+
+ if codelist_id_for_code:
+ if codelist_id_for_code not in codelist_dimension_cache:
+ codelist_dimension_cache[codelist_id_for_code] = (
+ self._get_dimension_for_codelist(
+ dataflow_id, codelist_id_for_code
+ )
+ )
+ dimension_id = codelist_dimension_cache[codelist_id_for_code]
+ # Cache labels and descriptions for this codelist
+ # Check if not in cache OR if cached value is empty (failed previous fetch)
+ cached_is_empty = not codelist_labels_cache.get(
+ codelist_id_for_code
+ )
+ if (
+ codelist_id_for_code not in codelist_labels_cache
+ or cached_is_empty
+ ):
+ # Try to get from main cache first
+ cached_labels = self._codelist_cache.get(
+ codelist_id_for_code, {}
+ )
+ cached_descs = self._codelist_descriptions.get(
+ codelist_id_for_code, {}
+ )
+ # If not found, try to fetch from API using URN agency
+ if not cached_labels and code_urn:
+ urn_agency = self._parse_agency_from_urn(code_urn)
+ if urn_agency:
+ self._fetch_single_codelist(
+ urn_agency, codelist_id_for_code
+ )
+ cached_labels = self._codelist_cache.get(
+ codelist_id_for_code, {}
+ )
+ cached_descs = self._codelist_descriptions.get(
+ codelist_id_for_code, {}
+ )
+ codelist_labels_cache[codelist_id_for_code] = cached_labels
+ codelist_desc_cache[codelist_id_for_code] = cached_descs
+
+ # Look up label from the code's actual codelist, not just the owning codelist
+ code_labels = (
+ codelist_labels_cache.get(codelist_id_for_code, {})
+ if codelist_id_for_code
+ else {}
+ )
+ code_descs = (
+ codelist_desc_cache.get(codelist_id_for_code, {})
+ if codelist_id_for_code
+ else {}
+ )
+ full_label = (
+ code_labels.get(indicator_code, code_id)
+ if indicator_code
+ else code_id
+ )
+ # Detect path-based labels in INDICATOR_PUB codelists
+ # These codelists store labels as comma-separated hierarchical paths
+ # e.g., "Section A, Category B, Item C" -> extract relative portion
+ # The hierarchy provides context, so we only need what's NEW at this node
+ label = full_label
+ # Check if this codelist uses path-style labels:
+ # 1. Named pattern: "_INDICATOR_PUB" in codelist name
+ # 2. Explicit codelist: CL_DIP_INDICATOR uses path-style labels
+ is_path_style_codelist = codelist_id_for_code and (
+ "_INDICATOR_PUB" in codelist_id_for_code
+ or codelist_id_for_code == "CL_DIP_INDICATOR"
+ )
+ if is_path_style_codelist:
+ # Special case for CL_DIP_INDICATOR: the first part of every label
+ # is "Inward Direct investment" or "Outward Direct investment" which
+ # is redundant with the parent categories "Inward Indicators"/"Outward Indicators"
+ # Just skip the first part for DIP labels
+ if (
+ codelist_id_for_code == "CL_DIP_INDICATOR"
+ and ", " in full_label
+ ):
+ parts = full_label.split(", ")
+ if len(parts) > 1 and (
+ parts[0].startswith("Inward")
+ or parts[0].startswith("Outward")
+ ):
+ label = ", ".join(parts[1:])
+ # For other path-style codelists, try exact prefix match with parent's full label
+ elif parent_full_label and full_label.startswith(parent_full_label):
+ # Remove parent's label prefix and separator
+ relative_label = full_label[len(parent_full_label) :].lstrip(
+ ", :"
+ )
+ if relative_label:
+ label = relative_label
+ elif ", " in full_label or ": " in full_label:
+ # Child has same label as parent (e.g., USD vs FTO gold variants)
+ parts = re.split(r", |: ", full_label)
+ label = parts[-1] if parts else full_label
+ elif ancestor_labels and (", " in full_label or ": " in full_label):
+ # This handles cases where hierarchy mixes codelists
+ def normalize_part(s: str) -> str:
+ """Normalize for comparison: lowercase, remove punctuation."""
+ return re.sub(r"[^a-z0-9]", "", s.lower())
+
+ # Split label by both ", " and ":" separators
+ def split_label(lbl: str) -> list:
+ """Split by comma-space and colon."""
+ # First split by ", "
+ parts = lbl.split(", ")
+ # Then split each part by ":"
+ result = []
+ for p in parts:
+ if ":" in p:
+ subparts = p.split(":")
+ result.extend(
+ [sp.strip() for sp in subparts if sp.strip()]
+ )
+ else:
+ result.append(p)
+ return result
+
+ # Collect all parts from all ancestor labels
+ all_ancestor_parts = []
+ for anc_label in ancestor_labels:
+ all_ancestor_parts.extend(split_label(anc_label))
+
+ child_parts = split_label(full_label)
+
+ # Build set of normalized ancestor parts
+ ancestor_normalized = {
+ normalize_part(p) for p in all_ancestor_parts
+ }
+
+ # Find parts in child that are genuinely new
+ new_parts = []
+ for part in child_parts:
+ part_norm = normalize_part(part)
+ # Allow for "Total X" in ancestor matching "X" in child
+ is_in_ancestor = part_norm in ancestor_normalized
+ if not is_in_ancestor:
+ for pn in ancestor_normalized:
+ # Check "totalX" matching "X"
+ if pn.startswith("total") and pn[5:] == part_norm:
+ is_in_ancestor = True
+ break
+ # Check if ancestor part is contained in child part
+ # e.g., "outflows" in "outflowsreservestemplate"
+ if len(pn) >= 6 and pn in part_norm:
+ is_in_ancestor = True
+ break
+ # Check if strings are very similar (>80% overlap)
+ # This handles encoding differences like "visvis" vs "vissvis"
+ # and inconsistent comma placement in IMF data
+ if len(part_norm) >= 15 and len(pn) >= 15:
+ # Check if one contains most of the other
+ shorter = min(part_norm, pn, key=len)
+ longer = max(part_norm, pn, key=len)
+ # If 80%+ of shorter is in longer, consider match
+ if shorter in longer or (
+ len(shorter) > 30
+ and any(
+ shorter[i : i + 30] in longer
+ for i in range(len(shorter) - 30)
+ )
+ ):
+ is_in_ancestor = True
+ break
+ if not is_in_ancestor:
+ new_parts.append(part)
+
+ if new_parts:
+ # For IRFCL path labels, keep all parts
+ if (
+ codelist_id_for_code
+ and "IRFCL" in codelist_id_for_code
+ and len(new_parts) >= 3
+ ):
+ # Keep the full label - synthetic groups will organize it
+ label = ", ".join(new_parts)
+ else:
+ label = (
+ ", ".join(new_parts)
+ if new_parts
+ else child_parts[-1] if child_parts else full_label
+ )
+ else:
+ label = child_parts[-1] if child_parts else full_label
+ elif ", " in full_label:
+ # No ancestors - this is a top-level node
+ # Take just the LAST part as the actual indicator label
+ parts = full_label.split(", ")
+ label = parts[-1] if parts else full_label
+ elif (
+ parent_full_label
+ and ", " in full_label
+ and full_label.startswith(parent_full_label)
+ ):
+ # For other codelists, check if parent's label is a prefix
+ label = full_label.rsplit(", ", 1)[-1]
+ description = (
+ code_descs.get(indicator_code, "") if indicator_code else ""
+ )
+
+ children = code_entry.get("hierarchicalCodes", [])
+ is_group = len(children) > 0
+
+ current_dimension_codes = parent_dimension_codes.copy()
+ if dimension_id and indicator_code:
+ current_dimension_codes[dimension_id] = indicator_code
+
+ # Clean parent_id: if it contains codelist prefix, extract just the code
+ clean_parent_id = parent_id
+ if parent_id and "___" in parent_id:
+ clean_parent_id = parent_id.split("___")[-1]
+
+ # Assign sequential order to ALL nodes (groups and leaf nodes)
+ order_counter[0] += 1
+ current_order = order_counter[0]
+
+ # For BOP dataflows, use depth (actual nesting) instead of IMF's
+ # inconsistent level attribute
+ use_depth_for_level = dataflow_id in ("BOP", "BOP_AGG", "IIP", "IIPCC")
+ node_level = (
+ depth if use_depth_for_level else (int(level) if level else depth)
+ )
+
+ indicator_info = {
+ "id": code_id,
+ "indicator_code": indicator_code,
+ "label": label,
+ "description": description,
+ "order": current_order,
+ "level": node_level,
+ "depth": depth,
+ "parent_id": clean_parent_id,
+ "is_group": is_group,
+ "code_urn": code_urn,
+ "dimension_id": dimension_id,
+ }
+
+ # Only build series_id if indicator_code belongs to a queryable dimension
+ if indicator_code and dimension_id:
+ # Filter to only dimensions that have a known position
+ ordered_dims = sorted(
+ [
+ (dim_id_iter, indicator_dimension_order[dim_id_iter])
+ for dim_id_iter in current_dimension_codes
+ if dim_id_iter in indicator_dimension_order
+ and current_dimension_codes.get(dim_id_iter)
+ ],
+ key=lambda item: item[1],
+ )
+ ordered_codes = [
+ current_dimension_codes[dim_id_iter]
+ for dim_id_iter, _ in ordered_dims
+ ]
+ unordered_dims = [
+ dim_id_iter
+ for dim_id_iter in current_dimension_codes
+ if dim_id_iter not in indicator_dimension_order
+ and current_dimension_codes.get(dim_id_iter)
+ ]
+ ordered_codes.extend(
+ [
+ current_dimension_codes[dim_id_iter]
+ for dim_id_iter in sorted(unordered_dims)
+ ]
+ )
+
+ if ordered_codes:
+ combined_codes = "_".join(ordered_codes)
+ indicator_info["series_id"] = (
+ f"{agency_clean}_{dataflow_id}_{combined_codes}"
+ )
+ else:
+ # Fallback to parent code ordering when dimension mapping fails
+ fallback_codes = parent_codes + [indicator_code]
+ indicator_info["series_id"] = (
+ f"{agency_clean}_{dataflow_id}_{'_'.join(fallback_codes)}"
+ )
+
+ indicators.append(indicator_info)
+
+ if children:
+ child_parent_codes = parent_codes + (
+ [indicator_code] if indicator_code else []
+ )
+ # Accumulate ancestor labels for path-based label extraction
+ child_ancestor_labels = ancestor_labels + [full_label]
+ child_indicators = process_hierarchical_codes(
+ children,
+ parent_id=code_id,
+ depth=depth + 1,
+ parent_codes=child_parent_codes,
+ parent_dimension_codes=current_dimension_codes.copy(),
+ order_counter=order_counter,
+ parent_full_label=full_label,
+ ancestor_labels=child_ancestor_labels,
+ )
+ indicators.extend(child_indicators)
+
+ return indicators
+
+ hierarchical_codes = hierarchy.get("hierarchicalCodes", [])
+
+ # If a top-level code filter is set, only include that specific top-level code
+ if top_level_code_filter:
+ filtered_codes = [
+ c for c in hierarchical_codes if c.get("id") == top_level_code_filter
+ ]
+ if filtered_codes:
+ hierarchical_codes = filtered_codes
+
+ indicators = process_hierarchical_codes(hierarchical_codes)
+
+ # Post-processing: Fix IRFCL hierarchy issues
+ if dataflow_id == "IRFCL":
+ indicators = self._fix_irfcl_hierarchy(indicators)
+
+ # Get table name from selected_table if available (for split tables)
+ table_name = (
+ selected_table.get("name", hierarchy.get("name"))
+ if selected_table
+ else hierarchy.get("name")
+ )
+
+ return {
+ "hierarchy_id": table_id, # Use full table_id including split suffix
+ "hierarchy_name": table_name,
+ "hierarchy_description": hierarchy.get("description", ""),
+ "dataflow_id": dataflow_id,
+ "codelist_id": codelist_id,
+ "agency_id": hierarchy.get("agencyID"),
+ "version": hierarchy.get("version"),
+ "indicators": indicators,
+ "total_indicators": len([i for i in indicators if not i["is_group"]]),
+ "total_groups": len([i for i in indicators if i["is_group"]]),
+ "type": "hierarchy",
+ }
+
+ def _fix_irfcl_hierarchy(self, indicators: list[dict]) -> list[dict]:
+ """
+ Fix IRFCL hierarchy issues where instrument types are incorrectly nested.
+
+ The IMF hierarchy has "futures", "swaps", "options", "other" as children of
+ "forwards", but they should be siblings (different instrument types at the
+ same level). This method re-parents them to be siblings of "forwards".
+ """
+ # Find "forwards" node and get its parent
+ forwards_node = None
+ for ind in indicators:
+ if ind.get("label", "").lower() == "forwards":
+ forwards_node = ind
+ break
+
+ if not forwards_node:
+ return indicators
+
+ forwards_id = forwards_node.get("id")
+ forwards_parent_id = forwards_node.get("parent_id")
+ forwards_depth = forwards_node.get("depth", 0)
+
+ # Re-parent children of "forwards" to be siblings instead
+ instrument_labels = {"futures", "swaps", "options", "other"}
+ for ind in indicators:
+ if ind.get("parent_id") == forwards_id:
+ label_lower = ind.get("label", "").lower()
+ if label_lower in instrument_labels:
+ # Move to same level as forwards
+ ind["parent_id"] = forwards_parent_id
+ ind["depth"] = forwards_depth
+
+ return indicators
+
+ def _create_synthetic_groups_for_shared_prefixes(
+ self, indicators: list[dict]
+ ) -> list[dict]:
+ """
+ Create synthetic group nodes for IRFCL siblings with shared label patterns.
+
+ For IRFCL path-encoded labels like:
+ - "Up to 1 month, Long positions"
+ - "More than 1 and up to 3 months, Long positions"
+
+ This creates a parent group "Long positions" and re-parents the children
+ with simplified labels ("Up to 1 month", etc.).
+
+ Also handles shared prefixes like "Options in foreign currencies...".
+ """
+ # pylint: disable=import-outside-toplevel
+ import re
+ from collections import defaultdict
+
+ # These are specific to IRFCL memo items structure
+ IRFCL_PATH_PATTERNS = [ # pylint: disable=C0103
+ "Options in foreign currencies",
+ "Up to 1 month",
+ "More than 1 and up to",
+ "More than 3 months",
+ "In-the-money",
+ "Long positions",
+ "Short positions",
+ ]
+
+ def is_irfcl_path_label(label: str) -> bool:
+ """Check if label contains IRFCL path patterns."""
+ return any(
+ pattern.lower() in label.lower() for pattern in IRFCL_PATH_PATTERNS
+ )
+
+ # Group indicators by parent_id
+ by_parent: dict[str | None, list[dict]] = defaultdict(list)
+ for ind in indicators:
+ by_parent[ind.get("parent_id")].append(ind)
+
+ # Track new synthetic groups
+ synthetic_groups: list[dict] = []
+
+ # For each parent, check if children share a common prefix OR suffix
+ for parent_id, children in by_parent.items():
+ # Only process if multiple children with comma-separated labels
+ # and they have IRFCL-specific path patterns (time periods, positions, options)
+ path_children = [
+ c
+ for c in children
+ if ", " in c.get("label", "")
+ and c.get("id")
+ and is_irfcl_path_label(c.get("label", ""))
+ ]
+ if len(path_children) < 2:
+ continue
+
+ # Find common prefix among path_children labels
+ labels = [c["label"] for c in path_children]
+ split_labels = [lbl.split(", ") for lbl in labels]
+ if not all(split_labels):
+ continue
+
+ # Find how many leading parts are shared (prefix)
+ min_parts = min(len(parts) for parts in split_labels)
+ shared_prefix_count = 0
+ for i in range(min_parts - 1):
+ first_part = split_labels[0][i]
+ if all(parts[i] == first_part for parts in split_labels):
+ shared_prefix_count += 1
+ else:
+ break
+
+ # Find how many trailing parts are shared (suffix)
+ shared_suffix_count = 0
+ for i in range(1, min_parts):
+ last_part = split_labels[0][-i]
+ if all(parts[-i] == last_part for parts in split_labels):
+ shared_suffix_count += 1
+ else:
+ break
+
+ # Use whichever is longer (prefix or suffix), prefer suffix for IRFCL
+ if shared_suffix_count > 0 and shared_suffix_count >= shared_prefix_count:
+ suffix_parts = split_labels[0][-shared_suffix_count:]
+ # Reverse so outermost (last part) is created first as parent
+ suffix_parts_reversed = list(reversed(suffix_parts))
+ first_child_order = min(c.get("order", 0) for c in path_children)
+ first_child_depth = path_children[0].get("depth", 0)
+ # Create nested synthetic groups
+ current_parent_id = parent_id
+ current_depth = first_child_depth
+ innermost_synthetic_id = None
+
+ for i, suffix_part in enumerate(suffix_parts_reversed):
+ synthetic_id = f"_SYNTH_{current_parent_id}_{re.sub(r'[^a-zA-Z0-9]', '_', suffix_part[:30])}_{i}"
+ synthetic_group = {
+ "id": synthetic_id,
+ "indicator_code": None,
+ "label": suffix_part,
+ "description": "",
+ "order": first_child_order - 0.5 + (i * 0.01),
+ "level": current_depth,
+ "depth": current_depth,
+ "parent_id": current_parent_id,
+ "is_group": True,
+ "code_urn": None,
+ "dimension_id": None,
+ "series_id": None,
+ }
+ synthetic_groups.append(synthetic_group)
+ # Next group will be child of this one
+ current_parent_id = synthetic_id
+ current_depth += 1
+ innermost_synthetic_id = synthetic_id
+
+ # re-parent to innermost synthetic group and remove suffix
+ for child in path_children:
+ child_parts = child["label"].split(", ")
+ remaining_parts = child_parts[:-shared_suffix_count]
+ if remaining_parts:
+ child["label"] = ", ".join(remaining_parts)
+ child["parent_id"] = innermost_synthetic_id
+ child["depth"] = current_depth
+ child["depth"] = first_child_depth + 1
+
+ elif shared_prefix_count > 0:
+ # Create group from shared prefix
+ shared_prefix = ", ".join(split_labels[0][:shared_prefix_count])
+ synthetic_id = f"_SYNTH_{parent_id}_{re.sub(r'[^a-zA-Z0-9]', '_', shared_prefix[:30])}"
+ first_child_order = min(c.get("order", 0) for c in path_children)
+ first_child_depth = path_children[0].get("depth", 0)
+ synthetic_group = {
+ "id": synthetic_id,
+ "indicator_code": None,
+ "label": shared_prefix,
+ "description": "",
+ "order": first_child_order - 0.5,
+ "level": first_child_depth,
+ "depth": first_child_depth,
+ "parent_id": parent_id,
+ "is_group": True,
+ "code_urn": None,
+ "dimension_id": None,
+ "series_id": None,
+ }
+ synthetic_groups.append(synthetic_group)
+
+ # re-parent and remove prefix
+ for child in path_children:
+ child_parts = child["label"].split(", ")
+ remaining_parts = child_parts[shared_prefix_count:]
+ if remaining_parts:
+ child["label"] = ", ".join(remaining_parts)
+ child["parent_id"] = synthetic_id
+ child["depth"] = first_child_depth + 1
+
+ # Merge synthetic groups into indicators list
+ if synthetic_groups:
+ all_indicators = indicators + synthetic_groups
+ all_indicators.sort(key=lambda x: x.get("order", 0))
+ for i, ind in enumerate(all_indicators):
+ ind["order"] = i + 1
+ # After creating one level of groups, the children may still
+ # have shared prefixes/suffixes that need another level of grouping
+ return self._create_synthetic_groups_for_shared_prefixes(all_indicators)
+
+ return indicators
+
+ def list_all_dataflow_tables(self) -> dict[str, list[dict]]:
+ """
+ Get a mapping of all dataflows to their available presentation tables.
+
+ Returns a curated list of validated dataflow/table combinations that
+ are known to work correctly with the IMF API.
+
+ Returns
+ -------
+ dict[str, list[dict]]
+ Mapping of dataflow IDs to their available hierarchies.
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.constants import (
+ PRESENTATION_TABLES,
+ )
+
+ result: dict[str, list[dict]] = {}
+
+ for friendly_name, table_spec in PRESENTATION_TABLES.items():
+ # Parse the table spec: "DATAFLOW_ID::HIERARCHY_ID" or "DATAFLOW_ID::HIERARCHY_ID:SPLIT_CODE"
+ parts = table_spec.split("::")
+ if len(parts) != 2:
+ continue
+
+ dataflow_id = parts[0]
+ table_id = parts[1]
+
+ if dataflow_id not in self.dataflows:
+ continue
+
+ try:
+ # Get all hierarchies for this dataflow
+ all_hierarchies = self.get_dataflow_hierarchies(dataflow_id)
+ if not all_hierarchies:
+ continue
+
+ # Find the matching hierarchy
+ matching_hierarchy = None
+ for h in all_hierarchies:
+ if h.get("id") == table_id:
+ matching_hierarchy = h
+ break
+
+ if matching_hierarchy:
+ # Add friendly_name to the hierarchy info
+ hierarchy_with_name = matching_hierarchy.copy()
+ hierarchy_with_name["friendly_name"] = friendly_name
+ hierarchy_with_name["dataflow_id"] = dataflow_id
+
+ if dataflow_id not in result:
+ result[dataflow_id] = []
+ result[dataflow_id].append(hierarchy_with_name)
+ except Exception: # noqa: S110 # pylint: disable=broad-exception-caught
+ pass
+
+ return result
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/port_watch_helpers.py b/openbb_platform/providers/imf/openbb_imf/utils/port_watch_helpers.py
index fbfc03b6162..69050a9c0cd 100644
--- a/openbb_platform/providers/imf/openbb_imf/utils/port_watch_helpers.py
+++ b/openbb_platform/providers/imf/openbb_imf/utils/port_watch_helpers.py
@@ -1,13 +1,42 @@
"""IMF Port Watch helpers."""
+# pylint: disable=R0914
+
+from typing import Any
+
from async_lru import alru_cache
from openbb_imf.utils.constants import (
CHOKEPOINTS_BASE_URL,
DAILY_TRADE_BASE_URL,
- PORT_COUNTRIES,
)
+def list_countries() -> list[dict[str, str]]:
+ """List available countries for IMF Port Watch.
+
+ Returns
+ -------
+ list of dict
+ A list of dictionaries with 'label' and 'value' for each country.
+ """
+ choices: list = []
+ ports = get_ports()
+ seen: set = set()
+
+ for port in ports:
+ if port["ISO3"] in seen:
+ continue
+
+ seen.add(port["ISO3"])
+ choices.append(
+ {
+ "label": port["countrynoaccents"],
+ "value": port["ISO3"],
+ }
+ )
+ return choices
+
+
def map_port_country_code(country_code: str) -> str:
"""Map the 3-letter country code to the full country name.
@@ -21,11 +50,13 @@ def map_port_country_code(country_code: str) -> str:
str
The full country name, without accents, corresponding to the provided country code.
"""
- COUNTRY_CODE_TO_PORT = {v: k for k, v in PORT_COUNTRIES.items()}
- if country_code not in COUNTRY_CODE_TO_PORT:
+ cc = country_code.upper()
+ countries = list_countries()
+ code_to_country = {country["value"]: country["label"] for country in countries}
+ if cc not in code_to_country:
raise ValueError("Country code is not supported by IMF Port Watch.")
- return COUNTRY_CODE_TO_PORT.get(country_code.upper(), country_code.upper())
+ return code_to_country.get(cc, cc)
def get_port_ids_by_country(country_code: str) -> str:
@@ -41,28 +72,13 @@ def get_port_ids_by_country(country_code: str) -> str:
str
A list of port IDs as a comma-separated string.
"""
- # pylint: disable=import-outside-toplevel
- import importlib.resources
- import json
+ ports = get_ports()
+ ports_ids: list = []
+ for port in ports:
+ if port["ISO3"] == country_code.upper():
+ ports_ids.append(port["portid"])
- json_path = (
- importlib.resources.files("openbb_imf") / "assets" / "imf_ports_by_country.json"
- )
-
- if not json_path.exists(): # type: ignore
- raise FileNotFoundError(f"Port IDs JSON file not found at {json_path}")
-
- port_ids_by_country: dict = {}
-
- with open(str(json_path), encoding="utf-8") as file:
- port_ids_by_country = json.load(file)
-
- if country_code.upper() not in port_ids_by_country:
- raise ValueError(
- f"Country code '{country_code}' is not supported by IMF Port Watch."
- )
-
- return port_ids_by_country.get(country_code.upper(), "")
+ return ",".join(ports_ids)
def get_port_id_choices() -> list:
@@ -73,31 +89,16 @@ def get_port_id_choices() -> list:
list
A list of dictionaries, with labels and values for each port ID.
"""
- # pylint: disable=import-outside-toplevel
- import json # noqa
- import importlib.resources
-
- json_path = (
- importlib.resources.files("openbb_imf") / "assets" / "imf_portid_map.json"
- )
-
- if not json_path.exists(): # type: ignore
- raise FileNotFoundError(f"imf_portid_map.json not found at: {json_path}")
-
choices: list = []
- portids: dict = {}
+ ports = get_ports()
- with open(str(json_path), encoding="utf-8") as file:
- portids = json.load(file)
-
- for portid, portname in portids.items():
+ for port in ports:
choices.append(
{
- "label": portname,
- "value": portid,
+ "label": port["portname"],
+ "value": port["portid"],
}
)
-
return choices
@@ -387,3 +388,65 @@ async def get_daily_port_activity_data(
)
return final_output
+
+
+@alru_cache(maxsize=1)
+async def list_ports() -> list[dict[str, Any]]:
+ """List all available ports from the IMF Port Watch dataset.
+
+ Returns
+ -------
+ list[dict]
+ A list of dictionaries, each representing a port with its details.
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_core.app.model.abstract.error import OpenBBError
+ from openbb_core.provider.utils.helpers import get_async_requests_session
+
+ url = (
+ "https://services9.arcgis.com/weJ1QsnbMYJlCHdG/arcgis/rest/services/PortWatch_ports_database/"
+ + "FeatureServer/0/query?where=1%3D1&outFields=countrynoaccents,portid,lon,lat,portname,ISO3,continent,fullname"
+ + "+&returnGeometry=false&orderByFields=vessel_count_total%20DESC&outSR=&f=json"
+ )
+ ports: list[dict] = []
+
+ try:
+ async with await get_async_requests_session() as session, await session.get(
+ url
+ ) as response:
+ if response.status != 200:
+ raise OpenBBError(
+ f"Failed to fetch ports data: {response.status} - {response.reason}"
+ )
+ data = await response.json()
+
+ for feature in data.get("features", []):
+ ports.append(feature.get("attributes", {}))
+
+ return ports
+
+ except Exception as e:
+ raise OpenBBError(f"Error fetching ports data: {e} -> {e.args}") from e
+
+
+def get_ports() -> list[dict[str, Any]]:
+ """Get the list of all ports synchronously."""
+ # pylint: disable=import-outside-toplevel
+ import asyncio
+
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ loop = None
+
+ if loop is not None:
+ # Already in an async context
+ import concurrent.futures
+
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(asyncio.run, list_ports())
+ ports = future.result()
+ else:
+ ports = asyncio.run(list_ports())
+
+ return ports
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/progressive_helper.py b/openbb_platform/providers/imf/openbb_imf/utils/progressive_helper.py
new file mode 100644
index 00000000000..7f98e5e7d82
--- /dev/null
+++ b/openbb_platform/providers/imf/openbb_imf/utils/progressive_helper.py
@@ -0,0 +1,242 @@
+"""IMF Progressive Query Helper."""
+
+# pylint: disable=W0212
+
+from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+
+class ImfParamsBuilder:
+ """A helper class to build IMF queries progressively by making sequential dimension selections,
+ for each dimension of a dataflow, filtering the available options at each step based on previous selections.
+ """
+
+ def __init__(self, dataflow_id: str):
+ """Initialize the ImfParamsBuilder object.
+
+ Parameters
+ ----------
+ dataflow_id : str
+ The ID of the dataflow to build a query for.
+ """
+ self._builder = ImfQueryBuilder()
+ if dataflow_id not in self._builder.metadata.dataflows:
+ raise KeyError(
+ f"Dataflow '{dataflow_id}' not found."
+ f" Available dataflows: {list(self._builder.metadata.dataflows.keys())}"
+ )
+
+ self.dataflow_id = dataflow_id
+ self.dsd = self._get_dsd()
+ self._dimensions = self._get_dimensions_in_order()
+ self.current_dimension = self._dimensions[0] if self._dimensions else None
+ self._selections: dict = {dim: None for dim in self._dimensions}
+ self._last_constraints_response: dict = {}
+
+ def _get_dsd(self):
+ """Get the Data Structure Definition (DSD) for the current dataflow."""
+ df_obj = self._builder.metadata.dataflows[self.dataflow_id]
+ dsd_id = df_obj.get("structureRef", {}).get("id")
+ return self._builder.metadata.datastructures.get(dsd_id, {})
+
+ def _get_dimensions_in_order(self) -> list[str]:
+ """Get the list of dimension IDs in their specified order."""
+ dimensions_metadata = self.dsd.get("dimensions", [])
+
+ # Sort by position if available, otherwise keep original order
+ if dimensions_metadata and all(
+ d.get("position") is not None for d in dimensions_metadata
+ ):
+ dimensions_metadata = sorted(
+ dimensions_metadata, key=lambda x: int(x.get("position")) # type: ignore
+ )
+
+ return [
+ d["id"]
+ for d in dimensions_metadata
+ if d.get("id") and d.get("id") != "TIME_PERIOD"
+ ]
+
+ def get_next_dimension_to_select(self) -> str | None:
+ """
+ Get the ID of the next dimension that needs a selection.
+
+ Returns
+ -------
+ str or None
+ The ID of the next dimension, or None if all dimensions have been selected.
+ """
+ for dim in self._dimensions:
+ if self._selections[dim] is None:
+ return dim
+ return None
+
+ def get_options_for_dimension(
+ self, dimension_id: str | None = None
+ ) -> list[dict[str, str]]:
+ """Get the available options for a given dimension, based on the current selections.
+
+ Parameters
+ ----------
+ dimension_id : str
+ The ID of the dimension to get options for.
+
+ Returns
+ -------
+ list[dict]
+ A list of available options, where each option is a dictionary with
+ 'label' and 'value' keys.
+ """
+ dimension_id = dimension_id or self.get_next_dimension_to_select()
+ if not dimension_id:
+ return []
+ if dimension_id not in self._dimensions:
+ raise ValueError(
+ f"Dimension '{dimension_id}' not found for dataflow '{self.dataflow_id}'."
+ )
+
+ key_parts: list = []
+ for dim in self._dimensions:
+ if self._selections[dim] is not None:
+ key_parts.append(self._selections[dim])
+ else:
+ # Use wildcard '*' for unselected dimensions instead of empty string
+ # Empty string creates malformed URLs like '../'
+ key_parts.append("*")
+ key = ".".join(key_parts)
+
+ constraints = self._builder.metadata.get_available_constraints(
+ dataflow_id=self.dataflow_id,
+ key=key,
+ component_id=dimension_id,
+ )
+ # Store the last constraints response for time period validation
+ self._last_constraints_response = constraints
+
+ options: list[dict[str, str]] = []
+ key_values = constraints.get("key_values", [])
+ for kv in key_values:
+ if kv.get("id") == dimension_id:
+ codelist_map = self._get_codelist_for_dim(dimension_id)
+ for value_id in kv.get("values", []):
+ options.append(
+ {
+ "label": codelist_map.get(value_id, value_id),
+ "value": value_id,
+ }
+ )
+ return options
+
+ def _get_codelist_for_dim(self, dimension_id: str) -> dict:
+ """Get the codelist map for a given dimension."""
+ df_obj = self._builder.metadata.dataflows[self.dataflow_id]
+ agency_id = df_obj.get("agencyID")
+ if not agency_id:
+ return {}
+
+ dimensions_metadata = {
+ d["id"]: d for d in self.dsd.get("dimensions", []) if d.get("id")
+ }
+ dim_meta = dimensions_metadata.get(dimension_id)
+ if not dim_meta:
+ return {}
+
+ dsd_id = self.dsd.get("id")
+ codelist_id = self._builder.metadata._resolve_codelist_id(
+ self.dataflow_id, dsd_id, dimension_id, dim_meta
+ )
+
+ if codelist_id:
+ return self._builder.metadata._get_codelist_map(
+ codelist_id, agency_id, self.dataflow_id
+ )
+ return {}
+
+ def set_dimension(self, dimension: tuple[str, str]) -> dict:
+ """Set a value for a dimension and clear downstream selections.
+
+ Parameters
+ ----------
+ dimension : tuple
+ A tuple of (dimension_id, value) to set.
+
+ Returns
+ -------
+ dict
+ The updated selections after setting the dimension.
+ """
+ if dimension[0] not in self._dimensions:
+ raise KeyError(
+ f"Dimension '{dimension[0]}' not valid for this dataflow."
+ f" Valid dimensions: {list(self._selections.keys())}"
+ )
+ self._selections[dimension[0]] = dimension[1]
+ # When a selection is made, we clear selections for downstream dimensions
+ # as they might now be invalid.
+ found_dim = False
+ for dim in self._dimensions:
+ if found_dim:
+ self._selections[dim] = None
+ if dim == dimension[0]:
+ found_dim = True
+
+ self.current_dimension = self.get_next_dimension_to_select()
+
+ return self._selections.copy()
+
+ def get_dimensions(self) -> dict[str, str | None]:
+ """Get the current selections for the dimension_id.
+
+ Returns
+ -------
+ dict
+ A dictionary of the current dimension selections.
+ """
+ return self._selections.copy()
+
+ def build_url(
+ self, start_date: str | None = None, end_date: str | None = None
+ ) -> str:
+ """
+ Build the final API URL based on the current selections.
+
+ Parameters
+ ----------
+ start_date : str, optional
+ The start date for the data query.
+ end_date : str, optional
+ The end date for the data query.
+
+ Returns
+ -------
+ str
+ The constructed IMF API URL.
+ """
+ return self._builder.build_url(
+ dataflow=self.dataflow_id,
+ start_date=start_date,
+ end_date=end_date,
+ **self._selections,
+ )
+
+ def fetch(self, start_date: str | None = None, end_date: str | None = None) -> dict:
+ """
+ Build the URL and fetch the data based on the current selections.
+
+ Parameters
+ ----------
+ start_date : str, optional
+ The start date for the data query.
+ end_date : str, optional
+ The end date for the data query.
+
+ Returns
+ -------
+ dict
+ The fetched data and metadata.
+ """
+ return self._builder.fetch_data(
+ dataflow=self.dataflow_id,
+ start_date=start_date,
+ end_date=end_date,
+ **self._selections,
+ )
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/query_builder.py b/openbb_platform/providers/imf/openbb_imf/utils/query_builder.py
new file mode 100644
index 00000000000..fad18bd329d
--- /dev/null
+++ b/openbb_platform/providers/imf/openbb_imf/utils/query_builder.py
@@ -0,0 +1,1454 @@
+"""IMF Query Builder utilities."""
+
+# pylint: disable=C0302,R0911,R0912,R0913,R0914,R0915,R0917,R1702,W0212
+# flake8: noqa: PLR0911,PLR0912,PLR0913,PLR0917
+
+import warnings
+
+from openbb_imf.utils.metadata import ImfMetadata
+
+
+class ImfQueryBuilder:
+ """IMF Query Builder for constructing and executing SDMX REST queries."""
+
+ def __init__(self):
+ """Initialize the query builder with metadata singleton."""
+ self.metadata = ImfMetadata()
+
+ def build_url(
+ self,
+ dataflow: str,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ limit: int | None = None,
+ **kwargs,
+ ) -> str:
+ """Build the IMF SDMX REST API URL for data retrieval."""
+ if dataflow not in self.metadata.dataflows:
+ raise ValueError(f"Dataflow '{dataflow}' not found.")
+
+ df = self.metadata.dataflows[dataflow]
+ agency_id = df.get("agencyID")
+ dsd_id = df.get("structureRef", {}).get("id")
+
+ if not dsd_id or dsd_id not in self.metadata.datastructures:
+ raise ValueError(f"Data structure not found for dataflow '{dataflow}'.")
+
+ dsd = self.metadata.datastructures[dsd_id]
+ all_dimensions = dsd.get("dimensions", [])
+ dimension_ids = {d["id"] for d in all_dimensions if d.get("id")}
+ # Create a map for case-insensitive matching of dimension IDs
+ dimension_id_map = {d_id.lower(): d_id for d_id in dimension_ids}
+
+ final_kwargs: dict = {}
+
+ for key, value in kwargs.items():
+ # Try to match the key (case-insensitive) to a known dimension ID
+ matched_dim_id = dimension_id_map.get(key.lower())
+ if matched_dim_id:
+ final_kwargs[matched_dim_id] = value
+ else:
+ # If not a dimension, keep the original key
+ final_kwargs[key] = value
+
+ dimensions = sorted(
+ [
+ d
+ for d in all_dimensions
+ if d.get("id") is not None and d.get("position") is not None
+ ],
+ key=lambda x: int(x.get("position")),
+ )
+ key_parts: list = []
+ # Use a set to keep track of dimensions that have been added to the key_parts
+ # to avoid adding them again to query_params
+ dimensions_in_key: set = set()
+
+ for dim in dimensions:
+ dim_id = dim.get("id")
+ param_value = final_kwargs.get(dim_id)
+
+ # Handle wildcards and empty values
+ if (
+ param_value is None
+ or param_value in ["", "*"]
+ or len(str(param_value)) > 1500
+ ):
+ key_parts.append("*")
+ elif isinstance(param_value, list):
+ key_parts.append("+".join(param_value))
+ else:
+ key_parts.append(str(param_value))
+ dimensions_in_key.add(dim_id)
+
+ key = ".".join(key_parts)
+
+ if not agency_id:
+ raise ValueError(f"Agency ID not found for dataflow '{dataflow}'.")
+
+ url = (
+ f"https://api.imf.org/external/sdmx/3.0/data/dataflow/"
+ f"{agency_id}/{dataflow}/+/{key}"
+ )
+ # Only include parameters in query_params that are not dimensions
+ query_params = {
+ k: v for k, v in final_kwargs.items() if k not in dimensions_in_key
+ }
+ # Format dates for TIME_PERIOD filter
+ frequency = (final_kwargs.get("FREQUENCY") or "").upper()
+
+ def format_date(
+ date_str: str, frequency: str, is_end_date: bool = False
+ ) -> str:
+ """Format date string based on frequency to match IMF TIME_PERIOD format."""
+ if not date_str:
+ return date_str
+
+ # Parse the date - could be YYYY, YYYY-MM, or YYYY-MM-DD
+ parts = date_str.split("-")
+ year = int(parts[0])
+ month = int(parts[1]) if len(parts) >= 2 else 1
+
+ if frequency == "A" or len(parts) == 1:
+ # Annual frequency or year-only input
+ if is_end_date:
+ # For end date, use first day of next year
+ return f"{year + 1}-01-01"
+
+ return f"{year}-01-01"
+
+ if is_end_date:
+ # For end date, use first day of next month
+ month += 1
+ if month > 12:
+ month = 1
+ year += 1
+
+ return f"{year}-{month:02d}-01"
+
+ return f"{year}-{month:02d}-01"
+
+ c_params = []
+
+ if start_date:
+ formatted_start = format_date(start_date, frequency)
+ c_params.append(f"ge:{formatted_start}")
+ if end_date:
+ formatted_end = format_date(end_date, frequency, is_end_date=True)
+ c_params.append(f"le:{formatted_end}")
+ if c_params:
+ query_params["c[TIME_PERIOD]"] = "+".join(c_params)
+
+ query_params = {k: v for k, v in query_params.items() if v is not None}
+
+ if query_params:
+ url += "?" + "&".join(f"{k}={v}" for k, v in query_params.items())
+
+ url += (
+ f"{'&' if '?' in url and not url.endswith('&') else '?'}"
+ + "dimensionAtObservation=TIME_PERIOD&detail=full&includeHistory=false"
+ )
+
+ if limit is not None and limit > 0:
+ url += f"&lastNObservations={limit}"
+
+ return url
+
+ def validate_dimension_constraints(self, dataflow: str, **kwargs) -> None:
+ """
+ Validate that the provided dimension parameter combinations are valid according
+ to IMF API constraints. Uses progressive constraint checking to ensure the
+ parameters are actually available for the dataflow.
+
+ Parameters
+ ----------
+ dataflow : str
+ The dataflow ID
+ **kwargs
+ Dimension parameters to validate
+
+ Raises
+ ------
+ ValueError
+ If the parameter combination is invalid according to API constraints
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_core.app.model.abstract.warning import OpenBBWarning
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ try:
+ builder = ImfParamsBuilder(dataflow)
+ dimensions_in_order = builder._get_dimensions_in_order()
+
+ # Build up selections progressively and validate each step
+ for dim_id in dimensions_in_order:
+ if dim_id in kwargs:
+ user_value = kwargs[dim_id]
+
+ # Normalize to list for checking
+ # Handle comma-separated or plus-separated strings
+ if isinstance(user_value, str):
+ if "," in user_value:
+ user_values = [v.strip() for v in user_value.split(",")]
+ elif "+" in user_value:
+ user_values = [v.strip() for v in user_value.split("+")]
+ else:
+ user_values = [user_value]
+ elif isinstance(user_value, list):
+ user_values = user_value
+ else:
+ user_values = [user_value] if user_value else []
+
+ # Filter out empty strings
+ user_values = [v for v in user_values if v]
+
+ if not user_values:
+ continue
+
+ # Skip wildcards - they're always valid
+ if user_values == ["*"] or len("+".join(user_values)) > 2000:
+ builder.set_dimension((dim_id, "*"))
+ continue
+
+ # Get available options for this dimension given prior selections
+ available_options = builder.get_options_for_dimension(dim_id)
+ available_values = {opt["value"] for opt in available_options}
+
+ # Check if user's values are valid
+ invalid_values = []
+ for val in user_values:
+ if val != "*" and val not in available_values:
+ invalid_values.append(val)
+
+ if invalid_values:
+ # Build helpful error message
+ prior_selections = {
+ d: kwargs.get(d)
+ for d in dimensions_in_order
+ if d in kwargs
+ and dimensions_in_order.index(d)
+ < dimensions_in_order.index(dim_id)
+ }
+
+ # Show all available values without truncation
+ all_values = sorted(available_values)
+ error_msg = (
+ f"Invalid value(s) for dimension '{dim_id}': {invalid_values}. "
+ f"Given prior selections {prior_selections}, "
+ f"available values are: {all_values}"
+ )
+ raise ValueError(error_msg)
+
+ # Set the valid value to progress the builder
+ builder.set_dimension((dim_id, user_values[0]))
+
+ # Check time period constraints from the last dimension validation
+ # The _last_constraints_response already contains contentConstraints with TIME_PERIOD info
+ start_date = kwargs.get("start_date")
+ end_date = kwargs.get("end_date")
+
+ if start_date or end_date:
+ constraints = builder._last_constraints_response
+ if constraints:
+ full_response = constraints.get("full_response", {})
+ data = full_response.get("data", {})
+
+ # Time period annotations can be in contentConstraints or dataConstraints
+ # Check both places
+ time_start = None
+ time_end = None
+
+ # Try contentConstraints first (primary location)
+ content_constraints = data.get("contentConstraints", [])
+ for constraint in content_constraints:
+ for annotation in constraint.get("annotations", []):
+ ann_id = annotation.get("id", "")
+ ann_title = annotation.get("title", "")
+ if ann_id == "time_period_start":
+ time_start = ann_title
+ elif ann_id == "time_period_end":
+ time_end = ann_title
+
+ # Fall back to dataConstraints if not found
+ if not (time_start and time_end):
+ data_constraints = data.get("dataConstraints", [])
+ for constraint in data_constraints:
+ for annotation in constraint.get("annotations", []):
+ ann_id = annotation.get("id", "")
+ ann_title = annotation.get("title", "")
+ if ann_id == "time_period_start":
+ time_start = ann_title
+ elif ann_id == "time_period_end":
+ time_end = ann_title
+
+ if time_start and time_end:
+ # Use >= because time_end represents the END of the last period
+ # e.g., time_end=2025-01-01 means data up to end of 2024
+ # So start_date=2025-01-01 would be requesting data AFTER the available range
+ if start_date and start_date >= time_end:
+ raise ValueError(
+ f"Requested start_date '{start_date}' is after the latest available data '{time_end}'. "
+ f"Available date range: {time_start} to {time_end}"
+ )
+ if end_date and end_date <= time_start:
+ raise ValueError(
+ f"Requested end_date '{end_date}' is before the earliest available data '{time_start}'. "
+ f"Available date range: {time_start} to {time_end}"
+ )
+
+ except KeyError as e:
+ # Dataflow not found or other metadata issue - let it pass through
+ warnings.warn(
+ f"Could not validate constraints for dataflow '{dataflow}': {e}",
+ OpenBBWarning,
+ )
+
+ def fetch_data(
+ self,
+ dataflow: str,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ limit: int | None = None,
+ _skip_validation: bool = False,
+ **kwargs,
+ ) -> dict:
+ """Fetch data from the IMF API for a given dataflow and parameters.
+
+ Uses XML format for data retrieval as the JSON format has data truncation issues.
+
+ Parameters
+ ----------
+ dataflow : str
+ The dataflow ID
+ start_date : str | None
+ Start date for the query
+ end_date : str | None
+ End date for the query
+ _skip_validation : bool
+ If True, skip constraint validation (use when caller already validated)
+ **kwargs
+ Dimension parameters
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_core.app.model.abstract.error import OpenBBError
+ from openbb_core.provider.utils.errors import EmptyDataError
+ from openbb_core.provider.utils.helpers import make_request
+ from openbb_imf.utils.helpers import parse_time_period
+ from openbb_imf.utils.table_presentation import (
+ extract_unit_from_label,
+ parse_unit_and_scale,
+ )
+ from pandas import DataFrame, to_numeric
+ from requests.exceptions import RequestException
+
+ # Validate dimension constraints before making the API call
+ if not _skip_validation:
+ self.validate_dimension_constraints(
+ dataflow, start_date=start_date, end_date=end_date, **kwargs
+ )
+
+ url = self.build_url(dataflow, start_date, end_date, limit=limit, **kwargs)
+ headers = {
+ "Accept": "application/xml",
+ "Cache-Control": "no-cache",
+ "User-Agent": "Open Data Platform - IMF Data Fetcher",
+ }
+ response = None
+
+ try:
+ response = make_request(url, headers=headers)
+ response.raise_for_status()
+ xml_content = response.text
+ except RequestException as e:
+ res_content = response.text if response else ""
+ raise OpenBBError(
+ f"An error occurred during the HTTP request: {url} -> {e} -> {res_content}"
+ ) from e
+
+ # Parse XML
+ try:
+ import defusedxml.ElementTree as DefusedET
+
+ root = DefusedET.fromstring(xml_content)
+ except Exception as e: # pylint: disable=broad-except
+ raise OpenBBError(f"Failed to parse XML response: {url} -> {e}") from e
+
+ # Define namespaces used in IMF SDMX responses
+ namespaces = {
+ "message": "http://www.sdmx.org/resources/sdmxml/schemas/v3_0/message",
+ "ss": "http://www.sdmx.org/resources/sdmxml/schemas/v3_0/data/structurespecific",
+ "common": "http://www.sdmx.org/resources/sdmxml/schemas/v3_0/common",
+ }
+
+ # Find all Series elements
+ dataset = root.find(".//message:DataSet", namespaces)
+ if dataset is None:
+ # Try without namespace prefix
+ dataset = root.find(".//DataSet")
+ if dataset is None:
+ # Try with ss namespace
+ dataset = root.find(".//ss:DataSet", namespaces)
+ if dataset is None:
+ raise OpenBBError(
+ EmptyDataError(f"No data found in the response. URL: {url}")
+ )
+
+ # Parse Group elements to extract group-level attributes (UNIT, ACCOUNTING_ENTRY, etc.)
+ # Group structure:
+ # USD
+ # NETLA
+ #
+ group_attributes: dict[str, dict[str, str]] = {}
+
+ # Find all Group elements - they can have namespace prefix
+ for group in dataset.findall("Group") + dataset.findall("ss:Group", namespaces):
+ # The group key is typically the INDICATOR code or similar dimension
+ group_key = None
+ for attr_name, attr_value in group.attrib.items():
+ # Skip namespace type attributes like ns1:type
+ if "type" in attr_name.lower() and "group" in attr_value.lower():
+ continue
+ # The first non-type attribute is the key (e.g., INDICATOR)
+ group_key = attr_value
+ break
+
+ if not group_key:
+ continue
+
+ # Extract Comp elements containing group-level attribute values
+ group_attrs: dict[str, str] = {}
+ for comp in group.findall("Comp") + group.findall("ss:Comp", namespaces):
+ comp_id = comp.attrib.get("id")
+ if comp_id:
+ # Value is in a child element
+ value_elem = comp.find("Value") or comp.find("ss:Value", namespaces)
+ if value_elem is not None and value_elem.text:
+ group_attrs[comp_id] = value_elem.text
+
+ if group_attrs:
+ group_attributes[group_key] = group_attrs
+
+ # Get dataflow metadata
+ dataflow_obj = self.metadata.dataflows.get(dataflow, {})
+ # Build translation maps for dimension values
+ translation_maps = self._get_cached_translations(dataflow)
+ # Build dimension order mapping for proper series_id construction
+ structure_ref = dataflow_obj.get("structureRef", {})
+ dsd_id = structure_ref.get("id")
+ indicator_dimension_order: dict[str, int] = {}
+ indicator_id_candidates = [
+ "INDICATOR",
+ "PRODUCTION_INDEX",
+ "COICOP_1999",
+ "INDEX_TYPE",
+ "ACTIVITY",
+ "PRODUCT",
+ "SERIES",
+ "ITEM",
+ "BOP_ACCOUNTING_ENTRY",
+ "ACCOUNTING_ENTRY",
+ ]
+
+ if dsd_id and dsd_id in self.metadata.datastructures:
+ dsd = self.metadata.datastructures[dsd_id]
+ dimensions = dsd.get("dimensions", [])
+
+ for idx, dim in enumerate(dimensions):
+ dim_id = dim.get("id", "")
+
+ if not dim_id:
+ continue
+
+ if dim_id in indicator_id_candidates or any(
+ keyword in dim_id
+ for keyword in ["INDICATOR", "ACCOUNTING_ENTRY", "ENTRY"]
+ ):
+ indicator_dimension_order[dim_id] = idx
+
+ # Process all Series elements
+ all_data_rows: list = []
+ all_unique_indicators: set = set()
+ all_series_derivation_types: dict = {}
+
+ # Build dimension order map for consistent title and series_id ordering
+ dim_order_map: dict[str, int] = {}
+ # Build attribute codelist map for proper code translation
+ attr_codelist_map: dict[str, dict] = {}
+ if dsd_id and dsd_id in self.metadata.datastructures:
+ dsd = self.metadata.datastructures[dsd_id]
+ for idx, dim in enumerate(dsd.get("dimensions", [])):
+ dim_order_map[dim.get("id", "")] = idx
+ # Resolve codelists for attributes (UNIT, SCALE, etc.)
+ for attr in dsd.get("attributes", []):
+ attr_id = attr.get("id")
+ if attr_id:
+ codelist_id = self.metadata._resolve_codelist_id(
+ dataflow, dsd_id, attr_id, attr
+ )
+ if codelist_id and codelist_id in self.metadata._codelist_cache:
+ attr_codelist_map[attr_id] = self.metadata._codelist_cache[
+ codelist_id
+ ]
+
+ # Find Series elements with multiple namespace approaches
+ series_elements = dataset.findall("Series") + dataset.findall(
+ "ss:Series", namespaces
+ )
+
+ # Remove duplicates while preserving order
+ seen_series = set()
+ unique_series = []
+ for s in series_elements:
+ s_id = id(s)
+ if s_id not in seen_series:
+ seen_series.add(s_id)
+ unique_series.append(s)
+ series_elements = unique_series
+
+ for series in series_elements:
+ # Extract series attributes (dimensions)
+ series_meta: dict = {}
+ indicator_code = None
+ indicator_codes_list: list = []
+ all_dimension_codes: list = [] # Track ALL dimension codes for series_id
+ # Collect ALL dimension labels for building a complete title
+ # Format: (position, dimension_id, display_value)
+ title_parts: list[tuple[int, str, str]] = []
+ # Dimensions to EXCLUDE from title (they have their own columns or are metadata)
+ # Note: COUNTERPART_COUNTRY is NOT excluded - it's meaningful for DIP, BOP, etc.
+ title_exclude_dims = {
+ "COUNTRY",
+ "REF_AREA",
+ "TIME_PERIOD",
+ "SCALE",
+ "UNIT",
+ "FREQ",
+ "FREQUENCY",
+ "OBS_VALUE",
+ "OBS_STATUS",
+ }
+
+ for attr_name, attr_value in series.attrib.items():
+ # Track ALL dimension codes for complete series_id
+ all_dimension_codes.append((attr_name, attr_value))
+
+ # Special handling for indicator-like dimensions
+ if attr_name in indicator_id_candidates or "INDICATOR" in attr_name:
+ indicator_code = attr_value
+ indicator_codes_list.append((attr_name, attr_value))
+ all_unique_indicators.add(attr_value)
+
+ # Translate the code to human-readable label
+ if (
+ attr_name in translation_maps
+ and attr_value in translation_maps[attr_name]
+ ):
+ display_value = translation_maps[attr_name][attr_value]
+ else:
+ display_value = attr_value
+
+ series_meta[attr_name] = display_value
+ series_meta[f"{attr_name}_code"] = attr_value
+ # Add to title parts with high position (indicator goes last)
+ dim_pos = dim_order_map.get(attr_name, 999)
+ title_parts.append((dim_pos, attr_name, display_value))
+
+ elif attr_name == "COUNTRY":
+ # Translate country code
+ if (
+ attr_name in translation_maps
+ and attr_value in translation_maps[attr_name]
+ ):
+ display_value = translation_maps[attr_name][attr_value]
+ else:
+ display_value = attr_value
+ series_meta[attr_name] = display_value
+ series_meta["country_code"] = attr_value
+
+ elif attr_name == "COUNTERPART_COUNTRY":
+ if (
+ attr_name in translation_maps
+ and attr_value in translation_maps[attr_name]
+ ):
+ display_value = translation_maps[attr_name][attr_value]
+ else:
+ display_value = attr_value
+ series_meta[attr_name] = display_value
+ series_meta["counterpart_country_code"] = attr_value
+ # Add to title parts - COUNTERPART_COUNTRY is meaningful for DIP, BOP
+ dim_pos = dim_order_map.get(attr_name, 999)
+ title_parts.append((dim_pos, attr_name, display_value))
+
+ elif attr_name == "SCALE":
+ # Handle scale/unit multiplier - use proper codelist from DSD
+ try:
+ scale_int = int(attr_value)
+ series_meta["unit_multiplier"] = (
+ 1 if scale_int == 0 else 10**scale_int
+ )
+ # Use DSD-specific codelist if available, else CL_UNIT_MULT
+ if attr_name in attr_codelist_map:
+ scale_codelist = attr_codelist_map[attr_name]
+ series_meta["scale"] = scale_codelist.get(
+ attr_value, f"10^{attr_value}"
+ )
+ elif cl_unit_mult := self.metadata._codelist_cache.get(
+ "CL_UNIT_MULT", {}
+ ):
+ series_meta["scale"] = cl_unit_mult.get(
+ attr_value, f"10^{attr_value}"
+ )
+ else:
+ series_meta["scale"] = f"10^{attr_value}"
+ except ValueError:
+ series_meta["scale"] = attr_value
+
+ elif attr_name == "UNIT":
+ # Handle unit - use proper codelist from DSD, not generic CL_UNIT
+ if attr_name in attr_codelist_map:
+ unit_codelist = attr_codelist_map[attr_name]
+ series_meta["unit"] = unit_codelist.get(attr_value, attr_value)
+ else:
+ # Fallback to generic CL_UNIT only if no DSD-specific codelist
+ cl_unit = self.metadata._codelist_cache.get("CL_UNIT", {})
+ series_meta["unit"] = cl_unit.get(attr_value, attr_value)
+
+ elif (
+ attr_name in translation_maps
+ and attr_value in translation_maps[attr_name]
+ ):
+ # Store translated label and preserve code
+ display_value = translation_maps[attr_name][attr_value]
+ series_meta[attr_name] = display_value
+ series_meta[f"{attr_name}_code"] = attr_value
+ # Add to title parts if not excluded
+ if attr_name not in title_exclude_dims:
+ dim_pos = dim_order_map.get(attr_name, 999)
+ title_parts.append((dim_pos, attr_name, display_value))
+ elif attr_name not in [
+ "IFS_FLAG",
+ "OVERLAP",
+ "OBS_STATUS",
+ "DECIMALS_DISPLAYED",
+ "COUNTRY_UPDATE_DATE",
+ ]:
+ # Dimension not in translation maps - store raw value
+ # Also store with _code suffix for consistency
+ series_meta[attr_name] = attr_value
+ series_meta[f"{attr_name}_code"] = attr_value
+ # Add to title parts if not excluded (use raw code as display)
+ if attr_name not in title_exclude_dims:
+ dim_pos = dim_order_map.get(attr_name, 999)
+ title_parts.append((dim_pos, attr_name, attr_value))
+
+ # Store indicator_codes for series_id building
+ if indicator_codes_list:
+ series_meta["indicator_codes"] = indicator_codes_list
+
+ # Build title from ALL collected dimension labels
+ # Sort by DSD dimension position so title is consistent
+ if title_parts:
+ sorted_title_parts = sorted(title_parts, key=lambda x: (x[0], x[1]))
+ series_meta["title"] = " - ".join(p[2] for p in sorted_title_parts)
+
+ # Apply group-level attributes (UNIT, ACCOUNTING_ENTRY, etc.) from Group elements
+ # The group_attributes dict maps indicator codes to their group-level attrs
+ if indicator_code and indicator_code in group_attributes:
+ group_attrs = group_attributes[indicator_code]
+ for attr_id, attr_value in group_attrs.items():
+ if attr_id == "UNIT" and "unit" not in series_meta:
+ # Translate UNIT code
+ if "UNIT" in attr_codelist_map:
+ unit_codelist = attr_codelist_map["UNIT"]
+ series_meta["unit"] = unit_codelist.get(
+ attr_value, attr_value
+ )
+ elif cl_unit := self.metadata._codelist_cache.get("CL_UNIT"):
+ series_meta["unit"] = cl_unit.get(attr_value, attr_value)
+ else:
+ series_meta["unit"] = attr_value
+ elif attr_id == "SCALE" and "scale" not in series_meta:
+ try:
+ scale_int = int(attr_value)
+ series_meta["unit_multiplier"] = (
+ 1 if scale_int == 0 else 10**scale_int
+ )
+ if "SCALE" in attr_codelist_map:
+ scale_codelist = attr_codelist_map["SCALE"]
+ series_meta["scale"] = scale_codelist.get(
+ attr_value, f"10^{attr_value}"
+ )
+ elif cl_unit_mult := self.metadata._codelist_cache.get(
+ "CL_UNIT_MULT"
+ ):
+ series_meta["scale"] = cl_unit_mult.get(
+ attr_value, f"10^{attr_value}"
+ )
+ else:
+ series_meta["scale"] = f"10^{attr_value}"
+ except ValueError:
+ series_meta["scale"] = attr_value
+ elif attr_id not in series_meta:
+ # Translate using translation maps if available
+ if (
+ attr_id in translation_maps
+ and attr_value in translation_maps[attr_id]
+ ):
+ series_meta[attr_id] = translation_maps[attr_id][attr_value]
+ series_meta[f"{attr_id}_code"] = attr_value
+ else:
+ series_meta[attr_id] = attr_value
+
+ if "unit" not in series_meta:
+ # Get CL_UNIT codelist for looking up unit codes
+ cl_unit = self.metadata._codelist_cache.get("CL_UNIT", {})
+ # First check TYPE_OF_TRANSFORMATION which provides unit-like info
+ type_of_transform = series_meta.get("TYPE_OF_TRANSFORMATION")
+ if type_of_transform:
+ # TYPE_OF_TRANSFORMATION may contain compound values like
+ # "Period average, Year-over-year (YOY) percent change"
+ # Try to extract just the unit part
+ if type_of_transform in ["Index", "Weight", "Ratio"]:
+ series_meta["unit"] = type_of_transform
+ elif "percent change" in type_of_transform.lower():
+ series_meta["unit"] = "Percent change"
+ if "year-over-year" in type_of_transform.lower():
+ series_meta["scale"] = "Year-over-year"
+ elif "period-over-period" in type_of_transform.lower():
+ series_meta["scale"] = "Period-over-period"
+ elif ", " in type_of_transform:
+ # Try last part after comma (e.g., "Weight, Percent" -> "Percent")
+ last_part = type_of_transform.split(", ")[-1].strip()
+ if last_part in ["Index", "Percent", "Weight", "Ratio"]:
+ series_meta["unit"] = last_part
+ else:
+ series_meta["unit"] = type_of_transform
+ else:
+ series_meta["unit"] = type_of_transform
+
+ # Try extracting unit AND scale from indicator label
+ # Label format: "Description, Scale, Unit" e.g.,
+ # "Exporter real GDP, Per capita, US dollar"
+ indicator_label = series_meta.get("INDICATOR")
+ extracted_unit = None
+ extracted_scale = None
+ if indicator_label:
+ extracted_unit_string = extract_unit_from_label(indicator_label)
+ if extracted_unit_string:
+ extracted_unit, extracted_scale = parse_unit_and_scale(
+ extracted_unit_string
+ )
+
+ # If still no unit, try extracting from indicator code suffix
+ # e.g., XQI_IX -> IX -> "Index" (from CL_UNIT)
+ # BUT: only if the suffix is actually a unit code, not a dimension code
+ # like "ALL" (All entities) or country codes
+ if "unit" not in series_meta:
+ ind_code = series_meta.get("INDICATOR_code")
+ if ind_code and "_" in ind_code:
+ parts = ind_code.rsplit("_", 1)
+ if len(parts) == 2:
+ unit_code = parts[1]
+ # Skip common dimension codes that appear as suffixes
+ dimension_codes = {"ALL", "FE", "RFI", "REXFI"}
+ if (
+ unit_code in cl_unit
+ and unit_code not in dimension_codes
+ ):
+ series_meta["unit"] = cl_unit[unit_code]
+
+ if extracted_scale:
+ # Only override if current scale is generic or missing
+ current_scale = series_meta.get("scale")
+ generic_scales = {"Units", "units", None, ""}
+ if current_scale in generic_scales or not current_scale:
+ series_meta["scale"] = extracted_scale
+
+ # If still no unit, use extracted unit from label
+ if "unit" not in series_meta and extracted_unit:
+ series_meta["unit"] = extracted_unit
+
+ # If still no unit, try other label sources
+ if "unit" not in series_meta:
+ # Try these label sources in order of priority
+ label_sources = [
+ series_meta.get("title"), # May be overwritten by PRODUCT
+ series_meta.get("PRODUCTION_INDEX"),
+ series_meta.get("INDEX_TYPE"),
+ ]
+ for label in label_sources:
+ if label:
+ extracted_unit_string = extract_unit_from_label(label)
+ if extracted_unit_string:
+ # Parse into separate unit and scale components
+ unit, scale = parse_unit_and_scale(
+ extracted_unit_string
+ )
+ if unit:
+ series_meta["unit"] = unit
+ if scale and "scale" not in series_meta:
+ series_meta["scale"] = scale
+ break
+
+ # Match the input format: dataflow::indicator_code
+ if indicator_codes_list:
+ # Sort by DSD dimension order for consistency
+ sorted_ind_codes = sorted(
+ indicator_codes_list,
+ key=lambda x: (indicator_dimension_order.get(x[0], 999), x[0]),
+ )
+ combined_codes = "_".join(code for _, code in sorted_ind_codes)
+ series_meta["series_id"] = f"{dataflow}::{combined_codes}"
+ elif indicator_code:
+ # Fallback if no indicator codes list
+ series_meta["series_id"] = f"{dataflow}::{indicator_code}"
+
+ # Process observations with multiple namespace approaches
+ obs_elements = series.findall("Obs") + series.findall("ss:Obs", namespaces)
+ # Remove duplicates
+ seen_obs = set()
+ unique_obs = []
+ for o in obs_elements:
+ o_id = id(o)
+ if o_id not in seen_obs:
+ seen_obs.add(o_id)
+ unique_obs.append(o)
+
+ derivation_types_in_series: set = set()
+
+ for obs in unique_obs:
+ obs_row = series_meta.copy()
+
+ # TIME_PERIOD - try multiple attribute names
+ time_period = (
+ obs.attrib.get("TIME_PERIOD")
+ or obs.attrib.get("TIME")
+ or obs.attrib.get("time")
+ or ""
+ )
+
+ # Get observation value - SDMX 3.0 XML format
+ # Value can be in OBS_VALUE attribute or ObsValue child element
+ obs_value = obs.attrib.get("OBS_VALUE") or obs.attrib.get("OBSERVATION")
+
+ # If not in attributes, check child elements
+ if obs_value is None:
+ # Try ObsValue element with namespace
+ obs_value_elem = obs.find("ss:ObsValue", namespaces)
+ if obs_value_elem is None:
+ obs_value_elem = obs.find("ObsValue")
+ if obs_value_elem is not None:
+ obs_value = (
+ obs_value_elem.attrib.get("value") or obs_value_elem.text
+ )
+ else:
+ # Search all children for value-like elements
+ for child in obs:
+ local_tag = (
+ child.tag.split("}")[-1]
+ if "}" in child.tag
+ else child.tag
+ )
+ if local_tag.upper() in ("OBSVALUE", "OBS_VALUE", "VALUE"):
+ obs_value = child.attrib.get("value") or child.text
+ if obs_value:
+ break
+
+ derivation_type = obs.attrib.get("DERIVATION_TYPE")
+ obs_row["TIME_PERIOD"] = time_period
+
+ # Extract observation-level attributes (UNIT, SCALE, etc.)
+ # These may override series-level attributes for specific observations
+ obs_unit = obs.attrib.get("UNIT")
+ if obs_unit:
+ # Use proper codelist from DSD, not generic CL_UNIT
+ if "UNIT" in attr_codelist_map:
+ unit_codelist = attr_codelist_map["UNIT"]
+ obs_row["unit"] = unit_codelist.get(obs_unit, obs_unit)
+ else:
+ cl_unit = self.metadata._codelist_cache.get("CL_UNIT", {})
+ obs_row["unit"] = cl_unit.get(obs_unit, obs_unit)
+
+ obs_scale = obs.attrib.get("SCALE")
+ if obs_scale:
+ try:
+ scale_int = int(obs_scale)
+ obs_row["unit_multiplier"] = (
+ 1 if scale_int == 0 else 10**scale_int
+ )
+ # Use DSD-specific codelist if available
+ if "SCALE" in attr_codelist_map:
+ scale_codelist = attr_codelist_map["SCALE"]
+ obs_row["scale"] = scale_codelist.get(
+ obs_scale, f"10^{obs_scale}"
+ )
+ elif cl_unit_mult := self.metadata._codelist_cache.get(
+ "CL_UNIT_MULT", {}
+ ):
+ obs_row["scale"] = cl_unit_mult.get(
+ obs_scale, f"10^{obs_scale}"
+ )
+ else:
+ obs_row["scale"] = f"10^{obs_scale}"
+ except ValueError:
+ obs_row["scale"] = obs_scale
+
+ # Only add rows with actual values
+ if obs_value is not None and obs_value not in {"", "D"}:
+ obs_row["value"] = obs_value
+
+ if derivation_type:
+ if "CL_DERIVATION_TYPE" in self.metadata._codelist_cache:
+ derivation_type = self.metadata._codelist_cache[
+ "CL_DERIVATION_TYPE"
+ ].get(derivation_type, derivation_type)
+ derivation_types_in_series.add(derivation_type)
+
+ all_data_rows.append(obs_row)
+
+ if indicator_code and derivation_types_in_series:
+ if len(derivation_types_in_series) == 1:
+ all_series_derivation_types[indicator_code] = list(
+ derivation_types_in_series
+ )[0]
+ else:
+ all_series_derivation_types[indicator_code] = "; ".join(
+ sorted(derivation_types_in_series)
+ )
+
+ if not all_data_rows:
+ # Build a more helpful error message with parameter info
+ param_info = ", ".join(f"{k}={v}" for k, v in kwargs.items() if v)
+ raise OpenBBError(
+ EmptyDataError(
+ f"No data rows found for dataflow '{dataflow}' with parameters: "
+ + f"{param_info}. "
+ + "The IMF constraints API reports this combination as valid, "
+ + "but no actual observations were returned in the data. "
+ + f"URL -> {url}"
+ )
+ )
+
+ # Create DataFrame and clean up
+ df = DataFrame(all_data_rows)
+ df = df.rename(columns={"value": "OBS_VALUE"})
+ df["OBS_VALUE"] = to_numeric(df["OBS_VALUE"], errors="coerce")
+
+ # Parse TIME_PERIOD into valid date format
+ if "TIME_PERIOD" in df.columns:
+ df["TIME_PERIOD"] = df["TIME_PERIOD"].apply(parse_time_period)
+
+ # Build metadata
+ metadata: dict = {}
+
+ # Get indicator descriptions from cache
+ all_indicators_meta = self.metadata.get_indicators_in(dataflow)
+ indicator_descriptions_map = {
+ item["indicator"]: item["description"] for item in all_indicators_meta
+ }
+
+ # Add description column to DataFrame based on indicator code
+ # Look for any indicator column to map descriptions
+ indicator_col = None
+ for col in df.columns:
+ if col.endswith("_code") and col.replace("_code", "") in [
+ "INDICATOR",
+ "BOP_ACCOUNTING_ENTRY",
+ "ACCOUNTING_ENTRY",
+ "SERIES",
+ "ITEM",
+ ]:
+ indicator_col = col
+ break
+
+ if indicator_col:
+ df["description"] = df[indicator_col].map(indicator_descriptions_map)
+ else:
+ df["description"] = ""
+
+ # Add indicator metadata
+ for indicator_code in all_unique_indicators:
+ # Use dataflow::indicator format for user-facing metadata keys
+ full_key = f"{dataflow}::{indicator_code}"
+ ind_meta = {
+ "description": indicator_descriptions_map.get(indicator_code, ""),
+ "indicator": indicator_code,
+ }
+
+ # Add derivation type to series metadata if available
+ if indicator_code in all_series_derivation_types:
+ ind_meta["derivation_type"] = all_series_derivation_types[
+ indicator_code
+ ]
+
+ metadata[full_key] = ind_meta
+
+ # Add dataset-level metadata from cached dataflow info
+ dataset_attrs = self._extract_dataset_attributes_from_cache(dataflow)
+
+ if dataset_attrs:
+ metadata["dataset"] = dataset_attrs
+
+ return {"data": df.to_dict(orient="records"), "metadata": metadata}
+
+ def _extract_dataset_attributes_from_cache(self, dataflow: str) -> dict:
+ """Extract dataset-level attributes from cached metadata."""
+ dataset_attrs: dict = {}
+ dataflow_obj = self.metadata.dataflows.get(dataflow, {})
+ dataset_attrs["dataflow_id"] = dataflow
+ dataset_attrs["dataflow_name"] = dataflow_obj.get("name", dataflow)
+ dataset_attrs["dataflow_description"] = dataflow_obj.get("description", "")
+
+ # Get additional attributes from the cached dataflow object
+ for attr_key in [
+ "publisher",
+ "department",
+ "contact_point",
+ "keywords",
+ "license",
+ "suggested_citation",
+ "short_source_citation",
+ "full_source_citation",
+ "publication_date",
+ "update_date",
+ "methodology_notes",
+ "topic_dataset",
+ "keywords_dataset",
+ ]:
+ if attr_val := dataflow_obj.get(attr_key):
+ dataset_attrs[attr_key] = attr_val
+
+ return dataset_attrs
+
+ def _build_translation_maps(self, structure: dict, dataflow: str) -> dict:
+ """Build dimension translation maps from the response structure values.
+ Fallback to metadata.get_dataflow_parameters if a dimension has no inline values.
+ Returns a mapping: { dimension_id: { code: label, ... }, ... }
+ """
+ maps: dict = {}
+
+ # First, get ALL translations from cached dataflow parameters
+ try:
+ df_params = self.metadata.get_dataflow_parameters(dataflow)
+ for dim_id, options in df_params.items():
+ if isinstance(options, list) and options:
+ maps[dim_id] = {
+ opt["value"]: opt["label"] for opt in options if "value" in opt
+ }
+ except Exception: # noqa # pylint: disable=broad-except
+ pass
+
+ # Then overlay with any labels from the structure (if they're better than codes)
+ for dim_group in ("series", "observation"):
+ for dim in structure.get("dimensions", {}).get(dim_group, []):
+ dim_id = dim.get("id")
+
+ if not dim_id:
+ continue
+
+ vals = dim.get("values", [])
+
+ if not vals:
+ continue
+
+ # Only update if the structure has actual names (not just codes)
+ for v in vals:
+ code = v.get("id")
+ name = v.get("name")
+ # Only use if name is different from code
+ if code and name and name != code:
+ if dim_id not in maps:
+ maps[dim_id] = {}
+ maps[dim_id][code] = name
+
+ return maps
+
+ def _parse_attributes(self, attr_values: list, attr_definitions: list) -> dict:
+ """Parse attribute values using their definitions."""
+ result: dict = {}
+
+ for i, value in enumerate(attr_values):
+ if value is None:
+ continue
+
+ if i < len(attr_definitions):
+ attr_def = attr_definitions[i]
+ attr_id = attr_def.get("id")
+
+ # If value is an index, look it up in the definition's values
+ if isinstance(value, int) and "values" in attr_def:
+ values_list = attr_def.get("values", [])
+ if value < len(values_list):
+ actual_value = values_list[value].get("id")
+ result[attr_id] = actual_value
+ else:
+ result[attr_id] = value
+
+ return result
+
+ def _get_cached_translations(self, dataflow: str) -> dict:
+ """Get translation maps from cached metadata."""
+ maps: dict = {}
+ # Get dataflow parameters for dimension translations
+ # Load ALL dimensions, not just a subset
+ dataflow_params = self.metadata.get_dataflow_parameters(dataflow)
+ for dim_id, items in dataflow_params.items():
+ maps[dim_id] = {item["value"]: item["label"] for item in items}
+
+ return maps
+
+ def _extract_dataset_attributes(
+ self, structure: dict, json_data: dict, dataflow: str
+ ) -> dict:
+ """Extract dataset-level attributes."""
+ dataset_attrs: dict = {}
+ # Add dataflow info
+ dataflow_obj = self.metadata.dataflows.get(dataflow, {})
+ dataset_attrs["dataflow_id"] = dataflow
+ dataset_attrs["dataflow_name"] = dataflow_obj.get("name", dataflow)
+ dataset_attrs["dataflow_description"] = dataflow_obj.get("description", "")
+ # Get dataset attributes from the cached dataflow object
+ # These are stored in the dataflow metadata, not in the API response
+ for attr_key in [
+ "publisher",
+ "department",
+ "contact_point",
+ "keywords",
+ "license",
+ "suggested_citation",
+ "short_source_citation",
+ "full_source_citation",
+ "publication_date",
+ "update_date",
+ "methodology_notes",
+ "topic_dataset",
+ "keywords_dataset",
+ ]:
+ if attr_key in dataflow_obj:
+ dataset_attrs[attr_key] = dataflow_obj.get(attr_key)
+
+ # Extract attributes from structure response (for UPDATE_DATE, PUBLICATION_DATE, etc.)``
+ if "attributes" in structure and "dataSet" in structure["attributes"]:
+ dataset_attributes = []
+ if "dataSets" in json_data and json_data["dataSets"]:
+ dataset_attributes = json_data["dataSets"][0].get("attributes", [])
+
+ # If no attributes in the response, return what we have from cache
+ if not dataset_attributes:
+ return dataset_attrs
+
+ attr_defs = structure["attributes"]["dataSet"]
+
+ # Iterate through both definitions and values together by index
+ for i, attr_def in enumerate(attr_defs):
+ attr_id = attr_def.get("id")
+
+ if attr_id not in [
+ "CONTACT_POINT",
+ "PUBLISHER",
+ "DEPARTMENT",
+ "LICENSE",
+ "SUGGESTED_CITATION",
+ "SHORT_SOURCE_CITATION",
+ "FULL_SOURCE_CITATION",
+ "PUBLICATION_DATE",
+ "UPDATE_DATE",
+ "METHODOLOGY_NOTES",
+ "TOPIC_DATASET",
+ "KEYWORDS_DATASET",
+ ]:
+ continue
+
+ attr_value = (
+ dataset_attributes[i] if i < len(dataset_attributes) else None
+ )
+
+ if attr_value is not None and attr_value != [None]:
+ if attr_id == "TOPIC_DATASET":
+ topic_codes = []
+ if isinstance(attr_value, int) and "values" in attr_def:
+ values_list = attr_def.get("values", [])
+ if attr_value < len(values_list):
+ topic_val = values_list[attr_value]
+ if isinstance(topic_val, dict):
+ if "ids" in topic_val:
+ topic_codes.extend(topic_val.get("ids", []))
+ elif "id" in topic_val:
+ topic_codes.append(topic_val.get("id"))
+ else:
+ topic_codes.append(topic_val)
+ elif isinstance(attr_value, list):
+ for val in attr_value:
+ if isinstance(val, int) and "values" in attr_def:
+ values_list = attr_def.get("values", [])
+ if val < len(values_list):
+ topic_val = values_list[val]
+ if isinstance(topic_val, dict):
+ if "ids" in topic_val:
+ topic_codes.extend(
+ topic_val.get("ids", [])
+ )
+ elif "id" in topic_val:
+ topic_codes.append(topic_val.get("id"))
+ else:
+ topic_codes.append(topic_val)
+ elif isinstance(val, str):
+ topic_codes.append(val)
+
+ # Translate topic codes to names using cached codelist
+ if topic_codes and "CL_TOPIC" in self.metadata._codelist_cache:
+ topic_names = []
+ for code in topic_codes:
+ topic_name = self.metadata._codelist_cache[
+ "CL_TOPIC"
+ ].get(code, code)
+ topic_names.append(topic_name)
+ dataset_attrs["topics"] = topic_names
+ elif topic_codes:
+ dataset_attrs["topics"] = topic_codes
+
+ elif attr_id == "KEYWORDS_DATASET":
+ keywords = self._extract_attribute_value(attr_value, attr_def)
+ if keywords:
+ dataset_attrs["keywords"] = keywords
+ elif attr_id == "UPDATE_DATE":
+ date_value = self._extract_attribute_value(attr_value, attr_def)
+ if date_value:
+ try:
+ if isinstance(date_value, str) and "." in date_value:
+ parts = date_value.split(".")
+ if len(parts) == 2:
+ fractional = parts[1][:6].ljust(6, "0")
+ date_value = f"{parts[0]}.{fractional}Z"
+ dataset_attrs["last_updated"] = date_value
+ except ( # noqa # pylint: disable=broad-exception-caught
+ Exception
+ ):
+ dataset_attrs["last_updated"] = date_value
+ elif attr_id == "PUBLICATION_DATE":
+ pub_date = self._extract_attribute_value(attr_value, attr_def)
+ if pub_date:
+ dataset_attrs["publication_date"] = pub_date
+ else:
+ final_value = self._extract_attribute_value(
+ attr_value, attr_def
+ )
+ if final_value:
+ dataset_attrs[attr_id.lower()] = final_value
+
+ return dataset_attrs
+
+ def _extract_attribute_value(self, attr_value, attr_def):
+ """Extract the actual value from an attribute."""
+ if isinstance(attr_value, int):
+ # Value is an index into the values array
+ if "values" in attr_def and attr_value < len(attr_def["values"]):
+ value_obj = attr_def["values"][attr_value]
+ if isinstance(value_obj, dict):
+ return (
+ value_obj.get("en")
+ or value_obj.get("name")
+ or value_obj.get("id")
+ )
+ return value_obj
+ elif isinstance(attr_value, list) and attr_value:
+ first_val = attr_value[0]
+ if (
+ isinstance(first_val, int)
+ and "values" in attr_def
+ and first_val < len(attr_def["values"])
+ ):
+ value_obj = attr_def["values"][first_val]
+ if isinstance(value_obj, dict):
+ return (
+ value_obj.get("en")
+ or value_obj.get("name")
+ or value_obj.get("id")
+ )
+ return value_obj
+ if isinstance(first_val, dict):
+ return first_val.get("en") or first_val.get("name") or first_val
+ return first_val
+ elif isinstance(attr_value, dict):
+ return attr_value.get("en") or attr_value.get("name") or attr_value
+
+ return attr_value
+
+ def _extract_indicator_metadata(
+ self, dimension_group_attrs: dict, structure: dict
+ ) -> dict:
+ """Extract indicator metadata from dimensionGroupAttributes."""
+ indicator_metadata: dict = {}
+
+ # Get the dimensionGroup attribute definitions from structure
+ dim_group_defs = structure.get("attributes", {}).get("dimensionGroup", [])
+
+ # Create index maps for each attribute type
+ attr_index_map: dict = {}
+ for i, attr_def in enumerate(dim_group_defs):
+ attr_index_map[attr_def.get("id")] = i
+
+ indicator_dim = None
+ indicator_dim_position = None
+ series_dims = structure.get("dimensions", {}).get("series", [])
+
+ # List of possible indicator dimension names
+ indicator_candidates = [
+ "INDICATOR",
+ "COICOP_1999",
+ "PRODUCTION_INDEX",
+ "ACTIVITY",
+ "PRODUCT",
+ "SERIES",
+ "ITEM",
+ ]
+
+ for i, dim in enumerate(series_dims):
+ dim_id = dim.get("id")
+ if dim_id in indicator_candidates or "INDICATOR" in dim_id:
+ indicator_dim = dim
+ indicator_dim_position = i
+ break
+
+ if not indicator_dim or indicator_dim_position is None:
+ return indicator_metadata
+
+ for group_key, attr_values in dimension_group_attrs.items():
+ # Parse the group key to get the indicator index
+ # The key format is like ":0:::" where positions correspond to series dimensions
+ key_cleaned = group_key.strip(":")
+ key_parts = key_cleaned.split(":") if key_cleaned else []
+
+ # Ensure we have enough parts
+ if len(key_parts) > indicator_dim_position:
+ indicator_idx_str = key_parts[indicator_dim_position]
+ else:
+ continue
+
+ if not indicator_idx_str:
+ continue
+
+ try:
+ indicator_idx = int(indicator_idx_str)
+ except ValueError:
+ continue
+
+ if indicator_idx >= len(indicator_dim.get("values", [])):
+ continue
+
+ indicator_code = indicator_dim["values"][indicator_idx].get("id")
+ # Extract attribute values
+ metadata_entry: dict = {}
+
+ # Parse each attribute value
+ for attr_id, attr_idx in attr_index_map.items():
+ if attr_idx < len(attr_values):
+ value = attr_values[attr_idx]
+
+ if attr_id == "SERIES_NAME" and value:
+ # Extract series name directly from the list
+ if isinstance(value, list) and value:
+ metadata_entry["series_name"] = (
+ value[0] if isinstance(value[0], str) else ""
+ )
+ elif attr_id == "TRADE_FLOW" and value is not None:
+ # Get trade flow code and translate using cached codelist
+ if isinstance(value, int):
+ trade_flow_values = dim_group_defs[attr_idx].get(
+ "values", []
+ )
+ if value < len(trade_flow_values):
+ trade_flow_code = trade_flow_values[value].get("id")
+ # Translate using CL_TRADE_FLOW from cache
+ if (
+ trade_flow_code
+ and "CL_TRADE_FLOW" in self.metadata._codelist_cache
+ ):
+ metadata_entry["trade_flow"] = (
+ self.metadata._codelist_cache[
+ "CL_TRADE_FLOW"
+ ].get(trade_flow_code, trade_flow_code)
+ )
+ elif trade_flow_code:
+ metadata_entry["trade_flow"] = trade_flow_code
+ elif attr_id == "VALUATION" and value is not None:
+ # Get valuation code and translate if codelist exists
+ if isinstance(value, int):
+ valuation_values = dim_group_defs[attr_idx].get(
+ "values", []
+ )
+ if value < len(valuation_values):
+ valuation_code = valuation_values[value].get("id")
+ # Try to translate using cached codelist
+ if (
+ valuation_code
+ and "CL_VALUATION" in self.metadata._codelist_cache
+ ):
+ metadata_entry["valuation"] = (
+ self.metadata._codelist_cache[
+ "CL_VALUATION"
+ ].get(valuation_code, valuation_code)
+ )
+ elif valuation_code:
+ metadata_entry["valuation"] = valuation_code
+ elif attr_id == "UNIT" and value is not None:
+ if isinstance(value, int):
+ unit_values = dim_group_defs[attr_idx].get("values", [])
+ if value < len(unit_values):
+ unit_code = unit_values[value].get("id")
+ # Skip translation for special aggregate codes that
+ # conflict with currency codes
+ special_aggregate_codes = {"ALL", "W0", "W1", "W2"}
+ if unit_code in special_aggregate_codes:
+ metadata_entry["unit"] = unit_code
+ elif (
+ unit_code
+ and "CL_UNIT" in self.metadata._codelist_cache
+ ):
+ translated_unit = self.metadata._codelist_cache[
+ "CL_UNIT"
+ ].get(unit_code, unit_code)
+ metadata_entry["unit"] = translated_unit
+ else:
+ metadata_entry["unit"] = unit_code
+ elif attr_id == "TOPIC" and value is not None:
+ # Extract topics - handle both integer indices and direct values
+ topic_codes: list = []
+
+ # Value is an integer index into the topic values array
+ if isinstance(value, int):
+ topic_values = dim_group_defs[attr_idx].get("values", [])
+ if value < len(topic_values):
+ topic_val = topic_values[value]
+ if isinstance(topic_val, dict):
+ # Could be {'id': 'F10_I'} or {'ids': ['L81', 'F10_I', 'F10_E']}
+ if "id" in topic_val:
+ topic_codes.append(topic_val["id"])
+ elif "ids" in topic_val:
+ topic_codes.extend(topic_val["ids"])
+
+ # Translate topic codes to names using cached codelist
+ topics: list = []
+ if topic_codes and "CL_TOPIC" in self.metadata._codelist_cache:
+ for code in topic_codes:
+ topic_name = self.metadata._codelist_cache[
+ "CL_TOPIC"
+ ].get(code, code)
+ topics.append(topic_name)
+ elif topic_codes:
+ topics = topic_codes
+
+ metadata_entry["topic"] = topics
+ elif (
+ attr_id == "KEY_INDICATOR"
+ and value
+ and isinstance(value, list)
+ and value
+ ):
+ metadata_entry["key_indicator"] = (
+ value[0] == "true" if isinstance(value[0], str) else False
+ )
+
+ indicator_metadata[indicator_code] = metadata_entry
+
+ return indicator_metadata
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/table_builder.py b/openbb_platform/providers/imf/openbb_imf/utils/table_builder.py
new file mode 100644
index 00000000000..20661335c5d
--- /dev/null
+++ b/openbb_platform/providers/imf/openbb_imf/utils/table_builder.py
@@ -0,0 +1,1551 @@
+"""IMF Table Builder - Handles hierarchical table data fetching with validation."""
+
+# pylint: disable=C0302,C0103,R0903,R0911,R0912,R0913,R0914,R0915,R0917,R1702,W0212
+# flake8: noqa: PLR0911,PLR0912,PLR0913,PLR0917
+
+import warnings
+from collections import defaultdict
+from datetime import datetime
+
+from openbb_core.app.model.abstract.warning import OpenBBWarning
+from openbb_imf.utils.helpers import parse_codelist_id_from_urn
+
+
+def _calculate_depth(
+ indicator: dict, indicator_by_code: dict, visited: set | None = None
+) -> int:
+ """
+ Calculate the actual depth of an indicator by tracing its parent_id chain.
+
+ This provides proper hierarchical indentation where children are always
+ indented more than their parents, unlike the 'level' field which only
+ distinguishes top-level (0) from nested (1+).
+
+ Args:
+ indicator: The indicator dictionary with 'indicator_code' and 'parent_id' fields.
+ indicator_by_code: Lookup dictionary mapping indicator_code to indicator dict.
+ visited: Set of already visited codes to prevent infinite loops.
+
+ Returns:
+ The depth (0 for root nodes, 1+ for nested nodes based on parent chain length).
+ """
+ if visited is None:
+ visited = set()
+
+ code = indicator.get("indicator_code", "")
+ if not code or code in visited:
+ return 0
+ visited.add(code)
+
+ parent_id = indicator.get("parent_id")
+ if parent_id is None or parent_id not in indicator_by_code:
+ return 0
+
+ return 1 + _calculate_depth(
+ indicator_by_code[parent_id], indicator_by_code, visited
+ )
+
+
+class ImfTableBuilder:
+ """
+ Handles fetching and organizing IMF data according to hierarchical presentation tables.
+
+ This class validates dimension constraints and builds properly ordered tabular data
+ with hierarchy information (order, level, parent_id) embedded in each row.
+ """
+
+ def __init__(self):
+ """Initialize the table builder with its own query builder instance."""
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ self.query_builder = ImfQueryBuilder()
+ self.metadata = self.query_builder.metadata
+
+ def _validate_dimension_constraints(self, dataflow: str, **kwargs) -> None:
+ """
+ Validate that the provided dimension parameter combinations are valid according
+ to IMF API constraints. Delegates to query_builder.validate_dimension_constraints.
+
+ Parameters
+ ----------
+ dataflow : str
+ The dataflow ID
+ **kwargs
+ Dimension parameters to validate
+
+ Raises
+ ------
+ ValueError
+ If the parameter combination is invalid according to API constraints
+ """
+ self.query_builder.validate_dimension_constraints(dataflow, **kwargs)
+
+ def get_table(
+ self,
+ dataflow: str | None = None,
+ table_id: str | None = None,
+ start_date: str | None = None,
+ end_date: str | None = None,
+ limit: int | None = None,
+ depth: int | None = None,
+ parent_id: str | None = None,
+ indicators: list[str] | str | None = None,
+ **kwargs,
+ ) -> dict:
+ """Fetch data organized according to a hierarchy/presentation table structure.
+
+ This method allows flexible querying of hierarchical data by depth level,
+ parent group, or specific indicators. This avoids URL length issues when
+ fetching large tables.
+
+ Parameters
+ ----------
+ dataflow : str | None
+ The dataflow ID (e.g., "BOP", "IRFCL", "FAS"). Can be omitted if table_id
+ is in the format "dataflow_id::table_id".
+ table_id : str | None
+ The specific hierarchy/table ID. Can be in format "dataflow_id::table_id"
+ (as returned by list_tables with as_choices=True). If None, uses the
+ first available table for the dataflow.
+ start_date : str | None
+ Start date for the time series (format: YYYY, YYYY-MM, or YYYY-QN)
+ end_date : str | None
+ End date for the time series
+ depth : int | None
+ Limit to indicators at specific depth level(s). Use 0 for top-level only.
+ Can help avoid URL length limits by fetching data in stages.
+ parent_id : str | None
+ Fetch only indicators under a specific parent group. Useful for drilling
+ down into sections of the hierarchy.
+ indicators : list[str] | str | None
+ Fetch specific indicator codes. If provided, overrides depth/parent_id.
+ **kwargs
+ Additional dimension parameters (e.g., COUNTRY="US", FREQUENCY="A")
+
+ Returns
+ -------
+ dict
+ A dictionary containing:
+ - table_metadata: Information about the table/hierarchy
+ - structure: Full hierarchy structure (not filtered, for reference)
+ - data: Data rows with hierarchy ordering columns
+ - series_metadata: Additional metadata for each series
+
+ Examples
+ --------
+ >>> from openbb_imf.utils.table_builder import ImfTableBuilder
+ >>> tb = ImfTableBuilder()
+
+ >>> # Get only top-level indicators (avoid URL length issues)
+ >>> result = tb.get_table("IRFCL", depth=0, COUNTRY="US", FREQUENCY="A")
+
+ >>> # Get all indicators under a specific group
+ >>> result = tb.get_table("IRFCL", parent_id="FA", COUNTRY="US")
+
+ >>> # Get specific indicators
+ >>> result = tb.get_table("IRFCL", indicators=["A", "FA", "L"], COUNTRY="US")
+
+ >>> # Combine depth with parent to drill down
+ >>> result = tb.get_table("BOP", parent_id="NETCD_T", depth=1, COUNTRY="US")
+
+ >>> # Use combined dataflow::table_id format from list_tables choices
+ >>> result = tb.get_table(table_id="BOP::H_BOP_BOP_AGG_STANDARD_PRESENTATION", COUNTRY="USA", FREQUENCY="A")
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ # Parse dataflow_id::table_id format if provided
+ if table_id and "::" in table_id:
+ parts = table_id.split("::", 1)
+ parsed_dataflow = parts[0]
+ parsed_table_id = parts[1]
+ # If dataflow was also provided, validate it matches
+ if dataflow is not None and dataflow != parsed_dataflow:
+ raise ValueError(
+ f"Dataflow mismatch: provided '{dataflow}' but table_id "
+ f"specifies '{parsed_dataflow}'. Use one or the other."
+ )
+ dataflow = parsed_dataflow
+ table_id = parsed_table_id
+
+ if dataflow is None:
+ raise ValueError(
+ "dataflow is required. Either provide it directly or use "
+ "table_id in 'dataflow_id::table_id' format."
+ )
+
+ # Validate parameter combinations using progressive helper
+ if kwargs or start_date or end_date:
+ self._validate_dimension_constraints(
+ dataflow, start_date=start_date, end_date=end_date, **kwargs
+ )
+
+ # If table_id not provided, auto-select if only one table available
+ if table_id is None:
+ available_tables = self.metadata.get_dataflow_hierarchies(dataflow)
+ if len(available_tables) == 1:
+ table_id = available_tables[0]["id"]
+ elif len(available_tables) == 0:
+ raise ValueError(
+ f"No tables/hierarchies found for dataflow '{dataflow}'"
+ )
+
+ table_structure = self.metadata.get_dataflow_table_structure(dataflow, table_id)
+ table_metadata = {
+ "hierarchy_id": table_structure["hierarchy_id"],
+ "hierarchy_name": table_structure["hierarchy_name"],
+ "hierarchy_description": table_structure["hierarchy_description"],
+ "dataflow_id": table_structure["dataflow_id"],
+ "codelist_id": table_structure["codelist_id"],
+ "agency_id": table_structure["agency_id"],
+ "version": table_structure["version"],
+ "total_groups": table_structure["total_groups"],
+ "type": table_structure["type"],
+ }
+ filtered_hierarchy_entries = table_structure["indicators"]
+
+ if indicators is not None:
+ # Filter to specific indicator codes
+ indicator_set = (
+ {indicators} if isinstance(indicators, str) else set(indicators)
+ )
+ filtered_hierarchy_entries = [
+ entry
+ for entry in filtered_hierarchy_entries
+ if entry.get("indicator_code") in indicator_set
+ ]
+ elif parent_id is not None:
+ # Filter by parent_id
+ filtered_hierarchy_entries = [
+ entry
+ for entry in filtered_hierarchy_entries
+ if entry.get("parent_id") == parent_id
+ ]
+ elif depth is not None:
+ # Filter by depth
+ filtered_hierarchy_entries = [
+ entry
+ for entry in filtered_hierarchy_entries
+ if entry.get("depth") == depth
+ ]
+
+ # Extract entries with actual indicator codes (skip pure groups with no code)
+ entries_with_codes = [
+ entry for entry in filtered_hierarchy_entries if entry.get("indicator_code")
+ ]
+
+ if not entries_with_codes:
+ raise ValueError(
+ "No indicators match the specified filters "
+ f"(depth={depth}, parent_id={parent_id}, indicators={indicators}). "
+ f"Total entries in hierarchy: {len(table_structure['indicators'])}"
+ )
+
+ dimension_codes: dict = defaultdict(list)
+ dimension_codes_with_depth = defaultdict(list)
+ codelist_to_dimension_cache = {}
+
+ for entry in entries_with_codes:
+ indicator_code = entry.get("indicator_code")
+ code_urn = entry.get("code_urn", "")
+
+ if not indicator_code:
+ continue
+
+ dimension_id = entry.get("dimension_id")
+
+ if not dimension_id:
+ # Parse codelist ID from the code_urn
+ codelist_id = parse_codelist_id_from_urn(code_urn)
+
+ if not codelist_id:
+ # Fallback: if we can't parse code_urn, skip this entry
+ warnings.warn(
+ f"Could not parse codelist from code_urn for {indicator_code}: {code_urn}",
+ OpenBBWarning,
+ )
+ continue
+
+ # Skip known meta/label codelists that are structural grouping nodes
+ if "_LABELS" in codelist_id or "_TABLE_LEVEL" in codelist_id:
+ continue
+
+ # Map codelist to dimension
+ if codelist_id not in codelist_to_dimension_cache:
+ dimension_id = self.metadata._get_dimension_for_codelist(
+ dataflow, codelist_id
+ )
+ # Fallback: infer dimension from known patterns in codelist name
+ if not dimension_id:
+ codelist_upper = codelist_id.upper()
+ # Check for common dimension keywords in the codelist name
+ dimension_patterns = [
+ ("INDICATOR", "INDICATOR"),
+ ("COUNTRY", "REF_AREA"),
+ ("REF_AREA", "REF_AREA"),
+ ("UNIT", "UNIT_MEASURE"),
+ ("FREQ", "FREQ"),
+ ("ACCOUNTING_ENTRY", "BOP_ACCOUNTING_ENTRY"),
+ ("PRODUCTION_INDEX", "INDEX_TYPE"),
+ ("COICOP_1999", "COICOP_1999"),
+ ("ACTIVITY", "ACTIVITY"),
+ ("SECTOR", "SECTOR"),
+ ]
+ # Get actual dimension IDs from the DSD
+ df_obj = self.metadata.dataflows.get(dataflow, {})
+ dsd_id = df_obj.get("structureRef", {}).get("id")
+ dsd = self.metadata.datastructures.get(dsd_id, {})
+ dsd_dims = [
+ d.get("id")
+ for d in dsd.get("dimensions", [])
+ if d.get("id")
+ ]
+ for pattern, default_dim in dimension_patterns:
+ if pattern in codelist_upper:
+ # Find matching dimension in DSD
+ for dsd_dim in dsd_dims:
+ if (
+ pattern in dsd_dim.upper()
+ or dsd_dim.upper() == default_dim
+ ):
+ dimension_id = dsd_dim
+ break
+ if dimension_id:
+ break
+ codelist_to_dimension_cache[codelist_id] = dimension_id
+ else:
+ dimension_id = codelist_to_dimension_cache[codelist_id]
+
+ if not dimension_id:
+ warnings.warn(
+ f"Could not map codelist {codelist_id} to dimension for dataflow {dataflow}",
+ OpenBBWarning,
+ )
+ continue
+
+ # Add code to the appropriate dimension
+ if indicator_code not in dimension_codes[dimension_id]:
+ dimension_codes[dimension_id].append(indicator_code)
+ # Track depth for constraint URL length optimization
+ code_depth = entry.get("depth", 0)
+ dimension_codes_with_depth[dimension_id].append(
+ (indicator_code, code_depth)
+ )
+
+ if not dimension_codes:
+ raise ValueError(
+ f"No valid indicator codes found after filtering and dimension mapping. "
+ f"Filtered entries: {len(entries_with_codes)}"
+ )
+
+ hierarchy_order_map = {}
+ hierarchy_by_series_id = {}
+ hierarchy_by_sorted_codes = {}
+ # Some hierarchies can legitimately contain multiple nodes with the same
+ # (indicator_code, parent_code) (e.g., BOP Credit vs Debit variants under a Net parent),
+ # so store a list and disambiguate later.
+ # Key: (indicator_code, parent_code) e.g., ("O", "A_P") vs ("O", "L_P")
+ hierarchy_by_composite_key: dict[tuple[str, str], list[dict]] = defaultdict(
+ list
+ )
+ parents_by_indicator_code: dict[str, set[str]] = defaultdict(set)
+
+ # Build indicator_by_code lookup for depth calculation
+ indicator_by_code = {}
+ for ind in table_structure["indicators"]:
+ code = ind.get("indicator_code")
+ if code:
+ indicator_by_code[code] = ind
+
+ dataflow_obj = self.metadata.dataflows.get(dataflow, {})
+ # Series ID prefix in the new format: dataflow::
+ series_id_prefix = f"{dataflow}::"
+
+ # Build dimension order mapping (needed for fallback matching)
+ structure_ref = dataflow_obj.get("structureRef", {})
+ dsd_id = structure_ref.get("id")
+ indicator_dimension_order: dict[str, int] = {}
+
+ if dsd_id and dsd_id in self.metadata.datastructures:
+ dsd = self.metadata.datastructures[dsd_id]
+ dimensions = dsd.get("dimensions", [])
+
+ indicator_id_candidates = [
+ "INDICATOR",
+ "PRODUCTION_INDEX",
+ "COICOP_1999",
+ "ACTIVITY",
+ "INDEX_TYPE",
+ "CPI_INDEX_TYPE",
+ "PRODUCT",
+ "SERIES",
+ "ITEM",
+ "SECTOR",
+ "BOP_ACCOUNTING_ENTRY",
+ "ACCOUNTING_ENTRY",
+ ]
+
+ for idx, dim in enumerate(dimensions):
+ dim_id = dim.get("id", "")
+ if not dim_id:
+ continue
+ is_indicator_candidate = dim_id in indicator_id_candidates or any(
+ keyword in dim_id
+ for keyword in ["INDICATOR", "ACCOUNTING_ENTRY", "ENTRY"]
+ )
+ if is_indicator_candidate:
+ indicator_dimension_order[dim_id] = idx
+
+ for _, ind in enumerate(table_structure["indicators"]):
+ indicator_code = ind.get("indicator_code")
+ if not indicator_code:
+ continue
+
+ # All nodes (groups and leaves) have order and can have data
+ order_value = ind.get("order")
+
+ if order_value is None:
+ continue
+
+ # The 'depth' field is calculated during hierarchy parsing
+ depth = ind.get("depth")
+
+ if depth is None:
+ depth = _calculate_depth(ind, indicator_by_code)
+
+ # Also resolve parent_id from node ID to indicator code for proper parent tracking
+ parent_node_id = ind.get("parent_id")
+ parent_indicator_code = None
+
+ if parent_node_id:
+ # parent_id may be a full node ID (CL_BOP_ACCOUNTING_ENTRY___L_P)
+ # or just an indicator code (L_P). Handle both cases.
+ for other_ind in table_structure["indicators"]:
+ other_id = other_ind.get("id")
+ other_code = other_ind.get("indicator_code")
+ if parent_node_id in (other_id, other_code):
+ parent_indicator_code = other_code
+ break
+
+ hierarchy_info = {
+ "order": order_value,
+ "level": depth,
+ "parent_id": parent_node_id, # Keep original for reference
+ "parent_code": parent_indicator_code, # Add resolved parent code
+ "label": ind.get("label", ""),
+ "indicator_code": indicator_code,
+ "is_group": ind.get("is_group", False),
+ "hierarchy_node_id": ind.get(
+ "id"
+ ), # Hierarchy node ID for parent matching
+ "hierarchy_series_id": ind.get("series_id", ""),
+ }
+ hierarchy_order_map[indicator_code] = hierarchy_info
+
+ # Store composite key lookup for indicators with same code but different parents
+ # This handles cases like "Other investment" under both Assets (A_P) and Liabilities (L_P)
+ if parent_indicator_code:
+ composite_key = (indicator_code, parent_indicator_code)
+ hierarchy_by_composite_key[composite_key].append(hierarchy_info)
+ parents_by_indicator_code[indicator_code].add(parent_indicator_code)
+
+ # Both groups and leaves can have data and should be matched
+ if series_id := ind.get("series_id"):
+ hierarchy_by_series_id[series_id] = hierarchy_info
+ # "IIP::A_P_D" (dataflow::codes)
+ # "IMF_STA_IIP_A_P_D" (agency_dataflow_codes)
+ if series_id.startswith(series_id_prefix):
+ codes_str = series_id[len(series_id_prefix) :]
+ sorted_codes = "_".join(sorted(codes_str.split("_")))
+ hierarchy_by_sorted_codes[sorted_codes] = hierarchy_info
+ else:
+ dataflow_marker = f"_{dataflow}_"
+ if dataflow_marker in series_id:
+ idx = series_id.find(dataflow_marker) + len(dataflow_marker)
+ codes_str = series_id[idx:]
+ if codes_str:
+ sorted_codes = "_".join(sorted(codes_str.split("_")))
+ hierarchy_by_sorted_codes[sorted_codes] = hierarchy_info
+ new_format_id = f"{dataflow}::{codes_str}"
+ hierarchy_by_series_id[new_format_id] = hierarchy_info
+
+ table_metadata["total_indicators"] = len(entries_with_codes)
+ fetch_kwargs = kwargs.copy()
+
+ # Filter dimension codes against available constraints given user's kwargs
+ try:
+ builder = ImfParamsBuilder(dataflow)
+ dims_in_order = builder._get_dimensions_in_order()
+ dim_id_map = {d.lower(): d for d in dims_in_order}
+
+ # Country-like dimension aliases - user can pass 'country' and it maps to
+ # whichever country dimension this dataflow uses
+ country_dims = ["COUNTRY", "REF_AREA", "JURISDICTION", "COUNTERPART_AREA"]
+ for country_dim in country_dims:
+ if country_dim in dims_in_order:
+ dim_id_map["country"] = country_dim
+ break
+
+ # Indicator-like dimension aliases
+ indicator_dims = ["INDICATOR", "CLASSIFICATION"]
+ for indicator_dim in indicator_dims:
+ if indicator_dim in dims_in_order:
+ dim_id_map["indicator"] = indicator_dim
+ break
+
+ normalized_kwargs = {}
+ for key, value in kwargs.items():
+ matched_dim = dim_id_map.get(key.lower())
+ if matched_dim:
+ normalized_kwargs[matched_dim] = value
+ else:
+ normalized_kwargs[key] = value
+
+ # Replace fetch_kwargs with normalized kwargs (uppercase dimension IDs)
+ fetch_kwargs = normalized_kwargs.copy()
+ # Track dimensions with zero valid codes (hierarchy mismatch)
+ empty_dimensions: list[str] = []
+ # Process dimensions in order, setting both user kwargs and hierarchy codes
+ for dim_id in dims_in_order:
+ # If user provided this dimension, set it and add to fetch_kwargs
+ if dim_id in normalized_kwargs and dim_id not in dimension_codes:
+ # Validate user-provided value against available options
+ user_value = normalized_kwargs[dim_id]
+
+ # Skip validation for wildcard - it's always valid
+ if user_value != "*":
+ available_options = builder.get_options_for_dimension(dim_id)
+ available_values = {opt["value"] for opt in available_options}
+
+ # Handle multi-value selections (e.g., "USA+CAN")
+ user_values = (
+ user_value.split("+")
+ if isinstance(user_value, str) and "+" in user_value
+ else [user_value]
+ )
+ invalid_values = [
+ v
+ for v in user_values
+ if v not in available_values and v != "*"
+ ]
+
+ if invalid_values:
+ # Build prior selections for context
+ prior_selections = {
+ d: normalized_kwargs.get(d)
+ for d in dims_in_order
+ if d in normalized_kwargs
+ and dims_in_order.index(d) < dims_in_order.index(dim_id)
+ }
+ display_values = sorted(available_values)
+ raise ValueError(
+ f"Invalid value(s) for dimension '{dim_id}': {invalid_values}. "
+ f"Given prior selections {prior_selections}, "
+ f"available values are: {display_values}"
+ )
+
+ builder.set_dimension((dim_id, user_value))
+ fetch_kwargs[dim_id] = user_value
+ # If this dimension has hierarchy codes, filter them against available
+ elif dim_id in dimension_codes:
+ # Check if user explicitly provided this dimension (e.g., INDICATOR='*')
+ # If so, use their value for the builder but still process hierarchy codes
+ user_override = normalized_kwargs.get(dim_id)
+ codes = dimension_codes[dim_id]
+ available_options = builder.get_options_for_dimension(dim_id)
+ available_values = {opt["value"] for opt in available_options}
+ filtered_codes = [c for c in codes if c in available_values]
+ # If no exact matches, try prefix matching for INDICATOR dimension
+ # Hierarchies may use base codes (FSI688_TREGK) while dataflow has
+ # suffixed codes (FSI688_TREGK_USD, FSI688_TREGK_EUR, FSI688_TREGK_XDC)
+ if not filtered_codes and dim_id in {"INDICATOR", "CLASSIFICATION"}:
+ # Find all available codes that start with a hierarchy code
+ for hier_code in codes:
+ matching_codes = [
+ av
+ for av in available_values
+ if av.startswith(hier_code + "_") or av == hier_code
+ ]
+ filtered_codes.extend(matching_codes)
+ # Remove duplicates while preserving order
+ filtered_codes = list(dict.fromkeys(filtered_codes))
+
+ if filtered_codes:
+ # Build the joined codes string
+ joined_codes = "+".join(filtered_codes)
+ # Check if codes portion would be too long for IMF API
+ MAX_CODES_LENGTH = 850
+ use_wildcard_for_fetch = len(joined_codes) > MAX_CODES_LENGTH
+
+ if use_wildcard_for_fetch:
+ # Use wildcard for data fetch - will filter results post-request
+ fetch_kwargs[dim_id] = "*"
+ # Store the actual codes we want for post-fetch filtering
+ if "_indicator_codes_to_filter" not in fetch_kwargs:
+ fetch_kwargs["_indicator_codes_to_filter"] = set()
+ fetch_kwargs["_indicator_codes_to_filter"].update(
+ filtered_codes
+ )
+ else:
+ # URL length is OK - include codes directly
+ fetch_kwargs[dim_id] = joined_codes
+
+ # For constraint checking, limit to depth 0-1 codes when codes
+ # string is too long.
+ if len(joined_codes) > MAX_CODES_LENGTH:
+ # Get depth info for hierarchy codes
+ code_depths = dict(
+ dimension_codes_with_depth.get(dim_id, [])
+ )
+ # Filter to level 0 and 1 only (group/category codes)
+ shallow_codes = [
+ c for c in codes if code_depths.get(c, 0) <= 1
+ ]
+ # Only use shallow codes if they exist AND are in available
+ shallow_available = [
+ c for c in shallow_codes if c in available_values
+ ]
+ # Fall back to truncated codes if no shallow codes available
+ if shallow_available:
+ constraint_key = "+".join(shallow_available)
+ # Check if shallow codes are still too long
+ if len(constraint_key) > MAX_CODES_LENGTH:
+ constraint_key = "*"
+ else:
+ # Truncate to fit URL limit
+ truncated = []
+ length = 0
+ for c in filtered_codes:
+ if length + len(c) + 1 > MAX_CODES_LENGTH:
+ break
+ truncated.append(c)
+ length += len(c) + 1
+ constraint_key = (
+ "+".join(truncated) if truncated else "*"
+ )
+ # Final safety check
+ if len(constraint_key) > MAX_CODES_LENGTH:
+ constraint_key = "*"
+ else:
+ constraint_key = joined_codes
+
+ builder_key = user_override if user_override else constraint_key
+ builder.set_dimension((dim_id, builder_key))
+ else:
+ # No codes available for this dimension given prior constraints
+ # This means the table's indicators don't exist for this country/period
+ empty_dimensions.append(dim_id)
+ # Build context for error message
+ prior_selections = {
+ d: normalized_kwargs.get(d)
+ for d in dims_in_order
+ if d in normalized_kwargs
+ and dims_in_order.index(d) < dims_in_order.index(dim_id)
+ }
+ raise ValueError(
+ f"No data available: Table indicator codes do not match "
+ f"available data for dimension '{dim_id}'. "
+ f"Table has indicators: {codes}"
+ f"but given {prior_selections}, "
+ f"available indicators are: {available_values}"
+ )
+
+ # If ALL indicator dimensions had zero matches, the hierarchy doesn't apply
+ indicator_dims_set = {"INDICATOR", "BOP_ACCOUNTING_ENTRY", "SERIES", "ITEM"}
+ missing_indicator_dims = [
+ d for d in empty_dimensions if d in indicator_dims_set
+ ]
+ expected_indicator_dims = [
+ d for d in dims_in_order if d in indicator_dims_set
+ ]
+ unmapped_indicator_dims = [
+ d
+ for d in expected_indicator_dims
+ if d not in dimension_codes and d not in fetch_kwargs
+ ]
+ if unmapped_indicator_dims:
+ # The hierarchy's indicator codes couldn't be mapped
+ # this table doesn't work for this dataflow
+ raise ValueError(
+ f"Table indicators could not be mapped to dimension(s) {unmapped_indicator_dims}. "
+ f"The hierarchy's indicator codes are not compatible with dataflow '{dataflow}'. "
+ f"Hierarchy had codes from codelists: {list(codelist_to_dimension_cache.keys())}, "
+ f"but none matched the dataflow's indicator dimension."
+ )
+
+ if missing_indicator_dims and not any(
+ d in fetch_kwargs for d in indicator_dims_set
+ ):
+ # Use the same error format as query_builder validation
+ for dim_id in missing_indicator_dims:
+ invalid_values = dimension_codes.get(dim_id, [])
+ # Get available values for this dimension
+ available_options = builder.get_options_for_dimension(dim_id)
+ available_values = sorted( # type: ignore
+ {opt["value"] for opt in available_options}
+ )
+ # Build prior selections dict
+ prior_selections = {
+ d: normalized_kwargs.get(d)
+ for d in dims_in_order
+ if d in normalized_kwargs
+ and dims_in_order.index(d) < dims_in_order.index(dim_id)
+ }
+ raise ValueError(
+ f"Invalid value(s) for dimension '{dim_id}': {invalid_values}. "
+ f"Given prior selections {prior_selections}, "
+ f"available values are: {available_values}"
+ )
+
+ # Handle any dimension codes not in the standard order
+ for dim_id, codes in dimension_codes.items():
+ if dim_id not in dims_in_order and dim_id not in fetch_kwargs:
+ # Validate these codes against available options
+ available_options = builder.get_options_for_dimension(dim_id)
+ available_values = {opt["value"] for opt in available_options}
+ filtered_codes = [c for c in codes if c in available_values]
+
+ # If no exact matches, try prefix matching
+ if not filtered_codes:
+ for hier_code in codes:
+ matching_codes = [
+ av
+ for av in available_values
+ if av.startswith(hier_code + "_") or av == hier_code
+ ]
+ filtered_codes.extend(matching_codes)
+ # Deduplicate
+ filtered_codes = list(dict.fromkeys(filtered_codes))
+
+ if filtered_codes:
+ # Check if URL would be too long
+ joined_codes = "+".join(filtered_codes)
+ if len(joined_codes) > 1500:
+ fetch_kwargs[dim_id] = "*"
+ if "_indicator_codes_to_filter" not in fetch_kwargs:
+ fetch_kwargs["_indicator_codes_to_filter"] = set()
+ fetch_kwargs["_indicator_codes_to_filter"].update(
+ filtered_codes
+ )
+ else:
+ fetch_kwargs[dim_id] = joined_codes
+ else:
+ # No valid codes - this dimension has no data for given constraints
+ prior_selections = {
+ d: fetch_kwargs.get(d) or normalized_kwargs.get(d)
+ for d in dims_in_order
+ if fetch_kwargs.get(d) or normalized_kwargs.get(d)
+ }
+ raise ValueError(
+ f"No valid values for dimension '{dim_id}' given constraints. "
+ f"Table indicator codes: {codes}"
+ f"available for {prior_selections}: {sorted(available_values)}"
+ )
+
+ except (KeyError, ValueError) as e:
+ # Check if this is a validation error - don't suppress those
+ error_msg = str(e)
+ if (
+ "Invalid value(s) for dimension" in error_msg
+ or "not compatible with dataflow" in error_msg
+ ):
+ raise ValueError(error_msg) from e
+ # Fallback: use all codes if progressive validation fails
+ warnings.warn(
+ f"Progressive constraint filtering failed: {e}. Using unfiltered codes.",
+ OpenBBWarning,
+ )
+ for dim_id, codes in dimension_codes.items():
+ if dim_id not in fetch_kwargs:
+ # Check if URL would be too long
+ joined_codes = "+".join(codes)
+ if len(joined_codes) > 1500:
+ fetch_kwargs[dim_id] = "*"
+ # Store codes for post-fetch filtering
+ if "_indicator_codes_to_filter" not in fetch_kwargs:
+ fetch_kwargs["_indicator_codes_to_filter"] = set()
+ fetch_kwargs["_indicator_codes_to_filter"].update(codes)
+ else:
+ fetch_kwargs[dim_id] = joined_codes
+
+ if "INDICATOR" in fetch_kwargs and fetch_kwargs["INDICATOR"] != "*":
+ # Find a dimension after INDICATOR to query - this triggers the constraint call
+ next_dim = builder.get_next_dimension_to_select()
+ if next_dim:
+ _ = builder.get_options_for_dimension(next_dim)
+
+ # The constraints response from the LAST get_options_for_dimension call
+ # contains time_period_start and time_period_end in the annotations.
+ if start_date or end_date:
+ last_response = getattr(builder, "_last_constraints_response", None)
+ if last_response:
+ full_response = last_response.get("full_response", {})
+ data_constraints = full_response.get("data", {}).get(
+ "dataConstraints", []
+ )
+
+ for constraint in data_constraints:
+ annotations = constraint.get("annotations", [])
+ time_start = None
+ time_end = None
+
+ for ann in annotations:
+ ann_id = ann.get("id", "")
+ ann_title = ann.get("title", "")
+ if ann_id == "time_period_start":
+ time_start = ann_title
+ elif ann_id == "time_period_end":
+ time_end = ann_title
+
+ if time_start and time_end:
+
+ def parse_date(date_str: str) -> datetime | None:
+ if not date_str:
+ return None
+ if "-Q" in date_str.upper():
+ year, q = date_str.upper().split("-Q")
+ month = int(q) * 3
+ return datetime(int(year), month, 1)
+ if len(date_str) == 7:
+ return datetime.strptime(date_str, "%Y-%m")
+ if len(date_str) >= 10:
+ return datetime.strptime(date_str[:10], "%Y-%m-%d")
+ return datetime(int(date_str[:4]), 1, 1)
+
+ try:
+ avail_start = parse_date(time_start)
+ avail_end = parse_date(time_end)
+ req_start = parse_date(start_date) if start_date else None
+ req_end = parse_date(end_date) if end_date else None
+ except (ValueError, TypeError):
+ # Date parsing failed - skip validation
+ break
+
+ no_overlap = False
+ if req_start and avail_end and req_start > avail_end:
+ no_overlap = True
+ if req_end and avail_start and req_end < avail_start:
+ no_overlap = True
+
+ if no_overlap:
+ raise ValueError(
+ f"No data available for the requested time period. "
+ f"Data for this table with country "
+ f"'{fetch_kwargs.get('COUNTRY', 'N/A')}' is only available "
+ f"from {time_start} to {time_end}. "
+ f"Your request: {start_date or 'beginning'} to {end_date or 'present'}."
+ )
+ break
+
+ # Extract post-fetch filter codes before passing to fetch_data
+ indicator_codes_to_filter = fetch_kwargs.pop("_indicator_codes_to_filter", None)
+
+ # Skip validation in fetch_data since we already validated progressively
+ data_result = self.query_builder.fetch_data(
+ dataflow=dataflow,
+ start_date=start_date,
+ end_date=end_date,
+ limit=limit,
+ _skip_validation=True, # We already validated above
+ **fetch_kwargs,
+ )
+
+ # Enrich data rows with hierarchical ordering information
+ data_rows = data_result.get("data", [])
+
+ # Post-fetch filtering: if we used wildcard for INDICATOR dimension,
+ # filter to only include rows whose indicator code is in our hierarchy
+ if indicator_codes_to_filter:
+ original_count = len(data_rows)
+ data_rows = [
+ row
+ for row in data_rows
+ if row.get("INDICATOR_code") in indicator_codes_to_filter
+ or row.get("indicator_code") in indicator_codes_to_filter
+ ]
+ filtered_count = len(data_rows)
+ if filtered_count < original_count:
+ # Update the data_result with filtered rows
+ data_result["data"] = data_rows
+
+ # Define column order: hierarchy fields first, then the rest
+ priority_columns = ["order", "level", "parent_id", "series_id"]
+
+ # Determine which dimension contains the indicator code for this dataflow
+ # Different dataflows use different dimension names (INDICATOR, COICOP_1999, etc.)
+ indicator_code_fields = [
+ "INDICATOR_code",
+ "COICOP_1999_code",
+ "INDEX_TYPE_code",
+ "CPI_INDEX_TYPE_code",
+ "PRODUCTION_INDEX_code",
+ "ACTIVITY_code",
+ "PRODUCT_code",
+ "SERIES_code",
+ "ITEM_code",
+ "CLASSIFICATION_code",
+ ]
+
+ for row in data_rows:
+ # Try each possible indicator field
+ indicator_code = ""
+ for field in indicator_code_fields:
+ indicator_code = row.get(field, "")
+ if indicator_code:
+ break
+
+ if not indicator_code:
+ continue
+
+ # Multi-stage hierarchy matching:
+ # 1. Try exact series_id match (includes all dimension codes)
+ # 2. Try sorted codes match (order-agnostic)
+ # 3. Fall back to indicator code lookup
+ hier_info = None
+ row_series_id = row.get("series_id", "")
+
+ # Stage 1: Exact series_id match
+ if row_series_id:
+ hier_info = hierarchy_by_series_id.get(row_series_id)
+
+ # Stage 2: Sorted codes match (handles dimension order differences)
+ if not hier_info and row_series_id and "::" in row_series_id:
+ codes_part = row_series_id.split("::", 1)[1]
+ if codes_part:
+ sorted_codes = "_".join(sorted(codes_part.split("_")))
+ hier_info = hierarchy_by_sorted_codes.get(sorted_codes)
+
+ # Stage 2.25: Constructed sorted-codes match when series_id is missing.
+ # Some IMF responses omit or vary series_id formats, but the hierarchy encodes
+ # series IDs like "..._BOP_DB_T_D74XEF". For BOP-style tables, we can reconstruct
+ # a comparable key from the row's indicator + accounting entry codes.
+ bop_entry_code = row.get("BOP_ACCOUNTING_ENTRY_code", "") or row.get(
+ "bop_accounting_entry_code", ""
+ )
+ if (
+ not hier_info
+ and not row_series_id
+ and bop_entry_code
+ and indicator_code
+ ):
+ constructed_sorted = "_".join(sorted([indicator_code, bop_entry_code]))
+ hier_info = hierarchy_by_sorted_codes.get(constructed_sorted)
+
+ # Stage 2.5: Composite key lookup for same indicator with different parents
+ # This handles cases like "Other investment" appearing under both Assets (A_P)
+ # and Liabilities (L_P) in BOP/IIP data
+ # Stage 2.5: Composite key lookup for same indicator with different parents
+ if not hier_info and bop_entry_code and indicator_code:
+
+ def _choose_from_candidates(
+ candidates: list[dict], entry_code: str
+ ) -> dict | None:
+ if not candidates:
+ return None
+ if len(candidates) == 1:
+ return candidates[0]
+
+ entry_code_upper = entry_code.upper()
+ markers: set[str] = {entry_code_upper}
+ if entry_code_upper in {"CD_T", "NEGCD_T"}:
+ markers |= {"CD", "CREDIT"}
+ elif entry_code_upper == "DB_T":
+ markers |= {"DB", "DEBIT"}
+ elif entry_code_upper == "A_P":
+ markers |= {"ASSET", "ASSETS"}
+ elif entry_code_upper == "L_P":
+ markers |= {"LIAB", "LIABILITIES", "LIABILITY"}
+
+ for cand in candidates:
+ haystack = f"{cand.get('hierarchy_node_id','')} {cand.get('hierarchy_series_id','')}".upper()
+ if any(m in haystack for m in markers):
+ return cand
+
+ return candidates[0]
+
+ # Check for BOP_ACCOUNTING_ENTRY dimension which distinguishes Assets vs Liabilities.
+ composite_key = (indicator_code, bop_entry_code)
+ hier_info = _choose_from_candidates(
+ hierarchy_by_composite_key.get(composite_key, []), bop_entry_code
+ )
+
+ # BOP Credit/Debit rows are typically grouped under a Net parent in the IMF hierarchy.
+ # The hierarchy's discriminator for these rows is the Net node (e.g., NETCD_T), not
+ # the row's accounting entry code (CD_T/DB_T). Prefer the hierarchy's Net parent.
+ if not hier_info and bop_entry_code in {"CD_T", "DB_T"}:
+ candidate_parents = parents_by_indicator_code.get(
+ indicator_code, set()
+ )
+ net_parent: str | None = None
+ if "NETCD_T" in candidate_parents:
+ net_parent = "NETCD_T"
+ else:
+ net_like = sorted(
+ p for p in candidate_parents if p.startswith("NET")
+ )
+ if len(net_like) == 1 or net_like:
+ net_parent = net_like[0]
+
+ if net_parent:
+ hier_info = _choose_from_candidates(
+ hierarchy_by_composite_key.get(
+ (indicator_code, net_parent), []
+ ),
+ bop_entry_code,
+ )
+
+ # Stage 3: Indicator code lookup (single dimension)
+ # BUT: if we have a bop_entry_code, don't use generic indicator match
+ # as it would place the row under the wrong section (e.g., Assets instead of Liabilities)
+ if not hier_info and not bop_entry_code:
+ hier_info = hierarchy_order_map.get(indicator_code)
+
+ # Stage 4: Prefix matching for unit-suffixed codes
+ # (e.g., FSI688_TREGK_USD → FSI688_TREGK)
+ if not hier_info:
+ for hier_code, info in hierarchy_order_map.items():
+ if indicator_code.startswith(hier_code + "_"):
+ hier_info = info
+ break
+
+ if not hier_info:
+ continue
+
+ # Apply hierarchy info to row
+ row["order"] = hier_info["order"]
+ row["level"] = hier_info["level"]
+ row["parent_id"] = hier_info["parent_id"]
+ row["parent_code"] = hier_info["parent_code"]
+ row["label"] = hier_info["label"]
+ row["hierarchy_node_id"] = hier_info.get("hierarchy_node_id")
+
+ # Filter out rows that didn't get matched (no hierarchy info)
+ data_rows = [row for row in data_rows if row.get("order") is not None]
+
+ # Get the indicator codelist for name lookups
+ # The hierarchy's codelist may be a label codelist, so we need to get the
+ # actual INDICATOR dimension's codelist from the dataflow's DSD
+ indicator_codelist = {}
+ indicator_codelist_id = None # Initialize to avoid UnboundLocalError
+
+ # Get the DSD for this dataflow
+ df_obj = self.metadata.dataflows.get(dataflow, {})
+ dsd_id = df_obj.get("structureRef", {}).get("id")
+ dsd_id = df_obj.get("structureRef", {}).get("id")
+ if dsd_id:
+ dsd = self.metadata.datastructures.get(dsd_id, {})
+ dimensions = dsd.get("dimensions", []) if isinstance(dsd, dict) else []
+
+ # Find ANY indicator-like dimension and get its codelist
+ # Different dataflows use different dimension names for indicators
+ indicator_dim_candidates = [
+ "INDICATOR",
+ "CLASSIFICATION",
+ "SERIES",
+ "ITEM",
+ "PRODUCT",
+ "ACTIVITY",
+ ]
+ for dim in dimensions:
+ dim_id = dim.get("id", "")
+ if dim_id in indicator_dim_candidates or "INDICATOR" in dim_id:
+ indicator_codelist_id = self.metadata._resolve_codelist_id(
+ dataflow, dsd_id, dim_id, dim
+ )
+ if indicator_codelist_id:
+ indicator_codelist = self.metadata._codelist_cache.get(
+ indicator_codelist_id, {}
+ )
+ break
+
+ # Get sector codelist for MFS-style indicator differentiation
+ # MFS indicators like S121_A_ACO_NRES, ODCORP_A_ACO_NRES have the same label
+ # but different sector prefixes - we need to prepend sector names to differentiate
+ sector_codelist = self.metadata._codelist_cache.get("CL_SECTOR", {})
+
+ # Mark data rows as non-headers and set title from indicator name
+ for row in data_rows:
+ row["is_category_header"] = False
+
+ # Get indicator code (the actual code with unit suffix like FSI688_TREGK_USD)
+ ind_code = row.get("INDICATOR_code", "")
+
+ # Look up the proper name from the codelist
+ ind_name = indicator_codelist.get(ind_code, "")
+
+ if ind_name and ", " in ind_name:
+ name_parts = ind_name.split(", ")
+ # Unit patterns to strip from the end
+ unit_patterns = [
+ "US dollar",
+ "Percent",
+ "Euro",
+ "Domestic currency",
+ "SDR",
+ "Yen",
+ "Pound",
+ "Yuan",
+ "National currency",
+ "Basis points",
+ "Units",
+ ]
+
+ # GFS classification suffixes - these indicate data type and should be
+ # stripped but the rest of the name should be preserved
+ # e.g., "Financial assets, Stock positions" -> "Financial assets"
+ # GFS classification suffixes - these indicate recording basis/type and should be
+ # stripped, but debtor/creditor info should be KEPT as it's meaningful
+ # e.g., "Revenue, Transactions (cash basis), Cash basis" -> "Revenue"
+ # e.g., "Liabilities, Foreign Creditors, Cash basis" -> "Liabilities, Foreign Creditors"
+ gfs_recording_suffixes = [
+ "Stock positions",
+ "Transactions",
+ "Flows",
+ "Stocks",
+ "Cash basis",
+ "Transactions (cash basis of recording)",
+ "Memorandum Item",
+ ]
+
+ # FSI classification tags - these are metadata, not meaningful names
+ # e.g., "(Core FSI)", "(Additional FSI)" should be stripped
+ fsi_classification_tags = ["(Core FSI)", "(Additional FSI)"]
+
+ # Strip unit from end if present
+ if any(name_parts[-1].startswith(u) for u in unit_patterns):
+ name_parts = name_parts[:-1]
+
+ # Strip GFS recording/classification suffixes from the end (can be multiple)
+ while len(name_parts) > 1 and name_parts[-1] in gfs_recording_suffixes:
+ name_parts = name_parts[:-1]
+
+ # Strip FSI classification tags from any position
+ name_parts = [p for p in name_parts if p not in fsi_classification_tags]
+
+ # Remove duplicate consecutive parts
+ # e.g., "Loans to X, Loans to X" -> "Loans to X"
+ deduped_parts: list = []
+ for p in name_parts:
+ if not deduped_parts or deduped_parts[-1] != p:
+ deduped_parts.append(p)
+ name_parts = deduped_parts
+
+ if len(name_parts) >= 1:
+ ind_name = ", ".join(name_parts)
+
+ # For MFS-style indicators, check if we need to add sector prefix
+ # to differentiate otherwise identical labels (e.g., S121_A_ACO_NRES vs ODCORP_A_ACO_NRES)
+ sector_prefix = None
+ is_gfs_indicator = (
+ indicator_codelist_id and indicator_codelist_id.startswith("CL_GFS")
+ )
+
+ # For GFS indicators with multiple SECTOR values (SECTOR="*"), get sector from SECTOR_code
+ # This differentiates rows like "General government" vs "Central government" for same indicator
+ if is_gfs_indicator:
+ sector_code = row.get("SECTOR_code")
+ if sector_code and sector_code in sector_codelist:
+ sector_prefix = sector_code
+ elif ind_code and "_" in ind_code:
+ # For MFS indicators, extract sector prefix from indicator code
+ first_part = ind_code.split("_")[0]
+ # Check if it's a known sector code
+ if first_part in sector_codelist:
+ sector_prefix = first_part
+
+ # For codelists that store full comma-separated path labels (e.g., CL_IRFCL_INDICATOR_PUB),
+ # prefer the hierarchy label which contains just the node-level label.
+ # The hierarchy structure already provides parent-child context, so we don't need
+ # the full path repeated in each label.
+ uses_path_labels = indicator_codelist_id and (
+ indicator_codelist_id.endswith("_INDICATOR_PUB")
+ or indicator_codelist_id.endswith("_INDICATOR_DEFAULT_PUB")
+ or indicator_codelist_id == "CL_DIP_INDICATOR"
+ )
+
+ # Set the title - for path-label codelists, prefer hierarchy label
+ if uses_path_labels and row.get("label"):
+ # Hierarchy already has proper node-level label
+ row["title"] = row["label"]
+ elif ind_name:
+ # If we have a sector prefix, prepend the sector name to differentiate
+ if sector_prefix:
+ sector_name = sector_codelist.get(sector_prefix, "")
+ if sector_name:
+ row["title"] = f"{sector_name}, {ind_name}"
+ else:
+ row["title"] = ind_name
+ else:
+ row["title"] = ind_name
+
+ # Fallback: prefer hierarchy label if no title was set.
+ # This keeps output consistent with the IMF hierarchy (source of truth)
+ # when codelist lookups are unavailable or incomplete.
+ if not row.get("title") and row.get("label"):
+ row["title"] = row["label"]
+
+ # Final fallback: if still no title, use the indicator code itself.
+ # This ensures every row has some identifying label.
+ if not row.get("title") and ind_code:
+ readable_code = ind_code.replace("_", " ")
+ row["title"] = readable_code
+
+ # For BOP data, append the accounting entry type (Credit/Debit/Net) to title
+ # This differentiates rows like "Goods, Credit" vs "Goods, Debit" vs "Goods"
+ bop_entry = row.get("BOP_ACCOUNTING_ENTRY_code")
+ if bop_entry and row.get("title"):
+ bop_entry_labels = {
+ "CD_T": "Credit",
+ "DB_T": "Debit",
+ "NETCD_T": "Net",
+ "A_T": "Assets",
+ "L_T": "Liabilities",
+ "A_P": "Assets",
+ "L_P": "Liabilities",
+ "A_NFA_T": "Assets (excl. reserves)",
+ "L_NIL_T": "Liabilities (incl. net incurrence)",
+ "NNAFANIL_T": "Net (Assets excl. reserves less Liabilities)",
+ }
+ entry_label = bop_entry_labels.get(bop_entry)
+ if entry_label:
+ row["title"] = f"{row['title']}, {entry_label}"
+
+ # For IIP data without explicit BOP_ACCOUNTING_ENTRY, extract asset/liability
+ # context from series_id pattern (e.g., _A_P_ for Assets, _L_P_ for Liabilities)
+ # This differentiates rows like "Other investment (Assets)" vs
+ # "Other investment (Liabilities)" that would otherwise have identical titles
+ if not bop_entry and row.get("title"):
+ series_id = row.get("series_id", "")
+ title_lower = row["title"].lower()
+ # Check for IIP-style series_id patterns
+ # _A_P_ or _A_P at end = Assets position
+ # _L_P_ or _L_P at end = Liabilities position
+ has_asset_context = "asset" in title_lower
+ has_liab_context = "liabilit" in title_lower
+
+ if not has_asset_context and not has_liab_context:
+ # Series ID pattern: IMF_STA_IIP_A_P_xxx (Assets) or IMF_STA_IIP_L_P_xxx (Liabilities)
+ if "_IIP_A_P_" in series_id or "_IIP_A_P" in series_id:
+ row["title"] = f"{row['title']} (Assets)"
+ elif "_IIP_L_P_" in series_id or "_IIP_L_P" in series_id:
+ row["title"] = f"{row['title']} (Liabilities)"
+
+ # For IIPCC currency composition data, append currency to title
+ # This differentiates rows by currency (Euro, US dollar, Other currencies, etc.)
+ currency_code = row.get("CURRENCY_code")
+ currency_label = row.get("CURRENCY")
+ unit_code = row.get("unit_code") or row.get("UNIT_MEASURE_code")
+ if currency_code and currency_label and row.get("title"):
+ # Don't append if currency is the same as the unit (e.g., both USD)
+ skip_currencies = {"_T", "W0", "W1", "W2", "ALL"}
+ if currency_code not in skip_currencies and currency_code != unit_code:
+ row["title"] = f"{row['title']} ({currency_label})"
+
+ # For CPI data with multiple INDEX_TYPEs (CPI, HICP, etc.), append the index type
+ if (
+ dataflow == "CPI"
+ and (index_type_code := row.get("INDEX_TYPE_code"))
+ and index_type_code != "CPI"
+ and row.get("title")
+ ):
+ row["title"] = f"{row['title']} ({index_type_code})"
+
+ # Set indicator_code from INDICATOR_code if not already set
+ # This ensures data rows have indicator_code just like category headers
+ if not row.get("indicator_code"):
+ row["indicator_code"] = ind_code
+
+ # Extract unit for data rows when unit is missing
+ # This handles dataflows like QGFS/GFS where unit is embedded in indicator code
+ # and CPI where unit comes from TYPE_OF_TRANSFORMATION
+ cl_unit_cache = self.metadata._codelist_cache.get("CL_UNIT", {})
+ # Skip special aggregate codes that conflict with currency codes
+ special_aggregate_codes = {"ALL", "W0", "W1", "W2"}
+ for row in data_rows:
+ if not row.get("unit"):
+ # Try TYPE_OF_TRANSFORMATION or TRANSFORMATION fields first (CPI, etc.)
+ row["unit"] = row.get("TYPE_OF_TRANSFORMATION") or row.get(
+ "TRANSFORMATION"
+ )
+
+ # Still no unit? Try extracting from indicator code suffix (QGFS/GFS)
+ # BUT: Only do this for GFS-style dataflows, NOT for MFS where suffixes
+ # like SVC mean "Survey Vertical Check", not currency codes
+ is_gfs_dataflow = dataflow in {"GFS", "QGFS", "GFSR", "GFSY"}
+ if not row.get("unit") and is_gfs_dataflow:
+ ind_code = row.get("indicator_code") or row.get("INDICATOR_code", "")
+ if ind_code:
+ parts = ind_code.rsplit("_", 1)
+ if len(parts) == 2:
+ unit_code = parts[1]
+ # Only use as unit if it's a real unit code in CL_UNIT
+ # Skip special aggregate codes like ALL, W0, W1, W2 which are
+ # dimension codes (e.g., "All entities") not unit codes
+ if (
+ unit_code not in special_aggregate_codes
+ and unit_code in cl_unit_cache
+ ):
+ row["unit"] = cl_unit_cache[unit_code]
+
+ matched_orders = {row.get("order") for row in data_rows}
+ existing_net_base_labels: set[str] = set()
+ for row in data_rows:
+ title = row.get("title", "")
+ # Find ", Net" anywhere in title (not just at end)
+ net_idx = title.find(", Net")
+ if net_idx > 0:
+ # Everything before ", Net" is the base label
+ existing_net_base_labels.add(title[:net_idx])
+
+ for ind in table_structure["indicators"]:
+ order = ind.get("order")
+ if order is not None and order not in matched_orders:
+ # Use depth from hierarchy metadata, fall back to _calculate_depth only if needed
+ # This matches the pattern used in the data matching loop above
+ depth = ind.get("depth")
+ if depth is None:
+ depth = _calculate_depth(ind, indicator_by_code)
+ indicator_code = ind.get("indicator_code", "")
+ node_id = ind.get("id", "")
+ base_label = ind.get("label", ind.get("name", ""))
+
+ # Skip creating synthetic header if there's a matching ", Net" data row
+ # In BOP-style tables, "Goods, Net" serves as the header for the Goods group
+ # so we don't need a separate "Goods" header
+ if base_label in existing_net_base_labels:
+ continue
+
+ # If label is just the indicator code (no proper name), try to look up
+ # a proper name from the codelist. The codelist may have unit-suffixed
+ # codes (e.g., "AQ1_XDC") while hierarchy uses base codes (e.g., "AQ1").
+ if base_label == indicator_code and indicator_code:
+ # Try direct lookup first
+ codelist_name = indicator_codelist.get(indicator_code, "")
+ if not codelist_name:
+ # Try prefix matching - find any code that starts with this code
+ for cl_code, cl_name in indicator_codelist.items():
+ if cl_code.startswith(indicator_code + "_"):
+ codelist_name = cl_name
+ break
+ if codelist_name:
+ # Process the codelist name the same way as data row titles
+ if ", " in codelist_name:
+ name_parts = codelist_name.split(", ")
+ unit_patterns = [
+ "US dollar",
+ "Percent",
+ "Euro",
+ "Domestic currency",
+ "SDR",
+ "Yen",
+ "Pound",
+ "Yuan",
+ "National currency",
+ "Basis points",
+ "Units",
+ ]
+ fsi_tags = ["(Core FSI)", "(Additional FSI)"]
+ # Strip unit suffix
+ if any(name_parts[-1].startswith(u) for u in unit_patterns):
+ name_parts = name_parts[:-1]
+ # Strip FSI classification tags
+ name_parts = [p for p in name_parts if p not in fsi_tags]
+ # Deduplicate consecutive parts
+ deduped: list = []
+ for p in name_parts:
+ if not deduped or deduped[-1] != p:
+ deduped.append(p)
+ base_label = ", ".join(deduped) if deduped else base_label
+ else:
+ base_label = codelist_name
+
+ # Strip GFS classification suffixes from header labels
+ # These come from codelists and may have suffixes like "Transactions, Cash basis, Memorandum Item"
+ gfs_header_suffixes = [
+ "Stock positions",
+ "Transactions",
+ "Flows",
+ "Stocks",
+ "Cash basis",
+ "Transactions (cash basis of recording)",
+ "Memorandum Item",
+ ]
+ if base_label and ", " in base_label:
+ label_parts = base_label.split(", ")
+ while (
+ len(label_parts) > 1 and label_parts[-1] in gfs_header_suffixes
+ ):
+ label_parts = label_parts[:-1]
+ base_label = ", ".join(label_parts)
+
+ # Try to find scale/unit from first child data row
+ scale = None
+ unit = None
+ child_indicator_code = None
+ # Look for children by finding data rows with matching parent_id
+ # parent_id on data rows is the node ID (e.g., "BYINS"), not indicator_code
+ for row in data_rows:
+ if row.get("parent_id") == node_id:
+ scale = row.get("scale")
+ unit = (
+ row.get("unit")
+ or row.get("UNIT")
+ or row.get("TYPE_OF_TRANSFORMATION")
+ or row.get("TRANSFORMATION")
+ )
+ child_indicator_code = row.get("indicator_code") or row.get(
+ "INDICATOR_code"
+ )
+ break
+
+ # If unit is still None, try to extract from indicator code suffix
+ # For dataflows like QGFS/GFS, indicator codes end with unit suffix
+ # e.g., G1_T_XDC -> XDC (Domestic currency), G1_T_USD -> USD
+ cl_unit = self.metadata._codelist_cache.get("CL_UNIT", {})
+ # Skip special aggregate codes that conflict with currency codes
+ special_aggregate_codes = {"ALL", "W0", "W1", "W2"}
+ # Only extract unit from code suffix for GFS/QGFS dataflows
+ is_gfs_dataflow = dataflow in {"GFS", "QGFS", "GFSR", "GFSY"}
+ if not unit and is_gfs_dataflow:
+ # Try: child code -> header's own code -> parent code
+ parent_id = ind.get("parent_id", "")
+ codes_to_try = [
+ child_indicator_code,
+ indicator_code,
+ parent_id, # Parent might have the unit suffix
+ ]
+ for code_to_parse in codes_to_try:
+ if code_to_parse:
+ parts = code_to_parse.rsplit("_", 1)
+ if len(parts) == 2:
+ unit_code = parts[1]
+ # Only use as unit if it's a real unit code in CL_UNIT
+ # Skip special aggregate codes like ALL, W0, W1, W2 which are
+ # dimension codes (e.g., "All entities") not unit codes
+ if (
+ unit_code not in special_aggregate_codes
+ and unit_code in cl_unit
+ ):
+ unit = cl_unit[unit_code]
+ break
+
+ # Format header title with scale and unit if available
+ valid_scale = scale and str(scale) != "nan"
+ valid_unit = unit and str(unit) != "nan"
+
+ if valid_scale and valid_unit:
+ header_title = f"{base_label} ({scale}, {unit})"
+ elif valid_scale:
+ header_title = f"{base_label} ({scale})"
+ elif valid_unit:
+ header_title = f"{base_label} ({unit})"
+ else:
+ header_title = base_label
+
+ # For MFS-style indicators, prepend sector name to differentiate
+ # headers with the same label but different sector prefixes
+ if indicator_code and "_" in indicator_code:
+ first_part = indicator_code.split("_")[0]
+ if first_part in sector_codelist:
+ sector_name = sector_codelist.get(first_part, "")
+ if sector_name:
+ header_title = f"{sector_name}, {header_title}"
+
+ # This is a group/category header without its own data point
+ header_row = {
+ "order": order,
+ "level": depth, # Use calculated depth for proper indentation
+ "parent_id": ind.get("parent_id"),
+ "hierarchy_node_id": node_id, # Hierarchy node ID for parent matching
+ "series_id": ind.get("series_id", ""),
+ "title": header_title,
+ "indicator_code": indicator_code,
+ "is_category_header": True, # Flag to identify headers
+ "scale": scale,
+ "unit": unit,
+ }
+ data_rows.append(header_row)
+
+ # Handle duplicate orders - create sub-orders for rows with same base order
+ # This is needed for multi-dimension data like IIPCC where different currencies
+ # map to the same indicator hierarchy node but need distinct rows
+ # Convert orders like [2, 2, 2, 2, 2] to [2.0, 2.1, 2.2, 2.3, 2.4]
+ order_series_counts: dict = defaultdict(set)
+ order_series_idx: dict = {}
+ # First pass: count unique series_ids per order
+ for row in data_rows:
+ order = row.get("order")
+ series_id = row.get("series_id", "")
+ if order is not None:
+ order_series_counts[order].add(series_id)
+ # Second pass: assign sub-orders only when multiple series share an order
+ for row in data_rows:
+ order = row.get("order")
+ series_id = row.get("series_id", "")
+ if order is not None and len(order_series_counts[order]) > 1:
+ # Multiple series have this order - assign sub-order by series_id
+ key = (order, series_id) # type: ignore
+ if key not in order_series_idx:
+ order_series_idx[key] = len(
+ [k for k in order_series_idx if k[0] == order]
+ )
+ sub_idx = order_series_idx[key]
+ # Use float to create sub-ordering: 2.0, 2.001, 2.002, etc.
+ row["order"] = float(order) + (sub_idx * 0.001)
+
+ # Sort by order
+ data_rows.sort(key=lambda x: x.get("order", float("inf")))
+
+ # Remove duplicate/unnecessary fields and reorder columns
+ cleaned_rows: list = []
+ for row in data_rows:
+ # Remove indicator_codes (internal), label (duplicate of dimension labels), title (redundant)
+ cleaned_row = {
+ k: v for k, v in row.items() if k not in ["indicator_codes", "label"]
+ }
+
+ # Reorder: priority columns first, then rest alphabetically
+ ordered_row: dict = {}
+ for col in priority_columns:
+ if col in cleaned_row:
+ ordered_row[col] = cleaned_row.pop(col)
+
+ # Add remaining columns in their original order
+ ordered_row.update(cleaned_row)
+ cleaned_rows.append(ordered_row)
+
+ # Extract series-level and dataset-level metadata
+ all_metadata = data_result.get("metadata", {})
+ dataset_metadata = all_metadata.pop("dataset", {})
+
+ # Add dataset metadata to table_metadata
+ if dataset_metadata:
+ table_metadata["dataflow_name"] = dataset_metadata.get("dataflow_name", "")
+ table_metadata["dataflow_description"] = dataset_metadata.get(
+ "dataflow_description", ""
+ )
+ for key in [
+ "keywords",
+ "source",
+ "source_url",
+ "publisher",
+ "department",
+ "contact_point",
+ "license",
+ "suggested_citation",
+ "short_source_citation",
+ "full_source_citation",
+ "publication_date",
+ "last_updated",
+ "methodology_notes",
+ "topics",
+ ]:
+ if key in dataset_metadata:
+ table_metadata[key] = dataset_metadata[key]
+
+ return {
+ "table_metadata": table_metadata,
+ "data": cleaned_rows, # List of dicts with hierarchy ordering
+ "series_metadata": all_metadata, # Series-level metadata keyed by series_id
+ }
diff --git a/openbb_platform/providers/imf/openbb_imf/utils/table_presentation.py b/openbb_platform/providers/imf/openbb_imf/utils/table_presentation.py
new file mode 100644
index 00000000000..868bc2f6e1e
--- /dev/null
+++ b/openbb_platform/providers/imf/openbb_imf/utils/table_presentation.py
@@ -0,0 +1,2408 @@
+"""Presentation table utilities for IMF provider.
+
+This module handles the complex pivot/presentation logic for transforming
+IMF economic indicator data into hierarchical presentation tables.
+"""
+
+# pylint: disable=C0302,R0912,R0911,R0914,R0915,R1702
+# flake8: noqa: PLR0912,PLR0911
+
+import re
+import warnings
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ import pandas as pd
+
+
+def extract_unit_from_label(label: str) -> str | None:
+ """Extract unit information from an indicator label.
+
+ Parses indicator labels to extract unit suffixes like:
+ - "Per capita, US dollar" from "Exporter real gross domestic product, Per capita, US dollar"
+ - "Percent" from "GDP growth rate, Percent"
+ - "US Dollar, Millions" from "Trade balance (US Dollar, Millions)"
+
+ Parameters
+ ----------
+ label : str
+ The indicator label to parse.
+
+ Returns
+ -------
+ str | None
+ The extracted unit string, or None if no unit found.
+ """
+ if not label:
+ return None
+
+ # Check for parenthetical unit at end: "(US Dollar, Millions)" or "(Domestic currency)"
+ if label.endswith(")"):
+ paren_start = label.rfind(" (")
+ if paren_start > 0:
+ suffix_content = label[paren_start + 2 : -1]
+ # Check if it looks like a unit (contains currency or scale keywords)
+ unit_keywords = [
+ "dollar",
+ "Dollar",
+ "USD",
+ "Euro",
+ "euro",
+ "Yen",
+ "yen",
+ "Percent",
+ "percent",
+ "%",
+ "Millions",
+ "Billions",
+ "Thousands",
+ "Units",
+ "Per capita",
+ "per capita",
+ "Index",
+ "index",
+ "Domestic currency",
+ "National currency",
+ "currency",
+ "SDR",
+ ]
+ if any(kw in suffix_content for kw in unit_keywords):
+ return suffix_content
+
+ # Check for comma-separated unit suffix at the end of the label
+ # Look for the last comma-separated part and check if it's a unit
+ parts = label.rsplit(", ", 1)
+ if len(parts) == 2:
+ last_part = parts[1]
+ last_part_lower = last_part.lower()
+ # "per" indicates a rate/unit (e.g., "US cents per pound", "dollars per metric tonne")
+ if " per " in last_part_lower:
+ return last_part
+ # Check for other unit keywords
+ unit_keywords_lower = [
+ "dollar",
+ "percent",
+ "index",
+ "ratio",
+ "currency",
+ "capita",
+ "cent",
+ ]
+ if any(kw in last_part_lower for kw in unit_keywords_lower):
+ return last_part
+
+ return None
+
+
+def parse_unit_and_scale(unit_string: str | None) -> tuple[str | None, str | None]:
+ """Parse a combined unit string into separate scale and unit components.
+
+ Many IMF indicator labels embed both scale and unit in a single suffix like:
+ - "Per capita, US dollar" → scale="Per capita", unit="US dollar"
+ - "Millions, US dollar" → scale="Millions", unit="US dollar"
+ - "US Dollar, Millions" → scale="Millions", unit="US Dollar"
+ - "US dollars per metric tonne" → unit="US dollars", scale="per metric tonne"
+ - "US cents per pound" → unit="US cents", scale="per pound"
+ - "Percent" → scale=None, unit="Percent"
+ - "US dollar" → scale=None, unit="US dollar"
+ - "Index" → scale=None, unit="Index"
+
+ Parameters
+ ----------
+ unit_string : str | None
+ The combined unit/scale string extracted from a label.
+
+ Returns
+ -------
+ tuple[str | None, str | None]
+ A tuple of (unit, scale) where:
+ - unit: The unit of measurement (e.g., "US dollar", "Percent")
+ - scale: The scale/multiplier (e.g., "Per capita", "Millions", "per metric tonne")
+ """
+ if not unit_string:
+ return None, None
+
+ # Check for "Unit of ..." pattern (e.g., "Percent of exports of goods and services")
+ # Common units that can be followed by "of ..."
+ unit_of_patterns = ["Percent of ", "Ratio of ", "Index of ", "Number of "]
+ for pattern in unit_of_patterns:
+ if unit_string.startswith(pattern):
+ unit = pattern.replace(" of ", "").strip()
+ scale = "of " + unit_string[len(pattern) :].strip().title()
+ return unit, scale
+
+ # Check for "X per Y" pattern (e.g., "US dollars per metric tonne")
+ # Split on " per " - unit is before, scale is "per ..."
+ if " per " in unit_string.lower():
+ # Find the position case-insensitively
+ lower_str = unit_string.lower()
+ per_idx = lower_str.find(" per ")
+ if per_idx > 0:
+ unit = unit_string[:per_idx].strip()
+ scale = unit_string[per_idx + 1 :].strip().title()
+ return unit, scale
+
+ # Scale indicators that appear before the unit (e.g., "Per capita, US dollar")
+ scale_prefixes = [
+ "Per capita, ",
+ "Percent, ",
+ "Millions, ",
+ "Billions, ",
+ "Thousands, ",
+ "Mean, ",
+ ]
+
+ for prefix in scale_prefixes:
+ if unit_string.startswith(prefix):
+ scale = prefix.rstrip(", ")
+ unit = unit_string[len(prefix) :]
+ return unit, scale
+
+ # Scale indicators that appear after the unit (e.g., "US Dollar, Millions")
+ scale_suffixes = [
+ ", Millions",
+ ", Billions",
+ ", Thousands",
+ ", Per capita",
+ ]
+
+ for suffix in scale_suffixes:
+ if unit_string.endswith(suffix):
+ scale = suffix.lstrip(", ")
+ unit = unit_string[: -len(suffix)]
+ return unit, scale
+
+ # Check for pattern: "scale_description, Unit" (e.g., "95 percent interval - lower bound, Percent")
+ # The unit is after the last comma, scale is before it
+ unit_keywords = [
+ "Percent",
+ "US dollar",
+ "US Dollar",
+ "Index",
+ "Ratio",
+ "SDR",
+ "EUR",
+ "Domestic currency",
+ "National currency",
+ "Euro",
+ ]
+ last_comma = unit_string.rfind(", ")
+ if last_comma > 0:
+ potential_unit = unit_string[last_comma + 2 :]
+ if potential_unit in unit_keywords:
+ scale = unit_string[:last_comma]
+ return potential_unit, scale
+
+ # If no scale prefix/suffix, the whole string is the unit
+ # But check if it's actually a scale-only value
+ scale_only_values = ["Per capita", "Millions", "Billions", "Thousands"]
+ if unit_string in scale_only_values:
+ return None, unit_string
+
+ return unit_string, None
+
+
+def strip_title_suffix(title: str) -> str:
+ """Strip scale/unit and type suffixes from title.
+
+ Handles:
+ - Trailing unit/type suffixes: ", Transactions", ", Stocks", ", Flows"
+ - Parenthetical unit patterns: "(Millions, US Dollar)"
+
+ Preserves dimension labels like "(Euro)", "(Foreign Currency)", "(Credit)".
+
+ Parameters
+ ----------
+ title : str
+ The raw title to process.
+
+ Returns
+ -------
+ str
+ The title with suffixes stripped.
+ """
+ # Strip trailing unit/type suffixes like ", Transactions", ", Stocks"
+ unit_suffixes = [", Transactions", ", Stocks", ", Flows"]
+ for suffix in unit_suffixes:
+ if title.endswith(suffix):
+ title = title[: -len(suffix)]
+ break
+ # Only strip parenthetical suffix if it's a known unit/scale pattern
+ # Preserve dimension labels like (Euro), (Foreign Currency), (Credit), etc.
+ if title.endswith(")"):
+ paren_start = title.rfind(" (")
+ if paren_start > 0:
+ suffix_content = title[paren_start + 2 : -1]
+ # Only strip if it looks like a unit/scale suffix
+ # Common patterns: "Millions", "Billions", "Percent", etc.
+ # Note: "Domestic currency" is a currency dimension label, not a unit
+ unit_patterns = [
+ "Millions",
+ "Billions",
+ "Thousands",
+ "Percent",
+ "Units",
+ ]
+ # Also handle compound patterns like "US Dollar, Millions"
+ # But NOT pure unit patterns alone (those describe the data)
+ is_unit_suffix = any(pattern in suffix_content for pattern in unit_patterns)
+ if is_unit_suffix:
+ title = title[:paren_start]
+ return title
+
+
+def format_unit_suffix(unit: str | None, scale: str | None) -> str:
+ """Format unit and scale into a display suffix.
+
+ Parameters
+ ----------
+ unit : str | None
+ The unit of measurement (e.g., "US Dollar", "Percent").
+ scale : str | None
+ The scale/multiplier (e.g., "Millions", "Billions").
+
+ Returns
+ -------
+ str
+ A formatted suffix like "(Percent)" or "(US Dollar, Millions)",
+ or empty string if no meaningful unit/scale provided.
+ """
+ parts = []
+ # Ensure unit and scale are valid strings (not None, nan, or other types)
+ if unit and isinstance(unit, str) and unit not in ("-", "nan", ""):
+ parts.append(unit)
+ if scale and isinstance(scale, str) and scale not in ("Units", "-", "nan", ""):
+ parts.append(scale)
+ if parts:
+ return f" ({', '.join(parts)})"
+ return ""
+
+
+UNIT_SCALE_PATTERNS = {
+ "Millions",
+ "Billions",
+ "Thousands",
+ "Percent",
+ "Units",
+}
+
+# Patterns that indicate a unit value in comma-separated titles
+# e.g., "Lamb, Unit prices, US cents per pound"
+UNIT_KEYWORDS = {
+ "dollars",
+ "cents",
+ "pound",
+ "tonne",
+ "ton",
+ "meter",
+ "metre",
+ "liter",
+ "litre",
+ "barrel",
+ "ounce",
+ "kilogram",
+ "gram",
+ "index",
+ "percent",
+ "ratio",
+ "rate",
+ "number",
+ "per",
+}
+
+
+def extract_unit_scale_from_title(title: str) -> tuple[str | None, str | None]:
+ """Extract unit/scale from a trailing parenthetical suffix or comma-separated part.
+
+ Handles two patterns:
+ 1. Parenthetical: "Title (US Dollar, Millions)" -> unit="US Dollar", scale="Millions"
+ 2. Comma-separated: "Lamb, Unit prices, US cents per pound" -> unit="US cents per pound"
+
+ Only returns values when the suffix contains known unit/scale keywords to
+ avoid over-eager parsing for titles that merely contain contextual labels.
+ """
+ if not title:
+ return None, None
+
+ unit_val: str | None = None
+ scale_val: str | None = None
+
+ # First try parenthetical pattern: "Title (unit, scale)"
+ if title.endswith(")"):
+ paren_start = title.rfind(" (")
+ if paren_start > 0:
+ suffix_content = title[paren_start + 2 : -1]
+ if any(pattern in suffix_content for pattern in UNIT_SCALE_PATTERNS):
+ parts = [p.strip() for p in suffix_content.split(",") if p.strip()]
+
+ if len(parts) == 1:
+ only = parts[0]
+ if only in UNIT_SCALE_PATTERNS:
+ scale_val = only
+ else:
+ unit_val = only
+ elif len(parts) >= 2:
+ unit_val = parts[0]
+ scale_val = parts[1]
+
+ return unit_val, scale_val
+
+ # Try comma-separated pattern: "Title, Unit prices, US cents per pound"
+ # Look for the last comma-separated part that contains unit keywords
+ parts = [p.strip() for p in title.split(",")]
+ if len(parts) >= 2:
+ # Check last part for unit keywords
+ last_part = parts[-1].lower()
+ if any(kw in last_part for kw in UNIT_KEYWORDS):
+ unit_val = parts[-1].strip()
+ return unit_val, scale_val
+
+ return None, None
+
+
+def is_bop_suffix_only(text: str) -> bool:
+ """Check if text is only a BOP-style suffix that lacks meaningful context.
+
+ Returns True if the text is just:
+ - Net, Credit, Debit, Assets, Liabilities (with optional parenthetical)
+ - Credit/Revenue, Debit/Expenditure
+ - These terms should NOT stand alone as titles
+ - Or if it starts with a lowercase word/preposition (fragment of larger phrase)
+
+ Parameters
+ ----------
+ text : str
+ The text to check.
+
+ Returns
+ -------
+ bool
+ True if text is just a BOP suffix without meaningful context.
+ """
+ if not text:
+ return False
+
+ # Normalize: strip leading comma/space, and trailing parenthetical
+ normalized = text.lstrip(", :")
+ if not normalized:
+ return True
+
+ # Check if starts with lowercase (indicates a fragment, not a proper category)
+ # e.g., "excluding reserves and related items, Net" is a fragment
+ first_word = normalized.split()[0] if normalized.split() else ""
+ # If first word is lowercase and not a number, it's a fragment
+ if first_word and first_word[0].islower() and not first_word[0].isdigit():
+ return True
+
+ # Strip trailing parenthetical for BOP term check
+ check_text = normalized
+ if check_text.endswith(")"):
+ paren_start = check_text.rfind(" (")
+ if paren_start > 0:
+ check_text = check_text[:paren_start].strip()
+
+ # Check if what remains is just a BOP suffix term
+ bop_only_terms = {
+ "net",
+ "credit",
+ "debit",
+ "assets",
+ "liabilities",
+ "credit/revenue",
+ "debit/expenditure",
+ "assets (excl. reserves)",
+ "liabilities (incl. net incurrence)",
+ }
+ return check_text.lower() in bop_only_terms
+
+
+class HierarchyContext:
+ """Manages hierarchical order/title/level data for title stripping.
+
+ This class encapsulates the logic for finding ancestor context in
+ hierarchical IMF table data, enabling title simplification by stripping
+ redundant prefixes and suffixes.
+
+ Parameters
+ ----------
+ order_title_level : list[tuple[int | float, str, int, bool]]
+ Sorted list of (order, title, level, is_header) tuples representing
+ the hierarchical structure.
+ """
+
+ def __init__(
+ self, order_title_level: list[tuple[int | float, str, int, bool]]
+ ) -> None:
+ """Initialize with sorted list of hierarchy metadata."""
+ self.order_title_level = sorted(order_title_level, key=lambda x: x[0])
+
+ def _get_true_siblings(
+ self, target_order: int | float, target_level: int
+ ) -> list[tuple[int | float, str]]:
+ """Get siblings that share the same parent (consecutive rows at same level).
+
+ Siblings are rows at the same level that appear between parent boundaries.
+ A parent boundary is any row at a lower level (parent) or a row at same
+ level after a gap (different parent group).
+ """
+ # Find target index
+ target_idx = None
+ for i, (order, _, level, _) in enumerate(self.order_title_level):
+ if order == target_order:
+ target_idx = i
+ break
+
+ if target_idx is None:
+ return []
+
+ siblings: list[tuple[int | float, str]] = []
+
+ # Look backwards to find start of sibling group (stop at parent or start)
+ start_idx = target_idx
+ for i in range(target_idx - 1, -1, -1):
+ _, _, level, _ = self.order_title_level[i]
+ if level < target_level:
+ # Found parent, siblings start after this
+ break
+ if level == target_level:
+ start_idx = i
+ # Skip higher levels (children of previous siblings)
+
+ # Look forwards to find end of sibling group (stop at parent or end)
+ end_idx = target_idx
+ for i in range(target_idx + 1, len(self.order_title_level)):
+ _, _, level, _ = self.order_title_level[i]
+ if level < target_level:
+ # Found next parent, siblings end before this
+ break
+ if level == target_level:
+ end_idx = i
+ # Skip higher levels (children of siblings)
+
+ # Collect all siblings (rows at target_level between start and end)
+ for i in range(start_idx, end_idx + 1):
+ order, title, level, _ = self.order_title_level[i]
+ if level == target_level:
+ siblings.append((order, title))
+
+ return siblings
+
+ def find_sibling_common_prefix(
+ self,
+ target_order: int | float,
+ target_title: str,
+ displayed_orders: set[int | float] | None = None,
+ ) -> str | None:
+ """Find common prefix shared by siblings at the same level.
+
+ When multiple rows at the same level share a common prefix like
+ "Depository corporations, Liabilities, ", this prefix is redundant
+ and can be stripped to show cleaner titles.
+
+ Only considers prefixes that end with ", " to avoid partial word matches.
+ Requires at least 3 siblings sharing the prefix to consider it redundant.
+
+ Parameters
+ ----------
+ target_order : int | float
+ The order value of the target row.
+ target_title : str
+ The title to find a common prefix for.
+ displayed_orders : set[int | float] | None
+ Set of order values that will actually be displayed.
+
+ Returns
+ -------
+ str | None
+ The common prefix including trailing ", " to strip, or None.
+ """
+ # Find target's level
+ target_level = None
+ for order, _, level, _ in self.order_title_level:
+ if order == target_order:
+ target_level = level
+ break
+
+ if target_level is None:
+ return None
+
+ # Get true siblings (respecting parent boundaries)
+ siblings = self._get_true_siblings(target_order, target_level)
+ if len(siblings) < 3:
+ return None
+
+ # Filter to displayed siblings only
+ if displayed_orders is not None:
+ siblings = [(o, t) for o, t in siblings if o in displayed_orders]
+ if len(siblings) < 3:
+ return None
+
+ # Split titles by ", " but preserve pattern for later reconstruction
+ def get_prefix_segments(title: str) -> list[str]:
+ """Split title into prefix segments (each ending with ', ')."""
+ segments = []
+ parts = re.split(r"(, )", title)
+ current = ""
+ for _, part in enumerate(parts):
+ current += part
+ if part == ", ":
+ segments.append(current)
+ current = ""
+ return segments
+
+ # Get segments for all sibling titles
+ sibling_titles = [t for _, t in siblings if t]
+ if len(sibling_titles) < 3:
+ return None
+
+ # Find the longest common prefix segments
+ all_segments = [get_prefix_segments(t) for t in sibling_titles]
+ if not all_segments:
+ return None
+
+ # Find how many prefix segments are shared by siblings
+ min_segments = min(len(s) for s in all_segments)
+ common_count = 0
+ for i in range(min_segments):
+ first_seg = all_segments[0][i].lower()
+ if all(segs[i].lower() == first_seg for segs in all_segments):
+ common_count += 1
+ else:
+ break
+
+ if common_count == 0:
+ return None
+
+ # Build the common prefix string
+ common_prefix = "".join(all_segments[0][:common_count])
+
+ # Make sure the target title starts with this prefix
+ if not target_title.lower().startswith(common_prefix.lower()):
+ return None
+
+ # SAFEGUARD: Don't strip common prefix if ALL siblings end with BOP suffixes
+ # (Credit/Debit/Net, Credit/Revenue, Debit/Expenditure, Assets, Liabilities)
+ # In this case the common prefix IS the meaningful category name
+ bop_endings = (
+ ", Credit",
+ ", Debit",
+ ", Net",
+ ", Credit/Revenue",
+ ", Debit/Expenditure",
+ ", Assets",
+ ", Liabilities",
+ " Assets",
+ " Liabilities",
+ )
+ if all(
+ any(t.endswith(ending) for ending in bop_endings) for t in sibling_titles
+ ):
+ return None
+
+ # Return the actual prefix from the target (preserve case)
+ return target_title[: len(common_prefix)]
+
+ def find_bop_group_prefix(
+ self,
+ target_order: int | float,
+ target_title: str,
+ displayed_orders: set[int | float] | None = None,
+ ) -> str | None:
+ """Find BOP-style group prefix for Credit/Debit/Net patterns.
+
+ BOP tables have patterns like:
+ - "Goods, Credit" (order 3.000)
+ - "Goods, Debit" (order 3.001)
+ - "Goods, Net" (order 3.002)
+
+ These are all at the same level but form a logical group.
+ When we see "Goods, Credit", we should strip "Goods, " because
+ "Goods, Net" (which becomes "Goods" after Net stripping) serves
+ as the implicit header for this group.
+
+ Parameters
+ ----------
+ target_order : int | float
+ The order value of the target row.
+ target_title : str
+ The title to find a group prefix for.
+ displayed_orders : set[int | float] | None
+ Set of order values that will actually be displayed.
+
+ Returns
+ -------
+ str | None
+ The group prefix to strip (e.g., "Goods, "), or None.
+ """
+ # BOP suffixes that indicate a grouped entry
+ bop_suffixes = [", Credit", ", Debit", ", Net"]
+
+ # Check if this title ends with a BOP suffix
+ base_name = None
+ our_suffix = None
+ for suffix in bop_suffixes:
+ if target_title.endswith(suffix):
+ base_name = target_title[: -len(suffix)]
+ our_suffix = suffix
+ break
+
+ if base_name is None:
+ return None
+
+ # For ", Net" suffix, don't strip - it becomes the group header
+ if our_suffix == ", Net":
+ return None
+
+ # Find siblings with the same base name
+ target_level = None
+ for order, _, level, _ in self.order_title_level:
+ if order == target_order:
+ target_level = level
+ break
+
+ if target_level is None:
+ return None
+
+ # Get nearby siblings (within a small order range to find the group)
+ siblings = self._get_true_siblings(target_order, target_level)
+
+ # Filter to displayed siblings
+ if displayed_orders is not None:
+ siblings = [(o, t) for o, t in siblings if o in displayed_orders]
+
+ # Check if there's a matching Net entry with the same base
+ has_matching_net = False
+ for _, sib_title in siblings:
+ if sib_title == f"{base_name}, Net":
+ has_matching_net = True
+ break
+
+ if has_matching_net:
+ # Strip the base name + ", " prefix
+ return f"{base_name}, "
+
+ return None
+
+ def find_best_prefix(
+ self,
+ target_order: int | float,
+ target_title: str,
+ displayed_orders: set[int | float] | None = None,
+ ) -> str | None:
+ """Find the longest ancestor title that is a prefix of target_title.
+
+ Parameters
+ ----------
+ target_order : int | float
+ The order value of the target row.
+ target_title : str
+ The title to find a prefix for.
+ displayed_orders : set[int | float] | None
+ Set of order values that will actually be displayed.
+ If provided, only consider ancestors in this set.
+
+ Returns
+ -------
+ str | None
+ The longest matching prefix, or None if no match found.
+ """
+ # Find this order in the sorted list
+ target_idx = None
+ target_level = 0
+ for i, (order, title, level, _) in enumerate(self.order_title_level):
+ if order == target_order:
+ target_idx = i
+ target_level = level
+ break
+
+ if target_idx is None:
+ return None
+
+ # Look backwards for ancestors
+ best_prefix = None
+ levels_seen: set = set()
+ ancestor_key_phrases: set = set()
+
+ for i in range(target_idx - 1, -1, -1):
+ order, title, level, _ = self.order_title_level[i]
+ # Only consider rows at lower levels
+ if level < target_level:
+ if level in levels_seen:
+ continue
+ levels_seen.add(level)
+
+ # If displayed_orders provided, only consider ancestors that will be shown
+ if displayed_orders is not None and order not in displayed_orders:
+ continue
+
+ if not title:
+ continue
+
+ if target_title.startswith(title) and (
+ best_prefix is None or len(title) > len(best_prefix)
+ ):
+ best_prefix = title
+
+ title_lower = title.lower()
+
+ if title_lower.endswith(")"):
+ paren_start = title_lower.rfind(" (")
+ if paren_start > 0:
+ title_lower = title_lower[:paren_start]
+
+ # Remove common suffixes to get the key entity
+ for suffix in [
+ " survey",
+ " (domestic currency, millions)",
+ " (percent of gdp)",
+ ]:
+ if title_lower.endswith(suffix):
+ title_lower = title_lower[: -len(suffix)]
+ # Store the normalized phrase
+ if title_lower:
+ ancestor_key_phrases.add(title_lower.strip())
+
+ # If no exact prefix match, check for key phrase matches
+ if best_prefix is None and ancestor_key_phrases:
+ target_lower = target_title.lower()
+ # Normalize hyphens to spaces for matching
+ target_normalized = target_lower.replace("-", " ")
+ for phrase in sorted(ancestor_key_phrases, key=len, reverse=True):
+ phrase_normalized = phrase.replace("-", " ")
+ # Check if target starts with the phrase followed by ", " or ": "
+ for sep in [", ", ": ", " - "]:
+ prefix_pattern = f"{phrase_normalized}{sep}"
+ if target_normalized.startswith(prefix_pattern):
+ # Find the actual case-preserved prefix in the original title
+ prefix_len = len(prefix_pattern)
+ best_prefix = target_title[:prefix_len]
+ break
+ if best_prefix:
+ break
+
+ # SAFEGUARD: Don't return prefix if it would leave only a BOP suffix
+ # This prevents "Financial account balance..., Net (...)" from becoming just "Net (...)"
+ if best_prefix:
+ remainder = target_title[len(best_prefix) :].lstrip(", :")
+ if is_bop_suffix_only(remainder):
+ return None
+
+ return best_prefix
+
+ def find_best_suffix(
+ self,
+ target_order: int | float,
+ target_title: str,
+ displayed_orders: set[int | float] | None = None,
+ ) -> str | None:
+ """Find ancestor context that appears as a suffix in the title.
+
+ Many IMF tables have titles like:
+ - Parent: "Domestic Creditors, Liabilities"
+ - Child: "Currency and deposits, Net incurrence of liabilities, Domestic Creditors"
+
+ The ", Domestic Creditors" suffix is redundant since it's shown in the parent.
+ This function finds such suffixes to strip.
+
+ Parameters
+ ----------
+ target_order : int | float
+ The order value of the target row.
+ target_title : str
+ The title to find a suffix for.
+ displayed_orders : set[int | float] | None
+ Set of order values that will actually be displayed.
+ If provided, only consider ancestors in this set.
+
+ Returns
+ -------
+ str | None
+ The matching suffix (including leading ", "), or None if no match.
+ """
+ # Find this order in the sorted list
+ target_idx = None
+ target_level = 0
+ for i, (order, title, level, _) in enumerate(self.order_title_level):
+ if order == target_order:
+ target_idx = i
+ target_level = level
+ break
+
+ if target_idx is None:
+ return None
+
+ # Collect ALL suffix parts from ALL ancestors that we should strip
+ # Start with immediate parent, go up the hierarchy
+ levels_seen: set = set()
+ ancestor_parts: set = set()
+ # Never strip accounting-entry qualifiers like Net/Credit/Debit.
+ # In BOP tables these are meaningful and required to preserve hierarchy.
+ protected_suffixes = {
+ "Assets",
+ "Liabilities",
+ "Net",
+ "Credit",
+ "Debit",
+ "Credit/Revenue",
+ "Debit/Expenditure",
+ }
+
+ for i in range(target_idx - 1, -1, -1):
+ order, title, level, _ = self.order_title_level[i]
+ if level < target_level:
+ if level in levels_seen:
+ continue
+ levels_seen.add(level)
+
+ # If displayed_orders provided, only consider ancestors that will be shown
+ if displayed_orders is not None and order not in displayed_orders:
+ continue
+
+ if not title:
+ continue
+
+ # Split on ", " followed by uppercase letter or colon hierarchy separator
+ parts = re.split(r", (?=[A-Z:])", title)
+ for p in parts:
+ pp = p.strip()
+ if pp:
+ ancestor_parts.add(pp)
+ # Also add related context patterns that imply each other
+ if pp == "Liabilities":
+ ancestor_parts.add("Net incurrence of liabilities")
+ ancestor_parts.add("Total liabilities")
+ elif pp == "Net acquisition of financial assets":
+ ancestor_parts.add("Net acquisition of financial assets")
+ elif pp == "Financial assets":
+ ancestor_parts.add("Assets")
+ elif "Debtors" in pp:
+ ancestor_parts.add("Net acquisition of financial assets")
+ ancestor_parts.add("Assets")
+ elif "Creditors" in pp:
+ ancestor_parts.add("Net incurrence of liabilities")
+ ancestor_parts.add("Total liabilities")
+
+ # Try to find the rightmost comma-separated part that matches
+ if not ancestor_parts:
+ return None
+
+ # Check if the title ends with ", "
+ for part in ancestor_parts:
+ if part in protected_suffixes:
+ continue
+ suffix_with_comma = f", {part}"
+ if target_title.endswith(suffix_with_comma):
+ return suffix_with_comma
+
+ return None
+
+ def find_ancestor_part_prefix(
+ self,
+ target_order: int | float,
+ target_title: str,
+ displayed_orders: set[int | float] | None = None,
+ ) -> str | None:
+ """Find ancestor context parts that appear as a prefix in the title.
+
+ Some IMF titles have ancestor context as a prefix:
+ - Parent: "Liabilities"
+ - Child: "Total liabilities, Domestic Creditors"
+
+ The "Total liabilities, " prefix is redundant since parent is "Liabilities".
+
+ Parameters
+ ----------
+ target_order : int | float
+ The order value of the target row.
+ target_title : str
+ The title to find a prefix for.
+ displayed_orders : set[int | float] | None
+ Set of order values that will actually be displayed.
+ If provided, only consider ancestors in this set.
+
+ Returns
+ -------
+ str | None
+ The matching prefix (including trailing ", " or ": "), or None.
+ """
+ # Find this order in the sorted list
+ target_idx = None
+ target_level = 0
+ for i, (order, title, level, _) in enumerate(self.order_title_level):
+ if order == target_order:
+ target_idx = i
+ target_level = level
+ break
+
+ if target_idx is None:
+ return None
+
+ levels_seen: set = set()
+ ancestor_parts: set = set()
+
+ for i in range(target_idx - 1, -1, -1):
+ order, title, level, _ = self.order_title_level[i]
+ if level < target_level:
+ if level in levels_seen:
+ continue
+ levels_seen.add(level)
+
+ if displayed_orders is not None and order not in displayed_orders:
+ continue
+
+ if not title:
+ continue
+
+ if title == target_title:
+ continue
+
+ parts = re.split(r", (?=[A-Z:])", title)
+
+ for p in parts:
+ pp = p.strip()
+ if pp:
+ ancestor_parts.add(pp)
+ # Add related patterns
+ if pp == "Liabilities":
+ ancestor_parts.add("Total liabilities")
+ elif pp == "Financial assets":
+ ancestor_parts.add("Financial assets")
+
+ if not ancestor_parts:
+ return None
+
+ # Check if title starts with ", " or ": "
+ strippable_single_words = {"Assets", "Liabilities"}
+ target_normalized = target_title.lower().replace("-", " ")
+ for part in ancestor_parts:
+ # Skip single-word parts unless they're known category markers
+ if " " not in part and part not in strippable_single_words:
+ continue
+ part_normalized = part.lower().replace("-", " ")
+ # Check both comma and colon separators
+ for sep in [", ", ": "]:
+ prefix_with_sep = f"{part}{sep}"
+ if target_title.startswith(prefix_with_sep):
+ # SAFEGUARD: Don't return if remainder is just a BOP suffix
+ remainder = target_title[len(prefix_with_sep) :]
+ if is_bop_suffix_only(remainder):
+ continue
+ return prefix_with_sep
+ # Also check normalized version
+ prefix_normalized = f"{part_normalized}{sep}"
+ if target_normalized.startswith(prefix_normalized):
+ # SAFEGUARD: Don't return if remainder is just a BOP suffix
+ remainder = target_title[len(prefix_normalized) :]
+ if is_bop_suffix_only(remainder):
+ continue
+ return target_title[: len(prefix_normalized)]
+
+ # Check if title starts with a PARTIAL version of an ancestor part
+ # e.g., Parent: "A to B liabilities", Child: "A to B , X"
+ # The child prefix "A to B , " is a partial match of parent
+ if ", " in target_title:
+ comma_idx = target_title.index(", ")
+ child_prefix = target_title[:comma_idx].lower().replace("-", " ")
+ for part in ancestor_parts:
+ part_normalized = part.lower().replace("-", " ")
+ # Check if child prefix is a prefix of the ancestor part
+ if part_normalized.startswith(child_prefix) and len(child_prefix) > 10:
+ # SAFEGUARD: Don't return if remainder is just a BOP suffix
+ remainder = target_title[comma_idx + 2 :]
+ if is_bop_suffix_only(remainder):
+ continue
+ # Strip the prefix plus ", "
+ return target_title[: comma_idx + 2]
+
+ return None
+
+ def simplify_title(
+ self,
+ order: int | float,
+ title: str,
+ displayed_orders: set[int | float] | None = None,
+ ) -> str:
+ """Apply all title simplifications (strip suffix, prefix, ancestor parts).
+
+ Parameters
+ ----------
+ order : int | float
+ The order value of the row.
+ title : str
+ The raw title to simplify.
+ displayed_orders : set[int | float] | None
+ Set of order values that will actually be displayed.
+
+ Returns
+ -------
+ str
+ The simplified title.
+ """
+ title = strip_title_suffix(title)
+ original_title = title # Save for safeguard
+ was_bop_group_stripped = False # Track if BOP group stripping happened
+
+ # Only strip prefixes from ancestors that will be displayed
+ best_prefix = self.find_best_prefix(order, title, displayed_orders)
+ if best_prefix and title.startswith(best_prefix):
+ relative = title[len(best_prefix) :].lstrip(", :")
+ # Only replace if relative is not empty, and it's not identical
+ if relative and title != best_prefix:
+ title = relative
+
+ # Check for common prefix among siblings at the same level
+ sibling_prefix = self.find_sibling_common_prefix(order, title, displayed_orders)
+ if sibling_prefix and title.startswith(sibling_prefix):
+ remainder = title[len(sibling_prefix) :]
+ if remainder: # Only strip if something remains
+ title = remainder
+
+ # Check for BOP-style Credit/Debit/Net grouping pattern
+ # This is INTENTIONAL stripping - we WANT "Credit"/"Debit" as children of "Net" header
+ bop_prefix = self.find_bop_group_prefix(order, title, displayed_orders)
+ if bop_prefix and title.startswith(bop_prefix):
+ remainder = title[len(bop_prefix) :]
+ if remainder: # Only strip if something remains
+ title = remainder
+ was_bop_group_stripped = True # Mark that this was intentional
+
+ # Strip ancestor-part prefixes before suffix stripping
+ while True:
+ part_prefix = self.find_ancestor_part_prefix(order, title, displayed_orders)
+ if part_prefix and title.startswith(part_prefix):
+ title = title[len(part_prefix) :]
+ else:
+ break
+
+ # Then strip redundant suffixes from parent context
+ while True:
+ best_suffix = self.find_best_suffix(order, title, displayed_orders)
+ if best_suffix and title.endswith(best_suffix):
+ title = title[: -len(best_suffix)]
+ else:
+ break
+
+ # SAFEGUARD: Never reduce title to ONLY a BOP suffix term
+ # UNLESS it was intentionally stripped by BOP group logic (Credit/Debit under Net header)
+ if not was_bop_group_stripped:
+ bop_only_terms = {
+ "Net",
+ "Credit",
+ "Debit",
+ "Credit/Revenue",
+ "Debit/Expenditure",
+ "Assets",
+ "Liabilities",
+ "Assets (excl. reserves)",
+ "Liabilities (incl. net incurrence)",
+ }
+ stripped_title = title.strip()
+ if stripped_title in bop_only_terms:
+ # Restore the original title (before any stripping)
+ title = original_title
+
+ # SAFEGUARD: Never return an empty title - restore original if stripped to nothing
+ if not title or not title.strip():
+ title = original_title
+
+ return title
+
+
+def build_order_title_level(
+ df: "pd.DataFrame",
+) -> list[tuple[int | float, str, int, bool]]:
+ """Build sorted list of (order, title, level, is_header) from DataFrame.
+
+ For each order, finds the best representative row (prefers headers,
+ otherwise takes longest title).
+
+ Parameters
+ ----------
+ df : pd.DataFrame
+ The DataFrame containing IMF indicator data.
+
+ Returns
+ -------
+ list[tuple[int | float, str, int, bool]]
+ Sorted list of (order, title, level, is_header) tuples.
+ """
+ order_title_level: list[tuple[int | float, str, int, bool]] = []
+
+ for order_val in df["order"].dropna().unique():
+ order_group = df[df["order"] == order_val]
+ rep_row = None
+
+ # Prefer header rows for titles if they exist for this order
+ header_rows = order_group[
+ order_group["is_category_header"].fillna(False)
+ ].copy()
+ if not header_rows.empty:
+ # If multiple headers, pick one with longest title
+ header_rows["title_len"] = header_rows["title"].str.len().fillna(0)
+ rep_row = header_rows.loc[header_rows["title_len"].idxmax()]
+ else:
+ # If no header, find the data row with the best title
+ data_rows_group = order_group[
+ ~order_group["is_category_header"].fillna(False)
+ ].copy()
+ if not data_rows_group.empty:
+ data_rows_group["title_len"] = (
+ data_rows_group["title"].str.len().fillna(0)
+ )
+ rep_row = data_rows_group.loc[data_rows_group["title_len"].idxmax()]
+
+ if rep_row is not None:
+ title = rep_row.get("title", "")
+ title = strip_title_suffix(title)
+ level = rep_row.get("level") or 0
+ is_header = rep_row.get("is_category_header", False)
+ order_title_level.append((order_val, title, level, is_header))
+
+ # Sort by order to ensure correct sequence
+ order_title_level.sort(key=lambda x: x[0])
+ return order_title_level
+
+
+def check_missing_country_data(
+ df: "pd.DataFrame",
+ requested_countries: list[str],
+ dates: list[Any],
+ countries: list[str],
+) -> None:
+ """Check which requested countries have no data for selected dates and warn.
+
+ Parameters
+ ----------
+ df : pd.DataFrame
+ The DataFrame containing IMF indicator data.
+ requested_countries : list[str]
+ List of country codes/names requested by the user.
+ dates : list[Any]
+ List of dates in the selected date range.
+ countries : list[str]
+ List of countries that have data.
+ """
+ # pylint: disable=import-outside-toplevel
+ from openbb_core.app.model.abstract.warning import OpenBBWarning
+
+ # Build a set of countries that have data for the selected dates
+ countries_with_data_for_dates: set[str] = set()
+ for d in dates:
+ date_df = df[df["date"] == d]
+ countries_with_data_for_dates.update(date_df["country"].dropna().unique())
+
+ # Check each requested country
+ missing_countries: list[tuple[str, Any]] = []
+ for req_country in requested_countries:
+ # Find the actual country name (we have codes like DEU, USA)
+ # Check if any country in data matches this code
+ for c in countries:
+ if c and (
+ req_country.upper() in c.upper()
+ or df[df["country"] == c]["country_code"].iloc[0] == req_country.upper()
+ ):
+ if c not in countries_with_data_for_dates:
+ # Get the latest date this country has data for
+ country_dates = sorted(
+ df[df["country"] == c]["date"].dropna().unique(),
+ reverse=True,
+ )
+ latest = country_dates[0] if country_dates else None
+ missing_countries.append((c, latest))
+ break
+
+ if missing_countries:
+ for country_name, latest_date in missing_countries:
+ warnings.warn(
+ f"No data for '{country_name}' in selected date range. "
+ f"Latest available data: {latest_date}. "
+ f"Try increasing 'limit' or adjusting date range.",
+ OpenBBWarning,
+ )
+
+
+def pivot_indicator_mode(
+ df: "pd.DataFrame",
+ dates: list[Any],
+ countries: list[str],
+) -> "pd.DataFrame":
+ """Pivot table for indicator mode.
+
+ Creates DataFrame with ["title", "country", "unit", "scale"] as index and dates as columns.
+
+ Parameters
+ ----------
+ df : pd.DataFrame
+ The DataFrame containing IMF indicator data.
+ dates : list[Any]
+ List of dates to use as columns.
+ countries : list[str]
+ List of countries.
+
+ Returns
+ -------
+ pd.DataFrame
+ Pivoted DataFrame with title, country, unit, and scale as index.
+ """
+ # pylint: disable=import-outside-toplevel
+ import pandas as pd
+
+ rows: list[dict[str, Any]] = []
+ # Group by title (indicator name), country, AND unit/scale
+ for title in df["title"].unique():
+ if pd.isna(title):
+ continue
+ title_df = df[df["title"] == title]
+ for country in countries:
+ country_df = title_df[title_df["country"] == country]
+ if len(country_df) == 0:
+ continue
+
+ # Group by unique unit/scale combinations within this title+country
+ # First, extract unit/scale for each row with fallback
+ country_df = country_df.copy()
+ units = []
+ scales = []
+ for _, data_row in country_df.iterrows():
+ row_unit = data_row.get("unit")
+ row_scale = data_row.get("scale")
+ # Treat "-" as missing
+ if row_unit == "-":
+ row_unit = None
+ if row_scale == "-":
+ row_scale = None
+ if not row_unit or not row_scale:
+ parsed_unit, parsed_scale = extract_unit_scale_from_title(
+ str(data_row.get("title") or "")
+ )
+ if not row_unit and parsed_unit:
+ row_unit = parsed_unit
+ if not row_scale and parsed_scale:
+ row_scale = parsed_scale
+ units.append(row_unit if row_unit else None)
+ scales.append(row_scale if row_scale else None)
+
+ country_df["_unit"] = units
+ country_df["_scale"] = scales
+
+ # Group by unit/scale and create one output row per group
+ for (unit_val, scale_val), group_df in country_df.groupby(
+ ["_unit", "_scale"], dropna=False
+ ):
+ row: dict[str, Any] = {
+ "title": title,
+ "country": country,
+ }
+ if unit_val is not None:
+ row["unit"] = unit_val
+
+ if scale_val is not None:
+ row["scale"] = scale_val
+
+ # Track if row has any non-zero values
+ has_nonzero_value = False
+ for d in dates:
+ val = group_df[group_df["date"] == d]["value"].values
+ if len(val) > 0 and pd.notna(val[0]):
+ row[str(d)] = val[0]
+ if val[0] != 0:
+ has_nonzero_value = True
+ else:
+ row[str(d)] = None
+
+ # Skip rows where all date values are 0 or None
+ if not has_nonzero_value:
+ continue
+
+ rows.append(row)
+
+ result_df = pd.DataFrame(rows)
+ if not result_df.empty:
+ result_df = result_df.set_index(["title", "country", "unit", "scale"])
+
+ return result_df
+
+
+def pivot_table_mode(
+ df: "pd.DataFrame",
+ dates: list[Any],
+ countries: list[str],
+ metadata: dict[str, Any],
+) -> "pd.DataFrame":
+ """Get a hierarchical pivot for table mode.
+
+ Handles:
+ - Parent/child hierarchy detection
+ - Title simplification based on displayed ancestors
+ - Proper indentation with visual hierarchy markers
+ - Uniform vs per-row unit display
+ - Hierarchy name headers
+
+ Parameters
+ ----------
+ df : pd.DataFrame
+ The DataFrame containing IMF indicator data.
+ dates : list[Any]
+ List of dates to use as columns.
+ countries : list[str]
+ List of countries.
+ metadata : dict[str, Any]
+ Metadata dictionary containing table information.
+
+ Returns
+ -------
+ pd.DataFrame
+ Pivoted DataFrame with hierarchical structure.
+ """
+ # pylint: disable=import-outside-toplevel
+ from collections import defaultdict
+
+ import pandas as pd
+
+ # Build the hierarchy context
+ order_title_level = build_order_title_level(df)
+ hierarchy_ctx = HierarchyContext(order_title_level)
+
+ # Detect ISORA-style tables (topic hierarchy, dash-delimited labels)
+ table_name = metadata.get("table", {}).get("hierarchy_name", "")
+ dataflow_id = metadata.get("table", {}).get("dataflow_id", "")
+ is_isora = "ISORA" in dataflow_id or "INDICATORS BY TOPIC" in table_name.upper()
+
+ # Build helper maps for unit/scale inheritance
+ node_id_to_order: dict[str, int | float] = {}
+ for node_row in df.itertuples(index=False):
+ node_id = getattr(node_row, "hierarchy_node_id", None)
+ order_val = getattr(node_row, "order", None)
+ if node_id and order_val is not None:
+ node_id_to_order[str(node_id)] = order_val
+
+ order_to_parent: dict[int | float, int | float] = {}
+ for order_val in df["order"].dropna().unique():
+ order_df = df[df["order"] == order_val]
+ first_row = order_df.iloc[0]
+ parent_id = first_row.get("parent_id")
+ parent_order: int | float | None = None
+ if parent_id:
+ parent_id_str = str(parent_id)
+ parent_order = node_id_to_order.get(parent_id_str)
+ if parent_order is None:
+ suffix_pattern = f"___{parent_id_str}"
+ for node_id, node_order in node_id_to_order.items():
+ if node_id.endswith(suffix_pattern):
+ parent_order = node_order
+ break
+ if parent_order is not None:
+ order_to_parent[order_val] = parent_order
+
+ # Capture unit/scale per order (from explicit columns or suffix) and inherit down
+ unit_scale_by_order: dict[int | float, tuple[str | None, str | None]] = {}
+
+ for order_val in df["order"].dropna().unique():
+ order_df = df[df["order"] == order_val]
+ unit_val: str | None = None
+ scale_val: str | None = None
+
+ for _, row in order_df.iterrows():
+ if unit_val is None:
+ candidate_unit = row.get("unit")
+ if candidate_unit and str(candidate_unit) != "nan":
+ unit_val = str(candidate_unit)
+ if scale_val is None:
+ candidate_scale = row.get("scale")
+ if candidate_scale and str(candidate_scale) != "nan":
+ scale_val = str(candidate_scale)
+
+ if unit_val is None or scale_val is None:
+ parsed_unit, parsed_scale = extract_unit_scale_from_title(
+ str(row.get("title") or "")
+ )
+ if unit_val is None and parsed_unit:
+ unit_val = parsed_unit
+ if scale_val is None and parsed_scale:
+ scale_val = parsed_scale
+
+ if unit_val is not None and scale_val is not None:
+ break
+
+ unit_scale_by_order[order_val] = (unit_val, scale_val)
+ for order_val in list(unit_scale_by_order.keys()):
+ unit_val, scale_val = unit_scale_by_order[order_val]
+ if unit_val is not None and scale_val is not None:
+ continue
+
+ visited: set[int | float] = set()
+ parent_order = order_to_parent.get(order_val)
+ while parent_order is not None and parent_order not in visited:
+ visited.add(parent_order)
+ p_unit, p_scale = unit_scale_by_order.get(parent_order, (None, None))
+ if unit_val is None and p_unit is not None:
+ unit_val = p_unit
+ if scale_val is None and p_scale is not None:
+ scale_val = p_scale
+ if unit_val is not None and scale_val is not None:
+ break
+ parent_order = order_to_parent.get(parent_order)
+
+ unit_scale_by_order[order_val] = (unit_val, scale_val)
+
+ # First pass: collect RAW data rows and track which orders have actual data
+ # Title simplification is deferred until we know which ancestors will be displayed
+ orders_with_data: set[int | float] = set()
+ raw_data_rows: list[dict[str, Any]] = []
+
+ # Include ALL orders that might have data - don't exclude category headers
+ # because they can also have data values (aggregates)
+ data_orders = df["order"].dropna().unique()
+
+ for order in sorted(data_orders):
+ order_df = df[df["order"] == order]
+
+ # Data rows - one per country, per series/dimension combination
+ for country in countries:
+ country_df = order_df[order_df["country"] == country]
+ if country_df.empty:
+ continue
+
+ # Determine grouping columns - use series_id if available, otherwise
+ # group by dimension code columns to separate different series
+ # ALWAYS include dimension code columns to separate by counterpart_country, etc.
+ # Exclude dv_type_code - it's just "Reported official data" for everything
+ dim_code_cols = [
+ c
+ for c in country_df.columns
+ if c.endswith("_code")
+ and c not in ("country_code", "frequency_code", "dv_type_code")
+ and country_df[c].notna().any()
+ ]
+ if "series_id" in country_df.columns:
+ # Include series_id AND dimension codes
+ group_cols = ["series_id"] + [
+ c for c in dim_code_cols if c != "series_id"
+ ]
+ else:
+ group_cols = dim_code_cols if dim_code_cols else ["symbol"]
+
+ # Ensure all group columns exist and handle NaN
+ valid_group_cols = [c for c in group_cols if c in country_df.columns]
+ if not valid_group_cols:
+ valid_group_cols = ["symbol"] if "symbol" in country_df.columns else []
+
+ if valid_group_cols:
+ for _, series_df in country_df.groupby(valid_group_cols, dropna=False):
+ if series_df.empty:
+ continue
+ first_in_series = series_df.iloc[0]
+
+ # Check if this series has ANY data for the selected dates
+ has_data = False
+ row_values: dict[str, Any] = {}
+ for d in dates:
+ # Handle date comparison - d may be string or datetime.date
+ # series_df["date"] may also be either type
+ d_str = str(d)
+ date_matches = series_df["date"].astype(str) == d_str
+ val = series_df[date_matches]["value"].values
+ if len(val) > 0 and pd.notna(val[0]):
+ row_values[str(d)] = val[0]
+ has_data = True
+ else:
+ row_values[str(d)] = None
+
+ if has_data:
+ orders_with_data.add(order)
+
+ # Store RAW title - simplification deferred
+ title = first_in_series.get("title", "")
+ if not title:
+ ind_code = first_in_series.get("indicator_code", "")
+ if ind_code:
+ title = ind_code.replace("_", " ").capitalize()
+
+ row_unit = first_in_series.get("unit") or ""
+ row_scale = first_in_series.get("scale") or ""
+
+ # Fallback to order-level (inherited) unit/scale when missing
+ inherited_unit, inherited_scale = unit_scale_by_order.get(
+ order, (None, None)
+ )
+
+ if not row_unit and inherited_unit:
+ row_unit = inherited_unit
+ if not row_scale and inherited_scale:
+ row_scale = inherited_scale
+
+ # As a last resort, parse the title suffix for unit/scale
+ if not row_unit or not row_scale:
+ parsed_unit, parsed_scale = extract_unit_scale_from_title(
+ title
+ )
+ if not row_unit and parsed_unit:
+ row_unit = parsed_unit
+ if not row_scale and parsed_scale:
+ row_scale = parsed_scale
+
+ # Collect dimension values for grouping headers
+ # Look for *_code fields that indicate dimension breakdowns
+ # Column names in the data are lowercase (sector_code, gfs_grp_code, etc.)
+ dimension_values: dict[str, tuple[str, str]] = {}
+ # Dimensions that should create grouping headers
+ # Keys are uppercase (for display), values are lowercase (for column lookup)
+ grouping_dims = {
+ "SECTOR": "sector",
+ "TYPE_OF_TRANSFORMATION": "type_of_transformation",
+ "COUNTERPART_COUNTRY": "counterpart_country",
+ "CURRENCY": "currency",
+ "INDEX_TYPE": "index_type",
+ "BOP_ACCOUNTING_ENTRY": "bop_accounting_entry",
+ "ACCOUNTING_ENTRY": "accounting_entry",
+ "ACCOUNT": "account",
+ "PRICE_TYPE": "price_type",
+ "S_ADJUSTMENT": "s_adjustment",
+ }
+ for dim_id, col_name in grouping_dims.items():
+ code_key = f"{col_name}_code"
+ code_val = first_in_series.get(code_key)
+ label_val = first_in_series.get(col_name)
+ if code_val and label_val:
+ dimension_values[dim_id] = (
+ str(code_val),
+ str(label_val),
+ )
+
+ raw_data_rows.append(
+ {
+ "order": order,
+ "level": first_in_series["level"] or 0,
+ "raw_title": title, # Store raw title
+ "country": country,
+ "values": row_values,
+ "unit": row_unit,
+ "scale": row_scale,
+ "dimension_values": dimension_values,
+ }
+ )
+
+ # Find all parent orders that lead to data rows
+ parent_orders: set[int | float] = set()
+ true_header_parents: set[int | float] = set()
+
+ for order in orders_with_data:
+ order_df = df[df["order"] == order]
+ if len(order_df) == 0:
+ continue
+ parent_id = order_df.iloc[0].get("parent_id")
+ # Trace up the hierarchy to find all parent headers
+ while parent_id:
+ parent_df = df[df["hierarchy_node_id"] == parent_id]
+ # hierarchy_node_id might be "CL_X___CODE" but parent_id is just "CODE"
+ if len(parent_df) == 0:
+ suffix_pattern = f"___{parent_id}"
+ parent_df = df[
+ df["hierarchy_node_id"].fillna("").str.endswith(suffix_pattern)
+ ]
+ if len(parent_df) == 0:
+ break
+ parent_order = parent_df.iloc[0]["order"]
+ parent_is_header = parent_df.iloc[0].get("is_category_header", False)
+ if parent_order is not None:
+ parent_orders.add(parent_order)
+ # Track true headers separately for title stripping
+ if parent_is_header:
+ true_header_parents.add(parent_order)
+ parent_id = parent_df.iloc[0].get("parent_id")
+
+ # Build per-country orders_with_data
+ country_orders_with_data: dict[str, set[int | float]] = {}
+ for raw_row in raw_data_rows:
+ country = raw_row["country"]
+ order = raw_row["order"]
+ if country not in country_orders_with_data:
+ country_orders_with_data[country] = set()
+ country_orders_with_data[country].add(order)
+
+ # Build per-country parent_orders (ancestors of data rows for each country)
+ country_parent_orders: dict[str, set[int | float]] = {}
+ for country, country_data_orders in country_orders_with_data.items():
+ country_parents: set[int | float] = set()
+ for order in country_data_orders:
+ order_df = df[df["order"] == order]
+ if len(order_df) == 0:
+ continue
+ parent_id = order_df.iloc[0].get("parent_id")
+ while parent_id:
+ parent_df = df[df["hierarchy_node_id"] == parent_id]
+ if len(parent_df) == 0:
+ suffix_pattern = f"___{parent_id}"
+ parent_df = df[
+ df["hierarchy_node_id"].fillna("").str.endswith(suffix_pattern)
+ ]
+ if len(parent_df) == 0:
+ break
+ parent_order = parent_df.iloc[0]["order"]
+ if parent_order is not None:
+ country_parents.add(parent_order)
+ parent_id = parent_df.iloc[0].get("parent_id")
+ country_parent_orders[country] = country_parents
+
+ # Compute the union of all per-country parent orders
+ # This ensures we only show headers that lead to data for at least one country in the result
+ effective_parent_orders: set[int | float] = set()
+ for country_parents in country_parent_orders.values():
+ effective_parent_orders.update(country_parents)
+
+ data_rows: list[dict[str, Any]] = []
+
+ for raw_row in raw_data_rows:
+ order = raw_row["order"]
+ title = raw_row["raw_title"]
+ country = raw_row["country"]
+ country_data_orders = country_orders_with_data.get(country, set())
+ displayed_orders = true_header_parents | country_data_orders
+ title = hierarchy_ctx.simplify_title(order, title, displayed_orders)
+
+ data_rows.append(
+ {
+ "order": order,
+ "level": raw_row["level"],
+ "title": title,
+ "country": raw_row["country"],
+ "values": raw_row["values"],
+ "unit": raw_row["unit"],
+ "scale": raw_row["scale"],
+ "dimension_values": raw_row.get("dimension_values", {}),
+ }
+ )
+
+ # Detect which dimensions have multiple values (need grouping headers)
+ dim_value_sets: dict[str, set[str]] = {}
+ for dr in data_rows:
+ for dim_id, (code, label) in dr.get("dimension_values", {}).items():
+ if dim_id not in dim_value_sets:
+ dim_value_sets[dim_id] = set()
+ dim_value_sets[dim_id].add(code)
+
+ # Dimensions with multiple values need synthetic grouping headers
+ multi_value_dims = [
+ dim_id for dim_id, codes in dim_value_sets.items() if len(codes) > 1
+ ]
+
+ # If we have multi-value dimensions, add grouping info to data rows
+ if multi_value_dims:
+ # Sort multi_value_dims in a sensible order (SECTOR first, then others)
+ dim_priority = {"SECTOR": 0, "GFS_GRP": 1, "TYPE_OF_TRANSFORMATION": 2}
+ multi_value_dims.sort(key=lambda d: dim_priority.get(d, 99))
+
+ for dr in data_rows:
+ dim_vals = dr.get("dimension_values", {})
+ # Build grouping key from multi-value dimensions
+ grouping_parts = []
+ for dim_id in multi_value_dims:
+ if dim_id in dim_vals:
+ code, label = dim_vals[dim_id]
+ grouping_parts.append((dim_id, code, label))
+ dr["_grouping_dims"] = grouping_parts
+
+ # Sort data rows by (grouping dimensions, order) for proper grouping
+ # This groups all items with the same dimension values together,
+ # then sorts by hierarchical order within each group
+ def row_sort_key(row: dict) -> tuple:
+ grouping = tuple(
+ (dim_id, code) for dim_id, code, label in row.get("_grouping_dims", [])
+ )
+ order_val = row.get("order", 0)
+ return grouping + (order_val,)
+
+ data_rows.sort(key=row_sort_key)
+
+ # Note: ISORA tables use dash-delimited titles but we do NOT create synthetic
+ # headers from them as it causes false groupings (e.g., "On" from "On-time").
+ # Instead, we rely on the existing hierarchy metadata (topic parent nodes).
+
+ # Check if all data rows have the same unit and scale
+ all_units = {dr.get("unit") for dr in data_rows if dr.get("unit")}
+ all_scales = {dr.get("scale") for dr in data_rows if dr.get("scale")}
+ uniform_unit = all_units.pop() if len(all_units) == 1 else None
+ uniform_scale = all_scales.pop() if len(all_scales) == 1 else None
+ # If there are multiple units, we need per-row display even if scale is uniform
+ has_uniform_unit_scale = uniform_unit is not None
+ uniform_suffix = ""
+
+ if has_uniform_unit_scale:
+ parts = []
+ if uniform_unit:
+ parts.append(uniform_unit)
+ if uniform_scale and uniform_scale != "Units":
+ parts.append(uniform_scale)
+ if parts:
+ uniform_suffix = f" ({', '.join(parts)})"
+
+ # Check if we need to add a hierarchy name header
+ hierarchy_name = metadata.get("table", {}).get("hierarchy_name")
+ first_level_0_is_data = False
+ first_level_0_title = None
+
+ for order in sorted(df["order"].unique()):
+ order_df = df[df["order"] == order]
+ first = order_df.iloc[0]
+ level = first["level"] or 0
+
+ if level == 0:
+ is_header = first["is_category_header"]
+ first_level_0_title = first["title"] or ""
+
+ if not is_header:
+ first_level_0_is_data = True
+ break
+
+ # Add it if: (1) first level-0 is data, OR (2) hierarchy name differs from first level-0 title
+ should_add_table_header = False
+
+ if hierarchy_name:
+ if first_level_0_is_data:
+ should_add_table_header = True
+ elif first_level_0_title:
+ hierarchy_name_clean = hierarchy_name.upper().replace("_", " ")
+ first_title_clean = first_level_0_title.upper().split(" (")[0]
+ if (
+ hierarchy_name_clean not in first_title_clean
+ and first_title_clean not in hierarchy_name_clean
+ ):
+ should_add_table_header = True
+
+ # Second pass: build final rows with headers that have data children
+ rows: list[dict[str, Any]] = []
+
+ if should_add_table_header and hierarchy_name:
+ header_title = hierarchy_name.upper()
+ if uniform_suffix:
+ header_title += uniform_suffix
+ row = {
+ "title": f"▸ {header_title}",
+ "country": "",
+ }
+ for d in dates:
+ row[str(d)] = ""
+ rows.append(row)
+
+ all_orders = list(df["order"].unique())
+ sorted_orders = sorted(all_orders, key=lambda o: float(o)) # pylint: disable=W0108
+
+ # Universal dimension grouping, when multi_value_dims exists
+ dim_group_map: dict[tuple, list[dict]] = defaultdict(list)
+ if multi_value_dims:
+ for dr in data_rows:
+ grouping_key = tuple(dr.get("_grouping_dims", []))
+ dim_group_map[grouping_key].append(dr)
+ else:
+ dim_group_map[()] = data_rows
+
+ def format_dim_labels(grouping_key: tuple) -> str:
+ """Format all dimension labels from a grouping key into a display string.
+
+ Excludes TYPE_OF_TRANSFORMATION when its value is a unit-like label
+ (e.g., "Domestic currency") since that's already shown in the parent
+ row's unit suffix. Keeps meaningful transformation types like "Index",
+ "Percent change", etc.
+ """
+ if not grouping_key:
+ return ""
+
+ # Unit-like transformation values to exclude (already shown in parent suffix)
+ unit_like_transformations = {
+ "Domestic currency",
+ "National currency",
+ "US dollar",
+ "US Dollar",
+ "SDR",
+ "Euro",
+ }
+
+ labels = []
+ filtered_labels = []
+ for dim_id, _, label in grouping_key:
+ labels.append(label)
+ if (
+ dim_id == "TYPE_OF_TRANSFORMATION"
+ and label in unit_like_transformations
+ ):
+ continue
+ filtered_labels.append(label)
+
+ # If filtering removed everything, fall back to the unfiltered labels so we
+ # never render a blank title row for unit-only dimensions.
+ effective_labels = filtered_labels if filtered_labels else labels
+
+ return " - ".join(effective_labels) if effective_labels else ""
+
+ # Build a map of order -> list of (grouping_key, data_rows_for_order)
+ # Preserve original data order by iterating data_rows directly
+ order_to_dim_data: dict[int | float, list[tuple[tuple, list[dict]]]] = defaultdict(
+ list
+ )
+ seen_order_keys: dict[int | float, set[tuple]] = defaultdict(set)
+
+ for dr in data_rows:
+ order = dr["order"]
+ grouping_key = tuple(dr.get("_grouping_dims", []))
+
+ if grouping_key in seen_order_keys[order]:
+ # Find existing entry and append
+ for entry in order_to_dim_data[order]:
+ if entry[0] == grouping_key:
+ entry[1].append(dr)
+ break
+ else:
+ # New grouping key for this order - add in data order
+ order_to_dim_data[order].append((grouping_key, [dr]))
+ seen_order_keys[order].add(grouping_key)
+
+ # Compute parent orders globally (across all dimension groups)
+ all_orders_with_data = {dr["order"] for dr in data_rows}
+ global_parent_orders: set[int | float] = set()
+ for order in all_orders_with_data:
+ order_df = df[df["order"] == order]
+ if len(order_df) == 0:
+ continue
+ parent_id = order_df.iloc[0].get("parent_id")
+ while parent_id:
+ parent_df = df[df["hierarchy_node_id"] == parent_id]
+ if len(parent_df) == 0:
+ suffix_pattern = f"___{parent_id}"
+ parent_df = df[
+ df["hierarchy_node_id"].fillna("").str.endswith(suffix_pattern)
+ ]
+ if len(parent_df) == 0:
+ break
+ parent_order = parent_df.iloc[0]["order"]
+ if parent_order is not None:
+ global_parent_orders.add(parent_order)
+ parent_id = parent_df.iloc[0].get("parent_id")
+
+ # Track BOP-only header nodes we intentionally skip so we can promote descendants.
+ bop_skipped_parent_ids: set[str] = set()
+
+ def _track_skipped_parent_ids(row_like: dict[str, Any]) -> None:
+ node_id = row_like.get("hierarchy_node_id")
+ ind_code = row_like.get("indicator_code")
+ for v in (node_id, ind_code):
+ if not v:
+ continue
+ sv = str(v)
+ bop_skipped_parent_ids.add(sv)
+ if "___" in sv:
+ bop_skipped_parent_ids.add(sv.rsplit("___", 1)[-1])
+
+ def _lookup_parent_row(parent_id: str):
+ parent_df = df[df["hierarchy_node_id"] == parent_id]
+ if len(parent_df) == 0:
+ suffix_pattern = f"___{parent_id}"
+ parent_df = df[
+ df["hierarchy_node_id"].fillna("").str.endswith(suffix_pattern)
+ ]
+ if len(parent_df) == 0 and "indicator_code" in df.columns:
+ parent_df = df[df["indicator_code"] == parent_id]
+ return parent_df
+
+ def _promote_level_if_parent_skipped(level: int, parent_id: Any) -> int:
+ adjusted = level
+ pid = str(parent_id) if parent_id else ""
+ while pid and pid in bop_skipped_parent_ids and adjusted > 0:
+ adjusted -= 1
+ parent_df = _lookup_parent_row(pid)
+ if len(parent_df) == 0:
+ break
+ pid = str(parent_df.iloc[0].get("parent_id") or "")
+ return adjusted
+
+ # Track the last meaningful (non-BOP-only) header title at each level.
+ # This is used to preserve qualifiers like "excluding exceptional financing"
+ # for BOP suffix rows even when intermediate accounting-entry headers are skipped.
+ last_meaningful_header_by_level: dict[int, str] = {}
+
+ def _normalize_title(raw_title: str | None) -> str:
+ title = (raw_title or "").lstrip()
+
+ # Remove header marker (used for promoted headers in the rendered output)
+ if title.startswith("▸"):
+ title = title[1:].lstrip()
+
+ # Strip parenthetical unit suffix
+ if " (" in title and title.endswith(")"):
+ paren_idx = title.rfind(" (")
+ if paren_idx > 0:
+ title = title[:paren_idx]
+
+ # Strip common unit qualifiers that can trail titles
+ unit_suffixes = [", Transactions", ", Stocks", ", Flows"]
+ for suffix in unit_suffixes:
+ if title.endswith(suffix):
+ title = title[: -len(suffix)]
+ break
+
+ return title
+
+ def _nearest_non_bop_ancestor_title(parent_id: Any) -> str | None:
+ pid = str(parent_id) if parent_id else ""
+ safety = 0
+ while pid and safety < 50:
+ safety += 1
+ parent_df = _lookup_parent_row(pid)
+ if len(parent_df) == 0:
+ return None
+ parent_first = parent_df.iloc[0]
+ parent_title = _normalize_title(str(parent_first.get("title") or ""))
+ if (
+ parent_title
+ and not is_bop_suffix_only(parent_title)
+ and not parent_title.endswith((", Net", ", Credit", ", Debit"))
+ ):
+ return parent_title
+ pid = str(parent_first.get("parent_id") or "")
+ return None
+
+ # OUTER LOOP: Iterate by sorted_orders (ITEM first)
+ for order in sorted_orders:
+ order_df = df[df["order"] == order]
+ if order_df.empty:
+ continue
+ first = order_df.iloc[0]
+ level = first["level"] or 0
+
+ # Clear deeper header context when we move up the tree.
+ for k in [k for k in last_meaningful_header_by_level if k > level]:
+ del last_meaningful_header_by_level[k]
+
+ is_header = first["is_category_header"]
+ title = first["title"] or ""
+ original_unit_suffix = ""
+
+ # Strip parenthetical unit suffix
+ if " (" in title and title.endswith(")"):
+ paren_idx = title.rfind(" (")
+ if paren_idx > 0:
+ original_unit_suffix = title[paren_idx:]
+ title = title[:paren_idx]
+
+ unit_suffixes = [", Transactions", ", Stocks", ", Flows"]
+ for suffix in unit_suffixes:
+ if title.endswith(suffix):
+ title = title[: -len(suffix)]
+ break
+
+ # Determine if this order should be rendered as a header
+ is_promoted_header = (
+ not is_header
+ and order in global_parent_orders
+ and order not in all_orders_with_data
+ )
+ # Only render as header if it doesn't have data of its own
+ should_render_as_header = (
+ is_header or is_promoted_header
+ ) and order not in all_orders_with_data
+
+ # Skip headers that don't lead to any data
+ if should_render_as_header and order not in global_parent_orders:
+ # If this is a BOP-only accounting-entry header (Net/Credit/Debit/etc.),
+ # track it even when skipped for "no data" so descendants can be promoted.
+ if is_bop_suffix_only(title):
+ _track_skipped_parent_ids(first.to_dict())
+ continue
+
+ # Skip phantom BOP headers that are just "Net", "Credit", "Debit", etc.
+ # Record them so descendants can be promoted (prevents Debit nesting under Credit
+ # when an intermediate accounting-entry node is hidden).
+ if should_render_as_header and is_bop_suffix_only(title):
+ _track_skipped_parent_ids(first.to_dict())
+ continue
+
+ # If a row's parent (or higher ancestor) was skipped as a BOP-only header,
+ # promote it so it doesn't appear as a child of the wrong visible node.
+ level = _promote_level_if_parent_skipped(level, first.get("parent_id"))
+
+ # ISORA: Only show topic headers
+ if is_isora and should_render_as_header:
+ if title and "___" in title:
+ continue
+ is_topic = bool(
+ title
+ and (
+ re.match(r"^\d+\.\s", title)
+ or "INDICATORS BY TOPIC" in title.upper()
+ )
+ )
+ if not is_topic:
+ continue
+
+ # Headers get minimal simplification
+ if should_render_as_header:
+ if title.startswith("Financial corporations, "):
+ title = title[len("Financial corporations, ") :]
+ elif title.startswith("Depository corporations, "):
+ title = title[len("Depository corporations, ") :]
+ else:
+ # Strip ancestor title prefixes
+ best_prefix = hierarchy_ctx.find_best_prefix(order, title, parent_orders)
+ if best_prefix and title.startswith(best_prefix):
+ relative = title[len(best_prefix) :].lstrip(", :")
+ # Don't strip if it would leave only a BOP suffix
+ if (
+ relative
+ and title != best_prefix
+ and not is_bop_suffix_only(relative)
+ ):
+ title = relative
+
+ while True:
+ best_suffix = hierarchy_ctx.find_best_suffix(
+ order, title, parent_orders
+ )
+ if best_suffix and title.endswith(best_suffix):
+ title = title[: -len(best_suffix)]
+ else:
+ break
+
+ while True:
+ part_prefix = hierarchy_ctx.find_ancestor_part_prefix(
+ order, title, parent_orders
+ )
+ if part_prefix and title.startswith(part_prefix):
+ title = title[len(part_prefix) :]
+ else:
+ break
+
+ # Update header context for this level, or (for BOP suffix rows) inherit
+ # the nearest meaningful header when the row's base is a strict prefix.
+ if should_render_as_header:
+ header_base = title.strip()
+ if header_base and not is_bop_suffix_only(header_base):
+ last_meaningful_header_by_level[level] = header_base
+ else:
+ for bop_suffix in (", Net", ", Credit", ", Debit"):
+ if title.endswith(bop_suffix):
+ base = title[: -len(bop_suffix)].strip()
+ ancestor_title: str | None = None
+ for ancestor_level in range(level - 1, -1, -1):
+ cand = last_meaningful_header_by_level.get(ancestor_level)
+ if not cand:
+ continue
+ if cand.endswith((", Net", ", Credit", ", Debit")):
+ continue
+ ancestor_title = cand
+ break
+
+ if (
+ ancestor_title
+ and ancestor_title != base
+ and ancestor_title.startswith(base)
+ ):
+ title = f"{ancestor_title}{bop_suffix}"
+ break
+
+ # Calculate indent
+ extra_indent = " " if should_add_table_header else ""
+ indent = extra_indent + " " * level
+
+ prefix = "▸ " if should_render_as_header else " "
+
+ if should_render_as_header:
+ if order in global_parent_orders:
+ if is_isora and title and "___" in title:
+ continue
+
+ header_title = title
+ if has_uniform_unit_scale:
+ if level == 0 and uniform_suffix:
+ header_title += uniform_suffix
+ elif original_unit_suffix:
+ header_title += original_unit_suffix
+
+ # Check if this header order also has data
+ order_data_rows = [dr for dr in data_rows if dr["order"] == order]
+ if order_data_rows:
+ # Header WITH data - render data rows with header styling
+ order_dim_groups = order_to_dim_data.get(order, [])
+ if order_dim_groups and len(order_dim_groups[0][1]) > 1:
+ # Multiple dimension values - show header then dimension breakdown
+ row = {
+ "title": f"{indent}{prefix}{header_title}",
+ "country": "",
+ }
+ for d in dates:
+ row[str(d)] = ""
+ rows.append(row)
+ # Render dimension breakdown under header
+ for grouping_key, dim_data_rows in sorted(order_dim_groups):
+ if grouping_key:
+ dim_label = format_dim_labels(grouping_key)
+ for dr in dim_data_rows:
+ dim_indent = extra_indent + " " * (level + 1)
+ row = {
+ "title": f"{dim_indent}{dim_label}",
+ "country": dr["country"],
+ }
+ row.update(dr["values"])
+ rows.append(row)
+ else:
+ # Single or no dimension - show header row with its data
+ for dr in order_data_rows:
+ row = {
+ "title": f"{indent}{prefix}{header_title}",
+ "country": dr["country"],
+ }
+ row.update(dr["values"])
+ rows.append(row)
+ else:
+ # Pure header - no data
+ row = {
+ "title": f"{indent}{prefix}{header_title}",
+ "country": "",
+ }
+ for d in dates:
+ row[str(d)] = ""
+ rows.append(row)
+ else:
+ # Data row - check if THIS specific order has multiple dimension values
+ order_dim_groups = order_to_dim_data.get(order, [])
+ # Has multi dims if there's more than one grouping key OR
+ # if a single grouping key has multiple rows (multiple counterpart countries, etc.)
+ order_has_multi_dims = len(order_dim_groups) > 1 or (
+ len(order_dim_groups) == 1 and len(order_dim_groups[0][1]) > 1
+ )
+
+ if order_has_multi_dims:
+ # Multiple dimension groups for this item - show breakdown
+ data_level = level
+ data_indent = extra_indent + " " * data_level
+
+ display_title = title
+ if data_level == 0 and uniform_suffix and not should_add_table_header:
+ display_title += uniform_suffix
+ elif not has_uniform_unit_scale:
+ # Get unit from first data row for this order
+ first_dr = order_dim_groups[0][1][0] if order_dim_groups else None
+ if first_dr:
+ row_unit_suffix = format_unit_suffix(
+ first_dr.get("unit"), first_dr.get("scale")
+ )
+ display_title += row_unit_suffix
+
+ # Find "World" or top-level aggregate to show on parent row
+ # World is typically code "G001" for COUNTERPART_COUNTRY dimension
+ world_grouping_key = None
+ world_data_row = None
+ for gk, drs in order_dim_groups:
+ if gk:
+ for dim_id, code, label in gk:
+ if dim_id == "COUNTERPART_COUNTRY" and (
+ code == "G001" or label == "World"
+ ):
+ world_grouping_key = gk
+ world_data_row = drs[0] if drs else None
+ break
+ if world_grouping_key:
+ break
+
+ # Item row - shows World data if available, otherwise empty
+ item_row: dict[str, Any] = {
+ "title": f"{data_indent}{display_title}",
+ "country": world_data_row["country"] if world_data_row else "",
+ }
+ if world_data_row:
+ item_row.update(world_data_row["values"])
+ else:
+ for d in dates:
+ item_row[str(d)] = ""
+ rows.append(item_row)
+
+ # Check if this is a COUNTERPART_COUNTRY breakdown
+ has_counterpart_country = any(
+ dim_id == "COUNTERPART_COUNTRY"
+ for gk, _ in order_dim_groups
+ if gk
+ for dim_id, _, _ in gk
+ )
+
+ if has_counterpart_country:
+ # Separate groups from countries and order: groups first, then countries
+ cc_indent = extra_indent + " " * (data_level + 1)
+
+ # Helper to detect if a code is a group vs individual country
+ # ISO country codes are 3 letters (e.g., USA, GBR, CHN)
+ # Group codes have letter+digit patterns (e.g., G001, GX225, U005, TX983)
+ def is_group_code(code: str) -> bool:
+ """Group codes have letter(s) followed by digits."""
+ # ISO codes are exactly 3 uppercase letters
+ if re.match(r"^[A-Z]{3}$", code):
+ return False
+ # Group codes: G###, GX###, U###, TX###
+ return bool(re.match(r"^[A-Z]+\d+$", code))
+
+ # Collect groups and individual countries separately
+ groups: list[tuple[tuple, list[dict], str, str]] = []
+ individual_countries: list[tuple[tuple, list[dict], str, str]] = []
+
+ for gk, drs in order_dim_groups:
+ if gk == world_grouping_key:
+ continue # Skip World - already on parent
+ if gk:
+ cc_code = None
+ cc_label = None
+ for dim_id, code, label in gk:
+ if dim_id == "COUNTERPART_COUNTRY":
+ cc_code = code
+ cc_label = label
+ break
+
+ if cc_label and cc_code:
+ if is_group_code(cc_code):
+ groups.append((gk, drs, cc_label, cc_code))
+ else:
+ individual_countries.append(
+ (gk, drs, cc_label, cc_code)
+ )
+
+ # Helper to get the max absolute value for sorting
+ def get_sort_value(item: tuple) -> float:
+ """Get the first numeric value from data rows for sorting."""
+ _, drs, _, _ = item
+ for dr in drs:
+ for v in dr.get("values", {}).values():
+ if v is not None:
+ try:
+ return abs(float(v))
+ except (ValueError, TypeError):
+ pass
+ return 0.0
+
+ # Render groups first (sorted by value, highest first),
+ # then countries (sorted by value, highest first)
+ for gk, drs, cc_label, cc_code in sorted(
+ groups, key=get_sort_value, reverse=True
+ ):
+ for dr in drs:
+ if all(d == 0 or d is None for d in dr["values"].values()):
+ continue # Skip zero-value countries
+ row = {
+ "title": f"{cc_indent}▸ {cc_label}",
+ "country": dr["country"],
+ }
+ row.update(dr["values"])
+ rows.append(row)
+
+ for gk, drs, cc_label, cc_code in sorted(
+ individual_countries, key=get_sort_value, reverse=True
+ ):
+ for dr in drs:
+ if all(d == 0 or d is None for d in dr["values"].values()):
+ continue # Skip zero-value countries
+ row = {
+ "title": f"{cc_indent} {cc_label}",
+ "country": dr["country"],
+ }
+ row.update(dr["values"])
+ rows.append(row)
+ else:
+ # Non-counterpart-country dimension breakdown (e.g., SECTOR)
+ for grouping_key, dim_data_rows in sorted(order_dim_groups):
+ if grouping_key == world_grouping_key:
+ continue
+ if grouping_key:
+ dim_label = format_dim_labels(grouping_key)
+ for dr in dim_data_rows:
+ sector_indent = extra_indent + " " * (data_level + 1)
+ row = {
+ "title": f"{sector_indent} {dim_label}",
+ "country": dr["country"],
+ }
+ row.update(dr["values"])
+ rows.append(row)
+ else:
+ for dr in dim_data_rows:
+ row = {
+ "title": f"{data_indent}{display_title}",
+ "country": dr["country"],
+ }
+ row.update(dr["values"])
+ rows.append(row)
+ else:
+ # Single dimension group - original behavior
+ for dr in data_rows:
+ if dr["order"] == order:
+ # Use the already-corrected level (includes BOP overrides)
+ data_level = level
+ data_indent = extra_indent + " " * data_level
+
+ # Use the stripped title (from BOP child processing above), not dr["title"]
+ display_title = title
+ if (
+ data_level == 0
+ and uniform_suffix
+ and not should_add_table_header
+ ):
+ display_title += uniform_suffix
+ elif not has_uniform_unit_scale:
+ row_unit_suffix = format_unit_suffix(
+ dr.get("unit"), dr.get("scale")
+ )
+ display_title += row_unit_suffix
+
+ row = {
+ "title": f"{data_indent}{display_title}",
+ "country": dr["country"],
+ }
+ row.update(dr["values"])
+ rows.append(row)
+
+ result_df = pd.DataFrame(rows)
+ result_df = result_df.set_index(["title", "country"])
+
+ return result_df
+
+
+def pivot_table_data(
+ result: list[Any],
+ country: str,
+ limit: int | None,
+ metadata: dict[str, Any],
+) -> "pd.DataFrame":
+ """Pivot table data based on whether hierarchy exists.
+
+ This function determines whether to use indicator mode (simple pivot)
+ or table mode (hierarchical pivot) based on the data.
+
+ Parameters
+ ----------
+ result : list[Any]
+ List of ImfEconomicIndicatorsData records to pivot.
+ country : str
+ Comma-separated country codes from the query.
+ limit : int | None
+ Maximum number of date columns to show.
+ metadata : dict[str, Any]
+ Metadata dictionary containing table information.
+
+ Returns
+ -------
+ pd.DataFrame
+ Pivoted DataFrame with appropriate structure.
+ """
+ # pylint: disable=import-outside-toplevel
+ from pandas import DataFrame
+
+ df = DataFrame(result)
+ all_dates = sorted(df["date"].dropna().unique().tolist(), reverse=True)
+ dates = all_dates[:limit] if limit is not None and limit > 0 else all_dates
+ countries = sorted(df["country"].dropna().unique().tolist())
+
+ # Check if any requested countries have no data for the selected dates
+ # and warn the user
+ if country and dates:
+ requested_countries = [c.strip() for c in country.split(",")]
+ check_missing_country_data(df, requested_countries, dates, countries)
+
+ has_hierarchy = df["order"].notna().any() if "order" in df.columns else False
+
+ if not has_hierarchy:
+ return pivot_indicator_mode(df, dates, countries)
+
+ return pivot_table_mode(df, dates, countries, metadata)
diff --git a/openbb_platform/providers/imf/openbb_imf/views/port_info.py b/openbb_platform/providers/imf/openbb_imf/views/port_info.py
index e28e7814ee9..8cacbddfef8 100644
--- a/openbb_platform/providers/imf/openbb_imf/views/port_info.py
+++ b/openbb_platform/providers/imf/openbb_imf/views/port_info.py
@@ -157,7 +157,7 @@ def plot_port_info_map(data: list[ImfPortInfoData]):
custom_data=df[["hover_html"]],
size="marker_size",
size_max=max_size,
- color_discrete_sequence=["fuchsia"],
+ color_discrete_sequence=["#7F7F7F"],
opacity=0.4,
height=600,
width=600,
diff --git a/openbb_platform/providers/imf/poetry.lock b/openbb_platform/providers/imf/poetry.lock
index 04590eda761..44f7bfbe62a 100644
--- a/openbb_platform/providers/imf/poetry.lock
+++ b/openbb_platform/providers/imf/poetry.lock
@@ -418,6 +418,42 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
+[[package]]
+name = "deepdiff"
+version = "8.6.1"
+description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b"},
+ {file = "deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a"},
+]
+
+[package.dependencies]
+orderly-set = ">=5.4.1,<6"
+
+[package.extras]
+cli = ["click (>=8.1.0,<8.2.0)", "pyyaml (>=6.0.0,<6.1.0)"]
+coverage = ["coverage (>=7.6.0,<7.7.0)"]
+dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)", "jsonpickle (>=4.0.0,<4.1.0)", "nox (==2025.5.1)", "numpy (>=2.0,<3.0) ; python_version < \"3.10\"", "numpy (>=2.2.0,<2.3.0) ; python_version >= \"3.10\"", "orjson (>=3.10.0,<3.11.0)", "pandas (>=2.2.0,<2.3.0)", "polars (>=1.21.0,<1.22.0)", "python-dateutil (>=2.9.0,<2.10.0)", "tomli (>=2.2.0,<2.3.0)", "tomli-w (>=1.2.0,<1.3.0)", "uuid6 (==2025.0.1)"]
+docs = ["Sphinx (>=6.2.0,<6.3.0)", "sphinx-sitemap (>=2.6.0,<2.7.0)", "sphinxemoji (>=0.3.0,<0.4.0)"]
+optimize = ["orjson"]
+static = ["flake8 (>=7.1.0,<7.2.0)", "flake8-pyproject (>=1.2.3,<1.3.0)", "pydantic (>=2.10.0,<2.11.0)"]
+test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest-cov (>=6.0.0,<6.1.0)", "python-dotenv (>=1.0.0,<1.1.0)"]
+
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+description = "XML bomb protection for Python stdlib modules"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+groups = ["main"]
+files = [
+ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
+ {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
+]
+
[[package]]
name = "exceptiongroup"
version = "1.3.1"
@@ -460,6 +496,22 @@ all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
+[[package]]
+name = "fastjsonschema"
+version = "2.21.2"
+description = "Fastest Python implementation of JSON schema"
+optional = true
+python-versions = "*"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463"},
+ {file = "fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de"},
+]
+
+[package.extras]
+devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"]
+
[[package]]
name = "frozenlist"
version = "1.8.0"
@@ -651,14 +703,14 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2
[[package]]
name = "importlib-metadata"
-version = "8.7.0"
+version = "8.7.1"
description = "Read metadata from Python packages"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"},
- {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"},
+ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"},
+ {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"},
]
[package.dependencies]
@@ -668,10 +720,102 @@ zipp = ">=3.20"
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-enabler = ["pytest-enabler (>=2.2)"]
+enabler = ["pytest-enabler (>=3.4)"]
perf = ["ipython"]
-test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
-type = ["pytest-mypy"]
+test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
+type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"]
+
+[[package]]
+name = "jsonschema"
+version = "4.25.1"
+description = "An implementation of JSON Schema validation for Python"
+optional = true
+python-versions = ">=3.9"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"},
+ {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"},
+]
+
+[package.dependencies]
+attrs = ">=22.2.0"
+jsonschema-specifications = ">=2023.03.6"
+referencing = ">=0.28.4"
+rpds-py = ">=0.7.1"
+
+[package.extras]
+format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"]
+format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry"
+optional = true
+python-versions = ">=3.9"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"},
+ {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"},
+]
+
+[package.dependencies]
+referencing = ">=0.31.0"
+
+[[package]]
+name = "jupyter-core"
+version = "5.9.1"
+description = "Jupyter core package. A base package on which Jupyter projects rely."
+optional = true
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407"},
+ {file = "jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508"},
+]
+
+[package.dependencies]
+platformdirs = ">=2.5"
+traitlets = ">=5.3"
+
+[package.extras]
+docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx-autodoc-typehints", "sphinxcontrib-spelling", "traitlets"]
+test = ["ipykernel", "pre-commit", "pytest (<9)", "pytest-cov", "pytest-timeout"]
+
+[[package]]
+name = "llvmlite"
+version = "0.46.0"
+description = "lightweight wrapper around basic LLVM functionality"
+optional = true
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "llvmlite-0.46.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4323177e936d61ae0f73e653e2e614284d97d14d5dd12579adc92b6c2b0597b0"},
+ {file = "llvmlite-0.46.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a2d461cb89537b7c20feb04c46c32e12d5ad4f0896c9dfc0f60336219ff248e"},
+ {file = "llvmlite-0.46.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1f6595a35b7b39c3518b85a28bf18f45e075264e4b2dce3f0c2a4f232b4a910"},
+ {file = "llvmlite-0.46.0-cp310-cp310-win_amd64.whl", hash = "sha256:e7a34d4aa6f9a97ee006b504be6d2b8cb7f755b80ab2f344dda1ef992f828559"},
+ {file = "llvmlite-0.46.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82f3d39b16f19aa1a56d5fe625883a6ab600d5cc9ea8906cca70ce94cabba067"},
+ {file = "llvmlite-0.46.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3df43900119803bbc52720e758c76f316a9a0f34612a886862dfe0a5591a17e"},
+ {file = "llvmlite-0.46.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de183fefc8022d21b0aa37fc3e90410bc3524aed8617f0ff76732fc6c3af5361"},
+ {file = "llvmlite-0.46.0-cp311-cp311-win_amd64.whl", hash = "sha256:e8b10bc585c58bdffec9e0c309bb7d51be1f2f15e169a4b4d42f2389e431eb93"},
+ {file = "llvmlite-0.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b9588ad4c63b4f0175a3984b85494f0c927c6b001e3a246a3a7fb3920d9a137"},
+ {file = "llvmlite-0.46.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3535bd2bb6a2d7ae4012681ac228e5132cdb75fefb1bcb24e33f2f3e0c865ed4"},
+ {file = "llvmlite-0.46.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cbfd366e60ff87ea6cc62f50bc4cd800ebb13ed4c149466f50cf2163a473d1e"},
+ {file = "llvmlite-0.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:398b39db462c39563a97b912d4f2866cd37cba60537975a09679b28fbbc0fb38"},
+ {file = "llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172"},
+ {file = "llvmlite-0.46.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6cc19b051753368a9c9f31dc041299059ee91aceec81bd57b0e385e5d5bf1a54"},
+ {file = "llvmlite-0.46.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bca185892908f9ede48c0acd547fe4dc1bafefb8a4967d47db6cf664f9332d12"},
+ {file = "llvmlite-0.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:67438fd30e12349ebb054d86a5a1a57fd5e87d264d2451bcfafbbbaa25b82a35"},
+ {file = "llvmlite-0.46.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:d252edfb9f4ac1fcf20652258e3f102b26b03eef738dc8a6ffdab7d7d341d547"},
+ {file = "llvmlite-0.46.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379fdd1c59badeff8982cb47e4694a6143bec3bb49aa10a466e095410522064d"},
+ {file = "llvmlite-0.46.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e8cbfff7f6db0fa2c771ad24154e2a7e457c2444d7673e6de06b8b698c3b269"},
+ {file = "llvmlite-0.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:7821eda3ec1f18050f981819756631d60b6d7ab1a6cf806d9efefbe3f4082d61"},
+ {file = "llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb"},
+]
[[package]]
name = "multidict"
@@ -832,6 +976,91 @@ files = [
[package.dependencies]
typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""}
+[[package]]
+name = "narwhals"
+version = "2.14.0"
+description = "Extremely lightweight compatibility layer between dataframe libraries"
+optional = true
+python-versions = ">=3.9"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "narwhals-2.14.0-py3-none-any.whl", hash = "sha256:b56796c9a00179bd757d15282c540024e1d5c910b19b8c9944d836566c030acf"},
+ {file = "narwhals-2.14.0.tar.gz", hash = "sha256:98be155c3599db4d5c211e565c3190c398c87e7bf5b3cdb157dece67641946e0"},
+]
+
+[package.extras]
+cudf = ["cudf (>=24.10.0)"]
+dask = ["dask[dataframe] (>=2024.8)"]
+duckdb = ["duckdb (>=1.1)"]
+ibis = ["ibis-framework (>=6.0.0)", "packaging", "pyarrow-hotfix", "rich"]
+modin = ["modin"]
+pandas = ["pandas (>=1.1.3)"]
+polars = ["polars (>=0.20.4)"]
+pyarrow = ["pyarrow (>=13.0.0)"]
+pyspark = ["pyspark (>=3.5.0)"]
+pyspark-connect = ["pyspark[connect] (>=3.5.0)"]
+sqlframe = ["sqlframe (>=3.22.0,!=3.39.3)"]
+
+[[package]]
+name = "nbformat"
+version = "5.10.4"
+description = "The Jupyter Notebook format"
+optional = true
+python-versions = ">=3.8"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b"},
+ {file = "nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a"},
+]
+
+[package.dependencies]
+fastjsonschema = ">=2.15"
+jsonschema = ">=2.6"
+jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0"
+traitlets = ">=5.1"
+
+[package.extras]
+docs = ["myst-parser", "pydata-sphinx-theme", "sphinx", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"]
+test = ["pep440", "pre-commit", "pytest", "testpath"]
+
+[[package]]
+name = "numba"
+version = "0.63.1"
+description = "compiling Python code using LLVM"
+optional = true
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "numba-0.63.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6d6bf5bf00f7db629305caaec82a2ffb8abe2bf45eaad0d0738dc7de4113779"},
+ {file = "numba-0.63.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08653d0dfc9cc9c4c9a8fba29ceb1f2d5340c3b86c4a7e5e07e42b643bc6a2f4"},
+ {file = "numba-0.63.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09eebf5650246ce2a4e9a8d38270e2d4b0b0ae978103bafb38ed7adc5ea906e"},
+ {file = "numba-0.63.1-cp310-cp310-win_amd64.whl", hash = "sha256:f8bba17421d865d8c0f7be2142754ebce53e009daba41c44cf6909207d1a8d7d"},
+ {file = "numba-0.63.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b33db00f18ccc790ee9911ce03fcdfe9d5124637d1ecc266f5ae0df06e02fec3"},
+ {file = "numba-0.63.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d31ea186a78a7c0f6b1b2a3fe68057fdb291b045c52d86232b5383b6cf4fc25"},
+ {file = "numba-0.63.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed3bb2fbdb651d6aac394388130a7001aab6f4541837123a4b4ab8b02716530c"},
+ {file = "numba-0.63.1-cp311-cp311-win_amd64.whl", hash = "sha256:1ecbff7688f044b1601be70113e2fb1835367ee0b28ffa8f3adf3a05418c5c87"},
+ {file = "numba-0.63.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2819cd52afa5d8d04e057bdfd54367575105f8829350d8fb5e4066fb7591cc71"},
+ {file = "numba-0.63.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5cfd45dbd3d409e713b1ccfdc2ee72ca82006860254429f4ef01867fdba5845f"},
+ {file = "numba-0.63.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69a599df6976c03b7ecf15d05302696f79f7e6d10d620367407517943355bcb0"},
+ {file = "numba-0.63.1-cp312-cp312-win_amd64.whl", hash = "sha256:bbad8c63e4fc7eb3cdb2c2da52178e180419f7969f9a685f283b313a70b92af3"},
+ {file = "numba-0.63.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:0bd4fd820ef7442dcc07da184c3f54bb41d2bdb7b35bacf3448e73d081f730dc"},
+ {file = "numba-0.63.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53de693abe4be3bd4dee38e1c55f01c55ff644a6a3696a3670589e6e4c39cde2"},
+ {file = "numba-0.63.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81227821a72a763c3d4ac290abbb4371d855b59fdf85d5af22a47c0e86bf8c7e"},
+ {file = "numba-0.63.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb227b07c2ac37b09432a9bda5142047a2d1055646e089d4a240a2643e508102"},
+ {file = "numba-0.63.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f180883e5508940cc83de8a8bea37fc6dd20fbe4e5558d4659b8b9bef5ff4731"},
+ {file = "numba-0.63.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0938764afa82a47c0e895637a6c55547a42c9e1d35cac42285b1fa60a8b02bb"},
+ {file = "numba-0.63.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f90a929fa5094e062d4e0368ede1f4497d5e40f800e80aa5222c4734236a2894"},
+ {file = "numba-0.63.1-cp314-cp314-win_amd64.whl", hash = "sha256:8d6d5ce85f572ed4e1a135dbb8c0114538f9dd0e3657eeb0bb64ab204cbe2a8f"},
+ {file = "numba-0.63.1.tar.gz", hash = "sha256:b320aa675d0e3b17b40364935ea52a7b1c670c9037c39cf92c49502a75902f4b"},
+]
+
+[package.dependencies]
+llvmlite = "==0.46.*"
+numpy = ">=1.22,<2.4"
+
[[package]]
name = "numpy"
version = "2.2.6"
@@ -983,6 +1212,28 @@ files = [
{file = "numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0"},
]
+[[package]]
+name = "openbb-charting"
+version = "2.4.0"
+description = "Charting extension for OpenBB"
+optional = true
+python-versions = "<3.14,>=3.9.21"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "openbb_charting-2.4.0-py3-none-any.whl", hash = "sha256:137f130094bd20b822a8af8355b7e9d2c3f9a1373d5495800f8e26b6bbf33120"},
+ {file = "openbb_charting-2.4.0.tar.gz", hash = "sha256:522c8f8a039562dd7d25c675a399956969959e2e157cab7ba9e720e6431ab4ec"},
+]
+
+[package.dependencies]
+nbformat = ">=5.10.0,<6.0.0"
+openbb-core = ">=1.5.1,<2.0.0"
+pandas-ta-openbb = ">=0.4.22,<0.5.0"
+plotly = ">=6.3.1,<7.0.0"
+
+[package.extras]
+pywry = ["pywry (>=0.6.2,<0.7.0)"]
+
[[package]]
name = "openbb-core"
version = "1.5.6"
@@ -1011,6 +1262,69 @@ uuid7 = ">=0.1.0,<0.2.0"
uvicorn = ">=0.35.0,<0.36.0"
websockets = ">=15.0,<16.0"
+[[package]]
+name = "openbb-economy"
+version = "1.5.0"
+description = "Economy extension for OpenBB"
+optional = false
+python-versions = "<3.14,>=3.9.21"
+groups = ["main"]
+files = [
+ {file = "openbb_economy-1.5.0-py3-none-any.whl", hash = "sha256:cad012ed440faa8d286575dc846444eaf54f5bf95505cc4617f88d46b40d4bb4"},
+ {file = "openbb_economy-1.5.0.tar.gz", hash = "sha256:1b17c0316079ab3f3303979ecb26064bc44527cf9a2bb40c401d94d6081c165c"},
+]
+
+[package.dependencies]
+openbb-core = ">=1.5.1,<2.0.0"
+
+[[package]]
+name = "openbb-platform-api"
+version = "1.2.2"
+description = "OpenBB Platform API: Launch script and widgets builder for the Open Data Platform REST API and Workspace Backend Connector."
+optional = false
+python-versions = "<3.14,>=3.10"
+groups = ["main"]
+files = [
+ {file = "openbb_platform_api-1.2.2-py3-none-any.whl", hash = "sha256:df5d8db50794d2d6e0d58f3496075df28e5280e8dace55704927eec86196d6bc"},
+ {file = "openbb_platform_api-1.2.2.tar.gz", hash = "sha256:a2c50a650ac47ace5db6c4b1e1a7de93dce49cffc2898e3cb8d8e6503ce8abb6"},
+]
+
+[package.dependencies]
+deepdiff = ">=8.6.1"
+openbb-core = ">=1.5.6,<2.0.0"
+
+[[package]]
+name = "orderly-set"
+version = "5.5.0"
+description = "Orderly set"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7"},
+ {file = "orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce"},
+]
+
+[package.extras]
+coverage = ["coverage (>=7.6.0,<7.7.0)"]
+dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)"]
+optimize = ["orjson"]
+static = ["flake8 (>=7.1.0,<7.2.0)", "flake8-pyproject (>=1.2.3,<1.3.0)"]
+test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest-cov (>=6.0.0,<6.1.0)", "python-dotenv (>=1.0.0,<1.1.0)"]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+description = "Core utilities for Python packages"
+optional = true
+python-versions = ">=3.8"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
+ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
+]
+
[[package]]
name = "pandas"
version = "2.3.3"
@@ -1111,6 +1425,88 @@ sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-d
test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"]
xml = ["lxml (>=4.9.2)"]
+[[package]]
+name = "pandas-ta-openbb"
+version = "0.4.22"
+description = "Fork of pandas-ta for use with NumPy 2 and OpenBB."
+optional = true
+python-versions = "<3.14,>=3.9.21"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "pandas_ta_openbb-0.4.22-py3-none-any.whl", hash = "sha256:ea357cc014952fbb0a2d95edfceffec0a5d8659c02eb2468ab6e11bacfbd5706"},
+ {file = "pandas_ta_openbb-0.4.22.tar.gz", hash = "sha256:fdea8000b54c929c904e09ed703171e597d0d57af1cc7318afcff9cb717e8745"},
+]
+
+[package.dependencies]
+numba = {version = ">=0.61.0", markers = "python_version >= \"3.10\""}
+numpy = ">=1.26.4"
+pandas = ">=2.2.0"
+scipy = "<=1.15.3"
+statsmodels = ">=0.14.0,<0.15.0"
+
+[[package]]
+name = "patsy"
+version = "1.0.2"
+description = "A Python package for describing statistical models and for building design matrices."
+optional = true
+python-versions = ">=3.6"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a"},
+ {file = "patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0"},
+]
+
+[package.dependencies]
+numpy = ">=1.4"
+
+[package.extras]
+test = ["pytest", "pytest-cov", "scipy"]
+
+[[package]]
+name = "platformdirs"
+version = "4.5.1"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
+optional = true
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"},
+ {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"},
+]
+
+[package.extras]
+docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"]
+type = ["mypy (>=1.18.2)"]
+
+[[package]]
+name = "plotly"
+version = "6.5.0"
+description = "An open-source interactive data visualization library for Python"
+optional = true
+python-versions = ">=3.8"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "plotly-6.5.0-py3-none-any.whl", hash = "sha256:5ac851e100367735250206788a2b1325412aa4a4917a4fe3e6f0bc5aa6f3d90a"},
+ {file = "plotly-6.5.0.tar.gz", hash = "sha256:d5d38224883fd38c1409bef7d6a8dc32b74348d39313f3c52ca998b8e447f5c8"},
+]
+
+[package.dependencies]
+narwhals = ">=1.15.1"
+packaging = "*"
+
+[package.extras]
+dev = ["plotly[dev-optional]"]
+dev-build = ["build", "jupyter", "plotly[dev-core]"]
+dev-core = ["pytest", "requests", "ruff (==0.11.12)"]
+dev-optional = ["anywidget", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "numpy", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "plotly[dev-build]", "plotly[kaleido]", "polars[timezone]", "pyarrow", "pyshp", "pytz", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"]
+express = ["numpy"]
+kaleido = ["kaleido (>=1.1.0)"]
+
[[package]]
name = "propcache"
version = "0.4.1"
@@ -1471,6 +1867,24 @@ files = [
{file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"},
]
+[[package]]
+name = "referencing"
+version = "0.37.0"
+description = "JSON Referencing + Python"
+optional = true
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"},
+ {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"},
+]
+
+[package.dependencies]
+attrs = ">=22.2.0"
+rpds-py = ">=0.7.0"
+typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
+
[[package]]
name = "requests"
version = "2.32.5"
@@ -1493,6 +1907,132 @@ urllib3 = ">=1.21.1,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+description = "Python bindings to Rust's persistent data structures (rpds)"
+optional = true
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"},
+ {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"},
+ {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"},
+ {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"},
+ {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"},
+ {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"},
+ {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"},
+ {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"},
+ {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"},
+ {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"},
+ {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"},
+ {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"},
+ {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"},
+ {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"},
+ {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"},
+ {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"},
+ {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"},
+ {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"},
+ {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"},
+ {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"},
+ {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"},
+ {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"},
+ {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"},
+ {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"},
+ {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"},
+ {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"},
+ {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"},
+ {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"},
+ {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"},
+ {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"},
+ {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"},
+ {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"},
+ {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"},
+ {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"},
+ {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"},
+ {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"},
+ {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"},
+ {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"},
+ {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"},
+ {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"},
+]
+
[[package]]
name = "ruff"
version = "0.13.3"
@@ -1522,6 +2062,71 @@ files = [
{file = "ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e"},
]
+[[package]]
+name = "scipy"
+version = "1.15.3"
+description = "Fundamental algorithms for scientific computing in Python"
+optional = true
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c"},
+ {file = "scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253"},
+ {file = "scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f"},
+ {file = "scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92"},
+ {file = "scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82"},
+ {file = "scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40"},
+ {file = "scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e"},
+ {file = "scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c"},
+ {file = "scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13"},
+ {file = "scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b"},
+ {file = "scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba"},
+ {file = "scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65"},
+ {file = "scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1"},
+ {file = "scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889"},
+ {file = "scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982"},
+ {file = "scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9"},
+ {file = "scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594"},
+ {file = "scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb"},
+ {file = "scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019"},
+ {file = "scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6"},
+ {file = "scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477"},
+ {file = "scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c"},
+ {file = "scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45"},
+ {file = "scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49"},
+ {file = "scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e"},
+ {file = "scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539"},
+ {file = "scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed"},
+ {file = "scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759"},
+ {file = "scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62"},
+ {file = "scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb"},
+ {file = "scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730"},
+ {file = "scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825"},
+ {file = "scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7"},
+ {file = "scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11"},
+ {file = "scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126"},
+ {file = "scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163"},
+ {file = "scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8"},
+ {file = "scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5"},
+ {file = "scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e"},
+ {file = "scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb"},
+ {file = "scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723"},
+ {file = "scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb"},
+ {file = "scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4"},
+ {file = "scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5"},
+ {file = "scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca"},
+ {file = "scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf"},
+]
+
+[package.dependencies]
+numpy = ">=1.23.5,<2.5"
+
+[package.extras]
+dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"]
+doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.0.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"]
+test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"]
+
[[package]]
name = "six"
version = "1.17.0"
@@ -1553,6 +2158,82 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
+[[package]]
+name = "statsmodels"
+version = "0.14.6"
+description = "Statistical computations and models for Python"
+optional = true
+python-versions = ">=3.9"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "statsmodels-0.14.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4ff0649a2df674c7ffb6fa1a06bffdb82a6adf09a48e90e000a15a6aaa734b0"},
+ {file = "statsmodels-0.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:109012088b3e370080846ab053c76d125268631410142daad2f8c10770e8e8d9"},
+ {file = "statsmodels-0.14.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e93bd5d220f3cb6fc5fc1bffd5b094966cab8ee99f6c57c02e95710513d6ac3f"},
+ {file = "statsmodels-0.14.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06eec42d682fdb09fe5d70a05930857efb141754ec5a5056a03304c1b5e32fd9"},
+ {file = "statsmodels-0.14.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0444e88557df735eda7db330806fe09d51c9f888bb1f5906cb3a61fb1a3ed4a8"},
+ {file = "statsmodels-0.14.6-cp310-cp310-win_amd64.whl", hash = "sha256:e83a9abe653835da3b37fb6ae04b45480c1de11b3134bd40b09717192a1456ea"},
+ {file = "statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4"},
+ {file = "statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6"},
+ {file = "statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb"},
+ {file = "statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca"},
+ {file = "statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d"},
+ {file = "statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7"},
+ {file = "statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5"},
+ {file = "statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c"},
+ {file = "statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368"},
+ {file = "statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d"},
+ {file = "statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37"},
+ {file = "statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f"},
+ {file = "statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e"},
+ {file = "statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71"},
+ {file = "statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4"},
+ {file = "statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589"},
+ {file = "statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c"},
+ {file = "statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233"},
+ {file = "statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae"},
+ {file = "statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37"},
+ {file = "statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad"},
+ {file = "statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4"},
+ {file = "statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19"},
+ {file = "statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721"},
+ {file = "statsmodels-0.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4d0c1b0f9f6915619e2a0d3853e5763d4d66876892ad352e7d7b93a737556978"},
+ {file = "statsmodels-0.14.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9e0fc891d6358bf376cc0ae1fee10a650478172ae9ba359daba1785fc496cd1a"},
+ {file = "statsmodels-0.14.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f52ef0f0b63b8fd11e1ef1c2a1e73a410720b8715c9a83a26d733b6815597fe"},
+ {file = "statsmodels-0.14.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b328eafa86a2a67303fdb1d25677d15b70cd2a5229aabec7670ec5ea840f1375"},
+ {file = "statsmodels-0.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:3bef39f8587754f2d644b2e831e102fa08ace9a5a1af4b583b122e6fd3e083ab"},
+ {file = "statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a"},
+]
+
+[package.dependencies]
+numpy = ">=1.22.3,<3"
+packaging = ">=21.3"
+pandas = ">=1.4,<2.1.0 || >2.1.0"
+patsy = ">=0.5.6"
+scipy = ">=1.8,<1.9.2 || >1.9.2"
+
+[package.extras]
+build = ["cython (>=3.0.10)"]
+develop = ["colorama", "cython (>=3.0.10)", "cython (>=3.0.10,<4)", "flake8", "isort", "jinja2", "joblib", "matplotlib (>=3)", "pytest (>=7.3.0,<8)", "pytest-cov", "pytest-randomly", "pytest-xdist", "pywinpty ; os_name == \"nt\"", "setuptools_scm[toml] (>=8.0,<9.0)"]
+docs = ["ipykernel", "jupyter_client", "matplotlib", "nbconvert", "nbformat", "numpydoc", "pandas-datareader", "sphinx"]
+
+[[package]]
+name = "traitlets"
+version = "5.14.3"
+description = "Traitlets Python configuration system"
+optional = true
+python-versions = ">=3.8"
+groups = ["main"]
+markers = "extra == \"charting\""
+files = [
+ {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"},
+ {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"},
+]
+
+[package.extras]
+docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
+test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -1582,26 +2263,26 @@ typing-extensions = ">=4.12.0"
[[package]]
name = "tzdata"
-version = "2025.2"
+version = "2025.3"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main"]
files = [
- {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
- {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
+ {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"},
+ {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"},
]
[[package]]
name = "urllib3"
-version = "2.6.1"
+version = "2.6.2"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"},
- {file = "urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f"},
+ {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
+ {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
]
[package.extras]
@@ -1898,7 +2579,10 @@ enabler = ["pytest-enabler (>=2.2)"]
test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
type = ["pytest-mypy"]
+[extras]
+charting = ["openbb-charting"]
+
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.14"
-content-hash = "019b3af81f6f82b3a0d73b0347724270aff7e2ad5227a044158e1c6fca53d6f1"
+content-hash = "d35085892579a6561a545db388f78970463f6f9dc71d8fedcd91fe045cfa0545"
diff --git a/openbb_platform/providers/imf/pyproject.toml b/openbb_platform/providers/imf/pyproject.toml
index d6ea217460c..72672244e19 100644
--- a/openbb_platform/providers/imf/pyproject.toml
+++ b/openbb_platform/providers/imf/pyproject.toml
@@ -9,8 +9,15 @@ packages = [{ include = "openbb_imf" }]
[tool.poetry.dependencies]
python = ">=3.10,<3.14"
-openbb-core = "^1.5.1"
+openbb-core = "^1.5.6"
+openbb-platform-api = "^1.2.2"
+openbb-economy = "^1.5.0"
+openbb-charting = { version = "^2.4.0", optional = true }
async-lru = "^2"
+defusedxml = ">=0.7.1"
+
+[tool.poetry.extras]
+charting = ["openbb-charting"]
[build-system]
requires = ["poetry-core"]
@@ -18,3 +25,6 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry.plugins."openbb_provider_extension"]
imf = "openbb_imf:imf_provider"
+
+[tool.poetry.plugins."openbb_core_extension"]
+imf_utils = "openbb_imf.imf_router:router"
diff --git a/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_consumer_price_index_fetcher_urllib3_v2.yaml b/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_consumer_price_index_fetcher_urllib3_v2.yaml
new file mode 100644
index 00000000000..6909d82ab36
--- /dev/null
+++ b/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_consumer_price_index_fetcher_urllib3_v2.yaml
@@ -0,0 +1,351 @@
+interactions:
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/CPI/%2B/*.*.*.*.*/COUNTRY
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:CPI(5.0.0)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"31587","type":"sdmx_metrics"},{"id":"time_period_start","title":"1900-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2026-01-01","type":"sdmx_metrics"}],"id":"CPI","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"5.0.0","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_CPI(5.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"COUNTRY","include":false,"removePrefix":false,"values":[{"value":"ABW"},{"value":"AFG"},{"value":"AGO"},{"value":"AIA"},{"value":"ALB"},{"value":"ANT"},{"value":"ARE"},{"value":"ARG"},{"value":"ARM"},{"value":"ATG"},{"value":"AUS"},{"value":"AUT"},{"value":"AZE"},{"value":"BDI"},{"value":"BEL"},{"value":"BEN"},{"value":"BFA"},{"value":"BGD"},{"value":"BGR"},{"value":"BHR"},{"value":"BHS"},{"value":"BIH"},{"value":"BLR"},{"value":"BLZ"},{"value":"BOL"},{"value":"BRA"},{"value":"BRB"},{"value":"BRN"},{"value":"BTN"},{"value":"BWA"},{"value":"CAF"},{"value":"CAN"},{"value":"CHE"},{"value":"CHL"},{"value":"CHN"},{"value":"CIV"},{"value":"CMR"},{"value":"COD"},{"value":"COG"},{"value":"COL"},{"value":"COM"},{"value":"CPV"},{"value":"CRI"},{"value":"CUW"},{"value":"CYM"},{"value":"CYP"},{"value":"CZE"},{"value":"DEU"},{"value":"DJI"},{"value":"DMA"},{"value":"DNK"},{"value":"DOM"},{"value":"DZA"},{"value":"ECU"},{"value":"EGY"},{"value":"ESP"},{"value":"EST"},{"value":"ETH"},{"value":"FIN"},{"value":"FJI"},{"value":"FRA"},{"value":"FSM"},{"value":"G163"},{"value":"GAB"},{"value":"GBR"},{"value":"GEO"},{"value":"GHA"},{"value":"GIN"},{"value":"GLP"},{"value":"GMB"},{"value":"GNB"},{"value":"GNQ"},{"value":"GRC"},{"value":"GRD"},{"value":"GRL"},{"value":"GTM"},{"value":"GUY"},{"value":"HKG"},{"value":"HND"},{"value":"HRV"},{"value":"HTI"},{"value":"HUN"},{"value":"IDN"},{"value":"IND"},{"value":"IRL"},{"value":"IRN"},{"value":"IRQ"},{"value":"ISL"},{"value":"ISR"},{"value":"ITA"},{"value":"JAM"},{"value":"JOR"},{"value":"JPN"},{"value":"KAZ"},{"value":"KEN"},{"value":"KGZ"},{"value":"KHM"},{"value":"KIR"},{"value":"KNA"},{"value":"KOR"},{"value":"KOS"},{"value":"KWT"},{"value":"LAO"},{"value":"LBN"},{"value":"LBR"},{"value":"LBY"},{"value":"LCA"},{"value":"LKA"},{"value":"LSO"},{"value":"LTU"},{"value":"LUX"},{"value":"LVA"},{"value":"MAC"},{"value":"MAR"},{"value":"MDA"},{"value":"MDG"},{"value":"MDV"},{"value":"MEX"},{"value":"MKD"},{"value":"MLI"},{"value":"MLT"},{"value":"MMR"},{"value":"MNE"},{"value":"MNG"},{"value":"MOZ"},{"value":"MRT"},{"value":"MSR"},{"value":"MTQ"},{"value":"MUS"},{"value":"MWI"},{"value":"MYS"},{"value":"NAM"},{"value":"NCL"},{"value":"NER"},{"value":"NGA"},{"value":"NIC"},{"value":"NLD"},{"value":"NOR"},{"value":"NPL"},{"value":"NRU"},{"value":"NZL"},{"value":"OMN"},{"value":"PAK"},{"value":"PAN"},{"value":"PER"},{"value":"PHL"},{"value":"PLW"},{"value":"PNG"},{"value":"POL"},{"value":"PRT"},{"value":"PRY"},{"value":"QAT"},{"value":"ROU"},{"value":"RUS"},{"value":"RWA"},{"value":"SAU"},{"value":"SDN"},{"value":"SEN"},{"value":"SGP"},{"value":"SLB"},{"value":"SLE"},{"value":"SLV"},{"value":"SMR"},{"value":"SOM"},{"value":"SRB"},{"value":"SSD"},{"value":"STP"},{"value":"SUR"},{"value":"SVK"},{"value":"SVN"},{"value":"SWE"},{"value":"SWZ"},{"value":"SXM"},{"value":"SYC"},{"value":"SYR"},{"value":"TCD"},{"value":"TGO"},{"value":"THA"},{"value":"TJK"},{"value":"TLS"},{"value":"TON"},{"value":"TTO"},{"value":"TUN"},{"value":"TUR"},{"value":"TUV"},{"value":"TZA"},{"value":"UGA"},{"value":"UKR"},{"value":"URY"},{"value":"USA"},{"value":"UZB"},{"value":"VCT"},{"value":"VEN"},{"value":"VGB"},{"value":"VNM"},{"value":"VUT"},{"value":"WBG"},{"value":"WSM"},{"value":"YEM"},{"value":"ZAF"},{"value":"ZMB"},{"value":"ZWE"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF5550","test":false,"prepared":"2025-12-16T05:14:38.550091525Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:14:38 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ Transfer-Encoding:
+ - chunked
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ content-encoding:
+ - gzip
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ vary:
+ - accept-encoding
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - b2dfcb0d3595626dc6220e68ec7f1319
+ x-envoy-upstream-service-time:
+ - '61'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/CPI/%2B/JPN.*.*.*.*/INDEX_TYPE
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:CPI(5.0.0)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"78","type":"sdmx_metrics"},{"id":"time_period_start","title":"1900-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-11-01","type":"sdmx_metrics"}],"id":"CPI","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"5.0.0","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_CPI(5.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"INDEX_TYPE","include":false,"removePrefix":false,"values":[{"value":"CPI"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF9874","test":false,"prepared":"2025-12-16T05:14:39.238805530Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '1156'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:14:39 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - 64e5e0c667fa9ccfb403fc4fe406c32f
+ x-envoy-upstream-service-time:
+ - '62'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/CPI/%2B/JPN.CPI.*.*.*/COICOP_1999
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:CPI(5.0.0)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"78","type":"sdmx_metrics"},{"id":"time_period_start","title":"1900-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-11-01","type":"sdmx_metrics"}],"id":"CPI","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"5.0.0","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_CPI(5.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"COICOP_1999","include":false,"removePrefix":false,"values":[{"value":"CP01"},{"value":"CP02"},{"value":"CP03"},{"value":"CP04"},{"value":"CP05"},{"value":"CP06"},{"value":"CP07"},{"value":"CP08"},{"value":"CP09"},{"value":"CP10"},{"value":"CP11"},{"value":"CP12"},{"value":"_T"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF9866","test":false,"prepared":"2025-12-16T05:14:39.759048387Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '1360'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:14:39 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - d22e42f0627ff27d2637c530ef4ef1f6
+ x-envoy-upstream-service-time:
+ - '83'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/CPI/%2B/JPN.CPI._T.*.*/TYPE_OF_TRANSFORMATION
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:CPI(5.0.0)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"18","type":"sdmx_metrics"},{"id":"time_period_start","title":"1900-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-11-01","type":"sdmx_metrics"}],"id":"CPI","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"5.0.0","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_CPI(5.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"TYPE_OF_TRANSFORMATION","include":false,"removePrefix":false,"values":[{"value":"IX"},{"value":"POP_PCH_PA_PT"},{"value":"SRP_IX"},{"value":"SRP_POP_PCH_PA_PT"},{"value":"SRP_YOY_PCH_PA_PT"},{"value":"YOY_PCH_PA_PT"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF1106","test":false,"prepared":"2025-12-16T05:14:40.523815481Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '1298'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:14:40 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - 39c11ff5d858fb19896cd96f2f650a2f
+ x-envoy-upstream-service-time:
+ - '59'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/CPI/%2B/JPN.CPI._T.YOY_PCH_PA_PT.*/FREQUENCY
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:CPI(5.0.0)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"3","type":"sdmx_metrics"},{"id":"time_period_start","title":"1956-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-11-01","type":"sdmx_metrics"}],"id":"CPI","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"5.0.0","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_CPI(5.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"FREQUENCY","include":false,"removePrefix":false,"values":[{"value":"A"},{"value":"M"},{"value":"Q"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF1244","test":false,"prepared":"2025-12-16T05:14:41.017267863Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '1180'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:14:41 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - 2789ca0ce9b0dad905aa5782956faccf
+ x-envoy-upstream-service-time:
+ - '68'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/xml
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Cache-Control:
+ - no-cache
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/data/dataflow/IMF.STA/CPI/+/JPN.CPI._T.YOY_PCH_PA_PT.Q?c%5BTIME_PERIOD%5D=ge:2024-01-01+le:2025-02-01&dimensionAtObservation=TIME_PERIOD&detail=full&includeHistory=false
+ response:
+ body:
+ string: IDREF8133false2025-12-16T05:14:41.552208978Zurn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_CPI(5.0.0)
+ headers:
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/xml
+ Date:
+ - Tue, 16 Dec 2025 05:14:41 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ Transfer-Encoding:
+ - chunked
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ content-encoding:
+ - gzip
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ vary:
+ - accept-encoding
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - 48c608bdcd7dd5c8f2f7617595341f19
+ x-envoy-upstream-service-time:
+ - '90'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+version: 1
diff --git a/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_direction_of_trade_fetcher_urllib3_v2.yaml b/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_direction_of_trade_fetcher_urllib3_v2.yaml
index 706d337cf24..85981d9d5b8 100644
--- a/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_direction_of_trade_fetcher_urllib3_v2.yaml
+++ b/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_direction_of_trade_fetcher_urllib3_v2.yaml
@@ -5,50 +5,298 @@ interactions:
Accept:
- application/json
Accept-Encoding:
- - gzip, deflate
+ - gzip, deflate, br, zstd
Connection:
- keep-alive
method: GET
- uri: http://dataservices.imf.org/REST/SDMX_JSON.svc/CompactData/DOT/A.US.TXG_FOB_USD.W00+B0?startPeriod=2020-01-01&endPeriod=2023-12-31
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/IMTS/%2B/*.*.*.*/COUNTRY
response:
body:
- string: !!binary |
- H4sIAAAAAAAEA6SSX6+aQBDFv4qhL216gWVBUJ5EwZZExPKnsfaBbGFUEgHDUrUx97t3Fm3uNU2T
- m/Rxz57fmZmdvUqzpjqyvHNZxyT7Kk0u1aHm9oWXki3tu+5oq+r5fFbOutK0O5USoqnrYBHne6iY
- XNa8Y3UO0tMLWLwB7P28tHkfs2hy1pVN/QjGbrDua7bAm59tDlwVUrBQbxRXTzQjagWcsx0MRK8c
- m21hV/Ku/aXworr0/IO9j7ghyoUXPYZUgeNzaE8lllHKatuD+e1pVDdM3uoTmf1w4hX/Zx4M+Qys
- gFYsxXcxKtcLTaNkLBcFMWVDHxGZWflQHm1Nczg2iAHWFqkEeIfuLTtwsZZVC0fWQoESJdSQkack
- 0Ua2MbIJQX8MdXGrMimFS5sJdckqEJXFd7APrN4hDzXa33Vw6fDgB3Pp+UmaNXWHv0dY08hHff/y
- Ze7PiFACBzjumxrQ8HGgDd5jKx8GJtVlk1IiPWNQBDmUp9eNbDYbUcHFzcTQ9U+Ai3gliaKiv5pj
- 7L1ugW4O/94jNhNDWwIi36/SZB55XxB2UJ5E3jxzIs/BcxoLwV+6/sxJwgiVZP0pm4fTLI1dcTUL
- 02XiRSsnSv4wUyIu0qWfZEG6SJAxhZD4gYdkFDhCWmnfUAx/3Mv3lysv8kMX/ZTQPiOcxtlXZ5F6
- QtN1a2golmVpODl2/BeiYeDkAbHo+Her9G7DMAwDAXSX1AHBo/jtskUqj8LdLbuMICCFBzjw+MgE
- BdJyk5HfzDBkKqlCXTahsYS8wpzCWcVfffR79ntQ9Hv/5+dpUqhUlZGyMmqz7KKKsEgBmUfx367C
- bphGFdAhm1kLrMxeZiCfZxnjkj26+wT887SyqwUAAA==
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:IMTS(1.0.0)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"471734","type":"sdmx_metrics"},{"id":"time_period_start","title":"1948-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-09-01","type":"sdmx_metrics"}],"id":"IMTS","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"1.0.0","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IMTS(1.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"COUNTRY","include":false,"removePrefix":false,"values":[{"value":"ABW"},{"value":"AFG"},{"value":"AGO"},{"value":"AIA"},{"value":"ALB"},{"value":"ANT"},{"value":"ARE"},{"value":"ARG"},{"value":"ARM"},{"value":"ASM"},{"value":"ATG"},{"value":"AUS"},{"value":"AUT"},{"value":"AZE"},{"value":"BDI"},{"value":"BEL"},{"value":"BEN"},{"value":"BFA"},{"value":"BGD"},{"value":"BGR"},{"value":"BHR"},{"value":"BHS"},{"value":"BIH"},{"value":"BLR"},{"value":"BLZ"},{"value":"BMU"},{"value":"BOL"},{"value":"BRA"},{"value":"BRB"},{"value":"BRN"},{"value":"BTN"},{"value":"BWA"},{"value":"CAF"},{"value":"CAN"},{"value":"CHE"},{"value":"CHL"},{"value":"CHN"},{"value":"CIV"},{"value":"CMR"},{"value":"COD"},{"value":"COG"},{"value":"COL"},{"value":"COM"},{"value":"CPV"},{"value":"CRI"},{"value":"CSK"},{"value":"CUB"},{"value":"CUW"},{"value":"CYP"},{"value":"CZE"},{"value":"DDR"},{"value":"DEU"},{"value":"DJI"},{"value":"DMA"},{"value":"DNK"},{"value":"DOM"},{"value":"DZA"},{"value":"ECU"},{"value":"EGY"},{"value":"ERI"},{"value":"ESP"},{"value":"EST"},{"value":"ETH"},{"value":"FIN"},{"value":"FJI"},{"value":"FLK"},{"value":"FRA"},{"value":"FRO"},{"value":"FSM"},{"value":"G001"},{"value":"G080"},{"value":"G092"},{"value":"G110"},{"value":"G163"},{"value":"G200"},{"value":"G205"},{"value":"G400"},{"value":"G505"},{"value":"G603"},{"value":"G903"},{"value":"G998"},{"value":"GAB"},{"value":"GBR"},{"value":"GEO"},{"value":"GHA"},{"value":"GIB"},{"value":"GIN"},{"value":"GMB"},{"value":"GNB"},{"value":"GNQ"},{"value":"GRC"},{"value":"GRD"},{"value":"GRL"},{"value":"GTM"},{"value":"GUM"},{"value":"GUY"},{"value":"GX170"},{"value":"GX405"},{"value":"GX440"},{"value":"GX605"},{"value":"GX901"},{"value":"HKG"},{"value":"HND"},{"value":"HRV"},{"value":"HTI"},{"value":"HUN"},{"value":"IDN"},{"value":"IND"},{"value":"IRL"},{"value":"IRN"},{"value":"IRQ"},{"value":"ISL"},{"value":"ISR"},{"value":"ITA"},{"value":"JAM"},{"value":"JOR"},{"value":"JPN"},{"value":"KAZ"},{"value":"KEN"},{"value":"KGZ"},{"value":"KHM"},{"value":"KIR"},{"value":"KNA"},{"value":"KOR"},{"value":"KOS"},{"value":"KWT"},{"value":"LAO"},{"value":"LBN"},{"value":"LBR"},{"value":"LBY"},{"value":"LCA"},{"value":"LKA"},{"value":"LSO"},{"value":"LTU"},{"value":"LUX"},{"value":"LVA"},{"value":"MAC"},{"value":"MAR"},{"value":"MDA"},{"value":"MDG"},{"value":"MDV"},{"value":"MEX"},{"value":"MHL"},{"value":"MKD"},{"value":"MLI"},{"value":"MLT"},{"value":"MMR"},{"value":"MNE"},{"value":"MNG"},{"value":"MOZ"},{"value":"MRT"},{"value":"MSR"},{"value":"MUS"},{"value":"MWI"},{"value":"MYS"},{"value":"NAM"},{"value":"NCL"},{"value":"NER"},{"value":"NGA"},{"value":"NIC"},{"value":"NLD"},{"value":"NOR"},{"value":"NPL"},{"value":"NRU"},{"value":"NZL"},{"value":"OMN"},{"value":"PAK"},{"value":"PAN"},{"value":"PER"},{"value":"PHL"},{"value":"PLW"},{"value":"PNG"},{"value":"POL"},{"value":"PRK"},{"value":"PRT"},{"value":"PRY"},{"value":"PYF"},{"value":"QAT"},{"value":"ROU"},{"value":"RUS"},{"value":"RWA"},{"value":"SAU"},{"value":"SCG"},{"value":"SDN"},{"value":"SEN"},{"value":"SGP"},{"value":"SLB"},{"value":"SLE"},{"value":"SLV"},{"value":"SMR"},{"value":"SOM"},{"value":"SRB"},{"value":"SSD"},{"value":"STP"},{"value":"SUN"},{"value":"SUR"},{"value":"SVK"},{"value":"SVN"},{"value":"SWE"},{"value":"SWZ"},{"value":"SXM"},{"value":"SYC"},{"value":"SYR"},{"value":"TCD"},{"value":"TGO"},{"value":"THA"},{"value":"TJK"},{"value":"TKM"},{"value":"TLS"},{"value":"TON"},{"value":"TTO"},{"value":"TUN"},{"value":"TUR"},{"value":"TUV"},{"value":"TX126"},{"value":"TX198"},{"value":"TX910"},{"value":"TZA"},{"value":"UGA"},{"value":"UKR"},{"value":"URY"},{"value":"USA"},{"value":"UZB"},{"value":"VAT"},{"value":"VCT"},{"value":"VEN"},{"value":"VNM"},{"value":"VUT"},{"value":"WBG"},{"value":"WSM"},{"value":"YAR"},{"value":"YEM"},{"value":"YMD"},{"value":"YUG"},{"value":"ZAF"},{"value":"ZMB"},{"value":"ZWE"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF1591","test":false,"prepared":"2025-12-16T05:16:34.072054378Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
headers:
- Cache-Control:
- - private
Connection:
- - Keep-Alive
- Content-Encoding:
- - gzip
- Content-Length:
- - '658'
+ - keep-alive
Content-Type:
- - application/json; charset=utf-8
+ - application/json
Date:
- - Fri, 20 Sep 2024 22:48:00 GMT
- Set-Cookie: null
- Vary:
- - Accept-Encoding
- X-Content-Type-Options:
+ - Tue, 16 Dec 2025 05:16:34 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ Transfer-Encoding:
+ - chunked
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ content-encoding:
+ - gzip
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ vary:
+ - accept-encoding
+ x-content-type-options:
- nosniff
- X-Frame-Options:
- - SAMEORIGIN
- X-Permitted-Cross-Domain-Policies:
- - none
- X-XSS-Protection:
- - 1; mode=block
+ x-correlation-id:
+ - 7e06e7c14cae19f49cc3c6475f9b35e9
+ x-envoy-upstream-service-time:
+ - '75'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/IMTS/%2B/USA.*.*.*/INDICATOR
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:IMTS(1.0.0)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"2175","type":"sdmx_metrics"},{"id":"time_period_start","title":"1948-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-09-01","type":"sdmx_metrics"}],"id":"IMTS","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"1.0.0","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IMTS(1.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"INDICATOR","include":false,"removePrefix":false,"values":[{"value":"MG_CIF_USD"},{"value":"TBG_USD"},{"value":"XG_FOB_USD"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF6665","test":false,"prepared":"2025-12-16T05:16:34.568119299Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '1210'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:16:34 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - 0738a84c629ce1356f550e1bd62d0c4e
+ x-envoy-upstream-service-time:
+ - '64'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/IMTS/%2B/USA.XG_FOB_USD.*.*/COUNTERPART_COUNTRY
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:IMTS(1.0.0)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"726","type":"sdmx_metrics"},{"id":"time_period_start","title":"1948-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-09-01","type":"sdmx_metrics"}],"id":"IMTS","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"1.0.0","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IMTS(1.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"COUNTERPART_COUNTRY","include":false,"removePrefix":false,"values":[{"value":"ABW"},{"value":"AFG"},{"value":"AGO"},{"value":"AIA"},{"value":"ALB"},{"value":"ANT"},{"value":"ARE"},{"value":"ARG"},{"value":"ARM"},{"value":"ATG"},{"value":"AUS"},{"value":"AUT"},{"value":"AZE"},{"value":"BDI"},{"value":"BEL"},{"value":"BEN"},{"value":"BFA"},{"value":"BGD"},{"value":"BGR"},{"value":"BHR"},{"value":"BHS"},{"value":"BIH"},{"value":"BLR"},{"value":"BLZ"},{"value":"BMU"},{"value":"BOL"},{"value":"BRA"},{"value":"BRB"},{"value":"BRN"},{"value":"BTN"},{"value":"BWA"},{"value":"CAF"},{"value":"CAN"},{"value":"CHE"},{"value":"CHL"},{"value":"CHN"},{"value":"CIV"},{"value":"CMR"},{"value":"COD"},{"value":"COG"},{"value":"COL"},{"value":"COM"},{"value":"CPV"},{"value":"CRI"},{"value":"CSK"},{"value":"CUB"},{"value":"CUW"},{"value":"CYP"},{"value":"CZE"},{"value":"DDR"},{"value":"DEU"},{"value":"DJI"},{"value":"DMA"},{"value":"DNK"},{"value":"DOM"},{"value":"DZA"},{"value":"ECU"},{"value":"EGY"},{"value":"ERI"},{"value":"ESP"},{"value":"EST"},{"value":"ETH"},{"value":"FIN"},{"value":"FJI"},{"value":"FLK"},{"value":"FRA"},{"value":"FRO"},{"value":"FSM"},{"value":"G001"},{"value":"G080"},{"value":"G092"},{"value":"G110"},{"value":"G163"},{"value":"G200"},{"value":"G205"},{"value":"G400"},{"value":"G505"},{"value":"G603"},{"value":"G903"},{"value":"G998"},{"value":"GAB"},{"value":"GBR"},{"value":"GEO"},{"value":"GHA"},{"value":"GIB"},{"value":"GIN"},{"value":"GMB"},{"value":"GNB"},{"value":"GNQ"},{"value":"GRC"},{"value":"GRD"},{"value":"GRL"},{"value":"GTM"},{"value":"GUM"},{"value":"GUY"},{"value":"GX170"},{"value":"GX405"},{"value":"GX440"},{"value":"GX605"},{"value":"GX901"},{"value":"HKG"},{"value":"HND"},{"value":"HRV"},{"value":"HTI"},{"value":"HUN"},{"value":"IDN"},{"value":"IND"},{"value":"IRL"},{"value":"IRN"},{"value":"IRQ"},{"value":"ISL"},{"value":"ISR"},{"value":"ITA"},{"value":"JAM"},{"value":"JOR"},{"value":"JPN"},{"value":"KAZ"},{"value":"KEN"},{"value":"KGZ"},{"value":"KHM"},{"value":"KIR"},{"value":"KNA"},{"value":"KOR"},{"value":"KOS"},{"value":"KWT"},{"value":"LAO"},{"value":"LBN"},{"value":"LBR"},{"value":"LBY"},{"value":"LCA"},{"value":"LKA"},{"value":"LSO"},{"value":"LTU"},{"value":"LUX"},{"value":"LVA"},{"value":"MAC"},{"value":"MAR"},{"value":"MDA"},{"value":"MDG"},{"value":"MDV"},{"value":"MEX"},{"value":"MHL"},{"value":"MKD"},{"value":"MLI"},{"value":"MLT"},{"value":"MMR"},{"value":"MNE"},{"value":"MNG"},{"value":"MOZ"},{"value":"MRT"},{"value":"MSR"},{"value":"MUS"},{"value":"MWI"},{"value":"MYS"},{"value":"NAM"},{"value":"NCL"},{"value":"NER"},{"value":"NGA"},{"value":"NIC"},{"value":"NLD"},{"value":"NOR"},{"value":"NPL"},{"value":"NRU"},{"value":"NZL"},{"value":"OMN"},{"value":"PAK"},{"value":"PAN"},{"value":"PER"},{"value":"PHL"},{"value":"PLW"},{"value":"PNG"},{"value":"POL"},{"value":"PRK"},{"value":"PRT"},{"value":"PRY"},{"value":"PYF"},{"value":"QAT"},{"value":"ROU"},{"value":"RUS"},{"value":"RWA"},{"value":"SAU"},{"value":"SCG"},{"value":"SDN"},{"value":"SEN"},{"value":"SGP"},{"value":"SLB"},{"value":"SLE"},{"value":"SLV"},{"value":"SMR"},{"value":"SOM"},{"value":"SRB"},{"value":"SSD"},{"value":"STP"},{"value":"SUN"},{"value":"SUR"},{"value":"SVK"},{"value":"SVN"},{"value":"SWE"},{"value":"SWZ"},{"value":"SXM"},{"value":"SYC"},{"value":"SYR"},{"value":"TCD"},{"value":"TGO"},{"value":"THA"},{"value":"TJK"},{"value":"TKM"},{"value":"TLS"},{"value":"TON"},{"value":"TTO"},{"value":"TUN"},{"value":"TUR"},{"value":"TUV"},{"value":"TWN"},{"value":"TX126"},{"value":"TX399"},{"value":"TX489"},{"value":"TX598"},{"value":"TX799"},{"value":"TX884"},{"value":"TX898"},{"value":"TX899"},{"value":"TX910"},{"value":"TZA"},{"value":"UGA"},{"value":"UKR"},{"value":"URY"},{"value":"UZB"},{"value":"VAT"},{"value":"VCT"},{"value":"VEN"},{"value":"VNM"},{"value":"VUT"},{"value":"WBG"},{"value":"WSM"},{"value":"YAR"},{"value":"YEM"},{"value":"YMD"},{"value":"YUG"},{"value":"ZAF"},{"value":"ZMB"},{"value":"ZWE"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF5741","test":false,"prepared":"2025-12-16T05:16:35.075876086Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:16:35 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ Transfer-Encoding:
+ - chunked
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ content-encoding:
+ - gzip
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ vary:
+ - accept-encoding
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - f236267040a2ba2678df373df8920f7f
+ x-envoy-upstream-service-time:
+ - '61'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/IMTS/%2B/USA.XG_FOB_USD.G001.*/FREQUENCY
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:IMTS(1.0.0)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"3","type":"sdmx_metrics"},{"id":"time_period_start","title":"1948-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-09-01","type":"sdmx_metrics"}],"id":"IMTS","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"1.0.0","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IMTS(1.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"FREQUENCY","include":false,"removePrefix":false,"values":[{"value":"A"},{"value":"M"},{"value":"Q"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF5377","test":false,"prepared":"2025-12-16T05:16:35.744587138Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '1183'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:16:35 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - b373baeb60cfcb0263dbc993297cbf97
+ x-envoy-upstream-service-time:
+ - '66'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/xml
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Cache-Control:
+ - no-cache
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/data/dataflow/IMF.STA/IMTS/+/USA.XG_FOB_USD.G001+G998.A?c%5BTIME_PERIOD%5D=ge:2023-01-01+le:2026-01-01&dimensionAtObservation=TIME_PERIOD&detail=full&includeHistory=false
+ response:
+ body:
+ string: IDREF1833false2025-12-16T05:16:36.306712826Zurn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IMTS(1.0.0)USDXGFOB
+ headers:
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/xml
+ Date:
+ - Tue, 16 Dec 2025 05:16:36 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ Transfer-Encoding:
+ - chunked
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ content-encoding:
+ - gzip
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ vary:
+ - accept-encoding
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - fe8af9be0ef160165114bfc9f7924dbc
+ x-envoy-upstream-service-time:
+ - '104'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
status:
code: 200
message: OK
diff --git a/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_economic_indicators_fetcher_urllib3_v2.yaml b/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_economic_indicators_fetcher_urllib3_v2.yaml
index 6c4a6ce1c3e..270dd3182bc 100644
--- a/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_economic_indicators_fetcher_urllib3_v2.yaml
+++ b/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_economic_indicators_fetcher_urllib3_v2.yaml
@@ -5,49 +5,294 @@ interactions:
Accept:
- application/json
Accept-Encoding:
- - gzip, deflate
+ - gzip, deflate, br, zstd
Connection:
- keep-alive
method: GET
- uri: http://dataservices.imf.org/REST/SDMX_JSON.svc/CompactData/IRFCL/M.JP.RAMFDA_USD.S1X?startPeriod=2023-01-01&endPeriod=2023-12-31
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/IL/%2B/*/COUNTRY
response:
body:
- string: !!binary |
- H4sIAAAAAAAEA6SSXY+aQBSG/4qhN226yDB+ULmS8pHSgFrAxtgLMg5HJREwDKs2Zv97z4w27W6y
- zSa95J3nPe/54Kq5TXVkvPNYxzT7qk0v1aEW9kWUmq3tu+5oG8b5fO6fB/2m3RmUENNYxVHK91Ax
- vaxFx2oO2sMfY/EGo+JFaQtVJmo468qmfm5MvXilMlsQzWPLQRhSiiPj5hLGiebEqEAItoOe7FVg
- sy3sStG1P/uiqC7K/wxXJW6W/kUUyoauAscX0J5KjOmX1VYZ+W01RpgEbvR2UtZVA8pN/s9MWOQL
- sAJaeZjQw1IFZeaWTLjOh9ZAH47ISN8UfKOTicVhY32a8BFFVwaiQ3rLDkKeZtHCkbVQoEQJHSKt
- EyszB/aQ2qMx8inU95RpKSnTJajOWAUyWf4S9oHVO/RDjQ/vOrh0+BHGgfb0oLlN3TGOwlVbJiHq
- 8hS4U/nb3FeJpgwOcNw3NSDwsWf23mMrH3pjOtDHlBLtCQslwKE8gRp3WhYIrtdrmeDhdVLo1ArU
- Mf4Sf3dYC+TvyQXy4p/XxIZSaEtA01WbBon/Dd0xqtPED3In8R38/rqQQjjzQtfJ5gkqiRMHnpMv
- U0++SDT13dtTaq6ktpyFWR4vowzpsRSyMPbzYJ7EjpQWpgyZbzD3Bwarx4WfhHMPeUpwG8SUrvnn
- NP/uREsfZd2aDHHgV3D6Eh8R63V68JL+1eecmwAMxEAU7cW5QfdRjEvZ3r2xhDTxY/ikMGupOpJn
- rVUj08Kt8dhavHHymGOicZaceTaubiNHaHx7R6w8QOZzqvoli+d85+4HilPlVbAFAAA=
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:IL(13.0.1)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"9557","type":"sdmx_metrics"},{"id":"time_period_start","title":"1945-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-12-01","type":"sdmx_metrics"}],"id":"IL","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"13.0.1","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IL(13.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"COUNTRY","include":false,"removePrefix":false,"values":[{"value":"ABW"},{"value":"AFG"},{"value":"AGO"},{"value":"AIA"},{"value":"ALB"},{"value":"ANT"},{"value":"ARE"},{"value":"ARG"},{"value":"ARM"},{"value":"ATG"},{"value":"AUS"},{"value":"AUT"},{"value":"AZE"},{"value":"BCE"},{"value":"BDI"},{"value":"BEA"},{"value":"BEL"},{"value":"BGD"},{"value":"BGR"},{"value":"BHR"},{"value":"BHS"},{"value":"BIH"},{"value":"BIS"},{"value":"BLR"},{"value":"BLZ"},{"value":"BOL"},{"value":"BRA"},{"value":"BRB"},{"value":"BRN"},{"value":"BTN"},{"value":"BWA"},{"value":"CAF"},{"value":"CAN"},{"value":"CHE"},{"value":"CHL"},{"value":"CHN"},{"value":"CMR"},{"value":"COD"},{"value":"COG"},{"value":"COL"},{"value":"COM"},{"value":"CPV"},{"value":"CRI"},{"value":"CSK"},{"value":"CUB"},{"value":"CWX"},{"value":"CYM"},{"value":"CYP"},{"value":"CZE"},{"value":"DEU"},{"value":"DJI"},{"value":"DMA"},{"value":"DNK"},{"value":"DOM"},{"value":"DZA"},{"value":"ECB"},{"value":"ECU"},{"value":"EGY"},{"value":"ERI"},{"value":"ESP"},{"value":"EST"},{"value":"ETH"},{"value":"EZB"},{"value":"FIN"},{"value":"FJI"},{"value":"FRA"},{"value":"FSM"},{"value":"G001"},{"value":"G110"},{"value":"G163"},{"value":"G200"},{"value":"G205"},{"value":"G309"},{"value":"G400"},{"value":"G505"},{"value":"G603"},{"value":"G758"},{"value":"G759"},{"value":"G903"},{"value":"GAB"},{"value":"GBR"},{"value":"GEO"},{"value":"GHA"},{"value":"GIN"},{"value":"GMB"},{"value":"GNQ"},{"value":"GRC"},{"value":"GRD"},{"value":"GTM"},{"value":"GUY"},{"value":"GX010"},{"value":"HKG"},{"value":"HND"},{"value":"HRV"},{"value":"HTI"},{"value":"HUN"},{"value":"IDN"},{"value":"IMF"},{"value":"IND"},{"value":"IRL"},{"value":"IRN"},{"value":"IRQ"},{"value":"ISL"},{"value":"ISR"},{"value":"ITA"},{"value":"JAM"},{"value":"JOR"},{"value":"JPN"},{"value":"KAZ"},{"value":"KEN"},{"value":"KGZ"},{"value":"KHM"},{"value":"KNA"},{"value":"KOR"},{"value":"KOS"},{"value":"KWT"},{"value":"LAO"},{"value":"LBN"},{"value":"LBR"},{"value":"LBY"},{"value":"LCA"},{"value":"LKA"},{"value":"LSO"},{"value":"LTU"},{"value":"LUX"},{"value":"LVA"},{"value":"MAC"},{"value":"MAR"},{"value":"MDA"},{"value":"MDG"},{"value":"MDV"},{"value":"MEX"},{"value":"MKD"},{"value":"MLT"},{"value":"MNE"},{"value":"MNG"},{"value":"MOZ"},{"value":"MRT"},{"value":"MSR"},{"value":"MUS"},{"value":"MWI"},{"value":"MYS"},{"value":"NAM"},{"value":"NGA"},{"value":"NIC"},{"value":"NLD"},{"value":"NOR"},{"value":"NPL"},{"value":"NZL"},{"value":"OMN"},{"value":"PAK"},{"value":"PAN"},{"value":"PER"},{"value":"PHL"},{"value":"PNG"},{"value":"POL"},{"value":"PRT"},{"value":"PRY"},{"value":"QAT"},{"value":"ROU"},{"value":"RUS"},{"value":"RWA"},{"value":"SAU"},{"value":"SDN"},{"value":"SGP"},{"value":"SLB"},{"value":"SLE"},{"value":"SLV"},{"value":"SMR"},{"value":"SOM"},{"value":"SRB"},{"value":"SSD"},{"value":"STP"},{"value":"SUR"},{"value":"SVK"},{"value":"SVN"},{"value":"SWE"},{"value":"SWZ"},{"value":"SYC"},{"value":"SYR"},{"value":"TCD"},{"value":"THA"},{"value":"TJK"},{"value":"TKM"},{"value":"TLS"},{"value":"TON"},{"value":"TTO"},{"value":"TUN"},{"value":"TUR"},{"value":"TWN"},{"value":"TX752"},{"value":"TZA"},{"value":"UGA"},{"value":"UKR"},{"value":"URY"},{"value":"USA"},{"value":"UZB"},{"value":"VCT"},{"value":"VEN"},{"value":"VNM"},{"value":"VUT"},{"value":"WBG"},{"value":"WSM"},{"value":"YAR"},{"value":"YEM"},{"value":"YMD"},{"value":"YUG"},{"value":"ZAF"},{"value":"ZMB"},{"value":"ZWE"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF6976","test":false,"prepared":"2025-12-16T05:17:20.433171672Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
headers:
- Cache-Control:
- - private
Connection:
- - Keep-Alive
- Content-Encoding:
- - gzip
- Content-Length:
- - '620'
+ - keep-alive
Content-Type:
- - application/json; charset=utf-8
+ - application/json
Date:
- - Sat, 07 Sep 2024 17:42:56 GMT
- Set-Cookie: null
- Vary:
- - Accept-Encoding
- X-Content-Type-Options:
+ - Tue, 16 Dec 2025 05:17:20 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ Transfer-Encoding:
+ - chunked
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ content-encoding:
+ - gzip
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ vary:
+ - accept-encoding
+ x-content-type-options:
- nosniff
- X-Frame-Options:
- - SAMEORIGIN
- X-Permitted-Cross-Domain-Policies:
- - none
- X-XSS-Protection:
- - 1; mode=block
+ x-correlation-id:
+ - cd9c374bd366cb1e31f4f9124bc59707
+ x-envoy-upstream-service-time:
+ - '62'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/IL/%2B/JPN.RGV_REVS.*.*/FREQUENCY
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:IL(13.0.1)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"3","type":"sdmx_metrics"},{"id":"time_period_start","title":"1950-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-12-01","type":"sdmx_metrics"}],"id":"IL","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"13.0.1","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IL(13.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"FREQUENCY","include":false,"removePrefix":false,"values":[{"value":"A"},{"value":"M"},{"value":"Q"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF9258","test":false,"prepared":"2025-12-16T05:17:20.912545745Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '1180'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:17:20 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - bc8d8fa6d41d9785feda7f0b4720497f
+ x-envoy-upstream-service-time:
+ - '73'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/IL/%2B/*.*.*.*/COUNTRY
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:IL(13.0.1)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"9557","type":"sdmx_metrics"},{"id":"time_period_start","title":"1945-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-12-01","type":"sdmx_metrics"}],"id":"IL","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"13.0.1","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IL(13.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"COUNTRY","include":false,"removePrefix":false,"values":[{"value":"ABW"},{"value":"AFG"},{"value":"AGO"},{"value":"AIA"},{"value":"ALB"},{"value":"ANT"},{"value":"ARE"},{"value":"ARG"},{"value":"ARM"},{"value":"ATG"},{"value":"AUS"},{"value":"AUT"},{"value":"AZE"},{"value":"BCE"},{"value":"BDI"},{"value":"BEA"},{"value":"BEL"},{"value":"BGD"},{"value":"BGR"},{"value":"BHR"},{"value":"BHS"},{"value":"BIH"},{"value":"BIS"},{"value":"BLR"},{"value":"BLZ"},{"value":"BOL"},{"value":"BRA"},{"value":"BRB"},{"value":"BRN"},{"value":"BTN"},{"value":"BWA"},{"value":"CAF"},{"value":"CAN"},{"value":"CHE"},{"value":"CHL"},{"value":"CHN"},{"value":"CMR"},{"value":"COD"},{"value":"COG"},{"value":"COL"},{"value":"COM"},{"value":"CPV"},{"value":"CRI"},{"value":"CSK"},{"value":"CUB"},{"value":"CWX"},{"value":"CYM"},{"value":"CYP"},{"value":"CZE"},{"value":"DEU"},{"value":"DJI"},{"value":"DMA"},{"value":"DNK"},{"value":"DOM"},{"value":"DZA"},{"value":"ECB"},{"value":"ECU"},{"value":"EGY"},{"value":"ERI"},{"value":"ESP"},{"value":"EST"},{"value":"ETH"},{"value":"EZB"},{"value":"FIN"},{"value":"FJI"},{"value":"FRA"},{"value":"FSM"},{"value":"G001"},{"value":"G110"},{"value":"G163"},{"value":"G200"},{"value":"G205"},{"value":"G309"},{"value":"G400"},{"value":"G505"},{"value":"G603"},{"value":"G758"},{"value":"G759"},{"value":"G903"},{"value":"GAB"},{"value":"GBR"},{"value":"GEO"},{"value":"GHA"},{"value":"GIN"},{"value":"GMB"},{"value":"GNQ"},{"value":"GRC"},{"value":"GRD"},{"value":"GTM"},{"value":"GUY"},{"value":"GX010"},{"value":"HKG"},{"value":"HND"},{"value":"HRV"},{"value":"HTI"},{"value":"HUN"},{"value":"IDN"},{"value":"IMF"},{"value":"IND"},{"value":"IRL"},{"value":"IRN"},{"value":"IRQ"},{"value":"ISL"},{"value":"ISR"},{"value":"ITA"},{"value":"JAM"},{"value":"JOR"},{"value":"JPN"},{"value":"KAZ"},{"value":"KEN"},{"value":"KGZ"},{"value":"KHM"},{"value":"KNA"},{"value":"KOR"},{"value":"KOS"},{"value":"KWT"},{"value":"LAO"},{"value":"LBN"},{"value":"LBR"},{"value":"LBY"},{"value":"LCA"},{"value":"LKA"},{"value":"LSO"},{"value":"LTU"},{"value":"LUX"},{"value":"LVA"},{"value":"MAC"},{"value":"MAR"},{"value":"MDA"},{"value":"MDG"},{"value":"MDV"},{"value":"MEX"},{"value":"MKD"},{"value":"MLT"},{"value":"MNE"},{"value":"MNG"},{"value":"MOZ"},{"value":"MRT"},{"value":"MSR"},{"value":"MUS"},{"value":"MWI"},{"value":"MYS"},{"value":"NAM"},{"value":"NGA"},{"value":"NIC"},{"value":"NLD"},{"value":"NOR"},{"value":"NPL"},{"value":"NZL"},{"value":"OMN"},{"value":"PAK"},{"value":"PAN"},{"value":"PER"},{"value":"PHL"},{"value":"PNG"},{"value":"POL"},{"value":"PRT"},{"value":"PRY"},{"value":"QAT"},{"value":"ROU"},{"value":"RUS"},{"value":"RWA"},{"value":"SAU"},{"value":"SDN"},{"value":"SGP"},{"value":"SLB"},{"value":"SLE"},{"value":"SLV"},{"value":"SMR"},{"value":"SOM"},{"value":"SRB"},{"value":"SSD"},{"value":"STP"},{"value":"SUR"},{"value":"SVK"},{"value":"SVN"},{"value":"SWE"},{"value":"SWZ"},{"value":"SYC"},{"value":"SYR"},{"value":"TCD"},{"value":"THA"},{"value":"TJK"},{"value":"TKM"},{"value":"TLS"},{"value":"TON"},{"value":"TTO"},{"value":"TUN"},{"value":"TUR"},{"value":"TWN"},{"value":"TX752"},{"value":"TZA"},{"value":"UGA"},{"value":"UKR"},{"value":"URY"},{"value":"USA"},{"value":"UZB"},{"value":"VCT"},{"value":"VEN"},{"value":"VNM"},{"value":"VUT"},{"value":"WBG"},{"value":"WSM"},{"value":"YAR"},{"value":"YEM"},{"value":"YMD"},{"value":"YUG"},{"value":"ZAF"},{"value":"ZMB"},{"value":"ZWE"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF5022","test":false,"prepared":"2025-12-16T05:17:21.650952563Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:17:21 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ Transfer-Encoding:
+ - chunked
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ content-encoding:
+ - gzip
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ vary:
+ - accept-encoding
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - ac3a1a8245b4b58232acb4810ace02bb
+ x-envoy-upstream-service-time:
+ - '61'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/availability/dataflow/IMF.STA/IL/%2B/JPN.*.*.*/INDICATOR
+ response:
+ body:
+ string: '{"data":{"dataConstraints":[{"links":[{"urn":"urn:sdmx:org.sdmx.infomodel.registry.DataConstraint=IMF.STA:IL(13.0.1)","type":"dataconstraint","rel":"self"}],"annotations":[{"id":"series_count","title":"48","type":"sdmx_metrics"},{"id":"time_period_start","title":"1945-01-01","type":"sdmx_metrics"},{"id":"time_period_end","title":"2025-12-01","type":"sdmx_metrics"}],"id":"IL","name":"Data
+ Constraint","names":{"en":"Data Constraint"},"description":"Data Availability
+ Constraint","descriptions":{"en":"Data Availability Constraint"},"version":"13.0.1","agencyID":"IMF.STA","role":"Actual","constraintAttachment":{"dataStructures":["urn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IL(13.0.0)"]},"cubeRegions":[{"include":true,"components":[{"id":"INDICATOR","include":false,"removePrefix":false,"values":[{"value":"GOLD35P_REVS"},{"value":"NFAOFA_ACO_NRES_S121"},{"value":"NFAOFL_LT_NRES_S121"},{"value":"RGOLDMV_REVS"},{"value":"RGOLDNV_REVS"},{"value":"RGV_REVS"},{"value":"RXDR_REVS"},{"value":"RXF11FX_REVS"},{"value":"RXF11_REVS"},{"value":"TRGMV_REVS"},{"value":"TRGNV_REVS"},{"value":"TRRPIMF_REVS"}]}],"keyValues":[],"annotations":[]}]}]},"meta":{"schema":"https://raw.githubusercontent.com/sdmx-twg/sdmx-json/master/metadata-message/tools/schemas/2.0.0/sdmx-json-metadata-schema.json","id":"IDREF2438","test":false,"prepared":"2025-12-16T05:17:22.366181264Z","contentLanguages":["en"],"sender":{"id":"unknown"}}}'
+ headers:
+ Connection:
+ - keep-alive
+ Content-Length:
+ - '1441'
+ Content-Type:
+ - application/json
+ Date:
+ - Tue, 16 Dec 2025 05:17:22 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - 8b788ff2683903419391a8480a73df0a
+ x-envoy-upstream-service-time:
+ - '61'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
+ status:
+ code: 200
+ message: OK
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/xml
+ Accept-Encoding:
+ - gzip, deflate, br, zstd
+ Cache-Control:
+ - no-cache
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://api.imf.org/external/sdmx/3.0/data/dataflow/IMF.STA/IL/+/JPN.RGV_REVS.*.Q?c%5BTIME_PERIOD%5D=ge:2023-01-01+le:2024-02-01&dimensionAtObservation=TIME_PERIOD&detail=full&includeHistory=false
+ response:
+ body:
+ string: IDREF9203false2025-12-16T05:17:22.970325555Zurn:sdmx:org.sdmx.infomodel.datastructure.DataStructure=IMF.STA:DSD_IL(13.0.0)
+ headers:
+ Connection:
+ - keep-alive
+ Content-Type:
+ - application/xml
+ Date:
+ - Tue, 16 Dec 2025 05:17:23 GMT
+ Request-Context:
+ - appId=cid-v1:e4518baf-f7f4-42d7-900e-7dd820548dc8
+ Set-Cookie:
+ - ApplicationGatewayAffinityCORS=dca9138616203b0df55300d07b8e6a61; Path=/; SameSite=None;
+ Secure
+ - ApplicationGatewayAffinity=dca9138616203b0df55300d07b8e6a61; Path=/
+ Transfer-Encoding:
+ - chunked
+ cache-control:
+ - no-cache, no-store, max-age=0, must-revalidate
+ content-encoding:
+ - gzip
+ expires:
+ - '0'
+ pragma:
+ - no-cache
+ server:
+ - istio-envoy
+ strict-transport-security:
+ - max-age=31536000 ; includeSubDomains
+ vary:
+ - accept-encoding
+ x-content-type-options:
+ - nosniff
+ x-correlation-id:
+ - 466838ab46fd072666f957e75cd64217
+ x-envoy-upstream-service-time:
+ - '111'
+ x-frame-options:
+ - DENY
+ x-xss-protection:
+ - '0'
status:
code: 200
message: OK
diff --git a/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_port_volume_fetcher_urllib3_v2.yaml b/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_port_volume_fetcher_urllib3_v2.yaml
index 624ec43517f..72600d1ecef 100644
--- a/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_port_volume_fetcher_urllib3_v2.yaml
+++ b/openbb_platform/providers/imf/tests/record/http/test_imf_fetchers/test_imf_port_volume_fetcher_urllib3_v2.yaml
@@ -1,4 +1,783 @@
interactions:
+- request:
+ body: null
+ headers:
+ Accept:
+ - application/json
+ Accept-Encoding:
+ - gzip, deflate
+ Connection:
+ - keep-alive
+ method: GET
+ uri: https://services9.arcgis.com/weJ1QsnbMYJlCHdG/arcgis/rest/services/PortWatch_ports_database/FeatureServer/0/query?where=1%3D1&outFields=countrynoaccents,portid,lon,lat,portname,ISO3,continent,fullname+&returnGeometry=false&orderByFields=vessel_count_total%20DESC&outSR=&f=json
+ response:
+ body:
+ string: !!binary |
+ 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
+ PXFe+MfmuppKo7QzJyW6//W//z9Jmr7GUA8DAA==
+ headers:
+ Accept-Ranges:
+ - bytes
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Headers:
+ - Content-Type, Authorization, X-Esri-Authorization
+ Access-Control-Allow-Origin:
+ - '*'
+ Access-Control-Expose-Headers:
+ - Request-Context
+ Cache-Control:
+ - public, max-age=300, s-maxage=300
+ Connection:
+ - keep-alive
+ Content-Encoding:
+ - gzip
+ Content-Length:
+ - '40213'
+ Content-Type:
+ - application/json; charset=utf-8
+ Date:
+ - Tue, 16 Dec 2025 05:19:04 GMT
+ Etag:
+ - sd1201_1678006690
+ Last-Modified:
+ - Wed, 03 Dec 2025 15:41:28 GMT
+ Request-Context:
+ - appId=cid-v1:091e3ef9-df68-4d69-9df4-ab8f80183701
+ - appId=cid-v1:eccdbd2e-a85f-4676-a932-e967891b0fc6
+ Strict-Transport-Security:
+ - max-age=63072000
+ Vary:
+ - X-Esri-Authorization
+ X-ArcGIS-Correlation-Id:
+ - 33bb62ffd0ceda8b
+ X-ArcGIS-Instance:
+ - wziaf7inz00000B
+ X-ArcGIS-Trace-Id:
+ - 20251216T051904Z-r156ddfdbffdfxgqhC1CO1fqe000000008x000000000004v
+ X-Cache:
+ - TCP_MISS
+ x-arcgis-upstream:
+ - us1h09c00-services
+ x-azure-ref:
+ - 20251216T051904Z-r156ddfdbffdfxgqhC1CO1fqe000000008x000000000004v
+ x-esri-org-request-units-per-min:
+ - usage=51;max=6000
+ x-esri-query-request-units:
+ - '3'
+ x-esri-tiles-basic-query-mode:
+ - 'true'
+ x-esri-tiles-basic-query-type:
+ - Basic
+ x-fd-int-roxy-purgeid:
+ - '0'
+ status:
+ code: 200
+ message: OK
- request:
body: null
headers:
@@ -13,122 +792,128 @@ interactions:
response:
body:
string: !!binary |
- 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
- RIXMN2tPjy1YnTHYb3Lojk7zTVJdp77GVvvWiR4nIrnYt7Ul+JA2idXBU2gnJ2sqOITlkG+Hgy9x
- YFoOSW2XVhNMf0kxd8hQ8xRi2EO7QVBPanYOV1G1GR4F3HVOZpwffHMs1dLZV1waioKxzsk93CGo
- A0+C6i9NgcX5MmSuAvjQEVHKdLusSnTHrAC2zpjKFdj6akdmlZEY//jt/wCt0T37V3AAAA==
+ H4sIAAAAAAAEAM2cWW8kx5WF/wufiUDsC58NG3oYawAJfhkYAqWm5B6zu2U2GxhB0H+f7ySXysqI
+ TGbRVMmABKm7conMvOfec+4Sv158+v5/b364/+rdn9/f3L776/WHm4uri68f/+7i8uLLx/f/+nLz
+ +PPF1a8XH7tD3n/+5pfP9zcf/uv6/cd7/r3huPu7Lze/XV78dPvp++vb44tzxo/64+eLq/95vty7
+ 6/sbfrj/5Wf96ebz3fvplG/5858efrq+fX/9+XDk53/dfvtw8OP/ffv+w83n++sPP3t+vb35+NP9
+ Py6u6uXFu08fWNPF1ccvt7f86ebH6y+393+7vv1y8/B3v10+L+KXm+u7lUV89fH+5qebu9k6Hg/u
+ 1nE48oQ7f/j0keXuvfXT0W9z73fXv+y+88Oxb3Pfnz/d3b9/t3Lrb+7v3n/8aXbn56O7m//1b9d3
+ P/xj+hhPnz1aa09ey/S/J6zm8fjfZT0/fPry8f5u7bt0yzkc/rus5qtvvg57l/J47O/2lX64vr39
+ /N0Pnx48zX64js99O0t+uPa7u1+++/7L7T9fsazZqW+9qp9ueNrr2+9+uL776dMrlrY8/63Xd/fp
+ 7jXLejztrVdzf/3xn68yrOcT33pFr/1uv9P3On0lb7aG9x90zRfh/6dPX76/nZOGwXndgv58++n6
+ /lXLeQH1a6vZQvzrF7MH7GsrehHor1/WBsbXVrOG79cvYhPaa8tYh/XrF/Kaj/P2H+W0BbzRrW/+
+ 73UQHpz3hss5FcL9aW+4mFdBeOXcN1zWKRA+PuUNF3EahJcnvaUBv+LjvP1HOW0Bb3TrmfYf3fzr
+ r/40u/Ps4H+HA/z98uLHm+v7L3c3D3mD63uUx/df7vXHXx+SAlcuF5+C89ZOymJS6Ffe+nD5KJmv
+ 3OUkYfXfR0H5wFI4x11cHmTd1cU3yJpr/qyX+CSvjv92Ujr81V/++/HMpby4Cu5yRPCvwuUqub7y
+ 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
+ AAA=
headers:
+ Accept-Ranges:
+ - bytes
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Headers:
+ - Content-Type, Authorization, X-Esri-Authorization
Access-Control-Allow-Origin:
- '*'
Access-Control-Expose-Headers:
@@ -140,47 +925,44 @@ interactions:
Content-Encoding:
- gzip
Content-Length:
- - '6550'
+ - '6500'
Content-Type:
- application/json; charset=utf-8
Date:
- - Thu, 29 May 2025 05:00:11 GMT
+ - Tue, 16 Dec 2025 05:19:05 GMT
Etag:
- - sd1677_-1014543990
+ - sd1869_-1730019336
Last-Modified:
- - Tue, 27 May 2025 16:23:20 GMT
+ - Tue, 02 Dec 2025 17:31:01 GMT
Request-Context:
- appId=cid-v1:091e3ef9-df68-4d69-9df4-ab8f80183701
- appId=cid-v1:eccdbd2e-a85f-4676-a932-e967891b0fc6
- Set-Cookie: null
Strict-Transport-Security:
- max-age=63072000
Vary:
- X-Esri-Authorization
- Via:
- - 1.1 ee2b1fd0b9624c1d972540f30530f6f4.cloudfront.net (CloudFront)
- X-Amz-Cf-Id:
- - zoxG624PR-NcILikvI6UjaNzqGMv-Cj_AdkkM_tjSyecbQesELOCFA==
- X-Amz-Cf-Pop:
- - YVR52-P2
X-ArcGIS-Correlation-Id:
- - 00-b033d639f01a53afb91c09b3dd276d89-9c45647ab2d3a5b8-00
+ - cf825b8a1cccb577
X-ArcGIS-Instance:
- - wziaf7inz00000G
+ - wziaf7inz000006
X-ArcGIS-Trace-Id:
- - zoxG624PR-NcILikvI6UjaNzqGMv-Cj_AdkkM_tjSyecbQesELOCFA==
+ - 20251216T051905Z-174dd8fc897lg6smhC1CO161hn000000085g0000000051eg
X-Cache:
- - Miss from cloudfront
+ - TCP_MISS
x-arcgis-upstream:
- - us1h09c00
+ - us1h09c00-services
+ x-azure-ref:
+ - 20251216T051905Z-174dd8fc897lg6smhC1CO161hn000000085g0000000051eg
x-esri-org-request-units-per-min:
- - usage=12;max=6000
+ - usage=57;max=6000
x-esri-query-request-units:
- '3'
x-esri-tiles-basic-query-mode:
- 'true'
x-esri-tiles-basic-query-type:
- Basic
+ x-fd-int-roxy-purgeid:
+ - '0'
status:
code: 200
message: OK
diff --git a/openbb_platform/providers/imf/tests/test_helpers.py b/openbb_platform/providers/imf/tests/test_helpers.py
new file mode 100644
index 00000000000..dba004e1748
--- /dev/null
+++ b/openbb_platform/providers/imf/tests/test_helpers.py
@@ -0,0 +1,329 @@
+"""Tests for IMF helper utilities."""
+
+# ruff: noqa: I001
+# pylint: disable=W0621,W0613,W0612,W0212,R0903,C0302,C0415
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from openbb_core.app.model.abstract.error import OpenBBError
+from openbb_imf.utils.helpers import (
+ detect_indicator_dimensions,
+ detect_transform_dimension,
+ normalize_country_label,
+ resolve_country_code,
+)
+
+
+class TestNormalizeCountryLabel:
+ """Tests for normalize_country_label function."""
+
+ def test_simple_name(self):
+ """Test normalization of simple country names."""
+ assert normalize_country_label("United States") == "united_states"
+ assert normalize_country_label("Japan") == "japan"
+ assert normalize_country_label("United Kingdom") == "united_kingdom"
+
+ def test_name_with_comma(self):
+ """Test normalization of names with comma suffix."""
+ assert normalize_country_label("Armenia, Republic of") == "armenia"
+ assert normalize_country_label("Korea, Republic of") == "korea"
+ assert normalize_country_label("Iran, Islamic Republic of") == "iran"
+
+ def test_name_with_parentheses(self):
+ """Test normalization of names with parenthetical content."""
+ assert normalize_country_label("Euro Area (EA)") == "euro_area"
+ assert normalize_country_label("China (Mainland)") == "china"
+ assert normalize_country_label("Taiwan (Province of China)") == "taiwan"
+
+ def test_name_with_hyphen(self):
+ """Test normalization of names with hyphens."""
+ assert normalize_country_label("Guinea-Bissau") == "guinea_bissau"
+ assert normalize_country_label("Timor-Leste") == "timor_leste"
+
+ def test_mixed_cases(self):
+ """Test normalization handles mixed cases."""
+ assert normalize_country_label("UNITED STATES") == "united_states"
+ assert normalize_country_label("united states") == "united_states"
+ assert normalize_country_label("United STATES") == "united_states"
+
+
+class TestResolveCountryCode:
+ """Tests for resolve_country_code function."""
+
+ @pytest.fixture
+ def mock_metadata(self):
+ """Create mock metadata with country codelist."""
+ metadata = MagicMock()
+ metadata._codelist_cache = {
+ "CL_COUNTRY": {
+ "USA": "United States",
+ "JPN": "Japan",
+ "GBR": "United Kingdom",
+ "DEU": "Germany",
+ "FRA": "France",
+ }
+ }
+ return metadata
+
+ def test_iso_code_passthrough(self, mock_metadata):
+ """Test that valid ISO codes are passed through."""
+ assert resolve_country_code("USA", mock_metadata) == "USA"
+ assert resolve_country_code("JPN", mock_metadata) == "JPN"
+ assert resolve_country_code("gbr", mock_metadata) == "GBR"
+
+ def test_name_to_code_resolution(self, mock_metadata):
+ """Test that country names are resolved to codes."""
+ assert resolve_country_code("Japan", mock_metadata) == "JPN"
+ assert resolve_country_code("japan", mock_metadata) == "JPN"
+ assert resolve_country_code("United Kingdom", mock_metadata) == "GBR"
+
+ def test_unrecognized_returns_uppercase(self, mock_metadata):
+ """Test that unrecognized values are returned as uppercase."""
+ assert resolve_country_code("XYZ", mock_metadata) == "XYZ"
+ assert resolve_country_code("unknown", mock_metadata) == "UNKNOWN"
+
+ def test_whitespace_handling(self, mock_metadata):
+ """Test that whitespace is trimmed."""
+ assert resolve_country_code(" USA ", mock_metadata) == "USA"
+ assert resolve_country_code(" Japan ", mock_metadata) == "JPN"
+
+ def test_empty_codelist_cache(self):
+ """Test handling when codelist cache is empty."""
+ metadata = MagicMock()
+ metadata._codelist_cache = {}
+ assert resolve_country_code("USA", metadata) == "USA"
+
+
+class TestDetectTransformDimension:
+ """Tests for detect_transform_dimension function."""
+
+ @pytest.fixture
+ def mock_metadata_transform(self):
+ """Mock metadata with TRANSFORM dimension."""
+ with patch("openbb_imf.utils.metadata.ImfMetadata") as MockMetadata:
+ mock_instance = MockMetadata.return_value
+ mock_instance.get_dataflow_parameters.return_value = {
+ "TRANSFORMATION": [
+ {"value": "IX", "label": "Index"},
+ {"value": "PC_PA", "label": "Year-over-year percent change"},
+ {"value": "PC_PP", "label": "Period-over-period percent change"},
+ ],
+ "COUNTRY": [
+ {"value": "USA", "label": "United States"},
+ ],
+ }
+ yield MockMetadata
+
+ @pytest.fixture
+ def mock_metadata_unit(self):
+ """Mock metadata with UNIT dimension."""
+ with patch("openbb_imf.utils.metadata.ImfMetadata") as MockMetadata:
+ mock_instance = MockMetadata.return_value
+ mock_instance.get_dataflow_parameters.return_value = {
+ "UNIT": [
+ {"value": "USD", "label": "US Dollar"},
+ {"value": "EUR", "label": "Euro"},
+ {"value": "XDC", "label": "Domestic currency"},
+ {"value": "IX", "label": "Index"},
+ ],
+ "COUNTRY": [
+ {"value": "USA", "label": "United States"},
+ ],
+ }
+ yield MockMetadata
+
+ def test_detect_transform_dimension(self, mock_metadata_transform):
+ """Test detection of TRANSFORM dimension."""
+ transform_dim, unit_dim, transform_lookup, unit_lookup = (
+ detect_transform_dimension("CPI")
+ )
+
+ assert transform_dim == "TRANSFORMATION"
+ assert unit_dim is None
+ assert "index" in transform_lookup
+ assert "yoy" in transform_lookup
+ assert "period" in transform_lookup
+ assert transform_lookup["index"] == "IX"
+ assert transform_lookup["yoy"] == "PC_PA"
+ assert transform_lookup["period"] == "PC_PP"
+
+ def test_detect_unit_dimension(self, mock_metadata_unit):
+ """Test detection of UNIT dimension."""
+ transform_dim, unit_dim, transform_lookup, unit_lookup = (
+ detect_transform_dimension("MFS_MA")
+ )
+
+ assert transform_dim is None
+ assert unit_dim == "UNIT"
+ assert "usd" in unit_lookup
+ assert "eur" in unit_lookup
+ assert "local" in unit_lookup
+ assert "index" in unit_lookup
+ assert unit_lookup["usd"] == "USD"
+ assert unit_lookup["eur"] == "EUR"
+ assert unit_lookup["local"] == "XDC"
+
+ def test_no_transform_or_unit(self):
+ """Test dataflow without transform or unit dimension."""
+ with patch("openbb_imf.utils.metadata.ImfMetadata") as MockMetadata:
+ mock_instance = MockMetadata.return_value
+ mock_instance.get_dataflow_parameters.return_value = {
+ "COUNTRY": [{"value": "USA", "label": "United States"}],
+ "INDICATOR": [{"value": "GDP", "label": "GDP"}],
+ }
+
+ transform_dim, unit_dim, transform_lookup, unit_lookup = (
+ detect_transform_dimension("WEO")
+ )
+
+ assert transform_dim is None
+ assert unit_dim is None
+ assert transform_lookup == {}
+ assert unit_lookup == {}
+
+ def test_direct_code_access(self, mock_metadata_transform):
+ """Test that direct codes are also in lookup."""
+ transform_dim, unit_dim, transform_lookup, unit_lookup = (
+ detect_transform_dimension("CPI")
+ )
+
+ # Direct codes should be accessible (case-insensitive)
+ assert "ix" in transform_lookup
+ assert "pc_pa" in transform_lookup
+ assert transform_lookup["ix"] == "IX"
+
+ def test_mixed_transform_and_unit(self):
+ """Test detection when both TRANSFORM and UNIT dimensions exist."""
+ with patch("openbb_imf.utils.metadata.ImfMetadata") as MockMetadata:
+ mock_instance = MockMetadata.return_value
+ mock_instance.get_dataflow_parameters.return_value = {
+ "TRANSFORMATION": [
+ {"value": "IX", "label": "Index"},
+ ],
+ "UNIT": [
+ {"value": "USD", "label": "US Dollar"},
+ ],
+ }
+
+ transform_dim, unit_dim, transform_lookup, unit_lookup = (
+ detect_transform_dimension("MIXED")
+ )
+
+ assert transform_dim == "TRANSFORMATION"
+ assert unit_dim == "UNIT"
+ assert "index" in transform_lookup
+ assert "usd" in unit_lookup
+
+
+class TestDetectIndicatorDimensions:
+ """Tests for detect_indicator_dimensions function."""
+
+ def test_indicator_dimension_mapping(self):
+ """Test that indicators are mapped to correct dimensions."""
+ mock_metadata = MagicMock()
+ # Mock get_dataflow_parameters to return dimension -> codes mapping
+ mock_metadata.get_dataflow_parameters.return_value = {
+ "INDICATOR": [
+ {"value": "GDP", "label": "GDP"},
+ {"value": "CPI", "label": "CPI"},
+ ],
+ "BOP_ACCOUNTING_ENTRY": [
+ {"value": "CD_T", "label": "Current Account"},
+ ],
+ }
+ mock_metadata.dataflows = {}
+ mock_metadata.datastructures = {}
+
+ result = detect_indicator_dimensions(
+ "TEST", ["GDP", "CPI", "CD_T"], mock_metadata
+ )
+
+ assert "INDICATOR" in result
+ assert "BOP_ACCOUNTING_ENTRY" in result
+ assert "GDP" in result["INDICATOR"]
+ assert "CPI" in result["INDICATOR"]
+ assert "CD_T" in result["BOP_ACCOUNTING_ENTRY"]
+
+ def test_fallback_to_indicator_dimension(self):
+ """Test fallback when indicator not found in metadata."""
+ mock_metadata = MagicMock()
+ mock_metadata.get_dataflow_parameters.return_value = {
+ "INDICATOR": [{"value": "GDP", "label": "GDP"}],
+ }
+ mock_metadata.dataflows = {}
+ mock_metadata.datastructures = {}
+
+ # "UNKNOWN" is not in any dimension - raises OpenBBError
+ with pytest.raises(OpenBBError):
+ detect_indicator_dimensions("TEST", ["GDP", "UNKNOWN"], mock_metadata)
+
+ def test_compound_indicator_codes(self):
+ """Test parsing of compound indicator codes."""
+ mock_metadata = MagicMock()
+ mock_metadata.get_dataflow_parameters.return_value = {
+ "SECTOR": [{"value": "S1", "label": "Sector 1"}],
+ "INDICATOR": [{"value": "I1", "label": "Indicator 1"}],
+ }
+ mock_metadata.dataflows = {}
+ mock_metadata.datastructures = {}
+
+ # Compound code: S1_I1 (underscore separator)
+ result = detect_indicator_dimensions("TEST", ["S1_I1"], mock_metadata)
+
+ assert "SECTOR" in result
+ assert "INDICATOR" in result
+ assert "S1" in result["SECTOR"]
+ assert "I1" in result["INDICATOR"]
+
+ def test_wildcard_fallback(self):
+ """Test wildcard fallback for missing dimensions in compound codes."""
+ mock_metadata = MagicMock()
+ mock_metadata.get_dataflow_parameters.return_value = {
+ "SECTOR": [{"value": "S1", "label": "Sector 1"}],
+ "INDICATOR": [{"value": "I1", "label": "Indicator 1"}],
+ "FREQ": [{"value": "A", "label": "Annual"}],
+ }
+ mock_metadata.dataflows = {"TEST": {"structureRef": {"id": "DSD_TEST"}}}
+ mock_metadata.datastructures = {
+ "DSD_TEST": {
+ "dimensions": [
+ {"id": "FREQ", "position": 1},
+ {"id": "SECTOR", "position": 2},
+ {"id": "INDICATOR", "position": 3},
+ ]
+ }
+ }
+
+ # Compound code with missing dimension: S1 (implies wildcard for INDICATOR)
+ result = detect_indicator_dimensions("TEST", ["S1"], mock_metadata)
+
+ assert "SECTOR" in result
+ assert "S1" in result["SECTOR"]
+
+ def test_detailed_error_message(self):
+ """Test that error message contains helpful information."""
+ mock_metadata = MagicMock()
+ mock_metadata.get_dataflow_parameters.return_value = {
+ "INDICATOR": [{"value": "GDP", "label": "GDP"}],
+ }
+ mock_metadata.dataflows = {}
+ mock_metadata.datastructures = {}
+
+ with pytest.raises(OpenBBError) as exc_info:
+ detect_indicator_dimensions("TEST", ["INVALID"], mock_metadata)
+
+ assert "Invalid indicator code(s)" in str(exc_info.value)
+ assert "INVALID" in str(exc_info.value)
+ assert "unrecognized" in str(exc_info.value)
+
+ def test_exception_fallback(self):
+ """Test fallback when metadata raises exception."""
+ mock_metadata = MagicMock()
+ mock_metadata.get_dataflow_parameters.side_effect = KeyError("Not found")
+ mock_metadata.dataflows = {}
+ mock_metadata.datastructures = {}
+
+ # When metadata fails, the function should raise an error for invalid codes
+ with pytest.raises(OpenBBError):
+ detect_indicator_dimensions("TEST", ["GDP", "CPI"], mock_metadata)
diff --git a/openbb_platform/providers/imf/tests/test_imf_fetchers.py b/openbb_platform/providers/imf/tests/test_imf_fetchers.py
index 5dab9d4073c..ae3c8911978 100644
--- a/openbb_platform/providers/imf/tests/test_imf_fetchers.py
+++ b/openbb_platform/providers/imf/tests/test_imf_fetchers.py
@@ -5,6 +5,7 @@ from datetime import date
import pytest
from openbb_core.app.service.user_service import UserService
from openbb_imf.models.available_indicators import ImfAvailableIndicatorsFetcher
+from openbb_imf.models.consumer_price_index import ImfConsumerPriceIndexFetcher
from openbb_imf.models.direction_of_trade import ImfDirectionOfTradeFetcher
from openbb_imf.models.economic_indicators import ImfEconomicIndicatorsFetcher
from openbb_imf.models.maritime_chokepoint_info import ImfMaritimeChokePointInfoFetcher
@@ -19,36 +20,45 @@ test_credentials = UserService().default_user_settings.credentials.model_dump(
)
-def scrub_string(key):
- """Scrub a string from the response."""
-
- def before_record_response(response):
- response["headers"][key] = response["headers"].update({key: None})
- return response
-
- return before_record_response
-
-
@pytest.fixture(scope="module")
def vcr_config():
"""VCR configuration."""
return {
"filter_headers": [("User-Agent", None)],
- "before_record_response": [
- scrub_string("Set-Cookie"),
- ],
}
+@pytest.mark.record_http
+def test_imf_consumer_price_index_fetcher(credentials=test_credentials):
+ """Test the IMF ConsumerPriceIndex fetcher."""
+ params = {
+ "country": "JPN",
+ "frequency": "quarter",
+ "transform": "yoy",
+ "expenditure": "total",
+ "start_date": date(2024, 1, 1),
+ "end_date": date(2025, 1, 1),
+ "harmonized": False,
+ "limit": None,
+ }
+
+ fetcher = ImfConsumerPriceIndexFetcher()
+ result = fetcher.test(params, credentials)
+ assert result is None
+
+
@pytest.mark.record_http
def test_imf_economic_indicators_fetcher(credentials=test_credentials):
"""Test the IMF EconomicIndicators fetcher."""
params = {
- "country": "JP",
- "frequency": "month",
- "symbol": "RAMFDA_USD",
+ "country": "JPN",
+ "frequency": "quarter",
+ "symbol": "IL::RGV_REVS",
"start_date": date(2023, 1, 1),
- "end_date": date(2023, 12, 31),
+ "end_date": date(2024, 1, 1),
+ "limit": None,
+ "transform": None,
+ "dimension_values": None,
}
fetcher = ImfEconomicIndicatorsFetcher()
@@ -59,7 +69,7 @@ def test_imf_economic_indicators_fetcher(credentials=test_credentials):
# The data for this request are local files, so we can't record them.
def test_imf_available_indicators_fetcher(credentials=test_credentials):
"""Test the IMF Available Indicators fetcher."""
- params = {}
+ params = {"query": "gold+volume"}
fetcher = ImfAvailableIndicatorsFetcher()
result = fetcher.test(params, credentials)
@@ -70,12 +80,13 @@ def test_imf_available_indicators_fetcher(credentials=test_credentials):
def test_imf_direction_of_trade_fetcher(credentials=test_credentials):
"""Test the ImfDirectionOfTrade fetcher."""
params = {
- "country": "us",
- "counterpart": "world,eu",
+ "country": "USA",
+ "counterpart": "G001,G998",
"frequency": "annual",
"direction": "exports",
- "start_date": date(2020, 1, 1),
- "end_date": date(2023, 1, 1),
+ "start_date": date(2023, 1, 1),
+ "end_date": date(2025, 1, 1),
+ "limit": None,
}
fetcher = ImfDirectionOfTradeFetcher()
diff --git a/openbb_platform/providers/imf/tests/test_imf_search.py b/openbb_platform/providers/imf/tests/test_imf_search.py
new file mode 100644
index 00000000000..8845b3585dc
--- /dev/null
+++ b/openbb_platform/providers/imf/tests/test_imf_search.py
@@ -0,0 +1,155 @@
+"""Test ImfMetadata search functionality."""
+
+# ruff: noqa: I001
+# pylint: disable=W0621,R0903
+import pytest
+
+from openbb_imf.utils import metadata as md
+
+
+MOCK_DATAFLOWS = [
+ {
+ "id": "BOP",
+ "name": "Balance of Payments",
+ "description": "International Monetary Fund balance of payments data.",
+ "structureRef": {"id": "DSD_BOP"},
+ },
+ {
+ "id": "CPI",
+ "name": "Consumer Price Index",
+ "description": "International Monetary Fund CPI dataset.",
+ "structureRef": {"id": "DSD_CPI"},
+ },
+ {
+ "id": "GOV",
+ "name": "Government Finance Statistics",
+ "description": "Statistics produced by the IMF.",
+ "structureRef": {"id": "DSD_GOV"},
+ },
+]
+
+
+@pytest.fixture
+def imf_metadata(monkeypatch):
+ """Provide a minimal ImfMetadata with canned dataflows and parameters."""
+ monkeypatch.setattr(md.ImfMetadata, "_instance", None)
+ monkeypatch.setattr(md.ImfMetadata, "_load_from_cache", lambda self: True)
+
+ meta = md.ImfMetadata()
+ meta.dataflows = {d["id"]: d for d in MOCK_DATAFLOWS}
+ meta.dataflow_groups = {"IMF.STA": MOCK_DATAFLOWS}
+
+ def _fake_get_dataflow_parameters(dataflow_id: str):
+ if dataflow_id not in meta.dataflows:
+ raise ValueError(f"Dataflow '{dataflow_id}' not found.")
+
+ # Return a lightweight, deterministic parameter mapping.
+ return {
+ "COUNTRY": [
+ {"value": "USA", "label": "United States"},
+ {"value": "MEX", "label": "Mexico"},
+ ],
+ "FREQUENCY": [
+ {"value": "A", "label": "Annual"},
+ {"value": "Q", "label": "Quarterly"},
+ ],
+ "TIME_PERIOD": [
+ {"value": "YYYY", "label": "Year (Start Date: 1980, End Date: 2024)"},
+ {
+ "value": "YYYY-MM",
+ "label": "Month (Start Date: 1990-01, End Date: 2024-12)",
+ },
+ {
+ "value": "YYYY-QQ",
+ "label": "Quarter (Start Date: 1990-Q1, End Date: 2024-Q4)",
+ },
+ {
+ "value": "YYYY-SS",
+ "label": "Semester (Start Date: 1990-S1, End Date: 2024-S2)",
+ },
+ {"value": "START", "label": "Start Date: 1990"},
+ {"value": "END", "label": "End Date: 2024"},
+ ],
+ }
+
+ meta.get_dataflow_parameters = _fake_get_dataflow_parameters
+ return meta
+
+
+def test_search_datasets_by_id(imf_metadata):
+ query = "BOP"
+ results = imf_metadata.search_dataflows(query)
+
+ assert results
+ assert any(
+ query.lower() in df["id"].lower()
+ for group in results
+ for df in group["dataflows"]
+ )
+
+
+def test_search_datasets_by_name(imf_metadata):
+ query = "Balance of Payments"
+ results = imf_metadata.search_dataflows(query)
+
+ assert results
+ assert any(
+ query.lower() in df["name"].lower()
+ for group in results
+ for df in group["dataflows"]
+ )
+
+
+def test_search_datasets_by_description(imf_metadata):
+ query = "International Monetary Fund"
+ results = imf_metadata.search_dataflows(query)
+
+ assert results
+ assert any(
+ query.lower() in df["description"].lower()
+ for group in results
+ for df in group["dataflows"]
+ )
+
+
+def test_search_datasets_no_match(imf_metadata):
+ query = "nonexistent_dataflow_xyz"
+ results = imf_metadata.search_dataflows(query)
+
+ assert results == []
+
+
+def test_get_dataflow_parameters_cpi(imf_metadata):
+ dataflow_id = "CPI"
+ parameters = imf_metadata.get_dataflow_parameters(dataflow_id)
+
+ assert "COUNTRY" in parameters
+ assert "FREQUENCY" in parameters
+ assert "TIME_PERIOD" in parameters
+ assert isinstance(parameters["COUNTRY"], list)
+ assert isinstance(parameters["FREQUENCY"], list)
+ assert isinstance(parameters["TIME_PERIOD"], list)
+ assert any(option["value"] == "USA" for option in parameters["COUNTRY"])
+ assert any(option["value"] == "A" for option in parameters["FREQUENCY"])
+ assert any(option["value"] == "YYYY" for option in parameters["TIME_PERIOD"])
+
+
+def test_get_dataflow_parameters_invalid_dataflow(imf_metadata):
+ dataflow_id = "INVALID_DATAFLOW"
+ with pytest.raises(ValueError, match=f"Dataflow '{dataflow_id}' not found."):
+ imf_metadata.get_dataflow_parameters(dataflow_id)
+
+
+def test_get_dataflow_parameters_time_period_options(imf_metadata):
+ dataflow_id = "CPI"
+ parameters = imf_metadata.get_dataflow_parameters(dataflow_id)
+ time_period_options = parameters.get("TIME_PERIOD")
+
+ assert time_period_options is not None
+ assert len(time_period_options) == 6
+ assert any(option["value"] == "YYYY" for option in time_period_options)
+ assert any(option["value"] == "YYYY-MM" for option in time_period_options)
+ assert any(option["value"] == "YYYY-QQ" for option in time_period_options)
+ assert any(option["value"] == "YYYY-SS" for option in time_period_options)
+ assert any("Start Date:" in option["label"] for option in time_period_options)
+ assert any("End Date:" in option["label"] for option in time_period_options)
diff --git a/openbb_platform/providers/imf/tests/test_progressive_helper.py b/openbb_platform/providers/imf/tests/test_progressive_helper.py
new file mode 100644
index 00000000000..4daf8ccbe2b
--- /dev/null
+++ b/openbb_platform/providers/imf/tests/test_progressive_helper.py
@@ -0,0 +1,190 @@
+"""Test ImfProgressiveQueryHelper."""
+
+# ruff: noqa: I001
+# pylint: disable=W0621,W0613,W0212,R0903
+
+from unittest.mock import patch, MagicMock
+
+import pytest
+from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+
+@pytest.fixture
+def mock_builder():
+ """Mock ImfQueryBuilder."""
+ with patch(
+ "openbb_imf.utils.progressive_helper.ImfQueryBuilder"
+ ) as mock_builder_class:
+ mock_instance = mock_builder_class.return_value
+ # Configure metadata mock
+ mock_instance.metadata = MagicMock()
+ mock_instance.metadata.dataflows = {
+ "GFS_BS": {
+ "name": "Government Finance Statistics, Balance Sheet",
+ "structureRef": {"id": "IMF_GFS_BS"},
+ "agencyID": "IMF.STA",
+ }
+ }
+ mock_instance.metadata.datastructures = {
+ "IMF_GFS_BS": {
+ "id": "IMF_GFS_BS",
+ "dimensions": [
+ {"id": "FREQ", "position": 1},
+ {"id": "REF_AREA", "position": 2},
+ {
+ "id": "INDICATOR",
+ "position": 3,
+ "conceptRef": {"id": "INDICATOR"},
+ },
+ ],
+ }
+ }
+ mock_instance.metadata.codelists = {
+ "CL_FREQ": {"codes": [{"id": "A", "name": "Annual"}]},
+ "CL_REF_AREA": {"codes": [{"id": "US", "name": "United States"}]},
+ "CL_GFS_INDICATOR": {
+ "codes": [{"id": "GG_XDC_G01_XDC_P1_B9", "name": "Gross debt"}]
+ },
+ }
+
+ def get_codelist_map(
+ codelist_id, agency_id=None, dataflow_id=None
+ ): # noqa: ARG001
+ if codelist_id in mock_instance.metadata.codelists:
+ return {
+ item["id"]: item["name"]
+ for item in mock_instance.metadata.codelists[codelist_id].get(
+ "codes", []
+ )
+ }
+ return {}
+
+ mock_instance.metadata._get_codelist_map.side_effect = get_codelist_map
+
+ def resolve_codelist_id(dataflow_id, dsd_id, dimension_id, dim_meta):
+ # Simplified resolver for the mocked DSD
+ if dimension_id == "FREQ":
+ return "CL_FREQ"
+ if dimension_id == "REF_AREA":
+ return "CL_REF_AREA"
+ if dimension_id == "INDICATOR":
+ return "CL_GFS_INDICATOR"
+ return None
+
+ mock_instance.metadata._resolve_codelist_id.side_effect = resolve_codelist_id
+
+ mock_instance.metadata.get_available_constraints.return_value = {
+ "key_values": [
+ {
+ "id": "INDICATOR",
+ "values": ["GG_XDC_G01_XDC_P1_B9"],
+ }
+ ]
+ }
+ yield mock_instance
+
+
+def test_progressive_helper_gfs_indicator(mock_builder):
+ """Test that the progressive helper can find specific codelists."""
+ helper = ImfParamsBuilder(dataflow_id="GFS_BS")
+ helper._builder = mock_builder
+
+ options = helper.get_options_for_dimension("INDICATOR")
+
+ assert options
+ assert len(options) == 1
+ assert options[0]["value"] == "GG_XDC_G01_XDC_P1_B9"
+ assert options[0]["label"] == "Gross debt"
+ mock_builder.metadata._get_codelist_map.assert_called_with(
+ "CL_GFS_INDICATOR", "IMF.STA", "GFS_BS"
+ )
+
+
+def test_constrained_codelist_resolution(mock_builder):
+ """Test that options are filtered based on constraints."""
+ helper = ImfParamsBuilder(dataflow_id="GFS_BS")
+ helper._builder = mock_builder
+
+ # Mock constraints returning only one indicator
+ mock_builder.metadata.get_available_constraints.return_value = {
+ "key_values": [
+ {
+ "id": "INDICATOR",
+ "values": ["GG_XDC_G01_XDC_P1_B9"],
+ }
+ ]
+ }
+
+ options = helper.get_options_for_dimension("INDICATOR")
+ assert len(options) == 1
+ assert options[0]["value"] == "GG_XDC_G01_XDC_P1_B9"
+
+
+def test_component_label_fallback(mock_builder):
+ """Test fallback when label is missing in codelist."""
+ helper = ImfParamsBuilder(dataflow_id="GFS_BS")
+ helper._builder = mock_builder
+
+ # Add a code to constraints that isn't in the codelist map
+ mock_builder.metadata.get_available_constraints.return_value = {
+ "key_values": [
+ {
+ "id": "INDICATOR",
+ "values": ["UNKNOWN_CODE"],
+ }
+ ]
+ }
+
+ # Mock empty codelist map for this test
+ mock_builder.metadata._get_codelist_map.return_value = {}
+
+ options = helper.get_options_for_dimension("INDICATOR")
+ assert len(options) == 1
+ assert options[0]["value"] == "UNKNOWN_CODE"
+ assert options[0]["label"] == "UNKNOWN_CODE" # Fallback to value
+
+
+def test_time_period_annotation_propagation(mock_builder):
+ """Test that time period annotations are propagated."""
+ helper = ImfParamsBuilder(dataflow_id="GFS_BS")
+ helper._builder = mock_builder
+
+ mock_builder.metadata.get_available_constraints.return_value = {
+ "key_values": [],
+ "time_period": {"start_period": "2000", "end_period": "2020"},
+ }
+
+ # Trigger a constraint update (e.g. by getting options)
+ helper.get_options_for_dimension("INDICATOR")
+
+ assert helper._last_constraints_response["time_period"]["start_period"] == "2000"
+ assert helper._last_constraints_response["time_period"]["end_period"] == "2020"
+
+
+def test_case_insensitive_dimension_keys(mock_builder):
+ """Test that dimension keys are case-insensitive."""
+ helper = ImfParamsBuilder(dataflow_id="GFS_BS")
+ helper._builder = mock_builder
+
+ options = helper.get_options_for_dimension("INDICATOR")
+ assert options
+ assert options[0]["value"] == "GG_XDC_G01_XDC_P1_B9"
+
+
+def test_wildcard_on_long_multi_code_inputs(mock_builder):
+ """Test that long multi-code inputs trigger wildcard behavior."""
+ helper = ImfParamsBuilder(dataflow_id="GFS_BS")
+ helper._builder = mock_builder
+
+ # Simulate a long list of codes selected for a previous dimension
+ long_list = ["A"] * 50
+ helper.set_dimension(("FREQ", "+".join(long_list)))
+
+ helper.get_options_for_dimension("REF_AREA")
+
+ call_args = mock_builder.metadata.get_available_constraints.call_args
+ assert call_args is not None
+ key_arg = call_args.kwargs.get("key")
+ assert key_arg is not None
+ # Key should include the long frequency string, not be blanked to wildcard
+ assert "+".join(long_list) in key_arg
diff --git a/openbb_platform/providers/imf/tests/test_progressive_validation.py b/openbb_platform/providers/imf/tests/test_progressive_validation.py
new file mode 100644
index 00000000000..3a07563cd89
--- /dev/null
+++ b/openbb_platform/providers/imf/tests/test_progressive_validation.py
@@ -0,0 +1,1081 @@
+"""Tests for progressive validation logic in ImfParamsBuilder and ImfQueryBuilder."""
+
+# ruff: noqa: I001
+# pylint: disable=W0621,W0613,W0212,R0903,C0302,C0415
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+class TestImfParamsBuilderInit:
+ """Tests for ImfParamsBuilder initialization."""
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_valid_dataflow_initializes(self, mock_metadata_cls):
+ """Valid dataflow should initialize successfully."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {
+ "structureRef": {"id": "DSD_BOP"},
+ "agencyID": "IMF",
+ }
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ {"id": "INDICATOR", "position": 2},
+ {"id": "FREQ", "position": 3},
+ ],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+ assert builder.dataflow_id == "BOP"
+ assert builder._dimensions == ["REF_AREA", "INDICATOR", "FREQ"]
+ assert builder.current_dimension == "REF_AREA"
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_invalid_dataflow_raises_keyerror(self, mock_metadata_cls):
+ """Invalid dataflow should raise KeyError with helpful message."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {"BOP": {}, "IFS": {}}
+ mock_metadata_cls.return_value = mock_metadata
+
+ with pytest.raises(KeyError) as exc_info:
+ ImfParamsBuilder("INVALID")
+
+ assert "INVALID" in str(exc_info.value)
+ assert "Available dataflows" in str(exc_info.value)
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_dimensions_sorted_by_position(self, mock_metadata_cls):
+ """Dimensions should be sorted by position."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "TEST": {
+ "structureRef": {"id": "DSD_TEST"},
+ "agencyID": "IMF",
+ }
+ }
+ # Dimensions in reverse order
+ mock_metadata.datastructures = {
+ "DSD_TEST": {
+ "id": "DSD_TEST",
+ "dimensions": [
+ {"id": "THIRD", "position": 3},
+ {"id": "FIRST", "position": 1},
+ {"id": "SECOND", "position": 2},
+ ],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("TEST")
+ assert builder._dimensions == ["FIRST", "SECOND", "THIRD"]
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_time_period_excluded_from_dimensions(self, mock_metadata_cls):
+ """TIME_PERIOD should be excluded from dimensions list."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "TEST": {
+ "structureRef": {"id": "DSD_TEST"},
+ }
+ }
+ mock_metadata.datastructures = {
+ "DSD_TEST": {
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ {"id": "TIME_PERIOD", "position": 2},
+ {"id": "INDICATOR", "position": 3},
+ ],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("TEST")
+ assert "TIME_PERIOD" not in builder._dimensions
+ assert builder._dimensions == ["REF_AREA", "INDICATOR"]
+
+
+class TestDimensionSelection:
+ """Tests for set_dimension and selection tracking."""
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_set_dimension_updates_selection(self, mock_metadata_cls):
+ """Setting a dimension should update selections."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {"BOP": {"structureRef": {"id": "DSD_BOP"}}}
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ {"id": "INDICATOR", "position": 2},
+ ],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+ result = builder.set_dimension(("REF_AREA", "US"))
+
+ assert result["REF_AREA"] == "US"
+ assert result["INDICATOR"] is None
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_set_dimension_clears_downstream(self, mock_metadata_cls):
+ """Setting an upstream dimension should clear downstream selections."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {"BOP": {"structureRef": {"id": "DSD_BOP"}}}
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ {"id": "INDICATOR", "position": 2},
+ {"id": "FREQ", "position": 3},
+ ],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+ builder._selections = {"REF_AREA": "US", "INDICATOR": "IND1", "FREQ": "A"}
+
+ # Re-set REF_AREA - should clear INDICATOR and FREQ
+ result = builder.set_dimension(("REF_AREA", "GB"))
+
+ assert result["REF_AREA"] == "GB"
+ assert result["INDICATOR"] is None
+ assert result["FREQ"] is None
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_set_invalid_dimension_raises_keyerror(self, mock_metadata_cls):
+ """Setting an invalid dimension should raise KeyError."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {"BOP": {"structureRef": {"id": "DSD_BOP"}}}
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+
+ with pytest.raises(KeyError) as exc_info:
+ builder.set_dimension(("INVALID_DIM", "VALUE"))
+
+ assert "INVALID_DIM" in str(exc_info.value)
+ assert "Valid dimensions" in str(exc_info.value)
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_get_next_dimension_to_select(self, mock_metadata_cls):
+ """get_next_dimension_to_select should return first unselected dimension."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {"BOP": {"structureRef": {"id": "DSD_BOP"}}}
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "dimensions": [
+ {"id": "DIM1", "position": 1},
+ {"id": "DIM2", "position": 2},
+ {"id": "DIM3", "position": 3},
+ ],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+
+ assert builder.get_next_dimension_to_select() == "DIM1"
+
+ builder.set_dimension(("DIM1", "VAL1"))
+ assert builder.get_next_dimension_to_select() == "DIM2"
+
+ builder.set_dimension(("DIM2", "VAL2"))
+ assert builder.get_next_dimension_to_select() == "DIM3"
+
+ builder.set_dimension(("DIM3", "VAL3"))
+ assert builder.get_next_dimension_to_select() is None
+
+
+# =============================================================================
+# Get Options Tests
+# =============================================================================
+
+
+class TestGetOptionsForDimension:
+ """Tests for get_options_for_dimension constraint checking."""
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_get_options_returns_available_values(self, mock_metadata_cls):
+ """get_options_for_dimension should return available values from constraints."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US", "GB", "DE"]}]
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+ options = builder.get_options_for_dimension("REF_AREA")
+
+ assert len(options) == 3
+ assert {"label": "US", "value": "US"} in options
+ assert {"label": "GB", "value": "GB"} in options
+ assert {"label": "DE", "value": "DE"} in options
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_get_options_uses_codelist_labels(self, mock_metadata_cls):
+ """get_options_for_dimension should use codelist for human-readable labels."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US", "GB"]}]
+ }
+ mock_metadata._resolve_codelist_id.return_value = "CL_AREA"
+ mock_metadata._get_codelist_map.return_value = {
+ "US": "United States",
+ "GB": "United Kingdom",
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+ options = builder.get_options_for_dimension("REF_AREA")
+
+ assert {"label": "United States", "value": "US"} in options
+ assert {"label": "United Kingdom", "value": "GB"} in options
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_get_options_for_invalid_dimension_raises(self, mock_metadata_cls):
+ """get_options_for_dimension with invalid dimension should raise ValueError."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {"BOP": {"structureRef": {"id": "DSD_BOP"}}}
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+
+ with pytest.raises(ValueError) as exc_info:
+ builder.get_options_for_dimension("INVALID")
+
+ assert "INVALID" in str(exc_info.value)
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_get_options_uses_wildcards_for_unselected(self, mock_metadata_cls):
+ """Unselected dimensions should use '*' wildcard in key."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [
+ {"id": "DIM1", "position": 1},
+ {"id": "DIM2", "position": 2},
+ {"id": "DIM3", "position": 3},
+ ],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {"key_values": []}
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+ builder.set_dimension(("DIM1", "VAL1"))
+ builder.get_options_for_dimension("DIM2")
+
+ # Check the key passed to get_available_constraints
+ call_args = mock_metadata.get_available_constraints.call_args
+ assert call_args.kwargs["key"] == "VAL1.*.*"
+
+
+# =============================================================================
+# Query Builder Validation Tests
+# =============================================================================
+
+
+class TestValidateDimensionConstraints:
+ """Tests for ImfQueryBuilder.validate_dimension_constraints."""
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_valid_values_pass_validation(self, mock_metadata_cls):
+ """Valid dimension values should pass validation without error."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ {"id": "INDICATOR", "position": 2},
+ ],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [
+ {"id": "REF_AREA", "values": ["US", "GB"]},
+ {"id": "INDICATOR", "values": ["IND1", "IND2"]},
+ ]
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ # Should not raise
+ builder.validate_dimension_constraints(
+ dataflow="BOP", REF_AREA="US", INDICATOR="IND1"
+ )
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_invalid_value_raises_valueerror(self, mock_metadata_cls):
+ """Invalid dimension value should raise ValueError with details."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ ],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [
+ {"id": "REF_AREA", "values": ["US", "GB"]},
+ ]
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+
+ with pytest.raises(ValueError) as exc_info:
+ builder.validate_dimension_constraints(
+ dataflow="BOP", REF_AREA="INVALID_COUNTRY"
+ )
+
+ error_msg = str(exc_info.value)
+ assert "INVALID_COUNTRY" in error_msg
+ assert "REF_AREA" in error_msg
+ assert "available values" in error_msg.lower()
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_wildcard_always_valid(self, mock_metadata_cls):
+ """Wildcard '*' should always be accepted."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {"key_values": []}
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ # Should not raise
+ builder.validate_dimension_constraints(dataflow="BOP", REF_AREA="*")
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_comma_separated_values_validated(self, mock_metadata_cls):
+ """Comma-separated values should all be validated."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [
+ {"id": "REF_AREA", "values": ["US", "GB", "DE"]},
+ ]
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ # Valid comma-separated should pass
+ builder.validate_dimension_constraints(dataflow="BOP", REF_AREA="US,GB")
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_comma_separated_with_invalid_raises(self, mock_metadata_cls):
+ """Comma-separated with one invalid value should raise."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [
+ {"id": "REF_AREA", "values": ["US", "GB"]},
+ ]
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+
+ with pytest.raises(ValueError) as exc_info:
+ builder.validate_dimension_constraints(
+ dataflow="BOP", REF_AREA="US,INVALID"
+ )
+
+ assert "INVALID" in str(exc_info.value)
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_plus_separated_values_validated(self, mock_metadata_cls):
+ """Plus-separated values should all be validated."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [
+ {"id": "REF_AREA", "values": ["US", "GB", "DE"]},
+ ]
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ # Valid plus-separated should pass
+ builder.validate_dimension_constraints(dataflow="BOP", REF_AREA="US+GB+DE")
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_empty_value_skipped(self, mock_metadata_cls):
+ """Empty/None values should be skipped in validation."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ # Should not raise - empty values skipped
+ builder.validate_dimension_constraints(dataflow="BOP", REF_AREA="")
+ builder.validate_dimension_constraints(dataflow="BOP", REF_AREA=None)
+
+
+# =============================================================================
+# Time Period Validation Tests
+# =============================================================================
+
+
+class TestTimePeriodValidation:
+ """Tests for time period range validation."""
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_start_date_after_available_range_raises(self, mock_metadata_cls):
+ """Start date after available data range should raise ValueError."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US"]}],
+ "full_response": {
+ "data": {
+ "dataConstraints": [
+ {
+ "annotations": [
+ {"id": "time_period_start", "title": "2000-01"},
+ {"id": "time_period_end", "title": "2023-12"},
+ ]
+ }
+ ]
+ }
+ },
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+
+ with pytest.raises(ValueError) as exc_info:
+ builder.validate_dimension_constraints(
+ dataflow="BOP", REF_AREA="US", start_date="2025-01"
+ )
+
+ error_msg = str(exc_info.value)
+ assert "2025-01" in error_msg
+ assert "after" in error_msg.lower()
+ assert "2023-12" in error_msg
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_end_date_before_available_range_raises(self, mock_metadata_cls):
+ """End date before available data range should raise ValueError."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US"]}],
+ "full_response": {
+ "data": {
+ "dataConstraints": [
+ {
+ "annotations": [
+ {"id": "time_period_start", "title": "2000-01"},
+ {"id": "time_period_end", "title": "2023-12"},
+ ]
+ }
+ ]
+ }
+ },
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+
+ with pytest.raises(ValueError) as exc_info:
+ builder.validate_dimension_constraints(
+ dataflow="BOP", REF_AREA="US", end_date="1990-01"
+ )
+
+ error_msg = str(exc_info.value)
+ assert "1990-01" in error_msg
+ assert "before" in error_msg.lower()
+ assert "2000-01" in error_msg
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_valid_date_range_passes(self, mock_metadata_cls):
+ """Valid date range within available data should pass."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US"]}],
+ "full_response": {
+ "data": {
+ "dataConstraints": [
+ {
+ "annotations": [
+ {"id": "time_period_start", "title": "2000-01"},
+ {"id": "time_period_end", "title": "2023-12"},
+ ]
+ }
+ ]
+ }
+ },
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ # Should not raise
+ builder.validate_dimension_constraints(
+ dataflow="BOP", REF_AREA="US", start_date="2010-01", end_date="2020-12"
+ )
+
+
+# =============================================================================
+# Progressive Constraint Propagation Tests
+# =============================================================================
+
+
+class TestProgressiveConstraintPropagation:
+ """Tests for how prior selections affect subsequent option availability."""
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_prior_selection_constrains_options(self, mock_metadata_cls):
+ """Prior selections should constrain available options for subsequent dimensions."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ {"id": "INDICATOR", "position": 2},
+ ],
+ }
+ }
+
+ # First call returns all countries
+ # Second call (after country selected) returns fewer indicators
+ call_count = [0]
+
+ def mock_constraints(dataflow_id, key, component_id):
+ call_count[0] += 1
+ if component_id == "INDICATOR" and "US" in key:
+ # US has fewer indicators
+ return {"key_values": [{"id": "INDICATOR", "values": ["IND1", "IND2"]}]}
+ elif component_id == "INDICATOR" and "GB" in key:
+ # GB has more indicators
+ return {
+ "key_values": [
+ {"id": "INDICATOR", "values": ["IND1", "IND2", "IND3", "IND4"]}
+ ]
+ }
+ return {"key_values": []}
+
+ mock_metadata.get_available_constraints.side_effect = mock_constraints
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+
+ # Select US
+ builder.set_dimension(("REF_AREA", "US"))
+ us_options = builder.get_options_for_dimension("INDICATOR")
+ assert len(us_options) == 2
+
+ # Reset and select GB
+ builder._selections = {"REF_AREA": None, "INDICATOR": None}
+ builder.set_dimension(("REF_AREA", "GB"))
+ gb_options = builder.get_options_for_dimension("INDICATOR")
+ assert len(gb_options) == 4
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_validate_considers_prior_selections_in_error(self, mock_metadata_cls):
+ """Error message should include prior selections for context."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ {"id": "INDICATOR", "position": 2},
+ ],
+ }
+ }
+
+ def mock_constraints(dataflow_id, key, component_id):
+ if component_id == "REF_AREA":
+ return {"key_values": [{"id": "REF_AREA", "values": ["US", "GB"]}]}
+ elif component_id == "INDICATOR":
+ # Only IND1 available for US
+ return {"key_values": [{"id": "INDICATOR", "values": ["IND1"]}]}
+ return {"key_values": []}
+
+ mock_metadata.get_available_constraints.side_effect = mock_constraints
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+
+ with pytest.raises(ValueError) as exc_info:
+ builder.validate_dimension_constraints(
+ dataflow="BOP", REF_AREA="US", INDICATOR="IND_NOT_AVAILABLE_FOR_US"
+ )
+
+ error_msg = str(exc_info.value)
+ # Error should mention the invalid value
+ assert "IND_NOT_AVAILABLE_FOR_US" in error_msg
+ # Error should show prior selections
+ assert "REF_AREA" in error_msg
+
+
+class TestValidationEdgeCases:
+ """Edge cases in progressive validation."""
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_no_key_values_in_constraints_response(self, mock_metadata_cls):
+ """Empty key_values should result in no available values."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {"key_values": []}
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+ options = builder.get_options_for_dimension("REF_AREA")
+
+ assert options == [] # pylint: disable=C1803
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_missing_dimension_in_constraints_response(self, mock_metadata_cls):
+ """Missing dimension in key_values should return empty options."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ {"id": "INDICATOR", "position": 2},
+ ],
+ }
+ }
+ # Response only has REF_AREA, not INDICATOR
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US"]}]
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+ options = builder.get_options_for_dimension("INDICATOR")
+
+ assert options == [] # pylint: disable=C1803
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_list_input_validated(self, mock_metadata_cls):
+ """List input for dimension value should be validated."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US", "GB"]}]
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ # List input should work
+ builder.validate_dimension_constraints(dataflow="BOP", REF_AREA=["US", "GB"])
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_whitespace_in_comma_separated_trimmed(self, mock_metadata_cls):
+ """Whitespace around comma-separated values should be trimmed."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US", "GB"]}]
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ # Whitespace should be trimmed
+ builder.validate_dimension_constraints(dataflow="BOP", REF_AREA=" US , GB ")
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_metadata_keyerror_warns_instead_of_raising(self, mock_metadata_cls):
+ """KeyError from metadata should warn, not raise."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+ import warnings
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {} # No dataflows
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+
+ # Should warn, not raise
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+ builder.validate_dimension_constraints(
+ dataflow="NONEXISTENT", REF_AREA="US"
+ )
+
+ # Check that a warning was issued
+ assert len(w) == 1
+ assert "could not validate" in str(w[0].message).lower()
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_no_time_constraints_in_response(self, mock_metadata_cls):
+ """Missing time period constraints should not cause errors."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US"]}],
+ # No time_period key
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ builder.validate_dimension_constraints(dataflow="BOP", REF_AREA="US")
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_irfcl_compatibility_filtering(self, mock_metadata_cls):
+ """Test that IRFCL hierarchies are filtered for compatibility."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "IRFCL": {"structureRef": {"id": "DSD_IRFCL"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_IRFCL": {
+ "id": "DSD_IRFCL",
+ "dimensions": [{"id": "INDICATOR", "position": 1}],
+ }
+ }
+ # Mock constraints returning a specific indicator
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "INDICATOR", "values": ["RAF_USD"]}]
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+
+ # If we validate constraints for IRFCL, it should pass if the indicator is compatible
+ builder.validate_dimension_constraints(dataflow="IRFCL", INDICATOR="RAF_USD")
+
+ # If we try an incompatible one (not in constraints)
+ with pytest.raises(ValueError):
+ builder.validate_dimension_constraints(
+ dataflow="IRFCL", INDICATOR="INCOMPATIBLE"
+ )
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_long_multi_code_input_wildcard(self, mock_metadata_cls):
+ """Test that long multi-code inputs are treated as wildcards during validation."""
+ from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ # Constraints return everything (wildcard)
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US", "GB", "FR"]}]
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+
+ # Create a long list of codes
+ long_list = ["US"] * 50
+
+ # Validation should pass because it should treat it as wildcard or check against constraints
+ builder.validate_dimension_constraints(dataflow="BOP", REF_AREA=long_list)
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [{"id": "REF_AREA", "position": 1}],
+ }
+ }
+ # No time period annotations
+ mock_metadata.get_available_constraints.return_value = {
+ "key_values": [{"id": "REF_AREA", "values": ["US"]}],
+ "full_response": {"data": {}},
+ }
+ mock_metadata._resolve_codelist_id.return_value = None
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfQueryBuilder()
+ # Should not raise even with dates provided
+ builder.validate_dimension_constraints(
+ dataflow="BOP", REF_AREA="US", start_date="2020-01", end_date="2023-12"
+ )
+
+
+# =============================================================================
+# Build URL Tests
+# =============================================================================
+
+
+class TestBuildUrl:
+ """Tests for ImfParamsBuilder.build_url."""
+
+ @patch("openbb_imf.utils.query_builder.ImfMetadata")
+ def test_build_url_uses_selections(self, mock_metadata_cls):
+ """build_url should use current selections."""
+ from openbb_imf.utils.progressive_helper import ImfParamsBuilder
+
+ mock_metadata = MagicMock()
+ mock_metadata.dataflows = {
+ "BOP": {"structureRef": {"id": "DSD_BOP"}, "agencyID": "IMF"}
+ }
+ mock_metadata.datastructures = {
+ "DSD_BOP": {
+ "id": "DSD_BOP",
+ "dimensions": [
+ {"id": "REF_AREA", "position": 1},
+ {"id": "INDICATOR", "position": 2},
+ ],
+ }
+ }
+ mock_metadata_cls.return_value = mock_metadata
+
+ builder = ImfParamsBuilder("BOP")
+ builder.set_dimension(("REF_AREA", "US"))
+ builder.set_dimension(("INDICATOR", "IND1"))
+
+ # Just verify the internal builder.build_url is called with correct args
+ with patch.object(
+ builder._builder, "build_url", return_value="http://test.url"
+ ) as mock_build:
+ result = builder.build_url(start_date="2020-01", end_date="2023-12")
+
+ mock_build.assert_called_once_with(
+ dataflow="BOP",
+ start_date="2020-01",
+ end_date="2023-12",
+ REF_AREA="US",
+ INDICATOR="IND1",
+ )
+ assert result == "http://test.url"
diff --git a/openbb_platform/providers/imf/tests/test_query_builder.py b/openbb_platform/providers/imf/tests/test_query_builder.py
new file mode 100644
index 00000000000..5463b2fdf88
--- /dev/null
+++ b/openbb_platform/providers/imf/tests/test_query_builder.py
@@ -0,0 +1,261 @@
+"""Comprehensive tests for IMF query parsing, search, and fetch helpers."""
+
+# ruff: noqa: I001, SLF001
+# pylint: disable=W0621,W0613,W0212,R0903
+
+from textwrap import dedent
+from unittest.mock import MagicMock, patch
+
+import pandas as pd
+import pytest
+
+from openbb_imf.utils import metadata as md
+from openbb_imf.utils.query_builder import ImfQueryBuilder
+
+
+MOCK_SDMX_CHUNKS_DATAFLOWS = [
+ {
+ "id": "DATAFLOW_A",
+ "name": "Dataflow A Name",
+ "description": "Description for dataflow A with keyword gold.",
+ "structureRef": {"id": "DSD_A"},
+ },
+ {
+ "id": "DATAFLOW_B",
+ "name": "Dataflow B Name with reserves",
+ "description": "Another description.",
+ "structureRef": {"id": "DSD_B"},
+ },
+ {
+ "id": "DATAFLOW_GOLD_STATISTICS",
+ "name": "Gold Statistics",
+ "description": "Comprehensive data on gold holdings and reserves.",
+ "structureRef": {"id": "DSD_GOLD"},
+ },
+ {
+ "id": "DATAFLOW_CENTRAL_BANK",
+ "name": "Central Bank Operations",
+ "description": "Data on central bank activities.",
+ "structureRef": {"id": "DSD_CENTRAL"},
+ },
+]
+
+MOCK_DATAFLOW_GROUPS = {
+ "IMF.STA": [
+ {
+ "id": "DATAFLOW_C",
+ "name": "Central Bank Data",
+ "description": "Data related to central banks.",
+ "structureRef": {"id": "DSD_CENTRAL"},
+ },
+ {
+ "id": "DATAFLOW_D",
+ "name": "Gold Reserves Statistics",
+ "description": "Statistics on gold reserves.",
+ "structureRef": {"id": "DSD_GOLD"},
+ },
+ {
+ "id": "DATAFLOW_E",
+ "name": "Economic Indicators",
+ "description": "Various economic indicators.",
+ "structureRef": {"id": "DSD_ECON"},
+ },
+ ]
+}
+
+MOCK_DATASTRUCTURES = [
+ {
+ "id": "DSD_TEST",
+ "dimensions": [
+ {"id": "COUNTRY", "position": 0, "conceptRef": {"id": "COUNTRY"}},
+ {"id": "INDICATOR", "position": 1, "conceptRef": {"id": "INDICATOR"}},
+ {"id": "TIME_PERIOD", "position": 2, "conceptRef": {"id": "TIME_PERIOD"}},
+ ],
+ }
+]
+
+MOCK_DATAFLOWS_FOR_PIVOT_TEST = {
+ "TEST_DATAFLOW": {
+ "id": "TEST_DATAFLOW",
+ "name": "Test Dataflow",
+ "description": "A dataflow for testing pivoting.",
+ "structureRef": {"id": "DSD_TEST"},
+ "agencyID": "IMF.STA",
+ "presentations": [
+ {
+ "presentation_title": "Indicators by Country",
+ "presentation_description": "Data pivoted by indicator for each country.",
+ }
+ ],
+ }
+}
+
+MOCK_CONCEPTSCHEMES = {}
+MOCK_DATASET_ID_MAPPING = {}
+MOCK_IMF_COUNTRY_MAP = {}
+
+
+@pytest.fixture
+def imf_metadata(monkeypatch):
+ """Provide an isolated ImfMetadata instance with canned data."""
+ monkeypatch.setattr(md.ImfMetadata, "_instance", None)
+ monkeypatch.setattr(md.ImfMetadata, "_load_from_cache", lambda self: True)
+
+ meta = md.ImfMetadata()
+ meta.dataflows = {d["id"]: d for d in MOCK_SDMX_CHUNKS_DATAFLOWS}
+ meta.dataflow_groups = MOCK_DATAFLOW_GROUPS
+ return meta
+
+
+def test_parse_query_variants(imf_metadata):
+ assert imf_metadata._parse_query("gold") == [["gold"]]
+ assert imf_metadata._parse_query('"central bank"') == [["central bank"]]
+ assert imf_metadata._parse_query("gold + reserves") == [["gold", "reserves"]]
+ assert imf_metadata._parse_query("gold | reserves") == [["gold"], ["reserves"]]
+ assert imf_metadata._parse_query('gold + reserves | "central bank"') == [
+ ["gold", "reserves"],
+ ["central bank"],
+ ]
+ assert imf_metadata._parse_query("") == []
+ assert imf_metadata._parse_query(" ") == []
+
+
+def test_search_dataflows_by_keywords(imf_metadata):
+ results = imf_metadata.search_dataflows("gold")
+ ids = {df["id"] for group in results for df in group.get("dataflows", [])}
+ assert "DATAFLOW_A" in ids
+ assert "DATAFLOW_GOLD_STATISTICS" in ids
+
+
+def test_search_dataflows_or_operator(imf_metadata):
+ results = imf_metadata.search_dataflows("DATAFLOW_A | DATAFLOW_B")
+ ids = {df["id"] for group in results for df in group.get("dataflows", [])}
+ assert ids == {"DATAFLOW_A", "DATAFLOW_B"}
+
+
+def test_search_dataflows_phrase(imf_metadata):
+ results = imf_metadata.search_dataflows('"central bank"')
+ ids = {df["id"] for group in results for df in group.get("dataflows", [])}
+ # Central bank phrase should match at least the explicit central bank flow.
+ assert "DATAFLOW_CENTRAL_BANK" in ids
+
+
+@pytest.fixture
+def mock_imf_query_builder():
+ """Return an ImfQueryBuilder wired with canned metadata."""
+ with patch("openbb_imf.utils.query_builder.ImfMetadata") as MockMetadata:
+ mock_metadata_instance = MockMetadata.return_value
+
+ dataflows_dict = {d["id"]: d for d in MOCK_SDMX_CHUNKS_DATAFLOWS}
+ dataflows_dict.update(MOCK_DATAFLOWS_FOR_PIVOT_TEST)
+ mock_metadata_instance.dataflows = dataflows_dict
+
+ datastructures_dict = {d["id"]: d for d in MOCK_DATASTRUCTURES}
+ mock_metadata_instance.datastructures = datastructures_dict
+
+ mock_metadata_instance.conceptschemes = MOCK_CONCEPTSCHEMES
+ mock_metadata_instance.dataflow_groups = MOCK_DATAFLOW_GROUPS
+ mock_metadata_instance.dataset_id_mapping = MOCK_DATASET_ID_MAPPING
+ mock_metadata_instance.imf_country_map = MOCK_IMF_COUNTRY_MAP
+
+ yield ImfQueryBuilder()
+
+
+MOCK_XML_RESPONSE = dedent(
+ """
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+)
+
+
+@pytest.fixture
+def mock_imf_query_builder_with_pivot_data():
+ """Return an ImfQueryBuilder with request mocked to return canned XML."""
+ with patch("openbb_imf.utils.query_builder.ImfMetadata") as MockMetadata, patch(
+ "openbb_core.provider.utils.helpers.make_request"
+ ) as mock_make_request:
+ mock_metadata_instance = MockMetadata.return_value
+
+ dataflows_dict = {d["id"]: d for d in MOCK_SDMX_CHUNKS_DATAFLOWS}
+ dataflows_dict.update(MOCK_DATAFLOWS_FOR_PIVOT_TEST)
+ mock_metadata_instance.dataflows = dataflows_dict
+
+ datastructures_dict = {d["id"]: d for d in MOCK_DATASTRUCTURES}
+ mock_metadata_instance.datastructures = datastructures_dict
+
+ mock_metadata_instance.conceptschemes = MOCK_CONCEPTSCHEMES
+ mock_metadata_instance.dataflow_groups = MOCK_DATAFLOW_GROUPS
+
+ mock_metadata_instance.get_dataflow_parameters.return_value = {
+ "COUNTRY": [{"value": "US", "label": "United States"}],
+ "INDICATOR": [
+ {"value": "GDP", "label": "Gross Domestic Product"},
+ {"value": "CPI", "label": "Consumer Price Index"},
+ ],
+ "TIME_PERIOD": [
+ {"value": "2020", "label": "2020"},
+ {"value": "2021", "label": "2021"},
+ ],
+ }
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.text = MOCK_XML_RESPONSE
+ mock_response.raise_for_status.return_value = None
+ mock_make_request.return_value = mock_response
+
+ builder = ImfQueryBuilder()
+ yield builder
+
+
+def test_fetch_data_structure(mock_imf_query_builder_with_pivot_data):
+ builder = mock_imf_query_builder_with_pivot_data
+ result = builder.fetch_data(
+ "TEST_DATAFLOW", COUNTRY="US", INDICATOR="GDP+CPI", _skip_validation=True
+ )
+
+ assert "data" in result and "metadata" in result
+ df = pd.DataFrame(result["data"])
+
+ expected_cols = {
+ "COUNTRY",
+ "country_code",
+ "INDICATOR",
+ "INDICATOR_code",
+ "series_id",
+ "TIME_PERIOD",
+ "OBS_VALUE",
+ }
+ assert expected_cols.issubset(set(df.columns))
+ assert set(df["INDICATOR_code"]) == {"GDP", "CPI"}
+
+
+def test_fetch_data_time_periods(mock_imf_query_builder_with_pivot_data):
+ builder = mock_imf_query_builder_with_pivot_data
+ result = builder.fetch_data(
+ "TEST_DATAFLOW", COUNTRY="US", INDICATOR="GDP+CPI", _skip_validation=True
+ )
+
+ df = pd.DataFrame(result["data"])
+ # Returned SDMX time periods include end-of-year date strings.
+ assert set(df["TIME_PERIOD"]) == {"2020-12-31", "2021-12-31"}
+
+
+def test_strict_error_missing_dataflow(mock_imf_query_builder):
+ builder = mock_imf_query_builder
+ with pytest.raises(ValueError, match="Dataflow 'MISSING' not found"):
+ builder.build_url("MISSING")
diff --git a/openbb_platform/providers/imf/tests/test_table_builder.py b/openbb_platform/providers/imf/tests/test_table_builder.py
new file mode 100644
index 00000000000..5f85f72f7d2
--- /dev/null
+++ b/openbb_platform/providers/imf/tests/test_table_builder.py
@@ -0,0 +1,791 @@
+"""Tests for IMF Table Builder."""
+
+# ruff: noqa: I001
+# pylint: disable=W0621,W0613,W0212,R0903,C0302,C0415
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+class TestImfTableBuilder:
+ """Tests for ImfTableBuilder class."""
+
+ @pytest.fixture
+ def mock_query_builder(self):
+ """Mock ImfQueryBuilder for table builder tests."""
+ with patch(
+ "openbb_imf.utils.query_builder.ImfQueryBuilder"
+ ) as MockQueryBuilder:
+ mock_instance = MockQueryBuilder.return_value
+ mock_instance.metadata = MagicMock()
+ mock_instance.dataflows = {
+ "BOP": {
+ "id": "BOP",
+ "name": "Balance of Payments",
+ "structureRef": {"id": "IMF_BOP"},
+ }
+ }
+ mock_instance.validate_dimension_constraints = MagicMock()
+ yield MockQueryBuilder
+
+ def test_table_builder_instantiation(self, mock_query_builder):
+ """Test that table builder can be instantiated."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ builder = ImfTableBuilder()
+ assert builder is not None
+ assert builder.query_builder is not None
+
+ def test_validate_dimension_constraints_delegates(self, mock_query_builder):
+ """Test that dimension validation delegates to query builder."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ builder = ImfTableBuilder()
+ builder._validate_dimension_constraints("BOP", COUNTRY="USA")
+
+ mock_query_builder.return_value.validate_dimension_constraints.assert_called_once_with(
+ "BOP", COUNTRY="USA"
+ )
+
+
+class TestTableIdParsing:
+ """Tests for table ID parsing logic."""
+
+ @pytest.fixture
+ def mock_query_builder(self):
+ """Mock ImfQueryBuilder."""
+ with patch(
+ "openbb_imf.utils.query_builder.ImfQueryBuilder"
+ ) as MockQueryBuilder:
+ mock_instance = MockQueryBuilder.return_value
+ mock_instance.metadata = MagicMock()
+ mock_instance.dataflows = {"BOP": {"id": "BOP"}}
+ mock_instance.validate_dimension_constraints = MagicMock()
+ yield MockQueryBuilder
+
+ def test_table_id_with_dataflow_prefix(self, mock_query_builder):
+ """Test parsing table_id with dataflow::table_id format."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ builder = ImfTableBuilder()
+
+ # Test that table_id parsing handles dataflow::table_id format
+ # The format "BOP::H_BOP_STANDARD" should be parsed correctly
+ # This is a smoke test to ensure the builder can be called with this format
+ # Actual parsing is validated via integration tests
+ assert builder.query_builder is not None
+
+ # Test signature accepts table_id with special characters
+ import inspect
+
+ sig = inspect.signature(builder.get_table)
+ assert "table_id" in sig.parameters
+
+
+class TestHierarchyDetection:
+ """Tests for hierarchy/table detection in symbols."""
+
+ def test_table_id_starts_with_h(self):
+ """Test that H_ prefix identifies table IDs."""
+ # Table IDs start with H_
+ table_ids = [
+ "H_BOP_BOP_AGG_STANDARD_PRESENTATION",
+ "H_IRFCL_TOTAL_RESERVES",
+ "H_GFS_EXPENSE",
+ ]
+ for tid in table_ids:
+ assert tid.startswith("H_"), f"{tid} should start with H_"
+
+ def test_indicator_ids_no_h_prefix(self):
+ """Test that indicator IDs don't start with H_."""
+ indicator_ids = [
+ "CD_T",
+ "DB_T",
+ "PCPI_IX",
+ "BM_MAI",
+ "GDP",
+ ]
+ for ind in indicator_ids:
+ assert not ind.startswith("H_"), f"{ind} should not start with H_"
+
+
+class TestTableDataStructure:
+ """Tests for expected table data structure."""
+
+ def test_table_data_contains_hierarchy_fields(self):
+ """Test that table data includes hierarchy metadata."""
+ # Expected fields in table data
+ expected_fields = [
+ "order",
+ "level",
+ "parent_id",
+ "series_id",
+ "title",
+ "TIME_PERIOD",
+ "OBS_VALUE",
+ ]
+
+ # Create sample table data
+ sample_row = {
+ "order": 1,
+ "level": 0,
+ "parent_id": None,
+ "series_id": "IMF_BOP_SERIES",
+ "COUNTRY": "United States",
+ "country_code": "USA",
+ "title": "Current Account Balance",
+ "TIME_PERIOD": "2024-12-31",
+ "OBS_VALUE": -300000000000.0,
+ }
+
+ for field in expected_fields:
+ assert field in sample_row, f"Missing field: {field}"
+
+
+class TestTableBuilderWithMockedMetadata:
+ """Tests with fully mocked metadata."""
+
+ @pytest.fixture
+ def mock_dependencies(self):
+ """Mock all dependencies for table builder."""
+
+ class FakeImfParamsBuilder:
+ """Lightweight stand-in for ImfParamsBuilder used in table tests."""
+
+ def __init__(self, dataflow: str): # noqa: ARG002
+ self._dimensions = ["COUNTRY", "INDICATOR"]
+ self._selections = {d: None for d in self._dimensions}
+
+ def _get_dimensions_in_order(self):
+ return list(self._dimensions)
+
+ def get_options_for_dimension(self, dim_id):
+ if dim_id.upper() == "COUNTRY":
+ return [{"value": "US", "label": "United States"}]
+ if dim_id.upper() == "INDICATOR":
+ return [
+ {"value": "CAB", "label": "CAB"},
+ {"value": "GOODS", "label": "GOODS"},
+ {"value": "IND", "label": "IND"},
+ {"value": "IND_XDC", "label": "IND_XDC"},
+ ]
+ return [{"value": "*", "label": "*"}]
+
+ def set_dimension(self, dim_tuple):
+ dim_id, value = dim_tuple
+ self._selections[dim_id] = value
+ return self._selections
+
+ def get_next_dimension_to_select(self):
+ for dim in self._dimensions:
+ if self._selections.get(dim) is None:
+ return dim
+ return None
+
+ with patch(
+ "openbb_imf.utils.query_builder.ImfQueryBuilder"
+ ) as MockQueryBuilder, patch(
+ "openbb_imf.utils.progressive_helper.ImfParamsBuilder",
+ FakeImfParamsBuilder,
+ ):
+ mock_qb = MockQueryBuilder.return_value
+
+ # Mock metadata
+ mock_qb.metadata = MagicMock()
+ mock_qb.metadata.get_table_in.return_value = {
+ "id": "H_BOP_STANDARD",
+ "title": "Balance of Payments Standard",
+ "hierarchy": [
+ {
+ "order": 1,
+ "level": 0,
+ "id": "CAB",
+ "parent_id": None,
+ "title": "Current Account",
+ "series_id": "CAB_SERIES",
+ "dimension_values": {"INDICATOR": ["CAB"]},
+ },
+ {
+ "order": 2,
+ "level": 1,
+ "id": "GOODS",
+ "parent_id": "CAB",
+ "title": "Goods",
+ "series_id": "GOODS_SERIES",
+ "dimension_values": {"INDICATOR": ["GOODS"]},
+ },
+ ],
+ }
+
+ # Mock validation
+ mock_qb.validate_dimension_constraints = MagicMock()
+
+ # Mock dataflows
+ mock_qb.dataflows = {
+ "BOP": {
+ "id": "BOP",
+ "name": "Balance of Payments",
+ "structureRef": {"id": "IMF_BOP"},
+ }
+ }
+
+ yield MockQueryBuilder
+
+ def test_get_table_returns_expected_structure(self, mock_dependencies):
+ """Test that get_table method exists and has correct signature."""
+ import inspect
+
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ builder = ImfTableBuilder()
+
+ # Verify get_table method exists and has expected parameters
+ assert hasattr(builder, "get_table")
+ sig = inspect.signature(builder.get_table)
+
+ # Check required parameters exist
+ params = sig.parameters
+ assert "dataflow" in params or "table_id" in params
+
+ # Verify the builder is properly initialized
+ assert builder.query_builder is not None
+ assert builder.metadata is not None
+
+ def test_hierarchy_to_dimension_mapping(self, mock_dependencies):
+ """Test that hierarchy codes are mapped to dimensions."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ # We need to mock `fetch_data` to return something that `get_table` can process
+ # `get_table` calls `fetch_data` on query_builder.
+
+ mock_qb = mock_dependencies.return_value
+ mock_qb.fetch_data.return_value = {
+ "data": [
+ {
+ "series_id": "CAB_SERIES",
+ "INDICATOR_code": "CAB",
+ "indicator_code": "CAB",
+ "TIME_PERIOD": "2020",
+ "OBS_VALUE": 100,
+ },
+ {
+ "series_id": "GOODS_SERIES",
+ "INDICATOR_code": "GOODS",
+ "indicator_code": "GOODS",
+ "TIME_PERIOD": "2020",
+ "OBS_VALUE": 50,
+ },
+ ],
+ "metadata": {},
+ }
+
+ # Mock table structure
+ mock_qb.metadata.get_dataflow_table_structure.return_value = {
+ "hierarchy_id": "H_BOP_STANDARD",
+ "hierarchy_name": "Balance of Payments Standard",
+ "hierarchy_description": "",
+ "dataflow_id": "BOP",
+ "codelist_id": "CL_INDICATOR",
+ "agency_id": "IMF",
+ "version": "1.0",
+ "total_groups": 2,
+ "type": "presentation",
+ "indicators": [
+ {
+ "order": 1,
+ "level": 0,
+ "id": "CAB",
+ "parent_id": None,
+ "title": "Current Account",
+ "series_id": "CAB_SERIES",
+ "indicator_code": "CAB",
+ "dimension_values": {"INDICATOR": ["CAB"]},
+ "dimension_id": "INDICATOR",
+ },
+ {
+ "order": 2,
+ "level": 1,
+ "id": "GOODS",
+ "parent_id": "CAB",
+ "title": "Goods",
+ "series_id": "GOODS_SERIES",
+ "indicator_code": "GOODS",
+ "dimension_values": {"INDICATOR": ["GOODS"]},
+ "dimension_id": "INDICATOR",
+ },
+ ],
+ }
+
+ builder = ImfTableBuilder()
+
+ # Call get_table
+ result = builder.get_table("BOP", "H_BOP_STANDARD", COUNTRY="US")
+ rows_by_series = {row["series_id"]: row for row in result["data"]}
+
+ assert set(rows_by_series) == {"CAB_SERIES", "GOODS_SERIES"}
+ assert rows_by_series["CAB_SERIES"]["order"] == 1
+ assert rows_by_series["CAB_SERIES"]["level"] == 0
+ assert rows_by_series["GOODS_SERIES"]["order"] == 2
+ assert rows_by_series["GOODS_SERIES"]["level"] == 1
+
+ def test_indicator_list_truncation_and_post_filtering(self, mock_dependencies):
+ """Test that long indicator lists are truncated and post-filtered."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ mock_qb = mock_dependencies.return_value
+
+ # Create a large hierarchy
+ indicators = []
+ for i in range(100):
+ indicators.append(
+ {
+ "order": i,
+ "level": 0,
+ "id": f"IND_{i}",
+ "parent_id": None,
+ "title": f"Indicator {i}",
+ "series_id": f"SERIES_{i}",
+ "indicator_code": f"IND_{i}",
+ "dimension_values": {"INDICATOR": [f"IND_{i}"]},
+ "dimension_id": "INDICATOR",
+ }
+ )
+
+ mock_qb.metadata.get_dataflow_table_structure.return_value = {
+ "hierarchy_id": "H_LARGE",
+ "hierarchy_name": "Large Table",
+ "hierarchy_description": "",
+ "dataflow_id": "BOP",
+ "codelist_id": "CL_INDICATOR",
+ "agency_id": "IMF",
+ "version": "1.0",
+ "total_groups": 100,
+ "type": "presentation",
+ "indicators": indicators,
+ }
+
+ # Empty data is acceptable; this test focuses on request parameters
+ mock_qb.fetch_data.return_value = {"data": [], "metadata": {}}
+
+ builder = ImfTableBuilder()
+ builder.get_table("BOP", "H_LARGE", COUNTRY="US")
+
+ # Check that fetch_data was called with wildcard or truncated list
+ call_args = mock_qb.fetch_data.call_args
+ assert call_args is not None
+ kwargs = call_args[1]
+
+ # We can check that the indicator parameter is bounded to a reasonable length
+ # (table builder uses a 1500-character safeguard in fallback path).
+ if "INDICATOR" in kwargs:
+ assert len(kwargs["INDICATOR"]) < 1500
+
+ def test_prefix_matching_suffixed_indicators(self, mock_dependencies):
+ """Test matching of indicators with suffixes."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ mock_qb = mock_dependencies.return_value
+
+ mock_qb.metadata.get_dataflow_table_structure.return_value = {
+ "hierarchy_id": "H_SUFFIX",
+ "hierarchy_name": "Suffix Table",
+ "hierarchy_description": "",
+ "dataflow_id": "BOP",
+ "codelist_id": "CL_INDICATOR",
+ "agency_id": "IMF",
+ "version": "1.0",
+ "total_groups": 1,
+ "type": "presentation",
+ "indicators": [
+ {
+ "order": 1,
+ "level": 0,
+ "id": "IND",
+ "parent_id": None,
+ "title": "Indicator",
+ "series_id": "IND_SERIES",
+ "indicator_code": "IND",
+ "dimension_values": {"INDICATOR": ["IND"]}, # Base code
+ "dimension_id": "INDICATOR",
+ }
+ ],
+ }
+
+ # Mock fetch_data returning suffixed version (e.g. IND_XDC)
+ mock_qb.fetch_data.return_value = {
+ "data": [
+ {
+ "series_id": "IND_XDC",
+ "INDICATOR_code": "IND_XDC",
+ "indicator_code": "IND_XDC",
+ "TIME_PERIOD": "2020",
+ "OBS_VALUE": 100,
+ }
+ ],
+ "metadata": {},
+ }
+
+ # We need to ensure the builder can map IND_XDC back to IND hierarchy entry
+ # This usually happens if the builder is smart enough to match prefix.
+
+ builder = ImfTableBuilder()
+ result = builder.get_table("BOP", "H_SUFFIX", COUNTRY="US")
+ row = result["data"][0]
+
+ assert row["series_id"] == "IND_XDC"
+ assert row["order"] == 1
+ assert row["level"] == 0
+
+ def test_time_range_validation_in_table_flow(self, mock_dependencies):
+ """Test that time range validation occurs in table flow."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ mock_qb = mock_dependencies.return_value
+
+ mock_qb.metadata.get_dataflow_table_structure.return_value = {
+ "hierarchy_id": "H_BOP_STANDARD",
+ "hierarchy_name": "Balance of Payments Standard",
+ "hierarchy_description": "",
+ "dataflow_id": "BOP",
+ "codelist_id": "CL_INDICATOR",
+ "agency_id": "IMF",
+ "version": "1.0",
+ "total_groups": 1,
+ "type": "presentation",
+ "indicators": [
+ {
+ "order": 1,
+ "level": 0,
+ "id": "CAB",
+ "parent_id": None,
+ "title": "Current Account",
+ "series_id": "CAB_SERIES",
+ "indicator_code": "CAB",
+ "dimension_values": {"INDICATOR": ["CAB"]},
+ "dimension_id": "INDICATOR",
+ }
+ ],
+ }
+
+ mock_qb.fetch_data.return_value = {
+ "data": [
+ {
+ "series_id": "CAB_SERIES",
+ "INDICATOR_code": "CAB",
+ "indicator_code": "CAB",
+ "TIME_PERIOD": "2020",
+ "OBS_VALUE": 100,
+ }
+ ],
+ "metadata": {},
+ }
+
+ builder = ImfTableBuilder()
+
+ builder.get_table(
+ "BOP", "H_BOP_STANDARD", COUNTRY="US", start_date="2020", end_date="2021"
+ )
+
+ mock_qb.fetch_data.assert_called()
+ call_kwargs = mock_qb.fetch_data.call_args[1]
+ assert call_kwargs.get("start_date") == "2020"
+ assert call_kwargs.get("end_date") == "2021"
+
+
+class TestBopCompositeHierarchyMatching:
+ """Regression tests for BOP hierarchy matching.
+
+ These tests are deterministic (no network) and validate that the IMF hierarchy
+ is treated as the source of truth for parent/child relationships.
+ """
+
+ @pytest.fixture
+ def mock_bop_dependencies(self):
+ """Mock query builder + params builder for BOP composite matching tests."""
+
+ class FakeImfParamsBuilder:
+ def __init__(self, dataflow: str): # noqa: ARG002
+ self._dimensions = ["COUNTRY", "INDICATOR", "BOP_ACCOUNTING_ENTRY"]
+ self._selections = {d: None for d in self._dimensions}
+
+ def _get_dimensions_in_order(self):
+ return list(self._dimensions)
+
+ def get_options_for_dimension(self, dim_id):
+ dim_id = dim_id.upper()
+ if dim_id == "COUNTRY":
+ return [{"value": "AU", "label": "Australia"}]
+ if dim_id == "INDICATOR":
+ return [
+ {"value": "SINCEX", "label": "SINCEX"},
+ {"value": "O", "label": "O"},
+ ]
+ if dim_id == "BOP_ACCOUNTING_ENTRY":
+ return [
+ {"value": "NETCD_T", "label": "Net"},
+ {"value": "CD_T", "label": "Credit"},
+ {"value": "DB_T", "label": "Debit"},
+ {"value": "A_P", "label": "Assets"},
+ {"value": "L_P", "label": "Liabilities"},
+ ]
+ return [{"value": "*", "label": "*"}]
+
+ def set_dimension(self, dim_tuple):
+ dim_id, value = dim_tuple
+ self._selections[dim_id] = value
+ return self._selections
+
+ def get_next_dimension_to_select(self):
+ for dim in self._dimensions:
+ if self._selections.get(dim) is None:
+ return dim
+ return None
+
+ with patch(
+ "openbb_imf.utils.query_builder.ImfQueryBuilder"
+ ) as MockQueryBuilder, patch(
+ "openbb_imf.utils.progressive_helper.ImfParamsBuilder",
+ FakeImfParamsBuilder,
+ ):
+ mock_qb = MockQueryBuilder.return_value
+ mock_qb.validate_dimension_constraints = MagicMock()
+
+ # Minimal metadata object with required attributes
+ mock_qb.metadata = MagicMock()
+ mock_qb.metadata.dataflows = {
+ "BOP": {
+ "id": "BOP",
+ "name": "Balance of Payments",
+ # Keep structureRef id falsy to avoid datastructure-dependent logic
+ "structureRef": {"id": ""},
+ }
+ }
+ mock_qb.metadata.datastructures = {}
+ mock_qb.metadata._codelist_cache = {}
+
+ # Ensure builder uses consistent dataflow map
+ mock_qb.dataflows = mock_qb.metadata.dataflows
+
+ # Hierarchy contains:
+ # - A Net node (NETCD_T)
+ # - Two leaf nodes with the same indicator_code (SINCEX) under NETCD_T
+ # - Two leaf nodes with the same indicator_code (O) under A_P and L_P
+ mock_qb.metadata.get_dataflow_table_structure.return_value = {
+ "hierarchy_id": "H_BOP_FAKE",
+ "hierarchy_name": "BOP Fake",
+ "hierarchy_description": "",
+ "dataflow_id": "BOP",
+ "codelist_id": "CL_BOP_INDICATOR",
+ "agency_id": "IMF",
+ "version": "1.0",
+ "total_groups": 7,
+ "type": "presentation",
+ "indicators": [
+ {
+ "order": 1,
+ "depth": 0,
+ "id": "NETCD_T",
+ "parent_id": None,
+ "label": "Net (credits less debits)",
+ "series_id": "",
+ "indicator_code": "NETCD_T",
+ "is_group": True,
+ "dimension_id": "BOP_ACCOUNTING_ENTRY",
+ },
+ {
+ "order": 2,
+ "depth": 1,
+ "id": "SINCEX_CD",
+ "parent_id": "NETCD_T",
+ "label": "Secondary income excluding exceptional financing",
+ "series_id": "",
+ "indicator_code": "SINCEX",
+ "is_group": False,
+ "dimension_id": "INDICATOR",
+ },
+ {
+ "order": 3,
+ "depth": 1,
+ "id": "SINCEX_DB",
+ "parent_id": "NETCD_T",
+ "label": "Secondary income excluding exceptional financing",
+ "series_id": "",
+ "indicator_code": "SINCEX",
+ "is_group": False,
+ "dimension_id": "INDICATOR",
+ },
+ {
+ "order": 4,
+ "depth": 0,
+ "id": "A_P",
+ "parent_id": None,
+ "label": "Assets, Positions",
+ "series_id": "",
+ "indicator_code": "A_P",
+ "is_group": True,
+ "dimension_id": "BOP_ACCOUNTING_ENTRY",
+ },
+ {
+ "order": 5,
+ "depth": 0,
+ "id": "L_P",
+ "parent_id": None,
+ "label": "Liabilities, Positions",
+ "series_id": "",
+ "indicator_code": "L_P",
+ "is_group": True,
+ "dimension_id": "BOP_ACCOUNTING_ENTRY",
+ },
+ {
+ "order": 6,
+ "depth": 1,
+ "id": "O_A",
+ "parent_id": "A_P",
+ "label": "Other investment",
+ "series_id": "",
+ "indicator_code": "O",
+ "is_group": False,
+ "dimension_id": "INDICATOR",
+ },
+ {
+ "order": 7,
+ "depth": 1,
+ "id": "O_L",
+ "parent_id": "L_P",
+ "label": "Other investment",
+ "series_id": "",
+ "indicator_code": "O",
+ "is_group": False,
+ "dimension_id": "INDICATOR",
+ },
+ ],
+ }
+
+ # Data rows intentionally omit usable series_id to force composite matching
+ mock_qb.fetch_data.return_value = {
+ "data": [
+ {
+ "series_id": "",
+ "INDICATOR_code": "SINCEX",
+ "BOP_ACCOUNTING_ENTRY_code": "CD_T",
+ "REF_AREA_code": "AU",
+ "TIME_PERIOD": "2024-12-31",
+ "OBS_VALUE": 9.86,
+ },
+ {
+ "series_id": "",
+ "INDICATOR_code": "SINCEX",
+ "BOP_ACCOUNTING_ENTRY_code": "DB_T",
+ "REF_AREA_code": "AU",
+ "TIME_PERIOD": "2024-12-31",
+ "OBS_VALUE": 10.04,
+ },
+ {
+ "series_id": "",
+ "INDICATOR_code": "O",
+ "BOP_ACCOUNTING_ENTRY_code": "A_P",
+ "REF_AREA_code": "AU",
+ "TIME_PERIOD": "2024-12-31",
+ "OBS_VALUE": 1.0,
+ },
+ {
+ "series_id": "",
+ "INDICATOR_code": "O",
+ "BOP_ACCOUNTING_ENTRY_code": "L_P",
+ "REF_AREA_code": "AU",
+ "TIME_PERIOD": "2024-12-31",
+ "OBS_VALUE": 2.0,
+ },
+ ],
+ "metadata": {},
+ }
+
+ yield MockQueryBuilder
+
+ def test_bop_credit_debit_resolves_under_net_parent(self, mock_bop_dependencies):
+ """Credit and Debit rows must resolve under the hierarchy's Net parent."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ builder = ImfTableBuilder()
+ result = builder.get_table("BOP", "H_BOP_FAKE", COUNTRY="AU")
+
+ rows = [r for r in result["data"] if r.get("INDICATOR_code") == "SINCEX"]
+ # Expect both Credit and Debit to be kept (not dropped)
+ assert len(rows) == 2
+
+ for row in rows:
+ # Composite match should use hierarchy parent (NETCD_T), not CD_T/DB_T
+ assert row.get("parent_code") == "NETCD_T"
+ assert "excluding exceptional financing" in (row.get("title") or "")
+
+ titles = {r.get("title") for r in rows}
+ assert any(t and t.endswith(", Credit") for t in titles)
+ assert any(t and t.endswith(", Debit") for t in titles)
+
+ def test_bop_assets_liabilities_remain_distinct_paths(self, mock_bop_dependencies):
+ """Assets and Liabilities must remain separate hierarchy paths."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ builder = ImfTableBuilder()
+ result = builder.get_table("BOP", "H_BOP_FAKE", COUNTRY="AU")
+
+ rows = [r for r in result["data"] if r.get("INDICATOR_code") == "O"]
+ assert len(rows) == 2
+
+ parent_codes = {r.get("parent_code") for r in rows}
+ assert parent_codes == {"A_P", "L_P"}
+
+ titles = {r.get("title") for r in rows}
+ assert any(t and t.endswith(", Assets") for t in titles)
+ assert any(t and t.endswith(", Liabilities") for t in titles)
+
+
+class TestTableBuilderErrorHandling:
+ """Tests for error handling in table builder."""
+
+ @pytest.fixture
+ def mock_query_builder(self):
+ """Mock ImfQueryBuilder."""
+ with patch(
+ "openbb_imf.utils.query_builder.ImfQueryBuilder"
+ ) as MockQueryBuilder:
+ mock_instance = MockQueryBuilder.return_value
+ mock_instance.metadata = MagicMock()
+ mock_instance.dataflows = {"BOP": {"id": "BOP"}}
+ mock_instance.validate_dimension_constraints = MagicMock()
+ yield MockQueryBuilder
+
+ def test_invalid_dataflow_raises_error(self, mock_query_builder):
+ """Test that invalid dimension constraints raises appropriate error."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ builder = ImfTableBuilder()
+
+ # Mock validation to raise error for invalid dataflow
+ mock_query_builder.return_value.validate_dimension_constraints.side_effect = (
+ ValueError("Invalid dataflow: INVALID_DATAFLOW")
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ builder._validate_dimension_constraints("INVALID_DATAFLOW", COUNTRY="USA")
+
+ assert "Invalid" in str(exc_info.value)
+
+ def test_dimension_constraint_validation_error(self, mock_query_builder):
+ """Test that invalid dimension values raise validation error."""
+ from openbb_imf.utils.table_builder import ImfTableBuilder
+
+ builder = ImfTableBuilder()
+
+ # Mock validation to raise error
+ mock_query_builder.return_value.validate_dimension_constraints.side_effect = (
+ ValueError("Invalid country: XYZ")
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ builder._validate_dimension_constraints("BOP", COUNTRY="XYZ")
+
+ assert (
+ "Invalid" in str(exc_info.value) or "country" in str(exc_info.value).lower()
+ )
diff --git a/openbb_platform/providers/nasdaq/openbb_nasdaq/app.py b/openbb_platform/providers/nasdaq/openbb_nasdaq/app.py
index 71e3a6fbdd2..7ddde92b647 100644
--- a/openbb_platform/providers/nasdaq/openbb_nasdaq/app.py
+++ b/openbb_platform/providers/nasdaq/openbb_nasdaq/app.py
@@ -325,6 +325,10 @@ def main():
"groupByParamName": "symbol",
},
},
+ {
+ "field": "name",
+ "pinned": "left",
+ },
],
}
},
@@ -395,6 +399,10 @@ def main():
"groupByParamName": "symbol2",
},
},
+ {
+ "field": "name",
+ "pinned": "left",
+ },
],
}
},
@@ -560,6 +568,10 @@ def main():
"groupByParamName": "symbol3",
},
},
+ {
+ "field": "name",
+ "pinned": "left",
+ },
],
}
},
diff --git a/openbb_platform/providers/oecd/openbb_oecd/models/consumer_price_index.py b/openbb_platform/providers/oecd/openbb_oecd/models/consumer_price_index.py
index 6229c255d50..304283304c0 100644
--- a/openbb_platform/providers/oecd/openbb_oecd/models/consumer_price_index.py
+++ b/openbb_platform/providers/oecd/openbb_oecd/models/consumer_price_index.py
@@ -3,7 +3,7 @@
# pylint: disable=unused-argument
from datetime import date
-from typing import Any, Literal
+from typing import Any
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.provider.abstract.fetcher import Fetcher
@@ -54,7 +54,7 @@ expenditure_dict_rev = {
}
expenditure_dict = {v: k for k, v in expenditure_dict_rev.items()}
expenditures = tuple(expenditure_dict.keys()) + ("all",)
-ExpenditureChoices = Literal[
+expenditure_choices = [
"total",
"all",
"actual_rentals",
@@ -87,6 +87,8 @@ ExpenditureChoices = Literal[
"water_supply_other_services",
]
+transform_choices = ["index", "yoy", "period"]
+
class OECDCPIQueryParams(ConsumerPriceIndexQueryParams):
"""OECD CPI Query.
@@ -99,20 +101,22 @@ class OECDCPIQueryParams(ConsumerPriceIndexQueryParams):
"multiple_items_allowed": True,
"choices": CountriesList,
},
+ "transform": {
+ "choices": transform_choices,
+ },
+ "expenditure": {
+ "choices": expenditure_choices,
+ },
}
- country: str = Field(
- description="Country to get CPI for. This is the list of OECD supported countries",
- default="united_states",
- )
- expenditure: ExpenditureChoices = Field(
+ expenditure: str = Field(
description="Expenditure component of CPI.",
default="total",
- json_schema_extra={"choices": list(expenditures)},
)
@field_validator("country", mode="before", check_fields=False)
- def validate_country(cls, c: str): # pylint: disable=E0213
+ @classmethod
+ def validate_country(cls, c: str):
"""Validate country."""
result: list = []
values = c.replace(" ", "_").split(",")
@@ -121,6 +125,16 @@ class OECDCPIQueryParams(ConsumerPriceIndexQueryParams):
result.append(v.lower())
return ",".join(result)
+ @field_validator("expenditure", mode="before", check_fields=False)
+ @classmethod
+ def validate_expenditure(cls, v):
+ """Validate expenditure."""
+ if v.lower() not in expenditure_choices:
+ raise ValueError(
+ f"Expenditure '{v}' is not a valid choice. Valid choices:\n\n{expenditure_choices}"
+ )
+ return v
+
class OECDCPIData(ConsumerPriceIndexData):
"""OECD CPI Data."""
@@ -156,7 +170,7 @@ class OECDCPIFetcher(Fetcher[OECDCPIQueryParams, list[OECDCPIData]]):
from openbb_oecd.utils import helpers # noqa
methodology = "HICP" if query.harmonized is True else "N"
- query.units = "mom" if query.transform == "period" else query.transform
+ unit = "mom" if query.transform == "period" else query.transform
query.frequency = (
"monthly"
if query.harmonized is True and query.frequency == "quarter"
@@ -167,7 +181,7 @@ class OECDCPIFetcher(Fetcher[OECDCPIQueryParams, list[OECDCPIData]]):
"index": "IX",
"yoy": "PA",
"mom": "PC",
- }[query.units]
+ }[unit]
expenditure = (
"" if query.expenditure == "all" else expenditure_dict[query.expenditure]
)