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
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Two-phase command parser.
|
|
2
|
+
|
|
3
|
+
Phase 1 (parse_command): Resolve namespace + command from positional
|
|
4
|
+
args and tokenize options into a raw dict. No validation.
|
|
5
|
+
|
|
6
|
+
Phase 2 (resolve_options): Validate types, choices, and required
|
|
7
|
+
options, producing the final ResolvedOptions.
|
|
8
|
+
|
|
9
|
+
The convenience method ``parse()`` runs both phases in one call.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from kd.cli.core.command_base import Command
|
|
18
|
+
from kd.cli.core.namespaces import Namespace, NamespaceRegistry
|
|
19
|
+
from kd.cli.core.option_registry import (
|
|
20
|
+
OUTPUT_OPTIONS,
|
|
21
|
+
OptionError,
|
|
22
|
+
OptionRegistry,
|
|
23
|
+
ResolvedOptions,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ParseError(Exception):
|
|
28
|
+
"""Raised when command parsing fails."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class UnresolvedCommand:
|
|
33
|
+
"""Phase-1 result: command identified, options tokenized but not validated."""
|
|
34
|
+
|
|
35
|
+
namespace: Namespace
|
|
36
|
+
command_name: str
|
|
37
|
+
command: Command | None # None if command not found
|
|
38
|
+
args: list[str] = field(default_factory=list)
|
|
39
|
+
raw_options: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ParsedCommand:
|
|
44
|
+
"""Result of parsing a command line."""
|
|
45
|
+
|
|
46
|
+
namespace: Namespace
|
|
47
|
+
command_name: str
|
|
48
|
+
command: Command | None # None if command not found
|
|
49
|
+
args: list[str] = field(default_factory=list)
|
|
50
|
+
options: ResolvedOptions = field(default_factory=ResolvedOptions)
|
|
51
|
+
raw_options: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CommandParser:
|
|
55
|
+
"""Parses argv into a resolved command with options."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, registry: NamespaceRegistry) -> None:
|
|
58
|
+
self._registry = registry
|
|
59
|
+
|
|
60
|
+
def parse_command(self, argv: list[str]) -> UnresolvedCommand:
|
|
61
|
+
"""Phase 1: resolve command and tokenize options. No validation.
|
|
62
|
+
|
|
63
|
+
1. Split argv into positionals and option tokens
|
|
64
|
+
2. Resolve namespace + command from positionals
|
|
65
|
+
3. Build option registry from command declarations + OUTPUT_OPTIONS
|
|
66
|
+
4. Parse option tokens into a raw dict (recovering misclassified positionals)
|
|
67
|
+
"""
|
|
68
|
+
positionals, option_tokens = self._split_args(argv)
|
|
69
|
+
namespace, command_name, remaining = self._registry.resolve(positionals)
|
|
70
|
+
|
|
71
|
+
command = namespace.get_command(command_name) if command_name else None
|
|
72
|
+
|
|
73
|
+
option_reg = self._build_option_registry(command)
|
|
74
|
+
raw_options, recovered = self._parse_option_tokens(option_tokens, option_reg)
|
|
75
|
+
|
|
76
|
+
# Recovered positionals are tokens that _split_args heuristically
|
|
77
|
+
# consumed as option values but were actually after boolean flags.
|
|
78
|
+
# Prepend them to args so the command sees them.
|
|
79
|
+
if recovered:
|
|
80
|
+
remaining = recovered + remaining
|
|
81
|
+
|
|
82
|
+
return UnresolvedCommand(
|
|
83
|
+
namespace=namespace,
|
|
84
|
+
command_name=command_name,
|
|
85
|
+
command=command,
|
|
86
|
+
args=remaining,
|
|
87
|
+
raw_options=raw_options,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def resolve_options(self, unresolved: UnresolvedCommand) -> ParsedCommand:
|
|
91
|
+
"""Phase 2: validate and resolve options.
|
|
92
|
+
|
|
93
|
+
Builds the option registry, validates types/choices/required,
|
|
94
|
+
and returns a fully resolved ``ParsedCommand``.
|
|
95
|
+
"""
|
|
96
|
+
option_reg = self._build_option_registry(unresolved.command)
|
|
97
|
+
try:
|
|
98
|
+
resolved = option_reg.resolve(unresolved.raw_options)
|
|
99
|
+
except OptionError as e:
|
|
100
|
+
raise ParseError(str(e)) from e
|
|
101
|
+
|
|
102
|
+
return ParsedCommand(
|
|
103
|
+
namespace=unresolved.namespace,
|
|
104
|
+
command_name=unresolved.command_name,
|
|
105
|
+
command=unresolved.command,
|
|
106
|
+
args=unresolved.args,
|
|
107
|
+
options=resolved,
|
|
108
|
+
raw_options=unresolved.raw_options,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def parse(self, argv: list[str]) -> ParsedCommand:
|
|
112
|
+
"""Full parse: resolve command + validate options in one call.
|
|
113
|
+
|
|
114
|
+
Convenience wrapper equivalent to ``parse_command()`` then
|
|
115
|
+
``resolve_options()``.
|
|
116
|
+
"""
|
|
117
|
+
return self.resolve_options(self.parse_command(argv))
|
|
118
|
+
|
|
119
|
+
def _split_args(self, argv: list[str]) -> tuple[list[str], list[str]]:
|
|
120
|
+
"""Split argv into positional args and option tokens.
|
|
121
|
+
|
|
122
|
+
Supports GNU-style mixed placement: options can appear anywhere
|
|
123
|
+
in the command line (before, between, or after positionals).
|
|
124
|
+
|
|
125
|
+
Slash-path expansion: the first positional token starting with
|
|
126
|
+
``/`` is expanded into namespace segments (``/a/b`` → ``a``, ``b``).
|
|
127
|
+
Once any positional has been emitted, later ``/``-prefixed tokens
|
|
128
|
+
are treated as literal arguments (file paths, etc.).
|
|
129
|
+
A bare ``/`` expands to nothing (root namespace).
|
|
130
|
+
|
|
131
|
+
The ``--`` separator ends option parsing: everything after it
|
|
132
|
+
is positional.
|
|
133
|
+
|
|
134
|
+
Heuristic: any ``--option`` followed by a non-dash token consumes
|
|
135
|
+
it as a value. If the option turns out to be a boolean flag,
|
|
136
|
+
``_parse_option_tokens`` recovers the stray value (see there).
|
|
137
|
+
A lone ``-`` is consumed as a value (stdin convention).
|
|
138
|
+
"""
|
|
139
|
+
positionals: list[str] = []
|
|
140
|
+
option_tokens: list[str] = []
|
|
141
|
+
|
|
142
|
+
i = 0
|
|
143
|
+
while i < len(argv):
|
|
144
|
+
token = argv[i]
|
|
145
|
+
|
|
146
|
+
# -- separator: everything after is positional
|
|
147
|
+
if token == "--":
|
|
148
|
+
positionals.extend(argv[i + 1 :])
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
if token.startswith("-") and len(token) > 1:
|
|
152
|
+
# Option token
|
|
153
|
+
option_tokens.append(token)
|
|
154
|
+
|
|
155
|
+
# If not --key=value form, consume next token as value
|
|
156
|
+
# (heuristic: assume non-dash options take a value)
|
|
157
|
+
if "=" not in token and i + 1 < len(argv):
|
|
158
|
+
next_token = argv[i + 1]
|
|
159
|
+
if next_token != "--" and not (
|
|
160
|
+
next_token.startswith("-") and len(next_token) > 1
|
|
161
|
+
):
|
|
162
|
+
option_tokens.append(next_token)
|
|
163
|
+
i += 2
|
|
164
|
+
continue
|
|
165
|
+
i += 1
|
|
166
|
+
else:
|
|
167
|
+
# Positional token — expand slash path only for the first one
|
|
168
|
+
if not positionals and token.startswith("/"):
|
|
169
|
+
segments = [s for s in token.split("/") if s]
|
|
170
|
+
positionals.extend(segments)
|
|
171
|
+
else:
|
|
172
|
+
positionals.append(token)
|
|
173
|
+
i += 1
|
|
174
|
+
|
|
175
|
+
return positionals, option_tokens
|
|
176
|
+
|
|
177
|
+
def _build_option_registry(self, command: Command | None) -> OptionRegistry:
|
|
178
|
+
"""Build option registry from command declarations + standard OUTPUT options."""
|
|
179
|
+
registry = OptionRegistry()
|
|
180
|
+
registry.register_all(OUTPUT_OPTIONS)
|
|
181
|
+
if command:
|
|
182
|
+
registry.register_all(command.get_options())
|
|
183
|
+
return registry
|
|
184
|
+
|
|
185
|
+
def _parse_option_tokens(
|
|
186
|
+
self, tokens: list[str], registry: OptionRegistry
|
|
187
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
188
|
+
"""Parse option tokens into a raw name->value dict.
|
|
189
|
+
|
|
190
|
+
Handles: --key value, --key=value, -k value, -k=value, boolean flags.
|
|
191
|
+
|
|
192
|
+
Returns (raw_options, recovered_positionals). Recovered positionals
|
|
193
|
+
are tokens that ``_split_args`` heuristically consumed as option values
|
|
194
|
+
but actually follow boolean flags — they belong in the positional stream.
|
|
195
|
+
"""
|
|
196
|
+
raw: dict[str, Any] = {}
|
|
197
|
+
recovered: list[str] = []
|
|
198
|
+
i = 0
|
|
199
|
+
|
|
200
|
+
while i < len(tokens):
|
|
201
|
+
token = tokens[i]
|
|
202
|
+
|
|
203
|
+
if "=" in token:
|
|
204
|
+
# --key=value or -k=value
|
|
205
|
+
name_part, value = token.split("=", 1)
|
|
206
|
+
name = name_part.lstrip("-")
|
|
207
|
+
canonical = registry.resolve_name(name) or name
|
|
208
|
+
self._set_option(raw, canonical, value, registry)
|
|
209
|
+
i += 1
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
if not token.startswith("-") or len(token) == 1:
|
|
213
|
+
# Stray positional (includes lone `-`) — recover for command args
|
|
214
|
+
recovered.append(token)
|
|
215
|
+
i += 1
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
name = token.lstrip("-")
|
|
219
|
+
canonical = registry.resolve_name(name) or name
|
|
220
|
+
|
|
221
|
+
# Boolean flag: no value consumed
|
|
222
|
+
if registry.is_boolean_flag(name):
|
|
223
|
+
raw[canonical] = True
|
|
224
|
+
i += 1
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
# Regular option: consume next token as value (lone `-` is valid)
|
|
228
|
+
if i + 1 < len(tokens) and not (
|
|
229
|
+
tokens[i + 1].startswith("-") and len(tokens[i + 1]) > 1
|
|
230
|
+
):
|
|
231
|
+
self._set_option(raw, canonical, tokens[i + 1], registry)
|
|
232
|
+
i += 2
|
|
233
|
+
elif registry.get_declaration(name) is not None:
|
|
234
|
+
raise ParseError(f"Option --{canonical} requires a value")
|
|
235
|
+
else:
|
|
236
|
+
raise ParseError(f"Unknown option: --{name}")
|
|
237
|
+
|
|
238
|
+
return raw, recovered
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _set_option(raw: dict[str, Any], name: str, value: str, registry: OptionRegistry) -> None:
|
|
242
|
+
"""Set an option value, appending to a list for repeatable options."""
|
|
243
|
+
if registry.is_list_option(name):
|
|
244
|
+
raw.setdefault(name, []).append(value)
|
|
245
|
+
else:
|
|
246
|
+
raw[name] = value
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Namespace tree and registry.
|
|
2
|
+
|
|
3
|
+
A Namespace is a node in the command tree (e.g., /system, /system/config).
|
|
4
|
+
Each namespace holds commands and optional sub-namespaces.
|
|
5
|
+
The NamespaceRegistry is the root of the tree.
|
|
6
|
+
|
|
7
|
+
Resolution is greedy left-to-right: ``kd system config set url value``
|
|
8
|
+
resolves to namespace=/system/config, command=set, args=[url, value].
|
|
9
|
+
|
|
10
|
+
Built-in command synonyms (e.g., ``get`` → ``show``) are loaded from
|
|
11
|
+
``kd/resources/command-synonyms.yaml`` and applied globally after
|
|
12
|
+
namespace registration. See :func:`load_synonyms` and :func:`apply_synonyms`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from importlib import resources as importlib_resources
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from kd.cli.core.command_base import Command
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Namespace:
|
|
27
|
+
"""A node in the command tree."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
name: str,
|
|
32
|
+
description: str,
|
|
33
|
+
path: str = "",
|
|
34
|
+
) -> None:
|
|
35
|
+
self.name = name
|
|
36
|
+
self.description = description
|
|
37
|
+
self.path = path
|
|
38
|
+
self.commands: dict[str, Command] = {}
|
|
39
|
+
self.sub_namespaces: dict[str, Namespace] = {}
|
|
40
|
+
self.synonyms: dict[str, str] = {}
|
|
41
|
+
|
|
42
|
+
def register_command(self, name: str, command: Command) -> None:
|
|
43
|
+
"""Register a command in this namespace."""
|
|
44
|
+
self.commands[name] = command
|
|
45
|
+
|
|
46
|
+
def register_sub_namespace(self, namespace: Namespace) -> None:
|
|
47
|
+
"""Register a child namespace."""
|
|
48
|
+
self.sub_namespaces[namespace.name] = namespace
|
|
49
|
+
|
|
50
|
+
def get_command(self, name: str) -> Command | None:
|
|
51
|
+
"""Look up a command by name, falling through to synonyms."""
|
|
52
|
+
cmd = self.commands.get(name)
|
|
53
|
+
if cmd is not None:
|
|
54
|
+
return cmd
|
|
55
|
+
canonical = self.synonyms.get(name)
|
|
56
|
+
if canonical is not None:
|
|
57
|
+
return self.commands.get(canonical)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def get_sub_namespace(self, name: str) -> Namespace | None:
|
|
61
|
+
"""Look up a child namespace by name."""
|
|
62
|
+
return self.sub_namespaces.get(name)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NamespaceRegistry:
|
|
66
|
+
"""Root of the namespace tree. Resolves token sequences to commands."""
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.root = Namespace("kd", "YouTrack CLI and SDK")
|
|
70
|
+
|
|
71
|
+
def register_namespace(self, namespace: Namespace) -> None:
|
|
72
|
+
"""Place a namespace in the tree by its path.
|
|
73
|
+
|
|
74
|
+
Path segments are split on '/'. Intermediate namespaces are
|
|
75
|
+
created automatically if they don't exist.
|
|
76
|
+
|
|
77
|
+
Example: path="system/config" creates /system (if missing)
|
|
78
|
+
then places the namespace as /system/config.
|
|
79
|
+
"""
|
|
80
|
+
if not namespace.path:
|
|
81
|
+
self.root.register_sub_namespace(namespace)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
segments = namespace.path.split("/")
|
|
85
|
+
parent = self.root
|
|
86
|
+
|
|
87
|
+
# Navigate/create intermediate namespaces
|
|
88
|
+
for segment in segments[:-1]:
|
|
89
|
+
child = parent.get_sub_namespace(segment)
|
|
90
|
+
if child is None:
|
|
91
|
+
child = Namespace(segment, f"{segment} commands", path=segment)
|
|
92
|
+
parent.register_sub_namespace(child)
|
|
93
|
+
parent = child
|
|
94
|
+
|
|
95
|
+
# Register using the last path segment as key (not namespace.name)
|
|
96
|
+
parent.sub_namespaces[segments[-1]] = namespace
|
|
97
|
+
|
|
98
|
+
def resolve(self, tokens: list[str]) -> tuple[Namespace, str, list[str]]:
|
|
99
|
+
"""Resolve tokens to (namespace, command_name, remaining_args).
|
|
100
|
+
|
|
101
|
+
Uses greedy left-to-right matching: consumes tokens as namespace
|
|
102
|
+
segments as long as matching sub-namespaces exist, then takes the
|
|
103
|
+
next token as the command name.
|
|
104
|
+
|
|
105
|
+
Returns empty command_name when no command token remains (bare
|
|
106
|
+
namespace invocation — triggers discovery).
|
|
107
|
+
|
|
108
|
+
Examples:
|
|
109
|
+
["system", "config", "set", "url", "val"]
|
|
110
|
+
-> (system/config namespace, "set", ["url", "val"])
|
|
111
|
+
|
|
112
|
+
["system", "config"]
|
|
113
|
+
-> (system/config namespace, "", [])
|
|
114
|
+
|
|
115
|
+
[]
|
|
116
|
+
-> (root namespace, "", [])
|
|
117
|
+
"""
|
|
118
|
+
if not tokens:
|
|
119
|
+
return self.root, "", []
|
|
120
|
+
|
|
121
|
+
current = self.root
|
|
122
|
+
consumed = 0
|
|
123
|
+
|
|
124
|
+
# Greedy namespace resolution
|
|
125
|
+
for token in tokens:
|
|
126
|
+
child = current.get_sub_namespace(token)
|
|
127
|
+
if child is None:
|
|
128
|
+
break
|
|
129
|
+
current = child
|
|
130
|
+
consumed += 1
|
|
131
|
+
|
|
132
|
+
remaining = tokens[consumed:]
|
|
133
|
+
|
|
134
|
+
if remaining:
|
|
135
|
+
command_name = remaining[0]
|
|
136
|
+
args = remaining[1:]
|
|
137
|
+
else:
|
|
138
|
+
command_name = ""
|
|
139
|
+
args = []
|
|
140
|
+
|
|
141
|
+
return current, command_name, args
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Built-in command synonyms
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_synonyms() -> list[tuple[str, str]]:
|
|
150
|
+
"""Load synonym definitions from the YAML resource file.
|
|
151
|
+
|
|
152
|
+
Returns a list of ``(synonym, canonical)`` pairs.
|
|
153
|
+
"""
|
|
154
|
+
ref = importlib_resources.files("kd.resources").joinpath("command-synonyms.yaml")
|
|
155
|
+
raw = yaml.safe_load(ref.read_text(encoding="utf-8"))
|
|
156
|
+
return [(entry["synonym"], entry["canonical"]) for entry in raw]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _apply_to_namespace(ns: Namespace, synonyms: list[tuple[str, str]]) -> None:
|
|
160
|
+
"""Apply synonyms to a single namespace (non-recursive)."""
|
|
161
|
+
for synonym, canonical in synonyms:
|
|
162
|
+
if canonical in ns.commands and synonym not in ns.commands:
|
|
163
|
+
ns.synonyms[synonym] = canonical
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def apply_synonyms(root: Namespace, synonyms: list[tuple[str, str]]) -> None:
|
|
167
|
+
"""Recursively apply synonym mappings to a namespace tree.
|
|
168
|
+
|
|
169
|
+
For each namespace that has the canonical command and does *not*
|
|
170
|
+
already define a command with the synonym name, a synonym entry
|
|
171
|
+
is added. Existing canonical commands are never overwritten.
|
|
172
|
+
"""
|
|
173
|
+
_apply_to_namespace(root, synonyms)
|
|
174
|
+
for child in root.sub_namespaces.values():
|
|
175
|
+
apply_synonyms(child, synonyms)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Option declaration and resolution.
|
|
2
|
+
|
|
3
|
+
Two option layers for kd:
|
|
4
|
+
- COMMAND: command-specific options (--project, --query, --limit)
|
|
5
|
+
- OUTPUT: output formatting options (--output, --fields)
|
|
6
|
+
|
|
7
|
+
Options are declared per-command and resolved by the parser.
|
|
8
|
+
Global options (--connection, --help, --version, --debug, --reference)
|
|
9
|
+
are handled separately in main.py, outside the layer system.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OptionLayer(Enum):
|
|
20
|
+
"""Option layers — separates options by concern."""
|
|
21
|
+
|
|
22
|
+
GLOBAL = "global"
|
|
23
|
+
COMMAND = "command"
|
|
24
|
+
OUTPUT = "output"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class OptionDeclaration:
|
|
29
|
+
"""Metadata for a single CLI option."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
layer: OptionLayer
|
|
33
|
+
type: type = str
|
|
34
|
+
short_name: str | None = None
|
|
35
|
+
default: Any = None
|
|
36
|
+
choices: list[str] | None = None
|
|
37
|
+
required: bool = False
|
|
38
|
+
help_text: str = ""
|
|
39
|
+
is_list: bool = False
|
|
40
|
+
aliases: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ResolvedOptions:
|
|
45
|
+
"""Parsed and validated options, organized by layer."""
|
|
46
|
+
|
|
47
|
+
command: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
output: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def get_layer(self, layer: OptionLayer) -> dict[str, Any]:
|
|
51
|
+
"""Get options dict for a specific layer."""
|
|
52
|
+
if layer == OptionLayer.COMMAND:
|
|
53
|
+
return self.command
|
|
54
|
+
return self.output
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OptionRegistry:
|
|
58
|
+
"""Manages option declarations and resolves raw option dicts."""
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self._declarations: dict[str, OptionDeclaration] = {}
|
|
62
|
+
self._short_names: dict[str, str] = {} # short_name -> full name
|
|
63
|
+
self._aliases: dict[str, str] = {} # alias -> canonical name
|
|
64
|
+
|
|
65
|
+
def register(self, declaration: OptionDeclaration) -> None:
|
|
66
|
+
"""Register an option declaration."""
|
|
67
|
+
self._declarations[declaration.name] = declaration
|
|
68
|
+
if declaration.short_name:
|
|
69
|
+
self._short_names[declaration.short_name] = declaration.name
|
|
70
|
+
for alias in declaration.aliases:
|
|
71
|
+
self._aliases[alias] = declaration.name
|
|
72
|
+
|
|
73
|
+
def register_all(self, declarations: list[OptionDeclaration]) -> None:
|
|
74
|
+
"""Register multiple declarations."""
|
|
75
|
+
for decl in declarations:
|
|
76
|
+
self.register(decl)
|
|
77
|
+
|
|
78
|
+
def get_declaration(self, name: str) -> OptionDeclaration | None:
|
|
79
|
+
"""Look up declaration by name, short name, or alias."""
|
|
80
|
+
if name in self._declarations:
|
|
81
|
+
return self._declarations[name]
|
|
82
|
+
full_name = self._short_names.get(name) or self._aliases.get(name)
|
|
83
|
+
if full_name:
|
|
84
|
+
return self._declarations[full_name]
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def resolve_name(self, name: str) -> str | None:
|
|
88
|
+
"""Resolve a short name or alias to its canonical name. Returns None if unknown."""
|
|
89
|
+
if name in self._declarations:
|
|
90
|
+
return name
|
|
91
|
+
return self._short_names.get(name) or self._aliases.get(name)
|
|
92
|
+
|
|
93
|
+
def is_boolean_flag(self, name: str) -> bool:
|
|
94
|
+
"""Check if an option is a boolean flag (no value expected)."""
|
|
95
|
+
decl = self.get_declaration(name)
|
|
96
|
+
return decl is not None and decl.type is bool
|
|
97
|
+
|
|
98
|
+
def is_list_option(self, name: str) -> bool:
|
|
99
|
+
"""Check if an option is a repeatable list option."""
|
|
100
|
+
decl = self.get_declaration(name)
|
|
101
|
+
return decl is not None and decl.is_list
|
|
102
|
+
|
|
103
|
+
def resolve(self, raw: dict[str, Any]) -> ResolvedOptions:
|
|
104
|
+
"""Resolve raw options dict into layered ResolvedOptions.
|
|
105
|
+
|
|
106
|
+
Validates types, choices, and required options.
|
|
107
|
+
Applies defaults for missing options.
|
|
108
|
+
"""
|
|
109
|
+
result = ResolvedOptions()
|
|
110
|
+
|
|
111
|
+
# Apply provided values
|
|
112
|
+
for raw_name, value in raw.items():
|
|
113
|
+
full_name = self.resolve_name(raw_name)
|
|
114
|
+
if full_name is None:
|
|
115
|
+
raise OptionError(f"Unknown option: --{raw_name}")
|
|
116
|
+
decl = self._declarations[full_name]
|
|
117
|
+
validated = self._validate_value(decl, value)
|
|
118
|
+
result.get_layer(decl.layer)[decl.name] = validated
|
|
119
|
+
|
|
120
|
+
# Apply defaults and check required
|
|
121
|
+
for decl in self._declarations.values():
|
|
122
|
+
layer_dict = result.get_layer(decl.layer)
|
|
123
|
+
if decl.name not in layer_dict:
|
|
124
|
+
if decl.required:
|
|
125
|
+
raise OptionError(f"Required option missing: --{decl.name}")
|
|
126
|
+
if decl.is_list:
|
|
127
|
+
layer_dict[decl.name] = []
|
|
128
|
+
elif decl.default is not None:
|
|
129
|
+
layer_dict[decl.name] = decl.default
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def _validate_value(self, decl: OptionDeclaration, value: Any) -> Any:
|
|
134
|
+
"""Validate and coerce an option value (single or list)."""
|
|
135
|
+
if decl.is_list:
|
|
136
|
+
items = value if isinstance(value, list) else [value]
|
|
137
|
+
return [self._validate_single(decl, v) for v in items]
|
|
138
|
+
return self._validate_single(decl, value)
|
|
139
|
+
|
|
140
|
+
def _validate_single(self, decl: OptionDeclaration, value: Any) -> Any:
|
|
141
|
+
"""Validate and coerce a single option value."""
|
|
142
|
+
if decl.type is bool:
|
|
143
|
+
if isinstance(value, bool):
|
|
144
|
+
return value
|
|
145
|
+
if isinstance(value, str):
|
|
146
|
+
if value.lower() in ("true", "1", "yes"):
|
|
147
|
+
return True
|
|
148
|
+
if value.lower() in ("false", "0", "no"):
|
|
149
|
+
return False
|
|
150
|
+
raise OptionError(f"Invalid boolean value for --{decl.name}: {value}")
|
|
151
|
+
|
|
152
|
+
if decl.type is int:
|
|
153
|
+
try:
|
|
154
|
+
return int(value)
|
|
155
|
+
except (ValueError, TypeError):
|
|
156
|
+
raise OptionError(f"Expected integer for --{decl.name}: {value}") from None
|
|
157
|
+
|
|
158
|
+
if decl.type is float:
|
|
159
|
+
try:
|
|
160
|
+
return float(value)
|
|
161
|
+
except (ValueError, TypeError):
|
|
162
|
+
raise OptionError(f"Expected number for --{decl.name}: {value}") from None
|
|
163
|
+
|
|
164
|
+
# str type — check choices
|
|
165
|
+
str_value = str(value)
|
|
166
|
+
if decl.choices and str_value not in decl.choices:
|
|
167
|
+
choices_str = ", ".join(decl.choices)
|
|
168
|
+
raise OptionError(
|
|
169
|
+
f"Invalid value for --{decl.name}: '{str_value}'. Choose from: {choices_str}"
|
|
170
|
+
)
|
|
171
|
+
return str_value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class OptionError(Exception):
|
|
175
|
+
"""Raised when option parsing or validation fails."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Shared COMMAND option builders
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Factory functions for recurring options. Commands opt in by calling the
|
|
182
|
+
# builder in _register_options(). Flags and short names are locked; callers
|
|
183
|
+
# may override help_text, required, and default.
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def project_option(
|
|
187
|
+
*, help_text: str = "Filter by project key", required: bool = False
|
|
188
|
+
) -> OptionDeclaration:
|
|
189
|
+
"""``--project / -p`` — project key filter."""
|
|
190
|
+
return OptionDeclaration(
|
|
191
|
+
name="project",
|
|
192
|
+
short_name="p",
|
|
193
|
+
layer=OptionLayer.COMMAND,
|
|
194
|
+
help_text=help_text,
|
|
195
|
+
required=required,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def limit_option(
|
|
200
|
+
*, default: int = 50, help_text: str = "Maximum number of results"
|
|
201
|
+
) -> OptionDeclaration:
|
|
202
|
+
"""``--limit / -l`` — result cap."""
|
|
203
|
+
return OptionDeclaration(
|
|
204
|
+
name="limit",
|
|
205
|
+
short_name="l",
|
|
206
|
+
layer=OptionLayer.COMMAND,
|
|
207
|
+
type=int,
|
|
208
|
+
default=default,
|
|
209
|
+
help_text=help_text,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def type_option(*, help_text: str = "Issue type (Bug, Task, Feature, etc.)") -> OptionDeclaration:
|
|
214
|
+
"""``--type / -T`` — issue type field."""
|
|
215
|
+
return OptionDeclaration(
|
|
216
|
+
name="type",
|
|
217
|
+
short_name="T",
|
|
218
|
+
layer=OptionLayer.COMMAND,
|
|
219
|
+
help_text=help_text,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def priority_option(
|
|
224
|
+
*, help_text: str = "Priority (Critical, Major, Normal, Minor)"
|
|
225
|
+
) -> OptionDeclaration:
|
|
226
|
+
"""``--priority / -P`` — issue priority field."""
|
|
227
|
+
return OptionDeclaration(
|
|
228
|
+
name="priority",
|
|
229
|
+
short_name="P",
|
|
230
|
+
layer=OptionLayer.COMMAND,
|
|
231
|
+
help_text=help_text,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def state_option(
|
|
236
|
+
*,
|
|
237
|
+
help_text: str = "State (Submitted, Open, etc.). See: /project/field list KEY --field State",
|
|
238
|
+
) -> OptionDeclaration:
|
|
239
|
+
"""``--state / -S`` — issue state field."""
|
|
240
|
+
return OptionDeclaration(
|
|
241
|
+
name="state",
|
|
242
|
+
short_name="S",
|
|
243
|
+
layer=OptionLayer.COMMAND,
|
|
244
|
+
help_text=help_text,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Standard OUTPUT options available to all commands
|
|
249
|
+
OUTPUT_OPTIONS: list[OptionDeclaration] = [
|
|
250
|
+
OptionDeclaration(
|
|
251
|
+
name="output",
|
|
252
|
+
short_name="o",
|
|
253
|
+
layer=OptionLayer.OUTPUT,
|
|
254
|
+
type=str,
|
|
255
|
+
choices=["table", "json", "yaml", "md"],
|
|
256
|
+
help_text="Output format (table, json, yaml, md)",
|
|
257
|
+
),
|
|
258
|
+
OptionDeclaration(
|
|
259
|
+
name="fields",
|
|
260
|
+
short_name="f",
|
|
261
|
+
layer=OptionLayer.OUTPUT,
|
|
262
|
+
type=str,
|
|
263
|
+
help_text="Comma-separated field list",
|
|
264
|
+
),
|
|
265
|
+
]
|