kd-cli 2.11.0__tar.gz → 2.11.1__tar.gz
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.
- {kd_cli-2.11.0/kd_cli.egg-info → kd_cli-2.11.1}/PKG-INFO +1 -1
- kd_cli-2.11.1/kd/_option_chain.py +414 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/auth.py +1 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cache.py +326 -376
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/command_base.py +1 -1
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/namespaces.py +9 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/input.py +17 -21
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/main.py +246 -192
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/article/__init__.py +3 -14
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/cache/__init__.py +0 -2
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/issue/__init__.py +8 -66
- kd_cli-2.11.1/kd/cli/namespaces/primer/__init__.py +979 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/query/__init__.py +5 -16
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/system/__init__.py +136 -162
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/tag/__init__.py +14 -30
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/output.py +4 -3
- kd_cli-2.11.1/kd/cli/rows.py +78 -0
- kd_cli-2.11.1/kd/cli/tree.py +24 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/config.py +19 -415
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/config_schema.py +1 -1
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/debug.py +1 -1
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/exceptions.py +1 -1
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/history.py +3 -6
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/locking.py +14 -11
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/primer.py +83 -115
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/CHANGELOG.md +10 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/SOURCES.txt +2 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/pyproject.toml +7 -1
- kd_cli-2.11.0/kd/cli/namespaces/primer/__init__.py +0 -1058
- kd_cli-2.11.0/kd/cli/tree.py +0 -65
- {kd_cli-2.11.0 → kd_cli-2.11.1}/LICENSE +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/MANIFEST.in +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/README.md +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/articles.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/http.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/hub.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/tags.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/youtrack.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/article_marker.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/client.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/config_read.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/connection.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/article.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/assist.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/attachment.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/comment.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/common.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/issue.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/project.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/saved_query.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/search.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/tag.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/user.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/work_item.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/operating_guide.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/query_parser.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/release_notes.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/operating-guide.md +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.11.0 → kd_cli-2.11.1}/setup.cfg +0 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""The 10-layer command-option resolution chain and shadow detection.
|
|
2
|
+
|
|
3
|
+
The layers are listed on :func:`resolve_command_defaults`. :mod:`kd.config`
|
|
4
|
+
re-exports the public names and is imported here only at call time, so either
|
|
5
|
+
module may be imported first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from kd.debug import DebugLog
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from kd.cli.core.option_registry import OptionDeclaration
|
|
18
|
+
from kd.config import ResolvedConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Keys that exist as both flat config keys and command/output options.
|
|
22
|
+
# These participate in levels 5/7 of the command option resolution chain.
|
|
23
|
+
_FLAT_OPTION_KEYS = {"limit", "output"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_command_defaults(
|
|
27
|
+
command_key: str,
|
|
28
|
+
project: str | None,
|
|
29
|
+
raw_options: dict[str, Any],
|
|
30
|
+
config: ResolvedConfig,
|
|
31
|
+
declarations: list[OptionDeclaration],
|
|
32
|
+
*,
|
|
33
|
+
debug_log: DebugLog | None = None,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
"""Resolve command option defaults from the config hierarchy.
|
|
36
|
+
|
|
37
|
+
Returns a dict of option values for options **not** present in
|
|
38
|
+
*raw_options* (CLI flags always win — level 1).
|
|
39
|
+
|
|
40
|
+
Resolution per option (first non-None wins):
|
|
41
|
+
|
|
42
|
+
1. CLI flag — skipped (present in *raw_options*)
|
|
43
|
+
2. Env var — N/A for command options
|
|
44
|
+
3. Project command default — ``projects.<proj>.defaults.<cmd>.<opt>``
|
|
45
|
+
4. Local command default — ``defaults.<cmd>.<opt>`` in local config
|
|
46
|
+
5. Local flat key — for ``limit``/``output`` only
|
|
47
|
+
6. Connection command default — ``connections.<name>.defaults.<cmd>.<opt>``
|
|
48
|
+
7. Connection flat key — for ``limit``/``output`` only
|
|
49
|
+
8. Global command default — ``defaults.<cmd>.<opt>`` in global config
|
|
50
|
+
9. Global flat key — for ``limit``/``output`` only
|
|
51
|
+
10. Built-in default — from ``OptionDeclaration.default``
|
|
52
|
+
"""
|
|
53
|
+
d = debug_log or DebugLog()
|
|
54
|
+
resolved: dict[str, Any] = {}
|
|
55
|
+
|
|
56
|
+
for decl in declarations:
|
|
57
|
+
opt = decl.name
|
|
58
|
+
|
|
59
|
+
# Level 1: CLI flag already set — skip
|
|
60
|
+
if opt in raw_options:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
value = _resolve_single_option(opt, command_key, project, config, d)
|
|
64
|
+
if value is not None:
|
|
65
|
+
resolved[opt] = value
|
|
66
|
+
|
|
67
|
+
return resolved
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class LayerState:
|
|
72
|
+
"""State of a single layer in the 10-layer command-option resolution chain.
|
|
73
|
+
|
|
74
|
+
``value`` is ``None`` when the layer does not contribute — either because
|
|
75
|
+
the option is not configured there, or because the layer never applies to
|
|
76
|
+
this option class (e.g. flat layers 5/7/9 for non-flat options). ``status``
|
|
77
|
+
disambiguates those cases and marks which layer wins vs. is shadowed.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
layer: int
|
|
81
|
+
source: str
|
|
82
|
+
origin: str | None
|
|
83
|
+
value: Any
|
|
84
|
+
status: str # "winning" | "shadowed" | "not set" | "n/a" | "invalid"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def walk_option_layers(
|
|
88
|
+
opt: str,
|
|
89
|
+
command_key: str,
|
|
90
|
+
project: str | None,
|
|
91
|
+
config: ResolvedConfig,
|
|
92
|
+
*,
|
|
93
|
+
cli_value: Any = None,
|
|
94
|
+
builtin_default: Any = None,
|
|
95
|
+
local_config_path: str | None = None,
|
|
96
|
+
global_config_path: str | None = None,
|
|
97
|
+
) -> list[LayerState]:
|
|
98
|
+
"""Walk the 10-layer command-option resolution chain for *opt*.
|
|
99
|
+
|
|
100
|
+
Returns one :class:`LayerState` per layer in priority order (1..10). The
|
|
101
|
+
first layer with a non-``None`` value has ``status="winning"``; subsequent
|
|
102
|
+
non-``None`` layers have ``status="shadowed"``. Layers that never apply
|
|
103
|
+
(flat layers 5/7/9 for options outside ``_FLAT_OPTION_KEYS``, layer 2 for
|
|
104
|
+
command options, layer 6/7 when no active connection) have ``status="n/a"``.
|
|
105
|
+
Populated-eligible layers with no value are ``"not set"``.
|
|
106
|
+
"""
|
|
107
|
+
from kd import config as kd_config # at call time: tests patch these names there
|
|
108
|
+
|
|
109
|
+
if local_config_path is None:
|
|
110
|
+
_lp = kd_config.find_local_config()
|
|
111
|
+
local_config_path = str(_lp) if _lp else None
|
|
112
|
+
if global_config_path is None:
|
|
113
|
+
global_config_path = str(kd_config.DEFAULT_CONFIG_PATH)
|
|
114
|
+
|
|
115
|
+
conn_name = config.default_connection_name
|
|
116
|
+
conn_origin = config.connection_origins.get(conn_name) if conn_name else None
|
|
117
|
+
conn_invalid = config.invalid_connections.get(conn_name) if conn_name else None
|
|
118
|
+
is_flat = opt in _FLAT_OPTION_KEYS
|
|
119
|
+
|
|
120
|
+
def _conn_key_origin(key: str) -> str | None:
|
|
121
|
+
if conn_origin is None:
|
|
122
|
+
return None
|
|
123
|
+
ko = conn_origin.keys.get(key)
|
|
124
|
+
return ko.path if ko is not None else conn_origin.path
|
|
125
|
+
|
|
126
|
+
def _conn_default_origin() -> str | None:
|
|
127
|
+
"""Layer 6's file: the one that sets this command's option, per leaf.
|
|
128
|
+
|
|
129
|
+
A local key replaces the global one in the connection merge even
|
|
130
|
+
when it is empty, so the first file that has the key is the origin.
|
|
131
|
+
"""
|
|
132
|
+
if conn_name is not None:
|
|
133
|
+
leaf = ["connections", conn_name, "defaults", command_key, opt]
|
|
134
|
+
for tier in kd_config.config_tier_values(config, leaf):
|
|
135
|
+
if tier.present:
|
|
136
|
+
return str(tier.path) if tier.path is not None else None
|
|
137
|
+
return _conn_key_origin("defaults")
|
|
138
|
+
|
|
139
|
+
# (layer, source, origin, value, applicable) in priority order.
|
|
140
|
+
raw: list[tuple[int, str, str | None, Any, bool]] = []
|
|
141
|
+
|
|
142
|
+
# Layer 1 — CLI flag
|
|
143
|
+
raw.append((1, f"cli.{opt}", None, cli_value, True))
|
|
144
|
+
|
|
145
|
+
# Layer 2 — env var (always n/a for command options)
|
|
146
|
+
raw.append((2, "env", None, None, False))
|
|
147
|
+
|
|
148
|
+
# Layer 3 — project command default (local-first then global, same layer)
|
|
149
|
+
proj_val: Any = None
|
|
150
|
+
proj_source = f"project.defaults.{command_key}"
|
|
151
|
+
proj_origin: str | None = None
|
|
152
|
+
if project:
|
|
153
|
+
v = _project_command_default(project, command_key, opt, config.local_projects)
|
|
154
|
+
if v is not None:
|
|
155
|
+
proj_val = v
|
|
156
|
+
proj_source = f"project({project}).local.{command_key}"
|
|
157
|
+
proj_origin = local_config_path
|
|
158
|
+
else:
|
|
159
|
+
v = _project_command_default(project, command_key, opt, config.global_projects)
|
|
160
|
+
if v is not None:
|
|
161
|
+
proj_val = v
|
|
162
|
+
proj_source = f"project({project}).global.{command_key}"
|
|
163
|
+
proj_origin = global_config_path
|
|
164
|
+
raw.append((3, proj_source, proj_origin, proj_val, project is not None))
|
|
165
|
+
|
|
166
|
+
# Layer 4 — local command default
|
|
167
|
+
raw.append(
|
|
168
|
+
(
|
|
169
|
+
4,
|
|
170
|
+
f"local.defaults.{command_key}",
|
|
171
|
+
local_config_path,
|
|
172
|
+
config.local_defaults.get(command_key, {}).get(opt),
|
|
173
|
+
True,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Layer 5 — local flat key (limit/output only)
|
|
178
|
+
raw.append(
|
|
179
|
+
(
|
|
180
|
+
5,
|
|
181
|
+
"local.flat",
|
|
182
|
+
local_config_path,
|
|
183
|
+
config.local_flat.get(opt) if is_flat else None,
|
|
184
|
+
is_flat,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Layer 6 — connection command default
|
|
189
|
+
raw.append(
|
|
190
|
+
(
|
|
191
|
+
6,
|
|
192
|
+
f"connection.defaults.{command_key}",
|
|
193
|
+
_conn_default_origin(),
|
|
194
|
+
config.connection_defaults.get(command_key, {}).get(opt),
|
|
195
|
+
conn_name is not None,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Layer 7 — connection flat key (limit/output only)
|
|
200
|
+
raw.append(
|
|
201
|
+
(
|
|
202
|
+
7,
|
|
203
|
+
"connection.flat",
|
|
204
|
+
_conn_key_origin(opt),
|
|
205
|
+
config.connection_flat.get(opt) if is_flat else None,
|
|
206
|
+
is_flat and conn_name is not None,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Layer 8 — global command default
|
|
211
|
+
raw.append(
|
|
212
|
+
(
|
|
213
|
+
8,
|
|
214
|
+
f"global.defaults.{command_key}",
|
|
215
|
+
global_config_path,
|
|
216
|
+
config.global_defaults.get(command_key, {}).get(opt),
|
|
217
|
+
True,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Layer 9 — global flat key (limit/output only)
|
|
222
|
+
raw.append(
|
|
223
|
+
(
|
|
224
|
+
9,
|
|
225
|
+
"global.flat",
|
|
226
|
+
global_config_path,
|
|
227
|
+
config.global_flat.get(opt) if is_flat else None,
|
|
228
|
+
is_flat,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Layer 10 — built-in default
|
|
233
|
+
raw.append((10, "built-in", None, builtin_default, builtin_default is not None))
|
|
234
|
+
|
|
235
|
+
# Stamp statuses: first non-None wins, later non-Nones are shadowed. An
|
|
236
|
+
# invalid active connection contributes nothing to layers 6 and 7, and
|
|
237
|
+
# says so instead of reading "not set" (KDCLI-321).
|
|
238
|
+
winner_found = False
|
|
239
|
+
out: list[LayerState] = []
|
|
240
|
+
for layer, source, origin, value, applicable in raw:
|
|
241
|
+
if conn_invalid is not None and layer in (6, 7) and applicable:
|
|
242
|
+
out.append(
|
|
243
|
+
LayerState(
|
|
244
|
+
layer=layer,
|
|
245
|
+
source=source,
|
|
246
|
+
origin=conn_invalid.path,
|
|
247
|
+
value=None,
|
|
248
|
+
status="invalid",
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
continue
|
|
252
|
+
if value is None:
|
|
253
|
+
status = "not set" if applicable else "n/a"
|
|
254
|
+
elif not winner_found:
|
|
255
|
+
status = "winning"
|
|
256
|
+
winner_found = True
|
|
257
|
+
else:
|
|
258
|
+
status = "shadowed"
|
|
259
|
+
out.append(
|
|
260
|
+
LayerState(layer=layer, source=source, origin=origin, value=value, status=status)
|
|
261
|
+
)
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _resolve_single_option(
|
|
266
|
+
opt: str,
|
|
267
|
+
command_key: str,
|
|
268
|
+
project: str | None,
|
|
269
|
+
config: ResolvedConfig,
|
|
270
|
+
d: DebugLog,
|
|
271
|
+
) -> Any:
|
|
272
|
+
"""Walk levels 3–9 for a single option. Returns None if no config value found."""
|
|
273
|
+
for state in walk_option_layers(opt, command_key, project, config):
|
|
274
|
+
if state.status == "winning":
|
|
275
|
+
d.debug("config", f"option {opt}: {state.source} -> {state.value}")
|
|
276
|
+
return state.value
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _project_command_default(
|
|
281
|
+
project: str,
|
|
282
|
+
command_key: str,
|
|
283
|
+
opt: str,
|
|
284
|
+
projects_raw: dict[str, Any],
|
|
285
|
+
) -> Any:
|
|
286
|
+
"""Look up a project command default from a raw projects dict."""
|
|
287
|
+
proj_data = projects_raw.get(project)
|
|
288
|
+
if not isinstance(proj_data, dict):
|
|
289
|
+
return None
|
|
290
|
+
defaults = proj_data.get("defaults")
|
|
291
|
+
if not isinstance(defaults, dict):
|
|
292
|
+
return None
|
|
293
|
+
cmd_defaults = defaults.get(command_key)
|
|
294
|
+
if not isinstance(cmd_defaults, dict):
|
|
295
|
+
return None
|
|
296
|
+
return cmd_defaults.get(opt)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
# Shadow detection — post-write check against the 10-layer chain
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass(frozen=True)
|
|
305
|
+
class ShadowWarning:
|
|
306
|
+
"""Result of :func:`detect_shadow` — a written value is shadowed.
|
|
307
|
+
|
|
308
|
+
``written`` names the layer and source the write landed at; ``winning``
|
|
309
|
+
names the higher-priority layer whose value continues to resolve.
|
|
310
|
+
``option`` is ``"<command_key>.<option>"``. ``hint_path`` suggests a
|
|
311
|
+
dotted path that would actually override the winning layer, or None
|
|
312
|
+
when no such path exists below the winning tier.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
written: dict[str, Any]
|
|
316
|
+
winning: dict[str, Any]
|
|
317
|
+
option: str
|
|
318
|
+
hint_path: str | None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def detect_shadow(
|
|
322
|
+
path: str,
|
|
323
|
+
tier: str,
|
|
324
|
+
rc: ResolvedConfig,
|
|
325
|
+
*,
|
|
326
|
+
default_lookup: Callable[[str, str], Any] | None = None,
|
|
327
|
+
) -> ShadowWarning | None:
|
|
328
|
+
"""Return a :class:`ShadowWarning` when *path* (just written in *tier*)
|
|
329
|
+
is shadowed by a higher-priority layer in *rc*, else ``None``.
|
|
330
|
+
|
|
331
|
+
Returns ``None`` when the path is not part of the command-option chain,
|
|
332
|
+
when the write landed at the winning layer, or when the post-write walker
|
|
333
|
+
reports no higher-priority layer with a value. Paths that don't map to a
|
|
334
|
+
specific ``<command_key>.<option>`` target — flat top-level writes and
|
|
335
|
+
connection-level flat writes — are cross-command by nature and are not
|
|
336
|
+
flagged here.
|
|
337
|
+
|
|
338
|
+
*rc* must be a freshly-resolved config (post-write); callers are
|
|
339
|
+
responsible for reloading. *default_lookup*, when provided, is called
|
|
340
|
+
as ``default_lookup(command_key, option_name)`` to obtain the layer-10
|
|
341
|
+
built-in default (typically the caller reaches into the CLI option
|
|
342
|
+
registry); ``None`` or a lookup returning ``None`` means layer 10 stays
|
|
343
|
+
unpopulated, which is fine for shadow comparison.
|
|
344
|
+
"""
|
|
345
|
+
classified = _classify_set_path(path, tier, rc.default_connection_name)
|
|
346
|
+
if classified is None:
|
|
347
|
+
return None
|
|
348
|
+
written_layer, command_key, option_name = classified
|
|
349
|
+
|
|
350
|
+
builtin_default = default_lookup(command_key, option_name) if default_lookup else None
|
|
351
|
+
|
|
352
|
+
states = walk_option_layers(
|
|
353
|
+
option_name,
|
|
354
|
+
command_key,
|
|
355
|
+
rc.default_project,
|
|
356
|
+
rc,
|
|
357
|
+
builtin_default=builtin_default,
|
|
358
|
+
)
|
|
359
|
+
winner = next((s for s in states if s.status == "winning"), None)
|
|
360
|
+
if winner is None or winner.layer >= written_layer:
|
|
361
|
+
return None # write is active (equal) or nothing set (impossible post-write)
|
|
362
|
+
|
|
363
|
+
written = next((s for s in states if s.layer == written_layer), None)
|
|
364
|
+
hint_path = _shadow_hint_path(winner, command_key, option_name, rc)
|
|
365
|
+
return ShadowWarning(
|
|
366
|
+
written={
|
|
367
|
+
"layer": written_layer,
|
|
368
|
+
"value": written.value if written else None,
|
|
369
|
+
"source": written.source if written else "",
|
|
370
|
+
},
|
|
371
|
+
winning={
|
|
372
|
+
"layer": winner.layer,
|
|
373
|
+
"value": winner.value,
|
|
374
|
+
"source": winner.source,
|
|
375
|
+
"origin": winner.origin,
|
|
376
|
+
},
|
|
377
|
+
option=f"{command_key}.{option_name}",
|
|
378
|
+
hint_path=hint_path,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _classify_set_path(
|
|
383
|
+
path: str, tier: str, active_connection: str | None
|
|
384
|
+
) -> tuple[int, str, str] | None:
|
|
385
|
+
"""Map a written config path to a chain layer + (command_key, option)."""
|
|
386
|
+
segs = path.split(".")
|
|
387
|
+
# defaults.<cmd>.<opt> — layer 4 (local) / 8 (global)
|
|
388
|
+
if len(segs) == 3 and segs[0] == "defaults":
|
|
389
|
+
layer = 4 if tier == "local" else 8
|
|
390
|
+
return layer, segs[1], segs[2]
|
|
391
|
+
# connections.<name>.defaults.<cmd>.<opt> — layer 6 when <name> is active
|
|
392
|
+
if (
|
|
393
|
+
len(segs) == 5
|
|
394
|
+
and segs[0] == "connections"
|
|
395
|
+
and segs[2] == "defaults"
|
|
396
|
+
and active_connection is not None
|
|
397
|
+
and segs[1] == active_connection
|
|
398
|
+
):
|
|
399
|
+
return 6, segs[3], segs[4]
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _shadow_hint_path(
|
|
404
|
+
winner: LayerState, command_key: str, option_name: str, rc: ResolvedConfig
|
|
405
|
+
) -> str | None:
|
|
406
|
+
"""Suggest a path that would actually override the winning layer, if any.
|
|
407
|
+
|
|
408
|
+
For a winning connection-flat value, suggest
|
|
409
|
+
``connections.<name>.defaults.<cmd>.<opt>`` which sits at layer 6 —
|
|
410
|
+
one step above layer 7 and scoped per-command.
|
|
411
|
+
"""
|
|
412
|
+
if winner.layer == 7 and rc.default_connection_name is not None:
|
|
413
|
+
return f"connections.{rc.default_connection_name}.defaults.{command_key}.{option_name}"
|
|
414
|
+
return None
|