mirror of
https://github.com/apache/superset.git
synced 2026-06-30 03:45:33 +00:00
Compare commits
1 Commits
chore/ci/s
...
amin/mcp-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b6ee2522c |
46
superset/commands/css/create.py
Normal file
46
superset/commands/css/create.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from superset.commands.base import BaseCommand
|
||||||
|
from superset.commands.css.exceptions import (
|
||||||
|
CssTemplateCreateFailedError,
|
||||||
|
CssTemplateInvalidError,
|
||||||
|
)
|
||||||
|
from superset.daos.css import CssTemplateDAO
|
||||||
|
from superset.models.core import CssTemplate
|
||||||
|
from superset.utils.decorators import on_error, transaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCssTemplateCommand(BaseCommand):
|
||||||
|
def __init__(self, properties: dict[str, Any]):
|
||||||
|
self._properties = properties
|
||||||
|
|
||||||
|
@transaction(on_error=partial(on_error, reraise=CssTemplateCreateFailedError))
|
||||||
|
def run(self) -> CssTemplate:
|
||||||
|
self.validate()
|
||||||
|
return CssTemplateDAO.create(attributes=self._properties)
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
if not self._properties.get("template_name", "").strip():
|
||||||
|
raise CssTemplateInvalidError()
|
||||||
|
if "css" not in self._properties:
|
||||||
|
raise CssTemplateInvalidError()
|
||||||
@@ -16,7 +16,13 @@
|
|||||||
# under the License.
|
# under the License.
|
||||||
from flask_babel import lazy_gettext as _
|
from flask_babel import lazy_gettext as _
|
||||||
|
|
||||||
from superset.commands.exceptions import CommandException, DeleteFailedError
|
from superset.commands.exceptions import (
|
||||||
|
CommandException,
|
||||||
|
CommandInvalidError,
|
||||||
|
CreateFailedError,
|
||||||
|
DeleteFailedError,
|
||||||
|
UpdateFailedError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CssTemplateDeleteFailedError(DeleteFailedError):
|
class CssTemplateDeleteFailedError(DeleteFailedError):
|
||||||
@@ -25,3 +31,15 @@ class CssTemplateDeleteFailedError(DeleteFailedError):
|
|||||||
|
|
||||||
class CssTemplateNotFoundError(CommandException):
|
class CssTemplateNotFoundError(CommandException):
|
||||||
message = _("CSS template not found.")
|
message = _("CSS template not found.")
|
||||||
|
|
||||||
|
|
||||||
|
class CssTemplateCreateFailedError(CreateFailedError):
|
||||||
|
message = _("CSS template could not be created.")
|
||||||
|
|
||||||
|
|
||||||
|
class CssTemplateInvalidError(CommandInvalidError):
|
||||||
|
message = _("CSS template parameters are invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
class CssTemplateUpdateFailedError(UpdateFailedError):
|
||||||
|
message = _("CSS template could not be updated.")
|
||||||
|
|||||||
53
superset/commands/css/update.py
Normal file
53
superset/commands/css/update.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from superset.commands.base import BaseCommand
|
||||||
|
from superset.commands.css.exceptions import (
|
||||||
|
CssTemplateInvalidError,
|
||||||
|
CssTemplateNotFoundError,
|
||||||
|
CssTemplateUpdateFailedError,
|
||||||
|
)
|
||||||
|
from superset.daos.css import CssTemplateDAO
|
||||||
|
from superset.models.core import CssTemplate
|
||||||
|
from superset.utils.decorators import on_error, transaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCssTemplateCommand(BaseCommand):
|
||||||
|
def __init__(self, model_id: int, properties: dict[str, Any]):
|
||||||
|
self._model_id = model_id
|
||||||
|
self._properties = properties
|
||||||
|
self._model: CssTemplate | None = None
|
||||||
|
|
||||||
|
@transaction(on_error=partial(on_error, reraise=CssTemplateUpdateFailedError))
|
||||||
|
def run(self) -> CssTemplate:
|
||||||
|
self.validate()
|
||||||
|
assert self._model
|
||||||
|
return CssTemplateDAO.update(self._model, attributes=self._properties)
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
self._model = CssTemplateDAO.find_by_id(self._model_id)
|
||||||
|
if not self._model:
|
||||||
|
raise CssTemplateNotFoundError()
|
||||||
|
template_name = self._properties.get("template_name")
|
||||||
|
if template_name is not None and not template_name.strip():
|
||||||
|
raise CssTemplateInvalidError()
|
||||||
@@ -148,6 +148,8 @@ Database Connections:
|
|||||||
CSS Templates:
|
CSS Templates:
|
||||||
- list_css_templates: List CSS templates with advanced filters (1-based pagination)
|
- list_css_templates: List CSS templates with advanced filters (1-based pagination)
|
||||||
- get_css_template_info: Get CSS template details by ID (includes full css content)
|
- get_css_template_info: Get CSS template details by ID (includes full css content)
|
||||||
|
- create_css_template: Create a new named CSS template for dashboard styling
|
||||||
|
- update_css_template: Update an existing CSS template's name or CSS content
|
||||||
|
|
||||||
Themes:
|
Themes:
|
||||||
- list_themes: List themes with advanced filters (1-based pagination)
|
- list_themes: List themes with advanced filters (1-based pagination)
|
||||||
@@ -685,8 +687,10 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
|
|||||||
update_chart_preview,
|
update_chart_preview,
|
||||||
)
|
)
|
||||||
from superset.mcp_service.css_template.tool import ( # noqa: F401, E402
|
from superset.mcp_service.css_template.tool import ( # noqa: F401, E402
|
||||||
|
create_css_template,
|
||||||
get_css_template_info,
|
get_css_template_info,
|
||||||
list_css_templates,
|
list_css_templates,
|
||||||
|
update_css_template,
|
||||||
)
|
)
|
||||||
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
|
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
|
||||||
add_chart_to_existing_dashboard,
|
add_chart_to_existing_dashboard,
|
||||||
|
|||||||
@@ -278,3 +278,107 @@ def serialize_css_template_object(obj: Any) -> CssTemplateInfo | None:
|
|||||||
changed_by_name=getattr(obj, "changed_by_name", None) or None,
|
changed_by_name=getattr(obj, "changed_by_name", None) or None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCssTemplateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
template_name: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=250,
|
||||||
|
description="Name for the CSS template.",
|
||||||
|
)
|
||||||
|
css: str = Field(
|
||||||
|
...,
|
||||||
|
description="CSS content for the template.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("template_name")
|
||||||
|
@classmethod
|
||||||
|
def template_name_must_not_be_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("template_name must not be empty")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCssTemplateResponse(BaseModel):
|
||||||
|
"""Response schema for create_css_template."""
|
||||||
|
|
||||||
|
id: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="ID of the created CSS template. None if creation failed.",
|
||||||
|
)
|
||||||
|
template_name: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Name of the created CSS template.",
|
||||||
|
)
|
||||||
|
css: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="CSS content of the created template.",
|
||||||
|
)
|
||||||
|
error: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Error message if creation failed, otherwise null.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("error")
|
||||||
|
@classmethod
|
||||||
|
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
|
||||||
|
"""Sanitize error text before it is exposed to LLM context."""
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return sanitize_for_llm_context(value, field_path=("error",))
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCssTemplateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(..., description="ID of the CSS template to update.")
|
||||||
|
template_name: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=250,
|
||||||
|
description="New name for the CSS template.",
|
||||||
|
)
|
||||||
|
css: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="New CSS content for the template.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("template_name")
|
||||||
|
@classmethod
|
||||||
|
def template_name_must_not_be_empty(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("template_name must not be empty")
|
||||||
|
return v.strip()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCssTemplateResponse(BaseModel):
|
||||||
|
"""Response schema for update_css_template."""
|
||||||
|
|
||||||
|
id: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="ID of the updated CSS template. None if update failed.",
|
||||||
|
)
|
||||||
|
template_name: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Name of the updated CSS template.",
|
||||||
|
)
|
||||||
|
css: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="CSS content of the updated template.",
|
||||||
|
)
|
||||||
|
error: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Error message if update failed, otherwise null.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("error")
|
||||||
|
@classmethod
|
||||||
|
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
|
||||||
|
"""Sanitize error text before it is exposed to LLM context."""
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return sanitize_for_llm_context(value, field_path=("error",))
|
||||||
|
|||||||
@@ -15,10 +15,14 @@
|
|||||||
# specific language governing permissions and limitations
|
# specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
from .create_css_template import create_css_template
|
||||||
from .get_css_template_info import get_css_template_info
|
from .get_css_template_info import get_css_template_info
|
||||||
from .list_css_templates import list_css_templates
|
from .list_css_templates import list_css_templates
|
||||||
|
from .update_css_template import update_css_template
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"list_css_templates",
|
"list_css_templates",
|
||||||
"get_css_template_info",
|
"get_css_template_info",
|
||||||
|
"create_css_template",
|
||||||
|
"update_css_template",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from fastmcp import Context
|
||||||
|
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||||
|
|
||||||
|
from superset.extensions import event_logger
|
||||||
|
from superset.mcp_service.css_template.schemas import (
|
||||||
|
CreateCssTemplateRequest,
|
||||||
|
CreateCssTemplateResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
tags=["mutate"],
|
||||||
|
class_permission_name="CssTemplate",
|
||||||
|
method_permission_name="write",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
title="Create CSS template",
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def create_css_template(
|
||||||
|
request: CreateCssTemplateRequest, ctx: Context
|
||||||
|
) -> CreateCssTemplateResponse:
|
||||||
|
"""Create a new CSS template that can be applied to dashboards.
|
||||||
|
|
||||||
|
Use this tool when a user wants to save a CSS stylesheet as a named
|
||||||
|
template for reuse across multiple dashboards.
|
||||||
|
|
||||||
|
The returned ``id`` can be used when configuring dashboard appearance.
|
||||||
|
"""
|
||||||
|
await ctx.info("Creating CSS template: template_name=%r" % (request.template_name,))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from superset.commands.css.create import CreateCssTemplateCommand
|
||||||
|
from superset.commands.css.exceptions import (
|
||||||
|
CssTemplateCreateFailedError,
|
||||||
|
CssTemplateInvalidError,
|
||||||
|
)
|
||||||
|
|
||||||
|
with event_logger.log_context(action="mcp.create_css_template.create"):
|
||||||
|
template = CreateCssTemplateCommand(
|
||||||
|
{
|
||||||
|
"template_name": request.template_name,
|
||||||
|
"css": request.css,
|
||||||
|
}
|
||||||
|
).run()
|
||||||
|
|
||||||
|
await ctx.info(
|
||||||
|
"CSS template created: id=%s, template_name=%r"
|
||||||
|
% (template.id, template.template_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateCssTemplateResponse(
|
||||||
|
id=template.id,
|
||||||
|
template_name=template.template_name,
|
||||||
|
css=template.css,
|
||||||
|
)
|
||||||
|
|
||||||
|
except CssTemplateInvalidError as exc:
|
||||||
|
await ctx.warning("CSS template validation failed: %s" % (str(exc),))
|
||||||
|
return CreateCssTemplateResponse(
|
||||||
|
template_name=request.template_name,
|
||||||
|
css=request.css,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
except CssTemplateCreateFailedError as exc:
|
||||||
|
await ctx.error("CSS template creation failed: %s" % (str(exc),))
|
||||||
|
return CreateCssTemplateResponse(
|
||||||
|
template_name=request.template_name,
|
||||||
|
css=request.css,
|
||||||
|
error=f"Failed to create CSS template: {exc}",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
await ctx.error(
|
||||||
|
"Unexpected error creating CSS template: %s: %s"
|
||||||
|
% (type(exc).__name__, str(exc))
|
||||||
|
)
|
||||||
|
raise
|
||||||
111
superset/mcp_service/css_template/tool/update_css_template.py
Normal file
111
superset/mcp_service/css_template/tool/update_css_template.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastmcp import Context
|
||||||
|
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||||
|
|
||||||
|
from superset.extensions import event_logger
|
||||||
|
from superset.mcp_service.css_template.schemas import (
|
||||||
|
UpdateCssTemplateRequest,
|
||||||
|
UpdateCssTemplateResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
tags=["mutate"],
|
||||||
|
class_permission_name="CssTemplate",
|
||||||
|
method_permission_name="write",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
title="Update CSS template",
|
||||||
|
readOnlyHint=False,
|
||||||
|
destructiveHint=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def update_css_template(
|
||||||
|
request: UpdateCssTemplateRequest, ctx: Context
|
||||||
|
) -> UpdateCssTemplateResponse:
|
||||||
|
"""Update an existing CSS template's name or CSS content.
|
||||||
|
|
||||||
|
Use this tool when a user wants to rename a CSS template or replace its
|
||||||
|
CSS content. At least one of ``template_name`` or ``css`` must be provided.
|
||||||
|
|
||||||
|
The template is identified by its ``id``.
|
||||||
|
"""
|
||||||
|
await ctx.info(
|
||||||
|
"Updating CSS template: id=%s, fields=%r"
|
||||||
|
% (
|
||||||
|
request.id,
|
||||||
|
[f for f in ("template_name", "css") if getattr(request, f) is not None],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from superset.commands.css.exceptions import (
|
||||||
|
CssTemplateInvalidError,
|
||||||
|
CssTemplateNotFoundError,
|
||||||
|
CssTemplateUpdateFailedError,
|
||||||
|
)
|
||||||
|
from superset.commands.css.update import UpdateCssTemplateCommand
|
||||||
|
|
||||||
|
properties: dict[str, Any] = {}
|
||||||
|
if request.template_name is not None:
|
||||||
|
properties["template_name"] = request.template_name
|
||||||
|
if request.css is not None:
|
||||||
|
properties["css"] = request.css
|
||||||
|
|
||||||
|
if not properties:
|
||||||
|
return UpdateCssTemplateResponse(
|
||||||
|
error="At least one of template_name or css must be provided.",
|
||||||
|
)
|
||||||
|
|
||||||
|
with event_logger.log_context(action="mcp.update_css_template.update"):
|
||||||
|
template = UpdateCssTemplateCommand(request.id, properties).run()
|
||||||
|
|
||||||
|
await ctx.info(
|
||||||
|
"CSS template updated: id=%s, template_name=%r"
|
||||||
|
% (template.id, template.template_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return UpdateCssTemplateResponse(
|
||||||
|
id=template.id,
|
||||||
|
template_name=template.template_name,
|
||||||
|
css=template.css,
|
||||||
|
)
|
||||||
|
|
||||||
|
except CssTemplateNotFoundError:
|
||||||
|
await ctx.warning("CSS template not found: id=%s" % (request.id,))
|
||||||
|
return UpdateCssTemplateResponse(
|
||||||
|
error="CSS template not found: %s" % (request.id,),
|
||||||
|
)
|
||||||
|
except CssTemplateInvalidError as exc:
|
||||||
|
await ctx.warning("CSS template validation failed: %s" % (str(exc),))
|
||||||
|
return UpdateCssTemplateResponse(
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
except CssTemplateUpdateFailedError as exc:
|
||||||
|
await ctx.error("CSS template update failed: %s" % (str(exc),))
|
||||||
|
return UpdateCssTemplateResponse(
|
||||||
|
error="Failed to update CSS template: %s" % (exc,),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
await ctx.error(
|
||||||
|
"Unexpected error updating CSS template: %s: %s"
|
||||||
|
% (type(exc).__name__, str(exc))
|
||||||
|
)
|
||||||
|
raise
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastmcp import Client
|
||||||
|
|
||||||
|
from superset.mcp_service.app import mcp
|
||||||
|
from superset.mcp_service.css_template.schemas import CreateCssTemplateRequest
|
||||||
|
from superset.utils import json
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mcp_server():
|
||||||
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_auth():
|
||||||
|
"""Mock authentication for all tests."""
|
||||||
|
with patch(
|
||||||
|
"superset.mcp_service.auth.get_user_from_request",
|
||||||
|
return_value=Mock(is_authenticated=True),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_css_template_request_valid() -> None:
|
||||||
|
req = CreateCssTemplateRequest(
|
||||||
|
template_name="My Theme",
|
||||||
|
css=".header { color: red; }",
|
||||||
|
)
|
||||||
|
assert req.template_name == "My Theme"
|
||||||
|
assert req.css == ".header { color: red; }"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_css_template_request_strips_name_whitespace() -> None:
|
||||||
|
req = CreateCssTemplateRequest(
|
||||||
|
template_name=" My Theme ",
|
||||||
|
css=".header { color: red; }",
|
||||||
|
)
|
||||||
|
assert req.template_name == "My Theme"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_css_template_request_empty_name_fails() -> None:
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="template_name must not be empty"):
|
||||||
|
CreateCssTemplateRequest(template_name=" ", css=".header { color: red; }")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_css_template_request_name_too_long() -> None:
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
CreateCssTemplateRequest(template_name="a" * 251, css="")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool logic tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_css_template_success(mcp_server: object) -> None:
|
||||||
|
"""Happy path: template created, id and fields returned."""
|
||||||
|
mock_template = MagicMock()
|
||||||
|
mock_template.id = 7
|
||||||
|
mock_template.template_name = "Dark Theme"
|
||||||
|
mock_template.css = "body { background: #000; }"
|
||||||
|
|
||||||
|
mock_command = MagicMock()
|
||||||
|
mock_command.run.return_value = mock_template
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"superset.commands.css.create.CreateCssTemplateCommand",
|
||||||
|
return_value=mock_command,
|
||||||
|
):
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
request = CreateCssTemplateRequest(
|
||||||
|
template_name="Dark Theme",
|
||||||
|
css="body { background: #000; }",
|
||||||
|
)
|
||||||
|
result = await client.call_tool(
|
||||||
|
"create_css_template", {"request": request.model_dump()}
|
||||||
|
)
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert data["id"] == 7
|
||||||
|
assert data["template_name"] == "Dark Theme"
|
||||||
|
assert data["css"] == "body { background: #000; }"
|
||||||
|
assert data["error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_css_template_invalid_error(mcp_server: object) -> None:
|
||||||
|
"""CssTemplateInvalidError is caught and returned as an error response."""
|
||||||
|
from superset.commands.css.exceptions import CssTemplateInvalidError
|
||||||
|
|
||||||
|
mock_command = MagicMock()
|
||||||
|
mock_command.run.side_effect = CssTemplateInvalidError()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"superset.commands.css.create.CreateCssTemplateCommand",
|
||||||
|
return_value=mock_command,
|
||||||
|
):
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
request = CreateCssTemplateRequest(
|
||||||
|
template_name="Bad Template",
|
||||||
|
css="",
|
||||||
|
)
|
||||||
|
result = await client.call_tool(
|
||||||
|
"create_css_template", {"request": request.model_dump()}
|
||||||
|
)
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert data["id"] is None
|
||||||
|
assert data["error"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_css_template_create_failed(mcp_server: object) -> None:
|
||||||
|
"""CssTemplateCreateFailedError is caught and returned as an error response."""
|
||||||
|
from superset.commands.css.exceptions import CssTemplateCreateFailedError
|
||||||
|
|
||||||
|
mock_command = MagicMock()
|
||||||
|
mock_command.run.side_effect = CssTemplateCreateFailedError()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"superset.commands.css.create.CreateCssTemplateCommand",
|
||||||
|
return_value=mock_command,
|
||||||
|
):
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
request = CreateCssTemplateRequest(
|
||||||
|
template_name="Failing Template",
|
||||||
|
css=".x { color: blue; }",
|
||||||
|
)
|
||||||
|
result = await client.call_tool(
|
||||||
|
"create_css_template", {"request": request.model_dump()}
|
||||||
|
)
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert data["id"] is None
|
||||||
|
assert data["error"] is not None
|
||||||
|
assert "Failed to create CSS template" in data["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_css_template_unexpected_exception_is_reraised(
|
||||||
|
mcp_server: object,
|
||||||
|
) -> None:
|
||||||
|
"""Unexpected exceptions are re-raised (not swallowed as error responses)."""
|
||||||
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
|
mock_command = MagicMock()
|
||||||
|
mock_command.run.side_effect = RuntimeError("unexpected database error")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"superset.commands.css.create.CreateCssTemplateCommand",
|
||||||
|
return_value=mock_command,
|
||||||
|
):
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
with pytest.raises((RuntimeError, ToolError)):
|
||||||
|
await client.call_tool(
|
||||||
|
"create_css_template",
|
||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"template_name": "Theme",
|
||||||
|
"css": ".x { color: red; }",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastmcp import Client
|
||||||
|
|
||||||
|
from superset.mcp_service.app import mcp
|
||||||
|
from superset.mcp_service.css_template.schemas import UpdateCssTemplateRequest
|
||||||
|
from superset.utils import json
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mcp_server():
|
||||||
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_auth():
|
||||||
|
"""Mock authentication for all tests."""
|
||||||
|
with patch(
|
||||||
|
"superset.mcp_service.auth.get_user_from_request",
|
||||||
|
return_value=Mock(is_authenticated=True),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_css_template_request_valid_name_only() -> None:
|
||||||
|
req = UpdateCssTemplateRequest(id=1, template_name="New Name")
|
||||||
|
assert req.id == 1
|
||||||
|
assert req.template_name == "New Name"
|
||||||
|
assert req.css is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_css_template_request_valid_css_only() -> None:
|
||||||
|
req = UpdateCssTemplateRequest(id=5, css=".body { color: blue; }")
|
||||||
|
assert req.id == 5
|
||||||
|
assert req.css == ".body { color: blue; }"
|
||||||
|
assert req.template_name is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_css_template_request_strips_name_whitespace() -> None:
|
||||||
|
req = UpdateCssTemplateRequest(id=1, template_name=" Padded ")
|
||||||
|
assert req.template_name == "Padded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_css_template_request_empty_name_fails() -> None:
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="template_name must not be empty"):
|
||||||
|
UpdateCssTemplateRequest(id=1, template_name=" ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_css_template_request_name_too_long() -> None:
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
UpdateCssTemplateRequest(id=1, template_name="x" * 251)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool logic tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_css_template_success(mcp_server: object) -> None:
|
||||||
|
"""Happy path: template updated, all fields returned."""
|
||||||
|
mock_template = MagicMock()
|
||||||
|
mock_template.id = 3
|
||||||
|
mock_template.template_name = "Updated Theme"
|
||||||
|
mock_template.css = "body { background: #fff; }"
|
||||||
|
|
||||||
|
mock_command = MagicMock()
|
||||||
|
mock_command.run.return_value = mock_template
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"superset.commands.css.update.UpdateCssTemplateCommand",
|
||||||
|
return_value=mock_command,
|
||||||
|
):
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
request = UpdateCssTemplateRequest(
|
||||||
|
id=3,
|
||||||
|
template_name="Updated Theme",
|
||||||
|
css="body { background: #fff; }",
|
||||||
|
)
|
||||||
|
result = await client.call_tool(
|
||||||
|
"update_css_template", {"request": request.model_dump()}
|
||||||
|
)
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert data["id"] == 3
|
||||||
|
assert data["template_name"] == "Updated Theme"
|
||||||
|
assert data["css"] == "body { background: #fff; }"
|
||||||
|
assert data["error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_css_template_no_fields_returns_error(
|
||||||
|
mcp_server: object,
|
||||||
|
) -> None:
|
||||||
|
"""Calling with neither template_name nor css returns a structured error."""
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
result = await client.call_tool("update_css_template", {"request": {"id": 1}})
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert data["id"] is None
|
||||||
|
assert "At least one" in data["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_css_template_not_found(mcp_server: object) -> None:
|
||||||
|
"""CssTemplateNotFoundError is caught and returned as an error response."""
|
||||||
|
from superset.commands.css.exceptions import CssTemplateNotFoundError
|
||||||
|
|
||||||
|
mock_command = MagicMock()
|
||||||
|
mock_command.run.side_effect = CssTemplateNotFoundError()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"superset.commands.css.update.UpdateCssTemplateCommand",
|
||||||
|
return_value=mock_command,
|
||||||
|
):
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
request = UpdateCssTemplateRequest(id=999, template_name="Ghost")
|
||||||
|
result = await client.call_tool(
|
||||||
|
"update_css_template", {"request": request.model_dump()}
|
||||||
|
)
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert data["id"] is None
|
||||||
|
assert "not found" in data["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_css_template_invalid_error(mcp_server: object) -> None:
|
||||||
|
"""CssTemplateInvalidError is caught and returned as an error response."""
|
||||||
|
from superset.commands.css.exceptions import CssTemplateInvalidError
|
||||||
|
|
||||||
|
mock_command = MagicMock()
|
||||||
|
mock_command.run.side_effect = CssTemplateInvalidError()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"superset.commands.css.update.UpdateCssTemplateCommand",
|
||||||
|
return_value=mock_command,
|
||||||
|
):
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
request = UpdateCssTemplateRequest(id=1, template_name="Valid Name")
|
||||||
|
result = await client.call_tool(
|
||||||
|
"update_css_template", {"request": request.model_dump()}
|
||||||
|
)
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert data["id"] is None
|
||||||
|
assert data["error"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_css_template_update_failed(mcp_server: object) -> None:
|
||||||
|
"""CssTemplateUpdateFailedError is caught and returned as an error response."""
|
||||||
|
from superset.commands.css.exceptions import CssTemplateUpdateFailedError
|
||||||
|
|
||||||
|
mock_command = MagicMock()
|
||||||
|
mock_command.run.side_effect = CssTemplateUpdateFailedError()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"superset.commands.css.update.UpdateCssTemplateCommand",
|
||||||
|
return_value=mock_command,
|
||||||
|
):
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
request = UpdateCssTemplateRequest(id=1, css=".x { color: red; }")
|
||||||
|
result = await client.call_tool(
|
||||||
|
"update_css_template", {"request": request.model_dump()}
|
||||||
|
)
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert data["id"] is None
|
||||||
|
assert "Failed to update CSS template" in data["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_css_template_unexpected_exception_is_reraised(
|
||||||
|
mcp_server: object,
|
||||||
|
) -> None:
|
||||||
|
"""Unexpected exceptions are re-raised (not swallowed as error responses)."""
|
||||||
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
|
mock_command = MagicMock()
|
||||||
|
mock_command.run.side_effect = RuntimeError("unexpected database error")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"superset.commands.css.update.UpdateCssTemplateCommand",
|
||||||
|
return_value=mock_command,
|
||||||
|
):
|
||||||
|
async with Client(mcp_server) as client:
|
||||||
|
with pytest.raises((RuntimeError, ToolError)):
|
||||||
|
await client.call_tool(
|
||||||
|
"update_css_template",
|
||||||
|
{"request": {"id": 1, "css": ".x { color: red; }"}},
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user