@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.
Files changed (56) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +13 -0
  5. package/skills/qingflow-cli/reference/builder/70-portal.md +37 -16
  6. package/skills/qingflow-cli/reference/examples/portal/portal_apply_v1alpha1.example.json +100 -0
  7. package/skills/qingflow-cli/reference/examples/portal/portal_sections_all_types.example.json +6 -7
  8. package/src/qingflow_mcp/app_form_validator.py +20 -3
  9. package/src/qingflow_mcp/builder_facade/app_form.py +137 -17
  10. package/src/qingflow_mcp/builder_facade/models.py +164 -13
  11. package/src/qingflow_mcp/builder_facade/portals_schema.py +397 -0
  12. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +14 -1
  13. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +49 -9
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +27 -0
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_classification.json +146 -0
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_extraction.json +146 -0
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_content_generation.json +146 -0
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_document_parsing.json +146 -0
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_generation.json +146 -0
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_image_recognition.json +146 -0
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_machine_translation.json +146 -0
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ai_text_summary.json +146 -0
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +2 -2
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +27 -0
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date_duration.json +146 -0
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/embed_field.json +146 -0
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/image_select.json +146 -0
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/location.json +146 -0
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +2 -2
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/ocr.json +146 -0
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +2 -2
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +27 -0
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +29 -2
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +20 -7
  35. package/src/qingflow_mcp/builder_facade/schemas/portals/manifest.json +59 -0
  36. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/complete.json +1397 -0
  37. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/carousel.json +193 -0
  38. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/chart.json +643 -0
  39. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/favourite.json +203 -0
  40. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/filter.json +268 -0
  41. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/grid.json +265 -0
  42. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/link.json +131 -0
  43. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/task.json +199 -0
  44. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/text.json +123 -0
  45. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/components/view.json +196 -0
  46. package/src/qingflow_mcp/builder_facade/schemas/portals/v1alpha1/summary.json +800 -0
  47. package/src/qingflow_mcp/builder_facade/service.py +1104 -71
  48. package/src/qingflow_mcp/cli/commands/builder.py +114 -5
  49. package/src/qingflow_mcp/public_surface.py +2 -0
  50. package/src/qingflow_mcp/server_app_builder.py +32 -1
  51. package/src/qingflow_mcp/solution/compiler/icon_utils.py +73 -0
  52. package/src/qingflow_mcp/solution/executor.py +5 -2
  53. package/src/qingflow_mcp/tools/ai_builder_tools.py +110 -12
  54. package/src/qingflow_mcp/tools/portal_tools.py +4 -54
  55. package/src/qingflow_mcp/tools/portal_writer.py +59 -0
  56. 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
- pc_x: int = Field(default=0, validation_alias=AliasChoices("pc_x", "pcX", "x"))
2512
- pc_y: int = Field(default=0, validation_alias=AliasChoices("pc_y", "pcY", "y"))
2513
- pc_w: int = Field(default=12, validation_alias=AliasChoices("pc_w", "pcW", "w"))
2514
- pc_h: int = Field(default=8, validation_alias=AliasChoices("pc_h", "pcH", "h"))
2515
- mobile_x: int = Field(default=0, validation_alias=AliasChoices("mobile_x", "mobileX"))
2516
- mobile_y: int = Field(default=0, validation_alias=AliasChoices("mobile_y", "mobileY"))
2517
- mobile_w: int = Field(default=6, validation_alias=AliasChoices("mobile_w", "mobileW"))
2518
- mobile_h: int = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
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: str
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(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
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
- dash_global_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_global_config", "dashGlobalConfig"))
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