vexor 0.2.0__py3-none-any.whl → 0.5.0__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.
vexor/cli.py CHANGED
@@ -2,27 +2,40 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ import shlex
6
+ import subprocess
6
7
  from pathlib import Path
7
- from typing import Sequence
8
- import shutil
9
- import re
8
+ from typing import Sequence, TYPE_CHECKING
10
9
 
11
10
  import typer
12
11
  from rich.console import Console
13
12
  from rich.table import Table
14
13
 
15
- from . import __version__
14
+ from . import __version__, config as config_module
15
+ from .cache import clear_all_cache, list_cache_entries
16
16
  from .config import (
17
17
  DEFAULT_BATCH_SIZE,
18
18
  DEFAULT_MODEL,
19
+ DEFAULT_PROVIDER,
20
+ SUPPORTED_PROVIDERS,
19
21
  load_config,
20
- set_api_key,
21
- set_batch_size,
22
- set_model,
22
+ )
23
+ from .modes import available_modes, get_strategy
24
+ from .services.cache_service import load_index_metadata_safe
25
+ from .services.config_service import apply_config_updates, get_config_snapshot
26
+ from .services.index_service import IndexStatus, build_index, clear_index_entries
27
+ from .services.search_service import SearchRequest, perform_search
28
+ from .services.system_service import (
29
+ fetch_remote_version,
30
+ find_command_on_path,
31
+ resolve_editor_command,
32
+ version_tuple,
23
33
  )
24
34
  from .text import Messages, Styles
25
- from .utils import collect_files, resolve_directory, format_path, ensure_positive
35
+ from .utils import resolve_directory, format_path, ensure_positive
36
+
37
+ if TYPE_CHECKING: # pragma: no cover - typing only
38
+ from .search import SearchResult
26
39
 
27
40
  REMOTE_VERSION_URL = "https://raw.githubusercontent.com/scarletkc/vexor/refs/heads/main/vexor/__init__.py"
28
41
  PROJECT_URL = "https://github.com/scarletkc/vexor"
@@ -36,11 +49,6 @@ app = typer.Typer(
36
49
  )
37
50
 
38
51
 
39
- @dataclass(slots=True)
40
- class DisplayResult:
41
- path: Path
42
- score: float
43
-
44
52
 
45
53
  def _version_callback(value: bool) -> None:
46
54
  if value:
@@ -48,6 +56,17 @@ def _version_callback(value: bool) -> None:
48
56
  raise typer.Exit()
49
57
 
50
58
 
59
+ def _validate_mode(mode: str) -> str:
60
+ try:
61
+ get_strategy(mode)
62
+ except ValueError as exc:
63
+ allowed = ", ".join(available_modes())
64
+ raise typer.BadParameter(
65
+ Messages.ERROR_MODE_INVALID.format(value=mode, allowed=allowed)
66
+ ) from exc
67
+ return mode
68
+
69
+
51
70
  @app.callback()
