kd-cli 2.6.2__tar.gz → 2.6.3__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.6.2/kd_cli.egg-info → kd_cli-2.6.3}/PKG-INFO +2 -1
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/youtrack.py +87 -4
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cache.py +61 -43
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/main.py +115 -1
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/system/__init__.py +1 -0
- kd_cli-2.6.3/kd/locking.py +68 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/CHANGELOG.md +65 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/operating-guide.md +26 -1
- {kd_cli-2.6.2 → kd_cli-2.6.3/kd_cli.egg-info}/PKG-INFO +2 -1
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/requires.txt +1 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/pyproject.toml +2 -1
- kd_cli-2.6.2/kd/locking.py +0 -97
- {kd_cli-2.6.2 → kd_cli-2.6.3}/LICENSE +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/MANIFEST.in +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/README.md +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/articles.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/http.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/hub.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/tags.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/article_marker.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/auth.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/input.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/cache/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/output.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/tree.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/client.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/config.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/config_read.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/config_schema.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/connection.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/debug.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/exceptions.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/history.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/article.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/assist.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/attachment.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/comment.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/common.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/issue.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/project.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/saved_query.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/search.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/tag.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/user.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/work_item.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/operating_guide.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/primer.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/query_parser.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/release_notes.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/sdlc-doctrine.md +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/sdlc.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kd-cli
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.3
|
|
4
4
|
Summary: Knowledge Dispatcher for issue trackers and knowledge bases
|
|
5
5
|
Author: Sergiy Gladkyy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,7 @@ Classifier: Topic :: Utilities
|
|
|
22
22
|
Requires-Python: >=3.11
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
+
Requires-Dist: filelock>=3.12
|
|
25
26
|
Requires-Dist: httpx>=0.27
|
|
26
27
|
Requires-Dist: packaging>=23.0
|
|
27
28
|
Requires-Dist: pydantic>=2.0
|
|
@@ -16,9 +16,11 @@ Uses HttpClient for transport and Pydantic models for serialization.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import re
|
|
19
20
|
from typing import Any
|
|
20
21
|
|
|
21
22
|
from kd.api.http import HttpClient
|
|
23
|
+
from kd.exceptions import KdError
|
|
22
24
|
from kd.models import (
|
|
23
25
|
AssistResponse,
|
|
24
26
|
Attachment,
|
|
@@ -169,6 +171,24 @@ _PROJECT_CUSTOM_FIELDS_PARAM = (
|
|
|
169
171
|
"canBeEmpty,emptyFieldText"
|
|
170
172
|
")"
|
|
171
173
|
)
|
|
174
|
+
# Per-issue projection that surfaces the same projectCustomField shape
|
|
175
|
+
# /admin/projects/{id} returns, but readable by any token with Read Issue.
|
|
176
|
+
# See KDCLI-A-22 for the YouTrack permission-tier rationale.
|
|
177
|
+
_PROJECT_FIELD_VIA_ISSUE_PARAM = (
|
|
178
|
+
"id,idReadable,"
|
|
179
|
+
"customFields("
|
|
180
|
+
"projectCustomField("
|
|
181
|
+
"field(name,fieldType(id)),"
|
|
182
|
+
"$type,"
|
|
183
|
+
"bundle(values(name,ordinal,isResolved)),"
|
|
184
|
+
"canBeEmpty,emptyFieldText"
|
|
185
|
+
")"
|
|
186
|
+
")"
|
|
187
|
+
)
|
|
188
|
+
# YouTrack internal-id format: "<entity-class>-<seq>", e.g. "0-9". Project
|
|
189
|
+
# short-names (KDCLI, VTX, FB) are alphabetic and never match this shape,
|
|
190
|
+
# so a regex match is a reliable "this is an id, not a key" signal.
|
|
191
|
+
_PROJECT_INTERNAL_ID_RE = re.compile(r"\d+-\d+")
|
|
172
192
|
|
|
173
193
|
|
|
174
194
|
class YouTrackAPI:
|
|
@@ -203,13 +223,76 @@ class YouTrackAPI:
|
|
|
203
223
|
self._http.delete(f"/admin/projects/{project_id}")
|
|
204
224
|
|
|
205
225
|
def list_project_custom_fields(self, project_id: str) -> list[dict[str, Any]]:
|
|
206
|
-
"""Fetch
|
|
207
|
-
|
|
226
|
+
"""Fetch a project's custom-field schema (name, type, bundle values).
|
|
227
|
+
|
|
228
|
+
Queries the per-issue ``projectCustomField`` projection so the call
|
|
229
|
+
works for any token with Read Issue, not just project admins. Falls
|
|
230
|
+
back to ``/admin/projects/{id}`` only when the project has no
|
|
231
|
+
issues to sample from. See KDCLI-A-22 for the permission-tier
|
|
232
|
+
rationale.
|
|
233
|
+
|
|
234
|
+
Accepts either the project short-name ("KDCLI") or the internal id
|
|
235
|
+
("0-9"); the search-query DSL needs the short-name, so internal
|
|
236
|
+
ids are resolved via a cheap shortName lookup that any contributor
|
|
237
|
+
token can make.
|
|
238
|
+
"""
|
|
239
|
+
short_name = self._resolve_project_short_name(project_id)
|
|
240
|
+
issues: list[dict[str, Any]] = self._http.get(
|
|
241
|
+
"/issues",
|
|
242
|
+
fields=_PROJECT_FIELD_VIA_ISSUE_PARAM,
|
|
243
|
+
params={"query": f"project: {short_name}", "$top": 1},
|
|
244
|
+
)
|
|
245
|
+
if issues:
|
|
246
|
+
sample = issues[0]
|
|
247
|
+
return [
|
|
248
|
+
cf["projectCustomField"]
|
|
249
|
+
for cf in sample.get("customFields") or []
|
|
250
|
+
if cf.get("projectCustomField")
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
admin: dict[str, Any] = self._http.get(
|
|
208
254
|
f"/admin/projects/{project_id}",
|
|
209
255
|
fields=_PROJECT_CUSTOM_FIELDS_PARAM,
|
|
210
256
|
)
|
|
211
|
-
|
|
212
|
-
|
|
257
|
+
admin_fields: list[dict[str, Any]] = admin.get("customFields") or []
|
|
258
|
+
if admin_fields:
|
|
259
|
+
return admin_fields
|
|
260
|
+
raise KdError(
|
|
261
|
+
f"No custom fields discovered for project '{project_id}'.",
|
|
262
|
+
suggestion=(
|
|
263
|
+
"Either the project has no issues to sample from, or the "
|
|
264
|
+
"active token lacks Read Project Full on this project. "
|
|
265
|
+
"Create one issue (any contributor token works), or use a "
|
|
266
|
+
"token with project-administration rights. See KDCLI-A-22."
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _resolve_project_short_name(self, project_id: str) -> str:
|
|
271
|
+
"""Return a project short-name (key) usable in a YouTrack query.
|
|
272
|
+
|
|
273
|
+
Pass-through if ``project_id`` already looks like a key. If it
|
|
274
|
+
looks like an internal id (``\\d+-\\d+``), look up the short-name
|
|
275
|
+
via ``GET /admin/projects/{id}?fields=shortName`` — that endpoint
|
|
276
|
+
is readable by any token with Read Project Basic, so it works
|
|
277
|
+
for contributor tokens.
|
|
278
|
+
"""
|
|
279
|
+
if not _PROJECT_INTERNAL_ID_RE.fullmatch(project_id):
|
|
280
|
+
return project_id
|
|
281
|
+
meta: dict[str, Any] = self._http.get(
|
|
282
|
+
f"/admin/projects/{project_id}",
|
|
283
|
+
fields="shortName",
|
|
284
|
+
)
|
|
285
|
+
short_name = meta.get("shortName")
|
|
286
|
+
if not short_name:
|
|
287
|
+
raise KdError(
|
|
288
|
+
f"Could not resolve project '{project_id}' to a short name.",
|
|
289
|
+
suggestion=(
|
|
290
|
+
"Verify the project exists and the active token has "
|
|
291
|
+
"Read Project Basic. Try `kd /project list` to see "
|
|
292
|
+
"projects this token can read."
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
return str(short_name)
|
|
213
296
|
|
|
214
297
|
# --- Time Tracking ---
|
|
215
298
|
|
|
@@ -21,7 +21,7 @@ import dataclasses
|
|
|
21
21
|
import re
|
|
22
22
|
from dataclasses import dataclass, field
|
|
23
23
|
from datetime import datetime, timedelta, timezone
|
|
24
|
-
from pathlib import Path
|
|
24
|
+
from pathlib import Path, PurePath, PurePosixPath
|
|
25
25
|
from typing import Any, Literal
|
|
26
26
|
|
|
27
27
|
import yaml
|
|
@@ -597,6 +597,22 @@ class CacheScopeEnumeration:
|
|
|
597
597
|
# ---------------------------------------------------------------------------
|
|
598
598
|
|
|
599
599
|
|
|
600
|
+
def _report_path(value: Path | PurePath | str) -> str:
|
|
601
|
+
"""Normalize a filesystem path to POSIX form for structured-report fields.
|
|
602
|
+
|
|
603
|
+
Single chokepoint for every path-bearing field on the cache report
|
|
604
|
+
dataclasses (``HydratedIssue.path``, ``HydratedArticle.path``,
|
|
605
|
+
``EpicRefreshReport.manifest_path``, ``CacheStatusInfo.path``,
|
|
606
|
+
``CacheSearchMatch.path`` and siblings). The structured envelope of
|
|
607
|
+
``/cache refresh``, ``/cache clear``, ``/cache status``, and
|
|
608
|
+
``/cache search`` uses ``/`` separators on Windows and POSIX alike so
|
|
609
|
+
LLM consumers see a platform-stable shape.
|
|
610
|
+
"""
|
|
611
|
+
if isinstance(value, (Path, PurePath)):
|
|
612
|
+
return value.as_posix()
|
|
613
|
+
return PurePosixPath(value.replace("\\", "/")).as_posix()
|
|
614
|
+
|
|
615
|
+
|
|
600
616
|
def cache_dir(start: Path | None = None) -> Path:
|
|
601
617
|
"""Return the cache directory inside the nearest ``.kd/`` workspace.
|
|
602
618
|
|
|
@@ -1133,19 +1149,19 @@ def _clear_subtree(directory: Path, errors: list[ClearReportError]) -> int:
|
|
|
1133
1149
|
_remove_file(entry)
|
|
1134
1150
|
removed += 1
|
|
1135
1151
|
except OSError as e:
|
|
1136
|
-
errors.append(ClearReportError(path=
|
|
1152
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1137
1153
|
elif entry.is_dir():
|
|
1138
1154
|
removed += _clear_subtree(entry, errors)
|
|
1139
1155
|
try:
|
|
1140
1156
|
entry.rmdir()
|
|
1141
1157
|
except OSError as e:
|
|
1142
|
-
errors.append(ClearReportError(path=
|
|
1158
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1143
1159
|
else:
|
|
1144
1160
|
try:
|
|
1145
1161
|
_remove_file(entry)
|
|
1146
1162
|
removed += 1
|
|
1147
1163
|
except OSError as e:
|
|
1148
|
-
errors.append(ClearReportError(path=
|
|
1164
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1149
1165
|
return removed
|
|
1150
1166
|
|
|
1151
1167
|
|
|
@@ -1183,25 +1199,25 @@ def clear_cache(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1183
1199
|
_remove_file(entry)
|
|
1184
1200
|
counts["other"] += 1
|
|
1185
1201
|
except OSError as e:
|
|
1186
|
-
errors.append(ClearReportError(path=
|
|
1202
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1187
1203
|
elif entry.is_dir():
|
|
1188
1204
|
kind = _classify(entry.name)
|
|
1189
1205
|
counts[kind] += _clear_subtree(entry, errors)
|
|
1190
1206
|
try:
|
|
1191
1207
|
entry.rmdir()
|
|
1192
1208
|
except OSError as e:
|
|
1193
|
-
errors.append(ClearReportError(path=
|
|
1209
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1194
1210
|
else:
|
|
1195
1211
|
try:
|
|
1196
1212
|
_remove_file(entry)
|
|
1197
1213
|
counts["other"] += 1
|
|
1198
1214
|
except OSError as e:
|
|
1199
|
-
errors.append(ClearReportError(path=
|
|
1215
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1200
1216
|
|
|
1201
1217
|
try:
|
|
1202
1218
|
target.rmdir()
|
|
1203
1219
|
except OSError as e:
|
|
1204
|
-
errors.append(ClearReportError(path=
|
|
1220
|
+
errors.append(ClearReportError(path=_report_path(target), reason=str(e)))
|
|
1205
1221
|
|
|
1206
1222
|
return ClearReport(
|
|
1207
1223
|
existed=True,
|
|
@@ -1244,7 +1260,7 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1244
1260
|
try:
|
|
1245
1261
|
entry.rmdir()
|
|
1246
1262
|
except OSError as e:
|
|
1247
|
-
errors.append(ClearReportError(path=
|
|
1263
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1248
1264
|
|
|
1249
1265
|
for entry in sorted(target.iterdir()):
|
|
1250
1266
|
if entry.is_symlink():
|
|
@@ -1252,7 +1268,7 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1252
1268
|
_remove_file(entry)
|
|
1253
1269
|
counts["other"] += 1
|
|
1254
1270
|
except OSError as e:
|
|
1255
|
-
errors.append(ClearReportError(path=
|
|
1271
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1256
1272
|
elif entry.is_dir():
|
|
1257
1273
|
if entry.name == CONNECTIONS_DIRNAME:
|
|
1258
1274
|
for scope in sorted(entry.iterdir()):
|
|
@@ -1261,7 +1277,7 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1261
1277
|
_remove_file(scope)
|
|
1262
1278
|
counts["other"] += 1
|
|
1263
1279
|
except OSError as e:
|
|
1264
|
-
errors.append(ClearReportError(path=
|
|
1280
|
+
errors.append(ClearReportError(path=_report_path(scope), reason=str(e)))
|
|
1265
1281
|
elif scope.is_dir():
|
|
1266
1282
|
for inner in sorted(scope.iterdir()):
|
|
1267
1283
|
if inner.is_dir():
|
|
@@ -1271,21 +1287,23 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1271
1287
|
_remove_file(inner)
|
|
1272
1288
|
counts["other"] += 1
|
|
1273
1289
|
except OSError as e:
|
|
1274
|
-
errors.append(
|
|
1290
|
+
errors.append(
|
|
1291
|
+
ClearReportError(path=_report_path(inner), reason=str(e))
|
|
1292
|
+
)
|
|
1275
1293
|
try:
|
|
1276
1294
|
scope.rmdir()
|
|
1277
1295
|
except OSError as e:
|
|
1278
|
-
errors.append(ClearReportError(path=
|
|
1296
|
+
errors.append(ClearReportError(path=_report_path(scope), reason=str(e)))
|
|
1279
1297
|
else:
|
|
1280
1298
|
try:
|
|
1281
1299
|
_remove_file(scope)
|
|
1282
1300
|
counts["other"] += 1
|
|
1283
1301
|
except OSError as e:
|
|
1284
|
-
errors.append(ClearReportError(path=
|
|
1302
|
+
errors.append(ClearReportError(path=_report_path(scope), reason=str(e)))
|
|
1285
1303
|
try:
|
|
1286
1304
|
entry.rmdir()
|
|
1287
1305
|
except OSError as e:
|
|
1288
|
-
errors.append(ClearReportError(path=
|
|
1306
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1289
1307
|
else:
|
|
1290
1308
|
_tally_kind_subdir(entry)
|
|
1291
1309
|
else:
|
|
@@ -1293,12 +1311,12 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1293
1311
|
_remove_file(entry)
|
|
1294
1312
|
counts["other"] += 1
|
|
1295
1313
|
except OSError as e:
|
|
1296
|
-
errors.append(ClearReportError(path=
|
|
1314
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1297
1315
|
|
|
1298
1316
|
try:
|
|
1299
1317
|
target.rmdir()
|
|
1300
1318
|
except OSError as e:
|
|
1301
|
-
errors.append(ClearReportError(path=
|
|
1319
|
+
errors.append(ClearReportError(path=_report_path(target), reason=str(e)))
|
|
1302
1320
|
|
|
1303
1321
|
return ClearReport(
|
|
1304
1322
|
existed=True,
|
|
@@ -1405,7 +1423,7 @@ def enumerate_cache_scopes(cache_root: Path | None = None) -> CacheScopeEnumerat
|
|
|
1405
1423
|
# a logic error, not user-recoverable state.
|
|
1406
1424
|
assert data is not None
|
|
1407
1425
|
manifest = _validate_scope_manifest_schema(data, manifest_path)
|
|
1408
|
-
scopes.append(CacheScopeRef(slug=entry.name, path=
|
|
1426
|
+
scopes.append(CacheScopeRef(slug=entry.name, path=_report_path(entry), manifest=manifest))
|
|
1409
1427
|
|
|
1410
1428
|
return CacheScopeEnumeration(scopes=scopes, orphan_dirs=orphans)
|
|
1411
1429
|
|
|
@@ -1459,7 +1477,7 @@ def _scan_articles_dir(
|
|
|
1459
1477
|
CacheStatusWarning(
|
|
1460
1478
|
code="article_pair_mismatch",
|
|
1461
1479
|
message=f"article body without sidecar: {article_id}.md",
|
|
1462
|
-
path=
|
|
1480
|
+
path=_report_path(bodies[article_id]),
|
|
1463
1481
|
)
|
|
1464
1482
|
)
|
|
1465
1483
|
for article_id in sorted(only_sidecar):
|
|
@@ -1467,7 +1485,7 @@ def _scan_articles_dir(
|
|
|
1467
1485
|
CacheStatusWarning(
|
|
1468
1486
|
code="article_pair_mismatch",
|
|
1469
1487
|
message=f"article sidecar without body: {article_id}.meta.yaml",
|
|
1470
|
-
path=
|
|
1488
|
+
path=_report_path(sidecars[article_id]),
|
|
1471
1489
|
)
|
|
1472
1490
|
)
|
|
1473
1491
|
|
|
@@ -1483,7 +1501,7 @@ def _scan_articles_dir(
|
|
|
1483
1501
|
CacheStatusWarning(
|
|
1484
1502
|
code="sidecar_unparseable",
|
|
1485
1503
|
message=f"unable to parse fetched_at from {sidecar_path.name}: {exc}",
|
|
1486
|
-
path=
|
|
1504
|
+
path=_report_path(sidecar_path),
|
|
1487
1505
|
)
|
|
1488
1506
|
)
|
|
1489
1507
|
continue
|
|
@@ -1522,7 +1540,7 @@ def _scan_yaml_kind_dir(
|
|
|
1522
1540
|
CacheStatusWarning(
|
|
1523
1541
|
code=code,
|
|
1524
1542
|
message=f"unable to parse banner from {path.name}: {exc}",
|
|
1525
|
-
path=
|
|
1543
|
+
path=_report_path(path),
|
|
1526
1544
|
)
|
|
1527
1545
|
)
|
|
1528
1546
|
continue
|
|
@@ -1701,7 +1719,7 @@ def compute_cache_status(
|
|
|
1701
1719
|
when = now if now is not None else datetime.now(timezone.utc)
|
|
1702
1720
|
target = cache_dir() if cache_root is None else Path(cache_root).resolve()
|
|
1703
1721
|
|
|
1704
|
-
info_path =
|
|
1722
|
+
info_path = _report_path(target)
|
|
1705
1723
|
if not target.exists():
|
|
1706
1724
|
return CacheStatusReport(
|
|
1707
1725
|
cache=CacheStatusInfo(
|
|
@@ -1855,7 +1873,7 @@ def _refresh_primer_articles(
|
|
|
1855
1873
|
id=article.id_readable,
|
|
1856
1874
|
summary=article.summary,
|
|
1857
1875
|
upstream_updated=meta.upstream_updated,
|
|
1858
|
-
path=
|
|
1876
|
+
path=_report_path(cache_root / ARTICLES_DIRNAME / f"{article.id_readable}.md"),
|
|
1859
1877
|
)
|
|
1860
1878
|
)
|
|
1861
1879
|
except APIError as e:
|
|
@@ -1906,7 +1924,7 @@ def _refresh_primer_query(
|
|
|
1906
1924
|
hydrated=[
|
|
1907
1925
|
HydratedQuery(
|
|
1908
1926
|
slug=PRIMER_SCOPE_QUERY_SLUG,
|
|
1909
|
-
path=
|
|
1927
|
+
path=_report_path(cache_root / QUERIES_DIRNAME / f"{PRIMER_SCOPE_QUERY_SLUG}.yaml"),
|
|
1910
1928
|
issue_count=issue_count,
|
|
1911
1929
|
article_count=article_count,
|
|
1912
1930
|
)
|
|
@@ -2102,7 +2120,7 @@ def refresh_issue(
|
|
|
2102
2120
|
id=issue.id_readable,
|
|
2103
2121
|
summary=issue.summary,
|
|
2104
2122
|
upstream_updated=_ms_to_iso(issue.updated),
|
|
2105
|
-
path=
|
|
2123
|
+
path=_report_path(root / ISSUES_DIRNAME / f"{issue.id_readable}.yaml"),
|
|
2106
2124
|
comments_count=comments_count,
|
|
2107
2125
|
)
|
|
2108
2126
|
],
|
|
@@ -2314,7 +2332,7 @@ def refresh_query(
|
|
|
2314
2332
|
query_name=sq.name,
|
|
2315
2333
|
query_id=sq.id,
|
|
2316
2334
|
slug=slug,
|
|
2317
|
-
path=
|
|
2335
|
+
path=_report_path(root / QUERIES_DIRNAME / f"{slug}.yaml"),
|
|
2318
2336
|
count=len(issues),
|
|
2319
2337
|
truncated=truncated,
|
|
2320
2338
|
)
|
|
@@ -2458,7 +2476,7 @@ def refresh_milestone(
|
|
|
2458
2476
|
field_name=actual_field_name,
|
|
2459
2477
|
query_text=query_text,
|
|
2460
2478
|
slug=slug,
|
|
2461
|
-
path=
|
|
2479
|
+
path=_report_path(root / QUERIES_DIRNAME / f"{slug}.yaml"),
|
|
2462
2480
|
count=len(issues),
|
|
2463
2481
|
truncated=truncated,
|
|
2464
2482
|
)
|
|
@@ -2616,7 +2634,7 @@ def refresh_epic_with_children(
|
|
|
2616
2634
|
id=epic.id_readable,
|
|
2617
2635
|
summary=epic.summary,
|
|
2618
2636
|
upstream_updated=_ms_to_iso(epic.updated),
|
|
2619
|
-
path=
|
|
2637
|
+
path=_report_path(root / ISSUES_DIRNAME / f"{epic.id_readable}.yaml"),
|
|
2620
2638
|
comments_count=None,
|
|
2621
2639
|
)
|
|
2622
2640
|
|
|
@@ -2657,7 +2675,7 @@ def refresh_epic_with_children(
|
|
|
2657
2675
|
id=child.id_readable,
|
|
2658
2676
|
summary=child.summary,
|
|
2659
2677
|
upstream_updated=_ms_to_iso(child.updated),
|
|
2660
|
-
path=
|
|
2678
|
+
path=_report_path(root / ISSUES_DIRNAME / f"{child.id_readable}.yaml"),
|
|
2661
2679
|
comments_count=None,
|
|
2662
2680
|
)
|
|
2663
2681
|
)
|
|
@@ -2701,7 +2719,7 @@ def refresh_epic_with_children(
|
|
|
2701
2719
|
"children": manifest_rows,
|
|
2702
2720
|
}
|
|
2703
2721
|
write_cached_query(slug, manifest_payload, fetched_at=when, cache_root=root)
|
|
2704
|
-
manifest_path =
|
|
2722
|
+
manifest_path = _report_path(root / QUERIES_DIRNAME / f"{slug}.yaml")
|
|
2705
2723
|
|
|
2706
2724
|
return EpicRefreshReport(
|
|
2707
2725
|
epic=epic_hydrated,
|
|
@@ -2754,7 +2772,7 @@ def refresh_article(
|
|
|
2754
2772
|
id=article.id_readable,
|
|
2755
2773
|
summary=article.summary,
|
|
2756
2774
|
upstream_updated=meta.upstream_updated,
|
|
2757
|
-
path=
|
|
2775
|
+
path=_report_path(root / ARTICLES_DIRNAME / f"{article.id_readable}.md"),
|
|
2758
2776
|
)
|
|
2759
2777
|
],
|
|
2760
2778
|
failed=[],
|
|
@@ -2907,7 +2925,7 @@ def _search_article_dir(
|
|
|
2907
2925
|
CacheStatusWarning(
|
|
2908
2926
|
code="sidecar_unparseable",
|
|
2909
2927
|
message=f"unable to parse {sidecar_path.name}: {exc}",
|
|
2910
|
-
path=
|
|
2928
|
+
path=_report_path(sidecar_path),
|
|
2911
2929
|
)
|
|
2912
2930
|
)
|
|
2913
2931
|
try:
|
|
@@ -2917,7 +2935,7 @@ def _search_article_dir(
|
|
|
2917
2935
|
CacheStatusWarning(
|
|
2918
2936
|
code="banner_unparseable",
|
|
2919
2937
|
message=f"unable to read {body_path.name}: {exc}",
|
|
2920
|
-
path=
|
|
2938
|
+
path=_report_path(body_path),
|
|
2921
2939
|
)
|
|
2922
2940
|
)
|
|
2923
2941
|
continue
|
|
@@ -2928,7 +2946,7 @@ def _search_article_dir(
|
|
|
2928
2946
|
CacheStatusWarning(
|
|
2929
2947
|
code="banner_unparseable",
|
|
2930
2948
|
message=f"unable to parse banner from {body_path.name}: {exc}",
|
|
2931
|
-
path=
|
|
2949
|
+
path=_report_path(body_path),
|
|
2932
2950
|
)
|
|
2933
2951
|
)
|
|
2934
2952
|
continue
|
|
@@ -2940,7 +2958,7 @@ def _search_article_dir(
|
|
|
2940
2958
|
continue
|
|
2941
2959
|
matches.append(
|
|
2942
2960
|
CacheSearchMatch(
|
|
2943
|
-
path=
|
|
2961
|
+
path=_report_path(body_path),
|
|
2944
2962
|
connection=connection,
|
|
2945
2963
|
slug=slug,
|
|
2946
2964
|
kind="article",
|
|
@@ -2972,7 +2990,7 @@ def _load_yaml_payload(
|
|
|
2972
2990
|
CacheStatusWarning(
|
|
2973
2991
|
code="payload_unparseable",
|
|
2974
2992
|
message=f"unable to read {path.name}: {exc}",
|
|
2975
|
-
path=
|
|
2993
|
+
path=_report_path(path),
|
|
2976
2994
|
)
|
|
2977
2995
|
)
|
|
2978
2996
|
return None
|
|
@@ -2983,7 +3001,7 @@ def _load_yaml_payload(
|
|
|
2983
3001
|
CacheStatusWarning(
|
|
2984
3002
|
code="banner_unparseable",
|
|
2985
3003
|
message=f"unable to parse banner from {path.name}: {exc}",
|
|
2986
|
-
path=
|
|
3004
|
+
path=_report_path(path),
|
|
2987
3005
|
)
|
|
2988
3006
|
)
|
|
2989
3007
|
body = raw # try the full file — payload may parse without banner
|
|
@@ -2994,7 +3012,7 @@ def _load_yaml_payload(
|
|
|
2994
3012
|
CacheStatusWarning(
|
|
2995
3013
|
code="payload_unparseable",
|
|
2996
3014
|
message=f"unable to parse YAML payload from {path.name}: {exc}",
|
|
2997
|
-
path=
|
|
3015
|
+
path=_report_path(path),
|
|
2998
3016
|
)
|
|
2999
3017
|
)
|
|
3000
3018
|
return None
|
|
@@ -3003,7 +3021,7 @@ def _load_yaml_payload(
|
|
|
3003
3021
|
CacheStatusWarning(
|
|
3004
3022
|
code="payload_unparseable",
|
|
3005
3023
|
message=f"YAML payload in {path.name} is not a mapping",
|
|
3006
|
-
path=
|
|
3024
|
+
path=_report_path(path),
|
|
3007
3025
|
)
|
|
3008
3026
|
)
|
|
3009
3027
|
return None
|
|
@@ -3052,7 +3070,7 @@ def _search_issue_dir(
|
|
|
3052
3070
|
continue
|
|
3053
3071
|
matches.append(
|
|
3054
3072
|
CacheSearchMatch(
|
|
3055
|
-
path=
|
|
3073
|
+
path=_report_path(path),
|
|
3056
3074
|
connection=connection,
|
|
3057
3075
|
slug=slug,
|
|
3058
3076
|
kind="issue",
|
|
@@ -3181,7 +3199,7 @@ def _search_query_dir(
|
|
|
3181
3199
|
CacheStatusWarning(
|
|
3182
3200
|
code="query_shape_unrecognized",
|
|
3183
3201
|
message=f"unknown query cache shape in {path.name}",
|
|
3184
|
-
path=
|
|
3202
|
+
path=_report_path(path),
|
|
3185
3203
|
)
|
|
3186
3204
|
)
|
|
3187
3205
|
continue
|
|
@@ -3189,7 +3207,7 @@ def _search_query_dir(
|
|
|
3189
3207
|
continue
|
|
3190
3208
|
matches.append(
|
|
3191
3209
|
CacheSearchMatch(
|
|
3192
|
-
path=
|
|
3210
|
+
path=_report_path(path),
|
|
3193
3211
|
connection=connection,
|
|
3194
3212
|
slug=slug,
|
|
3195
3213
|
kind="query",
|
|
@@ -141,6 +141,74 @@ def _print_error(error: KdError) -> None:
|
|
|
141
141
|
print(f"Hint: {error.suggestion}", file=sys.stderr)
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def _configure_windows_stdio() -> None:
|
|
145
|
+
"""Force UTF-8 on stdout/stderr when running on Windows.
|
|
146
|
+
|
|
147
|
+
No-op on POSIX. Errors during reconfigure are swallowed; the wrapper's
|
|
148
|
+
UnicodeEncodeError handler is the defensive backstop for the rare
|
|
149
|
+
embedded-interpreter case where reconfigure is unavailable.
|
|
150
|
+
"""
|
|
151
|
+
if sys.platform != "win32":
|
|
152
|
+
return
|
|
153
|
+
for stream in (sys.stdout, sys.stderr):
|
|
154
|
+
try:
|
|
155
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr,unused-ignore]
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_DEBUG_TOKEN = re.compile(r"^-D{1,3}$")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _debug_requested(argv: list[str]) -> bool:
|
|
164
|
+
"""Cheap preparse for ``--debug`` / ``-D{1,3}`` in the wrapper.
|
|
165
|
+
|
|
166
|
+
Used only by the friendly diagnostic handler to decide whether to re-raise
|
|
167
|
+
so the runtime prints the full traceback. Not a substitute for
|
|
168
|
+
:func:`_extract_globals`, which is the authoritative parser.
|
|
169
|
+
"""
|
|
170
|
+
return any(token == "--debug" or _DEBUG_TOKEN.match(token) for token in argv)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _command_summary(argv: list[str]) -> str:
|
|
174
|
+
"""Best-effort command path for the internal-error block.
|
|
175
|
+
|
|
176
|
+
Returns the first non-flag tokens joined by space, or ``"<unknown>"``.
|
|
177
|
+
"""
|
|
178
|
+
tokens = [t for t in argv if not t.startswith("-")]
|
|
179
|
+
return " ".join(tokens) if tokens else "<unknown>"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_MSYS_NAMESPACE_REGEX = re.compile(
|
|
183
|
+
r"^[A-Za-z]:[\\/].*?[\\/]"
|
|
184
|
+
r"(system|issue|article|primer|tag|user|time|project|query|search|alias|cache)$"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _maybe_emit_msys_hint(args: list[str]) -> None:
|
|
189
|
+
"""Stderr hint when git-bash MSYS rewrote a kd subcommand path.
|
|
190
|
+
|
|
191
|
+
From git-bash on Windows, ``kd /system info`` arrives as
|
|
192
|
+
``["C:/Program Files/Git/system", "info"]`` because MSYS converts the
|
|
193
|
+
leading ``/`` to a Windows path before kd ever sees argv. Print a one-
|
|
194
|
+
line hint pointing at the operating-guide recovery recipes; normal
|
|
195
|
+
command parsing still runs and still fails with "Unknown command", but
|
|
196
|
+
the hint tells the operator how to retry.
|
|
197
|
+
"""
|
|
198
|
+
if sys.platform != "win32" or not args:
|
|
199
|
+
return
|
|
200
|
+
if not _MSYS_NAMESPACE_REGEX.match(args[0]):
|
|
201
|
+
return
|
|
202
|
+
print(
|
|
203
|
+
"Hint: leading '/' in kd subcommand paths was converted to a "
|
|
204
|
+
"Windows path by git-bash / MSYS. Recover with `kd //system info`, "
|
|
205
|
+
"`MSYS_NO_PATHCONV=1 kd /system info`, or set "
|
|
206
|
+
"`alias kd='MSYS_NO_PATHCONV=1 kd'` in ~/.bashrc. "
|
|
207
|
+
"See `kd --guide` 'Windows shells' section.",
|
|
208
|
+
file=sys.stderr,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
144
212
|
def _resolve_config_or_exit(
|
|
145
213
|
flags: dict[str, str] | None,
|
|
146
214
|
debug_log: DebugLog,
|
|
@@ -158,7 +226,53 @@ def _resolve_config_or_exit(
|
|
|
158
226
|
|
|
159
227
|
|
|
160
228
|
def main(argv: list[str] | None = None) -> None:
|
|
161
|
-
"""CLI entry point.
|
|
229
|
+
"""CLI entry point.
|
|
230
|
+
|
|
231
|
+
Wraps :func:`_main` with the UTF-8 stdio shim and a friendly diagnostic
|
|
232
|
+
handler. ``SystemExit`` and ``KeyboardInterrupt`` propagate naturally past
|
|
233
|
+
``except Exception`` so existing exit paths and Ctrl-C behavior are
|
|
234
|
+
preserved.
|
|
235
|
+
"""
|
|
236
|
+
_configure_windows_stdio()
|
|
237
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
238
|
+
_maybe_emit_msys_hint(args)
|
|
239
|
+
debug = _debug_requested(args)
|
|
240
|
+
try:
|
|
241
|
+
_main(argv)
|
|
242
|
+
except KdError as e:
|
|
243
|
+
_print_error(e)
|
|
244
|
+
sys.exit(1)
|
|
245
|
+
except UnicodeEncodeError as e:
|
|
246
|
+
if debug:
|
|
247
|
+
raise
|
|
248
|
+
print(
|
|
249
|
+
f"Error: stdout encoding could not represent a character ({e.reason!s}). "
|
|
250
|
+
"Set PYTHONIOENCODING=utf-8 or upgrade kd to a build that forces UTF-8 stdout on Windows.",
|
|
251
|
+
file=sys.stderr,
|
|
252
|
+
)
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
if debug:
|
|
256
|
+
raise
|
|
257
|
+
py_version = sys.version.split()[0]
|
|
258
|
+
cmd_summary = _command_summary(args)
|
|
259
|
+
print(
|
|
260
|
+
f"Internal error: {type(e).__name__}: {e}\n"
|
|
261
|
+
"\n"
|
|
262
|
+
f"kd version: {__version__}\n"
|
|
263
|
+
f"Python: {py_version}\n"
|
|
264
|
+
f"Platform: {sys.platform}\n"
|
|
265
|
+
f"Command: {cmd_summary}\n"
|
|
266
|
+
"\n"
|
|
267
|
+
"This is a bug. Please file an issue with the above context.\n"
|
|
268
|
+
"Re-run with --debug to see the full traceback.",
|
|
269
|
+
file=sys.stderr,
|
|
270
|
+
)
|
|
271
|
+
sys.exit(1)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _main(argv: list[str] | None = None) -> None:
|
|
275
|
+
"""CLI dispatch body. See :func:`main` for the friendly-diagnostic wrapper."""
|
|
162
276
|
args = argv if argv is not None else sys.argv[1:]
|
|
163
277
|
|
|
164
278
|
# Extract global options before namespace parsing
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Advisory file locking for read-modify-write transactions on kd-managed files.
|
|
2
|
+
|
|
3
|
+
Provides :func:`file_transaction` — a context manager that acquires an
|
|
4
|
+
exclusive advisory lock on a sidecar lockfile (``<path>.lock``) for the
|
|
5
|
+
duration of a load → mutate → save cycle. Concurrent kd processes that
|
|
6
|
+
enter the same transaction serialize rather than clobber each other.
|
|
7
|
+
|
|
8
|
+
Backed by :mod:`filelock`, which abstracts over ``fcntl.flock`` on POSIX
|
|
9
|
+
and ``msvcrt.locking`` on Windows. Behavior is identical on both:
|
|
10
|
+
|
|
11
|
+
* **Sidecar lockfile, not the target file.** Atomic writes elsewhere in
|
|
12
|
+
kd use ``tempfile.mkstemp`` + ``os.replace``, which changes the target
|
|
13
|
+
file's inode on every write. Holding an advisory lock on the target
|
|
14
|
+
inode would not protect a second writer that opens the freshly-replaced
|
|
15
|
+
file. Locking a separate sidecar that nobody rewrites keeps the lock
|
|
16
|
+
stable across atomic replaces of the data file.
|
|
17
|
+
|
|
18
|
+
* **Sidecar lifecycle is filelock-managed.** filelock owns sidecar
|
|
19
|
+
creation and removal: it unlinks the sidecar on release on both POSIX
|
|
20
|
+
and Windows. The kd contract is the *behavior* of the lock —
|
|
21
|
+
concurrent enterers serialize — not the on-disk artifact. Existence
|
|
22
|
+
of the sidecar is not evidence of an active lock; absence is not
|
|
23
|
+
evidence of release. Removing the sidecar under contention is a
|
|
24
|
+
two-inode race and is not part of any recovery recipe.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Generator
|
|
30
|
+
from contextlib import contextmanager
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from filelock import FileLock, Timeout
|
|
34
|
+
|
|
35
|
+
from kd.exceptions import ConfigError
|
|
36
|
+
|
|
37
|
+
LOCK_SUFFIX = ".lock"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@contextmanager
|
|
41
|
+
def file_transaction(path: Path, *, timeout: float = 30.0) -> Generator[None, None, None]:
|
|
42
|
+
"""Hold an exclusive advisory lock on ``<path>.lock`` for the ``with`` body.
|
|
43
|
+
|
|
44
|
+
Acquisition blocks up to *timeout* seconds. On timeout, raises
|
|
45
|
+
:class:`ConfigError` naming the contested file. filelock unlinks
|
|
46
|
+
the sidecar on release; the kd contract is the lock behavior, not
|
|
47
|
+
the on-disk artifact.
|
|
48
|
+
"""
|
|
49
|
+
lock_path = path.with_name(path.name + LOCK_SUFFIX)
|
|
50
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
lock = FileLock(str(lock_path))
|
|
53
|
+
try:
|
|
54
|
+
lock.acquire(timeout=timeout)
|
|
55
|
+
except Timeout:
|
|
56
|
+
raise ConfigError(
|
|
57
|
+
f"Another kd process is writing to {path}; retry in a moment.",
|
|
58
|
+
suggestion=(
|
|
59
|
+
"If the wait persists, verify no other kd process is mutating "
|
|
60
|
+
"this file and check parent-directory permissions. Do not "
|
|
61
|
+
"delete the sidecar lockfile while another process may be "
|
|
62
|
+
"running."
|
|
63
|
+
),
|
|
64
|
+
) from None
|
|
65
|
+
try:
|
|
66
|
+
yield
|
|
67
|
+
finally:
|
|
68
|
+
lock.release()
|
|
@@ -4,6 +4,71 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.6.3] — 2026-04-30
|
|
8
|
+
|
|
9
|
+
This release focuses on Windows shakedown: encoding crashes, primer
|
|
10
|
+
locking, mojibake in help text, the git-bash path-conversion gotcha,
|
|
11
|
+
and a contributor-token field-resolver bug surfaced along the way.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- `/project/field list` and the `--type` / `--priority` / `--state` /
|
|
15
|
+
`--field` resolvers now work for tokens that lack project-administration
|
|
16
|
+
rights (e.g. contributor / bot accounts). When both the per-issue and
|
|
17
|
+
admin paths return no fields, kd raises a typed error pointing at
|
|
18
|
+
token permissions instead of failing the resolver silently. Surfaced
|
|
19
|
+
during the Windows shakedown but identity-class — the fix is
|
|
20
|
+
platform-agnostic (KDCLI-164).
|
|
21
|
+
- Primer mutations (`/primer/note add`, `/primer/tag add`,
|
|
22
|
+
`/primer/article add`, plus all update/remove paths) now succeed on
|
|
23
|
+
Windows. Replaces the previous POSIX-only implementation that raised
|
|
24
|
+
`ConfigError("File locking is not available on this platform (POSIX
|
|
25
|
+
only).")` at the very first acquisition on Windows. Concurrent kd
|
|
26
|
+
processes still serialize on the `.kd/primer.yaml.lock` file on both
|
|
27
|
+
platforms (KDCLI-159).
|
|
28
|
+
- Output containing characters outside cp1252 (`→`, `—`, `≤`) no longer
|
|
29
|
+
crashes with `UnicodeEncodeError` on Windows, and help/synopsis text
|
|
30
|
+
on the default Windows console no longer renders mojibake (`?` / `�`)
|
|
31
|
+
for en-dashes and similar. Both paths went through the same encoding
|
|
32
|
+
layer (KDCLI-158, KDCLI-161, KDCLI-156).
|
|
33
|
+
- Unhandled exceptions in the CLI now render a one-line (encoding) or
|
|
34
|
+
one-paragraph (generic) friendly diagnostic on stderr and exit `1`
|
|
35
|
+
instead of dumping a raw Python traceback. The traceback is preserved
|
|
36
|
+
under `--debug` / `-D` / `-DD` / `-DDD` so developer workflows are
|
|
37
|
+
intact. Applies on every platform (KDCLI-162).
|
|
38
|
+
- Operating guide and user guide now document the git-bash MSYS
|
|
39
|
+
path-conversion gotcha. `kd /primer show` from git-bash on Windows
|
|
40
|
+
arrives as `kd C:/Program Files/Git/primer show` because MSYS
|
|
41
|
+
rewrites the leading `/` before kd ever sees argv; recovery is the
|
|
42
|
+
recommended `alias kd='MSYS_NO_PATHCONV=1 kd'` in `~/.bashrc`,
|
|
43
|
+
`MSYS_NO_PATHCONV=1 kd …` per call, or the `kd //primer show`
|
|
44
|
+
double-slash form (KDCLI-163).
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- `kd /system info -o yaml` now exposes the active connection slug as
|
|
48
|
+
a top-level `connection` key. Paste `kd /system info -o yaml` into
|
|
49
|
+
a bug report and the slug used to reach the instance is captured
|
|
50
|
+
without hand-typing — `-c <slug>` overrides flow through to the
|
|
51
|
+
field as well. The previous payload named the URL but never the
|
|
52
|
+
slug, so reports against multi-connection setups had to spell the
|
|
53
|
+
slug out by hand (KDCLI-160).
|
|
54
|
+
- Git-bash MSYS path-conversion hint on Windows: when `kd <converted
|
|
55
|
+
path>` reaches argv as `C:/.../<known kd namespace>`, kd prints a
|
|
56
|
+
one-line stderr hint above the existing "Unknown command" failure
|
|
57
|
+
pointing at the recovery recipes (KDCLI-163).
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
- Added `filelock>=3.12` as a direct runtime dependency. Normal
|
|
61
|
+
`kd-cli` end-user installs gain one new direct runtime package; the
|
|
62
|
+
dev wheel set is unaffected because dev tooling already resolved
|
|
63
|
+
`filelock` transitively. See
|
|
64
|
+
`docs/design/ADR-012-cross-platform-locking.md` (KDCLI-159).
|
|
65
|
+
- Structured cache reports normalize file-path fields to POSIX form for
|
|
66
|
+
cross-platform stability. `path:` and `manifest_path:` fields inside the
|
|
67
|
+
YAML / JSON envelopes of `/cache refresh`, `/cache clear`, `/cache status`,
|
|
68
|
+
and `/cache search` now use `/` separators on Windows the same as on macOS
|
|
69
|
+
and Linux, so LLM consumers parsing the envelope see a platform-stable
|
|
70
|
+
shape (KDCLI-168).
|
|
71
|
+
|
|
7
72
|
## [2.6.2] — 2026-04-28
|
|
8
73
|
|
|
9
74
|
### Added
|
|
@@ -164,7 +164,7 @@ When both pinned tags and pinned articles exist, read them in order: notes tell
|
|
|
164
164
|
|
|
165
165
|
Keep the primer local and curated. If a note becomes generally true for kd, move it into docs or this guide. If it is workspace-specific, keep it in `.kd/primer.yaml`.
|
|
166
166
|
|
|
167
|
-
Primer mutations are serialized per file through an advisory lock on `.kd/primer.yaml.lock`; concurrent kd processes writing to the same primer queue rather than conflict, so agent-launched parallel `/primer/note`, `/primer/tag`, and `/primer/article` commands all converge without lost updates.
|
|
167
|
+
Primer mutations are serialized per file through an advisory lock on `.kd/primer.yaml.lock`; concurrent kd processes writing to the same primer queue rather than conflict, so agent-launched parallel `/primer/note`, `/primer/tag`, and `/primer/article` commands all converge without lost updates. The lock works identically on macOS, Linux, and Windows — the underlying primitive is `fcntl.flock` on POSIX and `msvcrt.locking` on Windows, both selected automatically.
|
|
168
168
|
|
|
169
169
|
## Articles And Push
|
|
170
170
|
|
|
@@ -244,6 +244,10 @@ kd -DDD /issue list --project KDCLI
|
|
|
244
244
|
|
|
245
245
|
Debug output goes to stderr and tokens are redacted. Use `-DDD` when investigating upstream API behavior because it includes request and response details.
|
|
246
246
|
|
|
247
|
+
On Windows, kd forces UTF-8 stdout and stderr at the entry point so non-ASCII characters (`→`, `—`, `≤`, em-dash) render correctly regardless of the console's default code page (cp1252 on most installs). No `PYTHONIOENCODING` workaround is needed.
|
|
248
|
+
|
|
249
|
+
Unhandled exceptions render a one-line or one-paragraph friendly diagnostic and exit non-zero. To see the full traceback (for bug reports or local debugging), re-run with `--debug` / `-D`; the wrapper re-raises the original exception so the runtime prints the traceback. `KdError` (expected user-facing failures) keep their existing one-line `Error: ...` / optional `Hint: ...` shape on every platform.
|
|
250
|
+
|
|
247
251
|
If config resolution is confusing, inspect it directly:
|
|
248
252
|
|
|
249
253
|
```bash
|
|
@@ -284,6 +288,27 @@ Multi-target commands (`/issue/attachment add`, `/article/attachment add`) carry
|
|
|
284
288
|
|
|
285
289
|
When filing a kd bug, include the output of `kd /system info -o yaml`. It names the active connection alongside `kd_version`, `python_version`, and `platform`, so the report is self-describing without hand-typing the version. Redact `url` / `email` before sharing externally.
|
|
286
290
|
|
|
291
|
+
## Windows Shells
|
|
292
|
+
|
|
293
|
+
kd works directly from PowerShell and `cmd.exe` on Windows — `kd /system info`, `kd /primer show`, and the rest run as written.
|
|
294
|
+
|
|
295
|
+
From git-bash (and other MSYS environments), the leading `/` of a kd subcommand path is converted to a Windows path *before* kd ever sees argv, so `kd /primer show` arrives as `kd C:/Program Files/Git/primer show` and falls through to `Unknown command: C:/Program Files/Git/primer`. kd detects this shape on Windows and prints a one-line hint to stderr above the failure.
|
|
296
|
+
|
|
297
|
+
Per-invocation recovery:
|
|
298
|
+
|
|
299
|
+
- `MSYS_NO_PATHCONV=1 kd /primer show` — turn MSYS path conversion off for one call.
|
|
300
|
+
- `kd //primer show` — the double-slash form MSYS itself documents as "I meant a literal slash."
|
|
301
|
+
|
|
302
|
+
Recommended permanent fix — a kd-scoped bash alias in `~/.bashrc`:
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
alias kd='MSYS_NO_PATHCONV=1 kd'
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Lowest blast radius: only `kd` invocations are affected. Other MSYS-aware tools (docker volume mounts, MinGW build scripts, etc.) keep their normal path-conversion behavior.
|
|
309
|
+
|
|
310
|
+
The global form (`export MSYS_NO_PATHCONV=1` in `~/.bashrc` or a Windows-level environment variable via `setx MSYS_NO_PATHCONV 1`) also works but turns path conversion off for *every* program in the shell — that surprises tools that rely on it. Prefer the alias unless you have a deliberate reason to disable conversion globally.
|
|
311
|
+
|
|
287
312
|
## Where To Go Next
|
|
288
313
|
|
|
289
314
|
Use these sources together:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kd-cli
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.3
|
|
4
4
|
Summary: Knowledge Dispatcher for issue trackers and knowledge bases
|
|
5
5
|
Author: Sergiy Gladkyy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,7 @@ Classifier: Topic :: Utilities
|
|
|
22
22
|
Requires-Python: >=3.11
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
+
Requires-Dist: filelock>=3.12
|
|
25
26
|
Requires-Dist: httpx>=0.27
|
|
26
27
|
Requires-Dist: packaging>=23.0
|
|
27
28
|
Requires-Dist: pydantic>=2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kd-cli"
|
|
7
|
-
version = "2.6.
|
|
7
|
+
version = "2.6.3"
|
|
8
8
|
description = "Knowledge Dispatcher for issue trackers and knowledge bases"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -26,6 +26,7 @@ classifiers = [
|
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
dependencies = [
|
|
29
|
+
"filelock>=3.12",
|
|
29
30
|
"httpx>=0.27",
|
|
30
31
|
"packaging>=23.0",
|
|
31
32
|
"pydantic>=2.0",
|
kd_cli-2.6.2/kd/locking.py
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
"""Advisory file locking for read-modify-write transactions on kd-managed files.
|
|
2
|
-
|
|
3
|
-
Provides :func:`file_transaction` — a context manager that acquires an
|
|
4
|
-
exclusive advisory lock on a sidecar lockfile (``<path>.lock``) for the
|
|
5
|
-
duration of a load → mutate → save cycle. Concurrent kd processes that
|
|
6
|
-
enter the same transaction serialize rather than clobber each other.
|
|
7
|
-
|
|
8
|
-
Design notes:
|
|
9
|
-
|
|
10
|
-
* **Sidecar lockfile, not the target file.** Atomic writes elsewhere in
|
|
11
|
-
kd use ``tempfile.mkstemp`` + ``os.replace``, which changes the target
|
|
12
|
-
file's inode on every write. Holding ``flock`` on the target inode
|
|
13
|
-
would not protect a second writer that opens the freshly-replaced file.
|
|
14
|
-
Locking a separate sidecar file that nobody rewrites keeps the lock
|
|
15
|
-
stable across atomic replaces of the data file.
|
|
16
|
-
|
|
17
|
-
* **No unlink on release.** Removing the lockfile after releasing the
|
|
18
|
-
lock is a classic race: process B can open the doomed inode while
|
|
19
|
-
process C creates a fresh one, and both end up holding "exclusive"
|
|
20
|
-
locks on different inodes. Empty sidecar files are cheap; leave them.
|
|
21
|
-
|
|
22
|
-
* **POSIX only.** ``fcntl`` is imported lazily inside the context
|
|
23
|
-
manager so that unrelated kd commands remain importable on Windows.
|
|
24
|
-
On non-POSIX platforms, :func:`file_transaction` raises ``ConfigError``
|
|
25
|
-
with a suggestion to run the command sequentially.
|
|
26
|
-
|
|
27
|
-
* **NFS caveat.** ``flock`` on NFS has been unreliable historically.
|
|
28
|
-
Modern Linux kernels implement it correctly, but operators using a
|
|
29
|
-
networked ``$HOME`` should be aware that concurrency guarantees weaken.
|
|
30
|
-
Local ``.kd/`` directories inside a repo are unaffected.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
from __future__ import annotations
|
|
34
|
-
|
|
35
|
-
import os
|
|
36
|
-
import sys
|
|
37
|
-
import time
|
|
38
|
-
from collections.abc import Generator
|
|
39
|
-
from contextlib import contextmanager
|
|
40
|
-
from pathlib import Path
|
|
41
|
-
|
|
42
|
-
from kd.exceptions import ConfigError
|
|
43
|
-
|
|
44
|
-
LOCK_SUFFIX = ".lock"
|
|
45
|
-
_POLL_INTERVAL_SECONDS = 0.05
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
@contextmanager
|
|
49
|
-
def file_transaction(path: Path, *, timeout: float = 30.0) -> Generator[None, None, None]:
|
|
50
|
-
"""Hold an exclusive advisory lock on ``<path>.lock`` for the ``with`` body.
|
|
51
|
-
|
|
52
|
-
The sidecar lockfile is created if missing (``O_RDWR | O_CREAT``,
|
|
53
|
-
never truncated). Acquisition polls non-blocking ``flock`` until
|
|
54
|
-
the lock is granted or *timeout* seconds elapse. On timeout, raises
|
|
55
|
-
:class:`ConfigError` naming the contested file. The lockfile is
|
|
56
|
-
intentionally left on disk after release.
|
|
57
|
-
|
|
58
|
-
Raises :class:`ConfigError` on non-POSIX platforms.
|
|
59
|
-
"""
|
|
60
|
-
if sys.platform == "win32":
|
|
61
|
-
raise ConfigError(
|
|
62
|
-
"File locking is not available on this platform (POSIX only).",
|
|
63
|
-
suggestion="Run the kd command sequentially; concurrent mutations are not supported on Windows.",
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
import fcntl # noqa: PLC0415 — lazy import keeps kd importable on Windows
|
|
67
|
-
|
|
68
|
-
lock_path = path.with_name(path.name + LOCK_SUFFIX)
|
|
69
|
-
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
-
|
|
71
|
-
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
72
|
-
try:
|
|
73
|
-
deadline = time.monotonic() + timeout
|
|
74
|
-
while True:
|
|
75
|
-
try:
|
|
76
|
-
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
77
|
-
break
|
|
78
|
-
except BlockingIOError:
|
|
79
|
-
if time.monotonic() >= deadline:
|
|
80
|
-
raise ConfigError(
|
|
81
|
-
f"Another kd process is writing to {path}; retry in a moment.",
|
|
82
|
-
suggestion=f"If no other kd process is running, remove the stale lockfile: {lock_path}",
|
|
83
|
-
) from None
|
|
84
|
-
time.sleep(_POLL_INTERVAL_SECONDS)
|
|
85
|
-
|
|
86
|
-
try:
|
|
87
|
-
yield
|
|
88
|
-
finally:
|
|
89
|
-
try:
|
|
90
|
-
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
91
|
-
except OSError:
|
|
92
|
-
pass
|
|
93
|
-
finally:
|
|
94
|
-
try:
|
|
95
|
-
os.close(fd)
|
|
96
|
-
except OSError:
|
|
97
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|