kd-cli 2.1.6__tar.gz → 2.1.7__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.1.6/kd_cli.egg-info → kd_cli-2.1.7}/PKG-INFO +1 -1
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/system/__init__.py +22 -326
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/config.py +156 -0
- kd_cli-2.1.7/kd/config_read.py +194 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/CHANGELOG.md +5 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/SOURCES.txt +1 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/pyproject.toml +1 -1
- {kd_cli-2.1.6 → kd_cli-2.1.7}/LICENSE +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/MANIFEST.in +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/README.md +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/articles.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/http.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/hub.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/tags.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/youtrack.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/article_marker.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/auth.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/input.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/main.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/output.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/tree.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/client.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/config_schema.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/connection.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/debug.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/exceptions.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/history.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/article.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/assist.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/attachment.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/comment.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/common.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/issue.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/project.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/saved_query.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/search.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/tag.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/user.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/work_item.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/primer.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/query_parser.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/release_notes.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.1.6 → kd_cli-2.1.7}/setup.cfg +0 -0
|
@@ -9,6 +9,7 @@ copy-on-write history for config and primer files.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import difflib
|
|
12
|
+
from dataclasses import asdict
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
@@ -18,14 +19,15 @@ from kd.cli.core.option_registry import OptionDeclaration, OptionLayer, Resolved
|
|
|
18
19
|
from kd.config import (
|
|
19
20
|
DEFAULT_CONFIG_PATH,
|
|
20
21
|
FLAT_KEYS,
|
|
21
|
-
LayerState,
|
|
22
22
|
ResolvedConfig,
|
|
23
|
+
ShadowWarning,
|
|
24
|
+
detect_shadow,
|
|
23
25
|
find_local_config,
|
|
24
|
-
load_config,
|
|
25
26
|
resolve_config,
|
|
26
27
|
update_config_path,
|
|
27
28
|
walk_option_layers,
|
|
28
29
|
)
|
|
30
|
+
from kd.config_read import get_config_path
|
|
29
31
|
from kd.config_schema import MISSING, ConfigKeyEntry, resolve_path, walk_schema
|
|
30
32
|
from kd.exceptions import ConfigError, ValidationError
|
|
31
33
|
from kd.history import (
|
|
@@ -273,7 +275,7 @@ class ConfigGetCommand(Command):
|
|
|
273
275
|
"source": str(rv.source),
|
|
274
276
|
"origin": rv.origin,
|
|
275
277
|
}
|
|
276
|
-
resolved =
|
|
278
|
+
resolved = get_config_path(rc, path)
|
|
277
279
|
if resolved is None:
|
|
278
280
|
return {"path": path, "value": None}
|
|
279
281
|
return resolved
|
|
@@ -370,132 +372,43 @@ class ConfigSetCommand(Command):
|
|
|
370
372
|
}
|
|
371
373
|
|
|
372
374
|
if not options.command.get("no-shadow-warning", False):
|
|
373
|
-
|
|
375
|
+
rc = resolve_config()
|
|
376
|
+
warning = detect_shadow(resolved_path, tier, rc, default_lookup=_lookup_builtin_default)
|
|
374
377
|
if warning is not None:
|
|
375
|
-
result["warning"] = warning
|
|
378
|
+
result["warning"] = asdict(warning)
|
|
376
379
|
_emit_shadow_warning(warning)
|
|
377
380
|
|
|
378
381
|
return result
|
|
379
382
|
|
|
380
383
|
|
|
381
|
-
def
|
|
382
|
-
"""
|
|
383
|
-
|
|
384
|
-
Returns ``None`` when the path is not part of the command-option chain,
|
|
385
|
-
when the write landed at the winning layer, or when the post-write walker
|
|
386
|
-
reports no higher-priority layer with a value. Skips paths that don't map
|
|
387
|
-
to a specific ``<command_key>.<option>`` target — flat top-level writes and
|
|
388
|
-
connection-level flat writes are cross-command by nature and are not
|
|
389
|
-
flagged here.
|
|
390
|
-
"""
|
|
391
|
-
rc = resolve_config()
|
|
392
|
-
classified = _classify_set_path(path, tier, rc.default_connection_name)
|
|
393
|
-
if classified is None:
|
|
394
|
-
return None
|
|
395
|
-
written_layer, command_key, option_name = classified
|
|
396
|
-
|
|
397
|
-
declared = _lookup_option(command_key, option_name)
|
|
398
|
-
builtin_default = declared.default if declared is not None else None
|
|
399
|
-
|
|
400
|
-
states = walk_option_layers(
|
|
401
|
-
option_name,
|
|
402
|
-
command_key,
|
|
403
|
-
_active_project(rc),
|
|
404
|
-
rc,
|
|
405
|
-
builtin_default=builtin_default,
|
|
406
|
-
)
|
|
407
|
-
winner = next((s for s in states if s.status == "winning"), None)
|
|
408
|
-
if winner is None or winner.layer >= written_layer:
|
|
409
|
-
return None # write is active (equal) or nothing set (impossible post-write)
|
|
410
|
-
|
|
411
|
-
written = next((s for s in states if s.layer == written_layer), None)
|
|
412
|
-
hint_path = _shadow_hint_path(winner, command_key, option_name, rc)
|
|
413
|
-
return {
|
|
414
|
-
"written": {
|
|
415
|
-
"layer": written_layer,
|
|
416
|
-
"value": written.value if written else None,
|
|
417
|
-
"source": written.source if written else "",
|
|
418
|
-
},
|
|
419
|
-
"winning": {
|
|
420
|
-
"layer": winner.layer,
|
|
421
|
-
"value": winner.value,
|
|
422
|
-
"source": winner.source,
|
|
423
|
-
"origin": winner.origin,
|
|
424
|
-
},
|
|
425
|
-
"option": f"{command_key}.{option_name}",
|
|
426
|
-
"hint_path": hint_path,
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def _classify_set_path(
|
|
431
|
-
path: str, tier: str, active_connection: str | None
|
|
432
|
-
) -> tuple[int, str, str] | None:
|
|
433
|
-
"""Map a written config path to a chain layer + (command_key, option)."""
|
|
434
|
-
segs = path.split(".")
|
|
435
|
-
# defaults.<cmd>.<opt> — layer 4 (local) / 8 (global)
|
|
436
|
-
if len(segs) == 3 and segs[0] == "defaults":
|
|
437
|
-
layer = 4 if tier == "local" else 8
|
|
438
|
-
return layer, segs[1], segs[2]
|
|
439
|
-
# connections.<name>.defaults.<cmd>.<opt> — layer 6 when <name> is active
|
|
440
|
-
if (
|
|
441
|
-
len(segs) == 5
|
|
442
|
-
and segs[0] == "connections"
|
|
443
|
-
and segs[2] == "defaults"
|
|
444
|
-
and active_connection is not None
|
|
445
|
-
and segs[1] == active_connection
|
|
446
|
-
):
|
|
447
|
-
return 6, segs[3], segs[4]
|
|
448
|
-
return None
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
def _lookup_option(command_key: str, option_name: str) -> OptionDeclaration | None:
|
|
452
|
-
"""Best-effort OptionDeclaration lookup; None if the registry cannot answer."""
|
|
384
|
+
def _lookup_builtin_default(command_key: str, option_name: str) -> Any:
|
|
385
|
+
"""Option-registry accessor passed to :func:`detect_shadow` for layer-10 hints."""
|
|
453
386
|
try:
|
|
454
387
|
options_map = _command_options_map()
|
|
455
388
|
except Exception:
|
|
456
389
|
return None
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
def _active_project(rc: ResolvedConfig) -> str | None:
|
|
461
|
-
"""Project context for shadow detection — mirrors _determine_project's config path."""
|
|
462
|
-
return rc.default_project
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
def _shadow_hint_path(
|
|
466
|
-
winner: LayerState, command_key: str, option_name: str, rc: ResolvedConfig
|
|
467
|
-
) -> str | None:
|
|
468
|
-
"""Suggest a path that would actually override the winning layer, if any.
|
|
469
|
-
|
|
470
|
-
For a winning connection-flat value, suggest
|
|
471
|
-
``connections.<name>.defaults.<cmd>.<opt>`` which sits at layer 6 —
|
|
472
|
-
one step above layer 7 and scoped per-command.
|
|
473
|
-
"""
|
|
474
|
-
if winner.layer == 7 and rc.default_connection_name is not None:
|
|
475
|
-
return f"connections.{rc.default_connection_name}.defaults.{command_key}.{option_name}"
|
|
476
|
-
return None
|
|
390
|
+
decl = options_map.get(command_key, {}).get(option_name)
|
|
391
|
+
return decl.default if decl is not None else None
|
|
477
392
|
|
|
478
393
|
|
|
479
|
-
def _emit_shadow_warning(warning:
|
|
394
|
+
def _emit_shadow_warning(warning: ShadowWarning) -> None:
|
|
480
395
|
"""Render the shadow warning to stderr — non-fatal, user-readable."""
|
|
481
396
|
import sys
|
|
482
397
|
|
|
483
|
-
written = warning
|
|
484
|
-
winning = warning
|
|
485
|
-
option = warning["option"]
|
|
398
|
+
written = warning.written
|
|
399
|
+
winning = warning.winning
|
|
486
400
|
lines = [
|
|
487
401
|
"",
|
|
488
402
|
(
|
|
489
|
-
f"Warning: wrote {option} = {written['value']!r} at layer
|
|
490
|
-
f"({written['source']}), but layer {winning['layer']}
|
|
491
|
-
f"= {winning['value']!r} shadows it."
|
|
403
|
+
f"Warning: wrote {warning.option} = {written['value']!r} at layer "
|
|
404
|
+
f"{written['layer']} ({written['source']}), but layer {winning['layer']} "
|
|
405
|
+
f"({winning['source']}) = {winning['value']!r} shadows it."
|
|
492
406
|
),
|
|
493
|
-
f"Run 'kd /system/config which {option}' for the full chain.",
|
|
407
|
+
f"Run 'kd /system/config which {warning.option}' for the full chain.",
|
|
494
408
|
]
|
|
495
|
-
|
|
496
|
-
if hint:
|
|
409
|
+
if warning.hint_path:
|
|
497
410
|
lines.append("Hint: to override at a higher-priority layer, set:")
|
|
498
|
-
lines.append(f" kd /system/config set {
|
|
411
|
+
lines.append(f" kd /system/config set {warning.hint_path} {written['value']!r}")
|
|
499
412
|
print("\n".join(lines), file=sys.stderr)
|
|
500
413
|
|
|
501
414
|
|
|
@@ -547,196 +460,6 @@ def _render_default(default: Any) -> str:
|
|
|
547
460
|
return str(default)
|
|
548
461
|
|
|
549
462
|
|
|
550
|
-
# ---------------------------------------------------------------------------
|
|
551
|
-
# Nested config get
|
|
552
|
-
# ---------------------------------------------------------------------------
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
def _nested_get(rc: ResolvedConfig, path: str) -> dict[str, Any] | None:
|
|
556
|
-
"""Resolve a nested dotted path to ``{path, value, source, origin}``.
|
|
557
|
-
|
|
558
|
-
Returns ``None`` when the path is legal but unset — caller wraps that
|
|
559
|
-
into ``{path, value: None}``.
|
|
560
|
-
"""
|
|
561
|
-
segments = path.split(".")
|
|
562
|
-
root = segments[0]
|
|
563
|
-
if root == "connections" and len(segments) >= 3:
|
|
564
|
-
return _nested_get_connection(rc, segments, path)
|
|
565
|
-
if root == "defaults":
|
|
566
|
-
return _nested_get_from_section(
|
|
567
|
-
path, segments[1:], [("local", rc.local_defaults), ("global", rc.global_defaults)]
|
|
568
|
-
)
|
|
569
|
-
if root == "projects":
|
|
570
|
-
return _nested_get_project(rc, segments, path)
|
|
571
|
-
if root == "aliases" and len(segments) == 2:
|
|
572
|
-
return _nested_get_from_section(
|
|
573
|
-
path, segments[1:], [("local", rc.local_aliases), ("global", rc.global_aliases)]
|
|
574
|
-
)
|
|
575
|
-
if root == "history" and len(segments) == 2:
|
|
576
|
-
return _nested_get_history(path, segments[1])
|
|
577
|
-
return None
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
def _nested_get_connection(
|
|
581
|
-
rc: ResolvedConfig, segments: list[str], path: str
|
|
582
|
-
) -> dict[str, Any] | None:
|
|
583
|
-
name = segments[1]
|
|
584
|
-
conn = rc.connections.get(name)
|
|
585
|
-
if conn is None:
|
|
586
|
-
return None
|
|
587
|
-
# connections.<name>.<key>
|
|
588
|
-
if len(segments) == 3:
|
|
589
|
-
key = segments[2]
|
|
590
|
-
value = getattr(conn, key, None)
|
|
591
|
-
if value is None or (isinstance(value, (dict, list)) and not value):
|
|
592
|
-
return None
|
|
593
|
-
origin = rc.connection_origins.get(name)
|
|
594
|
-
key_origin = origin.keys.get(key) if origin else None
|
|
595
|
-
if key_origin is not None:
|
|
596
|
-
return {
|
|
597
|
-
"path": path,
|
|
598
|
-
"value": _render_value(value),
|
|
599
|
-
"source": key_origin.source,
|
|
600
|
-
"origin": key_origin.path,
|
|
601
|
-
}
|
|
602
|
-
return {
|
|
603
|
-
"path": path,
|
|
604
|
-
"value": _render_value(value),
|
|
605
|
-
"source": origin.source if origin else "",
|
|
606
|
-
"origin": origin.path if origin else "",
|
|
607
|
-
}
|
|
608
|
-
# connections.<name>.compatibility.<field>
|
|
609
|
-
if len(segments) == 4 and segments[2] == "compatibility":
|
|
610
|
-
compat = conn.compatibility
|
|
611
|
-
field_name = segments[3]
|
|
612
|
-
value = getattr(compat, field_name, None)
|
|
613
|
-
if value is None:
|
|
614
|
-
return None
|
|
615
|
-
origin = rc.connection_origins.get(name)
|
|
616
|
-
key_origin = origin.keys.get("compatibility") if origin else None
|
|
617
|
-
source = key_origin.source if key_origin else (origin.source if origin else "")
|
|
618
|
-
origin_path = key_origin.path if key_origin else (origin.path if origin else "")
|
|
619
|
-
return {
|
|
620
|
-
"path": path,
|
|
621
|
-
"value": _render_value(value),
|
|
622
|
-
"source": source,
|
|
623
|
-
"origin": origin_path,
|
|
624
|
-
}
|
|
625
|
-
# connections.<name>.defaults.<cmd>.<opt>
|
|
626
|
-
if len(segments) == 5 and segments[2] == "defaults":
|
|
627
|
-
cmd, opt = segments[3], segments[4]
|
|
628
|
-
defaults = conn.defaults.get(cmd) if isinstance(conn.defaults, dict) else None
|
|
629
|
-
if not isinstance(defaults, dict) or opt not in defaults:
|
|
630
|
-
return None
|
|
631
|
-
origin = rc.connection_origins.get(name)
|
|
632
|
-
key_origin = origin.keys.get("defaults") if origin else None
|
|
633
|
-
source = key_origin.source if key_origin else (origin.source if origin else "")
|
|
634
|
-
origin_path = key_origin.path if key_origin else (origin.path if origin else "")
|
|
635
|
-
return {
|
|
636
|
-
"path": path,
|
|
637
|
-
"value": _render_value(defaults[opt]),
|
|
638
|
-
"source": source,
|
|
639
|
-
"origin": origin_path,
|
|
640
|
-
}
|
|
641
|
-
return None
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
def _nested_get_project(
|
|
645
|
-
rc: ResolvedConfig, segments: list[str], path: str
|
|
646
|
-
) -> dict[str, Any] | None:
|
|
647
|
-
if len(segments) < 4:
|
|
648
|
-
return None
|
|
649
|
-
key = segments[1]
|
|
650
|
-
section = segments[2]
|
|
651
|
-
for source_label, projects in (("local", rc.local_projects), ("global", rc.global_projects)):
|
|
652
|
-
proj = projects.get(key)
|
|
653
|
-
if not isinstance(proj, dict):
|
|
654
|
-
continue
|
|
655
|
-
if section == "field_aliases" and len(segments) == 4:
|
|
656
|
-
aliases = proj.get("field_aliases")
|
|
657
|
-
if isinstance(aliases, dict) and segments[3] in aliases:
|
|
658
|
-
return {
|
|
659
|
-
"path": path,
|
|
660
|
-
"value": _render_value(aliases[segments[3]]),
|
|
661
|
-
"source": source_label,
|
|
662
|
-
"origin": "",
|
|
663
|
-
}
|
|
664
|
-
if section == "defaults" and len(segments) == 5:
|
|
665
|
-
defaults = proj.get("defaults")
|
|
666
|
-
if isinstance(defaults, dict):
|
|
667
|
-
cmd = defaults.get(segments[3])
|
|
668
|
-
if isinstance(cmd, dict) and segments[4] in cmd:
|
|
669
|
-
return {
|
|
670
|
-
"path": path,
|
|
671
|
-
"value": _render_value(cmd[segments[4]]),
|
|
672
|
-
"source": source_label,
|
|
673
|
-
"origin": "",
|
|
674
|
-
}
|
|
675
|
-
return None
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
def _nested_get_from_section(
|
|
679
|
-
path: str,
|
|
680
|
-
remaining: list[str],
|
|
681
|
-
sources: list[tuple[str, dict[str, Any]]],
|
|
682
|
-
) -> dict[str, Any] | None:
|
|
683
|
-
"""Walk a flat or nested dict section across local/global sources."""
|
|
684
|
-
for source_label, section in sources:
|
|
685
|
-
value = _walk_dict(section, remaining)
|
|
686
|
-
if value is not None:
|
|
687
|
-
return {
|
|
688
|
-
"path": path,
|
|
689
|
-
"value": _render_value(value),
|
|
690
|
-
"source": source_label,
|
|
691
|
-
"origin": "",
|
|
692
|
-
}
|
|
693
|
-
return None
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
def _walk_dict(data: Any, segments: list[str]) -> Any:
|
|
697
|
-
current: Any = data
|
|
698
|
-
for seg in segments:
|
|
699
|
-
if not isinstance(current, dict) or seg not in current:
|
|
700
|
-
return None
|
|
701
|
-
current = current[seg]
|
|
702
|
-
return current
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
def _nested_get_history(path: str, key: str) -> dict[str, Any] | None:
|
|
706
|
-
"""``history`` is not exposed on ResolvedConfig — read both tiers directly."""
|
|
707
|
-
for source_label, tier_path in (
|
|
708
|
-
("local", find_local_config()),
|
|
709
|
-
("global", DEFAULT_CONFIG_PATH),
|
|
710
|
-
):
|
|
711
|
-
if tier_path is None or not tier_path.exists():
|
|
712
|
-
continue
|
|
713
|
-
cfg = load_config(tier_path)
|
|
714
|
-
value = getattr(cfg.history, key, None)
|
|
715
|
-
if value is None:
|
|
716
|
-
continue
|
|
717
|
-
# Only surface values actually written to the file, not schema defaults.
|
|
718
|
-
import yaml as _yaml
|
|
719
|
-
|
|
720
|
-
raw = _yaml.safe_load(tier_path.read_text()) or {}
|
|
721
|
-
history_block = raw.get("history") if isinstance(raw.get("history"), dict) else {}
|
|
722
|
-
if not isinstance(history_block, dict) or key not in history_block:
|
|
723
|
-
continue
|
|
724
|
-
return {
|
|
725
|
-
"path": path,
|
|
726
|
-
"value": _render_value(value),
|
|
727
|
-
"source": source_label,
|
|
728
|
-
"origin": str(tier_path),
|
|
729
|
-
}
|
|
730
|
-
return None
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
def _render_value(value: Any) -> Any:
|
|
734
|
-
"""Render a resolved value for output — keep scalars, stringify everything else."""
|
|
735
|
-
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
736
|
-
return value
|
|
737
|
-
return str(value)
|
|
738
|
-
|
|
739
|
-
|
|
740
463
|
class ConfigWhichCommand(Command):
|
|
741
464
|
"""Show which config files are active, or trace a single option's resolution."""
|
|
742
465
|
|
|
@@ -877,7 +600,7 @@ def _which_option_path(
|
|
|
877
600
|
if connection_override not in rc.connections:
|
|
878
601
|
known = ", ".join(sorted(rc.connections)) or "(none)"
|
|
879
602
|
raise ConfigError(f"Unknown connection: {connection_override}. Known: {known}.")
|
|
880
|
-
active_rc =
|
|
603
|
+
active_rc = rc.with_active_connection(connection_override)
|
|
881
604
|
active_project = project_override # None → no project layer
|
|
882
605
|
|
|
883
606
|
states = walk_option_layers(
|
|
@@ -915,33 +638,6 @@ def _which_option_path(
|
|
|
915
638
|
}
|
|
916
639
|
|
|
917
640
|
|
|
918
|
-
def _activate_connection(rc: ResolvedConfig, name: str) -> ResolvedConfig:
|
|
919
|
-
"""Return a derived ResolvedConfig with *name* as the active connection."""
|
|
920
|
-
conn = rc.connections[name]
|
|
921
|
-
conn_defaults = conn.defaults
|
|
922
|
-
conn_flat: dict[str, Any] = {}
|
|
923
|
-
if conn.output is not None:
|
|
924
|
-
conn_flat["output"] = conn.output
|
|
925
|
-
if conn.limit is not None:
|
|
926
|
-
conn_flat["limit"] = conn.limit
|
|
927
|
-
return ResolvedConfig(
|
|
928
|
-
rc.entries,
|
|
929
|
-
local_defaults=rc.local_defaults,
|
|
930
|
-
global_defaults=rc.global_defaults,
|
|
931
|
-
local_projects=rc.local_projects,
|
|
932
|
-
global_projects=rc.global_projects,
|
|
933
|
-
local_flat=rc.local_flat,
|
|
934
|
-
global_flat=rc.global_flat,
|
|
935
|
-
local_aliases=rc.local_aliases,
|
|
936
|
-
global_aliases=rc.global_aliases,
|
|
937
|
-
connections=rc.connections,
|
|
938
|
-
connection_origins=rc.connection_origins,
|
|
939
|
-
default_connection_name=name,
|
|
940
|
-
connection_defaults=conn_defaults,
|
|
941
|
-
connection_flat=conn_flat,
|
|
942
|
-
)
|
|
943
|
-
|
|
944
|
-
|
|
945
641
|
def _command_options_map() -> dict[str, dict[str, OptionDeclaration]]:
|
|
946
642
|
"""Walk the registered namespace tree and return ``{command_key: {opt: decl}}``.
|
|
947
643
|
|
|
@@ -31,6 +31,7 @@ import os
|
|
|
31
31
|
from dataclasses import dataclass, field
|
|
32
32
|
from enum import StrEnum
|
|
33
33
|
from pathlib import Path
|
|
34
|
+
from collections.abc import Callable
|
|
34
35
|
from typing import TYPE_CHECKING, Any, NamedTuple, get_origin
|
|
35
36
|
|
|
36
37
|
import yaml
|
|
@@ -248,6 +249,43 @@ class ResolvedConfig:
|
|
|
248
249
|
merged.update(self._local_aliases)
|
|
249
250
|
return merged
|
|
250
251
|
|
|
252
|
+
# -- derived views -------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def with_active_connection(self, name: str) -> "ResolvedConfig":
|
|
255
|
+
"""Return a derived ResolvedConfig with *name* as the active connection.
|
|
256
|
+
|
|
257
|
+
Reuses the existing connection map and per-connection origins — only
|
|
258
|
+
``default_connection_name``, ``connection_defaults``, and
|
|
259
|
+
``connection_flat`` change, reflecting the layer 6/7 view under the
|
|
260
|
+
requested connection. Used by ``kd /system/config which --connection``
|
|
261
|
+
for what-if exploration.
|
|
262
|
+
|
|
263
|
+
Raises :class:`KeyError` when *name* is not a known connection; callers
|
|
264
|
+
that need a user-facing message are expected to translate.
|
|
265
|
+
"""
|
|
266
|
+
conn = self._connections[name]
|
|
267
|
+
conn_flat: dict[str, Any] = {}
|
|
268
|
+
if conn.output is not None:
|
|
269
|
+
conn_flat["output"] = conn.output
|
|
270
|
+
if conn.limit is not None:
|
|
271
|
+
conn_flat["limit"] = conn.limit
|
|
272
|
+
return ResolvedConfig(
|
|
273
|
+
self._entries,
|
|
274
|
+
local_defaults=self._local_defaults,
|
|
275
|
+
global_defaults=self._global_defaults,
|
|
276
|
+
local_projects=self._local_projects,
|
|
277
|
+
global_projects=self._global_projects,
|
|
278
|
+
local_flat=self._local_flat,
|
|
279
|
+
global_flat=self._global_flat,
|
|
280
|
+
local_aliases=self._local_aliases,
|
|
281
|
+
global_aliases=self._global_aliases,
|
|
282
|
+
connections=self._connections,
|
|
283
|
+
connection_origins=self._connection_origins,
|
|
284
|
+
default_connection_name=name,
|
|
285
|
+
connection_defaults=conn.defaults,
|
|
286
|
+
connection_flat=conn_flat,
|
|
287
|
+
)
|
|
288
|
+
|
|
251
289
|
|
|
252
290
|
# ---------------------------------------------------------------------------
|
|
253
291
|
# Directory walk
|
|
@@ -1188,6 +1226,124 @@ def _project_command_default(
|
|
|
1188
1226
|
return cmd_defaults.get(opt)
|
|
1189
1227
|
|
|
1190
1228
|
|
|
1229
|
+
# ---------------------------------------------------------------------------
|
|
1230
|
+
# Shadow detection — post-write check against the 10-layer chain
|
|
1231
|
+
# ---------------------------------------------------------------------------
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
@dataclass(frozen=True)
|
|
1235
|
+
class ShadowWarning:
|
|
1236
|
+
"""Result of :func:`detect_shadow` — a written value is shadowed.
|
|
1237
|
+
|
|
1238
|
+
``written`` names the layer and source the write landed at; ``winning``
|
|
1239
|
+
names the higher-priority layer whose value continues to resolve.
|
|
1240
|
+
``option`` is ``"<command_key>.<option>"``. ``hint_path`` suggests a
|
|
1241
|
+
dotted path that would actually override the winning layer, or None
|
|
1242
|
+
when no such path exists below the winning tier.
|
|
1243
|
+
"""
|
|
1244
|
+
|
|
1245
|
+
written: dict[str, Any]
|
|
1246
|
+
winning: dict[str, Any]
|
|
1247
|
+
option: str
|
|
1248
|
+
hint_path: str | None
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def detect_shadow(
|
|
1252
|
+
path: str,
|
|
1253
|
+
tier: str,
|
|
1254
|
+
rc: ResolvedConfig,
|
|
1255
|
+
*,
|
|
1256
|
+
default_lookup: Callable[[str, str], Any] | None = None,
|
|
1257
|
+
) -> ShadowWarning | None:
|
|
1258
|
+
"""Return a :class:`ShadowWarning` when *path* (just written in *tier*)
|
|
1259
|
+
is shadowed by a higher-priority layer in *rc*, else ``None``.
|
|
1260
|
+
|
|
1261
|
+
Returns ``None`` when the path is not part of the command-option chain,
|
|
1262
|
+
when the write landed at the winning layer, or when the post-write walker
|
|
1263
|
+
reports no higher-priority layer with a value. Paths that don't map to a
|
|
1264
|
+
specific ``<command_key>.<option>`` target — flat top-level writes and
|
|
1265
|
+
connection-level flat writes — are cross-command by nature and are not
|
|
1266
|
+
flagged here.
|
|
1267
|
+
|
|
1268
|
+
*rc* must be a freshly-resolved config (post-write); callers are
|
|
1269
|
+
responsible for reloading. *default_lookup*, when provided, is called
|
|
1270
|
+
as ``default_lookup(command_key, option_name)`` to obtain the layer-10
|
|
1271
|
+
built-in default (typically the caller reaches into the CLI option
|
|
1272
|
+
registry); ``None`` or a lookup returning ``None`` means layer 10 stays
|
|
1273
|
+
unpopulated, which is fine for shadow comparison.
|
|
1274
|
+
"""
|
|
1275
|
+
classified = _classify_set_path(path, tier, rc.default_connection_name)
|
|
1276
|
+
if classified is None:
|
|
1277
|
+
return None
|
|
1278
|
+
written_layer, command_key, option_name = classified
|
|
1279
|
+
|
|
1280
|
+
builtin_default = default_lookup(command_key, option_name) if default_lookup else None
|
|
1281
|
+
|
|
1282
|
+
states = walk_option_layers(
|
|
1283
|
+
option_name,
|
|
1284
|
+
command_key,
|
|
1285
|
+
rc.default_project,
|
|
1286
|
+
rc,
|
|
1287
|
+
builtin_default=builtin_default,
|
|
1288
|
+
)
|
|
1289
|
+
winner = next((s for s in states if s.status == "winning"), None)
|
|
1290
|
+
if winner is None or winner.layer >= written_layer:
|
|
1291
|
+
return None # write is active (equal) or nothing set (impossible post-write)
|
|
1292
|
+
|
|
1293
|
+
written = next((s for s in states if s.layer == written_layer), None)
|
|
1294
|
+
hint_path = _shadow_hint_path(winner, command_key, option_name, rc)
|
|
1295
|
+
return ShadowWarning(
|
|
1296
|
+
written={
|
|
1297
|
+
"layer": written_layer,
|
|
1298
|
+
"value": written.value if written else None,
|
|
1299
|
+
"source": written.source if written else "",
|
|
1300
|
+
},
|
|
1301
|
+
winning={
|
|
1302
|
+
"layer": winner.layer,
|
|
1303
|
+
"value": winner.value,
|
|
1304
|
+
"source": winner.source,
|
|
1305
|
+
"origin": winner.origin,
|
|
1306
|
+
},
|
|
1307
|
+
option=f"{command_key}.{option_name}",
|
|
1308
|
+
hint_path=hint_path,
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _classify_set_path(
|
|
1313
|
+
path: str, tier: str, active_connection: str | None
|
|
1314
|
+
) -> tuple[int, str, str] | None:
|
|
1315
|
+
"""Map a written config path to a chain layer + (command_key, option)."""
|
|
1316
|
+
segs = path.split(".")
|
|
1317
|
+
# defaults.<cmd>.<opt> — layer 4 (local) / 8 (global)
|
|
1318
|
+
if len(segs) == 3 and segs[0] == "defaults":
|
|
1319
|
+
layer = 4 if tier == "local" else 8
|
|
1320
|
+
return layer, segs[1], segs[2]
|
|
1321
|
+
# connections.<name>.defaults.<cmd>.<opt> — layer 6 when <name> is active
|
|
1322
|
+
if (
|
|
1323
|
+
len(segs) == 5
|
|
1324
|
+
and segs[0] == "connections"
|
|
1325
|
+
and segs[2] == "defaults"
|
|
1326
|
+
and active_connection is not None
|
|
1327
|
+
and segs[1] == active_connection
|
|
1328
|
+
):
|
|
1329
|
+
return 6, segs[3], segs[4]
|
|
1330
|
+
return None
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _shadow_hint_path(
|
|
1334
|
+
winner: LayerState, command_key: str, option_name: str, rc: ResolvedConfig
|
|
1335
|
+
) -> str | None:
|
|
1336
|
+
"""Suggest a path that would actually override the winning layer, if any.
|
|
1337
|
+
|
|
1338
|
+
For a winning connection-flat value, suggest
|
|
1339
|
+
``connections.<name>.defaults.<cmd>.<opt>`` which sits at layer 6 —
|
|
1340
|
+
one step above layer 7 and scoped per-command.
|
|
1341
|
+
"""
|
|
1342
|
+
if winner.layer == 7 and rc.default_connection_name is not None:
|
|
1343
|
+
return f"connections.{rc.default_connection_name}.defaults.{command_key}.{option_name}"
|
|
1344
|
+
return None
|
|
1345
|
+
|
|
1346
|
+
|
|
1191
1347
|
# ---------------------------------------------------------------------------
|
|
1192
1348
|
# Project init
|
|
1193
1349
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Path-oriented reads against a resolved config.
|
|
2
|
+
|
|
3
|
+
Inverse of :func:`kd.config.update_config_path`. Walks a dotted path
|
|
4
|
+
against a :class:`~kd.config.ResolvedConfig` and returns the leaf value
|
|
5
|
+
with source + origin attribution, matching the attribution shape that
|
|
6
|
+
``kd /system/config get`` surfaces.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from kd.config import DEFAULT_CONFIG_PATH, ResolvedConfig, find_local_config, load_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_config_path(rc: ResolvedConfig, path: str) -> dict[str, Any] | None:
|
|
19
|
+
"""Resolve a nested dotted path to ``{path, value, source, origin}``.
|
|
20
|
+
|
|
21
|
+
Returns ``None`` when the path is legal but unset — callers wrap that
|
|
22
|
+
into ``{path, value: None}``.
|
|
23
|
+
"""
|
|
24
|
+
segments = path.split(".")
|
|
25
|
+
root = segments[0]
|
|
26
|
+
if root == "connections" and len(segments) >= 3:
|
|
27
|
+
return _get_connection(rc, segments, path)
|
|
28
|
+
if root == "defaults":
|
|
29
|
+
return _get_from_section(
|
|
30
|
+
path, segments[1:], [("local", rc.local_defaults), ("global", rc.global_defaults)]
|
|
31
|
+
)
|
|
32
|
+
if root == "projects":
|
|
33
|
+
return _get_project(rc, segments, path)
|
|
34
|
+
if root == "aliases" and len(segments) == 2:
|
|
35
|
+
return _get_from_section(
|
|
36
|
+
path, segments[1:], [("local", rc.local_aliases), ("global", rc.global_aliases)]
|
|
37
|
+
)
|
|
38
|
+
if root == "history" and len(segments) == 2:
|
|
39
|
+
return _get_history(path, segments[1])
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_connection(rc: ResolvedConfig, segments: list[str], path: str) -> dict[str, Any] | None:
|
|
44
|
+
name = segments[1]
|
|
45
|
+
conn = rc.connections.get(name)
|
|
46
|
+
if conn is None:
|
|
47
|
+
return None
|
|
48
|
+
# connections.<name>.<key>
|
|
49
|
+
if len(segments) == 3:
|
|
50
|
+
key = segments[2]
|
|
51
|
+
value = getattr(conn, key, None)
|
|
52
|
+
if value is None or (isinstance(value, (dict, list)) and not value):
|
|
53
|
+
return None
|
|
54
|
+
origin = rc.connection_origins.get(name)
|
|
55
|
+
key_origin = origin.keys.get(key) if origin else None
|
|
56
|
+
if key_origin is not None:
|
|
57
|
+
return {
|
|
58
|
+
"path": path,
|
|
59
|
+
"value": _render_value(value),
|
|
60
|
+
"source": key_origin.source,
|
|
61
|
+
"origin": key_origin.path,
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
"path": path,
|
|
65
|
+
"value": _render_value(value),
|
|
66
|
+
"source": origin.source if origin else "",
|
|
67
|
+
"origin": origin.path if origin else "",
|
|
68
|
+
}
|
|
69
|
+
# connections.<name>.compatibility.<field>
|
|
70
|
+
if len(segments) == 4 and segments[2] == "compatibility":
|
|
71
|
+
compat = conn.compatibility
|
|
72
|
+
field_name = segments[3]
|
|
73
|
+
value = getattr(compat, field_name, None)
|
|
74
|
+
if value is None:
|
|
75
|
+
return None
|
|
76
|
+
origin = rc.connection_origins.get(name)
|
|
77
|
+
key_origin = origin.keys.get("compatibility") if origin else None
|
|
78
|
+
source = key_origin.source if key_origin else (origin.source if origin else "")
|
|
79
|
+
origin_path = key_origin.path if key_origin else (origin.path if origin else "")
|
|
80
|
+
return {
|
|
81
|
+
"path": path,
|
|
82
|
+
"value": _render_value(value),
|
|
83
|
+
"source": source,
|
|
84
|
+
"origin": origin_path,
|
|
85
|
+
}
|
|
86
|
+
# connections.<name>.defaults.<cmd>.<opt>
|
|
87
|
+
if len(segments) == 5 and segments[2] == "defaults":
|
|
88
|
+
cmd, opt = segments[3], segments[4]
|
|
89
|
+
defaults = conn.defaults.get(cmd) if isinstance(conn.defaults, dict) else None
|
|
90
|
+
if not isinstance(defaults, dict) or opt not in defaults:
|
|
91
|
+
return None
|
|
92
|
+
origin = rc.connection_origins.get(name)
|
|
93
|
+
key_origin = origin.keys.get("defaults") if origin else None
|
|
94
|
+
source = key_origin.source if key_origin else (origin.source if origin else "")
|
|
95
|
+
origin_path = key_origin.path if key_origin else (origin.path if origin else "")
|
|
96
|
+
return {
|
|
97
|
+
"path": path,
|
|
98
|
+
"value": _render_value(defaults[opt]),
|
|
99
|
+
"source": source,
|
|
100
|
+
"origin": origin_path,
|
|
101
|
+
}
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_project(rc: ResolvedConfig, segments: list[str], path: str) -> dict[str, Any] | None:
|
|
106
|
+
if len(segments) < 4:
|
|
107
|
+
return None
|
|
108
|
+
key = segments[1]
|
|
109
|
+
section = segments[2]
|
|
110
|
+
for source_label, projects in (("local", rc.local_projects), ("global", rc.global_projects)):
|
|
111
|
+
proj = projects.get(key)
|
|
112
|
+
if not isinstance(proj, dict):
|
|
113
|
+
continue
|
|
114
|
+
if section == "field_aliases" and len(segments) == 4:
|
|
115
|
+
aliases = proj.get("field_aliases")
|
|
116
|
+
if isinstance(aliases, dict) and segments[3] in aliases:
|
|
117
|
+
return {
|
|
118
|
+
"path": path,
|
|
119
|
+
"value": _render_value(aliases[segments[3]]),
|
|
120
|
+
"source": source_label,
|
|
121
|
+
"origin": "",
|
|
122
|
+
}
|
|
123
|
+
if section == "defaults" and len(segments) == 5:
|
|
124
|
+
defaults = proj.get("defaults")
|
|
125
|
+
if isinstance(defaults, dict):
|
|
126
|
+
cmd = defaults.get(segments[3])
|
|
127
|
+
if isinstance(cmd, dict) and segments[4] in cmd:
|
|
128
|
+
return {
|
|
129
|
+
"path": path,
|
|
130
|
+
"value": _render_value(cmd[segments[4]]),
|
|
131
|
+
"source": source_label,
|
|
132
|
+
"origin": "",
|
|
133
|
+
}
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _get_from_section(
|
|
138
|
+
path: str,
|
|
139
|
+
remaining: list[str],
|
|
140
|
+
sources: list[tuple[str, dict[str, Any]]],
|
|
141
|
+
) -> dict[str, Any] | None:
|
|
142
|
+
"""Walk a flat or nested dict section across local/global sources."""
|
|
143
|
+
for source_label, section in sources:
|
|
144
|
+
value = _walk_dict(section, remaining)
|
|
145
|
+
if value is not None:
|
|
146
|
+
return {
|
|
147
|
+
"path": path,
|
|
148
|
+
"value": _render_value(value),
|
|
149
|
+
"source": source_label,
|
|
150
|
+
"origin": "",
|
|
151
|
+
}
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _walk_dict(data: Any, segments: list[str]) -> Any:
|
|
156
|
+
current: Any = data
|
|
157
|
+
for seg in segments:
|
|
158
|
+
if not isinstance(current, dict) or seg not in current:
|
|
159
|
+
return None
|
|
160
|
+
current = current[seg]
|
|
161
|
+
return current
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _get_history(path: str, key: str) -> dict[str, Any] | None:
|
|
165
|
+
"""``history`` is not exposed on ResolvedConfig — read both tiers directly."""
|
|
166
|
+
for source_label, tier_path in (
|
|
167
|
+
("local", find_local_config()),
|
|
168
|
+
("global", DEFAULT_CONFIG_PATH),
|
|
169
|
+
):
|
|
170
|
+
if tier_path is None or not tier_path.exists():
|
|
171
|
+
continue
|
|
172
|
+
cfg = load_config(tier_path)
|
|
173
|
+
value = getattr(cfg.history, key, None)
|
|
174
|
+
if value is None:
|
|
175
|
+
continue
|
|
176
|
+
# Only surface values actually written to the file, not schema defaults.
|
|
177
|
+
raw = yaml.safe_load(tier_path.read_text()) or {}
|
|
178
|
+
history_block = raw.get("history") if isinstance(raw.get("history"), dict) else {}
|
|
179
|
+
if not isinstance(history_block, dict) or key not in history_block:
|
|
180
|
+
continue
|
|
181
|
+
return {
|
|
182
|
+
"path": path,
|
|
183
|
+
"value": _render_value(value),
|
|
184
|
+
"source": source_label,
|
|
185
|
+
"origin": str(tier_path),
|
|
186
|
+
}
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _render_value(value: Any) -> Any:
|
|
191
|
+
"""Render a resolved value for output — keep scalars, stringify everything else."""
|
|
192
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
193
|
+
return value
|
|
194
|
+
return str(value)
|
|
@@ -4,6 +4,11 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.1.7] — 2026-04-18
|
|
8
|
+
|
|
9
|
+
### Internal
|
|
10
|
+
- Config get/set/which surface now lives symmetrically in the SDK, not split across the CLI module. Pure refactor — no command output, flag, or error-message change (KDCLI-63).
|
|
11
|
+
|
|
7
12
|
## [2.1.6] — 2026-04-17
|
|
8
13
|
|
|
9
14
|
Milestone 005 — CLI friction audit, session S2. Two raise sites enriched so users land on the right file and the right fix.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|