oda-data 2.7.0__tar.gz → 2.8.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.
Files changed (83) hide show
  1. {oda_data-2.7.0 → oda_data-2.8.0}/PKG-INFO +2 -3
  2. oda_data-2.8.0/pyproject.toml +226 -0
  3. oda_data-2.7.0/pyproject.toml → oda_data-2.8.0/pyproject.toml.orig +17 -3
  4. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/api/sources.py +106 -3
  5. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/cache/__init__.py +7 -3
  6. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/cache/api.py +122 -14
  7. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/cache/config.py +10 -2
  8. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/cache/types.py +24 -0
  9. oda_data-2.8.0/src/oda_data/clean_data/channel_share_proxies.csv +9 -0
  10. oda_data-2.8.0/src/oda_data/clean_data/channels.py +346 -0
  11. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/clean_data/common.py +8 -6
  12. oda_data-2.8.0/src/oda_data/clean_data/crs_channel_mapping.csv +447 -0
  13. oda_data-2.8.0/src/oda_data/clean_data/crs_channel_mapping_vintage.json +8 -0
  14. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/clean_data/dtypes.py +77 -2
  15. oda_data-2.8.0/src/oda_data/clean_data/multilateral_channel_crosswalk.csv +155 -0
  16. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/config.py +19 -1
  17. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/crs/crs_functions.py +15 -36
  18. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/crs/one.py +5 -6
  19. oda_data-2.8.0/src/oda_data/indicators/research/imputation_quality.py +202 -0
  20. oda_data-2.8.0/src/oda_data/indicators/research/imputation_shares.py +707 -0
  21. oda_data-2.8.0/src/oda_data/indicators/research/sector_imputations.py +748 -0
  22. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/provider_groupings.json +2 -1
  23. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/cache.py +105 -0
  24. oda_data-2.7.0/src/oda_data/clean_data/channels.py +0 -640
  25. oda_data-2.7.0/src/oda_data/clean_data/crs_channel_mapping.csv +0 -306
  26. oda_data-2.7.0/src/oda_data/indicators/research/sector_imputations.py +0 -376
  27. {oda_data-2.7.0 → oda_data-2.8.0}/README.md +0 -0
  28. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/__init__.py +0 -0
  29. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/api/__init__.py +0 -0
  30. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/api/constants.py +0 -0
  31. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/api/oecd.py +0 -0
  32. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/api/representations.py +0 -0
  33. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/cache/_migrate.py +0 -0
  34. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/clean_data/__init__.py +0 -0
  35. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/clean_data/schema.py +0 -0
  36. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/clean_data/validation.py +0 -0
  37. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/__init__.py +0 -0
  38. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/common.py +0 -0
  39. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/crs/__init__.py +0 -0
  40. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/crs/common.py +0 -0
  41. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/crs/crs_indicators.json +0 -0
  42. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/crs/oecd.py +0 -0
  43. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac1/__init__.py +0 -0
  44. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac1/common.py +0 -0
  45. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac1/dac1_functions.py +0 -0
  46. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac1/dac1_indicators.json +0 -0
  47. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac1/oecd.py +0 -0
  48. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac1/one.py +0 -0
  49. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac2a/__init__.py +0 -0
  50. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac2a/common.py +0 -0
  51. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac2a/dac2a_functions.py +0 -0
  52. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac2a/dac2a_indicators.json +0 -0
  53. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac2a/oecd.py +0 -0
  54. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/dac2a/one.py +0 -0
  55. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/indicator.py +0 -0
  56. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/research/__init__.py +0 -0
  57. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/research/eu.py +0 -0
  58. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/indicators/research/policy_markers.py +0 -0
  59. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/logger.py +0 -0
  60. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/oda_data_indicators.csv +0 -0
  61. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/crs_flow_types.json +0 -0
  62. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/crs_modalities.json +0 -0
  63. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/crs_policy_markers.json +0 -0
  64. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/crs_purpose.json +0 -0
  65. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/crs_type_of_finance.json +0 -0
  66. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/dac1_aid_flow_types.json +0 -0
  67. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/dac2a_aid_type.json +0 -0
  68. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/flow_types.json +0 -0
  69. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/indicators.json +0 -0
  70. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/key_columns.json +0 -0
  71. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/recipient_groupings.json +0 -0
  72. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/settings/v1_indicators_mapping.json +0 -0
  73. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/__init__.py +0 -0
  74. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/compatibility.py +0 -0
  75. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/gni.py +0 -0
  76. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/groupings.py +0 -0
  77. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/names/__init__.py +0 -0
  78. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/names/add.py +0 -0
  79. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/names/create_mapping.py +0 -0
  80. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/names/crs_names.json +0 -0
  81. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/names/dac1_names.json +0 -0
  82. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/names/dac2a_names.json +0 -0
  83. {oda_data-2.7.0 → oda_data-2.8.0}/src/oda_data/tools/sector_lists.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: oda_data
