notoecd 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
notoecd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: notoecd
3
+ Version: 0.1.0
4
+ Summary: Library for interacting with the OECD Data Explorer through Python
5
+ Author-email: Daniel Vegara Balsa <daniel.vegarabalsa@oecd.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dani-37/notoecd
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: pandas>=2.0
11
+ Requires-Dist: requests>=2.31
12
+
13
+ # notoecd
14
+
15
+ ⚠️ **Unofficial package, not endorsed by the OECD.**
16
+
17
+ A lightweight Python interface for exploring OECD SDMX structures and downloading OECD regional datasets.
18
+ The package provides utilities for:
19
+
20
+ - Discovering dataset metadata
21
+ - Searching for relevant datasets using keyword matching
22
+ - Exploring the structure and code lists of a dataset
23
+ - Fetching filtered SDMX data directly into a pandas DataFrame
24
+
25
+ ------------------------------------------------------------
26
+
27
+ ## Installation
28
+
29
+ You can install the package by running:
30
+
31
+ pip install notoecd
32
+
33
+ ------------------------------------------------------------
34
+
35
+ ## Quick Start
36
+
37
+ import notoecd
38
+
39
+ The main functions in this module are:
40
+
41
+ search_keywords(keywords) -> pd.DataFrame
42
+ get_structure(agencyID, dataflowID) -> Structure
43
+ get_df(agencyID, dataflowID, filters) -> pd.DataFrame
44
+
45
+ ------------------------------------------------------------
46
+
47
+ ## Searching for datasets
48
+
49
+ `search_keywords` performs:
50
+
51
+ - Normalized text matching
52
+ - Accent-insensitive search
53
+ - Multi-keyword OR matching
54
+ - Ranking by number of matched keywords
55
+
56
+ Example:
57
+
58
+ hits = notoecd.search_keywords(['gross domestic product', 'tl2', 'tl3'])
59
+
60
+ This returns datasets that mention GDP and regional levels (TL2/TL3). It gives their name, description, and identifiers (agencyID and dataflowID), which we will need for the next step.
61
+
62
+ ------------------------------------------------------------
63
+
64
+ ## Inspecting dataset structure
65
+
66
+ Once a dataset is identified, load its SDMX structure:
67
+
68
+ dataset = 'Gross domestic product - Regions'
69
+ agencyID = 'OECD.CFE.EDS'
70
+ dataflowID = 'DSD_REG_ECO@DF_GDP'
71
+
72
+ s = notoecd.get_structure(agencyID, dataflowID)
73
+
74
+ ### Table of contents
75
+
76
+ s.toc
77
+
78
+ This shows all filters and their available values.
79
+
80
+ ### Exploring code values
81
+
82
+ s.explain_vals('MEASURE')
83
+ s.explain_vals('UNIT_MEASURE')
84
+
85
+ This shows the available measures and units used in the dataset.
86
+
87
+ ------------------------------------------------------------
88
+
89
+ ## Filtering and downloading data
90
+
91
+ To download data, build a dictionary of filters.
92
+ Keys correspond to SDMX dimensions, values are strings or lists (for multiple values):
93
+
94
+ filters = {
95
+ 'territorial_level': ['tl2', 'tl3'],
96
+ 'measure': 'gdp',
97
+ 'prices': 'Q',
98
+ 'unit_measure': 'USD_PPP_PS'
99
+ }
100
+
101
+ Fetch the filtered dataset:
102
+
103
+ df = notoecd.get_df(agency, dataflow, filters)
104
+ df.head()
105
+
106
+ The returned object is a pandas DataFrame containing the requested subset of OECD SDMX data.
107
+
108
+ ------------------------------------------------------------
109
+
110
+ ## Examples
111
+
112
+ You can see this full example as a notebook called example.ipynb.
113
+
114
+
@@ -0,0 +1,102 @@
1
+ # notoecd
2
+
3
+ ⚠️ **Unofficial package, not endorsed by the OECD.**
4
+
5
+ A lightweight Python interface for exploring OECD SDMX structures and downloading OECD regional datasets.
6
+ The package provides utilities for:
7
+
8
+ - Discovering dataset metadata
9
+ - Searching for relevant datasets using keyword matching
10
+ - Exploring the structure and code lists of a dataset
11
+ - Fetching filtered SDMX data directly into a pandas DataFrame
12
+
13
+ ------------------------------------------------------------
14
+
15
+ ## Installation
16
+
17
+ You can install the package by running:
18
+
19
+ pip install notoecd
20
+
21
+ ------------------------------------------------------------
22
+
23
+ ## Quick Start
24
+
25
+ import notoecd
26
+
27
+ The main functions in this module are:
28
+
29
+ search_keywords(keywords) -> pd.DataFrame
30
+ get_structure(agencyID, dataflowID) -> Structure
31
+ get_df(agencyID, dataflowID, filters) -> pd.DataFrame
32
+
33
+ ------------------------------------------------------------
34
+
35
+ ## Searching for datasets
36
+
37
+ `search_keywords` performs:
38
+
39
+ - Normalized text matching
40
+ - Accent-insensitive search
41
+ - Multi-keyword OR matching
42
+ - Ranking by number of matched keywords
43
+
44
+ Example:
45
+
46
+ hits = notoecd.search_keywords(['gross domestic product', 'tl2', 'tl3'])
47
+
48
+ This returns datasets that mention GDP and regional levels (TL2/TL3). It gives their name, description, and identifiers (agencyID and dataflowID), which we will need for the next step.
49
+
50
+ ------------------------------------------------------------
51
+
52
+ ## Inspecting dataset structure
53
+
54
+ Once a dataset is identified, load its SDMX structure:
55
+
56
+ dataset = 'Gross domestic product - Regions'
57
+ agencyID = 'OECD.CFE.EDS'
58
+ dataflowID = 'DSD_REG_ECO@DF_GDP'
59
+
60
+ s = notoecd.get_structure(agencyID, dataflowID)
61
+
62
+ ### Table of contents
63
+
64
+ s.toc
65
+
66
+ This shows all filters and their available values.
67
+
68
+ ### Exploring code values
69
+
70
+ s.explain_vals('MEASURE')
71
+ s.explain_vals('UNIT_MEASURE')
72
+
73
+ This shows the available measures and units used in the dataset.
74
+
75
+ ------------------------------------------------------------
76
+
77
+ ## Filtering and downloading data
78
+
79
+ To download data, build a dictionary of filters.
80
+ Keys correspond to SDMX dimensions, values are strings or lists (for multiple values):
81
+
82
+ filters = {
83
+ 'territorial_level': ['tl2', 'tl3'],
84
+ 'measure': 'gdp',
85
+ 'prices': 'Q',
86
+ 'unit_measure': 'USD_PPP_PS'
87
+ }
88
+
89
+ Fetch the filtered dataset:
90
+
91
+ df = notoecd.get_df(agency, dataflow, filters)
92
+ df.head()
93
+
94
+ The returned object is a pandas DataFrame containing the requested subset of OECD SDMX data.
95
+
96
+ ------------------------------------------------------------
97
+
98
+ ## Examples
99
+
100
+ You can see this full example as a notebook called example.ipynb.
101
+
102
+
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: notoecd
3
+ Version: 0.1.0
4
+ Summary: Library for interacting with the OECD Data Explorer through Python
5
+ Author-email: Daniel Vegara Balsa <daniel.vegarabalsa@oecd.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dani-37/notoecd
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: pandas>=2.0
11
+ Requires-Dist: requests>=2.31
12
+
13
+ # notoecd
14
+
15
+ ⚠️ **Unofficial package, not endorsed by the OECD.**
16
+
17
+ A lightweight Python interface for exploring OECD SDMX structures and downloading OECD regional datasets.
18
+ The package provides utilities for:
19
+
20
+ - Discovering dataset metadata
21
+ - Searching for relevant datasets using keyword matching
22
+ - Exploring the structure and code lists of a dataset
23
+ - Fetching filtered SDMX data directly into a pandas DataFrame
24
+
25
+ ------------------------------------------------------------
26
+
27
+ ## Installation
28
+
29
+ You can install the package by running:
30
+
31
+ pip install notoecd
32
+
33
+ ------------------------------------------------------------
34
+
35
+ ## Quick Start
36
+
37
+ import notoecd
38
+
39
+ The main functions in this module are:
40
+
41
+ search_keywords(keywords) -> pd.DataFrame
42
+ get_structure(agencyID, dataflowID) -> Structure
43
+ get_df(agencyID, dataflowID, filters) -> pd.DataFrame
44
+
45
+ ------------------------------------------------------------
46
+
47
+ ## Searching for datasets
48
+
49
+ `search_keywords` performs:
50
+
51
+ - Normalized text matching
52
+ - Accent-insensitive search
53
+ - Multi-keyword OR matching
54
+ - Ranking by number of matched keywords
55
+
56
+ Example:
57
+
58
+ hits = notoecd.search_keywords(['gross domestic product', 'tl2', 'tl3'])
59
+
60
+ This returns datasets that mention GDP and regional levels (TL2/TL3). It gives their name, description, and identifiers (agencyID and dataflowID), which we will need for the next step.
61
+
62
+ ------------------------------------------------------------
63
+
64
+ ## Inspecting dataset structure
65
+
66
+ Once a dataset is identified, load its SDMX structure:
67
+
68
+ dataset = 'Gross domestic product - Regions'
69
+ agencyID = 'OECD.CFE.EDS'
70
+ dataflowID = 'DSD_REG_ECO@DF_GDP'
71
+
72
+ s = notoecd.get_structure(agencyID, dataflowID)
73
+
74
+ ### Table of contents
75
+
76
+ s.toc
77
+
78
+ This shows all filters and their available values.
79
+
80
+ ### Exploring code values
81
+
82
+ s.explain_vals('MEASURE')
83
+ s.explain_vals('UNIT_MEASURE')
84
+
85
+ This shows the available measures and units used in the dataset.
86
+
87
+ ------------------------------------------------------------
88
+
89
+ ## Filtering and downloading data
90
+
91
+ To download data, build a dictionary of filters.
92
+ Keys correspond to SDMX dimensions, values are strings or lists (for multiple values):
93
+
94
+ filters = {
95
+ 'territorial_level': ['tl2', 'tl3'],
96
+ 'measure': 'gdp',
97
+ 'prices': 'Q',
98
+ 'unit_measure': 'USD_PPP_PS'
99
+ }
100
+
101
+ Fetch the filtered dataset:
102
+
103
+ df = notoecd.get_df(agency, dataflow, filters)
104
+ df.head()
105
+
106
+ The returned object is a pandas DataFrame containing the requested subset of OECD SDMX data.
107
+
108
+ ------------------------------------------------------------
109
+
110
+ ## Examples
111
+
112
+ You can see this full example as a notebook called example.ipynb.
113
+
114
+
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ notoecd/notoecd.egg-info/PKG-INFO
4
+ notoecd/notoecd.egg-info/SOURCES.txt
5
+ notoecd/notoecd.egg-info/dependency_links.txt
6
+ notoecd/notoecd.egg-info/requires.txt
7
+ notoecd/notoecd.egg-info/top_level.txt
8
+ tests/test_api.py
9
+ tests/test_calls.py
10
+ tests/test_datasets.py
11
+ tests/test_structure.py
@@ -0,0 +1,2 @@
1
+ pandas>=2.0
2
+ requests>=2.31
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "notoecd"
7
+ version = "0.1.0"
8
+ description = "Library for interacting with the OECD Data Explorer through Python"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+
13
+
14
+ authors = [
15
+ { name = "Daniel Vegara Balsa", email = "daniel.vegarabalsa@oecd.org" }
16
+ ]
17
+
18
+ dependencies = [
19
+ "pandas>=2.0",
20
+ "requests>=2.31"
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/dani-37/notoecd"
25
+
26
+ [tool.setuptools]
27
+ package-dir = {"" = "notoecd"}
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["notoecd"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ import notoecd
2
+
3
+ def test_public_api_exports():
4
+ assert callable(notoecd.get_df)
5
+ assert callable(notoecd.get_structure)
6
+ assert callable(notoecd.search_keywords)
7
+
8
+ def test_import_package():
9
+ import importlib
10
+ m = importlib.import_module("notoecd")
11
+ assert m is not None
@@ -0,0 +1,70 @@
1
+ import pandas as pd
2
+ from types import SimpleNamespace
3
+ from unittest.mock import patch
4
+ import notoecd.calls as calls
5
+
6
+
7
+ def _fake_structure_with_toc_titles(titles):
8
+ toc = pd.DataFrame({"title": titles})
9
+ return SimpleNamespace(toc=toc)
10
+
11
+
12
+ def test_build_filter_expression_orders_by_toc_and_uppercases():
13
+ fake_s = _fake_structure_with_toc_titles(["PRICES", "UNIT_MEASURE", "MEASURE"])
14
+ filters = {"prices": "q", "unit_measure": ["USD_PPP_PS"], "measure": "gdp"}
15
+
16
+ with patch("notoecd.calls.get_structure", return_value=fake_s):
17
+ expr = calls._build_filter_expression("A", "B", filters)
18
+
19
+ assert expr == "Q.USD_PPP_PS.GDP"
20
+
21
+
22
+ def test_build_filter_expression_missing_dims_are_empty_parts():
23
+ fake_s = _fake_structure_with_toc_titles(["A", "B", "C"])
24
+
25
+ with patch("notoecd.calls.get_structure", return_value=fake_s):
26
+ expr = calls._build_filter_expression("A", "B", {"b": "x"})
27
+
28
+ assert expr == ".X."
29
+
30
+
31
+ def test_build_filter_expression_multi_value_joins_plus():
32
+ fake_s = _fake_structure_with_toc_titles(["territorial_level"])
33
+
34
+ with patch("notoecd.calls.get_structure", return_value=fake_s):
35
+ expr = calls._build_filter_expression("A", "B", {"territorial_level": ["tl2", "tl3"]})
36
+
37
+ assert expr == "TL2+TL3"
38
+
39
+
40
+ def test_get_df_builds_url_and_returns_copy():
41
+ calls._fetch_df.cache_clear()
42
+
43
+ fake_s = _fake_structure_with_toc_titles(["PRICES"])
44
+ fake_df = pd.DataFrame({"x": [1, 2]})
45
+
46
+ with patch("notoecd.calls.get_structure", return_value=fake_s), \
47
+ patch("notoecd.calls.pd.read_csv", return_value=fake_df) as mock_read_csv:
48
+ out = calls.get_df("OECD.CFE.EDS", "DSD_REG_ECO@DF_GDP", {"prices": "q"})
49
+
50
+ assert out.equals(fake_df)
51
+ assert out is not fake_df # must be a copy()
52
+
53
+ (url,), kwargs = mock_read_csv.call_args
54
+ assert url.startswith("https://sdmx.oecd.org/public/rest/data/")
55
+ assert "OECD.CFE.EDS,DSD_REG_ECO@DF_GDP," in url
56
+ assert "/Q" in url
57
+ assert "dimensionAtObservation=AllDimensions" in url
58
+ assert "format=csvfile" in url
59
+ assert kwargs["storage_options"]["User-Agent"]
60
+
61
+
62
+ def test_get_df_accepts_string_filter_expression_and_uppercases():
63
+ calls._fetch_df.cache_clear()
64
+
65
+ fake_df = pd.DataFrame({"x": [1]})
66
+ with patch("notoecd.calls.pd.read_csv", return_value=fake_df) as mock_read_csv:
67
+ _ = calls.get_df("A", "B", " tl2+tl3..gdp ")
68
+
69
+ (url,), _ = mock_read_csv.call_args
70
+ assert "/TL2+TL3..GDP" in url
@@ -0,0 +1,100 @@
1
+ import importlib
2
+ import requests
3
+ import pandas as pd
4
+
5
+
6
+ def _fake_dataflow_all_xml() -> bytes:
7
+ xml = """<?xml version="1.0" encoding="UTF-8"?>
8
+ <message:Structure
9
+ xmlns:message="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message"
10
+ xmlns:structure="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/structure"
11
+ xmlns:common="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common"
12
+ xmlns:xml="http://www.w3.org/XML/1998/namespace"
13
+ >
14
+ <message:Structures>
15
+ <structure:Dataflows>
16
+ <structure:Dataflow id="DSD_REG_ECO@DF_GDP" agencyID="OECD.CFE.EDS">
17
+ <common:Name xml:lang="en">Gross domestic product - Regions</common:Name>
18
+ <common:Description xml:lang="en">GDP by region</common:Description>
19
+ </structure:Dataflow>
20
+
21
+ <structure:Dataflow id="DF_CAFE" agencyID="OECD.TEST">
22
+ <common:Name xml:lang="en">Café prices</common:Name>
23
+ <common:Description xml:lang="en">Prices in cafes</common:Description>
24
+ </structure:Dataflow>
25
+
26
+ <structure:Dataflow id="DF_OTHER" agencyID="OECD">
27
+ <common:Name xml:lang="en">Other dataset</common:Name>
28
+ <common:Description xml:lang="en">Other description</common:Description>
29
+ </structure:Dataflow>
30
+ </structure:Dataflows>
31
+ </message:Structures>
32
+ </message:Structure>
33
+ """
34
+ return xml.encode("utf-8")
35
+
36
+
37
+ class _Resp:
38
+ def __init__(self, content: bytes, status_code: int = 200):
39
+ self.content = content
40
+ self.status_code = status_code
41
+
42
+ def raise_for_status(self):
43
+ if self.status_code >= 400:
44
+ raise requests.HTTPError(f"HTTP {self.status_code}")
45
+
46
+
47
+ def test_datasets_dataframe_built_on_import(monkeypatch):
48
+ def fake_get(url, *args, **kwargs):
49
+ if url.endswith("/public/rest/dataflow/all"):
50
+ return _Resp(_fake_dataflow_all_xml())
51
+ raise AssertionError(f"Unexpected URL in test_datasets: {url}")
52
+
53
+ monkeypatch.setattr(requests, "get", fake_get)
54
+
55
+ datasets_mod = importlib.import_module("notoecd.datasets")
56
+ importlib.reload(datasets_mod)
57
+
58
+ assert isinstance(datasets_mod.datasets, pd.DataFrame)
59
+ assert {"agencyID", "dataflowID", "name", "description"}.issubset(datasets_mod.datasets.columns)
60
+ assert len(datasets_mod.datasets) == 3
61
+
62
+
63
+ def test_search_keywords_or_and_normalization(monkeypatch):
64
+ def fake_get(url, *args, **kwargs):
65
+ if url.endswith("/public/rest/dataflow/all"):
66
+ return _Resp(_fake_dataflow_all_xml())
67
+ raise AssertionError(f"Unexpected URL in test_datasets: {url}")
68
+
69
+ monkeypatch.setattr(requests, "get", fake_get)
70
+
71
+ datasets_mod = importlib.import_module("notoecd.datasets")
72
+ importlib.reload(datasets_mod)
73
+
74
+ # OR behavior: should match GDP OR tl2 (not present) OR cafe (accent-insensitive)
75
+ hits = datasets_mod.search_keywords(["gross domestic product", "cafe"])
76
+
77
+ assert len(hits) == 2
78
+ assert any(hits["dataflowID"] == "DSD_REG_ECO@DF_GDP")
79
+ assert any(hits["dataflowID"] == "DF_CAFE")
80
+
81
+ names = " ".join(hits["name"].fillna("").tolist()).lower()
82
+ assert ("gross domestic product" in names) or ("café" in names) or ("cafe" in names)
83
+
84
+
85
+ def test_search_keywords_rejects_empty(monkeypatch):
86
+ def fake_get(url, *args, **kwargs):
87
+ if url.endswith("/public/rest/dataflow/all"):
88
+ return _Resp(_fake_dataflow_all_xml())
89
+ raise AssertionError(f"Unexpected URL in test_datasets: {url}")
90
+
91
+ monkeypatch.setattr(requests, "get", fake_get)
92
+
93
+ datasets_mod = importlib.import_module("notoecd.datasets")
94
+ importlib.reload(datasets_mod)
95
+
96
+ try:
97
+ datasets_mod.search_keywords([" ", ""])
98
+ raise AssertionError("Expected ValueError for empty keywords")
99
+ except ValueError:
100
+ pass
@@ -0,0 +1,98 @@
1
+ import importlib
2
+ import requests
3
+ import pandas as pd
4
+
5
+
6
+ def _fake_structure_xml() -> bytes:
7
+ return b"""<?xml version="1.0" encoding="UTF-8"?>
8
+ <message:Structure
9
+ xmlns:message="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message"
10
+ xmlns:structure="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/structure"
11
+ xmlns:common="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/common"
12
+ xmlns:xml="http://www.w3.org/XML/1998/namespace"
13
+ >
14
+ <message:Structures>
15
+
16
+ <structure:Concepts>
17
+ <structure:ConceptScheme>
18
+ <structure:Concept id="PRICES">
19
+ <common:Name xml:lang="en">Prices</common:Name>
20
+ <structure:CoreRepresentation>
21
+ <structure:Enumeration>
22
+ <Ref id="CL_PRICES"/>
23
+ </structure:Enumeration>
24
+ </structure:CoreRepresentation>
25
+ </structure:Concept>
26
+ </structure:ConceptScheme>
27
+ </structure:Concepts>
28
+
29
+ <structure:Codelists>
30
+ <structure:Codelist id="CL_PRICES">
31
+ <structure:Code id="Q">
32
+ <common:Name xml:lang="en">Quarterly</common:Name>
33
+ </structure:Code>
34
+ <structure:Code id="V">
35
+ <common:Name xml:lang="en">Volume</common:Name>
36
+ </structure:Code>
37
+ </structure:Codelist>
38
+ </structure:Codelists>
39
+
40
+ <structure:Constraints>
41
+ <structure:ContentConstraint>
42
+ <structure:CubeRegion>
43
+ <common:KeyValue id="PRICES">
44
+ <common:Value>Q</common:Value>
45
+ <common:Value>V</common:Value>
46
+ </common:KeyValue>
47
+ </structure:CubeRegion>
48
+ </structure:ContentConstraint>
49
+ </structure:Constraints>
50
+
51
+ <structure:DataStructures>
52
+ <structure:DataStructure>
53
+ <structure:DataStructureComponents>
54
+ <structure:DimensionList>
55
+ <structure:Dimension id="PRICES" position="1"/>
56
+ </structure:DimensionList>
57
+ </structure:DataStructureComponents>
58
+ </structure:DataStructure>
59
+ </structure:DataStructures>
60
+
61
+ </message:Structures>
62
+ </message:Structure>
63
+ """
64
+
65
+
66
+ class _Resp:
67
+ def __init__(self, content: bytes, status_code: int = 200):
68
+ self.content = content
69
+ self.status_code = status_code
70
+
71
+
72
+ def test_get_structure_builds_toc_values_and_explain(monkeypatch):
73
+ def fake_get(url, *args, **kwargs):
74
+ if "/public/rest/dataflow/" in url and "?references=all" in url:
75
+ return _Resp(_fake_structure_xml())
76
+ raise AssertionError(f"Unexpected URL in test_structure: {url}")
77
+
78
+ # Patch the requests used by notoecd.structure
79
+ monkeypatch.setattr(requests, "get", fake_get)
80
+
81
+ structure_mod = importlib.import_module("notoecd.structure")
82
+ importlib.reload(structure_mod)
83
+
84
+ # Clear cache so test is isolated
85
+ structure_mod.get_structure.cache_clear()
86
+
87
+ s = structure_mod.get_structure("OECD.CFE.EDS", "DSD_REG_ECO@DF_GDP")
88
+
89
+ assert isinstance(s.toc, pd.DataFrame)
90
+ assert list(s.toc["title"]) == ["PRICES"]
91
+ assert s.toc.loc[0, "values"] == ["Q", "V"]
92
+
93
+ assert isinstance(s.concepts, dict)
94
+ assert "CODELISTS" in s.concepts
95
+ assert "PRICES" in s.concepts
96
+
97
+ d = s.explain_vals("PRICES")
98
+ assert d == {"Q": "Quarterly", "V": "Volume"}