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.
Files changed (75) hide show
  1. {kd_cli-2.1.6/kd_cli.egg-info → kd_cli-2.1.7}/PKG-INFO +1 -1
  2. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/system/__init__.py +22 -326
  3. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/config.py +156 -0
  4. kd_cli-2.1.7/kd/config_read.py +194 -0
  5. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/CHANGELOG.md +5 -0
  6. {kd_cli-2.1.6 → kd_cli-2.1.7/kd_cli.egg-info}/PKG-INFO +1 -1
  7. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/SOURCES.txt +1 -0
  8. {kd_cli-2.1.6 → kd_cli-2.1.7}/pyproject.toml +1 -1
  9. {kd_cli-2.1.6 → kd_cli-2.1.7}/LICENSE +0 -0
  10. {kd_cli-2.1.6 → kd_cli-2.1.7}/MANIFEST.in +0 -0
  11. {kd_cli-2.1.6 → kd_cli-2.1.7}/README.md +0 -0
  12. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/__init__.py +0 -0
  13. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/__init__.py +0 -0
  14. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/articles.py +0 -0
  15. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/http.py +0 -0
  16. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/hub.py +0 -0
  17. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/saved_queries.py +0 -0
  18. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/tags.py +0 -0
  19. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/api/youtrack.py +0 -0
  20. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/article_marker.py +0 -0
  21. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/auth.py +0 -0
  22. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/__init__.py +0 -0
  23. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/__init__.py +0 -0
  24. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/command_base.py +0 -0
  25. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/command_parser.py +0 -0
  26. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/namespaces.py +0 -0
  27. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/core/option_registry.py +0 -0
  28. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/input.py +0 -0
  29. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/main.py +0 -0
  30. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/__init__.py +0 -0
  31. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/alias/__init__.py +0 -0
  32. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/article/__init__.py +0 -0
  33. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/init.py +0 -0
  34. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/issue/__init__.py +0 -0
  35. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/primer/__init__.py +0 -0
  36. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/project/__init__.py +0 -0
  37. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/query/__init__.py +0 -0
  38. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/release_notes.py +0 -0
  39. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/search/__init__.py +0 -0
  40. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/tag/__init__.py +0 -0
  41. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/time/__init__.py +0 -0
  42. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/namespaces/user/__init__.py +0 -0
  43. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/output.py +0 -0
  44. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/cli/tree.py +0 -0
  45. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/client.py +0 -0
  46. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/config_schema.py +0 -0
  47. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/connection.py +0 -0
  48. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/debug.py +0 -0
  49. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/exceptions.py +0 -0
  50. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/history.py +0 -0
  51. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/__init__.py +0 -0
  52. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/article.py +0 -0
  53. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/assist.py +0 -0
  54. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/attachment.py +0 -0
  55. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/comment.py +0 -0
  56. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/common.py +0 -0
  57. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/issue.py +0 -0
  58. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/project.py +0 -0
  59. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/saved_query.py +0 -0
  60. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/search.py +0 -0
  61. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/tag.py +0 -0
  62. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/user.py +0 -0
  63. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/models/work_item.py +0 -0
  64. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/primer.py +0 -0
  65. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/query_parser.py +0 -0
  66. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/release_notes.py +0 -0
  67. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/__init__.py +0 -0
  68. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/command-synonyms.yaml +0 -0
  69. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/config-template.yaml +0 -0
  70. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd/resources/error-messages.yaml +0 -0
  71. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/dependency_links.txt +0 -0
  72. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/entry_points.txt +0 -0
  73. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/requires.txt +0 -0
  74. {kd_cli-2.1.6 → kd_cli-2.1.7}/kd_cli.egg-info/top_level.txt +0 -0
  75. {kd_cli-2.1.6 → kd_cli-2.1.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.1.6
3
+ Version: 2.1.7
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -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 = _nested_get(rc, path)
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
- warning = _detect_shadow(resolved_path, tier, options)
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 _detect_shadow(path: str, tier: str, options: ResolvedOptions) -> dict[str, Any] | None:
382
- """Return a warning payload if *path* (just written in *tier*) is shadowed.
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
- return options_map.get(command_key, {}).get(option_name)
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: dict[str, Any]) -> None:
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["written"]
484
- winning = warning["winning"]
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 {written['layer']} "
490
- f"({written['source']}), but layer {winning['layer']} ({winning['source']}) "
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
- hint = warning.get("hint_path")
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 {hint} {written['value']!r}")
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 = _activate_connection(rc, connection_override)
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.1.6
3
+ Version: 2.1.7
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -7,6 +7,7 @@ kd/article_marker.py
7
7
  kd/auth.py
8
8
  kd/client.py
9
9
  kd/config.py
10
+ kd/config_read.py
10
11
  kd/config_schema.py
11
12
  kd/connection.py
12
13
  kd/debug.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kd-cli"
7
- version = "2.1.6"
7
+ version = "2.1.7"
8
8
  description = "Knowledge Dispatcher for issue trackers and knowledge bases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
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