kd-cli 2.10.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.10.0/kd_cli.egg-info → kd_cli-2.11.1}/PKG-INFO +18 -14
- {kd_cli-2.10.0 → kd_cli-2.11.1}/README.md +17 -13
- kd_cli-2.11.1/kd/_option_chain.py +414 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/http.py +97 -18
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/youtrack.py +3 -3
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/auth.py +3 -23
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cache.py +329 -379
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/command_base.py +4 -1
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/namespaces.py +9 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/input.py +31 -21
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/main.py +400 -140
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/article/__init__.py +38 -35
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/cache/__init__.py +33 -24
- kd_cli-2.11.1/kd/cli/namespaces/init.py +135 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/issue/__init__.py +33 -92
- kd_cli-2.11.1/kd/cli/namespaces/primer/__init__.py +979 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/project/__init__.py +1 -1
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/query/__init__.py +7 -18
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/system/__init__.py +308 -228
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/tag/__init__.py +17 -33
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/output.py +17 -5
- 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.10.0 → kd_cli-2.11.1}/kd/client.py +79 -17
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/config.py +648 -497
- kd_cli-2.11.1/kd/config_read.py +332 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/config_schema.py +1 -1
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/connection.py +110 -51
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/debug.py +1 -1
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/exceptions.py +57 -19
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/history.py +3 -6
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/locking.py +14 -11
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/primer.py +197 -141
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/CHANGELOG.md +248 -237
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/command-synonyms.yaml +1 -1
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/error-messages.yaml +124 -13
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/operating-guide.md +120 -38
- kd_cli-2.11.1/kd/resources/primers/hitl.yaml +27 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1/kd_cli.egg-info}/PKG-INFO +18 -14
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/SOURCES.txt +2 -2
- {kd_cli-2.10.0 → kd_cli-2.11.1}/pyproject.toml +7 -1
- kd_cli-2.10.0/kd/cli/namespaces/init.py +0 -62
- kd_cli-2.10.0/kd/cli/namespaces/primer/__init__.py +0 -1049
- kd_cli-2.10.0/kd/cli/tree.py +0 -65
- kd_cli-2.10.0/kd/config_read.py +0 -194
- kd_cli-2.10.0/kd/resources/primers/hitl.yaml +0 -41
- kd_cli-2.10.0/kd/resources/sdlc-doctrine.md +0 -602
- kd_cli-2.10.0/kd/sdlc.py +0 -21
- {kd_cli-2.10.0 → kd_cli-2.11.1}/LICENSE +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/MANIFEST.in +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/articles.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/hub.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/tags.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/article_marker.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/article.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/assist.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/attachment.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/comment.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/common.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/issue.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/project.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/saved_query.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/search.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/tag.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/user.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/work_item.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/operating_guide.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/query_parser.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/release_notes.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kd-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.11.1
|
|
4
4
|
Summary: Knowledge Dispatcher for issue trackers and knowledge bases
|
|
5
5
|
Author: Sergiy Gladkyy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -59,7 +59,7 @@ The **primer** (`kd /primer`) is the centerpiece: a small, workspace-local file
|
|
|
59
59
|
It exists to solve a problem that has gotten worse as LLMs have become central to real work:
|
|
60
60
|
|
|
61
61
|
- **Visible memory.** Agentic tools accumulate hidden "memories" in opaque per-LLM buckets you cannot easily read, edit, or audit. The primer is a plain file you can open, version, and review. If an LLM appears to "remember" something load-bearing, you can see exactly where that came from.
|
|
62
|
-
- **LLM-portable.** One primer works for any model. Switch from Claude to GPT
|
|
62
|
+
- **LLM-portable.** One primer works for any model. Switch from Claude to GPT to the next generation — the primer travels with the project, not with a vendor.
|
|
63
63
|
- **Shared by humans and LLMs.** A developer jotting "on this project the sprint field is called `Board`" in the primer is building the same artifact an LLM would build, in the same place, readable by both. No separate convention to maintain.
|
|
64
64
|
- **A routing layer, not a copy.** The primer can pin real knowledge-base articles (`kd /primer/article add FB-A-42`). It never duplicates their content — it points at them. The rest of kd extends this shape: pull what the current task needs from live systems, reason, push updates back. Nothing gets mirrored into a parallel store.
|
|
65
65
|
|
|
@@ -83,20 +83,24 @@ uv tool install kd-cli
|
|
|
83
83
|
|
|
84
84
|
The PyPI distribution name is `kd-cli` (two-letter `kd` is reserved by PyPI policy). The installed command, the import name, and the CLI entry point are all `kd`.
|
|
85
85
|
|
|
86
|
-
Release notes for the installed version: `kd /release-notes`.
|
|
86
|
+
Release notes for the installed version: `kd /release-notes`. Every release is listed in the [changelog](https://github.com/IntellectSolutionsCom/kd-cli/blob/main/CHANGELOG.md).
|
|
87
87
|
|
|
88
88
|
## Configure
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
#
|
|
92
|
-
kd init
|
|
91
|
+
# Global config with your YouTrack URL (recommended on first install).
|
|
92
|
+
kd init --global https://your-instance.youtrack.cloud
|
|
93
93
|
|
|
94
|
-
#
|
|
95
|
-
export KD_TOKEN=
|
|
96
|
-
|
|
94
|
+
# The scaffolded connection reads its token from KD_TOKEN (its token_env).
|
|
95
|
+
export KD_TOKEN=perm:your-token-here
|
|
96
|
+
|
|
97
|
+
# Check the connection.
|
|
98
|
+
kd /system info
|
|
97
99
|
```
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
Create the token in YouTrack under Profile → Account Security → New token. In PowerShell, set it with `$env:KD_TOKEN = "perm:your-token-here"`, or `setx KD_TOKEN perm:your-token-here` to keep it, then open a new terminal. `token_cmd:` in the connection reads the token from a command, such as a secrets manager, instead.
|
|
102
|
+
|
|
103
|
+
Global config lives at `~/.config/kd/config.yaml` (Windows: `C:\Users\<you>\.config\kd\config.yaml`); `kd init <url>` without `--global` writes `.kd/config.yaml` in the current directory. `KD_CONNECTION` picks the connection by name.
|
|
100
104
|
|
|
101
105
|
## Usage
|
|
102
106
|
|
|
@@ -116,13 +120,13 @@ kd /issue show FB-1
|
|
|
116
120
|
kd /time log FB-1 45 --type Development --description "Router reconfiguration"
|
|
117
121
|
|
|
118
122
|
# Articles
|
|
119
|
-
kd /article list --project
|
|
120
|
-
kd /article push guide.md --project
|
|
123
|
+
kd /article list --project FB
|
|
124
|
+
kd /article push guide.md --project FB --title "Setup Guide" --tag "type:guide"
|
|
121
125
|
kd /article pull FB-A-42 > setup.md
|
|
122
126
|
|
|
123
127
|
# Tags
|
|
124
128
|
kd /tag list
|
|
125
|
-
kd /tag articles type:case --project
|
|
129
|
+
kd /tag articles type:case --project FB
|
|
126
130
|
|
|
127
131
|
# Saved queries
|
|
128
132
|
kd /query list
|
|
@@ -156,7 +160,7 @@ client = YouTrackClient(
|
|
|
156
160
|
projects = client.projects.list()
|
|
157
161
|
issue = client.issues.create(project="FB", summary="Fix router config")
|
|
158
162
|
client.time.log(issue_id="FB-1", minutes=45, work_type="Development")
|
|
159
|
-
articles = client.tags.articles(["type:case"], project="
|
|
163
|
+
articles = client.tags.articles(["type:case"], project="FB")
|
|
160
164
|
results = client.search.search("router", types=["issue", "article"])
|
|
161
165
|
```
|
|
162
166
|
|
|
@@ -171,4 +175,4 @@ uv run mypy
|
|
|
171
175
|
```
|
|
172
176
|
|
|
173
177
|
---
|
|
174
|
-
Updated: 2026-
|
|
178
|
+
Updated: 2026-11-01
|
|
@@ -19,7 +19,7 @@ The **primer** (`kd /primer`) is the centerpiece: a small, workspace-local file
|
|
|
19
19
|
It exists to solve a problem that has gotten worse as LLMs have become central to real work:
|
|
20
20
|
|
|
21
21
|
- **Visible memory.** Agentic tools accumulate hidden "memories" in opaque per-LLM buckets you cannot easily read, edit, or audit. The primer is a plain file you can open, version, and review. If an LLM appears to "remember" something load-bearing, you can see exactly where that came from.
|
|
22
|
-
- **LLM-portable.** One primer works for any model. Switch from Claude to GPT
|
|
22
|
+
- **LLM-portable.** One primer works for any model. Switch from Claude to GPT to the next generation — the primer travels with the project, not with a vendor.
|
|
23
23
|
- **Shared by humans and LLMs.** A developer jotting "on this project the sprint field is called `Board`" in the primer is building the same artifact an LLM would build, in the same place, readable by both. No separate convention to maintain.
|
|
24
24
|
- **A routing layer, not a copy.** The primer can pin real knowledge-base articles (`kd /primer/article add FB-A-42`). It never duplicates their content — it points at them. The rest of kd extends this shape: pull what the current task needs from live systems, reason, push updates back. Nothing gets mirrored into a parallel store.
|
|
25
25
|
|
|
@@ -43,20 +43,24 @@ uv tool install kd-cli
|
|
|
43
43
|
|
|
44
44
|
The PyPI distribution name is `kd-cli` (two-letter `kd` is reserved by PyPI policy). The installed command, the import name, and the CLI entry point are all `kd`.
|
|
45
45
|
|
|
46
|
-
Release notes for the installed version: `kd /release-notes`.
|
|
46
|
+
Release notes for the installed version: `kd /release-notes`. Every release is listed in the [changelog](https://github.com/IntellectSolutionsCom/kd-cli/blob/main/CHANGELOG.md).
|
|
47
47
|
|
|
48
48
|
## Configure
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
#
|
|
52
|
-
kd init
|
|
51
|
+
# Global config with your YouTrack URL (recommended on first install).
|
|
52
|
+
kd init --global https://your-instance.youtrack.cloud
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
export KD_TOKEN=
|
|
56
|
-
|
|
54
|
+
# The scaffolded connection reads its token from KD_TOKEN (its token_env).
|
|
55
|
+
export KD_TOKEN=perm:your-token-here
|
|
56
|
+
|
|
57
|
+
# Check the connection.
|
|
58
|
+
kd /system info
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
Create the token in YouTrack under Profile → Account Security → New token. In PowerShell, set it with `$env:KD_TOKEN = "perm:your-token-here"`, or `setx KD_TOKEN perm:your-token-here` to keep it, then open a new terminal. `token_cmd:` in the connection reads the token from a command, such as a secrets manager, instead.
|
|
62
|
+
|
|
63
|
+
Global config lives at `~/.config/kd/config.yaml` (Windows: `C:\Users\<you>\.config\kd\config.yaml`); `kd init <url>` without `--global` writes `.kd/config.yaml` in the current directory. `KD_CONNECTION` picks the connection by name.
|
|
60
64
|
|
|
61
65
|
## Usage
|
|
62
66
|
|
|
@@ -76,13 +80,13 @@ kd /issue show FB-1
|
|
|
76
80
|
kd /time log FB-1 45 --type Development --description "Router reconfiguration"
|
|
77
81
|
|
|
78
82
|
# Articles
|
|
79
|
-
kd /article list --project
|
|
80
|
-
kd /article push guide.md --project
|
|
83
|
+
kd /article list --project FB
|
|
84
|
+
kd /article push guide.md --project FB --title "Setup Guide" --tag "type:guide"
|
|
81
85
|
kd /article pull FB-A-42 > setup.md
|
|
82
86
|
|
|
83
87
|
# Tags
|
|
84
88
|
kd /tag list
|
|
85
|
-
kd /tag articles type:case --project
|
|
89
|
+
kd /tag articles type:case --project FB
|
|
86
90
|
|
|
87
91
|
# Saved queries
|
|
88
92
|
kd /query list
|
|
@@ -116,7 +120,7 @@ client = YouTrackClient(
|
|
|
116
120
|
projects = client.projects.list()
|
|
117
121
|
issue = client.issues.create(project="FB", summary="Fix router config")
|
|
118
122
|
client.time.log(issue_id="FB-1", minutes=45, work_type="Development")
|
|
119
|
-
articles = client.tags.articles(["type:case"], project="
|
|
123
|
+
articles = client.tags.articles(["type:case"], project="FB")
|
|
120
124
|
results = client.search.search("router", types=["issue", "article"])
|
|
121
125
|
```
|
|
122
126
|
|
|
@@ -131,4 +135,4 @@ uv run mypy
|
|
|
131
135
|
```
|
|
132
136
|
|
|
133
137
|
---
|
|
134
|
-
Updated: 2026-
|
|
138
|
+
Updated: 2026-11-01
|
|
@@ -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
|