| import functools as ft |
| import logging |
| import os |
|
|
| |
| |
| |
| |
| import os as _os |
| import sys |
| from pathlib import Path |
|
|
| from dotenv import load_dotenv |
|
|
| |
| if not _os.environ.get("PYTEST_CURRENT_TEST") and not _os.environ.get("PYTEST_RUNNING"): |
| load_dotenv() |
| del _os |
|
|
|
|
| from pydantic import Field |
| from pydantic_settings import BaseSettings, SettingsConfigDict |
|
|
|
|
| class MCPServerSettings(BaseSettings): |
| """Configuration settings for MCP server.""" |
|
|
| port: int = Field( |
| default=8000, |
| description="Port for the MCP server", |
| ) |
| transport: str = Field( |
| default="http", |
| description="Transport for the MCP server", |
| ) |
| log_file: str | None = Field( |
| default=None, |
| description="Path to log file. If None, logs go to stderr/stdout.", |
| ) |
| log_level: str = Field( |
| default="INFO", |
| description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", |
| ) |
| charts_api_url: str | None = Field( |
| default=None, |
| description="URL for external charts API to store Vega-Lite specs (e.g. https://.../api/v1/charts). When set, viz specs are POSTed here instead of saving to static.", |
| ) |
| charts_api_token: str | None = Field( |
| default=None, |
| description="Optional bearer token for external charts API (Authorization header).", |
| ) |
| env: str | None = Field( |
| default=None, |
| description="Deployment environment (e.g. local, dev, staging, prod). Azure App Insights " |
| "and OpenTelemetry export are disabled when set to 'local' unless you set " |
| "OTEL_EXPORTER_OTLP_ENDPOINT (or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) and/or " |
| "MCP_OTEL_CONSOLE=1 for local trace export; see data360.otel_setup.", |
| ) |
| azure_connection_string: str | None = Field( |
| default=None, |
| description="Azure Application Insights connection string. If unset, falls back to APPLICATIONINSIGHTS_CONNECTION_STRING env var.", |
| ) |
| readiness_enabled: bool = Field( |
| default=True, |
| description="When false, GET /ready returns 200 with readiness_checks=disabled (no dependency probes).", |
| ) |
| health_check_timeout: float = Field( |
| default=5.0, |
| description="Per-check timeout in seconds for GET /ready outbound probes.", |
| ) |
|
|
|
|
| model_config = SettingsConfigDict(env_prefix="MCP_") |
|
|
|
|
| class Data360Settings(BaseSettings): |
| """Configuration settings for Data360 API integration.""" |
|
|
| api_base_url: str = Field( |
| default="https://data360api.worldbank.org", |
| description="Base URL for the Data360 API", |
| ) |
| codelist_api_base_url: str = Field( |
| default="https://extdataportal.worldbank.org/api/data360/metadata/codelist", |
| description=( |
| "URL for the unified Data360 codelist API. Returns all dimension codelists " |
| "(COMP_BREAKDOWN, UNIT_MEASURE, SEX, AGE, URBANISATION, FREQ, REF_AREA, …) " |
| "in a single call. Used by scripts/build_extdataportal_codelists.py to regenerate " |
| "the bundled src/data360/extdataportal_codelists.json." |
| ), |
| ) |
| search_url: str | None = Field( |
| default=None, |
| description="URL for search endpoint (defaults to {api_base_url}/data360/portal/v1/public_data360_search)", |
| ) |
| metadata_url: str | None = Field( |
| default=None, |
| description="URL for metadata endpoint (defaults to {api_base_url}/data360/metadata)", |
| ) |
| disaggregation_url: str | None = Field( |
| default=None, |
| description="URL for disaggregation endpoint (defaults to {api_base_url}/data360/disaggregation)", |
| ) |
| dimensions_url: str | None = Field( |
| default=None, |
| description="URL for dimensions endpoint (defaults to {api_base_url}/data360/portal/v1/dimensions)", |
| ) |
| data_url: str | None = Field( |
| default=None, |
| description="URL for data endpoint (defaults to {api_base_url}/data)", |
| ) |
| metadata_search_fields: list[str] = Field( |
| default=[ |
| "series_description/idno", |
| "series_description/name", |
| "series_description/database_id", |
| "series_description/definition_long", |
| "series_description/methodology", |
| "series_description/limitation", |
| "series_description/relevance", |
| "series_description/aggregation_method", |
| ] |
| ) |
|
|
| |
| data_obs_confidentiality_levels: list[str] = Field( |
| default=[ |
| "PU", |
| |
| |
| |
| ], |
| description="Confidentiality levels to limit the data observations to", |
| ) |
|
|
| model_config = SettingsConfigDict(env_prefix="DATA360_") |
|
|
| @property |
| def api_url(self) -> str: |
| """Base path for Data360 HTTP APIs: ``{api_base_url}/data360`` (no trailing slash).""" |
| return f"{self.api_base_url.rstrip('/')}/data360" |
|
|
|
|
| @ft.cache |
| def get_data360_settings() -> Data360Settings: |
| """Get cached Data360 settings instance.""" |
| return Data360Settings() |
|
|
|
|
| @ft.cache |
| def get_mcp_server_settings() -> MCPServerSettings: |
| """Get cached MCP server settings instance.""" |
| return MCPServerSettings() |
|
|
|
|
| def setup_logging( |
| log_file: str | None = None, |
| log_level: str = "INFO", |
| env: str | None = None, |
| azure_connection_string: str | None = None, |
| ) -> None: |
| """Configure logging to write to a file and/or console, and optionally Azure App Insights. |
| |
| Args: |
| log_file: Path to log file. If None, logs only go to stderr. |
| log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
| env: Deployment environment. Azure handler is skipped when 'local'. |
| azure_connection_string: Azure App Insights connection string. Falls back to |
| APPLICATIONINSIGHTS_CONNECTION_STRING env var if not provided. |
| """ |
| |
| numeric_level = getattr(logging, log_level.upper(), logging.INFO) |
|
|
| |
| formatter = logging.Formatter( |
| fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s", |
| datefmt="%Y-%m-%d %H:%M:%S", |
| ) |
|
|
| |
| root_logger = logging.getLogger() |
| root_logger.setLevel(numeric_level) |
|
|
| |
| root_logger.handlers.clear() |
|
|
| |
| console_handler = logging.StreamHandler(sys.stderr) |
| console_handler.setLevel(numeric_level) |
| console_handler.setFormatter(formatter) |
| root_logger.addHandler(console_handler) |
|
|
| |
| if log_file: |
| log_path = Path(log_file) |
| |
| log_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| file_handler = logging.FileHandler(log_path, encoding="utf-8") |
| file_handler.setLevel(numeric_level) |
| file_handler.setFormatter(formatter) |
| root_logger.addHandler(file_handler) |
|
|
| |
| effective_connection_string = azure_connection_string or os.environ.get( |
| "APPLICATIONINSIGHTS_CONNECTION_STRING" |
| ) |
| if env != "local" and effective_connection_string: |
| try: |
| from opencensus.ext.azure.log_exporter import ( |
| AzureLogHandler, |
| ) |
|
|
| def _callback(_): |
| return True |
|
|
| azure_handler = AzureLogHandler( |
| connection_string=effective_connection_string |
| ) |
| azure_handler.setLevel(numeric_level) |
| azure_handler.add_telemetry_processor(_callback) |
| root_logger.addHandler(azure_handler) |
| root_logger.info("Azure App Insights logging enabled (env=%s).", env) |
| except ImportError: |
| root_logger.warning( |
| "opencensus-ext-azure not installed; skipping Azure App Insights handler." |
| ) |
|
|