52
71
  def main(
53
72
  version: bool = typer.Option(
@@ -78,11 +97,26 @@ def search(
78
97
  "--include-hidden",
79
98
  help=Messages.HELP_INCLUDE_HIDDEN,
80
99
  ),
100
+ mode: str = typer.Option(
101
+ ...,
102
+ "--mode",
103
+ "-m",
104
+ help=Messages.HELP_MODE,
105
+ ),
106
+ no_recursive: bool = typer.Option(
107
+ False,
108
+ "--no-recursive",
109
+ "-n",
110
+ help=Messages.HELP_RECURSIVE,
111
+ ),
81
112
  ) -> None:
82
113
  """Run the semantic search using a cached index."""
83
114
  config = load_config()
84
115
  model_name = config.model or DEFAULT_MODEL
85
116
  batch_size = config.batch_size if config.batch_size is not None else DEFAULT_BATCH_SIZE
117
+ provider = config.provider or DEFAULT_PROVIDER
118
+ base_url = config.base_url
119
+ api_key = config.api_key
86
120
 
87
121
  clean_query = query.strip()
88
122
  if not clean_query:
@@ -94,45 +128,45 @@ def search(
94
128
  raise typer.BadParameter(str(exc), param_name="top") from exc
95
129
 
96
130
  directory = resolve_directory(path)
131
+ mode_value = _validate_mode(mode)
132
+ recursive = not no_recursive
97
133
  console.print(_styled(Messages.INFO_SEARCH_RUNNING.format(path=directory), Styles.INFO))
134
+ request = SearchRequest(
135
+ query=clean_query,
136
+ directory=directory,
137
+ include_hidden=include_hidden,
138
+ mode=mode_value,
139
+ recursive=recursive,
140
+ top_k=top,
141
+ model_name=model_name,
142
+ batch_size=batch_size,
143
+ provider=provider,
144
+ base_url=base_url,
145
+ api_key=api_key,
146
+ )
98
147
  try:
99
- cached_paths, file_vectors, meta = _load_index(directory, model_name, include_hidden)
148
+ response = perform_search(request)
100
149
  except FileNotFoundError:
101
150
  console.print(
102
151
  _styled(Messages.ERROR_INDEX_MISSING.format(path=directory), Styles.ERROR)
103
152
  )
104
153
  raise typer.Exit(code=1)
105
-
106
- _warn_if_stale(directory, include_hidden, meta.get("files", []))
107
-
108
- if not cached_paths:
109
- console.print(_styled(Messages.INFO_INDEX_EMPTY, Styles.WARNING))
110
- raise typer.Exit(code=0)
111
-
112
- searcher = _create_searcher(model_name=model_name, batch_size=batch_size)
113
- try:
114
- query_vector = searcher.embed_texts([clean_query])[0]
115
154
  except RuntimeError as exc:
116
155
  console.print(_styled(str(exc), Styles.ERROR))
117
156
  raise typer.Exit(code=1)
118
157
 
119
- from sklearn.metrics.pairwise import cosine_similarity # local import
120
-
121
- similarities = cosine_similarity(
122
- query_vector.reshape(1, -1), file_vectors
123
- )[0]
124
- scored = [
125
- DisplayResult(path=path, score=float(score))
126
- for path, score in zip(cached_paths, similarities)
127
- ]
128
- scored.sort(key=lambda item: item.score, reverse=True)
129
- results = scored[:top]
130
-
131
- if not results:
158
+ if response.index_empty:
159
+ console.print(_styled(Messages.INFO_INDEX_EMPTY, Styles.WARNING))
160
+ raise typer.Exit(code=0)
161
+ if response.is_stale:
162
+ console.print(
163
+ _styled(Messages.WARNING_INDEX_STALE.format(path=directory), Styles.WARNING)
164
+ )
165
+ if not response.results:
132
166
  console.print(_styled(Messages.INFO_NO_RESULTS, Styles.WARNING))
133
167
  raise typer.Exit(code=0)
134
168
 
135
- _render_results(results, directory, searcher.device)
169
+ _render_results(response.results, response.base_path, response.backend)
136
170
 
137
171
 
138
172
  @app.command()
@@ -148,20 +182,84 @@ def index(
148
182
  "--include-hidden",
149
183
  help=Messages.HELP_INDEX_INCLUDE,
150
184
  ),
185
+ mode: str = typer.Option(
186
+ ...,
187
+ "--mode",
188
+ "-m",
189
+ help=Messages.HELP_MODE,
190
+ ),
191
+ no_recursive: bool = typer.Option(
192
+ False,
193
+ "--no-recursive",
194
+ "-n",
195
+ help=Messages.HELP_RECURSIVE,
196
+ ),
151
197
  clear: bool = typer.Option(
152
198
  False,
153
199
  "--clear",
154
200
  help=Messages.HELP_INDEX_CLEAR,
155
201
  ),
202
+ show_cache: bool = typer.Option(
203
+ False,
204
+ "--show",
205
+ help=Messages.HELP_INDEX_SHOW,
206
+ ),
156
207
  ) -> None:
157
208
  """Create or refresh the cached index for the given directory."""
158
209
  config = load_config()
159
210
  model_name = config.model or DEFAULT_MODEL
160
211
  batch_size = config.batch_size if config.batch_size is not None else DEFAULT_BATCH_SIZE
212
+ provider = config.provider or DEFAULT_PROVIDER
213
+ base_url = config.base_url
214
+ api_key = config.api_key
161
215
 
162
216
  directory = resolve_directory(path)
217
+ mode_value = _validate_mode(mode)
218
+ recursive = not no_recursive
219
+ if clear and show_cache:
220
+ raise typer.BadParameter(Messages.ERROR_INDEX_SHOW_CONFLICT)
221
+
222
+ if show_cache:
223
+ metadata = load_index_metadata_safe(
224
+ directory,
225
+ model_name,
226
+ include_hidden,
227
+ mode_value,
228
+ recursive,
229
+ )
230
+ if not metadata:
231
+ console.print(
232
+ _styled(
233
+ Messages.INFO_INDEX_CLEAR_NONE.format(path=directory),
234
+ Styles.INFO,
235
+ )
236
+ )
237
+ return
238
+
239
+ files = metadata.get("files", [])
240
+ console.print(
241
+ _styled(Messages.INFO_INDEX_SHOW_HEADER.format(path=directory), Styles.TITLE)
242
+ )
243
+ summary = Messages.INFO_INDEX_SHOW_SUMMARY.format(
244
+ mode=metadata.get("mode"),
245
+ model=metadata.get("model"),
246
+ hidden="yes" if metadata.get("include_hidden") else "no",
247
+ recursive="yes" if metadata.get("recursive") else "no",
248
+ files=len(files),
249
+ dimension=metadata.get("dimension"),
250
+ version=metadata.get("version"),
251
+ generated=metadata.get("generated_at"),
252
+ )
253
+ console.print(_styled(summary, Styles.INFO))
254
+ return
255
+
163
256
  if clear:
164
- removed = _clear_index_cache(directory, include_hidden)
257
+ removed = clear_index_entries(
258
+ directory,
259
+ include_hidden=include_hidden,
260
+ mode=mode_value,
261
+ recursive=recursive,
262
+ )
165
263
  if removed:
166
264
  plural = "ies" if removed > 1 else "y"
167
265
  console.print(
@@ -184,34 +282,31 @@ def index(
184
282
  return
185
283
 
186
284
  console.print(_styled(Messages.INFO_INDEX_RUNNING.format(path=directory), Styles.INFO))
187
- files = collect_files(directory, include_hidden=include_hidden)
188
- if not files:
285
+ try:
286
+ result = build_index(
287
+ directory,
288
+ include_hidden=include_hidden,
289
+ mode=mode_value,
290
+ recursive=recursive,
291
+ model_name=model_name,
292
+ batch_size=batch_size,
293
+ provider=provider,
294
+ base_url=base_url,
295
+ api_key=api_key,
296
+ )
297
+ except RuntimeError as exc:
298
+ console.print(_styled(str(exc), Styles.ERROR))
299
+ raise typer.Exit(code=1)
300
+ if result.status == IndexStatus.EMPTY:
189
301
  console.print(_styled(Messages.INFO_NO_FILES, Styles.WARNING))
190
302
  raise typer.Exit(code=0)
191
-
192
- existing_meta = _load_index_metadata_safe(directory, model_name, include_hidden)
193
- if existing_meta:
194
- cached_files = existing_meta.get("files", [])
195
- if cached_files and _is_cache_current(
196
- directory, include_hidden, cached_files, current_files=files
197
- ):
198
- console.print(
199
- _styled(Messages.INFO_INDEX_UP_TO_DATE.format(path=directory), Styles.INFO)
200
- )
201
- return
202
-
203
- searcher = _create_searcher(model_name=model_name, batch_size=batch_size)
204
- file_labels = [_label_for_path(file) for file in files]
205
- embeddings = searcher.embed_texts(file_labels)
206
-
207
- cache_path = _store_index(
208
- root=directory,
209
- model=model_name,
210
- include_hidden=include_hidden,
211
- files=files,
212
- embeddings=embeddings,
213
- )
214
- console.print(_styled(Messages.INFO_INDEX_SAVED.format(path=cache_path), Styles.SUCCESS))
303
+ if result.status == IndexStatus.UP_TO_DATE:
304
+ console.print(
305
+ _styled(Messages.INFO_INDEX_UP_TO_DATE.format(path=directory), Styles.INFO)
306
+ )
307
+ return
308
+ if result.cache_path is not None:
309
+ console.print(_styled(Messages.INFO_INDEX_SAVED.format(path=result.cache_path), Styles.SUCCESS))
215
310
 
216
311
 
217
312
  @app.command()
@@ -236,57 +331,158 @@ def config(
236
331
  "--set-batch-size",
237
332
  help=Messages.HELP_SET_BATCH,
238
333
  ),
334
+ set_provider_option: str | None = typer.Option(
335
+ None,
336
+ "--set-provider",
337
+ help=Messages.HELP_SET_PROVIDER,
338
+ ),
339
+ set_base_url_option: str | None = typer.Option(
340
+ None,
341
+ "--set-base-url",
342
+ help=Messages.HELP_SET_BASE_URL,
343
+ ),
344
+ clear_base_url: bool = typer.Option(
345
+ False,
346
+ "--clear-base-url",
347
+ help=Messages.HELP_CLEAR_BASE_URL,
348
+ ),
239
349
  show: bool = typer.Option(
240
350
  False,
241
351
  "--show",
242
352
  help=Messages.HELP_SHOW_CONFIG,
243
353
  ),
354
+ show_index_all: bool = typer.Option(
355
+ False,
356
+ "--show-index-all",
357
+ help=Messages.HELP_SHOW_INDEX_ALL,
358
+ ),
359
+ clear_index_all: bool = typer.Option(
360
+ False,
361
+ "--clear-index-all",
362
+ help=Messages.HELP_CLEAR_INDEX_ALL,
363
+ ),
244
364
  ) -> None:
245
365
  """Manage Vexor configuration stored in ~/.vexor/config.json."""
246
- changed = False
366
+ if set_batch_option is not None and set_batch_option < 0:
367
+ raise typer.BadParameter(Messages.ERROR_BATCH_NEGATIVE)
368
+ if set_base_url_option and clear_base_url:
369
+ raise typer.BadParameter(Messages.ERROR_BASE_URL_CONFLICT)
370
+ if set_provider_option is not None:
371
+ normalized_provider = set_provider_option.strip().lower()
372
+ if normalized_provider not in SUPPORTED_PROVIDERS:
373
+ allowed = ", ".join(SUPPORTED_PROVIDERS)
374
+ raise typer.BadParameter(
375
+ Messages.ERROR_PROVIDER_INVALID.format(
376
+ value=set_provider_option, allowed=allowed
377
+ )
378
+ )
379
+ set_provider_option = normalized_provider
380
+
381
+ updates = apply_config_updates(
382
+ api_key=set_api_key_option,
383
+ clear_api_key=clear_api_key,
384
+ model=set_model_option,
385
+ batch_size=set_batch_option,
386
+ provider=set_provider_option,
387
+ base_url=set_base_url_option,
388
+ clear_base_url=clear_base_url,
389
+ )
247
390
 
248
- if set_api_key_option is not None:
249
- set_api_key(set_api_key_option)
391
+ if updates.api_key_set:
250
392
  console.print(_styled(Messages.INFO_API_SAVED, Styles.SUCCESS))
251
- changed = True
252
- if clear_api_key:
253
- set_api_key(None)
393
+ if updates.api_key_cleared:
254
394
  console.print(_styled(Messages.INFO_API_CLEARED, Styles.SUCCESS))
255
- changed = True
256
- if set_model_option is not None:
257
- set_model(set_model_option)
395
+ if updates.model_set and set_model_option is not None:
258
396
  console.print(
259
397
  _styled(Messages.INFO_MODEL_SET.format(value=set_model_option), Styles.SUCCESS)
260
398
  )
261
- changed = True
262
- if set_batch_option is not None:
263
- if set_batch_option < 0:
264
- raise typer.BadParameter(Messages.ERROR_BATCH_NEGATIVE)
265
- set_batch_size(set_batch_option)
399
+ if updates.batch_size_set and set_batch_option is not None:
266
400
  console.print(
267
401
  _styled(Messages.INFO_BATCH_SET.format(value=set_batch_option), Styles.SUCCESS)
268
402
  )
269
- changed = True
403
+ if updates.provider_set and set_provider_option is not None:
404
+ console.print(
405
+ _styled(Messages.INFO_PROVIDER_SET.format(value=set_provider_option), Styles.SUCCESS)
406
+ )
407
+ if updates.base_url_set and set_base_url_option is not None:
408
+ console.print(
409
+ _styled(Messages.INFO_BASE_URL_SET.format(value=set_base_url_option), Styles.SUCCESS)
410
+ )
411
+ if updates.base_url_cleared and clear_base_url:
412
+ console.print(_styled(Messages.INFO_BASE_URL_CLEARED, Styles.SUCCESS))
413
+
414
+ if clear_index_all:
415
+ removed = clear_all_cache()
416
+ if removed:
417
+ plural = "ies" if removed > 1 else "y"
418
+ console.print(
419
+ _styled(
420
+ Messages.INFO_INDEX_ALL_CLEARED.format(count=removed, plural=plural),
421
+ Styles.SUCCESS,
422
+ )
423
+ )
424
+ else:
425
+ console.print(_styled(Messages.INFO_INDEX_ALL_CLEAR_NONE, Styles.INFO))
426
+
427
+ should_edit = not any(
428
+ (
429
+ updates.changed,
430
+ show,
431
+ show_index_all,
432
+ clear_index_all,
433
+ )
434
+ )
435
+ if should_edit:
436
+ _edit_config_file()
437
+ return
270
438
 
271
- if show or not changed:
272
- cfg = load_config()
439
+ if show:
440
+ cfg = get_config_snapshot()
273
441
  console.print(
274
442
  _styled(
275
443
  Messages.INFO_CONFIG_SUMMARY.format(
276
444
  api="yes" if cfg.api_key else "no",
445
+ provider=cfg.provider or DEFAULT_PROVIDER,
277
446
  model=cfg.model or DEFAULT_MODEL,
278
447
  batch=cfg.batch_size if cfg.batch_size is not None else DEFAULT_BATCH_SIZE,
448
+ base_url=cfg.base_url or "none",
279
449
  ),
280
450
  Styles.INFO,
281
451
  )
282
452
  )
283
453
 
454
+ if show_index_all:
455
+ entries = list_cache_entries()
456
+ if not entries:
457
+ console.print(_styled(Messages.INFO_INDEX_ALL_EMPTY, Styles.INFO))
458
+ else:
459
+ console.print(_styled(Messages.INFO_INDEX_ALL_HEADER, Styles.TITLE))
460
+ table = Table(show_header=True, header_style=Styles.TABLE_HEADER)
461
+ table.add_column(Messages.TABLE_INDEX_HEADER_ROOT)
462
+ table.add_column(Messages.TABLE_INDEX_HEADER_MODE)
463
+ table.add_column(Messages.TABLE_INDEX_HEADER_MODEL)
464
+ table.add_column(Messages.TABLE_INDEX_HEADER_HIDDEN, justify="center")
465
+ table.add_column(Messages.TABLE_INDEX_HEADER_RECURSIVE, justify="center")
466
+ table.add_column(Messages.TABLE_INDEX_HEADER_FILES, justify="right")
467
+ table.add_column(Messages.TABLE_INDEX_HEADER_GENERATED)
468
+ for entry in entries:
469
+ table.add_row(
470
+ str(entry["root_path"]),
471
+ str(entry["mode"]),
472
+ str(entry["model"]),
473
+ "yes" if entry["include_hidden"] else "no",
474
+ "yes" if entry["recursive"] else "no",
475
+ str(entry["file_count"]),
476
+ str(entry["generated_at"]),
477
+ )
478
+ console.print(table)
479
+
284
480
 
285
481
  @app.command()
286
482
  def doctor() -> None:
287
483
  """Check whether the `vexor` command is available on PATH."""
288
484
  console.print(_styled(Messages.INFO_DOCTOR_CHECKING, Styles.INFO))
289
- command_path = shutil.which("vexor")
485
+ command_path = find_command_on_path("vexor")
290
486
  if command_path:
291
487
  console.print(
292
488
  _styled(Messages.INFO_DOCTOR_FOUND.format(path=command_path), Styles.SUCCESS)
@@ -302,14 +498,14 @@ def update() -> None:
302
498
  console.print(_styled(Messages.INFO_UPDATE_CHECKING, Styles.INFO))
303
499
  console.print(_styled(Messages.INFO_UPDATE_CURRENT.format(current=__version__), Styles.INFO))
304
500
  try:
305
- latest = _fetch_remote_version()
501
+ latest = fetch_remote_version(REMOTE_VERSION_URL)
306
502
  except RuntimeError as exc:
307
503
  console.print(
308
504
  _styled(Messages.ERROR_UPDATE_FETCH.format(reason=str(exc)), Styles.ERROR)
309
505
  )
310
506
  raise typer.Exit(code=1)
311
507
 
312
- if _version_tuple(latest) > _version_tuple(__version__):
508
+ if version_tuple(latest) > version_tuple(__version__):
313
509
  console.print(
314
510
  _styled(
315
511
  Messages.INFO_UPDATE_AVAILABLE.format(
@@ -327,7 +523,7 @@ def update() -> None:
327
523
  )
328
524
 
329
525
 
330
- def _render_results(results: Sequence[DisplayResult], base: Path, backend: str | None) -> None:
526
+ def _render_results(results: Sequence["SearchResult"], base: Path, backend: str | None) -> None:
331
527
  console.print(_styled(Messages.TABLE_TITLE, Styles.TITLE))
332
528
  if backend:
333
529
  console.print(_styled(f"{Messages.TABLE_BACKEND_PREFIX}{backend}", Styles.INFO))
@@ -335,132 +531,83 @@ def _render_results(results: Sequence[DisplayResult], base: Path, backend: str |
335
531
  table.add_column(Messages.TABLE_HEADER_INDEX, justify="right")
336
532
  table.add_column(Messages.TABLE_HEADER_SIMILARITY, justify="right")
337
533
  table.add_column(Messages.TABLE_HEADER_PATH, overflow="fold")
534
+ table.add_column(Messages.TABLE_HEADER_PREVIEW, overflow="fold")
338
535
  for idx, result in enumerate(results, start=1):
339
536
  table.add_row(
340
537
  str(idx),
341
538
  f"{result.score:.3f}",
342
539
  format_path(result.path, base),
540
+ _format_preview(result.preview),
343
541
  )
344
542
  console.print(table)
345
543
 
346
544
 
347
- def _create_searcher(model_name: str, batch_size: int):
348
- from .search import VexorSearcher # Local import keeps CLI startup fast
349
-
350
- return VexorSearcher(model_name=model_name, batch_size=batch_size)
351
-
352
-
353
- def _label_for_path(path: Path) -> str:
354
- return path.name.replace("_", " ")
355
-
356
-
357
- def _load_index(root: Path, model: str, include_hidden: bool):
358
- from .cache import load_index_vectors # local import
359
-
360
- return load_index_vectors(root, model, include_hidden)
361
-
545
+ def _styled(text: str, style: str) -> str:
546
+ return f"[{style}]{text}[/{style}]"
362
547
 
363
- def _load_index_metadata_safe(root: Path, model: str, include_hidden: bool):
364
- from .cache import load_index # local import
365
548
 
366
- try:
367
- return load_index(root, model, include_hidden)
368
- except FileNotFoundError:
369
- return None
549
+ def _format_preview(text: str | None, limit: int = 80) -> str:
550
+ if not text:
551
+ return "-"
552
+ snippet = text.strip()
553
+ if len(snippet) <= limit:
554
+ return snippet
555
+ return snippet[: limit - 1].rstrip() + "…"
370
556
 
371
557
 
372
- def _store_index(**kwargs):
373
- from .cache import store_index # local import
558
+ def run(argv: list[str] | None = None) -> None:
559
+ """Entry point wrapper allowing optional argument override."""
560
+ if argv is None:
561
+ app()
562
+ else:
563
+ app(args=list(argv))
374
564
 
375
- return store_index(**kwargs)
376
565
 
566
+ def _format_command(parts: Sequence[str]) -> str:
567
+ return " ".join(shlex.quote(part) for part in parts)
377
568
 
378
- def _clear_index_cache(root: Path, include_hidden: bool, model: str | None = None) -> int:
379
- from .cache import clear_index # local import
380
569
 
381
- return clear_index(root=root, include_hidden=include_hidden, model=model)
570
+ def _ensure_config_file() -> Path:
571
+ config_path = config_module.CONFIG_FILE
572
+ config_path.parent.mkdir(parents=True, exist_ok=True)
573
+ if not config_path.exists():
574
+ config_path.write_text("{}\n", encoding="utf-8")
575
+ return config_path
382
576
 
383
577
 
384
- def _fetch_remote_version(url: str = REMOTE_VERSION_URL) -> str:
385
- from urllib import request, error
578
+ def _edit_config_file() -> None:
579
+ command = resolve_editor_command()
580
+ if not command:
581
+ console.print(_styled(Messages.ERROR_CONFIG_EDITOR_NOT_FOUND, Styles.ERROR))
582
+ raise typer.Exit(code=1)
386
583
 
387
- try:
388
- with request.urlopen(url, timeout=10) as response:
389
- if response.status != 200:
390
- raise RuntimeError(f"HTTP {response.status}")
391
- text = response.read().decode("utf-8")
392
- except error.URLError as exc: # pragma: no cover - network error
393
- raise RuntimeError(str(exc)) from exc
394
-
395
- match = re.search(r"__version__\s*=\s*['\"]([^'\"]+)['\"]", text)
396
- if not match:
397
- raise RuntimeError("Version string not found")
398
- return match.group(1)
399
-
400
-
401
- def _version_tuple(raw: str) -> tuple[int, int, int, int]:
402
- raw = raw.strip()
403
- release_parts: list[int] = []
404
- suffix_number = 0
405
-
406
- for piece in raw.split('.'):
407
- match = re.match(r"^(\d+)", piece)
408
- if not match:
409
- break
410
- release_parts.append(int(match.group(1)))
411
- remainder = piece[match.end():]
412
- if remainder:
413
- suffix_match = re.match(r"[A-Za-z]+(\d+)", remainder)
414
- if suffix_match:
415
- suffix_number = int(suffix_match.group(1))
416
- break
417
- if len(release_parts) >= 4:
418
- break
419
-
420
- while len(release_parts) < 4:
421
- release_parts.append(0)
422
-
423
- if suffix_number:
424
- release_parts[3] = suffix_number
425
-
426
- return tuple(release_parts[:4])
427
-
428
-
429
- def _is_cache_current(
430
- root: Path,
431
- include_hidden: bool,
432
- cached_files: Sequence[dict],
433
- *,
434
- current_files: Sequence[Path] | None = None,
435
- ) -> bool:
436
- if not cached_files:
437
- return False
438
- from .cache import compare_snapshot # local import
439
-
440
- return compare_snapshot(
441
- root,
442
- include_hidden,
443
- cached_files,
444
- current_files=current_files,
584
+ cmd_list = list(command)
585
+ config_path = _ensure_config_file()
586
+ console.print(
587
+ _styled(
588
+ Messages.INFO_CONFIG_EDITING.format(
589
+ path=config_path,
590
+ editor=_format_command(cmd_list),
591
+ ),
592
+ Styles.INFO,
593
+ )
445
594
  )
446
-
447
-
448
- def _warn_if_stale(root: Path, include_hidden: bool, cached_files: Sequence[dict]) -> None:
449
- if not cached_files:
450
- return
451
- if not _is_cache_current(root, include_hidden, cached_files):
595
+ try:
596
+ subprocess.run(cmd_list + [str(config_path)], check=True)
597
+ except FileNotFoundError as exc:
598
+ console.print(
599
+ _styled(
600
+ Messages.ERROR_CONFIG_EDITOR_LAUNCH.format(reason=str(exc)),
601
+ Styles.ERROR,
602
+ )
603
+ )
604
+ raise typer.Exit(code=1)
605
+ except subprocess.CalledProcessError as exc:
606
+ code = exc.returncode if exc.returncode is not None else 1
452
607
  console.print(
453
- _styled(Messages.WARNING_INDEX_STALE.format(path=root), Styles.WARNING)
608
+ _styled(
609
+ Messages.ERROR_CONFIG_EDITOR_FAILED.format(code=code),
610
+ Styles.ERROR,
611
+ )
454
612
  )
455
-
456
-
457
- def _styled(text: str, style: str) -> str:
458
- return f"[{style}]{text}[/{style}]"
459
-
460
-
461
- def run(argv: list[str] | None = None) -> None:
462
- """Entry point wrapper allowing optional argument override."""
463
- if argv is None:
464
- app()
465
- else:
466
- app(args=list(argv))
613
+ raise typer.Exit(code=code)