diff --git a/pyproject.toml b/pyproject.toml index 650c4dc..57fce8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,11 @@ classifiers = [ ] authors = [{ name = "vvcb" }, { name = "shen1802" }, { name = "nikomoegre" }] requires-python = ">=3.13" -dependencies = ["ibis-framework[duckdb]>=10.5.0", "mcp[cli]>=1.6.0"] +dependencies = [ + "ibis-framework[duckdb]>=10.5.0", + "langfuse>=3.5.2", + "mcp[cli]>=1.6.0", +] urls = { Documentation = "https://fastomop.github.io/omcp/", Repository = "https://github.com/fastomop/omcp" } [project.scripts] diff --git a/sample.env b/sample.env index 5fc3cd2..75fbe31 100644 --- a/sample.env +++ b/sample.env @@ -1,47 +1,110 @@ -# Database Configuration +# OMOP MCP Server Configuration Template +# Copy this file to .env and modify the values according to your setup + +# ============================================================================ +# LOGGING AND OBSERVABILITY CONFIGURATION +# ============================================================================ + +# Enable/disable file logging (default: true) +# Set to false to disable all logging output +ENABLE_LOGGING=true +DEBUG=false +LOG_FILE=omcp.log + +# Enable/disable Langfuse observability and tracing (default: true) +# Set to false to disable Langfuse integration +ENABLE_LANGFUSE=true + + +# ============================================================================ +# LANGFUSE CONFIGURATION +# ============================================================================ + +# Langfuse public and secret keys +# Get these from your Langfuse project settings +LANGFUSE_PUBLIC_KEY="pk-lf-your-public-key-here" +LANGFUSE_SECRET_KEY="sk-lf-your-secret-key-here" + +# Langfuse host URL +# Use https://cloud.langfuse.com for Langfuse Cloud +# Use http://localhost:3000 for self-hosted Langfuse +LANGFUSE_HOST="https://cloud.langfuse.com" + +# Trace context file for distributed tracing (optional) +# Used for linking traces between fastomop and omcp across processes +# Default: Uses system temp directory (e.g., /tmp/ on Unix, %TEMP% on Windows) +# Only set this if you need a custom location or for cross-service coordination +#LANGFUSE_TRACE_CONTEXT_FILE=/tmp/.fastomop_langfuse_trace_context.json + +# ============================================================================ +# DATABASE CONFIGURATION +# ============================================================================ + # Supported database types: duckdb, postgres DB_TYPE=duckdb -#DB_TYPE=postgres - -# DuckDB Configuration (when DB_TYPE=duckdb) -# Database Path (for DuckDB) -DB_PATH=/path/to/omcp/synthetic_data/synthea.duckdb # Database Read-Only Mode (recommended for production) # Set to true to prevent accidental database modifications DB_READ_ONLY=true -# PostgreSQL Configuration (when DB_TYPE=postgres) -#DB_USERNAME=your_username -#DB_PASSWORD=your_password -#DB_HOST=localhost -#DB_PORT=5432 -#DB_DATABASE=your_database_name +# ============================================================================ +# DUCKDB CONFIGURATION (when DB_TYPE=duckdb) +# ============================================================================ + +# Path to your DuckDB database file +DB_PATH=/path/to/your/omop.duckdb + +# ============================================================================ +# POSTGRESQL CONFIGURATION (when DB_TYPE=postgres) +# ============================================================================ + +# PostgreSQL connection details +DB_USERNAME=your_username +DB_PASSWORD=your_password +DB_HOST=localhost +DB_PORT=5432 +DB_DATABASE=your_database_name + +# ============================================================================ +# OMOP SCHEMA CONFIGURATION +# ============================================================================ + +# Schema containing OMOP CDM tables +CDM_SCHEMA=cdm -# OMOP Schema Configuration -CDM_SCHEMA=base # Schema containing OMOP CDM tables -VOCAB_SCHEMA=base # Schema containing vocabulary tables +# Schema containing vocabulary tables (can be same as CDM_SCHEMA) +VOCAB_SCHEMA=vocab + +# ============================================================================ +# MCP SERVER CONFIGURATION +# ============================================================================ -# MCP Server Configuration (optional) # Transport type: # - stdio: For direct integration with MCP clients (Claude Desktop, etc.) # - sse: For web-based clients and HTTP API access MCP_TRANSPORT=stdio # Host and port configuration (required for SSE transport, ignored for stdio) -#MCP_HOST=localhost -#MCP_PORT=8080 +MCP_HOST=localhost +MCP_PORT=8080 -# Example Configurations: +# ============================================================================ +# EXAMPLE CONFIGURATIONS +# ============================================================================ -# DuckDB Example (Local file database) +# Example 1: DuckDB with full logging +#ENABLE_LOGGING=true +#ENABLE_LANGFUSE=true #DB_TYPE=duckdb #DB_PATH=/Users/username/data/omop.duckdb #DB_READ_ONLY=true #CDM_SCHEMA=cdm #VOCAB_SCHEMA=vocab +#MCP_TRANSPORT=stdio -# PostgreSQL Example (Remote database) +# Example 2: PostgreSQL with minimal logging +#ENABLE_LOGGING=true +#ENABLE_LANGFUSE=false #DB_TYPE=postgres #DB_USERNAME=omop_user #DB_PASSWORD=secure_password @@ -51,3 +114,16 @@ MCP_TRANSPORT=stdio #DB_READ_ONLY=true #CDM_SCHEMA=cdm_531 #VOCAB_SCHEMA=vocab +#MCP_TRANSPORT=sse +#MCP_HOST=0.0.0.0 +#MCP_PORT=8080 + +# Example 3: Development setup with no logging +#ENABLE_LOGGING=false +#ENABLE_LANGFUSE=false +#DB_TYPE=duckdb +#DB_PATH=/tmp/test.duckdb +#DB_READ_ONLY=false +#CDM_SCHEMA=main +#VOCAB_SCHEMA=main +#MCP_TRANSPORT=stdio diff --git a/src/omcp/config.py b/src/omcp/config.py index e69de29..cd9b888 100644 --- a/src/omcp/config.py +++ b/src/omcp/config.py @@ -0,0 +1,79 @@ +import os +import logging +from pathlib import Path +from dotenv import load_dotenv +from langfuse import Langfuse +from langfuse import observe + +load_dotenv() + +# Global configuration variables +ENABLE_LOGGING = os.environ.get("ENABLE_LOGGING", "true").lower() == "true" +ENABLE_LANGFUSE = os.environ.get("ENABLE_LANGFUSE", "true").lower() == "true" + + +def setup_logging(): + """Set up simplified logging configuration.""" + logger = logging.getLogger("omcp") + + if not ENABLE_LOGGING: + logger.addHandler(logging.NullHandler()) + return logger + + # Clear existing handlers + logger.handlers.clear() + logger.setLevel(logging.INFO) + + # Get log file path (simplified) + log_file = os.environ.get("LOG_FILE", "omcp.log") + if not os.path.isabs(log_file): + # Default to home directory logs folder + log_dir = Path.home() / ".omcp" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / log_file + + try: + # File handler + file_handler = logging.FileHandler(log_file) + file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + ) + logger.addHandler(file_handler) + + # Console handler for development + if os.environ.get("DEBUG", "false").lower() == "true": + console_handler = logging.StreamHandler() + console_handler.setFormatter( + logging.Formatter("%(levelname)s - %(message)s") + ) + logger.addHandler(console_handler) + + logger.info(f"Logging initialized - file: {log_file}") + + except Exception as e: + # Fallback to console only + console_handler = logging.StreamHandler() + logger.addHandler(console_handler) + logger.warning(f"Could not create log file, using console: {e}") + + return logger + + +# Initialize logger +logger = setup_logging() + +# Initialize Langfuse (simplified) +langfuse = None +if ENABLE_LANGFUSE: + try: + langfuse = Langfuse( + public_key=os.environ.get("LANGFUSE_PUBLIC_KEY"), + secret_key=os.environ.get("LANGFUSE_SECRET_KEY"), + host=os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com"), + ) + logger.info("Langfuse initialized") + except Exception as e: + logger.error(f"Langfuse initialization failed: {e}") + +# Export observe decorator +observe = observe if langfuse else lambda *args, **kwargs: lambda func: func diff --git a/src/omcp/exceptions.py b/src/omcp/exceptions.py index 551d891..edb5423 100644 --- a/src/omcp/exceptions.py +++ b/src/omcp/exceptions.py @@ -1,61 +1,375 @@ -# Define more specific error classes for better error handling +""" +OMCP Exception Module + +This module defines custom exception classes for better error handling in the OMCP +(OMOP Model Context Protocol) server. All exceptions inherit from a base QueryError +class and provide specific error types for different validation and execution scenarios. +""" + +from typing import List, Optional, Dict, Any class QueryError(Exception): - """Base exception raised for errors in the query execution""" + """ + Base exception raised for errors in query execution and validation. + + This is the parent class for all OMCP-specific exceptions. It provides + a standardized way to handle errors with additional context. + """ - pass + def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): + """ + Initialize the QueryError. + + Args: + message: Human-readable error message + details: Optional dictionary containing additional error context + """ + super().__init__(message) + self.message = message + self.details = details or {} + + def __str__(self) -> str: + """Return a string representation of the error.""" + if self.details: + detail_str = ", ".join(f"{k}: {v}" for k, v in self.details.items()) + return f"{self.message} (Details: {detail_str})" + return self.message class AmbiguousReferenceError(QueryError): - """Exception raised when a column reference is ambiguous""" + """ + Exception raised when a column reference is ambiguous. + + This occurs when a column name could refer to multiple tables in a query + without proper table qualification. + """ - pass + def __init__( + self, + message: str, + column_name: Optional[str] = None, + table_candidates: Optional[List[str]] = None, + ): + """ + Initialize the AmbiguousReferenceError. + + Args: + message: Error message + column_name: The ambiguous column name + table_candidates: List of tables that contain the column + """ + details = {} + if column_name: + details["column_name"] = column_name + if table_candidates: + details["table_candidates"] = table_candidates + super().__init__(message, details) class ColumnNotFoundError(QueryError): - """Exception raised when a column referenced in the query doesn't exist""" + """ + Exception raised when a column referenced in the query doesn't exist. + + This error occurs when a query references a column that is not present + in any of the tables being queried. + """ + + def __init__( + self, + message: str, + column_name: Optional[str] = None, + table_name: Optional[str] = None, + ): + """ + Initialize the ColumnNotFoundError. - pass + Args: + message: Error message + column_name: The missing column name + table_name: The table where the column was expected + """ + details = {} + if column_name: + details["column_name"] = column_name + if table_name: + details["table_name"] = table_name + super().__init__(message, details) class EmptyQueryError(QueryError): - """Exception raised when the query is empty""" + """ + Exception raised when the query is empty or contains only whitespace. - pass + This error prevents execution of empty queries which could cause + unexpected behavior. + """ + + def __init__(self, message: str = "Query cannot be empty"): + """Initialize the EmptyQueryError.""" + super().__init__(message) class NotSelectQueryError(QueryError): - """Exception raised when a non-SELECT query is attempted""" + """ + Exception raised when a non-SELECT query is attempted. + + For security reasons, only SELECT statements are allowed in the OMCP server. + This exception is raised when other SQL statement types are detected. + """ - pass + def __init__(self, message: str, query_type: Optional[str] = None): + """ + Initialize the NotSelectQueryError. + + Args: + message: Error message + query_type: The type of query that was attempted (INSERT, UPDATE, etc.) + """ + details = {} + if query_type: + details["attempted_query_type"] = query_type + super().__init__(message, details) class SqlSyntaxError(QueryError): - """Exception raised for SQL syntax errors""" + """ + Exception raised for SQL syntax errors. + + This wraps SQL parsing errors to provide consistent error handling + within the OMCP framework. + """ + + def __init__( + self, + message: str, + line_number: Optional[int] = None, + column_number: Optional[int] = None, + ): + """ + Initialize the SqlSyntaxError. - pass + Args: + message: Error message + line_number: Line number where the syntax error occurred + column_number: Column number where the syntax error occurred + """ + details = {} + if line_number is not None: + details["line_number"] = line_number + if column_number is not None: + details["column_number"] = column_number + super().__init__(message, details) class StarNotAllowedError(QueryError): - """Exception raised when a star (*) is used in the query""" + """ + Exception raised when a star (*) is used in the query. - pass + For security and performance reasons, SELECT * queries may be + restricted in certain configurations. + """ + + def __init__(self, message: str = "SELECT * is not allowed for security reasons"): + """Initialize the StarNotAllowedError.""" + super().__init__(message) class TableNotFoundError(QueryError): - """Exception raised when a table referenced in the query doesn't exist""" + """ + Exception raised when a table referenced in the query doesn't exist. + + This occurs when a query references tables that are not part of the + OMOP CDM or are not available in the current database schema. + """ - pass + def __init__( + self, + message: str, + table_names: Optional[List[str]] = None, + schema_name: Optional[str] = None, + ): + """ + Initialize the TableNotFoundError. + + Args: + message: Error message + table_names: List of table names that were not found + schema_name: The schema where tables were expected + """ + details = {} + if table_names: + details["missing_tables"] = table_names + if schema_name: + details["schema_name"] = schema_name + super().__init__(message, details) class UnauthorizedTableError(QueryError): - """Exception raised when query attempts to access unauthorized tables""" + """ + Exception raised when query attempts to access unauthorized tables. + + This security exception prevents access to tables that have been + explicitly excluded from the allowed table list. + """ + + def __init__(self, message: str, unauthorized_tables: Optional[List[str]] = None): + """ + Initialize the UnauthorizedTableError. - pass + Args: + message: Error message + unauthorized_tables: List of unauthorized table names + """ + details = {} + if unauthorized_tables: + details["unauthorized_tables"] = unauthorized_tables + super().__init__(message, details) class UnauthorizedColumnError(QueryError): - """Exception raised when query attempts to access unauthorized columns""" + """ + Exception raised when query attempts to access unauthorized columns. + + This security exception prevents access to columns that have been + explicitly excluded, such as source_value columns or other restricted fields. + """ + + def __init__( + self, + message: str, + unauthorized_columns: Optional[List[str]] = None, + column_type: Optional[str] = None, + ): + """ + Initialize the UnauthorizedColumnError. + + Args: + message: Error message + unauthorized_columns: List of unauthorized column names + column_type: Type of restricted column (e.g., "source_value") + """ + details = {} + if unauthorized_columns: + details["unauthorized_columns"] = unauthorized_columns + if column_type: + details["column_type"] = column_type + super().__init__(message, details) + + +class DatabaseConnectionError(QueryError): + """ + Exception raised when database connection fails. + + This exception handles various database connectivity issues including + network problems, authentication failures, and configuration errors. + """ + + def __init__( + self, + message: str, + connection_string: Optional[str] = None, + error_code: Optional[str] = None, + ): + """ + Initialize the DatabaseConnectionError. + + Args: + message: Error message + connection_string: The connection string that failed (sensitive info removed) + error_code: Database-specific error code + """ + details = {} + if connection_string: + # Sanitize connection string to remove sensitive information + sanitized = ( + connection_string.split("://")[0] + "://[REDACTED]" + if "://" in connection_string + else "[REDACTED]" + ) + details["connection_type"] = sanitized + if error_code: + details["error_code"] = error_code + super().__init__(message, details) + + +class ValidationError(QueryError): + """ + Exception raised for general validation errors. + + This is a catch-all exception for validation issues that don't fit + into more specific categories. + """ + + def __init__( + self, + message: str, + validation_type: Optional[str] = None, + failed_checks: Optional[List[str]] = None, + ): + """ + Initialize the ValidationError. + + Args: + message: Error message + validation_type: Type of validation that failed + failed_checks: List of specific validation checks that failed + """ + details = {} + if validation_type: + details["validation_type"] = validation_type + if failed_checks: + details["failed_checks"] = failed_checks + super().__init__(message, details) + + +class QueryTimeoutError(QueryError): + """ + Exception raised when a query execution times out. + + This exception is raised when query execution exceeds the configured + timeout limit, helping to prevent resource exhaustion. + """ + + def __init__(self, message: str, timeout_seconds: Optional[float] = None): + """ + Initialize the QueryTimeoutError. + + Args: + message: Error message + timeout_seconds: The timeout limit that was exceeded + """ + details = {} + if timeout_seconds is not None: + details["timeout_seconds"] = timeout_seconds + super().__init__(message, details) + + +class RowLimitExceededError(QueryError): + """ + Exception raised when query results exceed the configured row limit. + + This exception helps prevent memory issues and ensures reasonable + response times by limiting result set sizes. + """ + + def __init__( + self, + message: str, + row_limit: Optional[int] = None, + actual_rows: Optional[int] = None, + ): + """ + Initialize the RowLimitExceededError. - pass + Args: + message: Error message + row_limit: The configured row limit + actual_rows: The actual number of rows that would be returned + """ + details = {} + if row_limit is not None: + details["row_limit"] = row_limit + if actual_rows is not None: + details["actual_rows"] = actual_rows + super().__init__(message, details) diff --git a/src/omcp/main.py b/src/omcp/main.py index 8bafff1..4c6e1ba 100644 --- a/src/omcp/main.py +++ b/src/omcp/main.py @@ -1,16 +1,242 @@ import os import sys import signal -import logging from mcp.server.fastmcp import FastMCP import mcp from dotenv import load_dotenv, find_dotenv +from omcp.config import langfuse, logger +from omcp.trace_context import read_trace_context + +import uuid +import time +import traceback +from functools import wraps + +# OpenTelemetry context propagation +from opentelemetry.propagate import extract +from opentelemetry import context as otel_context_api + + +# --- Per-tool decorator to capture context + Langfuse trace --- +def capture_context(tool_name=None): + """ + Decorator to capture the incoming MCP tool call: + - tries to extract common context fields (messages, prompt, input, payload) + - attempts to capture any LLM prompt context if available + - starts a Langfuse per-request trace/span/generation (if enabled) + - records input/output to Langfuse (if enabled) + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + request_id = str(uuid.uuid4()) + ts = time.strftime("%d/%m/%y %H:%M:%S", time.localtime()) + + extracted = {} + + # Common names that may hold context in MCP payloads + possible_keys = ( + "payload", + "messages", + "prompt", + "input", + "tool_input", + "data", + "query", + "conversation", + "context", + "user_message", + "system_message", + "chat_history", + "request_context", + "llm_context", + "conversation_history", + ) + + # 1) Extract from kwargs + for k in possible_keys: + if k in kwargs: + extracted[k] = kwargs[k] + + # 2) Extract from args + if args: + for i, arg in enumerate(args): + arg_key = f"arg_{i}" + extracted[arg_key] = arg + + # If the argument is a dict, look for prompt-related keys + if isinstance(arg, dict): + for prompt_key in [ + "prompt", + "messages", + "conversation", + "context", + ]: + if prompt_key in arg: + extracted[f"nested_{prompt_key}"] = arg[prompt_key] + + # 3) Try to extract from the current execution context + import inspect + + frame = inspect.currentframe() + try: + # Look for any variables in calling frames that might contain prompt info + caller_frame = frame.f_back.f_back if frame and frame.f_back else None + if caller_frame: + caller_locals = caller_frame.f_locals + for var_name in ["prompt", "messages", "conversation", "request"]: + if var_name in caller_locals: + extracted[f"caller_{var_name}"] = str( + caller_locals[var_name] + )[:1000] # Truncate for safety + except Exception: + pass # Ignore frame inspection errors + finally: + del frame # Prevent reference cycles + + # 4) Capture environment variables that might contain relevant context + env_context = {} + env_keys = [ + "MCP_CLIENT_INFO", + "CONVERSATION_ID", + "SESSION_ID", + "USER_CONTEXT", + ] + for env_key in env_keys: + if os.environ.get(env_key): + env_context[env_key] = os.environ.get(env_key) + + if env_context: + extracted["environment_context"] = env_context + + # Enhanced call metadata + call_meta = { + "tool": tool_name or func.__name__, + "request_id": request_id, + "timestamp": ts, + "func_name": func.__name__, + "func_args_count": len(args), + # Truncated representations for logging + "func_args_repr": repr(args)[:1000] if args else "[]", + } + + # Start Langfuse logging for this single call (if enabled) + if langfuse: + try: + # Read trace context from shared file (propagated from fastomop) + trace_ctx = read_trace_context() + traceparent = trace_ctx.get("traceparent") + + # Prepare comprehensive input data + input_data = { + "extracted_context": extracted, + "call_metadata": call_meta, + "raw_args": [ + str(arg)[:500] for arg in args + ], # Truncated string representations + } + + # Add prompt-specific metadata if found + prompt_metadata = {} + for key, value in extracted.items(): + if any( + prompt_word in key.lower() + for prompt_word in ["prompt", "message", "conversation"] + ): + prompt_metadata[key] = value + + if prompt_metadata: + input_data["prompt_related_data"] = prompt_metadata + # Log specifically that we found prompt-related content + logger.info( + f"Captured prompt-related metadata for {call_meta['tool']}: {list(prompt_metadata.keys())}" + ) + + # Extract OpenTelemetry context from W3C Trace Context headers + # This properly propagates parent-child relationships across processes + context_token = None + if traceparent: + # Build carrier dict with W3C headers + carrier = {"traceparent": traceparent} + if trace_ctx.get("tracestate"): + carrier["tracestate"] = trace_ctx["tracestate"] + + # Extract context using OpenTelemetry propagator + extracted_context = extract(carrier) + + # Attach the extracted context to make it current + # This allows Langfuse to automatically use it for span creation + context_token = otel_context_api.attach(extracted_context) + + logger.info( + f"Linking {call_meta['tool']} to parent trace (OpenTelemetry context)" + ) + else: + # No parent context, create standalone span + logger.debug( + f"No parent trace context, creating standalone span for {call_meta['tool']}" + ) + + try: + # Use context manager for span + # Note: Using start_as_current_span for DB operations (not LLM calls) + # The span will automatically use the attached context + with langfuse.start_as_current_span( + name=call_meta["tool"] + ) as span: + # Update with input data + span.update(input=input_data) + + try: + response = func(*args, **kwargs) + # Update with output + span.update( + output={ + "response": response, + "response_type": type(response).__name__, + } + ) + return response + + except Exception as ex: + err_info = { + "error": str(ex), + "error_type": type(ex).__name__, + "traceback": traceback.format_exc(), + } + span.update(output=err_info) + logger.error( + f"Tool {call_meta['tool']} failed: {str(ex)}" + ) + raise + finally: + # Detach the context to restore the previous context + if context_token is not None: + otel_context_api.detach(context_token) + + except Exception as langfuse_error: + # If Langfuse fails for any reason, don't crash the server + logger.exception( + "Langfuse logging failed for tool %s (request %s): %s", + call_meta["tool"], + request_id, + str(langfuse_error), + ) + # Fall through to execute function without Langfuse logging + + # Execute function without Langfuse logging (if disabled or failed) + try: + response = func(*args, **kwargs) + return response + except Exception as func_error: + logger.error(f"Function execution failed: {str(func_error)}") + raise + + return wrapper + + return decorator -# Set up logging -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) # Import with fallback strategy try: @@ -102,6 +328,7 @@ def signal_handler(signum, frame): name="Get_Information_Schema", description="Get the information schema of the OMOP database.", ) +@capture_context(tool_name="Get_Information_Schema") def get_information_schema() -> mcp.types.CallToolResult: """Get the information schema of the OMOP database. @@ -114,6 +341,7 @@ def get_information_schema() -> mcp.types.CallToolResult: """ try: logger.debug("Getting information schema...") + # Note: @capture_context decorator already handles Langfuse tracing result = db.get_information_schema() logger.debug("Information schema retrieved successfully") return mcp.types.CallToolResult( @@ -137,6 +365,7 @@ def get_information_schema() -> mcp.types.CallToolResult: @mcp_app.tool( name="Select_Query", description="Execute a select query against the OMOP database." ) +@capture_context(tool_name="Select_Query") def read_query(query: str) -> mcp.types.CallToolResult: """Run a SQL query against the OMOP database. @@ -192,7 +421,11 @@ def main(): mcp_app.run(transport="stdio") elif transport_type == "sse": logger.info(f"Server will be available at http://{host}:{port}") - mcp_app.run(transport="sse", host=host, port=port) + # Add initialization delay to prevent timing issues + import time + + time.sleep(1) # Give the server time to fully initialize + mcp_app.run(transport="sse") except KeyboardInterrupt: logger.info("Server stopped by user") except Exception as e: diff --git a/src/omcp/trace_context.py b/src/omcp/trace_context.py new file mode 100644 index 0000000..1132262 --- /dev/null +++ b/src/omcp/trace_context.py @@ -0,0 +1,57 @@ +""" +Trace context sharing between fastomop and OMCP subprocess. +Uses a temporary file to receive dynamic trace context from parent process. + +Langfuse V3 Note: +- V3 uses W3C Trace Context standard via OpenTelemetry +- Uses traceparent header for proper context propagation +- Child spans automatically nest within the parent trace +""" + +import os +import tempfile +import json +from pathlib import Path +from typing import Optional, Dict + + +# Shared trace context file path (same as fastomop) +default_path = Path(tempfile.gettempdir()) / ".fastomop_langfuse_trace_context.json" +TRACE_CONTEXT_FILE = Path( + os.environ.get("LANGFUSE_TRACE_CONTEXT_FILE", str(default_path)) +) + + +def read_trace_context() -> Dict[str, Optional[str]]: + """ + Read current trace context from shared file. + + Returns W3C Trace Context headers for proper OpenTelemetry propagation. + + Returns: + Dict with traceparent, tracestate, and session_id (or None if not available) + traceparent format: version-trace_id-parent_span_id-trace_flags + Example: "00-xxxxxx-b7ad6b7169203331-01" + """ + try: + if TRACE_CONTEXT_FILE.exists(): + with open(TRACE_CONTEXT_FILE, "r") as f: + context = json.load(f) + return { + "traceparent": context.get("traceparent"), + "tracestate": context.get("tracestate"), + "session_id": context.get("session_id"), + # Backward compatibility: also read old trace_id format + "trace_id": context.get("trace_id"), + } + except Exception: + # Non-critical error, return empty context + # Don't log here to avoid noise, caller can decide how to handle None values + pass + + return { + "traceparent": None, + "tracestate": None, + "session_id": None, + "trace_id": None, + } diff --git a/uv.lock b/uv.lock index c813398..90360b4 100644 --- a/uv.lock +++ b/uv.lock @@ -124,6 +124,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 }, +] + [[package]] name = "backrefs" version = "5.8" @@ -386,6 +395,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -487,6 +508,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -823,6 +856,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/09/2032e7d15c544a0e3cd831c51d77a8ca57f7555b2e1b2922142eddb02a84/jupyterlab_server-2.27.3-py3-none-any.whl", hash = "sha256:e697488f66c3db49df675158a77b3b017520d772c6e1548c7d9bcc5df7944ee4", size = 59700 }, ] +[[package]] +name = "langfuse" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/12/8813d38f49d13c3d9d9c25a6d9545e028ca2ed96071fcbc26a5cdf498769/langfuse-3.5.2.tar.gz", hash = "sha256:bfd94f6cd768129d004da812bb85a34d81c28c23e4d4c1fb3a29b086c94d4269", size = 188047 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/c9/44189f008cac5f1bb252fb1a4f89dd3cf466f808d7f44eb56c8ed3c0319a/langfuse-3.5.2-py3-none-any.whl", hash = "sha256:51527aed5a3237410e587b04e07268382063435a4af2d53e7d9096a7b6535c94", size = 349012 }, +] + [[package]] name = "markdown" version = "3.8" @@ -1145,6 +1198,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ibis-framework", extra = ["duckdb"] }, + { name = "langfuse" }, { name = "mcp", extra = ["cli"] }, ] @@ -1176,6 +1230,7 @@ requires-dist = [ { name = "ibis-framework", extras = ["duckdb"], specifier = ">=10.5.0" }, { name = "ibis-framework", extras = ["duckdb"], marker = "extra == 'duckdb'", specifier = ">=10.5.0" }, { name = "ibis-framework", extras = ["postgres"], marker = "extra == 'postgres'", specifier = ">=10.5.0" }, + { name = "langfuse", specifier = ">=3.5.2" }, { name = "mcp", extras = ["cli"], specifier = ">=1.6.0" }, { name = "psycopg", marker = "extra == 'postgres'", specifier = ">=3.2.6" }, ] @@ -1195,6 +1250,88 @@ dev = [ { name = "ruff", specifier = ">=0.11.6" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/04/05040d7ce33a907a2a02257e601992f0cdf11c73b33f13c4492bf6c3d6d5/opentelemetry_api-1.37.0.tar.gz", hash = "sha256:540735b120355bd5112738ea53621f8d5edb35ebcd6fe21ada3ab1c61d1cd9a7", size = 64923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/48/28ed9e55dcf2f453128df738210a980e09f4e468a456fa3c763dbc8be70a/opentelemetry_api-1.37.0-py3-none-any.whl", hash = "sha256:accf2024d3e89faec14302213bc39550ec0f4095d1cf5ca688e1bfb1c8612f47", size = 65732 }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6c/10018cbcc1e6fff23aac67d7fd977c3d692dbe5f9ef9bb4db5c1268726cc/opentelemetry_exporter_otlp_proto_common-1.37.0.tar.gz", hash = "sha256:c87a1bdd9f41fdc408d9cc9367bb53f8d2602829659f2b90be9f9d79d0bfe62c", size = 20430 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/13/b4ef09837409a777f3c0af2a5b4ba9b7af34872bc43609dda0c209e4060d/opentelemetry_exporter_otlp_proto_common-1.37.0-py3-none-any.whl", hash = "sha256:53038428449c559b0c564b8d718df3314da387109c4d36bd1b94c9a641b0292e", size = 18359 }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/e3/6e320aeb24f951449e73867e53c55542bebbaf24faeee7623ef677d66736/opentelemetry_exporter_otlp_proto_http-1.37.0.tar.gz", hash = "sha256:e52e8600f1720d6de298419a802108a8f5afa63c96809ff83becb03f874e44ac", size = 17281 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/e9/70d74a664d83976556cec395d6bfedd9b85ec1498b778367d5f93e373397/opentelemetry_exporter_otlp_proto_http-1.37.0-py3-none-any.whl", hash = "sha256:54c42b39945a6cc9d9a2a33decb876eabb9547e0dcb49df090122773447f1aef", size = 19576 }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/ea/a75f36b463a36f3c5a10c0b5292c58b31dbdde74f6f905d3d0ab2313987b/opentelemetry_proto-1.37.0.tar.gz", hash = "sha256:30f5c494faf66f77faeaefa35ed4443c5edb3b0aa46dad073ed7210e1a789538", size = 46151 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/25/f89ea66c59bd7687e218361826c969443c4fa15dfe89733f3bf1e2a9e971/opentelemetry_proto-1.37.0-py3-none-any.whl", hash = "sha256:8ed8c066ae8828bbf0c39229979bdf583a126981142378a9cbe9d6fd5701c6e2", size = 72534 }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/62/2e0ca80d7fe94f0b193135375da92c640d15fe81f636658d2acf373086bc/opentelemetry_sdk-1.37.0.tar.gz", hash = "sha256:cc8e089c10953ded765b5ab5669b198bbe0af1b3f89f1007d19acd32dc46dda5", size = 170404 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/62/9f4ad6a54126fb00f7ed4bb5034964c6e4f00fcd5a905e115bd22707e20d/opentelemetry_sdk-1.37.0-py3-none-any.whl", hash = "sha256:8f3c3c22063e52475c5dbced7209495c2c16723d016d39287dfc215d1771257c", size = 131941 }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.58b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/1b/90701d91e6300d9f2fb352153fb1721ed99ed1f6ea14fa992c756016e63a/opentelemetry_semantic_conventions-0.58b0.tar.gz", hash = "sha256:6bd46f51264279c433755767bb44ad00f1c9e2367e1b42af563372c5a6fa0c25", size = 129867 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/90/68152b7465f50285d3ce2481b3aec2f82822e3f52e5152eeeaf516bab841/opentelemetry_semantic_conventions-0.58b0-py3-none-any.whl", hash = "sha256:5564905ab1458b96684db1340232729fce3b5375a06e140e8904c78e4f815b28", size = 207954 }, +] + [[package]] name = "overrides" version = "7.7.0" @@ -1352,6 +1489,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810 }, ] +[[package]] +name = "protobuf" +version = "6.32.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/98/645183ea03ab3995d29086b8bf4f7562ebd3d10c9a4b14ee3f20d47cfe50/protobuf-6.32.1-cp310-abi3-win32.whl", hash = "sha256:a8a32a84bc9f2aad712041b8b366190f71dde248926da517bde9e832e4412085", size = 424411 }, + { url = "https://files.pythonhosted.org/packages/8c/f3/6f58f841f6ebafe076cebeae33fc336e900619d34b1c93e4b5c97a81fdfa/protobuf-6.32.1-cp310-abi3-win_amd64.whl", hash = "sha256:b00a7d8c25fa471f16bc8153d0e53d6c9e827f0953f3c09aaa4331c718cae5e1", size = 435738 }, + { url = "https://files.pythonhosted.org/packages/10/56/a8a3f4e7190837139e68c7002ec749190a163af3e330f65d90309145a210/protobuf-6.32.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8c7e6eb619ffdf105ee4ab76af5a68b60a9d0f66da3ea12d1640e6d8dab7281", size = 426454 }, + { url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874 }, + { url = "https://files.pythonhosted.org/packages/5c/f6/88d77011b605ef979aace37b7703e4eefad066f7e84d935e5a696515c2dd/protobuf-6.32.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1864818300c297265c83a4982fd3169f97122c299f56a56e2445c3698d34710", size = 322013 }, + { url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289 }, +] + [[package]] name = "psutil" version = "7.0.0" @@ -2111,3 +2262,51 @@ sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf594 wheels = [ { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, ] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003 }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025 }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108 }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072 }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214 }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105 }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766 }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711 }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885 }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896 }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132 }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091 }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172 }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163 }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963 }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945 }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857 }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178 }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310 }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266 }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544 }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283 }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366 }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571 }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094 }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659 }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946 }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717 }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334 }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471 }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, +]