fmp-data 2.2.0__tar.gz → 2.2.1__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.
- {fmp_data-2.2.0 → fmp_data-2.2.1}/PKG-INFO +47 -1
- {fmp_data-2.2.0 → fmp_data-2.2.1}/README.md +44 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/__init__.py +2 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/base.py +188 -32
- fmp_data-2.2.1/fmp_data/cache/__init__.py +48 -0
- fmp_data-2.2.1/fmp_data/cache/base.py +56 -0
- fmp_data-2.2.1/fmp_data/cache/config.py +58 -0
- fmp_data-2.2.1/fmp_data/cache/file.py +83 -0
- fmp_data-2.2.1/fmp_data/cache/memory.py +68 -0
- fmp_data-2.2.1/fmp_data/cache/redis_backend.py +83 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/company/models.py +46 -16
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/config.py +13 -1
- {fmp_data-2.2.0 → fmp_data-2.2.1}/pyproject.toml +6 -0
- fmp_data-2.2.1/tests/unit/test_cache_backends.py +250 -0
- fmp_data-2.2.1/tests/unit/test_cache_integration.py +302 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_company.py +164 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/.gitignore +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/LICENSE +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/NOTICE +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/alternative/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/alternative/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/alternative/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/alternative/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/alternative/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/alternative/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/alternative/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/batch/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/batch/_csv_utils.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/batch/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/batch/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/batch/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/batch/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/batch/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/company/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/company/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/company/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/company/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/company/hints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/company/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/company/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/economics/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/economics/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/economics/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/economics/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/economics/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/economics/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/economics/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/exceptions.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/fundamental/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/fundamental/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/fundamental/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/fundamental/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/fundamental/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/fundamental/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/fundamental/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/helpers.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/index/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/index/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/index/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/index/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/index/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/index/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/institutional/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/institutional/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/institutional/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/institutional/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/institutional/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/institutional/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/institutional/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/intelligence/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/intelligence/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/intelligence/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/intelligence/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/intelligence/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/intelligence/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/intelligence/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/investment/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/investment/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/investment/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/investment/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/investment/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/investment/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/investment/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/config.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/embedding.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/hints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/registry.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/utils.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/validation.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/lc/vector_store.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/logger.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/market/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/market/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/market/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/market/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/market/hints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/market/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/market/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/market/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/mcp/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/mcp/__main__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/mcp/cli.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/mcp/discovery.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/mcp/server.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/mcp/setup.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/mcp/tool_loader.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/mcp/tools_manifest.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/mcp/utils.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/py.typed +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/rate_limit.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/sec/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/sec/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/sec/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/sec/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/sec/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/sec/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/technical/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/technical/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/technical/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/technical/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/technical/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/technical/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/technical/schema.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/transcripts/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/transcripts/async_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/transcripts/client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/transcripts/endpoints.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/transcripts/mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/fmp_data/transcripts/models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/examples/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/examples/test_examples_smoke.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/base.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/conftest.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_alternative.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_batch.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_company.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_economics.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_fundamental.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_index.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_institutional.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_intelligence.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_investment.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_lc.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_market.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_sec.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_technical.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/integration/test_transcripts.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/conftest.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/__init__.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/conftest.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/test_config.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/test_embedding.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/test_endpoint_based_rules.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/test_imports.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/test_mapping.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/test_models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/test_utils.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/test_validation_registry.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/lc/test_vector_store.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_alternative.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_alternative_coverage.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_async_clients.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_base.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_base_async.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_base_coverage.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_batch.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_cassette_contracts.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_client.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_company_coverage.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_config.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_economics.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_exceptions.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_fundamental.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_helpers.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_index.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_institutional.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_intelligence.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_investment.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_logger.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_market.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_mcp.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_mcp_cli.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_models.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_rate_limit.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_rate_limit_async.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_rate_limit_coverage.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_sec.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_technical.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_technical_coverage.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_transcripts.py +0 -0
- {fmp_data-2.2.0 → fmp_data-2.2.1}/tests/unit/test_vcr_sanitization.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fmp-data
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: Python client for the Financial Modeling Prep API
|
|
5
5
|
Project-URL: Homepage, https://github.com/MehdiZare/fmp-data
|
|
6
6
|
Project-URL: Repository, https://github.com/MehdiZare/fmp-data
|
|
@@ -30,6 +30,8 @@ Requires-Python: <3.15,>=3.10
|
|
|
30
30
|
Requires-Dist: httpx>=0.28.1
|
|
31
31
|
Requires-Dist: pydantic>=2.12.5
|
|
32
32
|
Requires-Dist: tenacity>=9.1.2
|
|
33
|
+
Provides-Extra: cache-redis
|
|
34
|
+
Requires-Dist: redis>=5.0.0; extra == 'cache-redis'
|
|
33
35
|
Provides-Extra: dev
|
|
34
36
|
Requires-Dist: bandit[toml]>=1.9.3; extra == 'dev'
|
|
35
37
|
Requires-Dist: coverage>=7.13.1; extra == 'dev'
|
|
@@ -95,6 +97,7 @@ This project uses UV as the primary package management tool for several key bene
|
|
|
95
97
|
- Built-in rate limiting
|
|
96
98
|
- Comprehensive logging
|
|
97
99
|
- Async support
|
|
100
|
+
- Opt-in response caching with memory, file, and Redis backends
|
|
98
101
|
- Type hints and validation with Pydantic
|
|
99
102
|
- Automatic retries with exponential backoff
|
|
100
103
|
- 85%+ test coverage with comprehensive test suite
|
|
@@ -121,6 +124,9 @@ To use this library, you'll need an API key from Financial Modeling Prep (FMP).
|
|
|
121
124
|
# Basic installation
|
|
122
125
|
uv pip install fmp-data
|
|
123
126
|
|
|
127
|
+
# With Redis cache backend support
|
|
128
|
+
uv pip install "fmp-data[cache-redis]"
|
|
129
|
+
|
|
124
130
|
# With Langchain integration
|
|
125
131
|
uv pip install "fmp-data[langchain]"
|
|
126
132
|
|
|
@@ -139,12 +145,52 @@ LangChain integration requires LangChain v1 (`langchain-core`, `langchain-openai
|
|
|
139
145
|
# Basic installation
|
|
140
146
|
pip install fmp-data
|
|
141
147
|
|
|
148
|
+
# With Redis cache backend support
|
|
149
|
+
pip install "fmp-data[cache-redis]"
|
|
150
|
+
|
|
142
151
|
# With extras
|
|
143
152
|
pip install fmp-data[langchain]
|
|
144
153
|
pip install fmp-data[mcp]
|
|
145
154
|
pip install fmp-data[langchain,mcp]
|
|
146
155
|
```
|
|
147
156
|
|
|
157
|
+
## Response Caching
|
|
158
|
+
|
|
159
|
+
Response caching is optional and works for both sync and async clients.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from fmp_data import CacheConfig, ClientConfig, FMPDataClient
|
|
163
|
+
|
|
164
|
+
config = ClientConfig(
|
|
165
|
+
api_key="YOUR_FMP_API_KEY", # pragma: allowlist secret
|
|
166
|
+
cache=CacheConfig(
|
|
167
|
+
backend="memory",
|
|
168
|
+
default_ttl=300,
|
|
169
|
+
ttl_overrides={"get_quote": 30},
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
with FMPDataClient(config=config) as client:
|
|
174
|
+
quote = client.company.get_quote("AAPL")
|
|
175
|
+
fresh_quote = client.company.get_quote("AAPL", force_refresh=True)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
You can also configure caching from the environment:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
export FMP_CACHE_ENABLED=true
|
|
182
|
+
export FMP_CACHE_BACKEND=file
|
|
183
|
+
export FMP_CACHE_TTL=300
|
|
184
|
+
export FMP_CACHE_DIR=.cache/fmp-data
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
For Redis-backed caching, install the optional extra and set:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
export FMP_CACHE_BACKEND=redis
|
|
191
|
+
export FMP_CACHE_REDIS_URL=redis://localhost:6379/0
|
|
192
|
+
```
|
|
193
|
+
|
|
148
194
|
## MCP Server (Claude Desktop Integration)
|
|
149
195
|
|
|
150
196
|
Model Context Protocol (MCP) server provides financial data access through a standardized protocol, enabling Claude Desktop to query FMP data seamlessly.
|
|
@@ -27,6 +27,7 @@ This project uses UV as the primary package management tool for several key bene
|
|
|
27
27
|
- Built-in rate limiting
|
|
28
28
|
- Comprehensive logging
|
|
29
29
|
- Async support
|
|
30
|
+
- Opt-in response caching with memory, file, and Redis backends
|
|
30
31
|
- Type hints and validation with Pydantic
|
|
31
32
|
- Automatic retries with exponential backoff
|
|
32
33
|
- 85%+ test coverage with comprehensive test suite
|
|
@@ -53,6 +54,9 @@ To use this library, you'll need an API key from Financial Modeling Prep (FMP).
|
|
|
53
54
|
# Basic installation
|
|
54
55
|
uv pip install fmp-data
|
|
55
56
|
|
|
57
|
+
# With Redis cache backend support
|
|
58
|
+
uv pip install "fmp-data[cache-redis]"
|
|
59
|
+
|
|
56
60
|
# With Langchain integration
|
|
57
61
|
uv pip install "fmp-data[langchain]"
|
|
58
62
|
|
|
@@ -71,12 +75,52 @@ LangChain integration requires LangChain v1 (`langchain-core`, `langchain-openai
|
|
|
71
75
|
# Basic installation
|
|
72
76
|
pip install fmp-data
|
|
73
77
|
|
|
78
|
+
# With Redis cache backend support
|
|
79
|
+
pip install "fmp-data[cache-redis]"
|
|
80
|
+
|
|
74
81
|
# With extras
|
|
75
82
|
pip install fmp-data[langchain]
|
|
76
83
|
pip install fmp-data[mcp]
|
|
77
84
|
pip install fmp-data[langchain,mcp]
|
|
78
85
|
```
|
|
79
86
|
|
|
87
|
+
## Response Caching
|
|
88
|
+
|
|
89
|
+
Response caching is optional and works for both sync and async clients.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from fmp_data import CacheConfig, ClientConfig, FMPDataClient
|
|
93
|
+
|
|
94
|
+
config = ClientConfig(
|
|
95
|
+
api_key="YOUR_FMP_API_KEY", # pragma: allowlist secret
|
|
96
|
+
cache=CacheConfig(
|
|
97
|
+
backend="memory",
|
|
98
|
+
default_ttl=300,
|
|
99
|
+
ttl_overrides={"get_quote": 30},
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
with FMPDataClient(config=config) as client:
|
|
104
|
+
quote = client.company.get_quote("AAPL")
|
|
105
|
+
fresh_quote = client.company.get_quote("AAPL", force_refresh=True)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
You can also configure caching from the environment:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
export FMP_CACHE_ENABLED=true
|
|
112
|
+
export FMP_CACHE_BACKEND=file
|
|
113
|
+
export FMP_CACHE_TTL=300
|
|
114
|
+
export FMP_CACHE_DIR=.cache/fmp-data
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
For Redis-backed caching, install the optional extra and set:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
export FMP_CACHE_BACKEND=redis
|
|
121
|
+
export FMP_CACHE_REDIS_URL=redis://localhost:6379/0
|
|
122
|
+
```
|
|
123
|
+
|
|
80
124
|
## MCP Server (Claude Desktop Integration)
|
|
81
125
|
|
|
82
126
|
Model Context Protocol (MCP) server provides financial data access through a standardized protocol, enabling Claude Desktop to query FMP data seamlessly.
|
|
@@ -19,6 +19,7 @@ import types as _types
|
|
|
19
19
|
from typing import Any
|
|
20
20
|
|
|
21
21
|
from fmp_data.async_client import AsyncFMPDataClient
|
|
22
|
+
from fmp_data.cache.config import CacheConfig
|
|
22
23
|
from fmp_data.client import FMPDataClient
|
|
23
24
|
from fmp_data.config import (
|
|
24
25
|
ClientConfig,
|
|
@@ -48,6 +49,7 @@ except ImportError:
|
|
|
48
49
|
__all__ = [
|
|
49
50
|
"AsyncFMPDataClient",
|
|
50
51
|
"AuthenticationError",
|
|
52
|
+
"CacheConfig",
|
|
51
53
|
"ClientConfig",
|
|
52
54
|
"ConfigError",
|
|
53
55
|
"FMPDataClient",
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
5
|
from contextvars import ContextVar
|
|
6
|
+
import copy
|
|
6
7
|
import json
|
|
7
8
|
import logging
|
|
8
9
|
import time
|
|
@@ -22,6 +23,7 @@ from tenacity import (
|
|
|
22
23
|
wait_exponential,
|
|
23
24
|
)
|
|
24
25
|
|
|
26
|
+
from fmp_data.cache.base import CacheBackend
|
|
25
27
|
from fmp_data.config import ClientConfig
|
|
26
28
|
from fmp_data.exceptions import (
|
|
27
29
|
AuthenticationError,
|
|
@@ -83,6 +85,22 @@ class BaseClient:
|
|
|
83
85
|
# Async client (lazily initialized)
|
|
84
86
|
self._async_client: httpx.AsyncClient | None = None
|
|
85
87
|
|
|
88
|
+
# Response cache (optional)
|
|
89
|
+
self._cache: CacheBackend | None = None
|
|
90
|
+
cache_cfg = getattr(config, "cache", None)
|
|
91
|
+
if cache_cfg is not None and getattr(cache_cfg, "enabled", False) is True:
|
|
92
|
+
from fmp_data.cache import create_backend
|
|
93
|
+
from fmp_data.cache.config import CacheConfig
|
|
94
|
+
|
|
95
|
+
if isinstance(cache_cfg, CacheConfig):
|
|
96
|
+
self._cache = create_backend(cache_cfg)
|
|
97
|
+
self._cache_ttl_overrides = cache_cfg.ttl_overrides
|
|
98
|
+
self._cache_default_ttl = cache_cfg.default_ttl
|
|
99
|
+
self.logger.info(
|
|
100
|
+
"Response caching enabled (backend=%s)",
|
|
101
|
+
cache_cfg.backend,
|
|
102
|
+
)
|
|
103
|
+
|
|
86
104
|
def _setup_http_client(self) -> None:
|
|
87
105
|
"""
|
|
88
106
|
Setup HTTP client with default configuration.
|
|
@@ -137,6 +155,85 @@ class BaseClient:
|
|
|
137
155
|
# Also close sync client
|
|
138
156
|
self.close()
|
|
139
157
|
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _build_cache_key(endpoint_name: str, params: dict[str, Any]) -> str:
|
|
160
|
+
"""Build a deterministic cache key from endpoint name and params.
|
|
161
|
+
|
|
162
|
+
Excludes 'apikey' to avoid leaking secrets in keys.
|
|
163
|
+
Uses urllib.parse.urlencode to properly escape special characters
|
|
164
|
+
and prevent key collisions from values containing '&' or '='.
|
|
165
|
+
"""
|
|
166
|
+
import hashlib
|
|
167
|
+
import urllib.parse
|
|
168
|
+
|
|
169
|
+
filtered = {k: v for k, v in sorted(params.items()) if k != "apikey"}
|
|
170
|
+
params_str = urllib.parse.urlencode(filtered, doseq=True)
|
|
171
|
+
|
|
172
|
+
# codeql[py/weak-sensitive-data-hashing]
|
|
173
|
+
# This hash is only used to shorten cache keys, not for credential storage.
|
|
174
|
+
params_hash = hashlib.sha256(
|
|
175
|
+
params_str.encode(), usedforsecurity=False
|
|
176
|
+
).hexdigest()[:16]
|
|
177
|
+
return f"{endpoint_name}:{params_hash}"
|
|
178
|
+
|
|
179
|
+
def _get_cache_ttl(self, endpoint_name: str) -> int:
|
|
180
|
+
"""Get TTL for a given endpoint, checking overrides first."""
|
|
181
|
+
if not hasattr(self, "_cache_ttl_overrides"):
|
|
182
|
+
return 300
|
|
183
|
+
return self._cache_ttl_overrides.get(endpoint_name, self._cache_default_ttl)
|
|
184
|
+
|
|
185
|
+
def _cache_get(
|
|
186
|
+
self, endpoint_name: str, cache_key: str
|
|
187
|
+
) -> dict[str, Any] | list[Any] | None:
|
|
188
|
+
"""Read from cache, returning None on any failure."""
|
|
189
|
+
assert self._cache is not None
|
|
190
|
+
try:
|
|
191
|
+
cached = self._cache.get(cache_key)
|
|
192
|
+
return None if cached is None else copy.deepcopy(cached)
|
|
193
|
+
except Exception:
|
|
194
|
+
self.logger.warning(
|
|
195
|
+
"Cache read failed for %s", endpoint_name, exc_info=True
|
|
196
|
+
)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
async def _cache_aget(
|
|
200
|
+
self, endpoint_name: str, cache_key: str
|
|
201
|
+
) -> dict[str, Any] | list[Any] | None:
|
|
202
|
+
"""Async read from cache, returning None on any failure."""
|
|
203
|
+
assert self._cache is not None
|
|
204
|
+
try:
|
|
205
|
+
cached = await self._cache.aget(cache_key)
|
|
206
|
+
return None if cached is None else copy.deepcopy(cached)
|
|
207
|
+
except Exception:
|
|
208
|
+
self.logger.warning(
|
|
209
|
+
"Cache read failed for %s", endpoint_name, exc_info=True
|
|
210
|
+
)
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
def _cache_set(
|
|
214
|
+
self, endpoint_name: str, cache_key: str, data: Any, ttl: int
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Write to cache, logging on failure."""
|
|
217
|
+
assert self._cache is not None
|
|
218
|
+
try:
|
|
219
|
+
self._cache.set(cache_key, copy.deepcopy(data), ttl)
|
|
220
|
+
except Exception:
|
|
221
|
+
self.logger.warning(
|
|
222
|
+
"Cache write failed for %s", endpoint_name, exc_info=True
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def _cache_aset(
|
|
226
|
+
self, endpoint_name: str, cache_key: str, data: Any, ttl: int
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Async write to cache, logging on failure."""
|
|
229
|
+
assert self._cache is not None
|
|
230
|
+
try:
|
|
231
|
+
await self._cache.aset(cache_key, copy.deepcopy(data), ttl)
|
|
232
|
+
except Exception:
|
|
233
|
+
self.logger.warning(
|
|
234
|
+
"Cache write failed for %s", endpoint_name, exc_info=True
|
|
235
|
+
)
|
|
236
|
+
|
|
140
237
|
def _handle_rate_limit(self, wait_time: float) -> None:
|
|
141
238
|
"""
|
|
142
239
|
Handle rate limiting by waiting or raising an exception based on retry count.
|
|
@@ -225,18 +322,12 @@ class BaseClient:
|
|
|
225
322
|
Returns:
|
|
226
323
|
Either a single Pydantic model of type T or a list of T.
|
|
227
324
|
"""
|
|
228
|
-
# Check rate limit before making request
|
|
229
|
-
if not self._rate_limiter.should_allow_request():
|
|
230
|
-
wait_time = self._rate_limiter.get_wait_time()
|
|
231
|
-
self._handle_rate_limit(wait_time)
|
|
232
|
-
|
|
233
325
|
request_start = time.perf_counter()
|
|
234
326
|
status_code = 0
|
|
235
327
|
success = False
|
|
328
|
+
force_refresh = kwargs.pop("force_refresh", False)
|
|
236
329
|
|
|
237
330
|
try:
|
|
238
|
-
self._rate_limiter.record_request()
|
|
239
|
-
|
|
240
331
|
# Validate and process parameters
|
|
241
332
|
validated_params = endpoint.validate_params(
|
|
242
333
|
kwargs,
|
|
@@ -250,6 +341,35 @@ class BaseClient:
|
|
|
250
341
|
query_params = endpoint.get_query_params(validated_params)
|
|
251
342
|
query_params["apikey"] = self.config.api_key
|
|
252
343
|
|
|
344
|
+
# Check cache before rate limiting — cache hits are free
|
|
345
|
+
cache_key: str | None = None
|
|
346
|
+
if (
|
|
347
|
+
self._cache is not None
|
|
348
|
+
and not force_refresh
|
|
349
|
+
and endpoint.response_model is not bytes
|
|
350
|
+
):
|
|
351
|
+
cache_key = self._build_cache_key(endpoint.name, query_params)
|
|
352
|
+
cached = self._cache_get(endpoint.name, cache_key)
|
|
353
|
+
if cached is not None:
|
|
354
|
+
self.logger.debug(
|
|
355
|
+
"Cache hit for %s",
|
|
356
|
+
endpoint.name,
|
|
357
|
+
extra={"endpoint": endpoint.name, "cache_key": cache_key},
|
|
358
|
+
)
|
|
359
|
+
success = True
|
|
360
|
+
return self._process_response(
|
|
361
|
+
endpoint,
|
|
362
|
+
cached,
|
|
363
|
+
validation_mode=self.config.validation_mode,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Check rate limit only when making an actual HTTP request
|
|
367
|
+
if not self._rate_limiter.should_allow_request():
|
|
368
|
+
wait_time = self._rate_limiter.get_wait_time()
|
|
369
|
+
self._handle_rate_limit(wait_time)
|
|
370
|
+
|
|
371
|
+
self._rate_limiter.record_request()
|
|
372
|
+
|
|
253
373
|
self.logger.debug(
|
|
254
374
|
f"Making request to {endpoint.name}",
|
|
255
375
|
extra={
|
|
@@ -277,6 +397,12 @@ class BaseClient:
|
|
|
277
397
|
validation_mode=self.config.validation_mode,
|
|
278
398
|
)
|
|
279
399
|
success = True
|
|
400
|
+
|
|
401
|
+
# Store in cache
|
|
402
|
+
if self._cache is not None and cache_key is not None:
|
|
403
|
+
ttl = self._get_cache_ttl(endpoint.name)
|
|
404
|
+
self._cache_set(endpoint.name, cache_key, data, ttl)
|
|
405
|
+
|
|
280
406
|
return result
|
|
281
407
|
finally:
|
|
282
408
|
response.close()
|
|
@@ -697,32 +823,9 @@ class BaseClient:
|
|
|
697
823
|
Returns:
|
|
698
824
|
Either a single Pydantic model of type T or a list of T.
|
|
699
825
|
"""
|
|
700
|
-
|
|
701
|
-
if not await self._async_rate_limiter.should_allow_request():
|
|
702
|
-
wait_time = self._async_rate_limiter.get_wait_time()
|
|
703
|
-
current_count = _rate_limit_retry_count.get() + 1
|
|
704
|
-
_rate_limit_retry_count.set(current_count)
|
|
705
|
-
|
|
706
|
-
if current_count > self.max_rate_limit_retries:
|
|
707
|
-
_rate_limit_retry_count.set(0)
|
|
708
|
-
raise RateLimitError(
|
|
709
|
-
f"Rate limit exceeded after "
|
|
710
|
-
f"{self.max_rate_limit_retries} retries. "
|
|
711
|
-
f"Please wait {wait_time:.1f} seconds",
|
|
712
|
-
retry_after=wait_time,
|
|
713
|
-
)
|
|
714
|
-
|
|
715
|
-
self.logger.warning(
|
|
716
|
-
f"Rate limit reached "
|
|
717
|
-
f"(attempt {current_count}/"
|
|
718
|
-
f"{self.max_rate_limit_retries}), "
|
|
719
|
-
f"waiting {wait_time:.1f} seconds before retrying"
|
|
720
|
-
)
|
|
721
|
-
await asyncio.sleep(wait_time)
|
|
826
|
+
force_refresh = kwargs.pop("force_refresh", False)
|
|
722
827
|
|
|
723
828
|
try:
|
|
724
|
-
await self._async_rate_limiter.record_request()
|
|
725
|
-
|
|
726
829
|
# Validate and process parameters
|
|
727
830
|
validated_params = endpoint.validate_params(
|
|
728
831
|
kwargs,
|
|
@@ -736,6 +839,52 @@ class BaseClient:
|
|
|
736
839
|
query_params = endpoint.get_query_params(validated_params)
|
|
737
840
|
query_params["apikey"] = self.config.api_key
|
|
738
841
|
|
|
842
|
+
# Check cache before rate limiting — cache hits are free
|
|
843
|
+
cache_key: str | None = None
|
|
844
|
+
if (
|
|
845
|
+
self._cache is not None
|
|
846
|
+
and not force_refresh
|
|
847
|
+
and endpoint.response_model is not bytes
|
|
848
|
+
):
|
|
849
|
+
cache_key = self._build_cache_key(endpoint.name, query_params)
|
|
850
|
+
cached = await self._cache_aget(endpoint.name, cache_key)
|
|
851
|
+
if cached is not None:
|
|
852
|
+
self.logger.debug(
|
|
853
|
+
"Cache hit for %s",
|
|
854
|
+
endpoint.name,
|
|
855
|
+
extra={"endpoint": endpoint.name, "cache_key": cache_key},
|
|
856
|
+
)
|
|
857
|
+
return self._process_response(
|
|
858
|
+
endpoint,
|
|
859
|
+
cached,
|
|
860
|
+
validation_mode=self.config.validation_mode,
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
# Check rate limit only when making an actual HTTP request
|
|
864
|
+
if not await self._async_rate_limiter.should_allow_request():
|
|
865
|
+
wait_time = self._async_rate_limiter.get_wait_time()
|
|
866
|
+
current_count = _rate_limit_retry_count.get() + 1
|
|
867
|
+
_rate_limit_retry_count.set(current_count)
|
|
868
|
+
|
|
869
|
+
if current_count > self.max_rate_limit_retries:
|
|
870
|
+
_rate_limit_retry_count.set(0)
|
|
871
|
+
raise RateLimitError(
|
|
872
|
+
f"Rate limit exceeded after "
|
|
873
|
+
f"{self.max_rate_limit_retries} retries. "
|
|
874
|
+
f"Please wait {wait_time:.1f} seconds",
|
|
875
|
+
retry_after=wait_time,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
self.logger.warning(
|
|
879
|
+
f"Rate limit reached "
|
|
880
|
+
f"(attempt {current_count}/"
|
|
881
|
+
f"{self.max_rate_limit_retries}), "
|
|
882
|
+
f"waiting {wait_time:.1f} seconds before retrying"
|
|
883
|
+
)
|
|
884
|
+
await asyncio.sleep(wait_time)
|
|
885
|
+
|
|
886
|
+
await self._async_rate_limiter.record_request()
|
|
887
|
+
|
|
739
888
|
self.logger.debug(
|
|
740
889
|
f"Making async request to {endpoint.name}",
|
|
741
890
|
extra={
|
|
@@ -757,11 +906,18 @@ class BaseClient:
|
|
|
757
906
|
data = response.content
|
|
758
907
|
else:
|
|
759
908
|
data = self.handle_response(endpoint, response)
|
|
760
|
-
|
|
909
|
+
result = self._process_response(
|
|
761
910
|
endpoint,
|
|
762
911
|
data,
|
|
763
912
|
validation_mode=self.config.validation_mode,
|
|
764
913
|
)
|
|
914
|
+
|
|
915
|
+
# Store in cache
|
|
916
|
+
if self._cache is not None and cache_key is not None:
|
|
917
|
+
ttl = self._get_cache_ttl(endpoint.name)
|
|
918
|
+
await self._cache_aset(endpoint.name, cache_key, data, ttl)
|
|
919
|
+
|
|
920
|
+
return result
|
|
765
921
|
finally:
|
|
766
922
|
await response.aclose()
|
|
767
923
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Response caching for FMP Data API client.
|
|
2
|
+
|
|
3
|
+
Provides pluggable cache backends (memory, file, redis) to reduce
|
|
4
|
+
redundant API calls and improve latency for repeated queries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fmp_data.cache.base import CacheBackend
|
|
10
|
+
from fmp_data.cache.config import CacheConfig
|
|
11
|
+
from fmp_data.cache.file import FileCache
|
|
12
|
+
from fmp_data.cache.memory import MemoryCache
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CacheBackend",
|
|
16
|
+
"CacheConfig",
|
|
17
|
+
"FileCache",
|
|
18
|
+
"MemoryCache",
|
|
19
|
+
"create_backend",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_backend(config: CacheConfig) -> CacheBackend:
|
|
24
|
+
"""Instantiate a cache backend from configuration.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config: Cache configuration
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A ready-to-use CacheBackend instance
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If the backend type is unknown
|
|
34
|
+
ImportError: If redis backend is requested but redis is not installed
|
|
35
|
+
"""
|
|
36
|
+
if config.backend == "memory":
|
|
37
|
+
return MemoryCache(default_ttl=config.default_ttl)
|
|
38
|
+
if config.backend == "file":
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
cache_dir = config.cache_dir or Path.home() / ".cache" / "fmp-data"
|
|
42
|
+
return FileCache(cache_dir=cache_dir, default_ttl=config.default_ttl)
|
|
43
|
+
if config.backend == "redis":
|
|
44
|
+
from fmp_data.cache.redis_backend import RedisCache
|
|
45
|
+
|
|
46
|
+
redis_url = config.redis_url or "redis://localhost:6379/0"
|
|
47
|
+
return RedisCache(redis_url=redis_url, default_ttl=config.default_ttl)
|
|
48
|
+
raise ValueError(f"Unknown cache backend: {config.backend!r}")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Abstract cache backend interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CacheBackend(ABC):
|
|
11
|
+
"""Abstract base class for cache backends."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def get(self, key: str) -> Any | None:
|
|
15
|
+
"""Retrieve a value from the cache.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
key: Cache key
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Cached value or None if not found / expired
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
26
|
+
"""Store a value in the cache.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
key: Cache key
|
|
30
|
+
value: Value to cache
|
|
31
|
+
ttl: Time-to-live in seconds (None = use backend default)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def delete(self, key: str) -> None:
|
|
36
|
+
"""Remove a key from the cache."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def clear(self) -> None:
|
|
40
|
+
"""Remove all entries from the cache."""
|
|
41
|
+
|
|
42
|
+
async def aget(self, key: str) -> Any | None:
|
|
43
|
+
"""Async variant of get. Offloads to a thread by default."""
|
|
44
|
+
return await asyncio.to_thread(self.get, key)
|
|
45
|
+
|
|
46
|
+
async def aset(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
47
|
+
"""Async variant of set. Offloads to a thread by default."""
|
|
48
|
+
await asyncio.to_thread(self.set, key, value, ttl)
|
|
49
|
+
|
|
50
|
+
async def adelete(self, key: str) -> None:
|
|
51
|
+
"""Async variant of delete. Offloads to a thread by default."""
|
|
52
|
+
await asyncio.to_thread(self.delete, key)
|
|
53
|
+
|
|
54
|
+
async def aclear(self) -> None:
|
|
55
|
+
"""Async variant of clear. Offloads to a thread by default."""
|
|
56
|
+
await asyncio.to_thread(self.clear)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Cache configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CacheConfig(BaseModel):
|
|
13
|
+
"""Configuration for the response cache."""
|
|
14
|
+
|
|
15
|
+
enabled: bool = Field(default=True, description="Whether caching is enabled")
|
|
16
|
+
backend: Literal["memory", "file", "redis"] = Field(
|
|
17
|
+
default="memory", description="Cache backend type"
|
|
18
|
+
)
|
|
19
|
+
default_ttl: int = Field(default=300, gt=0, description="Default TTL in seconds")
|
|
20
|
+
ttl_overrides: dict[str, int] = Field(
|
|
21
|
+
default_factory=dict,
|
|
22
|
+
description="Per-endpoint TTL overrides (endpoint_name -> seconds)",
|
|
23
|
+
)
|
|
24
|
+
cache_dir: Path | None = Field(
|
|
25
|
+
default=None, description="Directory for file-based cache"
|
|
26
|
+
)
|
|
27
|
+
redis_url: str | None = Field(default=None, description="Redis connection URL")
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_env(cls) -> CacheConfig | None:
|
|
31
|
+
"""Create cache config from environment variables.
|
|
32
|
+
|
|
33
|
+
Returns None if FMP_CACHE_ENABLED is not set or is 'false'.
|
|
34
|
+
"""
|
|
35
|
+
enabled_str = os.getenv("FMP_CACHE_ENABLED", "").lower()
|
|
36
|
+
if enabled_str not in ("true", "1", "yes"):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
cache_dir_str = os.getenv("FMP_CACHE_DIR")
|
|
40
|
+
cache_dir = Path(cache_dir_str) if cache_dir_str else None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
ttl = int(os.getenv("FMP_CACHE_TTL", "300"))
|
|
44
|
+
ttl = max(1, ttl)
|
|
45
|
+
except (ValueError, TypeError):
|
|
46
|
+
ttl = 300
|
|
47
|
+
|
|
48
|
+
backend_str = os.getenv("FMP_CACHE_BACKEND", "memory")
|
|
49
|
+
if backend_str not in ("memory", "file", "redis"):
|
|
50
|
+
backend_str = "memory"
|
|
51
|
+
|
|
52
|
+
return cls(
|
|
53
|
+
enabled=True,
|
|
54
|
+
backend=backend_str,
|
|
55
|
+
default_ttl=ttl,
|
|
56
|
+
cache_dir=cache_dir,
|
|
57
|
+
redis_url=os.getenv("FMP_CACHE_REDIS_URL"),
|
|
58
|
+
)
|