oda-data 2.4.2__tar.gz → 2.6.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 (76) hide show
  1. {oda_data-2.4.2 → oda_data-2.6.0}/PKG-INFO +20 -11
  2. {oda_data-2.4.2 → oda_data-2.6.0}/README.md +17 -9
  3. {oda_data-2.4.2 → oda_data-2.6.0}/pyproject.toml +12 -3
  4. oda_data-2.6.0/src/oda_data/__init__.py +113 -0
  5. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/api/sources.py +105 -87
  6. oda_data-2.6.0/src/oda_data/cache/__init__.py +38 -0
  7. oda_data-2.6.0/src/oda_data/cache/_migrate.py +388 -0
  8. oda_data-2.6.0/src/oda_data/cache/api.py +513 -0
  9. oda_data-2.6.0/src/oda_data/cache/config.py +114 -0
  10. oda_data-2.6.0/src/oda_data/cache/types.py +70 -0
  11. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/clean_data/common.py +1 -1
  12. oda_data-2.6.0/src/oda_data/config.py +34 -0
  13. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/provider_groupings.json +2 -1
  14. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/cache.py +232 -68
  15. oda_data-2.4.2/src/oda_data/__init__.py +0 -59
  16. oda_data-2.4.2/src/oda_data/config.py +0 -17
  17. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/api/__init__.py +0 -0
  18. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/api/constants.py +0 -0
  19. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/api/oecd.py +0 -0
  20. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/api/representations.py +0 -0
  21. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/clean_data/__init__.py +0 -0
  22. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/clean_data/channels.py +0 -0
  23. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/clean_data/crs_channel_mapping.csv +0 -0
  24. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/clean_data/dtypes.py +0 -0
  25. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/clean_data/schema.py +0 -0
  26. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/clean_data/validation.py +0 -0
  27. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/__init__.py +0 -0
  28. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/common.py +0 -0
  29. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/crs/__init__.py +0 -0
  30. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/crs/common.py +0 -0
  31. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/crs/crs_functions.py +0 -0
  32. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/crs/crs_indicators.json +0 -0
  33. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/crs/oecd.py +0 -0
  34. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/crs/one.py +0 -0
  35. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac1/__init__.py +0 -0
  36. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac1/common.py +0 -0
  37. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac1/dac1_functions.py +0 -0
  38. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac1/dac1_indicators.json +0 -0
  39. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac1/oecd.py +0 -0
  40. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac1/one.py +0 -0
  41. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac2a/__init__.py +0 -0
  42. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac2a/common.py +0 -0
  43. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac2a/dac2a_functions.py +0 -0
  44. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac2a/dac2a_indicators.json +0 -0
  45. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac2a/oecd.py +0 -0
  46. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/dac2a/one.py +0 -0
  47. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/indicator.py +0 -0
  48. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/research/__init__.py +0 -0
  49. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/research/eu.py +0 -0
  50. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/research/policy_markers.py +0 -0
  51. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/indicators/research/sector_imputations.py +0 -0
  52. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/logger.py +0 -0
  53. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/oda_data_indicators.csv +0 -0
  54. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/crs_flow_types.json +0 -0
  55. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/crs_modalities.json +0 -0
  56. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/crs_policy_markers.json +0 -0
  57. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/crs_purpose.json +0 -0
  58. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/crs_type_of_finance.json +0 -0
  59. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/dac1_aid_flow_types.json +0 -0
  60. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/dac2a_aid_type.json +0 -0
  61. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/flow_types.json +0 -0
  62. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/indicators.json +0 -0
  63. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/key_columns.json +0 -0
  64. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/recipient_groupings.json +0 -0
  65. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/settings/v1_indicators_mapping.json +0 -0
  66. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/__init__.py +0 -0
  67. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/compatibility.py +0 -0
  68. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/gni.py +0 -0
  69. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/groupings.py +0 -0
  70. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/names/__init__.py +0 -0
  71. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/names/add.py +0 -0
  72. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/names/create_mapping.py +0 -0
  73. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/names/crs_names.json +0 -0
  74. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/names/dac1_names.json +0 -0
  75. {oda_data-2.4.2 → oda_data-2.6.0}/src/oda_data/tools/names/dac2a_names.json +0 -0
  76. {oda_data-2.4.2 → oda_data-2.6.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.4.2
3
+ Version: 2.6.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,7 +18,8 @@ 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.4.1
21
+ Requires-Dist: oda-reader>=1.6.0
22
+ Requires-Dist: platformdirs>=4.0.0
22
23
  Requires-Dist: pandas[excel,parquet,performance]>=2.2.0
23
24
  Requires-Dist: pydeflate>=2.3.3
24
25
  Requires-Dist: reader>=3.18
@@ -448,21 +449,29 @@ dac_members = providers["dac_members"]
448
449
 
449
450
  ### Cache Management
450
451
 
451
- Control the package's caching behavior:
452
+ `oda_data` stores downloaded data in a per-OS, version-segmented directory
453
+ (`~/Library/Caches/oda-data/<version>/` on macOS, `~/.cache/oda-data/<version>/`
454
+ on Linux, and the equivalent `AppData\Local` path on Windows). The location can be
455
+ overridden with `oda_data.set_cache_root(path)` or the `ODA_DATA_CACHE_DIR`
456
+ environment variable. See [CACHING.md](CACHING.md) for full details on disk
457
+ locations, scopes, multi-process behavior, and migration from older versions.
452
458
 
453
- ```python
454
- from oda_data import clear_cache, disable_cache, enable_cache
459
+ The fastest recovery path for a corrupt or stale cache is:
455
460
 
456
- # Clear all cached data
457
- clear_cache()
461
+ ```python
462
+ from oda_data import cache
458
463
 
459
- # Temporarily disable caching (for development/testing)
460
- disable_cache()
464
+ # Clear only the raw zip archives and let them re-download on next read:
465
+ cache.clear("raw")
461
466
 
462
- # Re-enable caching
463
- enable_cache()
467
+ # Or wipe everything:
468
+ cache.clear("all")
464
469
  ```
465
470
 
471
+ The legacy helpers `clear_cache()`, `disable_cache()`, and `enable_cache()` continue
472
+ to work and are thin wrappers over `cache.clear("all")`, `cache.disable_cache("all")`,
473
+ and `cache.enable_cache("all")` respectively.
474
+
466
475
  ### Compatibility with Version 1.x
467
476
 
468
477
  For users migrating from v1.x, a compatibility layer is available:
@@ -417,21 +417,29 @@ dac_members = providers["dac_members"]
417
417
 
418
418
  ### Cache Management
419
419
 
420
- Control the package's caching behavior:
420
+ `oda_data` stores downloaded data in a per-OS, version-segmented directory
421
+ (`~/Library/Caches/oda-data/<version>/` on macOS, `~/.cache/oda-data/<version>/`
422
+ on Linux, and the equivalent `AppData\Local` path on Windows). The location can be
423
+ overridden with `oda_data.set_cache_root(path)` or the `ODA_DATA_CACHE_DIR`
424
+ environment variable. See [CACHING.md](CACHING.md) for full details on disk
425
+ locations, scopes, multi-process behavior, and migration from older versions.
421
426
 
422
- ```python
423
- from oda_data import clear_cache, disable_cache, enable_cache
427
+ The fastest recovery path for a corrupt or stale cache is:
424
428
 
425
- # Clear all cached data
426
- clear_cache()
429
+ ```python
430
+ from oda_data import cache
427
431
 
428
- # Temporarily disable caching (for development/testing)
429
- disable_cache()
432
+ # Clear only the raw zip archives and let them re-download on next read:
433
+ cache.clear("raw")
430
434
 
431
- # Re-enable caching
432
- enable_cache()
435
+ # Or wipe everything:
436
+ cache.clear("all")
433
437
  ```
434
438
 
439
+ The legacy helpers `clear_cache()`, `disable_cache()`, and `enable_cache()` continue
440
+ to work and are thin wrappers over `cache.clear("all")`, `cache.disable_cache("all")`,
441
+ and `cache.enable_cache("all")` respectively.
442
+
435
443
  ### Compatibility with Version 1.x
436
444
 
437
445
  For users migrating from v1.x, a compatibility layer is available:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oda_data"
3
- version = "2.4.2"
3
+ version = "2.6.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,7 +28,8 @@ classifiers = [
28
28
  dependencies = [
29
29
  "cachetools>=6.2.0",
30
30
  "filelock>=3.20.0",
31
- "oda-reader>=1.4.1",
31
+ "oda-reader>=1.6.0",
32
+ "platformdirs>=4.0.0",
32
33
  "pandas[excel,parquet,performance]>=2.2.0",
33
34
  "pydeflate>=2.3.3",
34
35
  "reader>=3.18",
@@ -109,11 +110,16 @@ unfixable = []
109
110
 
110
111
  [tool.ruff.lint.per-file-ignores]
111
112
  # Allow test files to have longer lines and more assertions
112
- "tests/**/*.py" = ["E501", "PLR2004", "RUF002", "RUF003"] # Allow × in comments/docstrings
113
+ "tests/**/*.py" = ["E501", "PLR2004", "RUF002", "RUF003", "PLW0603"] # Allow × in comments/docstrings; PLW0603 for spawn-pool worker globals
113
114
  # Allow global variable usage in __init__.py (architectural decision)
114
115
  "src/oda_data/__init__.py" = ["PLW0603", "PLW0602", "F811"]
116
+ # Lazy module-level state (override flag, chained-root cache, auto-migrate gate, 5GB warning latch).
117
+ "src/oda_data/cache/config.py" = ["PLW0603"]
118
+ "src/oda_data/cache/api.py" = ["PLW0603"]
115
119
  # Allow tmpdir reassignment pattern (Path conversion)
116
120
  "src/oda_data/tools/cache.py" = ["PLW2901"]
121
+ # Existing complexity in OECDClient._apply_filters — out of scope for refactor here.
122
+ "src/oda_data/api/oecd.py" = ["PLR0912"]
117
123
  # Allow loop variable binding patterns (intentional for lambda closures)
118
124
  "src/oda_data/indicators/crs/crs_functions.py" = ["B023"]
119
125
  "src/oda_data/indicators/research/sector_imputations.py" = ["B023"]
@@ -132,3 +138,6 @@ skip-magic-trailing-comma = false
132
138
 
133
139
  # Like Black, automatically detect line endings
134
140
  line-ending = "auto"
141
+
142
+ # Note: pytest configuration lives in `pytest.ini` (which takes precedence over
143
+ # this file). Markers and addopts must be added there, not here.
@@ -0,0 +1,113 @@
1
+ import warnings
2
+
3
+ from oda_data import cache
4
+ from oda_data.api.oecd import OECDClient
5
+ from oda_data.api.sources import (
6
+ AidDataData,
7
+ CRSData,
8
+ DAC1Data,
9
+ DAC2AData,
10
+ MultiSystemData,
11
+ )
12
+ from oda_data.cache.config import set_cache_root
13
+ from oda_data.indicators.research import sector_imputations
14
+ from oda_data.indicators.research.policy_markers import bilateral_policy_marker
15
+ from oda_data.tools.compatibility import ODAData
16
+ from oda_data.tools.gni import add_gni_share_column
17
+ from oda_data.tools.groupings import provider_groupings, recipient_groupings
18
+ from oda_data.tools.names.add import add_names_columns
19
+ from oda_data.tools.sector_lists import add_broad_sectors, add_sectors
20
+
21
+ _DEPRECATION_WARNED_SET_DATA_PATH = False
22
+
23
+
24
+ def clear_cache() -> None:
25
+ """Back-compat alias for oda_data.cache.clear('all').
26
+
27
+ Clears all scopes (bulk, query, http, raw). Prefer ``oda_data.cache.clear()``.
28
+ """
29
+ from oda_data.cache import clear
30
+
31
+ clear("all")
32
+
33
+
34
+ def disable_cache() -> None:
35
+ """Back-compat alias for oda_data.cache.disable_cache('all').
36
+
37
+ Disables all cache scopes. Prefer ``oda_data.cache.disable_cache()``.
38
+ """
39
+ from oda_data.cache import disable_cache as _disable
40
+
41
+ _disable("all")
42
+
43
+
44
+ def enable_cache() -> None:
45
+ """Back-compat alias for oda_data.cache.enable_cache('all').
46
+
47
+ Enables all cache scopes. Prefer ``oda_data.cache.enable_cache()``.
48
+ """
49
+ from oda_data.cache import enable_cache as _enable
50
+
51
+ _enable("all")
52
+
53
+
54
+ def set_data_path(path) -> None:
55
+ """Set the path for data outputs (parquet exports).
56
+
57
+ Deprecated: cache location is now controlled via set_cache_root() or
58
+ the ODA_DATA_CACHE_DIR env var. A one-time DeprecationWarning is emitted
59
+ on first call. Shim preserved through 2.x, removed in 3.0.
60
+ """
61
+ global _DEPRECATION_WARNED_SET_DATA_PATH
62
+
63
+ from pathlib import Path
64
+
65
+ from pydeflate import set_pydeflate_path
66
+
67
+ from oda_data.config import ODAPaths
68
+
69
+ if not _DEPRECATION_WARNED_SET_DATA_PATH:
70
+ warnings.warn(
71
+ "oda_data.set_data_path() now governs only data outputs (parquet exports),"
72
+ " not the cache. If you previously called set_data_path() to control where"
73
+ " downloads are cached, also call"
74
+ " oda_data.set_cache_root('<your_path>') or set the ODA_DATA_CACHE_DIR env"
75
+ " var. The set_data_path() shim itself is preserved through oda_data 2.x"
76
+ " and removed in 3.0.",
77
+ DeprecationWarning,
78
+ stacklevel=2,
79
+ )
80
+ _DEPRECATION_WARNED_SET_DATA_PATH = True
81
+
82
+ ODAPaths.raw_data = Path(path).resolve()
83
+ # Create on explicit user request; avoids import-time side effects.
84
+ ODAPaths.raw_data.mkdir(parents=True, exist_ok=True)
85
+ ODAPaths.pydeflate = ODAPaths.raw_data / ".pydeflate"
86
+ set_pydeflate_path(ODAPaths.raw_data)
87
+ # Do NOT call set_cache_dir here — cache chaining is handled lazily by
88
+ # oda_data_cache_root() inside cache/config.py.
89
+
90
+
91
+ __all__ = [
92
+ "AidDataData",
93
+ "CRSData",
94
+ "DAC1Data",
95
+ "DAC2AData",
96
+ "MultiSystemData",
97
+ "ODAData",
98
+ "OECDClient",
99
+ "add_broad_sectors",
100
+ "add_gni_share_column",
101
+ "add_names_columns",
102
+ "add_sectors",
103
+ "bilateral_policy_marker",
104
+ "cache",
105
+ "clear_cache",
106
+ "disable_cache",
107
+ "enable_cache",
108
+ "provider_groupings",
109
+ "recipient_groupings",
110
+ "sector_imputations",
111
+ "set_cache_root",
112
+ "set_data_path",
113
+ ]
@@ -123,41 +123,37 @@ class Source:
123
123
 
124
124
  @property
125
125
  def bulk_cache(self) -> BulkCacheManager:
126
- """Get bulk cache manager singleton.
127
-
128
- Creates one bulk cache per process, recreating if data directory changes.
129
- """
130
- current_raw = Path(ODAPaths.raw_data).resolve()
131
- # Fast-path without lock
132
- bc = Source._shared_bulk_cache
133
- if bc is not None and bc.base_dir.parent.resolve() == current_raw:
134
- return bc
135
-
136
- # Slow-path with lock
137
- with Source._cache_lock:
138
- bc = Source._shared_bulk_cache
139
- if bc is None or bc.base_dir.parent.resolve() != current_raw:
140
- Source._shared_bulk_cache = BulkCacheManager(current_raw)
141
- return Source._shared_bulk_cache
126
+ """Get bulk cache manager singleton, recreating if the cache root changed."""
127
+ return self._shared_manager(BulkCacheManager, "_shared_bulk_cache")
142
128
 
143
129
  @property
144
130
  def query_cache(self) -> QueryCacheManager:
145
- """Get query cache manager singleton.
131
+ """Get query cache manager singleton, recreating if the cache root changed."""
132
+ return self._shared_manager(QueryCacheManager, "_shared_query_cache")
133
+
134
+ @classmethod
135
+ def _shared_manager(cls, manager_cls, slot: str):
136
+ """Return a process-wide manager rooted at ``cache_root / "oda-data"``.
146
137
 
147
- Creates one query cache per process, recreating if data directory changes.
138
+ The singleton is intentionally anchored on ``Source`` (not on the
139
+ runtime subclass) so that every subclass shares the same instance.
140
+
141
+ The unresolved equality check on the fast path avoids a ``realpath``
142
+ syscall on every property access; we only ``.resolve()`` when the
143
+ configured root visibly changed.
148
144
  """
149
- current_raw = Path(ODAPaths.raw_data).resolve()
150
- # Fast-path without lock
151
- qc = Source._shared_query_cache
152
- if qc is not None and qc.base_dir.parent.resolve() == current_raw:
153
- return qc
154
-
155
- # Slow-path with lock
156
- with Source._cache_lock:
157
- qc = Source._shared_query_cache
158
- if qc is None or qc.base_dir.parent.resolve() != current_raw:
159
- Source._shared_query_cache = QueryCacheManager(current_raw)
160
- return Source._shared_query_cache
145
+ current = Path(ODAPaths.cache_root) / "oda-data"
146
+ existing = getattr(Source, slot)
147
+ if existing is not None and existing.base_dir.parent == current:
148
+ return existing
149
+ with cls._cache_lock:
150
+ existing = getattr(Source, slot)
151
+ if (
152
+ existing is None
153
+ or existing.base_dir.parent.resolve() != current.resolve()
154
+ ):
155
+ setattr(Source, slot, manager_cls(current))
156
+ return getattr(Source, slot)
161
157
 
162
158
  def _add_filter(self, column: str, predicate: str, value: str | int | list) -> None:
163
159
  """Adds a filter to the dataset, ensuring no duplicate columns.
@@ -348,14 +344,23 @@ class DACSource(Source):
348
344
  return "unknown"
349
345
 
350
346
  def _fetch_from_bulk_cache(
351
- self, filters: list[tuple] | None, columns: list | None
347
+ self,
348
+ filters: list[tuple] | None,
349
+ columns: list | None,
350
+ *,
351
+ refresh: bool = False,
352
352
  ) -> pd.DataFrame:
353
353
  """Fetch data from bulk cache with download coordination.
354
354
 
355
- This method uses FileLock via BulkCacheManager - if another thread is
355
+ This method uses FileLock via BulkCacheManager — if another thread is
356
356
  downloading, this call blocks until download completes.
357
357
 
358
358
  Uses PyArrow predicate pushdown and column projection for maximum efficiency.
359
+
360
+ Args:
361
+ filters: Optional parquet predicate filters.
362
+ columns: Optional column subset.
363
+ refresh: If True, bypass the bulk cache and re-download.
359
364
  """
360
365
  entry = BulkCacheEntry(
361
366
  key=f"{self.__class__.__name__}_bulk",
@@ -365,7 +370,7 @@ class DACSource(Source):
365
370
  )
366
371
 
367
372
  # This blocks if another thread is downloading (prevents concurrent waste)
368
- bulk_path = self.bulk_cache.ensure(entry, refresh=False)
373
+ bulk_path = self.bulk_cache.ensure(entry, refresh=refresh)
369
374
 
370
375
  pyarrow_filters = filters if filters else None
371
376
 
@@ -396,6 +401,8 @@ class DACSource(Source):
396
401
  using_bulk_download: bool = False,
397
402
  additional_filters: list[tuple] | None = None,
398
403
  columns: list | None = None,
404
+ *,
405
+ refresh: bool = False,
399
406
  ):
400
407
  """Reads a dataset with 3-tier cache coordination.
401
408
 
@@ -408,6 +415,8 @@ class DACSource(Source):
408
415
  using_bulk_download: Whether to read from bulk cache
409
416
  additional_filters: Additional filters to apply
410
417
  columns: Subset of columns to read
418
+ refresh: If True, bypass the bulk cache and re-download. Only has
419
+ effect when ``using_bulk_download=True``.
411
420
 
412
421
  Returns:
413
422
  Filtered and cleaned DataFrame
@@ -416,45 +425,49 @@ class DACSource(Source):
416
425
  filters = self._get_read_filters(additional_filters=additional_filters)
417
426
  param_hash = generate_param_hash(filters if filters else [])
418
427
 
419
- # 1. Try memory cache (thread-safe, fastest)
420
- df = self.memory_cache.get(param_hash)
421
- if df is not None:
428
+ # When refresh=True, skip the upper cache tiers entirely so the bulk
429
+ # fetcher's refresh=True actually re-downloads and the upper tiers see
430
+ # the fresh data. Without this guard a warm memory/query cache would
431
+ # short-circuit the refresh, returning stale data.
432
+ if not refresh:
433
+ # 1. Try memory cache (thread-safe, fastest)
434
+ df = self.memory_cache.get(param_hash)
422
435
  # Only use if all requested columns are available
423
- if not columns or all(c in df.columns for c in columns):
436
+ if df is not None and (
437
+ not columns or all(c in df.columns for c in columns)
438
+ ):
424
439
  logger.info("Cache hit: memory")
425
- return self._apply_columns_and_clean(
426
- df, columns, already_cleaned=True
427
- )
440
+ return self._apply_columns_and_clean(df, columns, already_cleaned=True)
428
441
 
429
- # For projected reads, also check a projection-specific memory cache
430
- if columns:
431
- proj_hash = generate_projection_hash(param_hash, columns)
432
- df = self.memory_cache.get(proj_hash)
442
+ # For projected reads, also check a projection-specific memory cache
443
+ if columns:
444
+ proj_hash = generate_projection_hash(param_hash, columns)
445
+ df = self.memory_cache.get(proj_hash)
446
+ if df is not None:
447
+ logger.info("Cache hit: memory (projection)")
448
+ return df
449
+
450
+ # 2. Try query cache (on-disk parquet, fast)
451
+ df = self.query_cache.load(
452
+ self.__class__.__name__, param_hash, None, columns
453
+ )
433
454
  if df is not None:
434
- logger.info("Cache hit: memory (projection)")
455
+ logger.info("Cache hit: query cache")
456
+ # Only cache in memory if we loaded full data (no column selection)
457
+ # This way memory cache is reusable for queries with different column subsets
458
+ if not columns:
459
+ self._cache_in_memory(param_hash, df)
460
+ # Data is already cleaned and column-selected at parquet read level
435
461
  return df
436
462
 
437
- # 2. Try query cache (on-disk parquet, fast)
438
- df = self.query_cache.load(self.__class__.__name__, param_hash, None, columns)
439
- if df is not None:
440
- logger.info("Cache hit: query cache")
441
- # Only cache in memory if we loaded full data (no column selection)
442
- # This way memory cache is reusable for queries with different column subsets
443
- if not columns:
444
- self._cache_in_memory(param_hash, df)
445
- # Data is already cleaned and column-selected at parquet read level
446
- return df
447
-
448
463
  # 3. Cache miss (query/memory) - need to fetch data
449
464
  if using_bulk_download:
450
465
  logger.info("Query cache miss - reading from bulk cache")
451
- df = self._fetch_from_bulk_cache(filters, columns)
466
+ df = self._fetch_from_bulk_cache(filters, columns, refresh=refresh)
452
467
  if columns:
453
468
  # Cache projected result under a projection-specific key
454
469
  # so it doesn't pollute the shared filter-only cache
455
- self._cache_in_memory(
456
- generate_projection_hash(param_hash, columns), df
457
- )
470
+ self._cache_in_memory(generate_projection_hash(param_hash, columns), df)
458
471
  return df
459
472
  else:
460
473
  # Download via API (uses init-time filters only)
@@ -802,12 +815,15 @@ class AidDataData(AidDataSource):
802
815
  self,
803
816
  additional_filters: list[tuple] | None = None,
804
817
  columns: list | None = None,
818
+ *,
819
+ refresh: bool = False,
805
820
  ):
806
821
  """Reads AidData from bulk cache with filtering.
807
822
 
808
823
  Args:
809
824
  additional_filters: Additional filters to apply
810
825
  columns: Subset of columns to read
826
+ refresh: If True, bypass the bulk cache and re-download.
811
827
 
812
828
  Returns:
813
829
  Filtered DataFrame
@@ -816,35 +832,39 @@ class AidDataData(AidDataSource):
816
832
  filters = self._get_read_filters(additional_filters=additional_filters)
817
833
  param_hash = generate_param_hash(filters if filters else [])
818
834
 
819
- # 1. Try memory cache
820
- df = self.memory_cache.get(param_hash)
821
- if df is not None:
835
+ # When refresh=True, skip the upper cache tiers so the bulk fetcher's
836
+ # refresh=True actually re-downloads. See DACSource.read for context.
837
+ if not refresh:
838
+ # 1. Try memory cache
839
+ df = self.memory_cache.get(param_hash)
822
840
  # Only use if all requested columns are available
823
- if not columns or all(c in df.columns for c in columns):
841
+ if df is not None and (
842
+ not columns or all(c in df.columns for c in columns)
843
+ ):
824
844
  logger.info("Cache hit: memory")
825
- return self._apply_columns_and_clean(
826
- df, columns, already_cleaned=True
827
- )
845
+ return self._apply_columns_and_clean(df, columns, already_cleaned=True)
828
846
 
829
- # For projected reads, also check a projection-specific memory cache
830
- if columns:
831
- proj_hash = generate_projection_hash(param_hash, columns)
832
- df = self.memory_cache.get(proj_hash)
847
+ # For projected reads, also check a projection-specific memory cache
848
+ if columns:
849
+ proj_hash = generate_projection_hash(param_hash, columns)
850
+ df = self.memory_cache.get(proj_hash)
851
+ if df is not None:
852
+ logger.info("Cache hit: memory (projection)")
853
+ return df
854
+
855
+ # 2. Try query cache
856
+ # Use column selection at parquet read level for efficiency
857
+ df = self.query_cache.load(
858
+ self.__class__.__name__, param_hash, None, columns
859
+ )
833
860
  if df is not None:
834
- logger.info("Cache hit: memory (projection)")
861
+ logger.info("Cache hit: query cache")
862
+ # Only cache in memory if we loaded full data (no column selection)
863
+ if not columns:
864
+ self._cache_in_memory(param_hash, df)
865
+ # Data is already cleaned and column-selected at parquet read level
835
866
  return df
836
867
 
837
- # 2. Try query cache
838
- # Use column selection at parquet read level for efficiency
839
- df = self.query_cache.load(self.__class__.__name__, param_hash, None, columns)
840
- if df is not None:
841
- logger.info("Cache hit: query cache")
842
- # Only cache in memory if we loaded full data (no column selection)
843
- if not columns:
844
- self._cache_in_memory(param_hash, df)
845
- # Data is already cleaned and column-selected at parquet read level
846
- return df
847
-
848
868
  # 3. Fetch from bulk cache (always bulk for AidData)
849
869
  entry = BulkCacheEntry(
850
870
  key=f"{self.__class__.__name__}_bulk",
@@ -853,7 +873,7 @@ class AidDataData(AidDataSource):
853
873
  version=self._get_package_version(),
854
874
  )
855
875
 
856
- bulk_path = self.bulk_cache.ensure(entry, refresh=False)
876
+ bulk_path = self.bulk_cache.ensure(entry, refresh=refresh)
857
877
 
858
878
  # Use PyArrow predicate pushdown and column selection for maximum efficiency
859
879
  pyarrow_filters = filters if filters else None
@@ -868,9 +888,7 @@ class AidDataData(AidDataSource):
868
888
  )
