kd-cli 2.0.4__py3-none-any.whl
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/__init__.py +45 -0
- kd/api/__init__.py +15 -0
- kd/api/articles.py +232 -0
- kd/api/http.py +234 -0
- kd/api/hub.py +69 -0
- kd/api/saved_queries.py +76 -0
- kd/api/tags.py +107 -0
- kd/api/youtrack.py +414 -0
- kd/article_marker.py +348 -0
- kd/auth.py +96 -0
- kd/cli/__init__.py +5 -0
- kd/cli/core/__init__.py +25 -0
- kd/cli/core/command_base.py +133 -0
- kd/cli/core/command_parser.py +246 -0
- kd/cli/core/namespaces.py +175 -0
- kd/cli/core/option_registry.py +265 -0
- kd/cli/input.py +42 -0
- kd/cli/main.py +612 -0
- kd/cli/namespaces/__init__.py +38 -0
- kd/cli/namespaces/alias/__init__.py +87 -0
- kd/cli/namespaces/article/__init__.py +843 -0
- kd/cli/namespaces/init.py +42 -0
- kd/cli/namespaces/issue/__init__.py +959 -0
- kd/cli/namespaces/primer/__init__.py +337 -0
- kd/cli/namespaces/project/__init__.py +243 -0
- kd/cli/namespaces/query/__init__.py +465 -0
- kd/cli/namespaces/search/__init__.py +82 -0
- kd/cli/namespaces/system/__init__.py +528 -0
- kd/cli/namespaces/tag/__init__.py +337 -0
- kd/cli/namespaces/time/__init__.py +307 -0
- kd/cli/namespaces/user/__init__.py +99 -0
- kd/cli/output.py +287 -0
- kd/cli/tree.py +65 -0
- kd/client.py +1953 -0
- kd/config.py +853 -0
- kd/connection.py +156 -0
- kd/debug.py +107 -0
- kd/exceptions.py +189 -0
- kd/history.py +169 -0
- kd/models/__init__.py +75 -0
- kd/models/article.py +64 -0
- kd/models/assist.py +47 -0
- kd/models/attachment.py +20 -0
- kd/models/comment.py +25 -0
- kd/models/common.py +39 -0
- kd/models/issue.py +67 -0
- kd/models/project.py +60 -0
- kd/models/saved_query.py +54 -0
- kd/models/search.py +20 -0
- kd/models/tag.py +30 -0
- kd/models/user.py +42 -0
- kd/models/work_item.py +59 -0
- kd/primer.py +231 -0
- kd/query_parser.py +181 -0
- kd/resources/__init__.py +1 -0
- kd/resources/command-synonyms.yaml +19 -0
- kd/resources/config-template.yaml +71 -0
- kd/resources/error-messages.yaml +101 -0
- kd_cli-2.0.4.dist-info/METADATA +149 -0
- kd_cli-2.0.4.dist-info/RECORD +64 -0
- kd_cli-2.0.4.dist-info/WHEEL +5 -0
- kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
- kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
- kd_cli-2.0.4.dist-info/top_level.txt +1 -0
kd/article_marker.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""kd-meta marker parser and renderer.
|
|
2
|
+
|
|
3
|
+
The marker block is the upsert key for ``/article push``. It lives as
|
|
4
|
+
an HTML comment at the top of an article's content and in the local
|
|
5
|
+
source file. See ``docs/proposals/19-article-push-proposal.md`` for
|
|
6
|
+
the full rationale.
|
|
7
|
+
|
|
8
|
+
Grammar (informal):
|
|
9
|
+
|
|
10
|
+
<!-- kd-meta
|
|
11
|
+
key: value
|
|
12
|
+
...
|
|
13
|
+
-->
|
|
14
|
+
<optional blank line>
|
|
15
|
+
<body>
|
|
16
|
+
|
|
17
|
+
Rules (pinned down by ``tests/test_article_marker.py``):
|
|
18
|
+
|
|
19
|
+
- Open and close lines must be exact (``<!-- kd-meta`` / ``-->``).
|
|
20
|
+
- Keys match ``[a-z][a-z0-9-]*``; uppercase and underscores are
|
|
21
|
+
rejected to keep the grammar ASCII-boring.
|
|
22
|
+
- Unknown keys are preserved verbatim on round-trip — the load-bearing
|
|
23
|
+
property that lets future kd versions add keys without breaking
|
|
24
|
+
older versions' round-trips.
|
|
25
|
+
- Known keys are emitted first in the canonical order ``slug``,
|
|
26
|
+
``upstream-id``, ``upstream-updated``. Unknown keys follow in their
|
|
27
|
+
original insertion order.
|
|
28
|
+
- Line endings and trailing newlines are preserved so that a
|
|
29
|
+
``parse → render`` cycle is a fixed point.
|
|
30
|
+
|
|
31
|
+
Legacy marker synonym (transitional, proposal 28 / M003 §S4):
|
|
32
|
+
|
|
33
|
+
The parser accepts any ``<!-- kd-meta`` or ``<!-- ytctl-meta``
|
|
34
|
+
open line and treats them identically. The renderer always writes
|
|
35
|
+
``<!-- kd-meta``, so every successful push rewrites a legacy
|
|
36
|
+
header to native; upstream state converges one article at a time.
|
|
37
|
+
The legacy set is pinned to ``_LEGACY_SYNONYMS`` and to a single
|
|
38
|
+
test assertion so removal is a one-file, one-PR edit once the
|
|
39
|
+
upstream population of ``ytctl-meta`` is empty. No auto-disable,
|
|
40
|
+
no timer — a deliberate future milestone.
|
|
41
|
+
|
|
42
|
+
Stdlib only; no third-party deps. The push workflow (in
|
|
43
|
+
``kd.client.ArticleOperations.push``) and its tests are the only
|
|
44
|
+
importers.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import re
|
|
50
|
+
from collections import OrderedDict
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
|
|
53
|
+
from kd.exceptions import ValidationError
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"ArticleMarker",
|
|
57
|
+
"MarkerError",
|
|
58
|
+
"ParsedFile",
|
|
59
|
+
"parse_content",
|
|
60
|
+
"render_content",
|
|
61
|
+
"validate_slug",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_MARKER_OPEN = "<!-- kd-meta"
|
|
66
|
+
_MARKER_CLOSE = "-->"
|
|
67
|
+
|
|
68
|
+
# Transitional read-only synonyms for the marker open line. The parser
|
|
69
|
+
# accepts these identically to ``_MARKER_OPEN``; the renderer only ever
|
|
70
|
+
# emits ``_MARKER_OPEN``, so every successful push rewrites an upstream
|
|
71
|
+
# body to the native marker.
|
|
72
|
+
#
|
|
73
|
+
# Removability contract (proposal 28 §Article Marker / M003 §S4):
|
|
74
|
+
# sunset this table by deleting the single entry below and the
|
|
75
|
+
# ``TestLegacySynonym.test_synonym_set_pinned`` assertion once the
|
|
76
|
+
# upstream population of ``ytctl-meta`` markers is empty. Do not add
|
|
77
|
+
# more synonyms; every extra entry is more work for the removal
|
|
78
|
+
# milestone.
|
|
79
|
+
_LEGACY_SYNONYMS: tuple[str, ...] = ("<!-- ytctl-meta",)
|
|
80
|
+
|
|
81
|
+
# Every marker-open line the parser recognizes (native + legacy).
|
|
82
|
+
_RECOGNIZED_OPENS: frozenset[str] = frozenset((_MARKER_OPEN, *_LEGACY_SYNONYMS))
|
|
83
|
+
|
|
84
|
+
# Keys are marker identifiers (e.g. "slug", "upstream-id"). Lowercase,
|
|
85
|
+
# ASCII, hyphen-separated. Underscores are intentionally excluded so
|
|
86
|
+
# the grammar stays HTML-comment-safe and greppable.
|
|
87
|
+
_KEY_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
88
|
+
|
|
89
|
+
# `key: value` line inside the marker block. Value is captured raw and
|
|
90
|
+
# stripped separately.
|
|
91
|
+
_LINE_RE = re.compile(r"^([a-z][a-z0-9-]*)\s*:\s*(.*)$")
|
|
92
|
+
|
|
93
|
+
# Slug charset: file-path shape, case-preserved. Rejects whitespace,
|
|
94
|
+
# colons (would break the marker grammar), and newlines.
|
|
95
|
+
_SLUG_RE = re.compile(r"^[A-Za-z0-9._][A-Za-z0-9._/\-]*$")
|
|
96
|
+
|
|
97
|
+
# Canonical emission order for keys kd itself writes. Anything else
|
|
98
|
+
# is an "unknown key" and appended in original order.
|
|
99
|
+
_KNOWN_KEYS: tuple[str, ...] = ("slug", "upstream-id", "upstream-updated")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MarkerError(ValidationError):
|
|
103
|
+
"""Raised when a marker block fails to parse or a slug is invalid."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ArticleMarker:
|
|
108
|
+
"""Parsed kd-meta marker block.
|
|
109
|
+
|
|
110
|
+
``data`` is an insertion-ordered dict of every key/value pair found
|
|
111
|
+
in the block, including unknown keys. The three known keys expose
|
|
112
|
+
typed properties for convenience; everything else is accessed via
|
|
113
|
+
``data``.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
data: "OrderedDict[str, str]" = field(default_factory=OrderedDict)
|
|
117
|
+
|
|
118
|
+
def get(self, key: str) -> str | None:
|
|
119
|
+
return self.data.get(key)
|
|
120
|
+
|
|
121
|
+
def set(self, key: str, value: str) -> None:
|
|
122
|
+
"""Set a key; validates the key charset."""
|
|
123
|
+
if not _KEY_RE.match(key):
|
|
124
|
+
raise MarkerError(f"invalid key: {key!r}")
|
|
125
|
+
self.data[key] = value
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def slug(self) -> str | None:
|
|
129
|
+
return self.data.get("slug")
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def upstream_id(self) -> str | None:
|
|
133
|
+
return self.data.get("upstream-id")
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def upstream_updated(self) -> str | None:
|
|
137
|
+
return self.data.get("upstream-updated")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class ParsedFile:
|
|
142
|
+
"""Result of parsing a local file's content into marker + body.
|
|
143
|
+
|
|
144
|
+
Preserves enough metadata to re-emit the file byte-identically:
|
|
145
|
+
|
|
146
|
+
- ``marker`` is ``None`` when the input has no marker at the top.
|
|
147
|
+
- ``body`` is the content after the marker (and its optional blank
|
|
148
|
+
separator line), with line endings normalized to ``"\\n"``.
|
|
149
|
+
``render_content`` restores the original line ending style.
|
|
150
|
+
- ``newline`` is ``"\\n"`` or ``"\\r\\n"`` detected from the input.
|
|
151
|
+
- ``trailing_newline`` is ``True`` iff the original input ended
|
|
152
|
+
with a line terminator.
|
|
153
|
+
- ``body_separator`` is ``True`` iff there was a blank line between
|
|
154
|
+
the marker close and the first body line. The renderer emits one
|
|
155
|
+
iff this flag is set. Synthesized markers default to ``True``
|
|
156
|
+
(canonical form; also what the YouTrack web UI normalizes into).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
marker: ArticleMarker | None
|
|
160
|
+
body: str
|
|
161
|
+
newline: str
|
|
162
|
+
trailing_newline: bool
|
|
163
|
+
body_separator: bool = True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Parser
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _detect_newline(text: str) -> str:
|
|
172
|
+
"""Return ``"\\r\\n"`` if the first line terminator is CRLF, else ``"\\n"``."""
|
|
173
|
+
idx = text.find("\n")
|
|
174
|
+
if idx > 0 and text[idx - 1] == "\r":
|
|
175
|
+
return "\r\n"
|
|
176
|
+
return "\n"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _split_lines(text: str, newline: str) -> list[str]:
|
|
180
|
+
"""Split on the given line terminator, preserving empty trailing lines.
|
|
181
|
+
|
|
182
|
+
Normalizes to ``\\n``-joined form internally; callers never see the
|
|
183
|
+
raw terminator. A trailing terminator produces an extra empty
|
|
184
|
+
element which the caller handles via ``trailing_newline``.
|
|
185
|
+
"""
|
|
186
|
+
if newline == "\r\n":
|
|
187
|
+
text = text.replace("\r\n", "\n")
|
|
188
|
+
return text.split("\n")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_content(text: str) -> ParsedFile:
|
|
192
|
+
"""Parse *text* into a ``ParsedFile`` with optional marker + body.
|
|
193
|
+
|
|
194
|
+
Raises ``MarkerError`` if a marker block is opened but malformed.
|
|
195
|
+
A file with no marker at the top returns ``marker=None``; the body
|
|
196
|
+
equals the input verbatim (modulo newline normalization).
|
|
197
|
+
"""
|
|
198
|
+
newline = _detect_newline(text)
|
|
199
|
+
trailing_newline = text.endswith(("\n", "\r\n"))
|
|
200
|
+
lines = _split_lines(text, newline)
|
|
201
|
+
# A trailing newline leaves an extra empty element at the end; drop
|
|
202
|
+
# it and record the fact so render() can restore it.
|
|
203
|
+
if trailing_newline and lines and lines[-1] == "":
|
|
204
|
+
lines = lines[:-1]
|
|
205
|
+
|
|
206
|
+
# Scan past any leading blank lines (tolerated before the marker
|
|
207
|
+
# open). If the first non-blank line is not the marker open, the
|
|
208
|
+
# file is marker-less and we return the entire text as body.
|
|
209
|
+
i = 0
|
|
210
|
+
while i < len(lines) and lines[i] == "":
|
|
211
|
+
i += 1
|
|
212
|
+
if i >= len(lines) or lines[i] not in _RECOGNIZED_OPENS:
|
|
213
|
+
return ParsedFile(
|
|
214
|
+
marker=None,
|
|
215
|
+
body=_join_body(lines, trailing_newline),
|
|
216
|
+
newline=newline,
|
|
217
|
+
trailing_newline=trailing_newline,
|
|
218
|
+
body_separator=False,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Find the matching close line before per-line parsing. If there is
|
|
222
|
+
# no close at all, report "not closed" rather than misdiagnosing a
|
|
223
|
+
# body line as a malformed key:value.
|
|
224
|
+
close_idx: int | None = None
|
|
225
|
+
for k in range(i + 1, len(lines)):
|
|
226
|
+
if lines[k] == _MARKER_CLOSE:
|
|
227
|
+
close_idx = k
|
|
228
|
+
break
|
|
229
|
+
if close_idx is None:
|
|
230
|
+
raise MarkerError("marker block not closed (expected '-->' on its own line)")
|
|
231
|
+
|
|
232
|
+
# Refuse a second marker anywhere later in the file — native or
|
|
233
|
+
# legacy. Prevents a stray ``<!-- ytctl-meta`` further down from
|
|
234
|
+
# being silently accepted as body content on a file that also
|
|
235
|
+
# carries a native header at the top.
|
|
236
|
+
for k in range(close_idx + 1, len(lines)):
|
|
237
|
+
if lines[k] in _RECOGNIZED_OPENS:
|
|
238
|
+
raise MarkerError("file contains more than one kd-meta block")
|
|
239
|
+
|
|
240
|
+
# Parse key:value lines between open and close.
|
|
241
|
+
marker = ArticleMarker()
|
|
242
|
+
for j in range(i + 1, close_idx):
|
|
243
|
+
line = lines[j]
|
|
244
|
+
if line == "":
|
|
245
|
+
continue # blank lines inside the marker are tolerated
|
|
246
|
+
m = _LINE_RE.match(line)
|
|
247
|
+
if m is None:
|
|
248
|
+
# Distinguish "invalid key charset" from "no colon at all"
|
|
249
|
+
# to give a more actionable error message.
|
|
250
|
+
if ":" not in line:
|
|
251
|
+
raise MarkerError(f"malformed key:value line: {line!r}")
|
|
252
|
+
raw_key = line.split(":", 1)[0].strip()
|
|
253
|
+
raise MarkerError(f"invalid key: {raw_key!r}")
|
|
254
|
+
key, value = m.group(1), m.group(2).strip()
|
|
255
|
+
marker.data[key] = value
|
|
256
|
+
|
|
257
|
+
# Consume one optional blank separator line after the close.
|
|
258
|
+
body_start = close_idx + 1
|
|
259
|
+
body_separator = False
|
|
260
|
+
if body_start < len(lines) and lines[body_start] == "":
|
|
261
|
+
body_separator = True
|
|
262
|
+
body_start += 1
|
|
263
|
+
|
|
264
|
+
body_lines = lines[body_start:]
|
|
265
|
+
body = _join_body(body_lines, trailing_newline)
|
|
266
|
+
return ParsedFile(
|
|
267
|
+
marker=marker,
|
|
268
|
+
body=body,
|
|
269
|
+
newline=newline,
|
|
270
|
+
trailing_newline=trailing_newline,
|
|
271
|
+
body_separator=body_separator,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _join_body(lines: list[str], trailing_newline: bool) -> str:
|
|
276
|
+
"""Join body lines with ``\\n``, restoring the final terminator if any."""
|
|
277
|
+
if not lines:
|
|
278
|
+
return "\n" if trailing_newline else ""
|
|
279
|
+
body = "\n".join(lines)
|
|
280
|
+
if trailing_newline:
|
|
281
|
+
body += "\n"
|
|
282
|
+
return body
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# Renderer
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def render_content(parsed: ParsedFile) -> str:
|
|
291
|
+
"""Render a ``ParsedFile`` back to text, preserving byte-identity.
|
|
292
|
+
|
|
293
|
+
- Known keys first (``slug``, ``upstream-id``, ``upstream-updated``),
|
|
294
|
+
then unknown keys in their original insertion order.
|
|
295
|
+
- Blank line separator between ``-->`` and the first body line
|
|
296
|
+
(canonical form; also the shape the YouTrack web UI normalizes
|
|
297
|
+
into).
|
|
298
|
+
- Line endings are ``parsed.newline``; trailing newline follows
|
|
299
|
+
``parsed.trailing_newline``.
|
|
300
|
+
"""
|
|
301
|
+
if parsed.marker is None:
|
|
302
|
+
# Marker-less files pass through verbatim. ``body`` already
|
|
303
|
+
# includes any trailing newline; line endings are ``\n``.
|
|
304
|
+
return _retarget_newlines(parsed.body, parsed.newline)
|
|
305
|
+
|
|
306
|
+
marker = parsed.marker
|
|
307
|
+
lines: list[str] = [_MARKER_OPEN]
|
|
308
|
+
# Known keys first, in canonical order, if present.
|
|
309
|
+
for key in _KNOWN_KEYS:
|
|
310
|
+
if key in marker.data:
|
|
311
|
+
lines.append(f"{key}: {marker.data[key]}")
|
|
312
|
+
# Unknown keys, in their original insertion order.
|
|
313
|
+
for key, value in marker.data.items():
|
|
314
|
+
if key in _KNOWN_KEYS:
|
|
315
|
+
continue
|
|
316
|
+
lines.append(f"{key}: {value}")
|
|
317
|
+
lines.append(_MARKER_CLOSE)
|
|
318
|
+
if parsed.body_separator:
|
|
319
|
+
lines.append("")
|
|
320
|
+
|
|
321
|
+
header = "\n".join(lines) + "\n"
|
|
322
|
+
body = parsed.body
|
|
323
|
+
combined = header + body
|
|
324
|
+
return _retarget_newlines(combined, parsed.newline)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _retarget_newlines(text: str, newline: str) -> str:
|
|
328
|
+
"""Replace ``\\n`` with *newline* if *newline* is CRLF; else no-op."""
|
|
329
|
+
if newline == "\r\n":
|
|
330
|
+
return text.replace("\n", "\r\n")
|
|
331
|
+
return text
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# Slug validation
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def validate_slug(slug: str) -> None:
|
|
340
|
+
"""Raise ``MarkerError`` if *slug* does not match the push slug charset.
|
|
341
|
+
|
|
342
|
+
Allowed: ``[A-Za-z0-9._]`` as first char, then ``[A-Za-z0-9._/-]*``.
|
|
343
|
+
Rejects whitespace, colons, newlines, and a leading ``/``.
|
|
344
|
+
"""
|
|
345
|
+
if not slug:
|
|
346
|
+
raise MarkerError("slug cannot be empty")
|
|
347
|
+
if not _SLUG_RE.match(slug):
|
|
348
|
+
raise MarkerError(f"invalid slug {slug!r}: must match [A-Za-z0-9._][A-Za-z0-9._/-]*")
|
kd/auth.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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.connection import ConnectionConfig
|
|
22
|
+
from kd.debug import DebugLog
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_connection_token(
|
|
26
|
+
conn: ConnectionConfig,
|
|
27
|
+
*,
|
|
28
|
+
debug_log: DebugLog | None = None,
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Resolve the API token for a connection.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
conn:
|
|
35
|
+
The connection config whose auth sources to try.
|
|
36
|
+
debug_log:
|
|
37
|
+
Optional debug logger.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
str
|
|
42
|
+
The resolved token.
|
|
43
|
+
|
|
44
|
+
Raises
|
|
45
|
+
------
|
|
46
|
+
AuthError
|
|
47
|
+
If no token can be resolved from any source.
|
|
48
|
+
"""
|
|
49
|
+
if conn.token_cmd:
|
|
50
|
+
if debug_log:
|
|
51
|
+
debug_log.info("auth", f"running token_cmd: {conn.token_cmd}")
|
|
52
|
+
return _run_token_cmd(conn.token_cmd)
|
|
53
|
+
|
|
54
|
+
if conn.token_env:
|
|
55
|
+
if debug_log:
|
|
56
|
+
debug_log.info("auth", f"reading token from env var: {conn.token_env}")
|
|
57
|
+
value = os.environ.get(conn.token_env)
|
|
58
|
+
if value:
|
|
59
|
+
return value
|
|
60
|
+
raise AuthError(
|
|
61
|
+
f"Environment variable '{conn.token_env}' is not set. "
|
|
62
|
+
f"Export it or use 'token_cmd' instead."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
raise AuthError(
|
|
66
|
+
"Connection has no auth source configured. "
|
|
67
|
+
"Set 'token_cmd' or 'token_env' in the connection config."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run_token_cmd(cmd: str) -> str:
|
|
72
|
+
"""Execute a token command and return its stdout.
|
|
73
|
+
|
|
74
|
+
Raises :class:`AuthError` on non-zero exit or empty output.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
proc = subprocess.run(
|
|
78
|
+
cmd,
|
|
79
|
+
shell=True, # noqa: S602 — user's own config, same trust as git credential.helper
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
timeout=10,
|
|
83
|
+
)
|
|
84
|
+
except subprocess.TimeoutExpired:
|
|
85
|
+
raise AuthError(f"token_cmd timed out after 10s: {cmd}") from None
|
|
86
|
+
|
|
87
|
+
if proc.returncode != 0:
|
|
88
|
+
stderr = proc.stderr.strip()
|
|
89
|
+
detail = f": {stderr}" if stderr else ""
|
|
90
|
+
raise AuthError(f"token_cmd failed (exit {proc.returncode}){detail}: {cmd}")
|
|
91
|
+
|
|
92
|
+
token = proc.stdout.strip()
|
|
93
|
+
if not token:
|
|
94
|
+
raise AuthError(f"token_cmd returned empty output: {cmd}")
|
|
95
|
+
|
|
96
|
+
return token
|
kd/cli/__init__.py
ADDED
kd/cli/core/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""CLI framework core — namespace system, command base, option registry."""
|
|
2
|
+
|
|
3
|
+
from kd.cli.core.command_base import Command, CommandResult
|
|
4
|
+
from kd.cli.core.namespaces import Namespace, NamespaceRegistry
|
|
5
|
+
from kd.cli.core.option_registry import (
|
|
6
|
+
OUTPUT_OPTIONS,
|
|
7
|
+
OptionDeclaration,
|
|
8
|
+
OptionError,
|
|
9
|
+
OptionLayer,
|
|
10
|
+
OptionRegistry,
|
|
11
|
+
ResolvedOptions,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"OUTPUT_OPTIONS",
|
|
16
|
+
"Command",
|
|
17
|
+
"CommandResult",
|
|
18
|
+
"Namespace",
|
|
19
|
+
"NamespaceRegistry",
|
|
20
|
+
"OptionDeclaration",
|
|
21
|
+
"OptionError",
|
|
22
|
+
"OptionLayer",
|
|
23
|
+
"OptionRegistry",
|
|
24
|
+
"ResolvedOptions",
|
|
25
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Command base class.
|
|
2
|
+
|
|
3
|
+
All CLI commands inherit from Command and implement execute().
|
|
4
|
+
Commands receive positional args, resolved options, and optionally
|
|
5
|
+
a YouTrackClient. They return structured data for output formatting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from kd.cli.core.option_registry import OptionDeclaration, ResolvedOptions
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from kd.config import ResolvedConfig
|
|
17
|
+
|
|
18
|
+
# Commands return structured data — the output layer formats it.
|
|
19
|
+
# list[dict] -> table rows; dict -> key-value pairs; str -> passthrough; None -> no output
|
|
20
|
+
CommandResult = list[dict[str, Any]] | dict[str, Any] | str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Command(ABC):
|
|
24
|
+
"""Base class for all CLI commands."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
name: str,
|
|
29
|
+
description: str,
|
|
30
|
+
*,
|
|
31
|
+
requires_client: bool = False,
|
|
32
|
+
requires_config: bool = False,
|
|
33
|
+
usage: str = "",
|
|
34
|
+
long_description: str = "",
|
|
35
|
+
examples: list[str] | None = None,
|
|
36
|
+
see_also: list[str] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.name = name
|
|
39
|
+
self.description = description
|
|
40
|
+
self.requires_client = requires_client
|
|
41
|
+
self.requires_config = requires_config
|
|
42
|
+
self.usage = usage
|
|
43
|
+
self.long_description = long_description
|
|
44
|
+
self.examples: list[str] = examples or []
|
|
45
|
+
self.see_also: list[str] = see_also or []
|
|
46
|
+
self.display_hint: str | None = None
|
|
47
|
+
self._options: list[OptionDeclaration] = []
|
|
48
|
+
self._resolved_config: ResolvedConfig | None = None
|
|
49
|
+
self._register_options()
|
|
50
|
+
|
|
51
|
+
def set_config(self, config: ResolvedConfig) -> None:
|
|
52
|
+
"""Inject the resolved config. Called by the dispatcher when
|
|
53
|
+
``requires_config`` is True, before ``execute()`` runs.
|
|
54
|
+
"""
|
|
55
|
+
self._resolved_config = config
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def config(self) -> ResolvedConfig:
|
|
59
|
+
"""The resolved config for this invocation.
|
|
60
|
+
|
|
61
|
+
Only valid when the command declares ``requires_config=True``; the
|
|
62
|
+
dispatcher calls :meth:`set_config` before :meth:`execute` in that
|
|
63
|
+
case. Raises :class:`RuntimeError` otherwise.
|
|
64
|
+
"""
|
|
65
|
+
if self._resolved_config is None:
|
|
66
|
+
raise RuntimeError(f"{self.name}: config not set. Did you forget requires_config=True?")
|
|
67
|
+
return self._resolved_config
|
|
68
|
+
|
|
69
|
+
def _register_options(self) -> None:
|
|
70
|
+
"""Register command-specific options. Override in subclasses."""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def execute(
|
|
74
|
+
self,
|
|
75
|
+
args: list[str],
|
|
76
|
+
options: ResolvedOptions,
|
|
77
|
+
client: Any = None,
|
|
78
|
+
) -> CommandResult:
|
|
79
|
+
"""Execute the command. Return structured data for output formatting.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
args: Positional arguments after namespace+command resolution.
|
|
83
|
+
options: Resolved command and output options.
|
|
84
|
+
client: YouTrackClient instance (None if requires_client is False).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def register_option(self, option: OptionDeclaration) -> None:
|
|
88
|
+
"""Add an option declaration to this command."""
|
|
89
|
+
self._options.append(option)
|
|
90
|
+
|
|
91
|
+
def get_options(self) -> list[OptionDeclaration]:
|
|
92
|
+
"""Return all option declarations for this command."""
|
|
93
|
+
return list(self._options)
|
|
94
|
+
|
|
95
|
+
def get_help_text(self) -> str:
|
|
96
|
+
"""Generate help text from command metadata and options."""
|
|
97
|
+
lines = [self.description]
|
|
98
|
+
|
|
99
|
+
if self.usage:
|
|
100
|
+
lines.append("")
|
|
101
|
+
lines.append(f"Usage: {self.usage}")
|
|
102
|
+
|
|
103
|
+
if self.long_description:
|
|
104
|
+
lines.append("")
|
|
105
|
+
lines.append(self.long_description)
|
|
106
|
+
|
|
107
|
+
if self._options:
|
|
108
|
+
lines.append("")
|
|
109
|
+
lines.append("Options:")
|
|
110
|
+
for opt in self._options:
|
|
111
|
+
flag = f" --{opt.name}"
|
|
112
|
+
if opt.short_name:
|
|
113
|
+
flag += f", -{opt.short_name}"
|
|
114
|
+
if opt.aliases:
|
|
115
|
+
alias_str = ", ".join(f"--{a}" for a in opt.aliases)
|
|
116
|
+
flag += f" (alias: {alias_str})"
|
|
117
|
+
if opt.choices:
|
|
118
|
+
flag += f" ({', '.join(opt.choices)})"
|
|
119
|
+
if opt.help_text:
|
|
120
|
+
flag += f" {opt.help_text}"
|
|
121
|
+
lines.append(flag)
|
|
122
|
+
|
|
123
|
+
if self.examples:
|
|
124
|
+
lines.append("")
|
|
125
|
+
lines.append("Examples:")
|
|
126
|
+
for ex in self.examples:
|
|
127
|
+
lines.append(f" {ex}")
|
|
128
|
+
|
|
129
|
+
if self.see_also:
|
|
130
|
+
lines.append("")
|
|
131
|
+
lines.append(f"See also: {', '.join(self.see_also)}")
|
|
132
|
+
|
|
133
|
+
return "\n".join(lines)
|