pydeflate 2.1.2__tar.gz → 2.2.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 (36) hide show
  1. {pydeflate-2.1.2 → pydeflate-2.2.0}/LICENSE +1 -1
  2. pydeflate-2.1.2/README.md → pydeflate-2.2.0/PKG-INFO +125 -0
  3. pydeflate-2.1.2/PKG-INFO → pydeflate-2.2.0/README.md +99 -23
  4. pydeflate-2.2.0/pyproject.toml +42 -0
  5. pydeflate-2.2.0/src/pydeflate/__init__.py +91 -0
  6. pydeflate-2.2.0/src/pydeflate/cache.py +139 -0
  7. pydeflate-2.2.0/src/pydeflate/constants.py +121 -0
  8. pydeflate-2.2.0/src/pydeflate/context.py +211 -0
  9. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/core/api.py +34 -12
  10. pydeflate-2.2.0/src/pydeflate/core/source.py +144 -0
  11. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/deflate/deflators.py +1 -1
  12. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/deflate/legacy_deflate.py +1 -1
  13. pydeflate-2.2.0/src/pydeflate/exceptions.py +166 -0
  14. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/exchange/exchangers.py +1 -2
  15. pydeflate-2.2.0/src/pydeflate/plugins.py +289 -0
  16. pydeflate-2.2.0/src/pydeflate/protocols.py +168 -0
  17. pydeflate-2.2.0/src/pydeflate/pydeflate_config.py +114 -0
  18. pydeflate-2.2.0/src/pydeflate/schemas.py +297 -0
  19. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/common.py +60 -107
  20. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/dac.py +39 -52
  21. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/imf.py +51 -38
  22. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/world_bank.py +44 -117
  23. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/utils.py +14 -9
  24. pydeflate-2.1.2/pydeflate/__init__.py +0 -47
  25. pydeflate-2.1.2/pydeflate/core/source.py +0 -63
  26. pydeflate-2.1.2/pydeflate/pydeflate_config.py +0 -43
  27. pydeflate-2.1.2/pyproject.toml +0 -29
  28. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/.pydeflate_data/README.md +0 -0
  29. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/core/__init__.py +0 -0
  30. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/core/deflator.py +0 -0
  31. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/core/exchange.py +0 -0
  32. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/deflate/__init__.py +0 -0
  33. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/exchange/__init__.py +0 -0
  34. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/settings/emu.json +0 -0
  35. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/settings/oecd_codes.json +0 -0
  36. {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2021-2024, Jorge Rivera
3
+ Copyright (c) 2021-2025, Jorge Rivera
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,3 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydeflate
3
+ Version: 2.2.0
4
+ Summary: Package to convert current prices figures to constant prices and vice versa
5
+ Author: Jorge Rivera
6
+ Author-email: Jorge Rivera <Jorge.Rivera@one.org>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: hdx-python-country>=3.9.8
10
+ Requires-Dist: imf-reader>=1.3.0
11
+ Requires-Dist: oda-reader>=1.2.2
12
+ Requires-Dist: pandas>=2.0
13
+ Requires-Dist: pandera>=0.20.0
14
+ Requires-Dist: pyarrow>=17.0
15
+ Requires-Dist: requests>=2.32.5
16
+ Requires-Dist: wbgapi>=1.0.12
17
+ Requires-Dist: platformdirs>=3.0.0
18
+ Requires-Dist: filelock>=3.15.0
19
+ Maintainer: Jorge Rivera
20
+ Requires-Python: >=3.11
21
+ Project-URL: Homepage, https://github.com/jm-rivera/pydeflate
22
+ Project-URL: Issues, https://github.com/jm-rivera/pydeflate/issues
23
+ Project-URL: Repository, https://github.com/jm-rivera/pydeflate
24
+ Description-Content-Type: text/markdown
25
+
1
26
  # pydeflate
2
27
 
3
28
  [![pypi](https://img.shields.io/pypi/v/pydeflate.svg)](https://pypi.python.org/pypi/pydeflate)
@@ -257,4 +282,104 @@ Pydeflate relies on data from external sources. If there are missing values in t
257
282
 
258
283
  Pydeflate periodically updates its underlying data from the World Bank, IMF, and OECD. If the data on your system is older than 50 days, pydeflate will display a warning upon import.
259
284
 
285
+ ## Advanced Features
286
+
287
+ ### Error Handling
288
+
289
+ Pydeflate v2.1.3+ provides specific exception types for better error handling:
290
+
291
+ ```python
292
+ from pydeflate import imf_gdp_deflate
293
+ from pydeflate.exceptions import NetworkError, ConfigurationError, MissingDataError
294
+
295
+ try:
296
+ result = imf_gdp_deflate(df, base_year=2015, source_currency="USA", target_currency="EUR")
297
+ except NetworkError as e:
298
+ # Handle network failures (retry, fallback to cached data, etc.)
299
+ print(f"Network error: {e}")
300
+ # Implement retry logic
301
+ except ConfigurationError as e:
302
+ # Handle invalid parameters (wrong currency codes, missing columns, etc.)
303
+ print(f"Configuration error: {e}")
304
+ raise
305
+ except MissingDataError as e:
306
+ # Handle missing deflator/exchange data for specific country-year combinations
307
+ print(f"Missing data: {e}")
308
+ # Use alternative source or fill gaps
309
+ ```
310
+
311
+ Available exception types:
312
+ - `PydeflateError`: Base exception for all pydeflate errors
313
+ - `NetworkError`: Network-related failures
314
+ - `ConfigurationError`: Invalid parameters or configuration
315
+ - `DataSourceError`: Issues loading or parsing data from sources
316
+ - `CacheError`: Cache operation failures
317
+ - `MissingDataError`: Required deflator/exchange data unavailable
318
+ - `SchemaValidationError`: Data validation failures
319
+
320
+ ### Custom Data Sources (Plugin System)
321
+
322
+ You can register custom data sources without modifying pydeflate's code:
323
+
324
+ ```python
325
+ from pydeflate.plugins import register_source, list_sources
326
+
327
+ # Define your custom source
328
+ @register_source("my_central_bank")
329
+ class MyCentralBankSource:
330
+ def __init__(self, update: bool = False):
331
+ self.name = "my_central_bank"
332
+ self.data = self.load_my_data(update) # Your data loading logic
333
+ self._idx = ["pydeflate_year", "pydeflate_entity_code", "pydeflate_iso3"]
334
+
335
+ def lcu_usd_exchange(self):
336
+ # Return exchange rate data
337
+ return self.data.filter(self._idx + ["pydeflate_EXCHANGE"])
338
+
339
+ def price_deflator(self, kind="NGDP_D"):
340
+ # Return deflator data
341
+ return self.data.filter(self._idx + [f"pydeflate_{kind}"])
342
+
343
+ def validate(self):
344
+ # Validate data format
345
+ if self.data.empty:
346
+ raise ValueError("No data loaded")
347
+
348
+ # List all available sources
349
+ print(list_sources()) # ['DAC', 'IMF', 'World Bank', 'my_central_bank', ...]
350
+
351
+ # Your custom source is now available for use with pydeflate
352
+ ```
353
+
354
+ ### Advanced Configuration
355
+
356
+ For advanced use cases, you can use context managers to customize pydeflate's behavior:
357
+
358
+ ```python
359
+ from pydeflate.context import pydeflate_session
360
+ import logging
361
+
362
+ # Use a custom cache directory and logging level
363
+ with pydeflate_session(data_dir="/tmp/my_cache", log_level=logging.DEBUG) as ctx:
364
+ result = imf_gdp_deflate(df, base_year=2015, ...)
365
+ # Data is cached in /tmp/my_cache
366
+ # Debug logging is enabled
367
+
368
+ # Or set a default context for your entire application
369
+ from pydeflate.context import PydeflateContext, set_default_context
370
+
371
+ ctx = PydeflateContext.create(
372
+ data_dir="/app/data/pydeflate_cache",
373
+ log_level=logging.INFO
374
+ )
375
+ set_default_context(ctx)
376
+
377
+ # All subsequent pydeflate operations use this configuration
378
+ ```
379
+
380
+ This is useful for:
381
+ - Using different cache directories for different projects
382
+ - Running multiple pydeflate operations in parallel without cache conflicts
383
+ - Customizing logging verbosity
384
+ - Testing with temporary cache directories
260
385
 
@@ -1,26 +1,3 @@
1
- Metadata-Version: 2.3
2
- Name: pydeflate
3
- Version: 2.1.2
4
- Summary: Package to convert current prices figures to constant prices and vice versa
5
- License: MIT
6
- Author: Jorge Rivera
7
- Author-email: jorge.rivera@one.org
8
- Requires-Python: >=3.10, <4.0
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.10
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Classifier: Programming Language :: Python :: 3.13
15
- Requires-Dist: hdx-python-country (>=3.8,<4.0.0)
16
- Requires-Dist: imf-reader (>=1.2.0,<2.0.0)
17
- Requires-Dist: oda-reader (>=1.0.5,<2.0.0)
18
- Requires-Dist: pandas (>=2.2.3,<3.0.0)
19
- Requires-Dist: pyarrow (>=14.0)
20
- Requires-Dist: requests (>=2.32.3,<3.0.0)
21
- Requires-Dist: wbgapi (>=1.0.12,<2.0.0)
22
- Description-Content-Type: text/markdown
23
-
24
1
  # pydeflate
25
2
 
26
3
  [![pypi](https://img.shields.io/pypi/v/pydeflate.svg)](https://pypi.python.org/pypi/pydeflate)
@@ -280,5 +257,104 @@ Pydeflate relies on data from external sources. If there are missing values in t
280
257
 
281
258
  Pydeflate periodically updates its underlying data from the World Bank, IMF, and OECD. If the data on your system is older than 50 days, pydeflate will display a warning upon import.
282
259
 
260
+ ## Advanced Features
261
+
262
+ ### Error Handling
263
+
264
+ Pydeflate v2.1.3+ provides specific exception types for better error handling:
265
+
266
+ ```python
267
+ from pydeflate import imf_gdp_deflate
268
+ from pydeflate.exceptions import NetworkError, ConfigurationError, MissingDataError
269
+
270
+ try:
271
+ result = imf_gdp_deflate(df, base_year=2015, source_currency="USA", target_currency="EUR")
272
+ except NetworkError as e:
273
+ # Handle network failures (retry, fallback to cached data, etc.)
274
+ print(f"Network error: {e}")
275
+ # Implement retry logic
276
+ except ConfigurationError as e:
277
+ # Handle invalid parameters (wrong currency codes, missing columns, etc.)
278
+ print(f"Configuration error: {e}")
279
+ raise
280
+ except MissingDataError as e:
281
+ # Handle missing deflator/exchange data for specific country-year combinations
282
+ print(f"Missing data: {e}")
283
+ # Use alternative source or fill gaps
284
+ ```
285
+
286
+ Available exception types:
287
+ - `PydeflateError`: Base exception for all pydeflate errors
288
+ - `NetworkError`: Network-related failures
289
+ - `ConfigurationError`: Invalid parameters or configuration
290
+ - `DataSourceError`: Issues loading or parsing data from sources
291
+ - `CacheError`: Cache operation failures
292
+ - `MissingDataError`: Required deflator/exchange data unavailable
293
+ - `SchemaValidationError`: Data validation failures
294
+
295
+ ### Custom Data Sources (Plugin System)
296
+
297
+ You can register custom data sources without modifying pydeflate's code:
298
+
299
+ ```python
300
+ from pydeflate.plugins import register_source, list_sources
301
+
302
+ # Define your custom source
303
+ @register_source("my_central_bank")
304
+ class MyCentralBankSource:
305
+ def __init__(self, update: bool = False):
306
+ self.name = "my_central_bank"
307
+ self.data = self.load_my_data(update) # Your data loading logic
308
+ self._idx = ["pydeflate_year", "pydeflate_entity_code", "pydeflate_iso3"]
309
+
310
+ def lcu_usd_exchange(self):
311
+ # Return exchange rate data
312
+ return self.data.filter(self._idx + ["pydeflate_EXCHANGE"])
313
+
314
+ def price_deflator(self, kind="NGDP_D"):
315
+ # Return deflator data
316
+ return self.data.filter(self._idx + [f"pydeflate_{kind}"])
317
+
318
+ def validate(self):
319
+ # Validate data format
320
+ if self.data.empty:
321
+ raise ValueError("No data loaded")
322
+
323
+ # List all available sources
324
+ print(list_sources()) # ['DAC', 'IMF', 'World Bank', 'my_central_bank', ...]
325
+
326
+ # Your custom source is now available for use with pydeflate
327
+ ```
328
+
329
+ ### Advanced Configuration
330
+
331
+ For advanced use cases, you can use context managers to customize pydeflate's behavior:
332
+
333
+ ```python
334
+ from pydeflate.context import pydeflate_session
335
+ import logging
336
+
337
+ # Use a custom cache directory and logging level
338
+ with pydeflate_session(data_dir="/tmp/my_cache", log_level=logging.DEBUG) as ctx:
339
+ result = imf_gdp_deflate(df, base_year=2015, ...)
340
+ # Data is cached in /tmp/my_cache
341
+ # Debug logging is enabled
342
+
343
+ # Or set a default context for your entire application
344
+ from pydeflate.context import PydeflateContext, set_default_context
345
+
346
+ ctx = PydeflateContext.create(
347
+ data_dir="/app/data/pydeflate_cache",
348
+ log_level=logging.INFO
349
+ )
350
+ set_default_context(ctx)
351
+
352
+ # All subsequent pydeflate operations use this configuration
353
+ ```
283
354
 
355
+ This is useful for:
356
+ - Using different cache directories for different projects
357
+ - Running multiple pydeflate operations in parallel without cache conflicts
358
+ - Customizing logging verbosity
359
+ - Testing with temporary cache directories
284
360
 
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "pydeflate"
3
+ version = "2.2.0"
4
+ description = "Package to convert current prices figures to constant prices and vice versa"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ authors = [{ name = "Jorge Rivera", email = "Jorge.Rivera@one.org" }]
8
+ maintainers = [{ name = "Jorge Rivera" }]
9
+ license = "MIT"
10
+ license-files = ["LICENSE*"]
11
+
12
+ dependencies = [
13
+ "hdx-python-country>=3.9.8",
14
+ "imf-reader>=1.3.0",
15
+ "oda-reader>=1.2.2",
16
+ "pandas>=2.0",
17
+ "pandera>=0.20.0",
18
+ "pyarrow>=17.0",
19
+ "requests>=2.32.5",
20
+ "wbgapi>=1.0.12",
21
+ "platformdirs>=3.0.0",
22
+ "filelock>=3.15.0",
23
+ ]
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "hypothesis>=6.122.3",
28
+ "pandas-stubs>=2.3.2.250926",
29
+ "pytest>=8.4.2",
30
+ "pytest-cov>=7.0.0",
31
+ "ruff>=0.13.3",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/jm-rivera/pydeflate"
36
+ Repository = "https://github.com/jm-rivera/pydeflate"
37
+ Issues = "https://github.com/jm-rivera/pydeflate/issues"
38
+
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.8.22,<0.9.0"]
42
+ build-backend = "uv_build"
@@ -0,0 +1,91 @@
1
+ __author__ = """Jorge Rivera"""
2
+ __version__ = "2.2.0"
3
+
4
+ from pydeflate.deflate.deflators import (
5
+ imf_cpi_deflate,
6
+ imf_cpi_e_deflate,
7
+ imf_gdp_deflate,
8
+ oecd_dac_deflate,
9
+ wb_cpi_deflate,
10
+ wb_gdp_deflate,
11
+ wb_gdp_linked_deflate,
12
+ )
13
+ from pydeflate.deflate.legacy_deflate import deflate
14
+ from pydeflate.exchange.exchangers import (
15
+ imf_exchange,
16
+ oecd_dac_exchange,
17
+ wb_exchange,
18
+ wb_exchange_ppp,
19
+ )
20
+ from pydeflate.pydeflate_config import set_data_dir, setup_logger
21
+
22
+ from pydeflate.context import (
23
+ PydeflateContext,
24
+ get_default_context,
25
+ pydeflate_session,
26
+ set_default_context,
27
+ temporary_context,
28
+ )
29
+ from pydeflate.exceptions import (
30
+ CacheError,
31
+ ConfigurationError,
32
+ DataSourceError,
33
+ MissingDataError,
34
+ NetworkError,
35
+ PluginError,
36
+ PydeflateError,
37
+ SchemaValidationError,
38
+ )
39
+ from pydeflate.plugins import (
40
+ get_source,
41
+ is_source_registered,
42
+ list_sources,
43
+ register_source,
44
+ )
45
+
46
+
47
+ def set_pydeflate_path(path):
48
+ """Set the path to the pydeflate data cache directory."""
49
+
50
+ return set_data_dir(path)
51
+
52
+
53
+ __all__ = [
54
+ # Deflation functions
55
+ "deflate",
56
+ "imf_cpi_deflate",
57
+ "imf_cpi_e_deflate",
58
+ "imf_gdp_deflate",
59
+ "oecd_dac_deflate",
60
+ "wb_cpi_deflate",
61
+ "wb_gdp_deflate",
62
+ "wb_gdp_linked_deflate",
63
+ # Exchange functions
64
+ "imf_exchange",
65
+ "oecd_dac_exchange",
66
+ "wb_exchange",
67
+ "wb_exchange_ppp",
68
+ # Configuration
69
+ "set_pydeflate_path",
70
+ "setup_logger",
71
+ # Context management
72
+ "PydeflateContext",
73
+ "get_default_context",
74
+ "pydeflate_session",
75
+ "set_default_context",
76
+ "temporary_context",
77
+ # Exceptions
78
+ "CacheError",
79
+ "ConfigurationError",
80
+ "DataSourceError",
81
+ "MissingDataError",
82
+ "NetworkError",
83
+ "PluginError",
84
+ "PydeflateError",
85
+ "SchemaValidationError",
86
+ # Plugin system
87
+ "get_source",
88
+ "is_source_registered",
89
+ "list_sources",
90
+ "register_source",
91
+ ]
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import Callable, Dict, Iterable, Optional
9
+
10
+ from filelock import FileLock
11
+
12
+ from pydeflate.pydeflate_config import get_data_dir
13
+
14
+ ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CacheEntry:
19
+ """Describe a cacheable dataset."""
20
+
21
+ key: str
22
+ filename: str
23
+ fetcher: Callable[[Path], None]
24
+ ttl_days: int = 30
25
+ version: str | None = None
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CacheRecord:
30
+ key: str
31
+ path: Path
32
+ downloaded_at: datetime
33
+ ttl_days: int
34
+ version: str | None
35
+
36
+
37
+ class CacheError(RuntimeError):
38
+ pass
39
+
40
+
41
+ class CacheManager:
42
+ """Handle cached datasets stored under the pydeflate data directory."""
43
+
44
+ def __init__(self, base_dir: Path | None = None) -> None:
45
+ self.base_dir = (base_dir or get_data_dir()).resolve()
46
+ self.base_dir.mkdir(parents=True, exist_ok=True)
47
+ self.manifest_path = self.base_dir / "manifest.json"
48
+ self._lock = FileLock(str(self.base_dir / ".cache.lock"))
49
+ self._manifest: Dict[str, dict] = self._load_manifest()
50
+
51
+ # ------------------------------------------------------------------
52
+ def ensure(self, entry: CacheEntry, *, refresh: bool = False) -> Path:
53
+ """Return a local path for the given entry, downloading when needed."""
54
+
55
+ with self._lock:
56
+ record = self._manifest.get(entry.key)
57
+ path = self.base_dir / entry.filename
58
+
59
+ if not refresh and record and path.exists():
60
+ if not self._is_stale(record, entry):
61
+ return path
62
+
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ tmp_path = Path(f"{path}.tmp-{os.getpid()}")
65
+ try:
66
+ entry.fetcher(tmp_path)
67
+ tmp_path.replace(path)
68
+ finally:
69
+ if tmp_path.exists():
70
+ tmp_path.unlink(missing_ok=True)
71
+
72
+ self._manifest[entry.key] = {
73
+ "filename": entry.filename,
74
+ "downloaded_at": datetime.now(timezone.utc).strftime(ISO_FORMAT),
75
+ "ttl_days": entry.ttl_days,
76
+ "version": entry.version,
77
+ }
78
+ self._save_manifest()
79
+ return path
80
+
81
+ # ------------------------------------------------------------------
82
+ def list_records(self) -> Iterable[CacheRecord]:
83
+ for key, payload in self._manifest.items():
84
+ path = self.base_dir / payload["filename"]
85
+ yield CacheRecord(
86
+ key=key,
87
+ path=path,
88
+ downloaded_at=datetime.strptime(payload["downloaded_at"], ISO_FORMAT),
89
+ ttl_days=payload["ttl_days"],
90
+ version=payload.get("version"),
91
+ )
92
+
93
+ # ------------------------------------------------------------------
94
+ def clear(self, key: str | None = None) -> None:
95
+ with self._lock:
96
+ if key is None:
97
+ for payload in self._manifest.values():
98
+ (self.base_dir / payload["filename"]).unlink(missing_ok=True)
99
+ self._manifest = {}
100
+ else:
101
+ payload = self._manifest.pop(key, None)
102
+ if payload:
103
+ (self.base_dir / payload["filename"]).unlink(missing_ok=True)
104
+ self._save_manifest()
105
+
106
+ # ------------------------------------------------------------------
107
+ def _is_stale(self, record: dict, entry: CacheEntry) -> bool:
108
+ version_changed = entry.version is not None and entry.version != record.get(
109
+ "version"
110
+ )
111
+ downloaded = datetime.strptime(record["downloaded_at"], ISO_FORMAT)
112
+ age = datetime.now(timezone.utc) - downloaded
113
+ ttl = timedelta(days=entry.ttl_days)
114
+ return version_changed or age > ttl
115
+
116
+ # ------------------------------------------------------------------
117
+ def _load_manifest(self) -> Dict[str, dict]:
118
+ if not self.manifest_path.exists():
119
+ return {}
120
+ try:
121
+ return json.loads(self.manifest_path.read_text())
122
+ except json.JSONDecodeError:
123
+ return {}
124
+
125
+ # ------------------------------------------------------------------
126
+ def _save_manifest(self) -> None:
127
+ payload = json.dumps(self._manifest, indent=2)
128
+ self.manifest_path.write_text(payload)
129
+
130
+
131
+ _CACHE_MANAGER: Optional[CacheManager] = None
132
+
133
+
134
+ def cache_manager() -> CacheManager:
135
+ global _CACHE_MANAGER
136
+ base_dir = get_data_dir().resolve()
137
+ if _CACHE_MANAGER is None or _CACHE_MANAGER.base_dir != base_dir:
138
+ _CACHE_MANAGER = CacheManager(base_dir)
139
+ return _CACHE_MANAGER
@@ -0,0 +1,121 @@
1
+ """Constants used throughout pydeflate.
2
+
3
+ Centralizing constants eliminates magic strings and makes refactoring safer.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class PydeflateColumns:
10
+ """Standard column names used in pydeflate DataFrames."""
11
+
12
+ # Index columns
13
+ YEAR = "pydeflate_year"
14
+ ENTITY_CODE = "pydeflate_entity_code"
15
+ ISO3 = "pydeflate_iso3"
16
+
17
+ # Data columns
18
+ EXCHANGE = "pydeflate_EXCHANGE"
19
+ EXCHANGE_D = "pydeflate_EXCHANGE_D"
20
+
21
+ # Deflator columns
22
+ NGDP_D = "pydeflate_NGDP_D"
23
+ NGDP_DL = "pydeflate_NGDP_DL"
24
+ CPI = "pydeflate_CPI"
25
+ PCPI = "pydeflate_PCPI"
26
+ PCPIE = "pydeflate_PCPIE"
27
+ DAC_DEFLATOR = "pydeflate_DAC_DEFLATOR"
28
+
29
+ # Standard index
30
+ STANDARD_INDEX = [YEAR, ENTITY_CODE, ISO3]
31
+
32
+ @classmethod
33
+ def deflator_column(cls, kind: str) -> str:
34
+ """Get deflator column name for a given kind.
35
+
36
+ Args:
37
+ kind: Deflator type (e.g., 'NGDP_D', 'CPI')
38
+
39
+ Returns:
40
+ Full column name with pydeflate_ prefix
41
+ """
42
+ if kind.startswith("pydeflate_"):
43
+ return kind
44
+ return f"pydeflate_{kind}"
45
+
46
+
47
+ class CurrencyCodes:
48
+ """Common currency code mappings."""
49
+
50
+ # ISO3 to common codes
51
+ USD = "USA"
52
+ EUR = "EUR" # For most sources
53
+ EUR_DAC = "EUI" # For DAC source
54
+ GBP = "GBR"
55
+ JPY = "JPN"
56
+ CAD = "CAN"
57
+
58
+ # Special codes
59
+ LCU = "LCU" # Local Currency Unit
60
+ PPP = "PPP" # Purchasing Power Parity
61
+ DAC = "DAC" # DAC members
62
+
63
+ # Mapping for user convenience
64
+ COMMON_ALIASES = {
65
+ "USD": USA,
66
+ "EUR": EUR,
67
+ "GBP": GBR,
68
+ "JPY": JPN,
69
+ "CAD": CAN,
70
+ }
71
+
72
+ @classmethod
73
+ def resolve(cls, code: str, source: str | None = None) -> str:
74
+ """Resolve a currency code to ISO3.
75
+
76
+ Args:
77
+ code: Currency code (USD, EUR, etc.) or ISO3
78
+ source: Data source name (affects EUR mapping for DAC)
79
+
80
+ Returns:
81
+ ISO3 country code or special code (LCU, PPP)
82
+ """
83
+ # Handle EUR special case for DAC
84
+ if code == "EUR" and source == "DAC":
85
+ return cls.EUR_DAC
86
+
87
+ # Try aliases
88
+ return cls.COMMON_ALIASES.get(code, code)
89
+
90
+
91
+ class DataSources:
92
+ """Names of built-in data sources."""
93
+
94
+ IMF = "IMF"
95
+ WORLD_BANK = "World Bank"
96
+ WORLD_BANK_PPP = "World Bank PPP"
97
+ DAC = "DAC"
98
+
99
+ # Aliases
100
+ WB = "World Bank"
101
+ OECD = "DAC"
102
+
103
+ ALL_SOURCES = [IMF, WORLD_BANK, WORLD_BANK_PPP, DAC]
104
+
105
+
106
+ class CacheDefaults:
107
+ """Default values for caching."""
108
+
109
+ TTL_DAYS_IMF = 60 # IMF data updates less frequently
110
+ TTL_DAYS_WB = 30 # World Bank monthly updates
111
+ TTL_DAYS_DAC = 30 # DAC data
112
+ DEFAULT_TTL = 30
113
+
114
+
115
+ class ValidationConfig:
116
+ """Validation configuration."""
117
+
118
+ MIN_YEAR = 1960 # No data before 1960
119
+ MAX_YEAR = 2100 # No projections beyond 2100
120
+ MIN_EXCHANGE_RATE = 1e-6 # Extremely low but non-zero
121
+ MAX_EXCHANGE_RATE = 1e6 # Extremely high but finite