3
- Version: 2.7.0
3
+ Version: 2.8.0
4
4
  Summary: A python package to work with Official Development Assistance data from the OECD DAC.
5
5
  Author: Jorge Rivera, Miguel Haro Ruiz
6
6
  Author-email: Jorge Rivera <jorge.rivera@one.org>, Miguel Haro Ruiz <miguel.haroruiz@one.org>
@@ -18,12 +18,11 @@ Classifier: Topic :: Scientific/Engineering
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Dist: cachetools>=6.2.0
20
20
  Requires-Dist: filelock>=3.20.0
21
- Requires-Dist: oda-reader>=1.6.0
21
+ Requires-Dist: oda-reader>=1.9.0
22
22
  Requires-Dist: platformdirs>=4.0.0
23
23
  Requires-Dist: pandas[excel,parquet,performance]>=2.2.0
24
24
  Requires-Dist: pydeflate>=2.3.3
25
25
  Requires-Dist: reader>=3.18
26
- Requires-Dist: thefuzz>=0.22.1
27
26
  Requires-Python: >=3.11
28
27
  Project-URL: Homepage, https://github.com/ONEcampaign/oda_data_package
29
28
  Project-URL: Repository, https://github.com/ONEcampaign/oda_data_package
@@ -0,0 +1,226 @@
1
+ [project]
2
+ name = "oda_data"
3
+ version = "2.8.0"
4
+ description = "A python package to work with Official Development Assistance data from the OECD DAC."
5
+ requires-python = ">=3.11"
6
+ readme = "README.md"
7
+ classifiers = [
8
+ "Development Status :: 5 - Production/Stable",
9
+ "Intended Audience :: Developers",
10
+ "Intended Audience :: Science/Research",
11
+ "License :: OSI Approved :: MIT License",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Topic :: Scientific/Engineering",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+ dependencies = [
21
+ "cachetools>=6.2.0",
22
+ "filelock>=3.20.0",
23
+ "oda-reader>=1.9.0",
24
+ "platformdirs>=4.0.0",
25
+ "pandas[excel,parquet,performance]>=2.2.0",
26
+ "pydeflate>=2.3.3",
27
+ "reader>=3.18",
28
+ ]
29
+
30
+ [project.license]
31
+ text = "MIT"
32
+
33
+ [[project.authors]]
34
+ name = "Jorge Rivera"
35
+ email = "jorge.rivera@one.org"
36
+
37
+ [[project.authors]]
38
+ name = "Miguel Haro Ruiz"
39
+ email = "miguel.haroruiz@one.org"
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/ONEcampaign/oda_data_package"
43
+ Repository = "https://github.com/ONEcampaign/oda_data_package"
44
+ Documentation = "https://github.com/ONEcampaign/oda_data_package"
45
+
46
+ [build-system]
47
+ requires = ["uv_build>=0.11.8,<0.12"]
48
+ build-backend = "uv_build"
49
+
50
+ [dependency-groups]
51
+ dev = [
52
+ "pre-commit>=4.5.0",
53
+ "ruff>=0.14.9",
54
+ "ty>=0.0.33",
55
+ "mkdocs-material>=9.7.0",
56
+ ]
57
+ test = [
58
+ "pytest>=8.0.0",
59
+ "pytest-mock>=3.12.0",
60
+ "pytest-cov>=4.1.0",
61
+ "pytest-xdist>=3.5.0",
62
+ "freezegun>=1.4.0",
63
+ ]
64
+ maintenance = [
65
+ "resolvekit==0.1.12 ; python_version >= '3.12'",
66
+ "pyyaml>=6.0 ; python_version >= '3.12'",
67
+ ]
68
+
69
+ [tool.uv]
70
+ default-groups = [
71
+ "dev",
72
+ "test",
73
+ ]
74
+
75
+ [tool.ruff]
76
+ line-length = 88
77
+ target-version = "py311"
78
+ extend-include = ["*.ipynb"]
79
+ exclude = [
80
+ ".git",
81
+ ".venv",
82
+ "venv",
83
+ "__pycache__",
84
+ "build",
85
+ "dist",
86
+ "*.egg-info",
87
+ ".pytest_cache",
88
+ "htmlcov",
89
+ ]
90
+
91
+ [tool.ruff.lint]
92
+ select = [
93
+ "E",
94
+ "W",
95
+ "F",
96
+ "I",
97
+ "N",
98
+ "UP",
99
+ "B",
100
+ "C4",
101
+ "SIM",
102
+ "PL",
103
+ "RUF",
104
+ "ERA",
105
+ "PERF",
106
+ "ANN",
107
+ ]
108
+ ignore = [
109
+ "E501",
110
+ "PLR0913",
111
+ "PLR0912",
112
+ "PLR0914",
113
+ "PLR0915",
114
+ "PLR2004",
115
+ "PLC0415",
116
+ "ANN401",
117
+ ]
118
+ fixable = ["ALL"]
119
+ unfixable = []
120
+
121
+ [tool.ruff.lint.per-file-ignores]
122
+ "tests/**/*.py" = [
123
+ "ANN",
124
+ "E501",
125
+ "PLR2004",
126
+ "RUF002",
127
+ "RUF003",
128
+ "PLW0603",
129
+ "ERA001",
130
+ ]
131
+ "src/oda_data/__init__.py" = [
132
+ "PLW0603",
133
+ "PLW0602",
134
+ "F811",
135
+ ]
136
+ "src/oda_data/cache/config.py" = ["PLW0603"]
137
+ "src/oda_data/cache/api.py" = ["PLW0603"]
138
+ "src/oda_data/tools/cache.py" = ["PLW2901"]
139
+ "src/oda_data/api/oecd.py" = ["PLR0912"]
140
+ "src/oda_data/indicators/crs/crs_functions.py" = ["B023"]
141
+ "src/oda_data/indicators/research/sector_imputations.py" = ["B023"]
142
+ "src/oda_data/tools/sector_lists.py" = ["N816"]
143
+
144
+ [tool.ruff.lint.pydocstyle]
145
+ convention = "google"
146
+
147
+ [tool.ruff.format]
148
+ quote-style = "double"
149
+ indent-style = "space"
150
+ skip-magic-trailing-comma = false
151
+ line-ending = "auto"
152
+
153
+ [tool.ty.environment]
154
+ python-version = "3.11"
155
+
156
+ [tool.ty.src]
157
+ include = ["src/oda_data"]
158
+
159
+ [tool.ty.analysis]
160
+ replace-imports-with-any = [
161
+ "pandas",
162
+ "pandas.**",
163
+ "numpy",
164
+ "numpy.**",
165
+ ]
166
+
167
+ [tool.ty.rules]
168
+ possibly-missing-attribute = "ignore"
169
+ possibly-unresolved-reference = "ignore"
170
+
171
+ [tool.pytest.ini_options]
172
+ testpaths = ["tests"]
173
+ python_files = ["test_*.py"]
174
+ python_classes = ["Test*"]
175
+ python_functions = ["test_*"]
176
+ addopts = """
177
+ -v
178
+ --strict-markers
179
+ --tb=short
180
+ --cov=src/oda_data
181
+ --cov-report=term-missing
182
+ --cov-report=html
183
+ --cov-branch
184
+ -m "not integration and not network"
185
+ """
186
+ markers = [
187
+ """slow: marks tests as slow (deselect with '-m "not slow"')""",
188
+ "integration: marks tests that call real OECD API (deselected by default)",
189
+ "network: marks tests requiring live network access (opt-in via RUN_NETWORK_TESTS=1; deselected by default)",
190
+ "unit: marks tests as unit tests",
191
+ "cache: marks tests related to caching functionality",
192
+ "validation: marks tests related to input validation",
193
+ "cleaning: marks tests related to data cleaning",
194
+ ]
195
+ norecursedirs = [
196
+ ".git",
197
+ ".venv",
198
+ "venv",
199
+ "__pycache__",
200
+ "*.egg-info",
201
+ ".pytest_cache",
202
+ "htmlcov",
203
+ ]
204
+
205
+ [tool.coverage.run]
206
+ source = ["src/oda_data"]
207
+ branch = true
208
+ omit = [
209
+ "*/tests/*",
210
+ "*/__init__.py",
211
+ "*/config.py",
212
+ "*/logger.py",
213
+ ]
214
+
215
+ [tool.coverage.report]
216
+ precision = 2
217
+ show_missing = true
218
+ exclude_lines = [
219
+ "pragma: no cover",
220
+ "def __repr__",
221
+ "raise AssertionError",
222
+ "raise NotImplementedError",
223
+ "if __name__ == .__main__.:",
224
+ "if TYPE_CHECKING:",
225
+ "@abstractmethod",
226
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oda_data"
3
- version = "2.7.0"
3
+ version = "2.8.0"
4
4
  description = "A python package to work with Official Development Assistance data from the OECD DAC."
5
5
  requires-python = ">=3.11"
6
6
 
@@ -28,12 +28,11 @@ classifiers = [
28
28
  dependencies = [
29
29
  "cachetools>=6.2.0",
30
30
  "filelock>=3.20.0",
31
- "oda-reader>=1.6.0",
31
+ "oda-reader>=1.9.0",
32
32
  "platformdirs>=4.0.0",
33
33
  "pandas[excel,parquet,performance]>=2.2.0",
34
34
  "pydeflate>=2.3.3",
35
35
  "reader>=3.18",
36
- "thefuzz>=0.22.1",
37
36
  ]
38
37
 
39
38
  [project.urls]
@@ -59,6 +58,21 @@ test = [
59
58
  "pytest-xdist>=3.5.0",
60
59
  "freezegun>=1.4.0",
61
60
  ]
61
+ # Maintenance-only tooling for scripts/refresh_channel_crosswalk.py. Never a
62
+ # runtime dependency of the oda_data package; excluded from the default
63
+ # groups below so a plain `uv sync` never requires it (resolvekit needs
64
+ # Python >=3.12, oda_data supports >=3.11). Install explicitly with
65
+ # `uv sync --group maintenance`.
66
+ maintenance = [
67
+ "resolvekit==0.1.12 ; python_version >= '3.12'",
68
+ "pyyaml>=6.0 ; python_version >= '3.12'",
69
+ ]
70
+
71
+ [tool.uv]
72
+ # Preserve today's implicit default (dev + test both installed by a plain
73
+ # `uv sync` / `uv run`); without this, adding the "maintenance" group above
74
+ # would otherwise be pulled into that same default set.
75
+ default-groups = ["dev", "test"]
62
76
 
63
77
  [tool.ruff]
64
78
  # Set the maximum line length
@@ -308,6 +308,17 @@ class DACSource(Source):
308
308
  )
309
309
  self.de_sectors = check_strings(sectors)
310
310
 
311
+ def _extra_hash_components(self) -> list[tuple]:
312
+ """Extra (name, predicate, value) tuples folded into the cache-key hash.
313
+
314
+ They bypass the read-filter path, never reaching a parquet predicate
315
+ or a query string. Folding them into the hash lets a constructor
316
+ flag that changes row-level behaviour, without being expressed as a
317
+ filter tuple, still produce a different cache key. Subclasses
318
+ override this; the default is a no-op.
319
+ """
320
+ return []
321
+
311
322
  def _get_filtered_download_filters(self) -> dict:
312
323
  """Generates a dictionary of filters for downloading the dataset.
313
324
 
@@ -358,6 +369,15 @@ class DACSource(Source):
358
369
  except Exception:
359
370
  return "unknown"
360
371
 
372
+ def _get_release_id(self) -> str | None:
373
+ """Best-effort upstream release identity for the bulk cache manifest.
374
+
375
+ Overridden by subclasses that go through the OECD bulk file service
376
+ (CRSData, MultiSystemData) with a cheap, non-download probe. The
377
+ default is None, which falls back to version/TTL-only staleness.
378
+ """
379
+ return None
380
+
361
381
  def _fetch_from_bulk_cache(
362
382
  self,
363
383
  filters: list[tuple] | None,
@@ -382,6 +402,7 @@ class DACSource(Source):
382
402
  fetcher=self._create_bulk_fetcher(),
383
403
  ttl_days=30,
384
404
  version=self._get_package_version(),
405
+ release_id=self._get_release_id(),
385
406
  )
386
407
 
387
408
  # This blocks if another thread is downloading (prevents concurrent waste)
@@ -438,12 +459,24 @@ class DACSource(Source):
438
459
  """
439
460
  # Create filters and generate cache key
440
461
  filters = self._get_read_filters(additional_filters=additional_filters)
441
- param_hash = generate_param_hash(filters if filters else [])
462
+ param_hash = generate_param_hash(
463
+ (filters if filters else []) + self._extra_hash_components()
464
+ )
442
465
 
443
466
  # When refresh=True, skip the upper cache tiers entirely so the bulk
444
467
  # fetcher's refresh=True actually re-downloads and the upper tiers see
445
468
  # the fresh data. Without this guard a warm memory/query cache would
446
- # short-circuit the refresh, returning stale data.
469
+ # short-circuit the refresh, returning stale data. Also evict the
470
+ # memory-tier entries this call is about to overwrite up front,
471
+ # rather than relying solely on the post-fetch write — this closes
472
+ # the window in which a concurrent reader could still observe the
473
+ # stale value while the refresh is in flight (#162).
474
+ if refresh:
475
+ self.memory_cache.pop(param_hash, None)
476
+ if columns:
477
+ self.memory_cache.pop(
478
+ generate_projection_hash(param_hash, columns), None
479
+ )
447
480
  if not refresh:
448
481
  # 1. Try memory cache (thread-safe, fastest)
449
482
  _cached = self.memory_cache.get(param_hash)
@@ -640,6 +673,7 @@ class CRSData(DACSource):
640
673
  years: list[int] | range | int | None = None,
641
674
  providers: list[int] | int | None = None,
642
675
  recipients: list[int] | int | None = None,
676
+ exclude_multilateral_core: bool = True,
643
677
  ) -> None:
644
678
  """
645
679
  Initialize CRSData.
@@ -648,9 +682,17 @@ class CRSData(DACSource):
648
682
  years (list[int] | range): List or range of years to filter.
649
683
  providers (Optional[list[int]]): List of provider codes.
650
684
  recipients (Optional[list[int]]): List of recipient codes.
685
+ exclude_multilateral_core (bool): Whether to exclude CRS rows that report
686
+ a donor's core (unearmarked) contribution to a multilateral
687
+ organisation (``bi_multi == 2``). The OECD's CRS changelog states
688
+ these rows must be excluded to avoid double-counting bilateral
689
+ flows. Defaults to True. Rows with a missing ``bi_multi`` value
690
+ are always kept. Pass False to get the raw CRS totals including
691
+ those core-contribution rows.
651
692
  """
652
693
  super().__init__()
653
694
  self._init_filters(years=years, providers=providers, recipients=recipients)
695
+ self.exclude_multilateral_core = exclude_multilateral_core
654
696
  self._param_hash = None
655
697
 
656
698
  def _create_bulk_fetcher(self) -> Callable[[Path], None]:
@@ -660,6 +702,61 @@ class CRSData(DACSource):
660
702
  """
661
703
  return create_crs_bulk_fetcher()
662
704
 
705
+ def _extra_hash_components(self) -> list[tuple]:
706
+ return [("exclude_multilateral_core", "==", self.exclude_multilateral_core)]
707
+
708
+ def _get_release_id(self) -> str | None:
709
+ """Cheap upstream release id for the CRS bulk file (#162)."""
710
+ from oda_data.tools.cache import get_crs_release_id
711
+
712
+ return get_crs_release_id()
713
+
714
+ def _exclude_core_contributions(self, df: pd.DataFrame) -> pd.DataFrame:
715
+ """Drop rows reporting a donor's core contribution to a multilateral organisation.
716
+
717
+ No-op when ``exclude_multilateral_core`` is False, or when the ``bi_multi``
718
+ column isn't present (e.g. it was excluded from a column projection).
719
+ Rows with a missing ``bi_multi`` value are kept. A plain ``!= 2`` filter
720
+ would drop them, since a null comparison is null rather than true.
721
+ """
722
+ if not self.exclude_multilateral_core:
723
+ return df
724
+ column = self.schema.BI_MULTI
725
+ if column not in df.columns:
726
+ return df
727
+ return df.loc[(df[column] != 2) | df[column].isna()].reset_index(drop=True)
728
+
729
+ def _fetch_from_bulk_cache(
730
+ self,
731
+ filters: list[tuple] | None,
732
+ columns: list | None,
733
+ *,
734
+ refresh: bool = False,
735
+ ) -> pd.DataFrame:
736
+ """Read from the bulk cache, then apply the core-contribution exclusion.
737
+
738
+ ``bi_multi`` is read even when not in the requested ``columns``, so the
739
+ exclusion can be applied before column projection, then dropped again if
740
+ the caller didn't ask for it.
741
+ """
742
+ read_columns = columns
743
+ added_bi_multi = False
744
+ if (
745
+ self.exclude_multilateral_core
746
+ and columns is not None
747
+ and self.schema.BI_MULTI not in columns
748
+ ):
749
+ read_columns = [*columns, self.schema.BI_MULTI]
750
+ added_bi_multi = True
751
+
752
+ df = super()._fetch_from_bulk_cache(filters, read_columns, refresh=refresh)
753
+ df = self._exclude_core_contributions(df)
754
+
755
+ if added_bi_multi:
756
+ df = df.drop(columns=[self.schema.BI_MULTI])
757
+
758
+ return df
759
+
663
760
  def download(self) -> pd.DataFrame:
664
761
  """Downloads CRS data via API (filtered query).
665
762
 
@@ -672,7 +769,7 @@ class CRSData(DACSource):
672
769
  filters=self._get_filtered_download_filters(),
673
770
  )
674
771
  logger.info("CRS data downloaded successfully.")
675
- return clean_raw_df(df)
772
+ return self._exclude_core_contributions(clean_raw_df(df))
676
773
 
677
774
 
678
775
  class MultiSystemData(DACSource):
@@ -722,6 +819,12 @@ class MultiSystemData(DACSource):
722
819
  """
723
820
  return create_multisystem_bulk_fetcher()
724
821
 
822
+ def _get_release_id(self) -> str | None:
823
+ """Cheap upstream release id for the MultiSystem bulk file (#162)."""
824
+ from oda_data.tools.cache import get_multisystem_release_id
825
+
826
+ return get_multisystem_release_id()
827
+
725
828
  def download(self) -> pd.DataFrame:
726
829
  """Downloads MultiSystem data via API (filtered query).
727
830
 
@@ -2,11 +2,12 @@
2
2
 
3
3
  Public symbols re-exported from sub-modules:
4
4
 
5
- - ``path``, ``entries``, ``clear``, ``size``, ``invalidate``,
5
+ - ``path``, ``entries``, ``clear``, ``size``, ``invalidate``, ``release_info``,
6
6
  ``enable_cache``, ``disable_cache`` — from :mod:`oda_data.cache.api`
7
7
  - ``migrate`` — from :mod:`oda_data.cache._migrate`
8
8
  - ``set_cache_root`` — from :mod:`oda_data.cache.config`
9
- - ``CacheRecord``, ``MigrationResult``, ``Scope`` — from :mod:`oda_data.cache.types`
9
+ - ``CacheRecord``, ``MigrationResult``, ``ReleaseInfo``, ``Scope`` — from
10
+ :mod:`oda_data.cache.types`
10
11
  """
11
12
 
12
13
  from oda_data.cache._migrate import migrate
@@ -17,14 +18,16 @@ from oda_data.cache.api import (
17
18
  entries,
18
19
  invalidate,
19
20
  path,
21
+ release_info,
20
22
  size,
21
23
  )
22
24
  from oda_data.cache.config import set_cache_root
23
- from oda_data.cache.types import CacheRecord, MigrationResult, Scope
25
+ from oda_data.cache.types import CacheRecord, MigrationResult, ReleaseInfo, Scope
24
26
 
25
27
  __all__ = [
26
28
  "CacheRecord",
27
29
  "MigrationResult",
30
+ "ReleaseInfo",
28
31
  "Scope",
29
32
  "clear",
30
33
  "disable_cache",
@@ -33,6 +36,7 @@ __all__ = [
33
36
  "invalidate",
34
37
  "migrate",
35
38
  "path",
39
+ "release_info",
36
40
  "set_cache_root",
37
41
  "size",
38
42
  ]