From 9b7b84b183f9959e8b8db23d0d9d932c0f3f4eea Mon Sep 17 00:00:00 2001 From: bwebs Date: Tue, 20 May 2025 00:40:20 -0700 Subject: [PATCH 1/2] Add embed observability feature with API and HTML container - Introduced `main.py` to define a Typer command group for embed observability. - Created `constants.py` for session length and permissions. - Implemented `create_sso_embed_url.py` for generating SSO embed URLs. - Added `embed_container.html` for the frontend iframe container. - Developed `embed_server.py` to handle API requests for SSO URL creation and event logging. --- lkr/embed/main.py | 31 +++++++ lkr/embed/observability/constants.py | 8 ++ .../observability/create_sso_embed_url.py | 53 +++++++++++ lkr/embed/observability/embed_container.html | 88 +++++++++++++++++++ lkr/embed/observability/embed_server.py | 86 ++++++++++++++++++ 5 files changed, 266 insertions(+) create mode 100644 lkr/embed/main.py create mode 100644 lkr/embed/observability/constants.py create mode 100644 lkr/embed/observability/create_sso_embed_url.py create mode 100644 lkr/embed/observability/embed_container.html create mode 100644 lkr/embed/observability/embed_server.py diff --git a/lkr/embed/main.py b/lkr/embed/main.py new file mode 100644 index 0000000..bc69d05 --- /dev/null +++ b/lkr/embed/main.py @@ -0,0 +1,31 @@ +from typing import Annotated + +import typer + +from lkr.auth_service import get_auth +from lkr.embed.observability.embed_server import run_server + +__all__ = ["group"] + +group = typer.Typer(name="embed", help="Embed commands for LookML Repository") + + +@group.command() +def observability( + ctx: typer.Context, + port: Annotated[ + int, typer.Option("--port", help="Port to run the API on", envvar="PORT") + ] = 8080, + log_event_prefix: Annotated[ + str, typer.Option("--log-event-prefix", help="Prefix to use for the log events") + ] = "lkr:embed:observability", +): + """ + Spin up an API to do embed observability. **Important:** requires a user with the `admin` role. This will create + an endpoint where you can send embed user properties to it, it will create the sso embed url, then load it up into a selenium + browser. The browser will log the user in using the sso embed url, then it will navigate to the dashboard and start the observability tests. It will log structured payloads + with metadata in each step. You can then ingest these using tools your of choice + """ + sdk = get_auth(ctx).get_current_sdk() + + run_server(sdk=sdk, port=port, log_event_prefix=log_event_prefix) diff --git a/lkr/embed/observability/constants.py b/lkr/embed/observability/constants.py new file mode 100644 index 0000000..3e50dbc --- /dev/null +++ b/lkr/embed/observability/constants.py @@ -0,0 +1,8 @@ + +MAX_SESSION_LENGTH = 2592000 + +PERMISSIONS = [ + "access_data", + "see_user_dashboards", + "see_lookml_dashboards" +] diff --git a/lkr/embed/observability/create_sso_embed_url.py b/lkr/embed/observability/create_sso_embed_url.py new file mode 100644 index 0000000..b145f6b --- /dev/null +++ b/lkr/embed/observability/create_sso_embed_url.py @@ -0,0 +1,53 @@ +from typing import Any, Dict, List, Optional, TypedDict +from uuid import uuid4 + +from looker_sdk.sdk.api40.methods import Looker40SDK +from looker_sdk.sdk.api40.models import EmbedSsoParams +from pydantic import BaseModel, Field + +from lkr.embed.observability.constants import MAX_SESSION_LENGTH, PERMISSIONS + + +class CreateSSOEmbedUrlParams(BaseModel): + external_user_id: Optional[str] = Field(description="The external user id to create the sso embed url for", default_factory=lambda: f"embed-observability-{str(uuid4())}") + external_group_id: Optional[str] = None + models: Optional[List[str]] = Field(description="The models to create the sso embed url for") + permissions: Optional[List[str]] = Field(description="The permissions to create the sso embed url for") + dashboard: Optional[str] = Field(description="The dashboard to create the sso embed url for") + user_attribute: Optional[List[str]] = Field(description="The user attributes to create the sso embed url for") + user_timezone: Optional[str] = Field(description="The user timezone to create the sso embed url for") + group_ids: Optional[List[str]] = Field(description="The group ids to create the sso embed url for") + embed_domain: Optional[str] = Field(description="The embed domain to create the sso embed url for") + + def to_embed_sso_params(self) -> EmbedSsoParams: + all_permissions = list(set(PERMISSIONS + (self.permissions or []))) + return EmbedSsoParams( + external_user_id=self.external_user_id, + external_group_id=self.external_group_id, + models=self.models, + permissions=all_permissions, + target_url=f"/embed/dashboards/{self.dashboard}", + session_length=MAX_SESSION_LENGTH, + force_logout_login=True, + first_name=None, + last_name=None, + user_timezone=None, + group_ids=None, + user_attributes=None, + embed_domain=None, + + ) +class URLResponse(TypedDict): + url: str + external_user_id: str + +def create_sso_embed_url(sdk: Looker40SDK, *, data: Dict[str, Any]) -> URLResponse: + params = CreateSSOEmbedUrlParams( + external_user_id=data.get("external_user_id"), + external_group_id=data.get("external_group_id"), + models=data.get("models"), + permissions=data.get("permissions"), + dashboard=data.get("dashboard"), + ) + sso_url = sdk.create_sso_embed_url(body=params.to_embed_sso_params()) + return dict(url=sso_url.url, external_user_id=sso_url.external_user_id) \ No newline at end of file diff --git a/lkr/embed/observability/embed_container.html b/lkr/embed/observability/embed_container.html new file mode 100644 index 0000000..4678e7a --- /dev/null +++ b/lkr/embed/observability/embed_container.html @@ -0,0 +1,88 @@ + + + + Looker Embed Container + + + + + + + \ No newline at end of file diff --git a/lkr/embed/observability/embed_server.py b/lkr/embed/observability/embed_server.py new file mode 100644 index 0000000..981dcba --- /dev/null +++ b/lkr/embed/observability/embed_server.py @@ -0,0 +1,86 @@ +import json +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +from looker_sdk.sdk.api40.methods import Looker40SDK + +from lkr.embed.observability.create_sso_embed_url import create_sso_embed_url +from lkr.logging import logger, structured_logger + + +def log_event(prefix: str, event: str, **kwargs): + logger.info(f"{prefix}:{event}", **kwargs) + +class EmbedHandler(BaseHTTPRequestHandler): + def __init__(self, *args, **kwargs): + self.sdk: Looker40SDK | None = None + super().__init__(*args, **kwargs) + + def log_message(self, format, *args): + # Override to disable default server logging + pass + + def do_GET(self): + path, *rest = self.path.split('?') + if path =='/': + # Serve the HTML file + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + html_path = Path(__file__).parent / 'embed_container.html' + with open(html_path, 'rb') as f: + self.wfile.write(f.read()) + else: + self.send_response(204) + self.end_headers() + + + def do_POST(self): + path, *rest = self.path.split('?') + if path == '/create_sso_embed_url': + if not self.sdk: + self.send_response(500) + self.end_headers() + return + else: + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + data = json.loads(post_data) + + sso_url = create_sso_embed_url(self.sdk, data=data) + self.send_response(200) + self.send_header('Content-type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(sso_url).encode('utf-8')) + if path == '/log_event': + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + event_data = json.loads(post_data) + + # # Log the event using structlog + # structured_logger.info( + # event_data['event_type'], + # timestamp=event_data['timestamp'], + # duration_ms=event_data['duration_ms'], + # dashboard_id=event_data['dashboard_id'], + # user_id=event_data['user_id'], + # **event_data['event_data'], + # ) + + self.send_response(200) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + +def run_server(*, sdk: Looker40SDK, port:int =3000, log_event_prefix="looker_embed_observability"): + class EmbedHandlerWithPrefix(EmbedHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.log_event_prefix = log_event_prefix + self.sdk = sdk + server_address = ('', port) + httpd = HTTPServer(server_address, EmbedHandlerWithPrefix) + structured_logger.info(f"{log_event_prefix}:embed_server_started", port=port, embed_domain=f"http://localhost:{port}") + httpd.serve_forever() \ No newline at end of file From d99324ece5e96c3b1a8b7224fa0a5abe8b04c7cf Mon Sep 17 00:00:00 2001 From: bwebs Date: Tue, 20 May 2025 14:28:19 -0700 Subject: [PATCH 2/2] Add new dependencies and update release workflow - Added `duckdb` and `fastmcp` as dependencies in `pyproject.toml`. - Updated `uv.lock` with new package versions and dependencies for `anyio`, `exceptiongroup`, `httpx`, and others. - Modified the release workflow to replace the version in `pyproject.toml` during the release process. - Introduced a new `mcp` command group in `main.py` for managing MCP-related functionalities. - Enhanced the `get_auth` function to handle different context types. --- .github/workflows/release.yml | 4 + lkr/auth_service.py | 25 +- lkr/logging.py | 2 +- lkr/main.py | 3 +- lkr/mcp/main.py | 639 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 + uv.lock | 261 ++++++++++++++ 7 files changed, 926 insertions(+), 10 deletions(-) create mode 100644 lkr/mcp/main.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c51a97f..184d67d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,10 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 + - name: Replace version in pyproject.toml + run: | + find . -name "pyproject.toml" -exec sed -i "s/^version = .*/version = \"${{ env.TAG }}\"/" {} + + - name: uv sync run: uv sync --frozen --no-dev diff --git a/lkr/auth_service.py b/lkr/auth_service.py index 42f7932..4461a41 100644 --- a/lkr/auth_service.py +++ b/lkr/auth_service.py @@ -23,18 +23,22 @@ __all__ = ["get_auth"] -def get_auth(ctx: typer.Context) -> Union["SqlLiteAuth", "ApiKeyAuth"]: - lkr_ctx = get(ctx, ["obj", "ctx_lkr"]) - if not lkr_ctx: - logger.error("No Looker context found") - raise typer.Exit(1) - elif lkr_ctx.use_sdk == "api_key": +def get_auth(ctx: typer.Context | LkrCtxObj) -> Union["SqlLiteAuth", "ApiKeyAuth"]: + if isinstance(ctx, LkrCtxObj): + lkr_ctx = ctx + else: + lkr_ctx: LkrCtxObj | None = get(ctx, ["obj", "ctx_lkr"]) + if not lkr_ctx: + logger.error("No Looker context found") + raise typer.Exit(1) + if lkr_ctx.use_sdk == "api_key" and lkr_ctx.api_key: logger.info("Using API key authentication") return ApiKeyAuth(lkr_ctx.api_key) else: return SqlLiteAuth(lkr_ctx) + class ApiKeyApiSettings(ApiSettings): def __init__(self, api_key: LookerApiKey): self.api_key = api_key @@ -388,7 +392,7 @@ def get_current_instance(self) -> str | None: def get_current_sdk( self, prompt_refresh_invalid_token: bool = False - ) -> Looker40SDK | None: + ) -> Looker40SDK: current_auth = self._get_current_auth() if current_auth: if not current_auth.valid_refresh_token: @@ -399,6 +403,8 @@ def get_current_sdk( else: raise InvalidRefreshTokenError(current_auth.instance_name) + + def refresh_current_token(token: Union[AccessToken, AuthToken]): current_auth.set_token(self.conn, new_token=token, commit=True) @@ -407,7 +413,10 @@ def refresh_current_token(token: Union[AccessToken, AuthToken]): new_token_callback=refresh_current_token, access_token=current_auth.to_access_token(), ) - return None + + else: + logger.error("No current instance found, please login") + raise typer.Exit(1) def delete_auth(self, instance_name: str): self.conn.execute("DELETE FROM auth WHERE instance_name = ?", (instance_name,)) diff --git a/lkr/logging.py b/lkr/logging.py index 2f1e618..1fd0dea 100644 --- a/lkr/logging.py +++ b/lkr/logging.py @@ -54,6 +54,6 @@ def set_log_level(level: LogLevel): """Set the logging level for the application.""" logger.setLevel(getattr(logging, level.value)) - structured_logger.setLevel(getattr(logging, level.value)) + logging.getLogger("lkr.structured").setLevel(getattr(logging, level.value)) # Update requests_transport logger level based on the new level requests_logger.setLevel(logging.DEBUG if level == LogLevel.DEBUG else logging.WARNING) diff --git a/lkr/main.py b/lkr/main.py index 961088b..78b925c 100644 --- a/lkr/main.py +++ b/lkr/main.py @@ -6,6 +6,7 @@ from lkr.auth.main import group as auth_group from lkr.classes import LkrCtxObj from lkr.logging import logger +from lkr.mcp.main import group as mcp_group from lkr.types import LogLevel app = typer.Typer( @@ -13,7 +14,7 @@ ) app.add_typer(auth_group, name="auth") - +app.add_typer(mcp_group, name="mcp") @app.callback() def callback( diff --git a/lkr/mcp/main.py b/lkr/mcp/main.py new file mode 100644 index 0000000..9aa4898 --- /dev/null +++ b/lkr/mcp/main.py @@ -0,0 +1,639 @@ +# server.py +import os +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Any, Callable, List, Literal, Self, Set + +import duckdb +import typer +from fastmcp import FastMCP +from looker_sdk.sdk.api40.models import ( + SqlQueryCreate, + WriteQuery, +) +from pydantic import BaseModel, Field, computed_field +from pydash import get + +from lkr.auth_service import get_auth +from lkr.classes import LkrCtxObj +from lkr.logging import logger + +__all__ = ["group"] + +current_instance: str | None = None +ctx_lkr: LkrCtxObj | None = None + + +# Create an MCP server +mcp = FastMCP("lkr:mcp") + +group = typer.Typer(name="spectacles") + + +db_path = os.path.expanduser("~/.lkr") +db_loc = Path(db_path) / "mcp_search_db" +db_loc.mkdir(exist_ok=True) + + +def get_database_search_file(prefix: str = "") -> Path: + p = db_loc / f"{prefix + '.' if prefix else ''}looker_connection_search.jsonl" + if not p.exists(): + p.touch() + return p + + +def get_connection_registry_file( + type: Literal["connection", "database", "schema", "table"], prefix: str = "" +) -> Path: + return ( + db_loc + / f"{prefix + '.' if prefix else ''}looker_connection_registry.{type}.jsonl" + ) + + +# Initialize DuckDB connection +conn = duckdb.connect(database=":memory:", read_only=False) + +# Install and load the FTS extension +conn.execute("INSTALL 'fts'") +conn.execute("LOAD 'fts'") +conn.execute("INSTALL 'json'") +conn.execute("LOAD 'json'") + + +class SpectaclesResponse(BaseModel): + success: bool + result: Any | None = None + error: str | None = None + sql: str | None = None + share_url: str | None = None + + +class SpectaclesRequest(BaseModel): + model: Annotated[ + str, + Field( + description="the model to run a test query against, you can find this by the filenames in the repository, they will end with .model.lkml. You should not pass in the .model.lkml extension.", + default="", + ), + ] + explore: Annotated[ + str, + Field( + description="the explore to run a test query against, you can find this by finding explore: {} in any file in the repository", + default="", + ), + ] + fields: Annotated[ + List[str], + Field( + description="this should be the list of fields you want to return from the test query. If the user does not provide them, use all that have changed in your current context", + default=[], + ), + ] + + +@mcp.tool() +def get_spectacles( + model: Annotated[ + str, + Field( + description="the model to run a test query against, you can find this by the filenames in the repository, they will end with .model.lkml. You should not pass in the .model.lkml extension." + ), + ], + explore: Annotated[ + str, + Field( + description="the explore to run a test query against, you can find this by finding explore: {} in any file in the repository" + ), + ], + fields: Annotated[ + List[str], + Field( + description="this should be the list of fields you want to return from the test query. If the user does not provide them, use all that have changed in your current context. The syntax of the field should be . name. The view will be the name of the view as it appears in the explore, or aliased from it with from or view_name" + ), + ], +): + """ + run a spectacles query to validate the changes to the model + """ + return [{"test": "foo"}] + req = SpectaclesRequest(model=model, explore=explore, fields=fields) + global ctx_lkr + if not ctx_lkr: + # logger.error("No Looker context found") + raise typer.Exit(1) + sdk = get_auth(ctx_lkr).get_current_sdk() + returned_sql = None + share_url = None + try: + query = sdk.create_query( + body=WriteQuery( + model=req.model, view=req.explore, fields=req.fields, limit="0" + ) + ) + if query.id is None: + raise Exception("Failed to create query") + + share_url = f"{sdk.auth.settings.base_url}/x/{query.client_id}" + sql = sdk.run_query(query_id=query.id, result_format="sql") + new_sql = f""" +SELECT * FROM ( + {sql} +) WHERE 1=2 +""" + returned_sql = new_sql + create_query = sdk.create_sql_query( + body=SqlQueryCreate( + model_name=req.model, + sql=new_sql, + ) + ) + if create_query.slug is None: + raise Exception("Failed to create sql query") + result = sdk.run_sql_query( + slug=create_query.slug, result_format="json", download="true" + ) + return SpectaclesResponse( + success=True, share_url=share_url + ) + except Exception as e: + return SpectaclesResponse( + success=False, error=str(e), share_url=share_url + ) + + +def now() -> datetime: + return datetime.now(timezone.utc) + + +class Connection(BaseModel): + connection: str + updated_at: datetime = Field(default_factory=now) + + @computed_field(return_type=str) + @property + def fully_qualified_name(self) -> str: + return self.connection + + +class Database(Connection): + database: str + + @computed_field(return_type=str) + @property + def fully_qualified_name(self) -> str: + return f"{self.connection}.{self.database}" + + +class Schema(Database): + database_schema_name: str + + @computed_field(return_type=str) + @property + def fully_qualified_name(self) -> str: + return f"{self.connection}.{self.database}.{self.database_schema_name}" + + +class Table(Schema): + database_table_name: str + + @computed_field(return_type=str) + @property + def fully_qualified_name(self) -> str: + return f"{self.connection}.{self.database}.{self.database_schema_name}.{self.database_table_name}" + + +class Row(Table): + database_column_name: str + data_type_database: str + data_type_looker: str + + @computed_field(return_type=str) + @property + def fully_qualified_name(self) -> str: + return f"{self.connection}.{self.database}.{self.database_schema_name}.{self.database_table_name}.{self.database_column_name}" + + def append(self, base_url: str) -> None: + with open(get_database_search_file(base_url), "a") as f: + f.write(self.model_dump_json() + "\n") + + def exists(self, base_url: str) -> bool: + columns = conn.execute( + f"SELECT * FROM read_json_auto('{get_database_search_file(base_url)}') WHERE fully_qualified_name = '{self.fully_qualified_name}'" + ).fetchall() + return len(columns) > 0 + + +def ok[T](func: Callable[[], T], default: T) -> T: + try: + return func() + except Exception: + # logger.error(f"Error in {func.__name__}: {str(e)}") + return default + + +def conn_registry_path( + type: Literal["connection", "database", "schema", "table"], prefix: str = "" +) -> Path: + file_loc = get_connection_registry_file(type, prefix) + if not file_loc.exists(): + file_loc.touch() + return file_loc + + +class ConnectionRegistry(BaseModel): + connections: Set[str] + databases: Set[str] + schemas: Set[str] + tables: Set[str] + prefix: str = "" + + def append(self, obj: Connection | Database | Schema | Table) -> None: + if isinstance(obj, Table): + self.tables.add(obj.fully_qualified_name) + with open(get_connection_registry_file("table", self.prefix), "a") as f: + f.write(obj.model_dump_json() + "\n") + elif isinstance(obj, Schema): + self.schemas.add(obj.fully_qualified_name) + with open(get_connection_registry_file("schema", self.prefix), "a") as f: + f.write(obj.model_dump_json() + "\n") + elif isinstance(obj, Database): + self.databases.add(obj.fully_qualified_name) + with open(get_connection_registry_file("database", self.prefix), "a") as f: + f.write(obj.model_dump_json() + "\n") + elif isinstance(obj, Connection): + self.connections.add(obj.fully_qualified_name) + with open( + get_connection_registry_file("connection", self.prefix), "a" + ) as f: + f.write(obj.model_dump_json() + "\n") + + def check( + self, type: Literal["connection", "database", "schema", "table"], value: str + ) -> bool: + if type == "connection": + return value in self.connections + elif type == "database": + return value in self.databases + elif type == "schema": + return value in self.schemas + elif type == "table": + return value in self.tables + else: + raise ValueError(f"Invalid type: {type}") + + def load_connections(self, dt_filter: datetime | None = None) -> None: + file = conn_registry_path("connection", self.prefix) + # logger.debug(f"Loading connections from {file}") + sql = f"SELECT connection FROM read_json_auto('{file}')" + if dt_filter: + sql += f" WHERE updated_at > '{dt_filter.isoformat()}'" + try: + results = conn.execute(sql).fetchall() + for row in results: + connection = Connection(connection=row[0]) + self.connections.add(connection.fully_qualified_name) + except Exception: + # logger.error(f"Error loading connections from {file}: {str(e)}") + return + + def load_databases(self, dt_filter: datetime | None = None) -> None: + file = conn_registry_path("database", self.prefix) + sql = f"SELECT connection, database FROM read_json_auto('{file}')" + if dt_filter: + sql += f" WHERE updated_at > '{dt_filter.isoformat()}'" + try: + results = conn.execute(sql).fetchall() + for row in results: + database = Database(connection=row[0], database=row[1]) + self.databases.add(database.fully_qualified_name) + except Exception: + # logger.error(f"Error loading databases from {file}: {str(e)}") + return + + def load_schemas(self, dt_filter: datetime | None = None) -> None: + file = conn_registry_path("schema", self.prefix) + sql = f"SELECT connection, database, database_schema_name FROM read_json_auto('{file}')" + if dt_filter: + sql += f" WHERE updated_at > '{dt_filter.isoformat()}'" + try: + results = conn.execute(sql).fetchall() + for row in results: + schema = Schema( + connection=row[0], database=row[1], database_schema_name=row[2] + ) + self.schemas.add(schema.fully_qualified_name) + except Exception: + # logger.error(f"Error loading schemas from {file}: {str(e)}") + return + + def load_tables(self, dt_filter: datetime | None = None) -> None: + file = conn_registry_path("table", self.prefix) + sql = f"SELECT connection, database, database_schema_name, database_table_name FROM read_json_auto('{file}')" + if dt_filter: + sql += f" WHERE updated_at > '{dt_filter.isoformat()}'" + try: + results = conn.execute(sql).fetchall() + for row in results: + table = Table( + connection=row[0], + database=row[1], + database_schema_name=row[2], + database_table_name=row[3], + ) + self.tables.add(table.fully_qualified_name) + except Exception: + # logger.error(f"Error loading tables from {file}: {str(e)}") + return + + @classmethod + def initialize(cls, prefix: str = "") -> Self: + registry = cls( + connections=set(), + databases=set(), + schemas=set(), + tables=set(), + prefix=prefix, + ) + registry.load_connections() + registry.load_databases() + registry.load_schemas() + registry.load_tables() + return registry + + +def populate_looker_connection_search_on_startup(ctx: typer.Context) -> None: + """ + populate the looker connection search + """ + global current_instance + # logger.debug("Populating looker connection search") + sdk = get_auth(ctx).get_current_sdk() + if not current_instance: + # logger.error("No current instance found") + raise typer.Abort() + registry = ConnectionRegistry.initialize(prefix=current_instance) + connections = ok(lambda: sdk.all_connections(), []) + for connection in connections: + if not connection.name: + continue + elif registry.check("connection", connection.name): + # logger.debug( + # f"Skipping {connection.name} because it already exists in the registry" + # ) + continue + # logger.debug(f"Populating looker connection search for {connection.name}") + databases = ok(lambda: sdk.connection_databases(connection.name or ""), []) + for database in databases: + if registry.check("database", database): + # logger.debug( + # f"Skipping {database} because it already exists in the registry" + # ) + continue + # logger.debug(f"Populating looker connection search for {database}") + schemas = ok( + lambda: sdk.connection_schemas( + connection.name or "", database, cache=True, fields="name" + ), + [], + ) + # logger.debug(f"Found {len(schemas)} schemas for {database}") + for schema in schemas: + if not schema.name: + continue + elif registry.check("schema", schema.name): + logger.debug( + f"Skipping {schema.name} because it already exists in the registry" + ) + continue + logger.debug(f"Populating looker connection search for {schema.name}.") + schema_tables = ok( + lambda: sdk.connection_tables( + connection.name, # type: ignore + database=database, + schema_name=schema.name, # type: ignore + table_limit=100000, + ), + [], + ) + if len(schema_tables) == 0: + continue + schema_name = get(schema_tables, "0.name", None) + # logger.debug(f"Found {len(schema_tables)} tables for {schema.name}") + for table in get(schema_tables, "0.tables", []): + if registry.check("table", table.name): + # logger.debug( + # f"Skipping {table.name} because it already exists in the registry" + # ) + continue + schema_columns = ok( + lambda: sdk.connection_columns( + connection.name, # type: ignore + database=database, + schema_name=schema_name, + table_names=table.name, + cache=True, + ), + [], + ) + logger.debug( + f"Found {len(get(schema_columns, '0.columns', []))} columns in {database}.{schema.name}.{table.name}" + ) + for column in get(schema_columns, "0.columns", []): + Row( + connection=connection.name, + database=database, + database_schema_name=schema_name, + database_table_name=table.name, + database_column_name=column.name, + data_type_database=column.data_type_database, + data_type_looker=column.data_type_looker, + ).append(current_instance) + + registry.append( + Table( + connection=connection.name, + database=database, + database_schema_name=schema_name, + database_table_name=table.name, + ) + ) + registry.append( + Schema( + connection=connection.name, + database=database, + database_schema_name=schema_name, + ) + ) + registry.append( + Database( + connection=connection.name, + database=database, + ) + ) + + if connection.dialect is None: + continue + if connection.dialect.name == "bigquery_standard_sql": + database = "looker-private-demo" + schema = "ecomm" + ecomm_tables = sdk.connection_tables( + connection.name, + database=database, + schema_name=schema, + table_limit=100000, + ) + if len(ecomm_tables) == 0: + continue + schema_name = get(ecomm_tables, "0.name", None) + for table in get(ecomm_tables, "0.tables", []): + if registry.check("table", table.name): + # logger.debug( + # f"Skipping {table.name} because it already exists in the registry" + # ) + continue + schema_columns = sdk.connection_columns( + connection.name, + database=database, + schema_name=schema, + table_names=table.name, + ) + if len(schema_columns) == 0: + continue + for column in get(schema_columns, "0.columns", []): + Row( + connection=connection.name, + database=database, + database_schema_name=schema, + database_table_name=table.name, + database_column_name=column.name, + data_type_database=column.data_type_database, + data_type_looker=column.data_type_looker, + ).append(current_instance) + + registry.append( + Connection( + connection=connection.name, + ) + ) + + +def load_database_search_file(file_loc: Path) -> None: + """ + load the database search file into a duckdb table and create FTS index + """ + conn.execute( + f""" + CREATE OR REPLACE TABLE looker_connection_search AS + SELECT * + FROM read_json_auto('{file_loc}'); + """ + ) + + # Create FTS index on fully_qualified_name + conn.execute( + """ + PRAGMA create_fts_index( + 'looker_connection_search', -- table + 'fully_qualified_name', -- index_id + 'fully_qualified_name' -- columns + ); + """ + ) + + +class SearchFullyQualifiedNamesRequest(BaseModel): + search_term: str = Field( + description="The search term to search for within the fully qualified column name. It will be converted to lowercase before searching. The fully quallified column name incluses database, schema, table, and column names." + ) + + +# Add a dynamic greeting resource +@mcp.tool() +def search_fully_qualified_names(req: SearchFullyQualifiedNamesRequest) -> List[dict]: + """ + Use lkr to search fully qualified columns which include connection, database, schema, table, column names, and data types + Returns a list of matching rows with their BM25 scores + """ + return [{"test": "foo"}] + result = conn.execute( + """ + SELECT + connection, + database, + database_schema_name, + database_table_name, + database_column_name, + data_type_database, + data_type_looker, + fts_main_looker_connection_search.match_bm25( + fully_qualified_name, + ? + ) AS score + FROM looker_connection_search + WHERE score IS NOT NULL + ORDER BY score DESC + LIMIT 10000 + """, + [req.search_term.lower()], + # [search_term, connections, databases, schemas, tables], + ).fetchall() + return [ + Row( + connection=row[0], + database=row[1], + database_schema_name=row[2], + database_table_name=row[3], + database_column_name=row[4], + data_type_database=row[5], + data_type_looker=row[6], + ).model_dump() + for row in result + ] + + +@group.callback() +def main(ctx: typer.Context): + global ctx_lkr + ctx_lkr = LkrCtxObj(force_oauth=False) + validate_current_instance_database_search_file(ctx) + + +@group.command(name="run") +def run(): + mcp.run() + + +def check_for_database_search_file(ctx: typer.Context) -> None: + global current_instance + if current_instance: + file_loc = get_database_search_file(current_instance) + populate_looker_connection_search_on_startup(ctx) + load_database_search_file(file_loc) + else: + # logger.error("No current instance found") + raise typer.Abort() + + +def validate_current_instance_database_search_file(ctx: typer.Context) -> None: + global current_instance + check = get_auth(ctx).get_current_instance() + if not current_instance: + current_instance = check + thread = threading.Thread(target=check_for_database_search_file, args=(ctx,)) + # thread.daemon = True + thread.start() + elif current_instance != check: + current_instance = check + thread = threading.Thread(target=check_for_database_search_file, args=(ctx,)) + # thread.daemon = True + thread.start() + else: + pass + +if __name__ == "__main__": + current_instance = "d7" + ctx_lkr = LkrCtxObj(force_oauth=False) + mcp.run("sse") diff --git a/pyproject.toml b/pyproject.toml index 84f98e1..98d8873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ "pydash>=8.0.5", "structlog>=25.3.0", "questionary>=2.1.0", + "duckdb>=1.2.2", + "fastmcp>=2.3.5", ] [project.scripts] diff --git a/uv.lock b/uv.lock index af412a9..a2f1b2b 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -165,6 +179,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, ] +[[package]] +name = "duckdb" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/b8/0f86278684fb7a1fac7c0c869fc6d68ed005cdc91c963eb4373e0551bc0a/duckdb-1.2.2.tar.gz", hash = "sha256:1e53555dece49201df08645dbfa4510c86440339889667702f936b7d28d39e43", size = 11595514 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/25/549f68e55e1b455bd2daf2e5fc912000a3139fe0395111b3d49b23a2cec1/duckdb-1.2.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f745379f44ad302560688855baaed9739c03b37a331338eda6a4ac655e4eb42f", size = 15271882 }, + { url = "https://files.pythonhosted.org/packages/f6/84/13de7bf9056dcc7a346125d9a9f0f26f76c633db6b54052738f78f828538/duckdb-1.2.2-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:087713fc5958cae5eb59097856b3deaae0def021660c8f2052ec83fa8345174a", size = 31964873 }, + { url = "https://files.pythonhosted.org/packages/0f/53/c8d2d56a801b7843ea87f8533a3634e6b38f06910098a266f8a096bd4c61/duckdb-1.2.2-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:a1f96395319c447a31b9477881bd84b4cb8323d6f86f21ceaef355d22dd90623", size = 16800653 }, + { url = "https://files.pythonhosted.org/packages/bb/36/e25791d879fb93b92a56bf481ce11949ab19109103ae2ba12d64e49355d9/duckdb-1.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6aba3bc0acf4f8d52b94f7746c3b0007b78b517676d482dc516d63f48f967baf", size = 18735524 }, + { url = "https://files.pythonhosted.org/packages/d7/46/4745aa10a1e460f4c8b473eddaffe2c783ac5280e1e5929dd84bd1a1acde/duckdb-1.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5c1556775a9ebaa49b5c8d64718f155ac3e05b34a49e9c99443cf105e8b0371", size = 20210314 }, + { url = "https://files.pythonhosted.org/packages/ff/0d/8563fc5ece36252e3d07dd3d29c7a0a034dcf62f14bed7cdc016d95adcbe/duckdb-1.2.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d625cc7d2faacfb2fc83ebbe001ae75dda175b3d8dce6a51a71c199ffac3627a", size = 18755134 }, + { url = "https://files.pythonhosted.org/packages/11/f1/b7ade7d980eee4fb3ad7469ccf23adb3668a9a28cf3989b24418392d3786/duckdb-1.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:73263f81545c5cb4360fbaf7b22a493e55ddf88fadbe639c43efb7bc8d7554c4", size = 22294397 }, + { url = "https://files.pythonhosted.org/packages/eb/c9/896e8ced7b408df81e015fe0c6497cda46c92d9dfc8bf84b6d13f5dad473/duckdb-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b1c0c4d737fd2ab9681e4e78b9f361e0a827916a730e84fa91e76dca451b14d5", size = 11370381 }, + { url = "https://files.pythonhosted.org/packages/41/31/5e2f68cbd000137f6ed52092ad83a8e9c09eca70c59e0b4c5eb679709997/duckdb-1.2.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:fb9a2c77236fae079185a990434cb9d8432902488ba990235c702fc2692d2dcd", size = 15272507 }, + { url = "https://files.pythonhosted.org/packages/d2/15/aa9078fc897e744e077c0c1510e34db4c809de1d51ddb5cb62e1f9c61312/duckdb-1.2.2-cp313-cp313-macosx_12_0_universal2.whl", hash = "sha256:d8bb89e580cb9a3aaf42e4555bf265d3db9446abfb118e32150e1a5dfa4b5b15", size = 31965548 }, + { url = "https://files.pythonhosted.org/packages/9f/28/943773d44fd97055c59b58dde9182733661c2b6e3b3549f15dc26b2e139e/duckdb-1.2.2-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:88916d7f0532dc926bed84b50408c00dcbe6d2097d0de93c3ff647d8d57b4f83", size = 16800600 }, + { url = "https://files.pythonhosted.org/packages/39/51/2caf01e7791e490290798c8c155d4d702ed61d69e815915b42e72b3e7473/duckdb-1.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30bece4f58a6c7bb0944a02dd1dc6de435a9daf8668fa31a9fe3a9923b20bd65", size = 18735886 }, + { url = "https://files.pythonhosted.org/packages/87/0c/48ae1d485725af3a452303af409a9022d751ecab260cb9ca2f8c9fb670bc/duckdb-1.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd2c6373b8b54474724c2119f6939c4568c428e1d0be5bcb1f4e3d7f1b7c8bb", size = 20210481 }, + { url = "https://files.pythonhosted.org/packages/69/c7/95fcd7bde0f754ea6700208d36b845379cbd2b28779c0eff4dd4a7396369/duckdb-1.2.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72f688a8b0df7030c5a28ca6072817c1f090979e08d28ee5912dee37c26a7d0c", size = 18756619 }, + { url = "https://files.pythonhosted.org/packages/ad/1b/c9eab9e84d4a70dd5f7e2a93dd6e9d7b4d868d3df755cd58b572d82d6c5d/duckdb-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26e9c349f56f7c99341b5c79bbaff5ba12a5414af0261e79bf1a6a2693f152f6", size = 22294667 }, + { url = "https://files.pythonhosted.org/packages/3f/3d/ce68db53084746a4a62695a4cb064e44ce04123f8582bb3afbf6ee944e16/duckdb-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1aec7102670e59d83512cf47d32a6c77a79df9df0294c5e4d16b6259851e2e9", size = 11370206 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, +] + +[[package]] +name = "fastmcp" +version = "2.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typer" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/30/1a70fce24dd0c9f7e7e2168adad1eb2c126e918128594a7bba06093b9263/fastmcp-2.3.5.tar.gz", hash = "sha256:09e11723c6588d8c13562d5eb04d42b13b91eb32f53cef77cc8c0ee121b2f907", size = 1004996 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/0f/098a4c7891d8c6adb69fc4f421e879bed73a352b3c3562b6a0be989b29bd/fastmcp-2.3.5-py3-none-any.whl", hash = "sha256:193e35a8d35a5c6a4af07e764873d8592aadc2f1e32dd8827b57869a83956088", size = 97240 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + [[package]] name = "idna" version = "3.10" @@ -189,6 +304,8 @@ version = "0.0.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, + { name = "duckdb" }, + { name = "fastmcp" }, { name = "looker-sdk" }, { name = "pydantic" }, { name = "pydash" }, @@ -207,6 +324,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "cryptography", specifier = ">=42.0.0" }, + { name = "duckdb", specifier = ">=1.2.2" }, + { name = "fastmcp", specifier = ">=2.3.5" }, { name = "looker-sdk", specifier = ">=25.4.0" }, { name = "pydantic", specifier = ">=2.11.4" }, { name = "pydash", specifier = ">=8.0.5" }, @@ -249,6 +368,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, ] +[[package]] +name = "mcp" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -258,6 +397,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, +] + [[package]] name = "packaging" version = "25.0" @@ -354,6 +505,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, ] +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 }, +] + [[package]] name = "pydash" version = "8.0.5" @@ -390,6 +555,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, ] +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + [[package]] name = "questionary" version = "2.1.0" @@ -464,6 +647,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233 }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 }, +] + [[package]] name = "structlog" version = "25.3.0" @@ -518,6 +735,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, ] +[[package]] +name = "uvicorn" +version = "0.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 }, +] + [[package]] name = "wcwidth" version = "0.2.13" @@ -526,3 +756,34 @@ sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, ] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +]