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.
Files changed (64) hide show
  1. kd/__init__.py +45 -0
  2. kd/api/__init__.py +15 -0
  3. kd/api/articles.py +232 -0
  4. kd/api/http.py +234 -0
  5. kd/api/hub.py +69 -0
  6. kd/api/saved_queries.py +76 -0
  7. kd/api/tags.py +107 -0
  8. kd/api/youtrack.py +414 -0
  9. kd/article_marker.py +348 -0
  10. kd/auth.py +96 -0
  11. kd/cli/__init__.py +5 -0
  12. kd/cli/core/__init__.py +25 -0
  13. kd/cli/core/command_base.py +133 -0
  14. kd/cli/core/command_parser.py +246 -0
  15. kd/cli/core/namespaces.py +175 -0
  16. kd/cli/core/option_registry.py +265 -0
  17. kd/cli/input.py +42 -0
  18. kd/cli/main.py +612 -0
  19. kd/cli/namespaces/__init__.py +38 -0
  20. kd/cli/namespaces/alias/__init__.py +87 -0
  21. kd/cli/namespaces/article/__init__.py +843 -0
  22. kd/cli/namespaces/init.py +42 -0
  23. kd/cli/namespaces/issue/__init__.py +959 -0
  24. kd/cli/namespaces/primer/__init__.py +337 -0
  25. kd/cli/namespaces/project/__init__.py +243 -0
  26. kd/cli/namespaces/query/__init__.py +465 -0
  27. kd/cli/namespaces/search/__init__.py +82 -0
  28. kd/cli/namespaces/system/__init__.py +528 -0
  29. kd/cli/namespaces/tag/__init__.py +337 -0
  30. kd/cli/namespaces/time/__init__.py +307 -0
  31. kd/cli/namespaces/user/__init__.py +99 -0
  32. kd/cli/output.py +287 -0
  33. kd/cli/tree.py +65 -0
  34. kd/client.py +1953 -0
  35. kd/config.py +853 -0
  36. kd/connection.py +156 -0
  37. kd/debug.py +107 -0
  38. kd/exceptions.py +189 -0
  39. kd/history.py +169 -0
  40. kd/models/__init__.py +75 -0
  41. kd/models/article.py +64 -0
  42. kd/models/assist.py +47 -0
  43. kd/models/attachment.py +20 -0
  44. kd/models/comment.py +25 -0
  45. kd/models/common.py +39 -0
  46. kd/models/issue.py +67 -0
  47. kd/models/project.py +60 -0
  48. kd/models/saved_query.py +54 -0
  49. kd/models/search.py +20 -0
  50. kd/models/tag.py +30 -0
  51. kd/models/user.py +42 -0
  52. kd/models/work_item.py +59 -0
  53. kd/primer.py +231 -0
  54. kd/query_parser.py +181 -0
  55. kd/resources/__init__.py +1 -0
  56. kd/resources/command-synonyms.yaml +19 -0
  57. kd/resources/config-template.yaml +71 -0
  58. kd/resources/error-messages.yaml +101 -0
  59. kd_cli-2.0.4.dist-info/METADATA +149 -0
  60. kd_cli-2.0.4.dist-info/RECORD +64 -0
  61. kd_cli-2.0.4.dist-info/WHEEL +5 -0
  62. kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
  63. kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
  64. kd_cli-2.0.4.dist-info/top_level.txt +1 -0
