Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

## Table of Contents

- [What's new (2026-06-21) — Layered Configuration Resolver](#whats-new-2026-06-21--layered-configuration-resolver)
- [What's new (2026-06-21) — Server-Sent Events (SSE) Client Parser](#whats-new-2026-06-21--server-sent-events-sse-client-parser)
- [What's new (2026-06-21) — Dotenv (.env) Parsing](#whats-new-2026-06-21--dotenv-env-parsing)
- [What's new (2026-06-21) — RFC 9457 Problem Details Parsing](#whats-new-2026-06-21--rfc-9457-problem-details-parsing)
Expand Down Expand Up @@ -133,6 +134,12 @@

---

## What's new (2026-06-21) — Layered Configuration Resolver

Compose config with `defaults < file < env < CLI` precedence. Full reference: [`docs/source/Eng/doc/new_features/v81_features_doc.rst`](docs/source/Eng/doc/new_features/v81_features_doc.rst).

- **`LayeredConfig` / `deep_merge` / `SourceTrace`** (`AC_resolve_config`, `AC_explain_config`): `json_patch.merge_patch` merges two docs, `config_sync` is last-write-wins, `AssetStore` is flat-per-env — none compose an ordered precedence stack with deep merge or report which layer won each key. `add_layer(name, mapping, priority)` then `resolve()` deep-merges (nested dicts recursively, scalars/lists replaced); `explain("db.host")` names the winning layer. Layers are caller-supplied (env passed in, never `os.environ` implicitly). Pure-stdlib, deterministic.

## What's new (2026-06-21) — Server-Sent Events (SSE) Client Parser

Consume `text/event-stream` responses. Full reference: [`docs/source/Eng/doc/new_features/v80_features_doc.rst`](docs/source/Eng/doc/new_features/v80_features_doc.rst).
Expand Down
7 changes: 7 additions & 0 deletions README/README_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

## 目录

- [本次更新 (2026-06-21) — 分层配置解析器](#本次更新-2026-06-21--分层配置解析器)
- [本次更新 (2026-06-21) — Server-Sent Events (SSE) 客户端解析器](#本次更新-2026-06-21--server-sent-events-sse-客户端解析器)
- [本次更新 (2026-06-21) — Dotenv (.env) 解析](#本次更新-2026-06-21--dotenv-env-解析)
- [本次更新 (2026-06-21) — RFC 9457 Problem Details 解析](#本次更新-2026-06-21--rfc-9457-problem-details-解析)
Expand Down Expand Up @@ -132,6 +133,12 @@

---

## 本次更新 (2026-06-21) — 分层配置解析器

以 `defaults < file < env < CLI` 优先级组合配置。完整参考:[`docs/source/Zh/doc/new_features/v81_features_doc.rst`](../docs/source/Zh/doc/new_features/v81_features_doc.rst)。

- **`LayeredConfig` / `deep_merge` / `SourceTrace`**(`AC_resolve_config`、`AC_explain_config`):`json_patch.merge_patch` 只合并两份文档,`config_sync` 是 last-write-wins,`AssetStore` 是每环境扁平 —— 都无法组成有序优先级堆叠并深度合并,也无法报告每个键由哪层胜出。`add_layer(name, mapping, priority)` 后 `resolve()` 深度合并(嵌套 dict 递归、标量/list 替换);`explain("db.host")` 标明胜出层。各层由调用端提供(env 由外部传入,绝不隐含 `os.environ`)。纯标准库、确定。

## 本次更新 (2026-06-21) — Server-Sent Events (SSE) 客户端解析器

消费 `text/event-stream` 响应。完整参考:[`docs/source/Zh/doc/new_features/v80_features_doc.rst`](../docs/source/Zh/doc/new_features/v80_features_doc.rst)。
Expand Down
7 changes: 7 additions & 0 deletions README/README_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

## 目錄

- [本次更新 (2026-06-21) — 分層設定解析器](#本次更新-2026-06-21--分層設定解析器)
- [本次更新 (2026-06-21) — Server-Sent Events (SSE) 用戶端解析器](#本次更新-2026-06-21--server-sent-events-sse-用戶端解析器)
- [本次更新 (2026-06-21) — Dotenv (.env) 解析](#本次更新-2026-06-21--dotenv-env-解析)
- [本次更新 (2026-06-21) — RFC 9457 Problem Details 解析](#本次更新-2026-06-21--rfc-9457-problem-details-解析)
Expand Down Expand Up @@ -132,6 +133,12 @@

---

## 本次更新 (2026-06-21) — 分層設定解析器

以 `defaults < file < env < CLI` 優先序組合設定。完整參考:[`docs/source/Zh/doc/new_features/v81_features_doc.rst`](../docs/source/Zh/doc/new_features/v81_features_doc.rst)。

- **`LayeredConfig` / `deep_merge` / `SourceTrace`**(`AC_resolve_config`、`AC_explain_config`):`json_patch.merge_patch` 只合併兩份文件,`config_sync` 是 last-write-wins,`AssetStore` 是每環境扁平 —— 都無法組成有序優先序堆疊並深度合併,也無法回報每個鍵由哪層勝出。`add_layer(name, mapping, priority)` 後 `resolve()` 深度合併(巢狀 dict 遞迴、純量/list 取代);`explain("db.host")` 標明勝出層。各層由呼叫端提供(env 由外部傳入,絕不隱含 `os.environ`)。純標準函式庫、具決定性。

## 本次更新 (2026-06-21) — Server-Sent Events (SSE) 用戶端解析器

消費 `text/event-stream` 回應。完整參考:[`docs/source/Zh/doc/new_features/v80_features_doc.rst`](../docs/source/Zh/doc/new_features/v80_features_doc.rst)。
Expand Down
45 changes: 45 additions & 0 deletions docs/source/Eng/doc/new_features/v81_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
Layered Configuration Resolver
==============================

``json_patch.merge_patch`` merges exactly two documents, ``config_sync``
resolves by last-write-wins timestamp, and ``AssetStore`` is flat per
environment. None of them compose an ordered ``defaults < file < env < CLI``
precedence stack with a deep dict merge, nor report *which layer won each key*.
This adds that 12-factor resolver.

Pure standard library (``copy``); imports no ``PySide6``. Layers are plain
mappings supplied by the caller (the env layer is passed in, never read from
``os.environ`` implicitly), so resolution is deterministic in CI.

Headless API
------------

.. code-block:: python

from je_auto_control import LayeredConfig, deep_merge

cfg = (LayeredConfig()
.add_layer("defaults", {"db": {"host": "local", "port": 5432}})
.add_layer("file", file_values, priority=10)
.add_layer("env", env_values, priority=20))

settings = cfg.resolve() # {"db": {"host": ..., "port": ...}}
host = cfg.get("db.host")
trace = cfg.explain("db.host") # SourceTrace(value=..., layer="env")

``add_layer`` registers a named layer; higher ``priority`` wins (priority
defaults to insertion order, so later layers override earlier ones).
``resolve`` deep-merges every layer in ascending priority — nested dicts are
merged recursively while scalars and lists are replaced. ``get`` reads a dotted
key from the resolved config with a default; ``explain`` returns a
``SourceTrace`` naming the winning layer for a dotted key (raising ``KeyError``
when absent). ``deep_merge`` is exposed as a standalone two-mapping helper.

Executor commands
-----------------

``AC_resolve_config`` deep-merges a ``layers`` list (each ``{name, mapping,
priority?}``) into ``{config}``. ``AC_explain_config`` returns ``{trace}`` (the
value and winning layer) for a dotted ``key``. Both are exposed as MCP tools
(``ac_resolve_config`` / ``ac_explain_config``) and as Script Builder commands
under **Data**.
1 change: 1 addition & 0 deletions docs/source/Eng/eng_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Comprehensive guides for all AutoControl features.
doc/new_features/v78_features_doc
doc/new_features/v79_features_doc
doc/new_features/v80_features_doc
doc/new_features/v81_features_doc
doc/ocr_backends/ocr_backends_doc
doc/observability/observability_doc
doc/operations_layer/operations_layer_doc
Expand Down
37 changes: 37 additions & 0 deletions docs/source/Zh/doc/new_features/v81_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
分層設定解析器
============

``json_patch.merge_patch`` 只合併兩份文件,``config_sync`` 以 last-write-wins 時間戳解析,
``AssetStore`` 則是每環境的扁平結構。它們都無法組成一個有序的 ``defaults < file < env < CLI`` 優先序
堆疊並做深度 dict 合併,也無法回報*每個鍵由哪一層勝出*。本功能補上這個 12-factor 解析器。

純標準函式庫(``copy``);不匯入 ``PySide6``。各層為呼叫端提供的純 mapping(env 層由外部傳入,絕不
隱含讀取 ``os.environ``),因此解析在 CI 中具決定性。

無頭 API
--------

.. code-block:: python

from je_auto_control import LayeredConfig, deep_merge

cfg = (LayeredConfig()
.add_layer("defaults", {"db": {"host": "local", "port": 5432}})
.add_layer("file", file_values, priority=10)
.add_layer("env", env_values, priority=20))

settings = cfg.resolve() # {"db": {"host": ..., "port": ...}}
host = cfg.get("db.host")
trace = cfg.explain("db.host") # SourceTrace(value=..., layer="env")

``add_layer`` 註冊一個具名層;``priority`` 越高越勝出(預設為插入順序,因此後加的層覆蓋先前的)。
``resolve`` 依優先序由低到高深度合併每一層 —— 巢狀 dict 遞迴合併,而純量與 list 直接取代。``get`` 以
點分鍵從解析後設定讀取並帶預設值;``explain`` 回傳 ``SourceTrace``,標明點分鍵的勝出層(不存在時拋
``KeyError``)。``deep_merge`` 另以獨立的雙 mapping 輔助函式提供。

執行器命令
----------

``AC_resolve_config`` 把 ``layers`` 清單(每筆 ``{name, mapping, priority?}``)深度合併成
``{config}``。``AC_explain_config`` 對點分 ``key`` 回傳 ``{trace}``(值與勝出層)。兩者皆以 MCP 工具
(``ac_resolve_config`` / ``ac_explain_config``)以及 Script Builder 中 **Data** 分類下的命令提供。
1 change: 1 addition & 0 deletions docs/source/Zh/zh_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ AutoControl 所有功能的完整使用指南。
doc/new_features/v78_features_doc
doc/new_features/v79_features_doc
doc/new_features/v80_features_doc
doc/new_features/v81_features_doc
doc/ocr_backends/ocr_backends_doc
doc/observability/observability_doc
doc/operations_layer/operations_layer_doc
Expand Down
5 changes: 5 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@
from je_auto_control.utils.dotenv import (
dotenv_values, dump_dotenv, load_dotenv, parse_dotenv,
)
# Layered config resolver (defaults < file < env < CLI, with provenance)
from je_auto_control.utils.layered_config import (
LayeredConfig, SourceTrace, deep_merge,
)
# Outbound CloudEvents emitter
from je_auto_control.utils.events import (
EventEmitter, post_cloudevent, to_cloudevent,
Expand Down Expand Up @@ -860,6 +864,7 @@ def start_autocontrol_gui(*args, **kwargs):
"rank_automation_candidates",
"Asset", "AssetStore", "AssetValue", "active_environment",
"dotenv_values", "dump_dotenv", "load_dotenv", "parse_dotenv",
"LayeredConfig", "SourceTrace", "deep_merge",
"EventEmitter", "post_cloudevent", "to_cloudevent",
"WebhookChannel", "WebhookResult", "notify_webhook", "set_default_poster",
"json_extract", "json_query", "json_query_one",
Expand Down
19 changes: 19 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1678,6 +1678,25 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None:
),
description="Parse a text/event-stream blob into events.",
))
specs.append(CommandSpec(
"AC_resolve_config", "Data", "Layered Config: Resolve",
fields=(
FieldSpec("layers", FieldType.STRING,
placeholder='[{"name": "defaults", "mapping": {}}, '
'{"name": "env", "mapping": {}, '
'"priority": 10}]'),
),
description="Deep-merge ordered config layers into one config.",
))
specs.append(CommandSpec(
"AC_explain_config", "Data", "Layered Config: Explain Key",
fields=(
FieldSpec("layers", FieldType.STRING,
placeholder='[{"name": "defaults", "mapping": {}}]'),
FieldSpec("key", FieldType.STRING, placeholder="db.host"),
),
description="Show the value and winning layer for a dotted config key.",
))
specs.append(CommandSpec(
"AC_rate_limit", "Flow", "Rate Limit (Token Bucket)",
fields=(
Expand Down
27 changes: 27 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3050,6 +3050,31 @@ def _parse_sse(text: str) -> Dict[str, Any]:
return {"events": [event.to_dict() for event in parse_event_stream(text)]}


def _build_layered_config(layers: Any):
"""Build a LayeredConfig from a list of {name, mapping, priority?} dicts."""
import json
from je_auto_control.utils.layered_config import LayeredConfig
if isinstance(layers, str):
layers = json.loads(layers)
config = LayeredConfig()
for layer in layers:
config.add_layer(layer["name"], layer.get("mapping", {}),
layer.get("priority"))
return config


def _resolve_config(layers: Any) -> Dict[str, Any]:
"""Adapter: deep-merge config layers into a resolved {config}."""
return {"config": _build_layered_config(layers).resolve()}


def _explain_config(layers: Any, key: str) -> Dict[str, Any]:
"""Adapter: report the value and winning layer for a dotted config key."""
trace = _build_layered_config(layers).explain(key)
return {"trace": {"key": trace.key, "value": trace.value,
"layer": trace.layer}}


def _percentiles(samples: Any, qs: Any = None) -> Dict[str, Any]:
"""Adapter: exact percentiles of a numeric sample list (or JSON string)."""
import json
Expand Down Expand Up @@ -4134,6 +4159,8 @@ def __init__(self):
"AC_parse_dotenv": _parse_dotenv,
"AC_load_dotenv": _load_dotenv,
"AC_parse_sse": _parse_sse,
"AC_resolve_config": _resolve_config,
"AC_explain_config": _explain_config,
"AC_unified_diff": _unified_diff,
"AC_apply_unified": _apply_unified,
"AC_three_way_merge": _three_way_merge,
Expand Down
6 changes: 6 additions & 0 deletions je_auto_control/utils/layered_config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Layered configuration resolution for AutoControl."""
from je_auto_control.utils.layered_config.layered_config import (
LayeredConfig, SourceTrace, deep_merge,
)

__all__ = ["LayeredConfig", "SourceTrace", "deep_merge"]
104 changes: 104 additions & 0 deletions je_auto_control/utils/layered_config/layered_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Ordered, deep-merging configuration resolver with per-key provenance.

``json_patch.merge_patch`` merges exactly two documents and ``config_sync``
resolves by last-write-wins timestamp; ``AssetStore`` is flat-per-environment.
None of them compose an ordered ``defaults < file < env < CLI`` precedence
stack with a deep dict merge, nor report *which layer won each key*. This adds
that 12-factor resolver.

Pure standard library (``copy``); imports no ``PySide6``. Layers are plain
mappings supplied by the caller (the env layer is passed in, never read from
``os.environ`` implicitly), so resolution is deterministic in CI.
"""
import copy
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Optional, Tuple


@dataclass(frozen=True)
class SourceTrace:
"""Where a resolved key's value came from."""

key: str
value: Any
layer: Optional[str]


@dataclass
class _Layer:
name: str
mapping: Dict[str, Any]
priority: int
index: int


def deep_merge(base: Mapping[str, Any],
override: Mapping[str, Any]) -> Dict[str, Any]:
"""Recursively merge ``override`` onto ``base``; ``override`` wins leaves."""
result: Dict[str, Any] = dict(base)
for key, value in override.items():
current = result.get(key)
if isinstance(value, Mapping) and isinstance(current, Mapping):
result[key] = deep_merge(current, value)
else:
result[key] = copy.deepcopy(value)
return result


def _get_path(mapping: Mapping[str, Any],
parts: List[str]) -> Tuple[bool, Any]:
current: Any = mapping
for part in parts:
if not isinstance(current, Mapping) or part not in current:
return False, None
current = current[part]
return True, current


class LayeredConfig:
"""Compose configuration layers by priority with deep merging."""

def __init__(self) -> None:
self._layers: List[_Layer] = []

def add_layer(self, name: str, mapping: Mapping[str, Any],
priority: Optional[int] = None) -> "LayeredConfig":
"""Add a layer; higher ``priority`` wins (default: insertion order)."""
index = len(self._layers)
resolved_priority = index if priority is None else priority
self._layers.append(
_Layer(name, dict(mapping or {}), resolved_priority, index))
return self

def layer_names(self) -> List[str]:
"""The layer names in insertion order."""
return [layer.name for layer in self._layers]

def _ascending(self) -> List[_Layer]:
return sorted(self._layers, key=lambda layer: (layer.priority,
layer.index))

def resolve(self) -> Dict[str, Any]:
"""Deep-merge all layers in ascending priority into one config dict."""
merged: Dict[str, Any] = {}
for layer in self._ascending():
merged = deep_merge(merged, layer.mapping)
return merged

def get(self, dotted_key: str, default: Any = None) -> Any:
"""Return the resolved value for a dotted key, or ``default``."""
found, value = _get_path(self.resolve(), dotted_key.split("."))
return value if found else default

def explain(self, dotted_key: str) -> SourceTrace:
"""Return the value and the winning layer name for a dotted key."""
parts = dotted_key.split(".")
found, value = _get_path(self.resolve(), parts)
if not found:
raise KeyError(dotted_key)
for layer in sorted(self._layers, reverse=True,
key=lambda item: (item.priority, item.index)):
layer_found, _ = _get_path(layer.mapping, parts)
if layer_found:
return SourceTrace(dotted_key, value, layer.name)
return SourceTrace(dotted_key, value, None)
Loading
Loading