kd-cli 2.9.1__tar.gz → 2.10.0__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.9.1/kd_cli.egg-info → kd_cli-2.10.0}/PKG-INFO +2 -2
- {kd_cli-2.9.1 → kd_cli-2.10.0}/README.md +1 -1
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/api/http.py +18 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/article_marker.py +23 -9
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/core/command_base.py +18 -0
- kd_cli-2.10.0/kd/cli/core/command_parser.py +348 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/core/option_registry.py +24 -8
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/article/__init__.py +25 -9
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/init.py +1 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/issue/__init__.py +66 -24
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/primer/__init__.py +57 -9
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/project/__init__.py +11 -11
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/query/__init__.py +14 -18
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/system/__init__.py +5 -2
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/tag/__init__.py +7 -3
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/time/__init__.py +2 -2
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/user/__init__.py +5 -6
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/output.py +56 -19
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/client.py +85 -8
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/resources/CHANGELOG.md +161 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/resources/operating-guide.md +60 -7
- {kd_cli-2.9.1 → kd_cli-2.10.0/kd_cli.egg-info}/PKG-INFO +2 -2
- {kd_cli-2.9.1 → kd_cli-2.10.0}/pyproject.toml +2 -1
- kd_cli-2.9.1/kd/cli/core/command_parser.py +0 -246
- {kd_cli-2.9.1 → kd_cli-2.10.0}/LICENSE +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/MANIFEST.in +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/api/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/api/articles.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/api/hub.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/api/tags.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/api/youtrack.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/auth.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cache.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/input.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/main.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/cache/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/cli/tree.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/config.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/config_read.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/config_schema.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/connection.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/debug.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/exceptions.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/history.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/locking.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/article.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/assist.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/attachment.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/comment.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/common.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/issue.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/project.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/saved_query.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/search.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/tag.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/user.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/models/work_item.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/operating_guide.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/primer.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/query_parser.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/release_notes.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/resources/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/resources/sdlc-doctrine.md +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd/sdlc.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.9.1 → kd_cli-2.10.0}/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.10.0
|
|
4
4
|
Summary: Knowledge Dispatcher for issue trackers and knowledge bases
|
|
5
5
|
Author: Sergiy Gladkyy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -167,7 +167,7 @@ pytest tests/ -v --tb=short
|
|
|
167
167
|
|
|
168
168
|
ruff format kd/ tests/
|
|
169
169
|
ruff check --fix kd/ tests/
|
|
170
|
-
mypy
|
|
170
|
+
uv run mypy
|
|
171
171
|
```
|
|
172
172
|
|
|
173
173
|
---
|
|
@@ -23,6 +23,7 @@ from kd.exceptions import (
|
|
|
23
23
|
ConflictError,
|
|
24
24
|
NotFoundError,
|
|
25
25
|
PermissionDeniedError,
|
|
26
|
+
ValidationError,
|
|
26
27
|
enrich_error,
|
|
27
28
|
)
|
|
28
29
|
|
|
@@ -30,6 +31,22 @@ _RETRYABLE_STATUSES = {429, 502, 503}
|
|
|
30
31
|
_MAX_RETRIES = 3
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def _refuse_control_characters(path: str) -> None:
|
|
35
|
+
"""Refuse a path that no URL can carry, before it is sent.
|
|
36
|
+
|
|
37
|
+
The refused set is exactly what httpx rejects as non-printable ASCII:
|
|
38
|
+
C0 controls and DEL. The user values kd puts in a path are entity ids,
|
|
39
|
+
one per segment, so the error names the segment that carries the
|
|
40
|
+
character, escaped onto one line.
|
|
41
|
+
"""
|
|
42
|
+
for segment in path.split("/"):
|
|
43
|
+
if any(c < " " or c == "\x7f" for c in segment):
|
|
44
|
+
raise ValidationError(
|
|
45
|
+
f"Invalid id {segment!r}: it contains a control character "
|
|
46
|
+
"(line break, tab or similar). Pass one id per argument."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
33
50
|
class HttpClient:
|
|
34
51
|
"""Low-level HTTP client with auth, retry, and error mapping."""
|
|
35
52
|
|
|
@@ -116,6 +133,7 @@ class HttpClient:
|
|
|
116
133
|
|
|
117
134
|
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
118
135
|
"""Execute request with retry and error mapping."""
|
|
136
|
+
_refuse_control_characters(path)
|
|
119
137
|
d = self._debug
|
|
120
138
|
|
|
121
139
|
# Log request details before first attempt
|
|
@@ -18,6 +18,9 @@ Grammar (informal):
|
|
|
18
18
|
Rules (pinned down by ``tests/test_article_marker.py``):
|
|
19
19
|
|
|
20
20
|
- Open and close lines must be exact (``<!-- kd-meta`` / ``-->``).
|
|
21
|
+
- Only a marker that opens on the first non-blank line counts; a
|
|
22
|
+
marker-open line after body content is body. A second marker block
|
|
23
|
+
directly below the top one (only blank lines between) is an error.
|
|
21
24
|
- Keys match ``[a-z][a-z0-9-]*``; uppercase and underscores are
|
|
22
25
|
rejected to keep the grammar ASCII-boring.
|
|
23
26
|
- Unknown keys are preserved verbatim on round-trip — the load-bearing
|
|
@@ -49,8 +52,9 @@ Legacy marker synonym (transitional, proposal 28 / M003 §S4):
|
|
|
49
52
|
upstream population of ``ytctl-meta`` is empty. No auto-disable,
|
|
50
53
|
no timer — a deliberate future milestone.
|
|
51
54
|
|
|
52
|
-
Stdlib only; no third-party deps. The push workflow
|
|
53
|
-
``kd.client.ArticleOperations.push``)
|
|
55
|
+
Stdlib only; no third-party deps. The push workflow
|
|
56
|
+
(``kd.client.ArticleOperations.push``), ``/article update``
|
|
57
|
+
(``kd.client.ArticleOperations.update``) and their tests are the only
|
|
54
58
|
importers.
|
|
55
59
|
"""
|
|
56
60
|
|
|
@@ -248,13 +252,17 @@ def parse_content(text: str) -> ParsedFile:
|
|
|
248
252
|
if close_idx is None:
|
|
249
253
|
raise MarkerError("marker block not closed (expected '-->' on its own line)")
|
|
250
254
|
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
# A second marker block with only blank lines above it would become
|
|
256
|
+
# the identity once the top block is stripped (``/article update
|
|
257
|
+
# --content``) or edited away, so it is refused. A marker-open line
|
|
258
|
+
# after body content stays body. This check only refuses, so it
|
|
259
|
+
# compares leniently (whitespace, stray CR, BOM): an editor or
|
|
260
|
+
# encoding round trip can turn a near-exact open into an exact one.
|
|
261
|
+
k = close_idx + 1
|
|
262
|
+
while k < len(lines) and _loose(lines[k]) == "":
|
|
263
|
+
k += 1
|
|
264
|
+
if k < len(lines) and _loose(lines[k]) in _RECOGNIZED_OPENS:
|
|
265
|
+
raise MarkerError("a second kd-meta block directly follows the top marker; keep one")
|
|
258
266
|
|
|
259
267
|
# Parse key:value lines between open and close.
|
|
260
268
|
marker = ArticleMarker()
|
|
@@ -291,6 +299,12 @@ def parse_content(text: str) -> ParsedFile:
|
|
|
291
299
|
)
|
|
292
300
|
|
|
293
301
|
|
|
302
|
+
def _loose(line: str) -> str:
|
|
303
|
+
"""Normalize a line for the stacked-block refusal only: drop BOMs and
|
|
304
|
+
surrounding whitespace. Never used to detect the top marker."""
|
|
305
|
+
return line.replace("\ufeff", "").strip()
|
|
306
|
+
|
|
307
|
+
|
|
294
308
|
def _join_body(lines: list[str], trailing_newline: bool) -> str:
|
|
295
309
|
"""Join body lines with ``\\n``, restoring the final terminator if any."""
|
|
296
310
|
if not lines:
|
|
@@ -11,6 +11,7 @@ from abc import ABC, abstractmethod
|
|
|
11
11
|
from typing import TYPE_CHECKING, Any
|
|
12
12
|
|
|
13
13
|
from kd.cli.core.option_registry import OptionDeclaration, ResolvedOptions
|
|
14
|
+
from kd.exceptions import ValidationError
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from kd.config import ResolvedConfig
|
|
@@ -20,6 +21,18 @@ if TYPE_CHECKING:
|
|
|
20
21
|
CommandResult = list[dict[str, Any]] | dict[str, Any] | str | None
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
def reject_extra_args(args: list[str], allowed: int, usage: str) -> None:
|
|
25
|
+
"""Refuse positionals beyond the first *allowed* ones.
|
|
26
|
+
|
|
27
|
+
For commands whose positional count is fixed and whose effect is
|
|
28
|
+
outward or irreversible: a word left over by the parser (for example
|
|
29
|
+
``off`` after a boolean option) must not be dropped silently. The
|
|
30
|
+
error names the first extra word and the command's usage line.
|
|
31
|
+
"""
|
|
32
|
+
if len(args) > allowed:
|
|
33
|
+
raise ValidationError(f"Unexpected argument '{args[allowed]}'. Usage: {usage}")
|
|
34
|
+
|
|
35
|
+
|
|
23
36
|
class Command(ABC):
|
|
24
37
|
"""Base class for all CLI commands."""
|
|
25
38
|
|
|
@@ -31,6 +44,7 @@ class Command(ABC):
|
|
|
31
44
|
requires_client: bool = False,
|
|
32
45
|
requires_config: bool = False,
|
|
33
46
|
usage: str = "",
|
|
47
|
+
joins_text: bool = False,
|
|
34
48
|
long_description: str = "",
|
|
35
49
|
examples: list[str] | None = None,
|
|
36
50
|
see_also: list[str] | None = None,
|
|
@@ -40,6 +54,10 @@ class Command(ABC):
|
|
|
40
54
|
self.requires_client = requires_client
|
|
41
55
|
self.requires_config = requires_config
|
|
42
56
|
self.usage = usage
|
|
57
|
+
# The command joins its arguments into one text (a summary, comment,
|
|
58
|
+
# note, name or query). The parser then refuses a bare switch that is
|
|
59
|
+
# directly followed by a word when an argument comes before it.
|
|
60
|
+
self.joins_text = joins_text
|
|
43
61
|
self.long_description = long_description
|
|
44
62
|
self.examples: list[str] = examples or []
|
|
45
63
|
self.see_also: list[str] = see_also or []
|
|
@@ -0,0 +1,348 @@
|
|
|
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
|
+
is_spaced_bool_word,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ParseError(Exception):
|
|
29
|
+
"""Raised when command parsing fails."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class UnresolvedCommand:
|
|
34
|
+
"""Phase-1 result: command identified, options tokenized but not validated."""
|
|
35
|
+
|
|
36
|
+
namespace: Namespace
|
|
37
|
+
command_name: str
|
|
38
|
+
command: Command | None # None if command not found
|
|
39
|
+
args: list[str] = field(default_factory=list)
|
|
40
|
+
raw_options: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ParsedCommand:
|
|
45
|
+
"""Result of parsing a command line."""
|
|
46
|
+
|
|
47
|
+
namespace: Namespace
|
|
48
|
+
command_name: str
|
|
49
|
+
command: Command | None # None if command not found
|
|
50
|
+
args: list[str] = field(default_factory=list)
|
|
51
|
+
options: ResolvedOptions = field(default_factory=ResolvedOptions)
|
|
52
|
+
raw_options: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_option_token(token: str) -> bool:
|
|
56
|
+
"""``-x`` / ``--name`` style token; a lone ``-`` is a value (stdin convention)."""
|
|
57
|
+
return token.startswith("-") and len(token) > 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CommandParser:
|
|
61
|
+
"""Parses argv into a resolved command with options."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, registry: NamespaceRegistry) -> None:
|
|
64
|
+
self._registry = registry
|
|
65
|
+
|
|
66
|
+
def parse_command(self, argv: list[str]) -> UnresolvedCommand:
|
|
67
|
+
"""Phase 1: resolve command and tokenize options. No validation.
|
|
68
|
+
|
|
69
|
+
1. Split argv knowing only the output options, and resolve the
|
|
70
|
+
namespace + command from the positionals
|
|
71
|
+
2. Split argv again with that command's option registry, so each
|
|
72
|
+
option takes exactly the tokens it declares and positionals keep
|
|
73
|
+
the order they were typed in
|
|
74
|
+
3. Parse the option tokens into a raw dict
|
|
75
|
+
|
|
76
|
+
The command's options are not known until its name is read. The two
|
|
77
|
+
splits resolve different commands only when a switch of the command
|
|
78
|
+
sits before the command name and the first split let it take a word
|
|
79
|
+
that is not ``true``/``false``; that form is refused with a message
|
|
80
|
+
naming the form that works. A switch directly before a namespace or
|
|
81
|
+
command word swallows that word in the first split too, so the
|
|
82
|
+
command is never found and the usual unknown-command path reports it.
|
|
83
|
+
|
|
84
|
+
On a command that joins its arguments into one text
|
|
85
|
+
(``Command.joins_text``), a bare switch directly followed by a word
|
|
86
|
+
is refused when any argument, the id included, comes before the
|
|
87
|
+
switch: the word would otherwise become text or the target. ``--``,
|
|
88
|
+
an option between the switch and the word, the ``=`` form and a
|
|
89
|
+
``true``/``false`` the switch takes all break the adjacency.
|
|
90
|
+
"""
|
|
91
|
+
first_positionals, first_tokens, _ = self._split_args(
|
|
92
|
+
argv, self._build_option_registry(None)
|
|
93
|
+
)
|
|
94
|
+
namespace, command_name, _ = self._registry.resolve(first_positionals)
|
|
95
|
+
command = namespace.get_command(command_name) if command_name else None
|
|
96
|
+
|
|
97
|
+
option_reg = self._build_option_registry(command)
|
|
98
|
+
positionals, option_tokens, after_switch = self._split_args(argv, option_reg)
|
|
99
|
+
second_namespace, second_name, remaining = self._registry.resolve(positionals)
|
|
100
|
+
if second_namespace is not namespace or second_name != command_name:
|
|
101
|
+
raise ParseError(
|
|
102
|
+
self._misplaced_switch_message(
|
|
103
|
+
first_tokens, option_tokens, namespace, command_name, option_reg
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
if command is not None and command.joins_text:
|
|
107
|
+
self._reject_switch_between_args(
|
|
108
|
+
positionals, len(positionals) - len(remaining), after_switch, command, option_reg
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
raw_options = self._parse_option_tokens(option_tokens, option_reg)
|
|
112
|
+
|
|
113
|
+
return UnresolvedCommand(
|
|
114
|
+
namespace=namespace,
|
|
115
|
+
command_name=command_name,
|
|
116
|
+
command=command,
|
|
117
|
+
args=remaining,
|
|
118
|
+
raw_options=raw_options,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def resolve_options(self, unresolved: UnresolvedCommand) -> ParsedCommand:
|
|
122
|
+
"""Phase 2: validate and resolve options.
|
|
123
|
+
|
|
124
|
+
Builds the option registry, validates types/choices/required,
|
|
125
|
+
and returns a fully resolved ``ParsedCommand``.
|
|
126
|
+
"""
|
|
127
|
+
option_reg = self._build_option_registry(unresolved.command)
|
|
128
|
+
try:
|
|
129
|
+
resolved = option_reg.resolve(unresolved.raw_options)
|
|
130
|
+
except OptionError as e:
|
|
131
|
+
raise ParseError(str(e)) from e
|
|
132
|
+
|
|
133
|
+
return ParsedCommand(
|
|
134
|
+
namespace=unresolved.namespace,
|
|
135
|
+
command_name=unresolved.command_name,
|
|
136
|
+
command=unresolved.command,
|
|
137
|
+
args=unresolved.args,
|
|
138
|
+
options=resolved,
|
|
139
|
+
raw_options=unresolved.raw_options,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def parse(self, argv: list[str]) -> ParsedCommand:
|
|
143
|
+
"""Full parse: resolve command + validate options in one call.
|
|
144
|
+
|
|
145
|
+
Convenience wrapper equivalent to ``parse_command()`` then
|
|
146
|
+
``resolve_options()``.
|
|
147
|
+
"""
|
|
148
|
+
return self.resolve_options(self.parse_command(argv))
|
|
149
|
+
|
|
150
|
+
def _split_args(
|
|
151
|
+
self, argv: list[str], registry: OptionRegistry
|
|
152
|
+
) -> tuple[list[str], list[str], list[tuple[int, str]]]:
|
|
153
|
+
"""Split argv into positional args, option tokens, and switch-adjacent words.
|
|
154
|
+
|
|
155
|
+
Supports GNU-style mixed placement: options can appear anywhere
|
|
156
|
+
in the command line (before, between, or after positionals).
|
|
157
|
+
|
|
158
|
+
Slash-path expansion: the first positional token starting with
|
|
159
|
+
``/`` is expanded into namespace segments (``/a/b`` → ``a``, ``b``).
|
|
160
|
+
Once any positional has been emitted, later ``/``-prefixed tokens
|
|
161
|
+
are treated as literal arguments (file paths, etc.).
|
|
162
|
+
A bare ``/`` expands to nothing (root namespace).
|
|
163
|
+
|
|
164
|
+
The ``--`` separator ends option parsing: everything after it
|
|
165
|
+
is positional.
|
|
166
|
+
|
|
167
|
+
Which token an option takes comes from *registry*: a boolean
|
|
168
|
+
option takes the next token only when it is ``true`` or ``false``
|
|
169
|
+
(any case); any other option, known or not, takes the next token
|
|
170
|
+
unless it is ``--`` or another option. A lone ``-`` is a value
|
|
171
|
+
(stdin convention). An unknown option is reported later by
|
|
172
|
+
``_parse_option_tokens`` or phase 2.
|
|
173
|
+
|
|
174
|
+
The third value lists each positional that directly follows a bare
|
|
175
|
+
switch (a boolean option without ``=`` that took no token), as its
|
|
176
|
+
index in the positionals and the switch token. A word after ``--``
|
|
177
|
+
or after another option is never listed.
|
|
178
|
+
"""
|
|
179
|
+
positionals: list[str] = []
|
|
180
|
+
option_tokens: list[str] = []
|
|
181
|
+
after_switch: list[tuple[int, str]] = []
|
|
182
|
+
|
|
183
|
+
i = 0
|
|
184
|
+
while i < len(argv):
|
|
185
|
+
token = argv[i]
|
|
186
|
+
|
|
187
|
+
# -- separator: everything after is positional
|
|
188
|
+
if token == "--":
|
|
189
|
+
positionals.extend(argv[i + 1 :])
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
if _is_option_token(token):
|
|
193
|
+
option_tokens.append(token)
|
|
194
|
+
if (
|
|
195
|
+
"=" not in token
|
|
196
|
+
and i + 1 < len(argv)
|
|
197
|
+
and self._takes_next(token, argv[i + 1], registry)
|
|
198
|
+
):
|
|
199
|
+
option_tokens.append(argv[i + 1])
|
|
200
|
+
i += 2
|
|
201
|
+
continue
|
|
202
|
+
# A bare switch directly followed by a word (not an option, ``--`` included).
|
|
203
|
+
if (
|
|
204
|
+
"=" not in token
|
|
205
|
+
and i + 1 < len(argv)
|
|
206
|
+
and not _is_option_token(argv[i + 1])
|
|
207
|
+
and registry.is_boolean_flag(token.lstrip("-"))
|
|
208
|
+
):
|
|
209
|
+
after_switch.append((len(positionals), token))
|
|
210
|
+
i += 1
|
|
211
|
+
else:
|
|
212
|
+
# Positional token — expand slash path only for the first one
|
|
213
|
+
if not positionals and token.startswith("/"):
|
|
214
|
+
segments = [s for s in token.split("/") if s]
|
|
215
|
+
positionals.extend(segments)
|
|
216
|
+
else:
|
|
217
|
+
positionals.append(token)
|
|
218
|
+
i += 1
|
|
219
|
+
|
|
220
|
+
return positionals, option_tokens, after_switch
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _reject_switch_between_args(
|
|
224
|
+
positionals: list[str],
|
|
225
|
+
offset: int,
|
|
226
|
+
after_switch: list[tuple[int, str]],
|
|
227
|
+
command: Command,
|
|
228
|
+
registry: OptionRegistry,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Refuse a word right after a bare switch when an argument precedes it.
|
|
231
|
+
|
|
232
|
+
*offset* is the number of namespace and command tokens at the front
|
|
233
|
+
of *positionals*, so ``index - offset`` is the word's place in the
|
|
234
|
+
command's arguments; anything past the first has an argument before
|
|
235
|
+
the switch.
|
|
236
|
+
"""
|
|
237
|
+
for index, switch in after_switch:
|
|
238
|
+
if index - offset > 0:
|
|
239
|
+
name = switch.lstrip("-")
|
|
240
|
+
canonical = registry.resolve_name(name) or name
|
|
241
|
+
raise ParseError(
|
|
242
|
+
f"Unexpected argument '{positionals[index]}' after --{canonical}: "
|
|
243
|
+
f"a switch takes no word after it. Put --{canonical} after the "
|
|
244
|
+
f"last argument, or write --{canonical}=false. Usage: {command.usage}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _takes_next(token: str, next_token: str, registry: OptionRegistry) -> bool:
|
|
249
|
+
"""Whether option *token* takes *next_token* as its value."""
|
|
250
|
+
if registry.is_boolean_flag(token.lstrip("-")):
|
|
251
|
+
return is_spaced_bool_word(next_token)
|
|
252
|
+
return next_token != "--" and not _is_option_token(next_token)
|
|
253
|
+
|
|
254
|
+
def _build_option_registry(self, command: Command | None) -> OptionRegistry:
|
|
255
|
+
"""Build option registry from command declarations + standard OUTPUT options."""
|
|
256
|
+
registry = OptionRegistry()
|
|
257
|
+
registry.register_all(OUTPUT_OPTIONS)
|
|
258
|
+
if command:
|
|
259
|
+
registry.register_all(command.get_options())
|
|
260
|
+
return registry
|
|
261
|
+
|
|
262
|
+
def _parse_option_tokens(self, tokens: list[str], registry: OptionRegistry) -> dict[str, Any]:
|
|
263
|
+
"""Parse option tokens into a raw name->value dict.
|
|
264
|
+
|
|
265
|
+
Handles: --key value, --key=value, -k value, -k=value, and boolean
|
|
266
|
+
options bare or followed by their value. ``_split_args`` only places
|
|
267
|
+
a non-option token right after the option that takes it, so a
|
|
268
|
+
following non-option token is always that option's value.
|
|
269
|
+
"""
|
|
270
|
+
raw: dict[str, Any] = {}
|
|
271
|
+
i = 0
|
|
272
|
+
|
|
273
|
+
while i < len(tokens):
|
|
274
|
+
token = tokens[i]
|
|
275
|
+
|
|
276
|
+
if "=" in token:
|
|
277
|
+
# --key=value or -k=value
|
|
278
|
+
name_part, value = token.split("=", 1)
|
|
279
|
+
name = name_part.lstrip("-")
|
|
280
|
+
canonical = registry.resolve_name(name) or name
|
|
281
|
+
self._set_option(raw, canonical, value, registry)
|
|
282
|
+
i += 1
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
name = token.lstrip("-")
|
|
286
|
+
canonical = registry.resolve_name(name) or name
|
|
287
|
+
has_value = i + 1 < len(tokens) and not _is_option_token(tokens[i + 1])
|
|
288
|
+
|
|
289
|
+
if registry.is_boolean_flag(name):
|
|
290
|
+
# Bare boolean option means true; a value was paired by _split_args.
|
|
291
|
+
if has_value:
|
|
292
|
+
self._set_option(raw, canonical, tokens[i + 1], registry)
|
|
293
|
+
i += 2
|
|
294
|
+
else:
|
|
295
|
+
raw[canonical] = True
|
|
296
|
+
i += 1
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
if has_value:
|
|
300
|
+
self._set_option(raw, canonical, tokens[i + 1], registry)
|
|
301
|
+
i += 2
|
|
302
|
+
elif registry.get_declaration(name) is not None:
|
|
303
|
+
raise ParseError(f"Option --{canonical} requires a value")
|
|
304
|
+
else:
|
|
305
|
+
raise ParseError(f"Unknown option: --{name}")
|
|
306
|
+
|
|
307
|
+
return raw
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _misplaced_switch_message(
|
|
311
|
+
first_tokens: list[str],
|
|
312
|
+
second_tokens: list[str],
|
|
313
|
+
namespace: Namespace,
|
|
314
|
+
command_name: str,
|
|
315
|
+
registry: OptionRegistry,
|
|
316
|
+
) -> str:
|
|
317
|
+
"""Name the switch written before its command name, and the working form.
|
|
318
|
+
|
|
319
|
+
The two splits list the same option tokens up to the word the first
|
|
320
|
+
split let an option take and the second did not; that option is the
|
|
321
|
+
switch. Both lists come from ``_split_args``, which places a taken
|
|
322
|
+
word right after its option.
|
|
323
|
+
"""
|
|
324
|
+
index = next(
|
|
325
|
+
(
|
|
326
|
+
i
|
|
327
|
+
for i in range(len(first_tokens))
|
|
328
|
+
if i >= len(second_tokens) or first_tokens[i] != second_tokens[i]
|
|
329
|
+
),
|
|
330
|
+
len(first_tokens),
|
|
331
|
+
)
|
|
332
|
+
switch = first_tokens[index - 1] if index > 0 else ""
|
|
333
|
+
assert _is_option_token(switch), "splits diverged without an option taking a word"
|
|
334
|
+
name = switch.lstrip("-")
|
|
335
|
+
canonical = registry.resolve_name(name) or name
|
|
336
|
+
display = f"/{namespace.path} {command_name}" if namespace.path else command_name
|
|
337
|
+
return (
|
|
338
|
+
f"Option --{canonical} belongs to {display}; put it after the "
|
|
339
|
+
f"command name: kd {display} ... --{canonical}"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _set_option(raw: dict[str, Any], name: str, value: str, registry: OptionRegistry) -> None:
|
|
344
|
+
"""Set an option value, appending to a list for repeatable options."""
|
|
345
|
+
if registry.is_list_option(name):
|
|
346
|
+
raw.setdefault(name, []).append(value)
|
|
347
|
+
else:
|
|
348
|
+
raw[name] = value
|
|
@@ -15,6 +15,19 @@ from dataclasses import dataclass, field
|
|
|
15
15
|
from enum import Enum
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
|
+
# Words a boolean option accepts as its value. The ``=`` form
|
|
19
|
+
# (``--flag=no``) reads all six. The space form (``--flag false``) reads
|
|
20
|
+
# only the two documented literals: there the next token may be a
|
|
21
|
+
# positional instead (an INDEX of ``1``, prose that starts with "no").
|
|
22
|
+
_BOOL_TRUE_WORDS = ("true", "1", "yes")
|
|
23
|
+
_BOOL_FALSE_WORDS = ("false", "0", "no")
|
|
24
|
+
_SPACED_BOOL_WORDS = ("true", "false")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_spaced_bool_word(token: str) -> bool:
|
|
28
|
+
"""Whether a boolean option takes *token*, the next argv token, as its value."""
|
|
29
|
+
return token.lower() in _SPACED_BOOL_WORDS
|
|
30
|
+
|
|
18
31
|
|
|
19
32
|
class OptionLayer(Enum):
|
|
20
33
|
"""Option layers — separates options by concern."""
|
|
@@ -92,7 +105,7 @@ class OptionRegistry:
|
|
|
92
105
|
return self._short_names.get(name) or self._aliases.get(name)
|
|
93
106
|
|
|
94
107
|
def is_boolean_flag(self, name: str) -> bool:
|
|
95
|
-
"""Check if an option is
|
|
108
|
+
"""Check if an option is boolean: true when bare, or set by a following true/false."""
|
|
96
109
|
decl = self.get_declaration(name)
|
|
97
110
|
return decl is not None and decl.type is bool
|
|
98
111
|
|
|
@@ -144,9 +157,9 @@ class OptionRegistry:
|
|
|
144
157
|
if isinstance(value, bool):
|
|
145
158
|
return value
|
|
146
159
|
if isinstance(value, str):
|
|
147
|
-
if value.lower() in
|
|
160
|
+
if value.lower() in _BOOL_TRUE_WORDS:
|
|
148
161
|
return True
|
|
149
|
-
if value.lower() in
|
|
162
|
+
if value.lower() in _BOOL_FALSE_WORDS:
|
|
150
163
|
return False
|
|
151
164
|
raise OptionError(f"Invalid boolean value for --{decl.name}: {value}")
|
|
152
165
|
|
|
@@ -249,9 +262,11 @@ def state_option(
|
|
|
249
262
|
# Standard OUTPUT options available to all commands.
|
|
250
263
|
#
|
|
251
264
|
# ``--quiet`` is registered globally so any command's parser accepts it,
|
|
252
|
-
# but it only affects
|
|
253
|
-
#
|
|
254
|
-
#
|
|
265
|
+
# but it only affects writes: a write on the write-output contract
|
|
266
|
+
# (``display_hint="mutation"``) prints only its ``ok:`` line, and a write
|
|
267
|
+
# outside it (``display_hint="write"``) has none, so it prints nothing. On
|
|
268
|
+
# reads and structured output modes (``-o yaml`` / ``-o json``),
|
|
269
|
+
# ``--quiet`` is silently no-op.
|
|
255
270
|
OUTPUT_OPTIONS: list[OptionDeclaration] = [
|
|
256
271
|
OptionDeclaration(
|
|
257
272
|
name="output",
|
|
@@ -274,8 +289,9 @@ OUTPUT_OPTIONS: list[OptionDeclaration] = [
|
|
|
274
289
|
type=bool,
|
|
275
290
|
default=False,
|
|
276
291
|
help_text=(
|
|
277
|
-
"
|
|
278
|
-
"
|
|
292
|
+
"Print only the ok: line of a write; a write without one (the /primer "
|
|
293
|
+
"writes, kd init, /system/config set, /system restore, /tag add) prints "
|
|
294
|
+
"nothing. No-op on reads and structured modes (-o yaml/json)."
|
|
279
295
|
),
|
|
280
296
|
),
|
|
281
297
|
]
|