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.
- {pydeflate-2.1.2 → pydeflate-2.2.0}/LICENSE +1 -1
- pydeflate-2.1.2/README.md → pydeflate-2.2.0/PKG-INFO +125 -0
- pydeflate-2.1.2/PKG-INFO → pydeflate-2.2.0/README.md +99 -23
- pydeflate-2.2.0/pyproject.toml +42 -0
- pydeflate-2.2.0/src/pydeflate/__init__.py +91 -0
- pydeflate-2.2.0/src/pydeflate/cache.py +139 -0
- pydeflate-2.2.0/src/pydeflate/constants.py +121 -0
- pydeflate-2.2.0/src/pydeflate/context.py +211 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/core/api.py +34 -12
- pydeflate-2.2.0/src/pydeflate/core/source.py +144 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/deflate/deflators.py +1 -1
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/deflate/legacy_deflate.py +1 -1
- pydeflate-2.2.0/src/pydeflate/exceptions.py +166 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/exchange/exchangers.py +1 -2
- pydeflate-2.2.0/src/pydeflate/plugins.py +289 -0
- pydeflate-2.2.0/src/pydeflate/protocols.py +168 -0
- pydeflate-2.2.0/src/pydeflate/pydeflate_config.py +114 -0
- pydeflate-2.2.0/src/pydeflate/schemas.py +297 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/common.py +60 -107
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/dac.py +39 -52
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/imf.py +51 -38
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/world_bank.py +44 -117
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/utils.py +14 -9
- pydeflate-2.1.2/pydeflate/__init__.py +0 -47
- pydeflate-2.1.2/pydeflate/core/source.py +0 -63
- pydeflate-2.1.2/pydeflate/pydeflate_config.py +0 -43
- pydeflate-2.1.2/pyproject.toml +0 -29
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/.pydeflate_data/README.md +0 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/core/__init__.py +0 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/core/deflator.py +0 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/core/exchange.py +0 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/deflate/__init__.py +0 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/exchange/__init__.py +0 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/settings/emu.json +0 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/settings/oecd_codes.json +0 -0
- {pydeflate-2.1.2 → pydeflate-2.2.0/src}/pydeflate/sources/__init__.py +0 -0
|
@@ -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
|
[](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
|
[](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
|