From 8363c85a8fe49b07c018aae7a81d54b691d60416 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:50:34 +0000 Subject: [PATCH 1/5] Initial plan From 3afb1c138a55ca0b9989a485b8adeeda4a282368 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:58:50 +0000 Subject: [PATCH 2/5] Retry image requests without unsupported inputs --- ...14-model-capability-runtime-degradation.md | 2 +- py-src/data_formulator/agents/client_utils.py | 30 +++++++++++++-- tests/backend/agents/test_client_utils.py | 38 +++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/docs/dev-guides/14-model-capability-runtime-degradation.md b/docs/dev-guides/14-model-capability-runtime-degradation.md index 3d795ebe..8f8aa364 100644 --- a/docs/dev-guides/14-model-capability-runtime-degradation.md +++ b/docs/dev-guides/14-model-capability-runtime-degradation.md @@ -9,7 +9,7 @@ 1. **唯一调用路径** — 所有 Agent 通过 `Client.get_completion()` 或 `Client.get_completion_with_tools()` 调用 LLM,两者内部均走 `litellm.completion()`。 2. **`drop_params=True`** — LiteLLM 自动丢弃模型不支持的参数(如 `reasoning_effort`、`parallel_tool_calls`),不会报错。 -3. **图片降级** — 如果模型不支持图片,`_is_image_deserialize_error()` 捕获异常后自动剥离图片重试。 +3. **图片降级** — 如果模型不支持图片,`_is_image_deserialize_error()` 捕获异常后自动剥离图片重试;对于信息不明确的上游请求失败,仅在原请求确实包含图片时降级。 4. **无前端预检查** — 前端始终允许用户上传图片;后端自动处理。 ## Client API diff --git a/py-src/data_formulator/agents/client_utils.py b/py-src/data_formulator/agents/client_utils.py index b222e507..6c34d778 100644 --- a/py-src/data_formulator/agents/client_utils.py +++ b/py-src/data_formulator/agents/client_utils.py @@ -295,10 +295,32 @@ def _strip_images_from_messages(self, messages): sanitized_messages.append(msg) return sanitized_messages - def _is_image_deserialize_error(self, error_text: str) -> bool: + def _messages_contain_images(self, messages) -> bool: + """Return whether messages contain an image_url content block.""" + return any( + isinstance(msg, dict) + and isinstance(msg.get("content"), list) + and any( + isinstance(item, dict) and item.get("type") == "image_url" + for item in msg["content"] + ) + for msg in messages + ) + + def _is_image_deserialize_error(self, error_text: str, has_images: bool = False) -> bool: """Detect provider errors caused by image blocks on text-only models.""" lowered = error_text.lower() - return ("image_url" in lowered and "expected `text`" in lowered) or "unknown variant `image_url`" in lowered + return ( + ("image_url" in lowered and "expected `text`" in lowered) + or "unknown variant `image_url`" in lowered + or ( + has_images + and ( + "upstream request failed" in lowered + or ("image" in lowered and ("not support" in lowered or "unsupported" in lowered)) + ) + ) + ) def _is_reasoning_effort_error(self, error_text: str) -> bool: """Detect provider errors caused by an unsupported ``reasoning_effort`` @@ -393,7 +415,7 @@ def get_completion(self, messages, stream=False, reasoning_effort="low", if self._is_reasoning_effort_error(err): params.pop("reasoning_effort", None) return self._dispatch(messages=messages, stream=stream, params=params) - if self._is_image_deserialize_error(err): + if self._is_image_deserialize_error(err, self._messages_contain_images(messages)): sanitized = self._strip_images_from_messages(messages) return self._dispatch(messages=sanitized, stream=stream, params=params) raise @@ -416,7 +438,7 @@ def get_completion_with_tools(self, messages, tools, stream=False, params.pop("reasoning_effort", None) return self._dispatch(messages=messages, stream=stream, params=params, tools=tools, extra=kwargs) - if self._is_image_deserialize_error(err): + if self._is_image_deserialize_error(err, self._messages_contain_images(messages)): sanitized = self._strip_images_from_messages(messages) return self._dispatch(messages=sanitized, stream=stream, params=params, tools=tools, extra=kwargs) diff --git a/tests/backend/agents/test_client_utils.py b/tests/backend/agents/test_client_utils.py index fffc3b58..4161f7ea 100644 --- a/tests/backend/agents/test_client_utils.py +++ b/tests/backend/agents/test_client_utils.py @@ -7,6 +7,7 @@ - Model name prefixing for gemini / anthropic / ollama - Ollama api_base normalisation (trailing /api stripping) - Image block stripping helpers (_strip_image_blocks, _strip_images_from_messages) +- Image request detection (_messages_contain_images) - Image deserialise error detection (_is_image_deserialize_error) - Client.from_config constructor """ @@ -203,6 +204,43 @@ def test_partial_match_image_url_without_expected(self): """'image_url' alone without 'expected `text`' should NOT match.""" assert self.client._is_image_deserialize_error("received image_url block") is False + def test_upstream_failure_with_images_detected(self): + err = "400: Error from provider (OpenCode Go): Upstream request failed" + assert self.client._is_image_deserialize_error(err, has_images=True) is True + + def test_upstream_failure_without_images_not_detected(self): + err = "400: Error from provider (OpenCode Go): Upstream request failed" + assert self.client._is_image_deserialize_error(err, has_images=False) is False + + def test_unsupported_image_message_with_images_detected(self): + err = "The selected model does not support image inputs" + assert self.client._is_image_deserialize_error(err, has_images=True) is True + + +# --------------------------------------------------------------------------- +# _messages_contain_images +# --------------------------------------------------------------------------- + +class TestMessagesContainImages: + def setup_method(self): + self.client = Client("openai", "gpt-4o", api_key="k") + + def test_multimodal_message_detected(self): + messages = [ + {"role": "user", "content": [ + {"type": "text", "text": "Describe this"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}, + ]}, + ] + assert self.client._messages_contain_images(messages) is True + + def test_text_only_messages_not_detected(self): + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": [{"type": "text", "text": "Describe this"}]}, + ] + assert self.client._messages_contain_images(messages) is False + # --------------------------------------------------------------------------- # Client.from_config From 070a7f7b509da8eaf06186d5a7817ce11456b161 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 23 Jul 2026 13:55:52 -0700 Subject: [PATCH 3/5] fix to mssql connector --- .../data_formulator/data_loader/__init__.py | 2 +- .../data_loader/connector_errors.py | 16 ++++ .../data_loader/mssql_data_loader.py | 88 +++-------------- .../data_formulator/datalake/parquet_utils.py | 27 +++++- py-src/data_formulator/errors.py | 1 + pyproject.toml | 34 +++---- requirements.txt | 2 +- src/app/dfSlice.tsx | 2 +- src/components/ComponentType.tsx | 2 +- src/components/ConnectorFormCard.tsx | 2 +- src/components/LoadPlanCard.tsx | 8 +- src/i18n/locales/en/loader.json | 3 +- src/i18n/locales/zh/loader.json | 3 +- src/views/DBTableManager.tsx | 91 ++++++++++++++++-- src/views/DataLoadingChat.tsx | 13 +-- src/views/UnifiedDataUploadDialog.tsx | 2 +- tests/backend/data/test_connector_errors.py | 17 ++++ tests/backend/data/test_df_to_safe_records.py | 13 +++ .../data/test_sync_catalog_cross_db.py | 8 +- uv.lock | 95 ++++++++----------- 20 files changed, 255 insertions(+), 174 deletions(-) diff --git a/py-src/data_formulator/data_loader/__init__.py b/py-src/data_formulator/data_loader/__init__.py index c024f411..4ed096a8 100644 --- a/py-src/data_formulator/data_loader/__init__.py +++ b/py-src/data_formulator/data_loader/__init__.py @@ -57,7 +57,7 @@ # --------------------------------------------------------------------------- _LOADER_SPECS: list[tuple[str, str, str, str]] = [ ("mysql", "data_formulator.data_loader.mysql_data_loader", "MySQLDataLoader", "pymysql"), - ("mssql", "data_formulator.data_loader.mssql_data_loader", "MSSQLDataLoader", "pyodbc"), + ("mssql", "data_formulator.data_loader.mssql_data_loader", "MSSQLDataLoader", "mssql-python"), ("postgresql", "data_formulator.data_loader.postgresql_data_loader", "PostgreSQLDataLoader", "psycopg2-binary"), ("kusto", "data_formulator.data_loader.kusto_data_loader", "KustoDataLoader", "azure-kusto-data"), ("databricks", "data_formulator.data_loader.databricks_data_loader", "DatabricksDataLoader", "databricks-sql-connector"), diff --git a/py-src/data_formulator/data_loader/connector_errors.py b/py-src/data_formulator/data_loader/connector_errors.py index 35b88b21..d263602c 100644 --- a/py-src/data_formulator/data_loader/connector_errors.py +++ b/py-src/data_formulator/data_loader/connector_errors.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +import re from typing import Any from data_formulator.data_loader.external_data_loader import ConnectorParamError @@ -42,6 +43,21 @@ def classify_connector_error(error: Exception, *, operation: str = "") -> Connec text = raw_detail.lower() http_status = _http_status(chain) + firewall_match = re.search( + r"client with ip address ['\"]?([0-9a-f:.]+)['\"]? is not allowed to access the server", + raw_detail, + flags=re.IGNORECASE, + ) + if firewall_match: + client_ip = firewall_match.group(1) + return ConnectorErrorInfo( + ErrorCode.DB_FIREWALL_BLOCKED, + f"Azure SQL blocked this network address ({client_ip}). Ask the " + "database administrator to add this IP to the SQL server firewall, " + "then try again.", + detail=safe_detail, + ) + if any(isinstance(exc, ConnectorParamError) for exc in chain): return ConnectorErrorInfo( ErrorCode.INVALID_REQUEST, diff --git a/py-src/data_formulator/data_loader/mssql_data_loader.py b/py-src/data_formulator/data_loader/mssql_data_loader.py index 8503d4ae..db75aa58 100644 --- a/py-src/data_formulator/data_loader/mssql_data_loader.py +++ b/py-src/data_formulator/data_loader/mssql_data_loader.py @@ -1,11 +1,10 @@ import json import logging import math -import struct from typing import Any +import mssql_python import pyarrow as pa -import pyodbc from data_formulator.data_loader.external_data_loader import ExternalDataLoader, CatalogNode, MAX_IMPORT_ROWS, sanitize_table_name from data_formulator.data_loader import probe_utils @@ -13,24 +12,6 @@ log = logging.getLogger(__name__) -# ODBC connection attribute for passing a pre-fetched Entra ID (Azure AD) -# access token to the SQL Server driver (SQL_COPT_SS_ACCESS_TOKEN). -_SQL_COPT_SS_ACCESS_TOKEN = 1256 - -# Token audience/scope for Azure SQL / SQL Server Entra ID authentication. -_AZURE_SQL_SCOPE = "https://database.windows.net/.default" - - -def _is_nan(value) -> bool: - """Check if a value is NaN (works for float, int, None).""" - if value is None: - return True - try: - return math.isnan(float(value)) - except (TypeError, ValueError): - return False - - class MSSQLDataLoader(ExternalDataLoader): DISPLAY_NAME = "SQL Server" DESCRIPTION = "Connect to a Microsoft SQL Server database to query tables with SQL." @@ -79,20 +60,13 @@ def list_params() -> list[dict[str, Any]]: "tier": "connection", "description": "SQL Server port (default: 1433)", }, - { - "name": "driver", - "type": "string", - "required": False, - "default": "ODBC Driver 17 for SQL Server", - "tier": "connection", - "description": "ODBC driver name", - }, { "name": "encrypt", "type": "string", "required": False, "default": "yes", "tier": "connection", + "advanced": True, "description": "Enable encryption (yes/no)", }, { @@ -101,6 +75,7 @@ def list_params() -> list[dict[str, Any]]: "required": False, "default": "no", "tier": "connection", + "advanced": True, "description": "Trust server certificate (yes/no)", }, { @@ -109,6 +84,7 @@ def list_params() -> list[dict[str, Any]]: "required": False, "default": "30", "tier": "connection", + "advanced": True, "description": "Connection timeout in seconds", }, ] @@ -180,8 +156,7 @@ def auth_instructions() -> str: **Windows authentication (Windows only):** Choose *Windows authentication* and leave username/password empty. -**Prerequisites (macOS/Linux only):** -Install the ODBC driver: `brew install unixodbc msodbcsql18` (macOS) or `sudo apt-get install unixodbc-dev msodbcsql18` (Ubuntu/Debian). For Entra ID you also need the Azure CLI (`brew install azure-cli`) and to run `az login`. +**Microsoft Entra prerequisite:** Install the Azure CLI and run `az login`. The SQL Server driver is included with Data Formulator; no separate ODBC driver installation is needed. **Troubleshooting:** Confirm you are signed in with `az account show`. Ensure the SQL Server service is running and TCP/IP is enabled. Test SQL auth with `sqlcmd -S -d -U -P `.""" @@ -196,7 +171,6 @@ def __init__(self, params: dict[str, Any]): self.user = params.get("user", "").strip() self.password = params.get("password", "").strip() self.port = params.get("port", "1433") - self.driver = params.get("driver", "ODBC Driver 17 for SQL Server") self.encrypt = params.get("encrypt", "yes") self.trust_server_certificate = params.get("trust_server_certificate", "no") self.connection_timeout = params.get("connection_timeout", "30") @@ -206,66 +180,34 @@ def __init__(self, params: dict[str, Any]): # When no database specified, connect to master for catalog browsing connect_db = self.database or "master" - # Build the auth-independent part of the ODBC connection string. + # Build the auth-independent connection string. mssql-python uses + # Direct Database Connectivity, so no external ODBC driver is needed. conn_str = ( - f"DRIVER={{{self.driver}}};" f"SERVER={self.server},{self.port};" f"DATABASE={connect_db};" f"Encrypt={self.encrypt};" f"TrustServerCertificate={self.trust_server_certificate};" - f"Connection Timeout={self.connection_timeout};" ) - connect_kwargs: dict[str, Any] = {} + try: + connection_timeout = max(1, int(self.connection_timeout)) + except (TypeError, ValueError) as e: + raise ValueError("Connection timeout must be a positive number of seconds") from e + if self.auth_path == "entra_id": - # Entra ID: fetch a token via DefaultAzureCredential (az login / - # Managed Identity / VS Code / env) and hand it to the driver. - # No UID/PWD/Trusted_Connection goes into the string. - connect_kwargs["attrs_before"] = { - _SQL_COPT_SS_ACCESS_TOKEN: self._get_entra_id_token_struct() - } + conn_str += "Authentication=ActiveDirectoryDefault;" elif self.user or self.auth_path == "sql_auth": - conn_str += f"UID={self.user};PWD={self.password};" + conn_str += f"Authentication=SqlPassword;UID={self.user};PWD={self.password};" else: conn_str += "Trusted_Connection=yes;" try: - self._conn = pyodbc.connect(conn_str, **connect_kwargs) + self._conn = mssql_python.connect(conn_str, timeout=connection_timeout) log.info(f"Successfully connected to SQL Server: {self.server}/{self.database}") except Exception as e: log.error(f"Failed to connect to SQL Server: {e}") raise ValueError(f"Failed to connect to SQL Server '{self.server}': {e}") from e - @staticmethod - def _get_entra_id_token_struct() -> bytes: - """Fetch an Entra ID access token for Azure SQL and pack it for ODBC. - - Uses ``DefaultAzureCredential`` so a local ``az login`` (or Managed - Identity / VS Code / environment credentials) is enough to connect. - The token is packed into the little-endian, length-prefixed UTF-16-LE - struct expected by the SQL Server ODBC access-token attribute. - """ - try: - from azure.identity import DefaultAzureCredential - except ImportError as e: - raise ValueError( - "Microsoft Entra ID authentication requires the 'azure-identity' " - "package. Install it with `uv pip install azure-identity`." - ) from e - - try: - credential = DefaultAzureCredential() - token = credential.get_token(_AZURE_SQL_SCOPE).token - except Exception as e: - raise ValueError( - "Failed to acquire a Microsoft Entra ID token. Run `az login` in " - "your terminal (or configure a Managed Identity), then retry. " - f"Details: {e}" - ) from e - - token_bytes = token.encode("UTF-16-LE") - return struct.pack(f" list[dict[str, Any]]: streaming should call this function rather than using ``df.to_json`` / ``df.to_dict`` directly. """ + safe_df = df.copy(deep=False) + for column in safe_df.select_dtypes(include=["object", "string"]).columns: + safe_df[column] = safe_df[column].map(_repair_invalid_unicode) return json.loads( - df.to_json(orient="records", date_format="iso", default_handler=str) + safe_df.to_json(orient="records", date_format="iso", default_handler=str) ) +def _repair_invalid_unicode(value: Any) -> Any: + """Replace malformed surrogate code points while preserving valid Unicode.""" + if isinstance(value, str): + try: + value.encode("utf-8") + return value + except UnicodeEncodeError: + return value.encode("utf-8", errors="replace").decode("utf-8") + if isinstance(value, dict): + return { + _repair_invalid_unicode(key): _repair_invalid_unicode(item) + for key, item in value.items() + } + if isinstance(value, list): + return [_repair_invalid_unicode(item) for item in value] + if isinstance(value, tuple): + return tuple(_repair_invalid_unicode(item) for item in value) + if isinstance(value, set): + return {_repair_invalid_unicode(item) for item in value} + return value + + def normalize_dtype_to_app_type(dtype_str: str) -> str: """Map a pandas/Arrow dtype string to a standardized App Type label. diff --git a/py-src/data_formulator/errors.py b/py-src/data_formulator/errors.py index 689a8d85..529c4fb4 100644 --- a/py-src/data_formulator/errors.py +++ b/py-src/data_formulator/errors.py @@ -50,6 +50,7 @@ class ErrorCode: # --- Data / connector --- CONNECTOR_AUTH_FAILED = "CONNECTOR_AUTH_FAILED" + DB_FIREWALL_BLOCKED = "DB_FIREWALL_BLOCKED" DB_CONNECTION_FAILED = "DB_CONNECTION_FAILED" DB_QUERY_ERROR = "DB_QUERY_ERROR" DATA_LOAD_ERROR = "DATA_LOAD_ERROR" diff --git a/pyproject.toml b/pyproject.toml index 64a34376..05e61d2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,27 +41,27 @@ dependencies = [ "pyarrow>=13.0.0", "xlrd", "openpyxl>=3.1.0", - "lxml", # pandas.read_html web table extraction + "lxml", # pandas.read_html web table extraction "yfinance", # Data-loader deps (all included by default; try/except keeps things safe) - "pymongo", # mongodb - "azure-cosmos", # cosmosdb - "azure-storage-blob", # azure_blob - "azure-identity", # azure_blob, kusto - "azure-kusto-data", # kusto - "azure-keyvault-secrets", # kusto - "pymysql", # mysql - "pyodbc", # mssql - "psycopg2-binary", # postgresql - "boto3", # s3, athena - "google-cloud-bigquery", # bigquery - "google-auth", # bigquery - "db-dtypes", # bigquery + "pymongo", # mongodb + "azure-cosmos", # cosmosdb + "azure-storage-blob", # azure_blob + "azure-identity", # azure_blob, kusto + "azure-kusto-data", # kusto + "azure-keyvault-secrets", # kusto + "pymysql", # mysql + "mssql-python>=1.11.0", # mssql + "psycopg2-binary", # postgresql + "boto3", # s3, athena + "google-cloud-bigquery", # bigquery + "google-auth", # bigquery + "db-dtypes", # bigquery "databricks-sql-connector", # databricks # SSO / Auth deps - "PyJWT[crypto]>=2.8.0", # OIDC JWT verification (includes cryptography) - "requests", # GitHub OAuth code exchange, Superset API calls - "flask-session>=0.8.0", # Server-side session (SQLite) for TokenStore + "PyJWT[crypto]>=2.8.0", # OIDC JWT verification (includes cryptography) + "requests", # GitHub OAuth code exchange, Superset API calls + "flask-session>=0.8.0", # Server-side session (SQLite) for TokenStore ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index a0478fb8..2feb2e44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ azure-identity azure-kusto-data azure-keyvault-secrets pymysql -pyodbc +mssql-python>=1.11.0 psycopg2-binary boto3 google-cloud-bigquery diff --git a/src/app/dfSlice.tsx b/src/app/dfSlice.tsx index bface3f7..2c5cc07a 100644 --- a/src/app/dfSlice.tsx +++ b/src/app/dfSlice.tsx @@ -74,7 +74,7 @@ export interface ServerConfig { source_type: string; name: string; icon: string; - params_form: Array<{name: string; type: string; required: boolean; default?: string; description?: string; sensitive?: boolean; tier?: 'connection' | 'auth' | 'filter'}>; + params_form: Array<{name: string; type: string; required: boolean; default?: string; options?: string[]; advanced?: boolean; description?: string; sensitive?: boolean; tier?: 'connection' | 'auth' | 'filter'}>; pinned_params: Record; hierarchy: Array<{key: string; label: string}>; effective_hierarchy: Array<{key: string; label: string}>; diff --git a/src/components/ComponentType.tsx b/src/components/ComponentType.tsx index d550a273..f94f87be 100644 --- a/src/components/ComponentType.tsx +++ b/src/components/ComponentType.tsx @@ -595,7 +595,7 @@ export interface ConnectorInstance { /** Backend signals that SSO token exchange can auto-connect this source. */ sso_auto_connect?: boolean; deletable?: boolean; - params_form: Array<{name: string; type: string; required: boolean; default?: string | number | boolean; description?: string; sensitive?: boolean; tier?: 'connection' | 'auth' | 'filter'}>; + params_form: Array<{name: string; type: string; required: boolean; default?: string | number | boolean; options?: string[]; advanced?: boolean; description?: string; sensitive?: boolean; tier?: 'connection' | 'auth' | 'filter'}>; pinned_params: Record; hierarchy: Array<{key: string; label: string}>; effective_hierarchy: Array<{key: string; label: string}>; diff --git a/src/components/ConnectorFormCard.tsx b/src/components/ConnectorFormCard.tsx index df55e19d..13934f03 100644 --- a/src/components/ConnectorFormCard.tsx +++ b/src/components/ConnectorFormCard.tsx @@ -32,7 +32,7 @@ import type { ConnectorFormPrompt, ConnectorInstance, ConnectorAuthPath } from ' interface LoaderMeta { type: string; name: string; - params: Array<{ name: string; type: string; required: boolean; default?: string | number | boolean; description?: string; sensitive?: boolean; tier?: 'connection' | 'auth' | 'filter' }>; + params: Array<{ name: string; type: string; required: boolean; default?: string | number | boolean; options?: string[]; advanced?: boolean; description?: string; sensitive?: boolean; tier?: 'connection' | 'auth' | 'filter' }>; auth_mode?: string; auth_paths?: ConnectorAuthPath[]; auth_instructions?: string; diff --git a/src/components/LoadPlanCard.tsx b/src/components/LoadPlanCard.tsx index ccacdd01..95fd8665 100644 --- a/src/components/LoadPlanCard.tsx +++ b/src/components/LoadPlanCard.tsx @@ -251,17 +251,19 @@ export const LoadPlanCard: React.FC = ({ plan, onConfirm, con ); })} - {/* Footer: action button (unconfirmed) or quiet caption (confirmed). */} + {/* Footer: keep actions available after loading and show the + prior-load status immediately to their left. */} - {confirmed ? ( + {confirmed && ( {t('dataLoading.loadPlan.loadedCount', { count: plan.candidates.filter(c => !c.resolutionError).length, defaultValue: '✓ Loaded', })} - ) : canLoadInNewWorkspace ? ( + )} + {canLoadInNewWorkspace ? ( // A workspace with data is already open — make the load // destination explicit rather than silently appending. <> diff --git a/src/i18n/locales/en/loader.json b/src/i18n/locales/en/loader.json index 42f09667..2f9053eb 100644 --- a/src/i18n/locales/en/loader.json +++ b/src/i18n/locales/en/loader.json @@ -14,11 +14,10 @@ "user": "Username (leave empty for Entra ID / Windows auth)", "password": "Password (leave empty for Entra ID / Windows auth)", "port": "SQL Server port (default: 1433)", - "driver": "ODBC driver name", "encrypt": "Enable encryption (yes/no)", "trust_server_certificate": "Trust server certificate (yes/no)", "connection_timeout": "Connection timeout in seconds", - "authInstructions": "**Microsoft Entra ID (recommended):** Run `az login` once in your terminal, then start Data Formulator. Choose *Microsoft Entra ID*, fill in only `server` and (optionally) `database`, and leave username/password empty — your Azure CLI credentials are used automatically. Managed Identity, VS Code, and environment credentials also work via `DefaultAzureCredential`.\n\n> Your Entra identity must be granted access to the database, e.g. an admin runs `CREATE USER [you@contoso.com] FROM EXTERNAL PROVIDER;` and grants the needed roles.\n\n**Example (Entra ID):** server: `myserver.database.windows.net` · database: `mydb` (username/password empty)\n\n**SQL Server authentication:** Choose *SQL Server authentication* and provide username and password.\n\n**Example (SQL auth):** server: `localhost` · database: `mydb` · user: `sa` · password: `MyP@ss` · port: `1433`\n\n**Windows authentication (Windows only):** Choose *Windows authentication* and leave username/password empty.\n\n**Prerequisites (macOS/Linux only):**\nInstall the ODBC driver: `brew install unixodbc msodbcsql18` (macOS) or `sudo apt-get install unixodbc-dev msodbcsql18` (Ubuntu/Debian). For Entra ID you also need the Azure CLI (`brew install azure-cli`) and to run `az login`.\n\n**Troubleshooting:** Confirm you are signed in with `az account show`. Ensure the SQL Server service is running and TCP/IP is enabled. Test SQL auth with `sqlcmd -S -d -U -P `." + "authInstructions": "**Microsoft Entra ID (recommended):** Run `az login` once in your terminal, then start Data Formulator. Choose *Microsoft Entra ID*, fill in only `server` and (optionally) `database`, and leave username/password empty — your Azure CLI credentials are used automatically. Managed Identity, VS Code, and environment credentials also work via `DefaultAzureCredential`.\n\n> Your Entra identity must be granted access to the database, e.g. an admin runs `CREATE USER [you@contoso.com] FROM EXTERNAL PROVIDER;` and grants the needed roles.\n\n**Example (Entra ID):** server: `myserver.database.windows.net` · database: `mydb` (username/password empty)\n\n**SQL Server authentication:** Choose *SQL Server authentication* and provide username and password.\n\n**Example (SQL auth):** server: `localhost` · database: `mydb` · user: `sa` · password: `MyP@ss` · port: `1433`\n\n**Windows authentication (Windows only):** Choose *Windows authentication* and leave username/password empty.\n\n**Driver:** The Microsoft SQL Server driver is bundled with Data Formulator; no separate ODBC installation is needed. For Entra ID, install Azure CLI and run `az login`.\n\n**Troubleshooting:** Confirm you are signed in with `az account show`. Ensure the SQL Server service is running and TCP/IP is enabled. Test SQL auth with `sqlcmd -S -d -U -P `." }, "postgresql": { "user": "PostgreSQL username", diff --git a/src/i18n/locales/zh/loader.json b/src/i18n/locales/zh/loader.json index e4d815bf..dc19a90b 100644 --- a/src/i18n/locales/zh/loader.json +++ b/src/i18n/locales/zh/loader.json @@ -14,11 +14,10 @@ "user": "用户名(使用 Entra ID / Windows 认证时留空)", "password": "密码(使用 Entra ID / Windows 认证时留空)", "port": "SQL Server 端口(默认 1433)", - "driver": "ODBC 驱动名称", "encrypt": "启用加密(yes/no)", "trust_server_certificate": "信任服务器证书(yes/no)", "connection_timeout": "连接超时时间(秒)", - "authInstructions": "**Microsoft Entra ID(推荐):** 先在终端运行一次 `az login`,然后启动 Data Formulator。选择 *Microsoft Entra ID*,只填写 `server`(和可选的 `database`),用户名/密码留空 — 会自动使用你的 Azure CLI 凭据。也支持通过 `DefaultAzureCredential` 使用托管标识、VS Code 和环境凭据。\n\n> 你的 Entra 身份必须已获授权访问该数据库,例如管理员运行 `CREATE USER [you@contoso.com] FROM EXTERNAL PROVIDER;` 并授予相应角色。\n\n**示例(Entra ID):** server: `myserver.database.windows.net` · database: `mydb`(用户名/密码留空)\n\n**SQL Server 认证:** 选择 *SQL Server 认证* 并提供用户名和密码。\n\n**示例(SQL 认证):** server: `localhost` · database: `mydb` · user: `sa` · password: `MyP@ss` · port: `1433`\n\n**Windows 认证(仅 Windows):** 选择 *Windows 认证* 并将用户名/密码留空。\n\n**前置条件(仅 macOS/Linux):**\n安装 ODBC 驱动:`brew install unixodbc msodbcsql18`(macOS)或 `sudo apt-get install unixodbc-dev msodbcsql18`(Ubuntu/Debian)。使用 Entra ID 还需安装 Azure CLI(`brew install azure-cli`)并运行 `az login`。\n\n**排查:** 用 `az account show` 确认已登录。确保 SQL Server 服务正在运行且 TCP/IP 已启用。用 `sqlcmd -S -d -U -P ` 测试 SQL 认证。" + "authInstructions": "**Microsoft Entra ID(推荐):** 先在终端运行一次 `az login`,然后启动 Data Formulator。选择 *Microsoft Entra ID*,只填写 `server`(和可选的 `database`),用户名/密码留空 — 会自动使用你的 Azure CLI 凭据。也支持通过 `DefaultAzureCredential` 使用托管标识、VS Code 和环境凭据。\n\n> 你的 Entra 身份必须已获授权访问该数据库,例如管理员运行 `CREATE USER [you@contoso.com] FROM EXTERNAL PROVIDER;` 并授予相应角色。\n\n**示例(Entra ID):** server: `myserver.database.windows.net` · database: `mydb`(用户名/密码留空)\n\n**SQL Server 认证:** 选择 *SQL Server 认证* 并提供用户名和密码。\n\n**示例(SQL 认证):** server: `localhost` · database: `mydb` · user: `sa` · password: `MyP@ss` · port: `1433`\n\n**Windows 认证(仅 Windows):** 选择 *Windows 认证* 并将用户名/密码留空。\n\n**驱动:** Microsoft SQL Server 驱动已随 Data Formulator 提供,无需单独安装 ODBC 驱动。使用 Entra ID 时,请安装 Azure CLI 并运行 `az login`。\n\n**排查:** 用 `az account show` 确认已登录。确保 SQL Server 服务正在运行且 TCP/IP 已启用。用 `sqlcmd -S -d -U -P ` 测试 SQL 认证。" }, "postgresql": { "user": "PostgreSQL 用户名", diff --git a/src/views/DBTableManager.tsx b/src/views/DBTableManager.tsx index c3714455..59ca2b24 100644 --- a/src/views/DBTableManager.tsx +++ b/src/views/DBTableManager.tsx @@ -17,6 +17,8 @@ import { } from '@mui/material'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import { CONNECTOR_ACTION_URLS } from '../app/utils'; import { apiRequest, type ApiError } from '../app/apiClient'; @@ -80,7 +82,7 @@ export const DataLoaderForm: React.FC<{ dataLoaderType: string, /** Loader registry key (e.g. "mysql") for i18n lookups. Falls back to dataLoaderType. */ loaderType?: string, - paramDefs: {name: string, default?: string | number | boolean, type: string, required: boolean, description?: string, sensitive?: boolean, tier?: 'connection' | 'auth' | 'filter'}[], + paramDefs: {name: string, default?: string | number | boolean, options?: string[], type: string, required: boolean, advanced?: boolean, description?: string, sensitive?: boolean, tier?: 'connection' | 'auth' | 'filter'}[], authInstructions: string, connectorId?: string, autoConnect?: boolean, @@ -111,8 +113,7 @@ export const DataLoaderForm: React.FC<{ /** When true, lay parameters out in a single column and tighten spacing * so the form fits inside a chat card (design 38). */ compact?: boolean, - /** When true, suppress the connector's built-in authInstructions block so - * agent-authored setup guidance can replace it (design 38). */ + /** When true, keep setup instructions collapsed initially in compact forms. */ hideInstructions?: boolean, /** One-time seed for sensitive fields (passwords/tokens) the user handed to * the agent in chat. Populates the transient sensitive state so the user @@ -206,6 +207,8 @@ export const DataLoaderForm: React.FC<{ const [isLoadingDatabases, setIsLoadingDatabases] = useState(false); const [databaseDiscoveryError, setDatabaseDiscoveryError] = useState(''); const [databaseMenuOpen, setDatabaseMenuOpen] = useState(false); + const [showAdvancedConnection, setShowAdvancedConnection] = useState(false); + const [instructionsExpanded, setInstructionsExpanded] = useState(!hideInstructions); // In-app CLI sign-in (local mode only), e.g. `az login` for Entra ID. const [cliLoginStatus, setCliLoginStatus] = useState<{ installed: boolean; signed_in: boolean; account: { user?: string } | null } | null>(null); @@ -847,6 +850,38 @@ export const DataLoaderForm: React.FC<{ }} /> ) + ) : paramDef.options ? ( + renderFieldRow(paramDef, + { + dispatch(dfActions.updateDataLoaderConnectParam({ + dataLoaderType, + paramName: paramDef.name, + paramValue: value ?? '', + })); + }} + onInputChange={(_event, value, reason) => { + if (reason === 'input') { + dispatch(dfActions.updateDataLoaderConnectParam({ + dataLoaderType, + paramName: paramDef.name, + paramValue: value, + })); + } + }} + renderInput={(inputParams) => ( + + )} + /> + ) ) : ( renderFieldRow(paramDef, p.tier === 'connection'); + const connectionParams = paramDefs.filter(p => p.tier === 'connection' && !p.advanced); + const advancedConnectionParams = paramDefs.filter(p => p.tier === 'connection' && p.advanced); const filterParams = paramDefs.filter(p => p.tier === 'filter'); const authParams = paramDefs.filter(p => p.tier === 'auth'); const selectedAuthPath = authPaths.find(path => path.id === params._auth_path) @@ -915,6 +951,32 @@ export const DataLoaderForm: React.FC<{ )} {connectionParams.length > 0 && renderParamGrid(connectionParams)} + {advancedConnectionParams.length > 0 && ( + + + {showAdvancedConnection && ( + + {renderParamGrid(advancedConnectionParams)} + + )} + + )} , ) )} @@ -1132,24 +1194,36 @@ export const DataLoaderForm: React.FC<{ mt: 2, ml: 4.75, maxWidth: 760, - p: 1.5, borderRadius: 1, bgcolor: 'action.hover', color: 'text.secondary', + overflow: 'hidden', }} > - setInstructionsExpanded(value => !value)} + endIcon={instructionsExpanded + ? + : } sx={{ + justifyContent: 'space-between', + px: 1.5, + py: 1, fontSize: 11.5, lineHeight: 1.5, fontWeight: 600, color: 'text.primary', - mb: 1, + textTransform: 'none', }} > {t('db.setupDetails', { defaultValue: 'Setup details' })} - + + {instructionsExpanded && ( ({ + px: 1.5, + pb: 1.5, maxWidth: 720, fontFamily: theme.typography.fontFamily, fontSize: 11.5, @@ -1167,6 +1241,7 @@ export const DataLoaderForm: React.FC<{ })}> {setupDetailsContent} + )} )} {onDelete && connectorIdRef.current && ( diff --git a/src/views/DataLoadingChat.tsx b/src/views/DataLoadingChat.tsx index 80fb99fc..0fed3aad 100644 --- a/src/views/DataLoadingChat.tsx +++ b/src/views/DataLoadingChat.tsx @@ -247,16 +247,17 @@ const InlineTablePreviewView: React.FC<{ expanded={expanded} onTogglePreview={preview.sampleRows.length > 0 ? () => setExpanded(!expanded) : undefined} /> - {/* Footer: matches LoadPlanCard — right-aligned contained - Load button (unconfirmed) or quiet "Loaded" caption. */} + {/* Footer: keep the load action available after loading and show + the prior-load status immediately to its left. */} {(onLoad || confirmed) && ( - + - {confirmed ? ( + {confirmed && ( {t('dataLoading.loadPlan.loadedCount', { count: 1, defaultValue: '✓ Loaded' })} - ) : onLoad ? ( + )} + {onLoad && ( - ) : null} + )} )} diff --git a/src/views/UnifiedDataUploadDialog.tsx b/src/views/UnifiedDataUploadDialog.tsx index 7b27c4f0..df926ab3 100644 --- a/src/views/UnifiedDataUploadDialog.tsx +++ b/src/views/UnifiedDataUploadDialog.tsx @@ -990,7 +990,7 @@ const ScrollFadeContainer: React.FC<{ interface LoaderType { type: string; name: string; - params: Array<{name: string; type: string; required: boolean; default?: string | number | boolean; description?: string; sensitive?: boolean; tier?: 'connection' | 'auth' | 'filter'}>; + params: Array<{name: string; type: string; required: boolean; default?: string | number | boolean; options?: string[]; advanced?: boolean; description?: string; sensitive?: boolean; tier?: 'connection' | 'auth' | 'filter'}>; hierarchy: Array<{key: string; label: string}>; auth_mode?: string; auth_paths?: ConnectorAuthPath[]; diff --git a/tests/backend/data/test_connector_errors.py b/tests/backend/data/test_connector_errors.py index 0dcd7c3c..f6c93a53 100644 --- a/tests/backend/data/test_connector_errors.py +++ b/tests/backend/data/test_connector_errors.py @@ -54,3 +54,20 @@ def test_http_500_with_lost_connection_maps_to_connection_failed(): assert info.code == ErrorCode.DB_CONNECTION_FAILED assert info.retry is True + + +def test_azure_sql_firewall_denial_exposes_safe_client_ip(): + error = RuntimeError( + "Cannot open server 'example' requested by the login. Client with IP " + "address '131.107.1.158' is not allowed to access the server." + ) + + info = classify_connector_error(error, operation="connect") + + assert info.code == ErrorCode.DB_FIREWALL_BLOCKED + assert info.retry is False + assert "131.107.1.158" in info.message + assert "SQL server firewall" in info.message + assert "example" not in info.message + + diff --git a/tests/backend/data/test_df_to_safe_records.py b/tests/backend/data/test_df_to_safe_records.py index 177aa569..b0172e40 100644 --- a/tests/backend/data/test_df_to_safe_records.py +++ b/tests/backend/data/test_df_to_safe_records.py @@ -77,3 +77,16 @@ def test_default_handler_catches_exotic_types(self): records = df_to_safe_records(df) assert records[0]["val"] == 42 assert abs(records[1]["val"] - 3.14) < 0.001 + + def test_malformed_unicode_is_replaced_without_mutating_input(self): + malformed = "broken \ud800 text" + df = pd.DataFrame({"value": [malformed, "正常な文字列", {"items": [malformed]}]}) + + records = df_to_safe_records(df) + + assert records == [ + {"value": "broken ? text"}, + {"value": "正常な文字列"}, + {"value": {"items": ["broken ? text"]}}, + ] + assert df.iloc[0]["value"] == malformed diff --git a/tests/backend/data/test_sync_catalog_cross_db.py b/tests/backend/data/test_sync_catalog_cross_db.py index 162bfda9..f3a0bd3d 100644 --- a/tests/backend/data/test_sync_catalog_cross_db.py +++ b/tests/backend/data/test_sync_catalog_cross_db.py @@ -232,7 +232,9 @@ class TestMSSQLSyncCatalogMetadata: @pytest.fixture() def _patch_mssql_connect(self): - with patch("pyodbc.connect") as mock_conn: + with patch( + "data_formulator.data_loader.mssql_data_loader.mssql_python.connect" + ) as mock_conn: conn = MagicMock() mock_conn.return_value = conn yield mock_conn @@ -249,6 +251,10 @@ def _make_loader(self, *, database: str = "", _patch=None): def test_single_db_delegates_to_list_tables(self, _patch_mssql_connect): loader = self._make_loader(database="mydb") + connection_string = _patch_mssql_connect.call_args.args[0] + assert "DRIVER=" not in connection_string + assert "Authentication=SqlPassword" in connection_string + assert _patch_mssql_connect.call_args.kwargs["timeout"] == 30 fake_tables = [ {"name": "dbo.orders", "metadata": {"_source_name": "dbo.orders", "columns": []}}, ] diff --git a/uv.lock b/uv.lock index 30809761..d83d8212 100644 --- a/uv.lock +++ b/uv.lock @@ -625,6 +625,7 @@ dependencies = [ { name = "google-cloud-bigquery" }, { name = "litellm" }, { name = "lxml" }, + { name = "mssql-python" }, { name = "numpy" }, { name = "openai" }, { name = "openpyxl" }, @@ -634,7 +635,6 @@ dependencies = [ { name = "pyjwt", extra = ["crypto"] }, { name = "pymongo" }, { name = "pymysql" }, - { name = "pyodbc" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, @@ -675,6 +675,7 @@ requires-dist = [ { name = "google-cloud-bigquery" }, { name = "litellm", specifier = ">=1.84.0,<1.92" }, { name = "lxml" }, + { name = "mssql-python", specifier = ">=1.11.0" }, { name = "numpy" }, { name = "openai" }, { name = "openpyxl", specifier = ">=3.1.0" }, @@ -685,7 +686,6 @@ requires-dist = [ { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, { name = "pymongo" }, { name = "pymysql" }, - { name = "pyodbc" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, @@ -2023,6 +2023,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, ] +[[package]] +name = "mssql-python" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-identity" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5e/d13c75acbbbed70eab7340151c9443faba6bbad1879c6ddc4c1d5a2d3b0a/mssql_python-1.11.0-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:fac92caf4ac3e0eba49c424d632f04312b82622ce08b894199120082015a43a5", size = 28098416, upload-time = "2026-07-10T07:55:15.133Z" }, + { url = "https://files.pythonhosted.org/packages/bd/07/ca5f8bcca649d36296a3ac767393e92b37c945081fe3442e815d568d3e19/mssql_python-1.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4e152dceedbe5d048d00940374f94a1117e1ac4d419aaaf58da6010f6006b80e", size = 25690642, upload-time = "2026-07-10T07:55:18.346Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/9602b5d20aa2677c13194e45b7767b9049f5c8d00ddbca5bb5702fda4809/mssql_python-1.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5745b10843cb3fcb164111071b80dd5758779c19a504a869fdf5d90b062bd08a", size = 26121306, upload-time = "2026-07-10T07:55:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/614862a882bc91e2cb69a09eb99f84b7a46d18b65892e6710c2df63a5836/mssql_python-1.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a40e7dc9b14d1387064f3c19a0fcf449165b465fdea64d731877d97cdffb5569", size = 25561657, upload-time = "2026-07-10T07:55:25.28Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/8917361ea6df1b35fb68026686b12f07c8e66ca0de7d3dc88c9c19fb5633/mssql_python-1.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d26d2e57c2295cacc7f8545bf5cb981a3752fed2f8de29c4c61d6a084b70c00", size = 25951583, upload-time = "2026-07-10T07:55:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/21/bc963bd464f54c628c46def2fcef08ad128f395b2ec6913cd46f36d0cc5b/mssql_python-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:91519f0c9cea93061eab0318fe38b2f319a13a2402da3f7a7e33672257e85463", size = 15454178, upload-time = "2026-07-10T07:55:32.687Z" }, + { url = "https://files.pythonhosted.org/packages/55/a9/e8c5a57ca49b0197787b25fbe5d68f99a25cdbaacadfc9dbd7e2262e0b0e/mssql_python-1.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:667f1c11b2c51be6af428dc9de9d22bb91902d724b559570fe9be47e4cf58070", size = 18664803, upload-time = "2026-07-10T07:55:35.733Z" }, + { url = "https://files.pythonhosted.org/packages/d6/47/6d5c7e5f3463baee487a98890819ef5f485519374aae42e6f41027e18028/mssql_python-1.11.0-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:11ad2555a9e815855171d04d7895a5abcd4917b048cf787dd381f42d1fd219fc", size = 28093516, upload-time = "2026-07-10T07:55:39.51Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/1e2a2335e672ec1306c23f5d04872f50ecd47a58adbff506956619d87637/mssql_python-1.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8b5f048fae5afb59400c7e4361ac40c5b9d89931c330bbc0b461f31ef0320947", size = 26188274, upload-time = "2026-07-10T07:55:43.044Z" }, + { url = "https://files.pythonhosted.org/packages/75/bf/25e894b0a19c6198593ab5c9db9c99b2792e149f5fa5715e0804c99813d7/mssql_python-1.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d9ed6e3a3b4bbfb8acb18946de0919ca7b83e8456fa8cf78f668ef89f476f093", size = 26783603, upload-time = "2026-07-10T07:55:47.033Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b4/84d46d3302c05b061f5c9705773aa88da06b71332cfa13913872d5e26eff/mssql_python-1.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:89df9c3f2959d8f5e64e33d3eba53309868f11959046772633c4785f743297ea", size = 25996320, upload-time = "2026-07-10T07:55:50.617Z" }, + { url = "https://files.pythonhosted.org/packages/f2/43/aa9e05237791199e2ca36185623420b5d9617ff8817790f04ec2c683d07e/mssql_python-1.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a945708c9e78620c7de18ba12768af8de7a6295810b408ce7dd7b9ba36d8bde", size = 26543021, upload-time = "2026-07-10T07:55:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/d5/55/aa13c7933847b961f49c433f07b1bf12caef255902ca968739bf428124fd/mssql_python-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:31c9a9eeccbd7a4c83fde0c06a50a96d479219cbd084f755b94ce249bd9fbbd1", size = 15453869, upload-time = "2026-07-10T07:55:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/4f339e73be2adff49774e29d22bc743e7c34b2821c76234ff8facbba468a/mssql_python-1.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:868afce9d1deb4e97168d69d2daa8b384826017fb5ac3765ca2a8f4da23aac66", size = 18664898, upload-time = "2026-07-10T07:56:00.365Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3f/156108d863dbec85a639cd72224d1313e72d05222c26e574f7ebe32621d4/mssql_python-1.11.0-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:6c8481f35a43422368c3adad7f59c6a03559feeef7c52f68beaca990a102ba6a", size = 28092865, upload-time = "2026-07-10T07:56:03.894Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/ac4cc76569df83868d4466311251727b6f0ba3ddf7a06ddd35058c795bce/mssql_python-1.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b4fa7bf93a2c0d981db47756f87cbfcec369de96271c8397b9958080313cf52b", size = 26690917, upload-time = "2026-07-10T07:56:07.129Z" }, + { url = "https://files.pythonhosted.org/packages/92/c6/483fbe683b5233a7aadfb0e47be66b8f3a044aa7519757fb27b631ab9a7e/mssql_python-1.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:de45c77d3f01e7ac7326914d8a69afe14eb56fdbf84802b2b0f83bf432f32d64", size = 27452387, upload-time = "2026-07-10T07:56:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7b/be33b7b72571e97cf3079912a5087804c27c0649733434e62b4a6ed5b9dc/mssql_python-1.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a85cbe94404f476d3d8a137807b5a88dafa12f07e8e698ae07b0e7cbb7dc1675", size = 26434714, upload-time = "2026-07-10T07:56:14.295Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e8/499bc18e8e37e92865519b0fae68ac9aac4c6749a6c9fca41dc10dec4234/mssql_python-1.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:128f82393913598eccacfe0fb919f3708d8465a97f03805a94d1021b4645187d", size = 27140003, upload-time = "2026-07-10T07:56:17.633Z" }, + { url = "https://files.pythonhosted.org/packages/89/5b/0e83a0d48e622add429489d752b6695d2553dc676bd29251f41c375fda65/mssql_python-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:37defdfaed71bb866d213c5e0be1ff247eda94df3f55a9adfe258fa77758d14e", size = 15453215, upload-time = "2026-07-10T07:56:21.135Z" }, + { url = "https://files.pythonhosted.org/packages/98/99/2fd591cf428fc47680459098c4a81b11137201f1d30d696cdb096dcc01d4/mssql_python-1.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:054f2de2e75816f795ad477c4f53dcb8d355274aecb5c54c473bcaa958c640ec", size = 18664396, upload-time = "2026-07-10T07:56:24.362Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/ba9d8e79befde8e7fd38ba14027e19e5eda06564bc5adfccd1bce9c6236b/mssql_python-1.11.0-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:8cbe443d3f5c0da89846036379ad7312779193b568b63f708aa8657546e5839a", size = 28098149, upload-time = "2026-07-10T07:56:28.185Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/8b866f2fded4c60d4160e667af8bfceb6c5d3dce7f999bb587a35f51f4da/mssql_python-1.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:77aa593cb0fea313c1a2732bbd04a96f74f65a4cdb45e06d6664cb563d66dc59", size = 27192819, upload-time = "2026-07-10T07:56:31.564Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6d/95f829032be5ad5173e1d684e61813f3d626c76dd0491ac90184adec5e72/mssql_python-1.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:cda7923fcb28b45a2df1163be4c3930b0b81cd024ee8a54d23549f1684194e07", size = 28117273, upload-time = "2026-07-10T07:56:34.827Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f6/a6462df7ce8da7357dbafeec24ac3e4600f61ae14b729515c356c31bd1fc/mssql_python-1.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:883e508620abc8ab76cc8aaddf0a9c446ed66638685fead2484f8223bc4946d1", size = 26872296, upload-time = "2026-07-10T07:56:38.068Z" }, + { url = "https://files.pythonhosted.org/packages/70/fc/de043151098f448fc77a6207cabce79e83fd0b33c3b765105656db39afe8/mssql_python-1.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:11ea1c98180a572bc0a951af24240a5b9a393d47e42a64b7a5541216a02f9c07", size = 27732483, upload-time = "2026-07-10T07:56:41.423Z" }, + { url = "https://files.pythonhosted.org/packages/18/10/36cf4678cb890fec2c042ea32a0d393c21673613d9b2fd165a2f3a8b5da2/mssql_python-1.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:764938cd33291aa1cc28fce794449cf5f69db8f9456fe218c1ff07cc2960da0d", size = 15979098, upload-time = "2026-07-10T07:56:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/2a3894a9e377a9bf438dc057be3dfc25fb6d4437b0e25194302be301d51c/mssql_python-1.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e345d24f6a1d30566c8e835f341d6ab4f6fe259c5855fb231174db03124776c3", size = 19294794, upload-time = "2026-07-10T07:56:47.78Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -2860,59 +2898,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" }, ] -[[package]] -name = "pyodbc" -version = "5.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8f/85/44b10070a769a56bd910009bb185c0c0a82daff8d567cd1a116d7d730c7d/pyodbc-5.3.0.tar.gz", hash = "sha256:2fe0e063d8fb66efd0ac6dc39236c4de1a45f17c33eaded0d553d21c199f4d05", size = 121770, upload-time = "2025-10-17T18:04:09.43Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/c7/534986d97a26cb8f40ef456dfcf00d8483161eade6d53fa45fcf2d5c2b87/pyodbc-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ebc3be93f61ea0553db88589e683ace12bf975baa954af4834ab89f5ee7bf8ae", size = 71958, upload-time = "2025-10-17T18:03:10.163Z" }, - { url = "https://files.pythonhosted.org/packages/69/3c/6fe3e9eae6db1c34d6616a452f9b954b0d5516c430f3dd959c9d8d725f2a/pyodbc-5.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b987a25a384f31e373903005554230f5a6d59af78bce62954386736a902a4b3", size = 71843, upload-time = "2025-10-17T18:03:11.058Z" }, - { url = "https://files.pythonhosted.org/packages/44/0e/81a0315d0bf7e57be24338dbed616f806131ab706d87c70f363506dc13d5/pyodbc-5.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676031723aac7dcbbd2813bddda0e8abf171b20ec218ab8dfb21d64a193430ea", size = 327191, upload-time = "2025-10-17T18:03:11.93Z" }, - { url = "https://files.pythonhosted.org/packages/43/ae/b95bb2068f911950322a97172c68675c85a3e87dc04a98448c339fcbef21/pyodbc-5.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5c30c5cd40b751f77bbc73edd32c4498630939bcd4e72ee7e6c9a4b982cc5ca", size = 332228, upload-time = "2025-10-17T18:03:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/dc/21/2433625f7d5922ee9a34e3805805fa0f1355d01d55206c337bb23ec869bf/pyodbc-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2035c7dfb71677cd5be64d3a3eb0779560279f0a8dc6e33673499498caa88937", size = 1296469, upload-time = "2025-10-17T18:03:14.61Z" }, - { url = "https://files.pythonhosted.org/packages/3a/f4/c760caf7bb9b3ab988975d84bd3e7ebda739fe0075c82f476d04ee97324c/pyodbc-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5cbe4d753723c8a8f65020b7a259183ef5f14307587165ce37e8c7e251951852", size = 1353163, upload-time = "2025-10-17T18:03:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/14/ad/f9ca1e9e44fd91058f6e35b233b1bb6213d590185bfcc2a2c4f1033266e7/pyodbc-5.3.0-cp311-cp311-win32.whl", hash = "sha256:d255f6b117d05cfc046a5201fdf39535264045352ea536c35777cf66d321fbb8", size = 62925, upload-time = "2025-10-17T18:03:17.649Z" }, - { url = "https://files.pythonhosted.org/packages/e6/cf/52b9b94efd8cfd11890ae04f31f50561710128d735e4e38a8fbb964cd2c2/pyodbc-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:f1ad0e93612a6201621853fc661209d82ff2a35892b7d590106fe8f97d9f1f2a", size = 69329, upload-time = "2025-10-17T18:03:18.474Z" }, - { url = "https://files.pythonhosted.org/packages/8b/6f/bf5433bb345007f93003fa062e045890afb42e4e9fc6bd66acc2c3bd12ca/pyodbc-5.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:0df7ff47fab91ea05548095b00e5eb87ed88ddf4648c58c67b4db95ea4913e23", size = 64447, upload-time = "2025-10-17T18:03:19.691Z" }, - { url = "https://files.pythonhosted.org/packages/f5/0c/7ecf8077f4b932a5d25896699ff5c394ffc2a880a9c2c284d6a3e6ea5949/pyodbc-5.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ebf6b5d989395efe722b02b010cb9815698a4d681921bf5db1c0e1195ac1bde", size = 72994, upload-time = "2025-10-17T18:03:20.551Z" }, - { url = "https://files.pythonhosted.org/packages/03/78/9fbde156055d88c1ef3487534281a5b1479ee7a2f958a7e90714968749ac/pyodbc-5.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:197bb6ddafe356a916b8ee1b8752009057fce58e216e887e2174b24c7ab99269", size = 72535, upload-time = "2025-10-17T18:03:21.423Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f9/8c106dcd6946e95fee0da0f1ba58cd90eb872eebe8968996a2ea1f7ac3c1/pyodbc-5.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6ccb5315ec9e081f5cbd66f36acbc820ad172b8fa3736cf7f993cdf69bd8a96", size = 333565, upload-time = "2025-10-17T18:03:22.695Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/2c70f47a76a4fafa308d148f786aeb35a4d67a01d41002f1065b465d9994/pyodbc-5.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5dd3d5e469f89a3112cf8b0658c43108a4712fad65e576071e4dd44d2bd763c7", size = 340283, upload-time = "2025-10-17T18:03:23.691Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b2/0631d84731606bfe40d3b03a436b80cbd16b63b022c7b13444fb30761ca8/pyodbc-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b180bc5e49b74fd40a24ef5b0fe143d0c234ac1506febe810d7434bf47cb925b", size = 1302767, upload-time = "2025-10-17T18:03:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/74/b9/707c5314cca9401081b3757301241c167a94ba91b4bd55c8fa591bf35a4a/pyodbc-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3c39de3005fff3ae79246f952720d44affc6756b4b85398da4c5ea76bf8f506", size = 1361251, upload-time = "2025-10-17T18:03:26.538Z" }, - { url = "https://files.pythonhosted.org/packages/97/7c/893036c8b0c8d359082a56efdaa64358a38dda993124162c3faa35d1924d/pyodbc-5.3.0-cp312-cp312-win32.whl", hash = "sha256:d32c3259762bef440707098010035bbc83d1c73d81a434018ab8c688158bd3bb", size = 63413, upload-time = "2025-10-17T18:03:27.903Z" }, - { url = "https://files.pythonhosted.org/packages/c0/70/5e61b216cc13c7f833ef87f4cdeab253a7873f8709253f5076e9bb16c1b3/pyodbc-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe77eb9dcca5fc1300c9121f81040cc9011d28cff383e2c35416e9ec06d4bc95", size = 70133, upload-time = "2025-10-17T18:03:28.746Z" }, - { url = "https://files.pythonhosted.org/packages/aa/85/e7d0629c9714a85eb4f85d21602ce6d8a1ec0f313fde8017990cf913e3b4/pyodbc-5.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:afe7c4ac555a8d10a36234788fc6cfc22a86ce37fc5ba88a1f75b3e6696665dc", size = 64700, upload-time = "2025-10-17T18:03:29.638Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1d/9e74cbcc1d4878553eadfd59138364b38656369eb58f7e5b42fb344c0ce7/pyodbc-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e9ab0b91de28a5ab838ac4db0253d7cc8ce2452efe4ad92ee6a57b922bf0c24", size = 72975, upload-time = "2025-10-17T18:03:30.466Z" }, - { url = "https://files.pythonhosted.org/packages/37/c7/27d83f91b3144d3e275b5b387f0564b161ddbc4ce1b72bb3b3653e7f4f7a/pyodbc-5.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6132554ffbd7910524d643f13ce17f4a72f3a6824b0adef4e9a7f66efac96350", size = 72541, upload-time = "2025-10-17T18:03:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/1b/33/2bb24e7fc95e98a7b11ea5ad1f256412de35d2e9cc339be198258c1d9a76/pyodbc-5.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1629af4706e9228d79dabb4863c11cceb22a6dab90700db0ef449074f0150c0d", size = 343287, upload-time = "2025-10-17T18:03:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/fa/24/88cde8b6dc07a93a92b6c15520a947db24f55db7bd8b09e85956642b7cf3/pyodbc-5.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ceaed87ba2ea848c11223f66f629ef121f6ebe621f605cde9cfdee4fd9f4b68", size = 350094, upload-time = "2025-10-17T18:03:33.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/99/53c08562bc171a618fa1699297164f8885e66cde38c3b30f454730d0c488/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cc472c8ae2feea5b4512e23b56e2b093d64f7cbc4b970af51da488429ff7818", size = 1301029, upload-time = "2025-10-17T18:03:34.561Z" }, - { url = "https://files.pythonhosted.org/packages/d8/10/68a0b5549876d4b53ba4c46eed2a7aca32d589624ed60beef5bd7382619e/pyodbc-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c79df54bbc25bce9f2d87094e7b39089c28428df5443d1902b0cc5f43fd2da6f", size = 1361420, upload-time = "2025-10-17T18:03:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/41/0f/9dfe4987283ffcb981c49a002f0339d669215eb4a3fe4ee4e14537c52852/pyodbc-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c2eb0b08e24fe5c40c7ebe9240c5d3bd2f18cd5617229acee4b0a0484dc226f2", size = 63399, upload-time = "2025-10-17T18:03:36.931Z" }, - { url = "https://files.pythonhosted.org/packages/56/03/15dcefe549d3888b649652af7cca36eda97c12b6196d92937ca6d11306e9/pyodbc-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:01166162149adf2b8a6dc21a212718f205cabbbdff4047dc0c415af3fd85867e", size = 70133, upload-time = "2025-10-17T18:03:38.47Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c1/c8b128ae59a14ecc8510e9b499208e342795aecc3af4c3874805c720b8db/pyodbc-5.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:363311bd40320b4a61454bebf7c38b243cd67c762ed0f8a5219de3ec90c96353", size = 64683, upload-time = "2025-10-17T18:03:39.68Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f2/c26d82a7ce1e90b8bbb8731d3d53de73814e2f6606b9db9d978303aa8d5f/pyodbc-5.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3f1bdb3ce6480a17afaaef4b5242b356d4997a872f39e96f015cabef00613797", size = 73513, upload-time = "2025-10-17T18:03:40.536Z" }, - { url = "https://files.pythonhosted.org/packages/82/d5/1ab1b7c4708cbd701990a8f7183c5bb5e0712d5e8479b919934e46dadab4/pyodbc-5.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7713c740a10f33df3cb08f49a023b7e1e25de0c7c99650876bbe717bc95ee780", size = 72631, upload-time = "2025-10-17T18:03:41.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/7e3831eeac2b09b31a77e6b3495491ce162035ff2903d7261b49d35aa3c2/pyodbc-5.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf18797a12e70474e1b7f5027deeeccea816372497e3ff2d46b15bec2d18a0cc", size = 344580, upload-time = "2025-10-17T18:03:42.67Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a6/71d26d626a3c45951620b7ff356ec920e420f0e09b0a924123682aa5e4ab/pyodbc-5.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:08b2439500e212625471d32f8fde418075a5ddec556e095e5a4ba56d61df2dc6", size = 350224, upload-time = "2025-10-17T18:03:43.731Z" }, - { url = "https://files.pythonhosted.org/packages/93/14/f702c5e8c2d595776266934498505f11b7f1545baf21ffec1d32c258e9d3/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:729c535341bb09c476f219d6f7ab194bcb683c4a0a368010f1cb821a35136f05", size = 1301503, upload-time = "2025-10-17T18:03:45.013Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b2/ad92ebdd1b5c7fec36b065e586d1d34b57881e17ba5beec5c705f1031058/pyodbc-5.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c67e7f2ce649155ea89beb54d3b42d83770488f025cf3b6f39ca82e9c598a02e", size = 1361050, upload-time = "2025-10-17T18:03:46.298Z" }, - { url = "https://files.pythonhosted.org/packages/19/40/dc84e232da07056cb5aaaf5f759ba4c874bc12f37569f7f1670fc71e7ae1/pyodbc-5.3.0-cp314-cp314-win32.whl", hash = "sha256:a48d731432abaee5256ed6a19a3e1528b8881f9cb25cb9cf72d8318146ea991b", size = 65670, upload-time = "2025-10-17T18:03:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/b8/79/c48be07e8634f764662d7a279ac204f93d64172162dbf90f215e2398b0bd/pyodbc-5.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:58635a1cc859d5af3f878c85910e5d7228fe5c406d4571bffcdd281375a54b39", size = 72177, upload-time = "2025-10-17T18:03:57.296Z" }, - { url = "https://files.pythonhosted.org/packages/fc/79/e304574446b2263f428ce14df590ba52c2e0e0205e8d34b235b582b7d57e/pyodbc-5.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:754d052030d00c3ac38da09ceb9f3e240e8dd1c11da8906f482d5419c65b9ef5", size = 66668, upload-time = "2025-10-17T18:03:58.174Z" }, - { url = "https://files.pythonhosted.org/packages/43/17/f4eabf443b838a2728773554017d08eee3aca353102934a7e3ba96fb0e31/pyodbc-5.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f927b440c38ade1668f0da64047ffd20ec34e32d817f9a60d07553301324b364", size = 75780, upload-time = "2025-10-17T18:03:47.273Z" }, - { url = "https://files.pythonhosted.org/packages/59/ea/e79e168c3d38c27d59d5d96273fd9e3c3ba55937cc944c4e60618f51de90/pyodbc-5.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:25c4cfb2c08e77bc6e82f666d7acd52f0e52a0401b1876e60f03c73c3b8aedc0", size = 75503, upload-time = "2025-10-17T18:03:48.171Z" }, - { url = "https://files.pythonhosted.org/packages/90/81/d1d7c125ec4a20e83fdc28e119b8321192b2bd694f432cf63e1199b2b929/pyodbc-5.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc834567c2990584b9726cba365834d039380c9dbbcef3030ddeb00c6541b943", size = 398356, upload-time = "2025-10-17T18:03:49.131Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fc/f6be4b3cc3910f8c2aba37aa41671121fd6f37b402ae0fefe53a70ac7cd5/pyodbc-5.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8339d3094858893c1a68ee1af93efc4dff18b8b65de54d99104b99af6306320d", size = 397291, upload-time = "2025-10-17T18:03:50.18Z" }, - { url = "https://files.pythonhosted.org/packages/03/2e/0610b1ed05a5625528d52f6cece9610e84617d35f475c89c2a52f66d13f7/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74528fe148980d0c735c0ebb4a4dc74643ac4574337c43c1006ac4d09593f92d", size = 1353900, upload-time = "2025-10-17T18:03:51.339Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f1/43497e1d37f9f71b43b2b3172e7b1bdf50851e278390c3fb6b46a3630c53/pyodbc-5.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d89a7f2e24227150c13be8164774b7e1f9678321a4248f1356a465b9cc17d31e", size = 1406062, upload-time = "2025-10-17T18:03:52.546Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/88a1277c2f7d9ab1cec0a71e074ba24fd4a1710a43974682546da90a1343/pyodbc-5.3.0-cp314-cp314t-win32.whl", hash = "sha256:af4d8c9842fc4a6360c31c35508d6594d5a3b39922f61b282c2b4c9d9da99514", size = 70132, upload-time = "2025-10-17T18:03:53.715Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c7/ee98c62050de4aa8bafb6eb1e11b95e0b0c898bd5930137c6dc776e06a9b/pyodbc-5.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bfeb3e34795d53b7d37e66dd54891d4f9c13a3889a8f5fe9640e56a82d770955", size = 79452, upload-time = "2025-10-17T18:03:54.664Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8f/d8889efd96bbe8e5d43ff9701f6b1565a8e09c3e1f58c388d550724f777b/pyodbc-5.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:13656184faa3f2d5c6f19b701b8f247342ed581484f58bf39af7315c054e69db", size = 70142, upload-time = "2025-10-17T18:03:55.551Z" }, -] - [[package]] name = "pyproject-hooks" version = "1.2.0" From 4eeaba8f01abdb94134366217c814e422cba6db9 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 23 Jul 2026 13:59:05 -0700 Subject: [PATCH 4/5] fix --- src/i18n/locales/en/common.json | 8 ++-- src/i18n/locales/zh/common.json | 8 ++-- src/views/DBTableManager.tsx | 74 ++++++++------------------------- 3 files changed, 26 insertions(+), 64 deletions(-) diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 77d51ff3..25ebcdcd 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -350,10 +350,10 @@ "optional": "optional", "connectionTimeout": "Connection timed out. Please check your credentials and try again.", "delegatedLogin": "Login via service", - "cliLoginInProgress": "Signing in…", - "cliLoginSwitch": "Switch account", - "cliLoginSignedInAs": "Signed in as {{user}}", - "cliNotInstalled": "Azure CLI not found — install it or sign in via a terminal.", + "cliLoginReady": "Signed in as {{user}}. You are ready to connect.", + "cliLoginCurrentAccount": "your current account", + "cliLoginRequired": "Sign in with Azure CLI before connecting. Run `az login` in a terminal, then reopen this form.", + "cliNotInstalled": "Azure CLI not found. Install it and run `az login` in a terminal before connecting.", "cliLoginFailed": "Sign-in failed. Try running the login command in a terminal.", "popupBlocked": "Popup was blocked. Please allow popups and try again.", "tierConnection": "Connection", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 10404e65..125490b8 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -350,10 +350,10 @@ "optional": "可选", "connectionTimeout": "连接超时。请检查凭据后重试。", "delegatedLogin": "通过服务登录", - "cliLoginInProgress": "正在登录…", - "cliLoginSwitch": "切换账户", - "cliLoginSignedInAs": "已登录:{{user}}", - "cliNotInstalled": "未找到 Azure CLI — 请安装或在终端中登录。", + "cliLoginReady": "已登录为 {{user}},现在可以连接。", + "cliLoginCurrentAccount": "当前账户", + "cliLoginRequired": "连接前请先登录 Azure CLI。在终端运行 `az login`,然后重新打开此表单。", + "cliNotInstalled": "未找到 Azure CLI。请安装后在终端运行 `az login`,再进行连接。", "cliLoginFailed": "登录失败。请尝试在终端中运行登录命令。", "popupBlocked": "弹出窗口被阻止。请允许弹出窗口后重试。", "tierConnection": "连接", diff --git a/src/views/DBTableManager.tsx b/src/views/DBTableManager.tsx index 59ca2b24..5139f45a 100644 --- a/src/views/DBTableManager.tsx +++ b/src/views/DBTableManager.tsx @@ -210,10 +210,8 @@ export const DataLoaderForm: React.FC<{ const [showAdvancedConnection, setShowAdvancedConnection] = useState(false); const [instructionsExpanded, setInstructionsExpanded] = useState(!hideInstructions); - // In-app CLI sign-in (local mode only), e.g. `az login` for Entra ID. + // CLI sign-in status (local mode only), e.g. `az login` for Entra ID. const [cliLoginStatus, setCliLoginStatus] = useState<{ installed: boolean; signed_in: boolean; account: { user?: string } | null } | null>(null); - const [cliLoginBusy, setCliLoginBusy] = useState(false); - const [cliLoginError, setCliLoginError] = useState(''); // The auth path the user has currently selected (also computed in the // render body; duplicated here so effects/handlers can react to it). @@ -225,7 +223,7 @@ export const DataLoaderForm: React.FC<{ // Fetch current CLI sign-in status when a CLI-login auth path is selected. useEffect(() => { - if (!cliStatusUrl) { setCliLoginStatus(null); setCliLoginError(''); return; } + if (!cliStatusUrl) { setCliLoginStatus(null); return; } let cancelled = false; (async () => { try { @@ -242,28 +240,6 @@ export const DataLoaderForm: React.FC<{ return () => { cancelled = true; }; }, [cliStatusUrl]); - const handleCliLogin = useCallback(async () => { - if (!cliLogin) return; - setCliLoginBusy(true); - setCliLoginError(''); - try { - const { data } = await apiRequest(cliLogin.login_url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - setCliLoginStatus({ installed: true, signed_in: !!data.signed_in, account: data.account ?? null }); - } catch (error: any) { - setCliLoginError( - error?.apiError?.message - || error?.message - || t('db.cliLoginFailed', { defaultValue: 'Sign-in failed. Try running the login command in a terminal.' }), - ); - } finally { - setCliLoginBusy(false); - } - }, [cliLogin, t]); - // Sensitive params (passwords, tokens, secrets) live in component state only — // never persisted to Redux / localStorage. // Sensitivity is declared by the loader via `sensitive: true` or `type: "password"`. @@ -1071,36 +1047,22 @@ export const DataLoaderForm: React.FC<{ {isLocalMode && selectedAuthPath?.cli_login && ( 0 ? 2 : 0 }}> - - - {cliLoginStatus?.signed_in && cliLoginStatus.account?.user && ( - - {t('db.cliLoginSignedInAs', { user: cliLoginStatus.account.user, defaultValue: 'Signed in as {{user}}' })} - - )} - {cliLoginStatus && !cliLoginStatus.installed && ( - - {t('db.cliNotInstalled', { defaultValue: 'Azure CLI not found — install it or sign in via a terminal.' })} - - )} - - {cliLoginError && ( - {cliLoginError} - )} + {cliLoginStatus?.signed_in ? ( + + {t('db.cliLoginReady', { + user: cliLoginStatus.account?.user || t('db.cliLoginCurrentAccount', { defaultValue: 'your current account' }), + defaultValue: 'Signed in as {{user}}. You are ready to connect.', + })} + + ) : cliLoginStatus?.installed ? ( + + {t('db.cliLoginRequired', { defaultValue: 'Sign in with Azure CLI before connecting. Run `az login` in a terminal, then reopen this form.' })} + + ) : cliLoginStatus && !cliLoginStatus.installed ? ( + + {t('db.cliNotInstalled', { defaultValue: 'Azure CLI not found. Install it and run `az login` in a terminal before connecting.' })} + + ) : null} )} From 1f6ac829462efa4baa98eac884eef246209f49c9 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 23 Jul 2026 14:24:23 -0700 Subject: [PATCH 5/5] dependency update --- README.md | 4 ++-- package.json | 4 +++- pyproject.toml | 2 +- uv.lock | 8 ++++---- yarn.lock | 16 ++++++++-------- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 576816a2..1970f36d 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ https://github.com/user-attachments/assets/8e4f8a08-6423-4227-a1f7-559e0126ce31 ## News 🔥🔥🔥 -[07-23-2026] **Data Formulator 0.8 alpha** (a1–a3, latest: 0.8.0a3) includes: +[07-23-2026] **Data Formulator 0.8 alpha** (a1–a4, latest: 0.8.0a4) includes: - **Conversational database loading.** Agents can discover relevant tables, propose filters, preview results, and revise a loading plan through conversation before importing data. - **Unified Data Thread.** Questions, clarifications, explanations, tables, and charts share one conversation history, with branching from earlier steps into new questions, calculated columns, or visualizations. @@ -53,7 +53,7 @@ https://github.com/user-attachments/assets/8e4f8a08-6423-4227-a1f7-559e0126ce31 - **Microsoft authentication for enterprise connectors.** SQL Server supports passwordless Microsoft Entra ID authentication through `az login`, including an in-app flow for local deployments. Kusto supports delegated Microsoft sign-in alongside Azure default identity and service principal authentication. - **Connector setup and diagnostics.** Connection forms separate connection, scope, and source-specific authentication options. Persistent server logs and an in-app log viewer help diagnose failures. -> Preview with `pip install --pre data_formulator==0.8.0a3` or `uvx data_formulator@0.8.0a3`. +> Preview with `pip install --pre data_formulator==0.8.0a4` or `uvx data_formulator@0.8.0a4`. > Install the latest stable release (0.7) with `pip install data_formulator` or run instantly with `uvx data_formulator`. diff --git a/package.json b/package.json index 5ff8f332..e1486360 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "dompurify": "^3.4.2", "markdown-it": "^14.3.0", "linkify-it": "^5.0.2", - "undici": "^7.28.0" + "undici": "^7.28.0", + "immutable": "^5.1.9", + "uuid": "^11.1.1" }, "dependencies": { "@azure/msal-browser": "^5.6.3", diff --git a/pyproject.toml b/pyproject.toml index 05e61d2e..663c4e3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "data_formulator" -version = "0.8.0a3" +version = "0.8.0a4" requires-python = ">=3.11" authors = [ diff --git a/uv.lock b/uv.lock index d83d8212..d2af5b05 100644 --- a/uv.lock +++ b/uv.lock @@ -604,7 +604,7 @@ wheels = [ [[package]] name = "data-formulator" -version = "0.8.0a3" +version = "0.8.0a4" source = { editable = "." } dependencies = [ { name = "azure-cosmos" }, @@ -2644,11 +2644,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] diff --git a/yarn.lock b/yarn.lock index 14b4c821..b5650101 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3792,10 +3792,10 @@ immer@^9.0.21: resolved "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz" integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA== -immutable@^5.1.5: - version "5.1.5" - resolved "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz" - integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A== +immutable@^5.1.5, immutable@^5.1.9: + version "5.1.9" + resolved "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/immutable/-/immutable-5.1.9.tgz#ac23c3a01992ab665e14ac9ffff298f28cd74a0c" + integrity sha1-rCPDoBmSq2ZeFKyf//KY8ozXSgw= import-fresh@^3.2.1: version "3.3.1" @@ -6593,10 +6593,10 @@ utrie@^1.0.2: dependencies: base64-arraybuffer "^1.0.2" -uuid@^8.3.0: - version "8.3.2" - resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@^11.1.1, uuid@^8.3.0: + version "11.1.1" + resolved "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad" + integrity sha1-9tgdLhxl0Adi5eKbFsXS2ZXiCK0= validator@^13.15.20: version "13.15.26"