@qingflow-tech/qingflow-cli 1.1.61-beta.4 → 1.1.61-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +13 -0
- package/skills/qingflow-cli/reference/builder/70-portal.md +37 -16
- package/skills/qingflow-cli/reference/examples/portal/portal_apply_v1alpha1.example.json +100 -0
- package/skills/qingflow-cli/reference/examples/portal/portal_sections_all_types.example.json +6 -7
- package/src/qingflow_mcp/app_form_validator.py +20 -3
- package/src/qingflow_mcp/builder_facade/app_form.py +137 -17
- package/src/qingflow_mcp/builder_facade/models.py +164 -13
- package/src/qingflow_mcp/builder_facade/portals_schema.py +397 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +14 -1
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +49 -9
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +27 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_classification.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_extraction.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_generation.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_document_parsing.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_generation.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_recognition.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_machine_translation.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_text_summary.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +2 -2
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +27 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date_duration.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/embed_field.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/image_select.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/location.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +2 -2
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ocr.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +2 -2
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +27 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +29 -2
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +20 -7
- package/src/qingflow_mcp/builder_facade/schemas/portals/manifest.json +59 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/complete.json +1397 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/carousel.json +193 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/chart.json +643 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/favourite.json +203 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/filter.json +268 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/grid.json +265 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/link.json +131 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/task.json +199 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/text.json +123 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/view.json +196 -0
- package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/summary.json +800 -0
- package/src/qingflow_mcp/builder_facade/service.py +1104 -71
- package/src/qingflow_mcp/cli/commands/builder.py +114 -5
- package/src/qingflow_mcp/public_surface.py +2 -0
- package/src/qingflow_mcp/server_app_builder.py +32 -1
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +73 -0
- package/src/qingflow_mcp/solution/executor.py +5 -2
- package/src/qingflow_mcp/tools/ai_builder_tools.py +110 -12
- package/src/qingflow_mcp/tools/portal_tools.py +4 -54
- package/src/qingflow_mcp/tools/portal_writer.py +59 -0
- package/src/qingflow_mcp/tools/solution_tools.py +2 -0
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
from enum import Enum
|
|
5
|
-
from typing import Any, Union
|
|
5
|
+
from typing import Any, Literal, Union
|
|
6
6
|
|
|
7
7
|
from pydantic import AliasChoices, Field, model_validator
|
|
8
8
|
|
|
@@ -2508,14 +2508,17 @@ class PortalInlineChartPatch(ChartUpsertPatch):
|
|
|
2508
2508
|
|
|
2509
2509
|
|
|
2510
2510
|
class PortalComponentPositionPatch(StrictModel):
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2511
|
+
# 后端语义(DashSvcImpl 读/写链):position 各坐标可为 null——生产数据中筛选组件
|
|
2512
|
+
# (type=6)的 x/y/cols/rows 原生为 NULL,读面透传 null、写链 setPosition 直存 null。
|
|
2513
|
+
# 因此显式 null 必须原样透传(round-trip 保真);仅键缺省时才回落布局默认值。
|
|
2514
|
+
pc_x: int | None = Field(default=0, validation_alias=AliasChoices("pc_x", "pcX", "x"))
|
|
2515
|
+
pc_y: int | None = Field(default=0, validation_alias=AliasChoices("pc_y", "pcY", "y"))
|
|
2516
|
+
pc_w: int | None = Field(default=12, validation_alias=AliasChoices("pc_w", "pcW", "w"))
|
|
2517
|
+
pc_h: int | None = Field(default=8, validation_alias=AliasChoices("pc_h", "pcH", "h"))
|
|
2518
|
+
mobile_x: int | None = Field(default=0, validation_alias=AliasChoices("mobile_x", "mobileX"))
|
|
2519
|
+
mobile_y: int | None = Field(default=0, validation_alias=AliasChoices("mobile_y", "mobileY"))
|
|
2520
|
+
mobile_w: int | None = Field(default=6, validation_alias=AliasChoices("mobile_w", "mobileW"))
|
|
2521
|
+
mobile_h: int | None = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
|
|
2519
2522
|
mobile_provided: bool = Field(default=False, exclude=True)
|
|
2520
2523
|
|
|
2521
2524
|
@model_validator(mode="before")
|
|
@@ -2554,6 +2557,10 @@ class PortalChartRefPatch(StrictModel):
|
|
|
2554
2557
|
app_key: str | None = None
|
|
2555
2558
|
chart_id: str | None = None
|
|
2556
2559
|
chart_name: str | None = None
|
|
2560
|
+
# 引用面判别:后端有两类图表组件——轻流报表(DASH_CONTENT_TYPE_CHART=1,chart 表唯一键
|
|
2561
|
+
# chartKey,写链 addChartInfos 按 chartKey 解析)与轻析 BI 报表(DASH_CONTENT_TYPE_BICHART=9,
|
|
2562
|
+
# bi_chart 表整型 chart_id,写链 addBiChartInfo 直存)。缺省 bi_chart(向后兼容既有声明)。
|
|
2563
|
+
ref_kind: Literal["chart", "bi_chart"] | None = None
|
|
2557
2564
|
|
|
2558
2565
|
@model_validator(mode="before")
|
|
2559
2566
|
@classmethod
|
|
@@ -2573,12 +2580,18 @@ class PortalChartRefPatch(StrictModel):
|
|
|
2573
2580
|
payload["chart_name"] = payload.pop("name")
|
|
2574
2581
|
if "appKey" in payload and "app_key" not in payload:
|
|
2575
2582
|
payload["app_key"] = payload.pop("appKey")
|
|
2583
|
+
if "refKind" in payload and "ref_kind" not in payload:
|
|
2584
|
+
payload["ref_kind"] = payload.pop("refKind")
|
|
2585
|
+
if "kind" in payload and "ref_kind" not in payload:
|
|
2586
|
+
payload["ref_kind"] = payload.pop("kind")
|
|
2576
2587
|
return payload
|
|
2577
2588
|
|
|
2578
2589
|
@model_validator(mode="after")
|
|
2579
2590
|
def validate_target(self) -> "PortalChartRefPatch":
|
|
2580
2591
|
if not (self.chart_id or self.chart_name):
|
|
2581
2592
|
raise ValueError("chart_ref requires chart_id or chart_name")
|
|
2593
|
+
if self.ref_kind == "chart" and not self.chart_id:
|
|
2594
|
+
raise ValueError("chart_ref with ref_kind=chart requires chart_id (chart 表唯一键 chartKey)")
|
|
2582
2595
|
if self.chart_name and not self.chart_id and not self.app_key:
|
|
2583
2596
|
raise ValueError("chart_ref.app_key is required when resolving by chart_name")
|
|
2584
2597
|
return self
|
|
@@ -2604,17 +2617,49 @@ class PortalViewRefPatch(StrictModel):
|
|
|
2604
2617
|
|
|
2605
2618
|
|
|
2606
2619
|
class PortalSectionPatch(StrictModel):
|
|
2607
|
-
title
|
|
2620
|
+
# title 语义=组件显示标题(draft_dash_chart.component_title / grid_config.gridTitle 等),
|
|
2621
|
+
# 生产存量存在空标题形态(显示标题开但标题空,读链回退视图名/图表名)——None=不设标题,
|
|
2622
|
+
# 编译对 None 不落库(后端透传 NULL),round-trip 保真;手写声明缺省 title=None(不设)。
|
|
2623
|
+
title: str | None = None
|
|
2608
2624
|
source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
|
|
2609
2625
|
role: str | None = Field(default=None, validation_alias=AliasChoices("role", "zone", "section_role", "sectionRole"))
|
|
2626
|
+
# 显示标题开关(draft_dash_chart.being_show_title):提取侧读面必有值(True/False);
|
|
2627
|
+
# 声明缺省 None 时编译回落产品默认 True(与既有创建语义兼容)。
|
|
2628
|
+
show_title: bool | None = Field(
|
|
2629
|
+
default=None,
|
|
2630
|
+
validation_alias=AliasChoices("show_title", "showTitle", "being_show_title", "beingShowTitle"),
|
|
2631
|
+
)
|
|
2610
2632
|
position: PortalComponentPositionPatch | None = None
|
|
2611
|
-
dash_style_config: dict[str, Any] | None = Field(
|
|
2633
|
+
dash_style_config: dict[str, Any] | None = Field(
|
|
2634
|
+
default=None,
|
|
2635
|
+
validation_alias=AliasChoices("dash_style_config", "style_config", "dashStyleConfigBO", "dashStyleConfig"),
|
|
2636
|
+
)
|
|
2612
2637
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
2613
2638
|
chart_ref: PortalChartRefPatch | None = None
|
|
2614
2639
|
chart: PortalInlineChartPatch | None = Field(default=None, validation_alias=AliasChoices("chart", "inline_chart", "inlineChart", "create_chart", "createChart"))
|
|
2615
2640
|
view_ref: PortalViewRefPatch | None = None
|
|
2641
|
+
view_display: dict[str, Any] | None = Field(
|
|
2642
|
+
default=None,
|
|
2643
|
+
validation_alias=AliasChoices("view_display", "viewDisplay"),
|
|
2644
|
+
)
|
|
2616
2645
|
text: str | None = None
|
|
2617
2646
|
url: str | None = None
|
|
2647
|
+
login_auth: bool | None = Field(
|
|
2648
|
+
default=None,
|
|
2649
|
+
validation_alias=AliasChoices("login_auth", "loginAuth", "being_login_auth", "beingLoginAuth"),
|
|
2650
|
+
)
|
|
2651
|
+
carousel: dict[str, Any] | None = Field(
|
|
2652
|
+
default=None,
|
|
2653
|
+
validation_alias=AliasChoices("carousel", "carousel_config", "carouselConfig"),
|
|
2654
|
+
)
|
|
2655
|
+
task: dict[str, Any] | None = Field(
|
|
2656
|
+
default=None,
|
|
2657
|
+
validation_alias=AliasChoices("task", "task_config", "taskConfig"),
|
|
2658
|
+
)
|
|
2659
|
+
favourite: dict[str, Any] | None = Field(
|
|
2660
|
+
default=None,
|
|
2661
|
+
validation_alias=AliasChoices("favourite", "favourite_config", "favouriteConfig"),
|
|
2662
|
+
)
|
|
2618
2663
|
|
|
2619
2664
|
@model_validator(mode="before")
|
|
2620
2665
|
@classmethod
|
|
@@ -2659,7 +2704,7 @@ class PortalSectionPatch(StrictModel):
|
|
|
2659
2704
|
|
|
2660
2705
|
@model_validator(mode="after")
|
|
2661
2706
|
def validate_shape(self) -> "PortalSectionPatch":
|
|
2662
|
-
supported = {"chart", "view", "grid", "filter", "text", "link"}
|
|
2707
|
+
supported = {"chart", "view", "grid", "filter", "text", "link", "carousel", "task", "favourite"}
|
|
2663
2708
|
if self.source_type not in supported:
|
|
2664
2709
|
raise ValueError(f"unsupported portal source_type '{self.source_type}'")
|
|
2665
2710
|
if self.source_type == "chart" and self.chart_ref is None and self.chart is None:
|
|
@@ -2670,6 +2715,12 @@ class PortalSectionPatch(StrictModel):
|
|
|
2670
2715
|
raise ValueError("text section requires text")
|
|
2671
2716
|
if self.source_type == "link" and self.url is None:
|
|
2672
2717
|
raise ValueError("link section requires url")
|
|
2718
|
+
if self.source_type == "carousel" and self.carousel is None:
|
|
2719
|
+
raise ValueError("carousel section requires carousel")
|
|
2720
|
+
if self.source_type == "task" and self.task is None:
|
|
2721
|
+
raise ValueError("task section requires task")
|
|
2722
|
+
if self.source_type == "favourite" and self.favourite is None:
|
|
2723
|
+
raise ValueError("favourite section requires favourite")
|
|
2673
2724
|
return self
|
|
2674
2725
|
|
|
2675
2726
|
|
|
@@ -2685,8 +2736,20 @@ class PortalApplyRequest(StrictModel):
|
|
|
2685
2736
|
icon: str | None = None
|
|
2686
2737
|
color: str | None = None
|
|
2687
2738
|
hide_copyright: bool | None = Field(default=None, validation_alias=AliasChoices("hide_copyright", "hideCopyright"))
|
|
2688
|
-
|
|
2739
|
+
global_config: dict[str, Any] | None = Field(
|
|
2740
|
+
default=None,
|
|
2741
|
+
validation_alias=AliasChoices("global_config", "globalConfig", "dash_global_config", "dashGlobalConfig"),
|
|
2742
|
+
)
|
|
2743
|
+
style_template_id: int | None = Field(
|
|
2744
|
+
default=None,
|
|
2745
|
+
validation_alias=AliasChoices("style_template_id", "styleTemplateId"),
|
|
2746
|
+
)
|
|
2747
|
+
overall_style: dict[str, Any] | None = Field(
|
|
2748
|
+
default=None,
|
|
2749
|
+
validation_alias=AliasChoices("overall_style", "overallStyle", "overallConfigBO"),
|
|
2750
|
+
)
|
|
2689
2751
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
2752
|
+
schema_version: str | None = Field(default=None, validation_alias=AliasChoices("schema_version", "schemaVersion"))
|
|
2690
2753
|
|
|
2691
2754
|
@model_validator(mode="before")
|
|
2692
2755
|
@classmethod
|
|
@@ -2694,8 +2757,14 @@ class PortalApplyRequest(StrictModel):
|
|
|
2694
2757
|
if not isinstance(value, dict):
|
|
2695
2758
|
return value
|
|
2696
2759
|
payload = dict(value)
|
|
2760
|
+
payload.pop("schema_version", None)
|
|
2761
|
+
payload.pop("schemaVersion", None)
|
|
2697
2762
|
if "dash_name" not in payload and "dashName" not in payload and "name" in payload:
|
|
2698
2763
|
payload["dash_name"] = payload.pop("name")
|
|
2764
|
+
if "dash_global_config" in payload and "global_config" not in payload:
|
|
2765
|
+
payload["global_config"] = payload.pop("dash_global_config")
|
|
2766
|
+
elif "dashGlobalConfig" in payload and "global_config" not in payload:
|
|
2767
|
+
payload["global_config"] = payload.pop("dashGlobalConfig")
|
|
2699
2768
|
if "sections" not in payload and "pages" in payload:
|
|
2700
2769
|
pages = payload.pop("pages")
|
|
2701
2770
|
if not isinstance(pages, list):
|
|
@@ -2730,6 +2799,88 @@ class PortalApplyRequest(StrictModel):
|
|
|
2730
2799
|
return self
|
|
2731
2800
|
|
|
2732
2801
|
|
|
2802
|
+
def _public_portal_section_payload(section: PortalSectionPatch) -> dict[str, Any]:
|
|
2803
|
+
"""Dump one portal section with canonical naming for schema validation."""
|
|
2804
|
+
payload: dict[str, Any] = {
|
|
2805
|
+
"title": section.title,
|
|
2806
|
+
"source_type": section.source_type,
|
|
2807
|
+
}
|
|
2808
|
+
if section.role is not None:
|
|
2809
|
+
payload["role"] = section.role
|
|
2810
|
+
if section.position is not None:
|
|
2811
|
+
payload["position"] = {
|
|
2812
|
+
"pc": {
|
|
2813
|
+
"x": section.position.pc_x,
|
|
2814
|
+
"y": section.position.pc_y,
|
|
2815
|
+
"cols": section.position.pc_w,
|
|
2816
|
+
"rows": section.position.pc_h,
|
|
2817
|
+
},
|
|
2818
|
+
}
|
|
2819
|
+
if section.position.mobile_provided:
|
|
2820
|
+
payload["position"]["mobile"] = {
|
|
2821
|
+
"x": section.position.mobile_x,
|
|
2822
|
+
"y": section.position.mobile_y,
|
|
2823
|
+
"cols": section.position.mobile_w,
|
|
2824
|
+
"rows": section.position.mobile_h,
|
|
2825
|
+
}
|
|
2826
|
+
if section.dash_style_config is not None:
|
|
2827
|
+
payload["style_config"] = section.dash_style_config
|
|
2828
|
+
if section.chart_ref is not None:
|
|
2829
|
+
payload["chart_ref"] = section.chart_ref.model_dump(mode="json", exclude_none=True)
|
|
2830
|
+
if section.chart is not None:
|
|
2831
|
+
payload["chart"] = section.chart.model_dump(mode="json", exclude_none=True)
|
|
2832
|
+
if section.view_ref is not None:
|
|
2833
|
+
payload["view_ref"] = section.view_ref.model_dump(mode="json", exclude_none=True)
|
|
2834
|
+
if section.view_display is not None:
|
|
2835
|
+
payload["view_display"] = section.view_display
|
|
2836
|
+
if section.text is not None:
|
|
2837
|
+
payload["text"] = section.text
|
|
2838
|
+
if section.url is not None:
|
|
2839
|
+
payload["url"] = section.url
|
|
2840
|
+
if section.login_auth is not None:
|
|
2841
|
+
payload["login_auth"] = section.login_auth
|
|
2842
|
+
if section.carousel is not None:
|
|
2843
|
+
payload["carousel"] = section.carousel
|
|
2844
|
+
if section.task is not None:
|
|
2845
|
+
payload["task"] = section.task
|
|
2846
|
+
if section.favourite is not None:
|
|
2847
|
+
payload["favourite"] = section.favourite
|
|
2848
|
+
if section.config:
|
|
2849
|
+
payload["config"] = section.config
|
|
2850
|
+
return payload
|
|
2851
|
+
|
|
2852
|
+
|
|
2853
|
+
def public_portal_apply_payload(request: PortalApplyRequest) -> dict[str, Any]:
|
|
2854
|
+
"""Canonical-named portal declaration used for schema validation."""
|
|
2855
|
+
payload: dict[str, Any] = {}
|
|
2856
|
+
if request.dash_key:
|
|
2857
|
+
payload["dash_key"] = request.dash_key
|
|
2858
|
+
if request.dash_name is not None:
|
|
2859
|
+
payload["name"] = request.dash_name
|
|
2860
|
+
if request.package_tag_id is not None:
|
|
2861
|
+
payload["package_tag_id"] = request.package_tag_id
|
|
2862
|
+
payload["publish"] = request.publish
|
|
2863
|
+
if request.sections is not None:
|
|
2864
|
+
payload["sections"] = [_public_portal_section_payload(section) for section in request.sections]
|
|
2865
|
+
if request.layout_preset is not None:
|
|
2866
|
+
payload["layout_preset"] = request.layout_preset
|
|
2867
|
+
if request.visibility is not None:
|
|
2868
|
+
payload["visibility"] = request.visibility.model_dump(mode="json", exclude_none=True)
|
|
2869
|
+
if request.icon is not None:
|
|
2870
|
+
payload["icon"] = request.icon
|
|
2871
|
+
if request.color is not None:
|
|
2872
|
+
payload["color"] = request.color
|
|
2873
|
+
if request.hide_copyright is not None:
|
|
2874
|
+
payload["hide_copyright"] = request.hide_copyright
|
|
2875
|
+
if request.global_config is not None:
|
|
2876
|
+
payload["global_config"] = request.global_config
|
|
2877
|
+
if request.style_template_id is not None:
|
|
2878
|
+
payload["style_template_id"] = request.style_template_id
|
|
2879
|
+
if request.overall_style is not None:
|
|
2880
|
+
payload["overall_style"] = request.overall_style
|
|
2881
|
+
return payload
|
|
2882
|
+
|
|
2883
|
+
|
|
2733
2884
|
FieldPatch.model_rebuild()
|
|
2734
2885
|
FieldMutation.model_rebuild()
|
|
2735
2886
|
FieldUpdatePatch.model_rebuild()
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
11
|
+
from referencing import Registry, Resource
|
|
12
|
+
|
|
13
|
+
from .app_form_schema import compact_schema_errors, schema_error_message
|
|
14
|
+
|
|
15
|
+
JSONObject = dict[str, Any]
|
|
16
|
+
PORTALS_SCHEMA_ROOT = Path(__file__).with_name("schemas") / "portals"
|
|
17
|
+
PORTALS_SUPPORTED_SCHEMA_KINDS = (
|
|
18
|
+
"summary",
|
|
19
|
+
"complete",
|
|
20
|
+
"chart",
|
|
21
|
+
"view",
|
|
22
|
+
"grid",
|
|
23
|
+
"filter",
|
|
24
|
+
"text",
|
|
25
|
+
"link",
|
|
26
|
+
"carousel",
|
|
27
|
+
"task",
|
|
28
|
+
"favourite",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PortalsContractError(ValueError):
|
|
33
|
+
"""Raised when a portals declaration violates the versioned schema contract."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, error_code: str, message: str, *, path: str | None = None, details: JSONObject | None = None) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.error_code = error_code
|
|
38
|
+
self.message = message
|
|
39
|
+
self.path = path
|
|
40
|
+
self.details = details or {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def compact_none_values(value: Any) -> Any:
|
|
44
|
+
"""Recursively drop None values so pydantic dumps match the JSON Schema contract."""
|
|
45
|
+
if isinstance(value, dict):
|
|
46
|
+
return {key: compact_none_values(item) for key, item in value.items() if item is not None}
|
|
47
|
+
if isinstance(value, list):
|
|
48
|
+
return [compact_none_values(item) for item in value]
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class PortalsSchemaContext:
|
|
54
|
+
"""Resolved schema context for one portals schema version."""
|
|
55
|
+
|
|
56
|
+
schema_version: str
|
|
57
|
+
api_version: str
|
|
58
|
+
schemas: dict[str, JSONObject]
|
|
59
|
+
schema_files: dict[str, str]
|
|
60
|
+
ref_registry: Registry
|
|
61
|
+
supported_schema_kinds: tuple[str, ...]
|
|
62
|
+
contract_source: str
|
|
63
|
+
contract_version: str
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def summary_schema(self) -> JSONObject:
|
|
67
|
+
return self.schemas["summary"]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def complete_schema(self) -> JSONObject:
|
|
71
|
+
return self.schemas["complete"]
|
|
72
|
+
|
|
73
|
+
def schema_for_kind(self, schema_kind: str) -> JSONObject:
|
|
74
|
+
requested = str(schema_kind or "").strip()
|
|
75
|
+
schema = self.schemas.get(requested)
|
|
76
|
+
if schema is not None:
|
|
77
|
+
return schema
|
|
78
|
+
raise PortalsContractError(
|
|
79
|
+
"UNSUPPORTED_SCHEMA_KIND",
|
|
80
|
+
f"unsupported portals schema kind: {requested}",
|
|
81
|
+
details={"supportedKinds": list(self.supported_schema_kinds)},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _validate(self, payload: JSONObject, *, schema_kind: str) -> None:
|
|
85
|
+
schema = self.schema_for_kind(schema_kind)
|
|
86
|
+
validator = Draft202012Validator(schema, format_checker=FormatChecker(), registry=self.ref_registry)
|
|
87
|
+
errors = sorted(validator.iter_errors(payload), key=lambda item: (list(item.absolute_path), item.message))
|
|
88
|
+
if not errors:
|
|
89
|
+
return
|
|
90
|
+
compact_errors = compact_schema_errors(errors, schema)
|
|
91
|
+
error = compact_errors[0] if compact_errors else errors[0]
|
|
92
|
+
pointer = "".join(f"/{str(part).replace('~', '~0').replace('/', '~1')}" for part in error.absolute_path) or "/"
|
|
93
|
+
raise PortalsContractError(
|
|
94
|
+
"INVALID_SPEC",
|
|
95
|
+
schema_error_message(error),
|
|
96
|
+
path=pointer,
|
|
97
|
+
details={
|
|
98
|
+
"validator": error.validator,
|
|
99
|
+
"schemaPath": "/".join(str(part) for part in error.absolute_schema_path),
|
|
100
|
+
"schemaKind": schema_kind,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def validate_upsert(self, payload: JSONObject, *, schema_kind: str = "complete") -> None:
|
|
105
|
+
"""Validate a single (alias-normalized) portal declaration."""
|
|
106
|
+
self._validate(payload, schema_kind=schema_kind)
|
|
107
|
+
|
|
108
|
+
def validate_declaration(self, payload: JSONObject, *, schema_kind: str = "complete") -> None:
|
|
109
|
+
"""Validate one portal declaration used by ``portal_apply``."""
|
|
110
|
+
if not isinstance(payload, dict):
|
|
111
|
+
raise PortalsContractError("INVALID_SPEC", "portals declaration must be a JSON object", path="/")
|
|
112
|
+
self._validate(payload, schema_kind=schema_kind)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PortalsSchemaRegistry:
|
|
116
|
+
"""Registry for versioned portals declaration schemas (mirrors ViewsSchemaRegistry)."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, root: Path = PORTALS_SCHEMA_ROOT, *, enabled_versions: tuple[str, ...] | None = None) -> None:
|
|
119
|
+
self.root = root
|
|
120
|
+
self.manifest = self._read_json(root / "manifest.json")
|
|
121
|
+
versions = self.manifest.get("versions")
|
|
122
|
+
if not isinstance(versions, dict) or not versions:
|
|
123
|
+
raise RuntimeError("portals schema manifest must define versions")
|
|
124
|
+
env_versions = self._configured_versions()
|
|
125
|
+
defaults = tuple(
|
|
126
|
+
str(version)
|
|
127
|
+
for version, metadata in versions.items()
|
|
128
|
+
if isinstance(metadata, dict) and bool(metadata.get("enabledByDefault"))
|
|
129
|
+
)
|
|
130
|
+
configured = tuple(enabled_versions) if enabled_versions is not None else (env_versions or defaults)
|
|
131
|
+
self.stored_versions = tuple(str(version) for version in versions)
|
|
132
|
+
self.latest_version = str(self.manifest.get("latestVersion") or "")
|
|
133
|
+
self._stored_api_to_version: dict[str, str] = {}
|
|
134
|
+
for version, metadata in versions.items():
|
|
135
|
+
if not isinstance(metadata, dict):
|
|
136
|
+
raise RuntimeError(f"portals schema metadata must be an object: {version}")
|
|
137
|
+
api_version = str(metadata.get("apiVersion") or "")
|
|
138
|
+
if not api_version:
|
|
139
|
+
raise RuntimeError(f"portals schema version has no apiVersion: {version}")
|
|
140
|
+
if api_version in self._stored_api_to_version:
|
|
141
|
+
raise RuntimeError(f"duplicate portals apiVersion: {api_version}")
|
|
142
|
+
self._stored_api_to_version[api_version] = str(version)
|
|
143
|
+
self.enabled_versions = configured
|
|
144
|
+
if self.latest_version not in versions:
|
|
145
|
+
raise RuntimeError("portals latestVersion is not stored")
|
|
146
|
+
if self.latest_version not in self.enabled_versions:
|
|
147
|
+
raise RuntimeError("portals latestVersion must remain enabled")
|
|
148
|
+
self._contexts: dict[str, PortalsSchemaContext] = {}
|
|
149
|
+
self._api_to_version: dict[str, str] = {}
|
|
150
|
+
for version in self.enabled_versions:
|
|
151
|
+
metadata = versions.get(version)
|
|
152
|
+
if not isinstance(metadata, dict):
|
|
153
|
+
raise RuntimeError(f"enabled portals schema version is not stored: {version}")
|
|
154
|
+
api_version = str(metadata.get("apiVersion") or "")
|
|
155
|
+
schema_files = metadata.get("schemaFiles")
|
|
156
|
+
if not isinstance(schema_files, dict):
|
|
157
|
+
schema_files = {kind: f"{version}/{kind}.json" for kind in ("summary", "complete")}
|
|
158
|
+
supported_schema_kinds = tuple(str(item) for item in metadata.get("schemaKinds") or schema_files)
|
|
159
|
+
if not {"summary", "complete"}.issubset(supported_schema_kinds):
|
|
160
|
+
raise RuntimeError(f"portals schema version must support summary and complete kinds: {version}")
|
|
161
|
+
schemas: dict[str, JSONObject] = {}
|
|
162
|
+
paths: dict[str, Path] = {}
|
|
163
|
+
for name in supported_schema_kinds:
|
|
164
|
+
relative_path = schema_files.get(name)
|
|
165
|
+
if not isinstance(relative_path, str) or not relative_path:
|
|
166
|
+
raise RuntimeError(f"portals schema kind has no schema file: {version}/{name}")
|
|
167
|
+
path = root / relative_path
|
|
168
|
+
schema = self._read_json(path)
|
|
169
|
+
schemas[name] = schema
|
|
170
|
+
paths[name] = path
|
|
171
|
+
ref_registry = Registry()
|
|
172
|
+
for name, schema in schemas.items():
|
|
173
|
+
schema_id = str(schema.get("$id") or "")
|
|
174
|
+
if version not in schema_id:
|
|
175
|
+
raise RuntimeError(f"portals {name} schema $id mismatch for {version}: {schema_id}")
|
|
176
|
+
Draft202012Validator.check_schema(schema)
|
|
177
|
+
resource = Resource.from_contents(schema)
|
|
178
|
+
if schema_id:
|
|
179
|
+
ref_registry = ref_registry.with_resource(schema_id, resource)
|
|
180
|
+
ref_registry = ref_registry.with_resource(paths[name].resolve().as_uri(), resource)
|
|
181
|
+
for name, schema in schemas.items():
|
|
182
|
+
self._validate_local_refs(schema, paths[name], schemas, paths)
|
|
183
|
+
context = PortalsSchemaContext(
|
|
184
|
+
version,
|
|
185
|
+
api_version,
|
|
186
|
+
schemas,
|
|
187
|
+
{name: str(schema_files[name]) for name in supported_schema_kinds},
|
|
188
|
+
ref_registry,
|
|
189
|
+
supported_schema_kinds,
|
|
190
|
+
str(metadata.get("contractSource") or "portal_apply"),
|
|
191
|
+
str(metadata.get("contractVersion") or version),
|
|
192
|
+
)
|
|
193
|
+
self._contexts[version] = context
|
|
194
|
+
self._api_to_version[api_version] = version
|
|
195
|
+
|
|
196
|
+
def schema(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
schema_version: str | None = None,
|
|
200
|
+
schema_kind: str | None = None,
|
|
201
|
+
) -> JSONObject:
|
|
202
|
+
context = self.resolve_schema_version(schema_version)
|
|
203
|
+
requested_kind = str(schema_kind or "summary").strip()
|
|
204
|
+
if requested_kind not in context.supported_schema_kinds:
|
|
205
|
+
raise PortalsContractError(
|
|
206
|
+
"UNSUPPORTED_SCHEMA_KIND",
|
|
207
|
+
f"unsupported portals schema kind: {requested_kind}",
|
|
208
|
+
details={"supportedKinds": list(context.supported_schema_kinds)},
|
|
209
|
+
)
|
|
210
|
+
schema, dependencies = self._bundle_schema(
|
|
211
|
+
context.schema_for_kind(requested_kind),
|
|
212
|
+
schema_path=(self.root / context.schema_files[requested_kind]),
|
|
213
|
+
schemas=context.schemas,
|
|
214
|
+
schema_files=context.schema_files,
|
|
215
|
+
)
|
|
216
|
+
return {
|
|
217
|
+
"schemaVersion": context.schema_version,
|
|
218
|
+
"apiVersion": context.api_version,
|
|
219
|
+
"latestVersion": self.latest_version,
|
|
220
|
+
"storedVersions": list(self.stored_versions),
|
|
221
|
+
"enabledVersions": list(self.enabled_versions),
|
|
222
|
+
"supportedSchemaKinds": list(context.supported_schema_kinds),
|
|
223
|
+
"schemaKind": requested_kind,
|
|
224
|
+
"schemaFile": context.schema_files[requested_kind],
|
|
225
|
+
"schemaBundled": bool(dependencies),
|
|
226
|
+
"schemaDependencies": dependencies,
|
|
227
|
+
"schema": schema,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def _bundle_schema(
|
|
232
|
+
cls,
|
|
233
|
+
schema: JSONObject,
|
|
234
|
+
*,
|
|
235
|
+
schema_path: Path,
|
|
236
|
+
schemas: dict[str, JSONObject],
|
|
237
|
+
schema_files: dict[str, str],
|
|
238
|
+
) -> tuple[JSONObject, list[str]]:
|
|
239
|
+
"""Inline reachable cross-file definitions for standalone consumers."""
|
|
240
|
+
bundled = deepcopy(schema)
|
|
241
|
+
definitions = bundled.get("$defs")
|
|
242
|
+
if not isinstance(definitions, dict):
|
|
243
|
+
definitions = {}
|
|
244
|
+
dependencies: set[str] = set()
|
|
245
|
+
# schema_path is <root>/<version>/components/<file> or <root>/<version>/<file>.
|
|
246
|
+
# Find the manifest root from the configured path instead of relying on
|
|
247
|
+
# a fixed components depth.
|
|
248
|
+
cls_root = schema_path.parent
|
|
249
|
+
for candidate in schema_path.parents:
|
|
250
|
+
if (candidate / "manifest.json").is_file():
|
|
251
|
+
cls_root = candidate
|
|
252
|
+
break
|
|
253
|
+
paths = {name: cls_root / relative for name, relative in schema_files.items()}
|
|
254
|
+
|
|
255
|
+
def resolve_pointer(document: JSONObject, fragment: str, *, ref: str) -> Any:
|
|
256
|
+
target: Any = document
|
|
257
|
+
for raw_part in fragment[1:].split("/") if fragment.startswith("/") else ():
|
|
258
|
+
part = raw_part.replace("~1", "/").replace("~0", "~")
|
|
259
|
+
if not isinstance(target, dict) or part not in target:
|
|
260
|
+
raise RuntimeError(f"portals schema contains a dangling $ref in {schema_path}: {ref}")
|
|
261
|
+
target = target[part]
|
|
262
|
+
return target
|
|
263
|
+
|
|
264
|
+
pending: list[tuple[str, JSONObject, Path]] = []
|
|
265
|
+
|
|
266
|
+
def add_definition(name: str, source: JSONObject, source_path: Path) -> None:
|
|
267
|
+
if name in definitions:
|
|
268
|
+
return
|
|
269
|
+
source_definitions = source.get("$defs")
|
|
270
|
+
if not isinstance(source_definitions, dict) or name not in source_definitions:
|
|
271
|
+
raise RuntimeError(f"portals schema references an unknown definition: {name}")
|
|
272
|
+
definitions[name] = deepcopy(source_definitions[name])
|
|
273
|
+
pending.append((name, source, source_path))
|
|
274
|
+
|
|
275
|
+
def rewrite(value: Any, source: JSONObject, source_path: Path) -> None:
|
|
276
|
+
if isinstance(value, dict):
|
|
277
|
+
ref = value.get("$ref")
|
|
278
|
+
if isinstance(ref, str):
|
|
279
|
+
ref_file, _, fragment = ref.partition("#")
|
|
280
|
+
target_source = source
|
|
281
|
+
target_path = source_path
|
|
282
|
+
if ref_file:
|
|
283
|
+
target_path = (source_path.parent / ref_file).resolve()
|
|
284
|
+
target_name = next(
|
|
285
|
+
(name for name, path in paths.items() if path.resolve() == target_path),
|
|
286
|
+
None,
|
|
287
|
+
)
|
|
288
|
+
if target_name is None:
|
|
289
|
+
raise RuntimeError(f"portals schema contains an external $ref in {source_path}: {ref}")
|
|
290
|
+
target_source = schemas[target_name]
|
|
291
|
+
dependencies.add(schema_files[target_name])
|
|
292
|
+
value["$ref"] = f"#{fragment}"
|
|
293
|
+
local_ref = str(value["$ref"])
|
|
294
|
+
if local_ref.startswith("#/$defs/"):
|
|
295
|
+
name = local_ref.removeprefix("#/$defs/").split("/", 1)[0]
|
|
296
|
+
add_definition(name, target_source, target_path)
|
|
297
|
+
else:
|
|
298
|
+
resolve_pointer(target_source, fragment, ref=ref)
|
|
299
|
+
for child in value.values():
|
|
300
|
+
rewrite(child, source, source_path)
|
|
301
|
+
elif isinstance(value, list):
|
|
302
|
+
for child in value:
|
|
303
|
+
rewrite(child, source, source_path)
|
|
304
|
+
|
|
305
|
+
for key, value in bundled.items():
|
|
306
|
+
if key != "$defs":
|
|
307
|
+
rewrite(value, schema, schema_path)
|
|
308
|
+
while pending:
|
|
309
|
+
name, source, source_path = pending.pop()
|
|
310
|
+
rewrite(definitions[name], source, source_path)
|
|
311
|
+
if definitions:
|
|
312
|
+
bundled["$defs"] = definitions
|
|
313
|
+
return bundled, sorted(dependencies)
|
|
314
|
+
|
|
315
|
+
def resolve_schema_version(self, schema_version: str | None) -> PortalsSchemaContext:
|
|
316
|
+
version = str(schema_version or self.latest_version).strip()
|
|
317
|
+
if version not in self.stored_versions:
|
|
318
|
+
raise self._version_error("UNSUPPORTED_SCHEMA_VERSION", version)
|
|
319
|
+
if version not in self.enabled_versions:
|
|
320
|
+
raise self._version_error("SCHEMA_VERSION_DISABLED", version)
|
|
321
|
+
return self._contexts[version]
|
|
322
|
+
|
|
323
|
+
def resolve_api_version(self, api_version: str) -> PortalsSchemaContext:
|
|
324
|
+
version = self._api_to_version.get(api_version)
|
|
325
|
+
if version is not None:
|
|
326
|
+
return self._contexts[version]
|
|
327
|
+
stored = self._stored_api_to_version.get(api_version)
|
|
328
|
+
if stored is not None:
|
|
329
|
+
raise self._version_error("SCHEMA_VERSION_DISABLED", stored)
|
|
330
|
+
raise self._version_error("UNSUPPORTED_SCHEMA_VERSION", api_version)
|
|
331
|
+
|
|
332
|
+
def _version_error(self, error_code: str, requested: str) -> PortalsContractError:
|
|
333
|
+
return PortalsContractError(
|
|
334
|
+
error_code,
|
|
335
|
+
f"unsupported portals schema selection: {requested}",
|
|
336
|
+
details={
|
|
337
|
+
"requestedVersion": requested,
|
|
338
|
+
"latestVersion": self.latest_version,
|
|
339
|
+
"storedVersions": list(self.stored_versions),
|
|
340
|
+
"enabledVersions": list(self.enabled_versions),
|
|
341
|
+
},
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _read_json(path: Path) -> JSONObject:
|
|
346
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
347
|
+
if not isinstance(value, dict):
|
|
348
|
+
raise RuntimeError(f"portals schema artifact must be an object: {path}")
|
|
349
|
+
return value
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def _validate_local_refs(
|
|
353
|
+
document: JSONObject,
|
|
354
|
+
path: Path,
|
|
355
|
+
documents: dict[str, JSONObject],
|
|
356
|
+
paths: dict[str, Path],
|
|
357
|
+
) -> None:
|
|
358
|
+
stack: list[Any] = [document]
|
|
359
|
+
while stack:
|
|
360
|
+
value = stack.pop()
|
|
361
|
+
if isinstance(value, dict):
|
|
362
|
+
ref = value.get("$ref")
|
|
363
|
+
if ref is not None:
|
|
364
|
+
if not isinstance(ref, str):
|
|
365
|
+
raise RuntimeError(f"portals schema contains an invalid $ref in {path}: {ref}")
|
|
366
|
+
ref_file, _, fragment = ref.partition("#")
|
|
367
|
+
target_document = document
|
|
368
|
+
if ref_file:
|
|
369
|
+
target_path = (path.parent / ref_file).resolve()
|
|
370
|
+
if target_path not in {item.resolve() for item in paths.values()}:
|
|
371
|
+
raise RuntimeError(f"portals schema contains an external $ref in {path}: {ref}")
|
|
372
|
+
target_name = next(name for name, item in paths.items() if item.resolve() == target_path)
|
|
373
|
+
target_document = documents[target_name]
|
|
374
|
+
target: Any = target_document
|
|
375
|
+
for raw_part in fragment[1:].split("/") if fragment.startswith("/") else ():
|
|
376
|
+
part = raw_part.replace("~1", "/").replace("~0", "~")
|
|
377
|
+
if not isinstance(target, dict) or part not in target:
|
|
378
|
+
raise RuntimeError(f"portals schema contains a dangling $ref in {path}: {ref}")
|
|
379
|
+
target = target[part]
|
|
380
|
+
stack.extend(value.values())
|
|
381
|
+
elif isinstance(value, list):
|
|
382
|
+
stack.extend(value)
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _configured_versions() -> tuple[str, ...]:
|
|
386
|
+
raw = os.environ.get("QINGFLOW_PORTALS_SCHEMA_VERSIONS", "")
|
|
387
|
+
return tuple(part.strip() for part in raw.split(",") if part.strip())
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
_REGISTRY: PortalsSchemaRegistry | None = None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def portals_schema_registry() -> PortalsSchemaRegistry:
|
|
394
|
+
global _REGISTRY
|
|
395
|
+
if _REGISTRY is None:
|
|
396
|
+
_REGISTRY = PortalsSchemaRegistry()
|
|
397
|
+
return _REGISTRY
|