kd/connection.py ADDED
@@ -0,0 +1,156 @@
1
+ """Named connection configuration and runtime resolution.
2
+
3
+ A connection represents a complete working context: URL, auth source,
4
+ defaults, and compatibility profile for a single YouTrack instance.
5
+
6
+ Config-level models (:class:`ConnectionConfig`, :class:`CompatibilityConfig`)
7
+ map directly to YAML. :class:`ConnectionContext` is the frozen runtime object
8
+ that commands and the SDK receive after token resolution and override merging.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any, Literal
15
+
16
+ from pydantic import BaseModel, model_validator
17
+
18
+ from kd.auth import resolve_connection_token
19
+ from kd.exceptions import ConfigError
20
+
21
+ if TYPE_CHECKING:
22
+ from kd.debug import DebugLog
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Config models (YAML ↔ Pydantic)
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ class CompatibilityConfig(BaseModel):
31
+ """Per-connection compatibility settings.
32
+
33
+ Only ``pinned`` mode in M031. ``probe`` mode arrives in M033.
34
+ """
35
+
36
+ target: str = "auto"
37
+ mode: Literal["pinned"] = "pinned"
38
+
39
+
40
+ class ConnectionConfig(BaseModel):
41
+ """One named connection in the ``connections:`` config section."""
42
+
43
+ provider: str = "youtrack"
44
+ url: str
45
+ token_cmd: str | None = None
46
+ token_env: str | None = None
47
+ default_project: str | None = None
48
+ output: str | None = None
49
+ limit: int | None = None
50
+ defaults: dict[str, dict[str, Any]] = {}
51
+ compatibility: CompatibilityConfig = CompatibilityConfig()
52
+
53
+ @model_validator(mode="after")
54
+ def _require_auth_source(self) -> ConnectionConfig:
55
+ if not self.token_cmd and not self.token_env:
56
+ msg = (
57
+ "Connection must specify at least one of 'token_cmd' or 'token_env'. "
58
+ "Secrets are never stored as raw values in config."
59
+ )
60
+ raise ValueError(msg)
61
+ return self
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Runtime context (frozen, post-resolution)
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class ConnectionContext:
71
+ """Resolved runtime state for the active connection.
72
+
73
+ Created by :func:`resolve_connection` after token resolution and
74
+ override merging. Commands receive this — never raw config models.
75
+ """
76
+
77
+ name: str
78
+ provider: str
79
+ base_url: str
80
+ token: str
81
+ default_project: str | None
82
+ output: str | None
83
+ limit: int | None
84
+ defaults: dict[str, dict[str, Any]]
85
+ compat_tier: str
86
+ capabilities: frozenset[str] # empty in M031, populated in M033
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Connection resolution
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def resolve_connection(
95
+ connections: dict[str, ConnectionConfig],
96
+ default_connection: str | None,
97
+ *,
98
+ debug_log: DebugLog | None = None,
99
+ ) -> ConnectionContext:
100
+ """Resolve the active connection into a :class:`ConnectionContext`.
101
+
102
+ Parameters
103
+ ----------
104
+ connections:
105
+ All parsed ``ConnectionConfig`` objects keyed by name.
106
+ default_connection:
107
+ Name of the connection to resolve (from ``default_connection`` config key).
108
+ debug_log:
109
+ Optional debug logger.
110
+ """
111
+ if not connections:
112
+ raise ConfigError(
113
+ "No connections configured. Add a 'connections:' section to your config "
114
+ "(run 'kd init' to scaffold one)."
115
+ )
116
+
117
+ if not default_connection:
118
+ if len(connections) == 1:
119
+ default_connection = next(iter(connections))
120
+ else:
121
+ names = ", ".join(sorted(connections))
122
+ raise ConfigError(
123
+ f"No default_connection set and multiple connections exist ({names}). "
124
+ "Use -c <name> or set 'default_connection' in your config."
125
+ )
126
+
127
+ if default_connection not in connections:
128
+ names = ", ".join(sorted(connections))
129
+ raise ConfigError(
130
+ f"Connection '{default_connection}' not found. Available connections: {names}. "
131
+ "Use -c <name> or set 'default_connection' in your config."
132
+ )
133
+
134
+ conn = connections[default_connection]
135
+
136
+ if debug_log:
137
+ debug_log.info("connection", f"resolving connection '{default_connection}'")
138
+
139
+ token = resolve_connection_token(conn, debug_log=debug_log)
140
+
141
+ if debug_log:
142
+ debug_log.info("connection", f"url={conn.url}")
143
+ debug_log.info("connection", f"compat_tier={conn.compatibility.target}")
144
+
145
+ return ConnectionContext(
146
+ name=default_connection,
147
+ provider=conn.provider,
148
+ base_url=conn.url,
149
+ token=token,
150
+ default_project=conn.default_project,
151
+ output=conn.output,
152
+ limit=conn.limit,
153
+ defaults=conn.defaults,
154
+ compat_tier=conn.compatibility.target,
155
+ capabilities=frozenset(),
156
+ )
kd/debug.py ADDED
@@ -0,0 +1,107 @@
1
+ """Progressive debug logging.
2
+
3
+ Three verbosity levels via stacking ``-D`` flags:
4
+
5
+ - ``-D`` (info): high-level flow — config source, API status, item count
6
+ - ``-DD`` (debug): request details, config chain, retries, elapsed timestamps
7
+ - ``-DDD`` (trace): full bodies, headers, per-source walk, correlation ID
8
+
9
+ All output goes to **stderr** so stdout remains pipeable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ import time
16
+ import uuid
17
+ from enum import IntEnum
18
+
19
+
20
+ class DebugLevel(IntEnum):
21
+ """Verbosity levels for diagnostic output."""
22
+
23
+ SILENT = 0
24
+ INFO = 1
25
+ DEBUG = 2
26
+ TRACE = 3
27
+
28
+
29
+ class DebugLog:
30
+ """Component-tagged diagnostic logger writing to stderr.
31
+
32
+ Create once per invocation and thread through the call stack
33
+ or retrieve via :func:`get_debug_log`.
34
+ """
35
+
36
+ def __init__(self, level: DebugLevel = DebugLevel.SILENT) -> None:
37
+ self._level = level
38
+ self._start = time.monotonic()
39
+ self._correlation_id = uuid.uuid4().hex[:8]
40
+
41
+ @property
42
+ def level(self) -> DebugLevel:
43
+ """Current verbosity level (read-only)."""
44
+ return self._level
45
+
46
+ def info(self, component: str, message: str) -> None:
47
+ """Emit at level 1 (info) — high-level flow."""
48
+ if self._level >= DebugLevel.INFO:
49
+ self._emit("info", component, message)
50
+
51
+ def debug(self, component: str, message: str) -> None:
52
+ """Emit at level 2 (debug) — request details, config chain."""
53
+ if self._level >= DebugLevel.DEBUG:
54
+ self._emit("debug", component, message)
55
+
56
+ def trace(self, component: str, message: str) -> None:
57
+ """Emit at level 3 (trace) — full bodies, headers, walk."""
58
+ if self._level >= DebugLevel.TRACE:
59
+ self._emit("trace", component, message)
60
+
61
+ def _emit(self, level: str, component: str, message: str) -> None:
62
+ parts: list[str] = [f"[{level:<5}]", f"({component})"]
63
+ if self._level >= DebugLevel.TRACE:
64
+ parts.append(f"[{self._correlation_id}]")
65
+ if self._level >= DebugLevel.DEBUG:
66
+ elapsed = time.monotonic() - self._start
67
+ parts.append(f"+{elapsed:.1f}s")
68
+ parts.append(message)
69
+ print(" ".join(parts), file=sys.stderr)
70
+
71
+
72
+ def redact_token(token: str) -> str:
73
+ """Redact an API token for debug display.
74
+
75
+ Shows the first 8 characters followed by ``***``.
76
+ Short or empty tokens become just ``***``.
77
+ """
78
+ if len(token) >= 8:
79
+ return token[:8] + "***"
80
+ return "***"
81
+
82
+
83
+ # ── Module-level singleton ──────────────────────────────────────────
84
+
85
+ _log: DebugLog | None = None
86
+
87
+
88
+ def init_debug_log(level: DebugLevel) -> DebugLog:
89
+ """Create and store the module-level :class:`DebugLog`.
90
+
91
+ Called once from ``main()`` after parsing the ``-D`` flags.
92
+ Returns the new instance for explicit threading.
93
+ """
94
+ global _log # noqa: PLW0603
95
+ _log = DebugLog(level)
96
+ return _log
97
+
98
+
99
+ def get_debug_log() -> DebugLog:
100
+ """Return the module-level :class:`DebugLog`.
101
+
102
+ Returns a silent (level 0) instance when :func:`init_debug_log`
103
+ has not been called — callers never need to guard against ``None``.
104
+ """
105
+ if _log is not None:
106
+ return _log
107
+ return DebugLog()
kd/exceptions.py ADDED
@@ -0,0 +1,189 @@
1
+ """Exception hierarchy for kd.
2
+
3
+ All kd exceptions inherit from KdError. API-layer exceptions
4
+ carry the HTTP status code and response body for debugging.
5
+
6
+ Error messages from the YouTrack API are rewritten into user-friendly
7
+ text using regex mappings loaded from ``resources/error-messages.yaml``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.resources
13
+ import re
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+
19
+ class KdError(Exception):
20
+ """Base exception for all kd errors."""
21
+
22
+
23
+ class AuthError(KdError):
24
+ """Token missing, invalid, or expired."""
25
+
26
+
27
+ class ConfigError(KdError):
28
+ """Config file missing, malformed, or incomplete."""
29
+
30
+
31
+ class APIError(KdError):
32
+ """HTTP error from YouTrack or Hub API."""
33
+
34
+ def __init__(
35
+ self,
36
+ status_code: int,
37
+ message: str,
38
+ response_body: str = "",
39
+ suggestion: str | None = None,
40
+ ) -> None:
41
+ self.status_code = status_code
42
+ self.response_body = response_body
43
+ self.suggestion = suggestion
44
+ super().__init__(message)
45
+
46
+
47
+ class NotFoundError(APIError):
48
+ """404 — entity does not exist."""
49
+
50
+ def __init__(
51
+ self,
52
+ message: str,
53
+ response_body: str = "",
54
+ suggestion: str | None = None,
55
+ ) -> None:
56
+ super().__init__(404, message, response_body, suggestion)
57
+
58
+
59
+ class ConflictError(APIError):
60
+ """409 — duplicate project key, etc."""
61
+
62
+ def __init__(
63
+ self,
64
+ message: str,
65
+ response_body: str = "",
66
+ suggestion: str | None = None,
67
+ ) -> None:
68
+ super().__init__(409, message, response_body, suggestion)
69
+
70
+
71
+ class PermissionDeniedError(APIError):
72
+ """403 — insufficient permissions for the requested operation."""
73
+
74
+ def __init__(
75
+ self,
76
+ message: str,
77
+ response_body: str = "",
78
+ suggestion: str | None = None,
79
+ ) -> None:
80
+ super().__init__(403, message, response_body, suggestion)
81
+
82
+
83
+ class ValidationError(KdError):
84
+ """Invalid input before API call."""
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Error message enrichment
89
+ # ---------------------------------------------------------------------------
90
+
91
+ _ErrorMapping = dict[str, Any]
92
+ _error_mappings: list[_ErrorMapping] | None = None
93
+
94
+
95
+ def _load_mappings() -> list[_ErrorMapping]:
96
+ """Load and compile error message mappings from the resource file.
97
+
98
+ Each entry supports three optional filters — ``status``, ``path``,
99
+ and ``pattern`` — plus a required ``message`` and an optional
100
+ ``suggestion``. All present filters must match for the rule to apply.
101
+ """
102
+ global _error_mappings # noqa: PLW0603
103
+ if _error_mappings is not None:
104
+ return _error_mappings
105
+
106
+ raw = (
107
+ importlib.resources.files("kd.resources")
108
+ .joinpath("error-messages.yaml")
109
+ .read_text(encoding="utf-8")
110
+ )
111
+ entries: list[dict[str, Any]] = yaml.safe_load(raw) or []
112
+ compiled: list[_ErrorMapping] = []
113
+ for e in entries:
114
+ compiled.append(
115
+ {
116
+ "status": e.get("status"),
117
+ "path_re": re.compile(e["path"]) if e.get("path") else None,
118
+ "msg_re": re.compile(e["pattern"]) if e.get("pattern") else None,
119
+ "message": e["message"],
120
+ "suggestion": e.get("suggestion", ""),
121
+ }
122
+ )
123
+ _error_mappings = compiled
124
+ return _error_mappings
125
+
126
+
127
+ def _substitute(
128
+ template: str,
129
+ named: dict[str, str],
130
+ positional: tuple[str | None, ...],
131
+ ) -> str:
132
+ """Replace ``{name}`` and ``{1}..{N}`` placeholders in *template*.
133
+
134
+ Unmatched optional groups (``None``) are skipped.
135
+ """
136
+ out = template
137
+ for key, named_val in named.items():
138
+ out = out.replace(f"{{{key}}}", named_val)
139
+ for i, pos_val in enumerate(positional, 1):
140
+ if pos_val is not None:
141
+ out = out.replace(f"{{{i}}}", pos_val)
142
+ return out
143
+
144
+
145
+ def enrich_error(
146
+ message: str,
147
+ *,
148
+ status: int | None = None,
149
+ path: str | None = None,
150
+ ) -> tuple[str, str | None]:
151
+ """Match *message* against known API error patterns.
152
+
153
+ Rules may filter on HTTP ``status``, request URL ``path``, and/or
154
+ the extracted response ``message`` text. All present filters must
155
+ match. The first matching rule wins.
156
+
157
+ Returns ``(rewritten_message, suggestion)`` if a rule matches,
158
+ or ``(message, None)`` otherwise.
159
+ """
160
+ for mapping in _load_mappings():
161
+ if mapping["status"] is not None and mapping["status"] != status:
162
+ continue
163
+
164
+ path_match: re.Match[str] | None = None
165
+ if mapping["path_re"] is not None:
166
+ if path is None:
167
+ continue
168
+ path_match = mapping["path_re"].search(path)
169
+ if path_match is None:
170
+ continue
171
+
172
+ msg_match: re.Match[str] | None = None
173
+ if mapping["msg_re"] is not None:
174
+ msg_match = mapping["msg_re"].search(message)
175
+ if msg_match is None:
176
+ continue
177
+
178
+ named: dict[str, str] = {}
179
+ if path_match is not None:
180
+ named.update({k: v for k, v in path_match.groupdict().items() if v is not None})
181
+ if msg_match is not None:
182
+ named.update({k: v for k, v in msg_match.groupdict().items() if v is not None})
183
+ positional = msg_match.groups() if msg_match is not None else ()
184
+
185
+ rewritten = _substitute(mapping["message"], named, positional)
186
+ suggestion_tmpl = mapping["suggestion"] or None
187
+ suggestion = _substitute(suggestion_tmpl, named, positional) if suggestion_tmpl else None
188
+ return rewritten, suggestion
189
+ return message, None
kd/history.py ADDED
@@ -0,0 +1,169 @@
1
+ """Copy-on-write history for config and primer files.
2
+
3
+ Provides automatic snapshots before every mutation, retention pruning,
4
+ atomic file writes, and diff utilities. Snapshot files are stored in a
5
+ ``history/`` subdirectory next to the original file.
6
+
7
+ Phase 1 — local snapshots only. Git auto-commit is deferred to Phase 2.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import logging
14
+ import os
15
+ import shutil
16
+ import tempfile
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import NamedTuple
20
+
21
+ from kd.exceptions import ConfigError, KdError
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ DEFAULT_RETENTION = 20
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Atomic writes
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def atomic_write_text(path: Path, text: str) -> None:
34
+ """Write *text* to *path* atomically.
35
+
36
+ Writes to a temp file in the same directory, then ``os.replace``
37
+ to swap. Either the new content is fully committed or the old
38
+ content is fully intact.
39
+ """
40
+ dir_ = str(path.parent) if str(path.parent) else "."
41
+ fd, tmp = tempfile.mkstemp(dir=dir_, prefix=f".{path.name}.", suffix=".tmp")
42
+ try:
43
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
44
+ f.write(text)
45
+ os.replace(tmp, path)
46
+ except OSError as e:
47
+ try:
48
+ os.unlink(tmp)
49
+ except OSError:
50
+ pass
51
+ raise KdError(f"Cannot write to {path}: {e}") from e
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Timestamp helpers
56
+ # ---------------------------------------------------------------------------
57
+
58
+ _TS_FORMAT = "%Y-%m-%dT%H-%M-%S"
59
+
60
+
61
+ def _timestamp() -> str:
62
+ """Return a filesystem-safe ISO 8601 timestamp (UTC, second precision)."""
63
+ return datetime.now(timezone.utc).strftime(_TS_FORMAT)
64
+
65
+
66
+ def _parse_timestamp(ts: str) -> datetime:
67
+ """Parse a filesystem-safe timestamp back to datetime."""
68
+ return datetime.strptime(ts, _TS_FORMAT).replace(tzinfo=timezone.utc)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Snapshot operations
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def snapshot_before_write(path: Path, *, retention: int = DEFAULT_RETENTION) -> Path | None:
77
+ """Copy *path* to ``<parent>/history/<name>.<timestamp>`` before mutation.
78
+
79
+ Returns the snapshot path, or ``None`` if the file does not exist yet
80
+ (first write — nothing to snapshot). Prunes oldest snapshots beyond
81
+ *retention*. Best-effort: failures log a warning but do not block the
82
+ caller's write.
83
+ """
84
+ if not path.exists():
85
+ return None
86
+ try:
87
+ history_dir = path.parent / "history"
88
+ history_dir.mkdir(parents=True, exist_ok=True)
89
+ snapshot_name = f"{path.name}.{_timestamp()}"
90
+ snapshot_path = history_dir / snapshot_name
91
+ shutil.copy2(path, snapshot_path)
92
+ _prune_snapshots(history_dir, path.name, retention)
93
+ return snapshot_path
94
+ except OSError as e:
95
+ log.warning("Failed to snapshot %s: %s", path, e)
96
+ return None
97
+
98
+
99
+ def _prune_snapshots(history_dir: Path, filename: str, retention: int) -> None:
100
+ """Remove oldest snapshots of *filename* beyond *retention* count."""
101
+ snapshots = _sorted_snapshots(history_dir, filename)
102
+ for info in snapshots[retention:]:
103
+ try:
104
+ info.path.unlink()
105
+ except OSError:
106
+ pass
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Listing and lookup
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ class SnapshotInfo(NamedTuple):
115
+ """Metadata for a single snapshot file."""
116
+
117
+ path: Path
118
+ timestamp: str
119
+ size: int
120
+
121
+
122
+ def list_snapshots(history_dir: Path, filename: str) -> list[SnapshotInfo]:
123
+ """Return snapshots for *filename*, newest first.
124
+
125
+ Returns an empty list if the history directory does not exist.
126
+ """
127
+ if not history_dir.is_dir():
128
+ return []
129
+ return _sorted_snapshots(history_dir, filename)
130
+
131
+
132
+ def _sorted_snapshots(history_dir: Path, filename: str) -> list[SnapshotInfo]:
133
+ """Glob and sort snapshots for *filename*, newest first."""
134
+ prefix = f"{filename}."
135
+ results: list[SnapshotInfo] = []
136
+ for entry in history_dir.iterdir():
137
+ if entry.name.startswith(prefix) and entry.is_file():
138
+ ts = entry.name[len(prefix) :]
139
+ try:
140
+ _parse_timestamp(ts)
141
+ except ValueError:
142
+ continue # skip files that don't match the timestamp format
143
+ results.append(SnapshotInfo(path=entry, timestamp=ts, size=entry.stat().st_size))
144
+ results.sort(key=lambda s: s.timestamp, reverse=True)
145
+ return results
146
+
147
+
148
+ def get_snapshot_path(history_dir: Path, filename: str, timestamp: str) -> Path:
149
+ """Resolve a snapshot by timestamp, or raise ``ConfigError``."""
150
+ path = history_dir / f"{filename}.{timestamp}"
151
+ if not path.is_file():
152
+ raise ConfigError(f"Snapshot not found: {path.name}")
153
+ return path
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Diff
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ def diff_text(text_a: str, text_b: str, label_a: str, label_b: str) -> str:
162
+ """Return a unified diff between two text strings.
163
+
164
+ Returns an empty string when the texts are identical.
165
+ """
166
+ lines_a = text_a.splitlines(keepends=True)
167
+ lines_b = text_b.splitlines(keepends=True)
168
+ diff = difflib.unified_diff(lines_a, lines_b, fromfile=label_a, tofile=label_b)
169
+ return "".join(diff)
kd/models/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """Pydantic models for YouTrack entities.
2
+
3
+ All models use field aliases to map between Python snake_case
4
+ and YouTrack's camelCase API responses.
5
+ """
6
+
7
+ from kd.models.article import Article, ArticleCreate, ArticleRef, ArticleUpdate
8
+ from kd.models.assist import AssistResponse, AssistSuggestion
9
+ from kd.models.attachment import Attachment
10
+ from kd.models.comment import Comment, CommentCreate
11
+ from kd.models.common import FieldSet, IdEntity, NamedEntity
12
+ from kd.models.issue import CustomField, Issue, IssueCreate, IssueUpdate
13
+ from kd.models.project import (
14
+ BundleValue,
15
+ Project,
16
+ ProjectCreate,
17
+ ProjectCustomField,
18
+ TimeTrackingSettings,
19
+ )
20
+ from kd.models.saved_query import (
21
+ QueryValidationResult,
22
+ SavedQuery,
23
+ SavedQueryCreate,
24
+ SavedQueryUpdate,
25
+ )
26
+ from kd.models.search import SearchResult
27
+ from kd.models.tag import Tag, TagColor
28
+ from kd.models.user import Group, ProjectTeam, User, UserCreate
29
+ from kd.models.work_item import (
30
+ Duration,
31
+ WorkItem,
32
+ WorkItemCreate,
33
+ WorkItemType,
34
+ WorkItemUpdate,
35
+ )
36
+
37
+ __all__ = [
38
+ "Article",
39
+ "ArticleCreate",
40
+ "ArticleRef",
41
+ "ArticleUpdate",
42
+ "AssistResponse",
43
+ "AssistSuggestion",
44
+ "Attachment",
45
+ "BundleValue",
46
+ "Comment",
47
+ "CommentCreate",
48
+ "CustomField",
49
+ "Duration",
50
+ "FieldSet",
51
+ "Group",
52
+ "IdEntity",
53
+ "NamedEntity",
54
+ "Issue",
55
+ "IssueCreate",
56
+ "IssueUpdate",
57
+ "QueryValidationResult",
58
+ "Project",
59
+ "ProjectCreate",
60
+ "ProjectCustomField",
61
+ "ProjectTeam",
62
+ "SavedQuery",
63
+ "SavedQueryCreate",
64
+ "SavedQueryUpdate",
65
+ "SearchResult",
66
+ "Tag",
67
+ "TagColor",
68
+ "TimeTrackingSettings",
69
+ "User",
70
+ "UserCreate",
71
+ "WorkItem",
72
+ "WorkItemCreate",
73
+ "WorkItemType",
74
+ "WorkItemUpdate",
75
+ ]