kd-cli 2.1.5__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.5/kd_cli.egg-info → kd_cli-2.1.7}/PKG-INFO +1 -1
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/saved_queries.py +1 -2
- kd_cli-2.1.7/kd/auth.py +173 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/main.py +3 -2
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/system/__init__.py +23 -327
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/config.py +178 -6
- kd_cli-2.1.7/kd/config_read.py +194 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/connection.py +14 -1
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/exceptions.py +12 -5
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/saved_query.py +1 -1
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/CHANGELOG.md +19 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/SOURCES.txt +1 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/pyproject.toml +1 -1
- kd_cli-2.1.5/kd/auth.py +0 -96
- {kd_cli-2.1.5 → kd_cli-2.1.7}/LICENSE +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/MANIFEST.in +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/README.md +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/articles.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/http.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/hub.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/tags.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/youtrack.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/article_marker.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/input.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/output.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/tree.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/client.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/config_schema.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/debug.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/history.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/article.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/assist.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/attachment.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/comment.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/common.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/issue.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/project.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/search.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/tag.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/user.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/work_item.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/primer.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/query_parser.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/release_notes.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.7}/setup.cfg +0 -0
|
@@ -65,8 +65,7 @@ class SavedQueriesAPI:
|
|
|
65
65
|
"""List saved queries owned by a specific user.
|
|
66
66
|
|
|
67
67
|
The literal string ``"me"`` is accepted as ``user_id`` and resolves
|
|
68
|
-
to the authenticated user server-side
|
|
69
|
-
``vod.youtrack.cloud``).
|
|
68
|
+
to the authenticated user server-side.
|
|
70
69
|
"""
|
|
71
70
|
data = self._http.get(
|
|
72
71
|
f"/users/{user_id}/savedQueries",
|
kd_cli-2.1.7/kd/auth.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Connection-aware token resolution.
|
|
2
|
+
|
|
3
|
+
Each connection resolves its token from one of two sources:
|
|
4
|
+
|
|
5
|
+
1. ``token_cmd`` — shell command that prints the token to stdout.
|
|
6
|
+
2. ``token_env`` — environment variable name containing the token.
|
|
7
|
+
|
|
8
|
+
Priority: ``token_cmd`` > ``token_env``. Secrets never live in config
|
|
9
|
+
files — only references to external sources.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from kd.exceptions import AuthError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from kd.config import ConnectionOrigin
|
|
22
|
+
from kd.connection import ConnectionConfig
|
|
23
|
+
from kd.debug import DebugLog
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_connection_token(
|
|
27
|
+
conn: ConnectionConfig,
|
|
28
|
+
*,
|
|
29
|
+
name: str | None = None,
|
|
30
|
+
origin: ConnectionOrigin | None = None,
|
|
31
|
+
debug_log: DebugLog | None = None,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Resolve the API token for a connection.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
conn:
|
|
38
|
+
The connection config whose auth sources to try.
|
|
39
|
+
name:
|
|
40
|
+
Optional connection name, used in error messages to name the
|
|
41
|
+
failing connection.
|
|
42
|
+
origin:
|
|
43
|
+
Optional per-key origin map, used to cite the exact config file
|
|
44
|
+
that declared the (now-broken) auth key.
|
|
45
|
+
debug_log:
|
|
46
|
+
Optional debug logger.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
str
|
|
51
|
+
The resolved token.
|
|
52
|
+
|
|
53
|
+
Raises
|
|
54
|
+
------
|
|
55
|
+
AuthError
|
|
56
|
+
If no token can be resolved from any source. When *name* and
|
|
57
|
+
*origin* are supplied, the error carries a multi-line
|
|
58
|
+
``.suggestion`` with connection-specific guidance and a
|
|
59
|
+
``Config: <path>`` pointer.
|
|
60
|
+
"""
|
|
61
|
+
if conn.token_cmd:
|
|
62
|
+
if debug_log:
|
|
63
|
+
debug_log.info("auth", f"running token_cmd: {conn.token_cmd}")
|
|
64
|
+
return _run_token_cmd(conn.token_cmd)
|
|
65
|
+
|
|
66
|
+
if conn.token_env:
|
|
67
|
+
if debug_log:
|
|
68
|
+
debug_log.info("auth", f"reading token from env var: {conn.token_env}")
|
|
69
|
+
value = os.environ.get(conn.token_env)
|
|
70
|
+
if value:
|
|
71
|
+
return value
|
|
72
|
+
raise _missing_token_error(conn, name, origin)
|
|
73
|
+
|
|
74
|
+
raise _no_auth_source_error(conn, name, origin)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_token_cmd(cmd: str) -> str:
|
|
78
|
+
"""Execute a token command and return its stdout.
|
|
79
|
+
|
|
80
|
+
Raises :class:`AuthError` on non-zero exit or empty output.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
proc = subprocess.run(
|
|
84
|
+
cmd,
|
|
85
|
+
shell=True, # noqa: S602 — user's own config, same trust as git credential.helper
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=10,
|
|
89
|
+
)
|
|
90
|
+
except subprocess.TimeoutExpired:
|
|
91
|
+
raise AuthError(f"token_cmd timed out after 10s: {cmd}") from None
|
|
92
|
+
|
|
93
|
+
if proc.returncode != 0:
|
|
94
|
+
stderr = proc.stderr.strip()
|
|
95
|
+
detail = f": {stderr}" if stderr else ""
|
|
96
|
+
raise AuthError(f"token_cmd failed (exit {proc.returncode}){detail}: {cmd}")
|
|
97
|
+
|
|
98
|
+
token = proc.stdout.strip()
|
|
99
|
+
if not token:
|
|
100
|
+
raise AuthError(f"token_cmd returned empty output: {cmd}")
|
|
101
|
+
|
|
102
|
+
return token
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _missing_token_error(
|
|
106
|
+
conn: ConnectionConfig,
|
|
107
|
+
name: str | None,
|
|
108
|
+
origin: ConnectionOrigin | None,
|
|
109
|
+
) -> AuthError:
|
|
110
|
+
"""Build an ``AuthError`` for a ``token_env`` that points at an unset var."""
|
|
111
|
+
env_name = conn.token_env or "<token_env>"
|
|
112
|
+
where = _describe_connection(conn, name)
|
|
113
|
+
message = f"{where} needs a token — environment variable '{env_name}' is not set."
|
|
114
|
+
suggestion = _build_suggestion(
|
|
115
|
+
lines=[
|
|
116
|
+
f"Either export {env_name} or configure `token_cmd:` on the connection:",
|
|
117
|
+
f" connections.{name or '<name>'}.token_cmd: gopass show -o path/to/token",
|
|
118
|
+
],
|
|
119
|
+
origin=origin,
|
|
120
|
+
key="token_env",
|
|
121
|
+
)
|
|
122
|
+
return AuthError(message, suggestion=suggestion)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _no_auth_source_error(
|
|
126
|
+
conn: ConnectionConfig,
|
|
127
|
+
name: str | None,
|
|
128
|
+
origin: ConnectionOrigin | None,
|
|
129
|
+
) -> AuthError:
|
|
130
|
+
"""Build an ``AuthError`` for a connection with no ``token_cmd`` or ``token_env``."""
|
|
131
|
+
where = _describe_connection(conn, name)
|
|
132
|
+
message = f"{where} has no auth source configured."
|
|
133
|
+
suggestion = _build_suggestion(
|
|
134
|
+
lines=[
|
|
135
|
+
"Set one of `token_cmd:` or `token_env:` on the connection:",
|
|
136
|
+
f" connections.{name or '<name>'}.token_cmd: gopass show -o path/to/token",
|
|
137
|
+
f" connections.{name or '<name>'}.token_env: KD_{(name or 'NAME').upper()}_TOKEN",
|
|
138
|
+
],
|
|
139
|
+
origin=origin,
|
|
140
|
+
key=None,
|
|
141
|
+
)
|
|
142
|
+
return AuthError(message, suggestion=suggestion)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _describe_connection(conn: ConnectionConfig, name: str | None) -> str:
|
|
146
|
+
"""Render the ``Connection '<name>' (<url>)`` prefix, degraded when no name."""
|
|
147
|
+
if name:
|
|
148
|
+
return f"Connection '{name}' ({conn.url})"
|
|
149
|
+
return f"Connection ({conn.url})"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_suggestion(
|
|
153
|
+
*,
|
|
154
|
+
lines: list[str],
|
|
155
|
+
origin: ConnectionOrigin | None,
|
|
156
|
+
key: str | None,
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Assemble a multi-line suggestion, appending ``Config: <path>`` when known.
|
|
159
|
+
|
|
160
|
+
When *origin* is provided, prefer the per-key origin for *key* (which
|
|
161
|
+
points at the exact file declaring that key); fall back to the
|
|
162
|
+
connection's primary path. Omit the line entirely when no origin is
|
|
163
|
+
known so tests and ad-hoc callers don't see a dangling ``Config:``.
|
|
164
|
+
"""
|
|
165
|
+
path: str | None = None
|
|
166
|
+
if origin is not None:
|
|
167
|
+
if key and key in origin.keys:
|
|
168
|
+
path = origin.keys[key].path
|
|
169
|
+
else:
|
|
170
|
+
path = origin.path
|
|
171
|
+
if path:
|
|
172
|
+
lines = [*lines, f"Config: {path}"]
|
|
173
|
+
return "\n".join(lines)
|
|
@@ -29,7 +29,7 @@ from kd.client import YouTrackClient
|
|
|
29
29
|
from kd.config import ResolvedConfig, resolve_command_defaults, resolve_config
|
|
30
30
|
from kd.connection import ConnectionContext, resolve_connection
|
|
31
31
|
from kd.debug import DebugLevel, DebugLog, init_debug_log
|
|
32
|
-
from kd.exceptions import
|
|
32
|
+
from kd.exceptions import KdError
|
|
33
33
|
|
|
34
34
|
# Global options — single source of truth for extraction, help, and reference.
|
|
35
35
|
GLOBAL_OPTIONS: list[OptionDeclaration] = [
|
|
@@ -121,7 +121,7 @@ def expand_toplevel_alias(
|
|
|
121
121
|
def _print_error(error: KdError) -> None:
|
|
122
122
|
"""Print error with optional suggestion to stderr."""
|
|
123
123
|
print(f"Error: {error}", file=sys.stderr)
|
|
124
|
-
if
|
|
124
|
+
if error.suggestion:
|
|
125
125
|
print(f"Hint: {error.suggestion}", file=sys.stderr)
|
|
126
126
|
|
|
127
127
|
|
|
@@ -272,6 +272,7 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
272
272
|
connection = resolve_connection(
|
|
273
273
|
connections=resolved.connections,
|
|
274
274
|
default_connection=resolved.default_connection_name,
|
|
275
|
+
connection_origins=resolved.connection_origins,
|
|
275
276
|
debug_log=debug_log,
|
|
276
277
|
)
|
|
277
278
|
client = YouTrackClient(
|
|
@@ -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
|
|
@@ -296,7 +298,7 @@ class ConfigSetCommand(Command):
|
|
|
296
298
|
),
|
|
297
299
|
examples=[
|
|
298
300
|
"kd /system/config set default_connection corp",
|
|
299
|
-
"kd /system/config set connections.
|
|
301
|
+
"kd /system/config set connections.support.default_project BCO --local",
|
|
300
302
|
"kd /system/config set history.retention 50 --global",
|
|
301
303
|
],
|
|
302
304
|
see_also=["/system/config get", "/system/config keys", "/system/config list"],
|
|
@@ -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
|
|