869
889
  # Cache under projection-specific key to avoid polluting
870
890
  # the shared filter-only cache
871
- self._cache_in_memory(
872
- generate_projection_hash(param_hash, columns), df
873
- )
891
+ self._cache_in_memory(generate_projection_hash(param_hash, columns), df)
874
892
  return df
875
893
  else:
876
894
  logger.info("Query cache miss - reading from bulk cache")
@@ -0,0 +1,38 @@
1
+ """oda_data.cache — typed cache management surface.
2
+
3
+ Public symbols re-exported from sub-modules:
4
+
5
+ - ``path``, ``entries``, ``clear``, ``size``, ``invalidate``,
6
+ ``enable_cache``, ``disable_cache`` — from :mod:`oda_data.cache.api`
7
+ - ``migrate`` — from :mod:`oda_data.cache._migrate`
8
+ - ``set_cache_root`` — from :mod:`oda_data.cache.config`
9
+ - ``CacheRecord``, ``MigrationResult``, ``Scope`` — from :mod:`oda_data.cache.types`
10
+ """
11
+
12
+ from oda_data.cache._migrate import migrate
13
+ from oda_data.cache.api import (
14
+ clear,
15
+ disable_cache,
16
+ enable_cache,
17
+ entries,
18
+ invalidate,
19
+ path,
20
+ size,
21
+ )
22
+ from oda_data.cache.config import set_cache_root
23
+ from oda_data.cache.types import CacheRecord, MigrationResult, Scope
24
+
25
+ __all__ = [
26
+ "CacheRecord",
27
+ "MigrationResult",
28
+ "Scope",
29
+ "clear",
30
+ "disable_cache",
31
+ "enable_cache",
32
+ "entries",
33
+ "invalidate",
34
+ "migrate",
35
+ "path",
36
+ "set_cache_root",
37
+ "size",
38
+ ]