bhtool 0.2.3__tar.gz → 0.2.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bhtool
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Assorted CLI utilities for Bosco’s workflows (dev, media, macOS, LLM helpers).
5
5
  Author-email: Bosco Ho <apposite@gmail.com>
6
6
  Requires-Python: >=3.13
@@ -26,10 +26,6 @@ _MOVIES_BORDER = "cyan"
26
26
  _MOVIES_TITLE = "movies"
27
27
 
28
28
 
29
- def _movies_console() -> Console:
30
- return Console(highlight=False)
31
-
32
-
33
29
  def _panel_lines(body: Text | str, *, title: str = _MOVIES_TITLE, border: str = _MOVIES_BORDER) -> Panel:
34
30
  return Panel(
35
31
  body,
@@ -41,6 +37,32 @@ def _panel_lines(body: Text | str, *, title: str = _MOVIES_TITLE, border: str =
41
37
  )
42
38
 
43
39
 
40
+ def _is_case_only_rename(old_path: Path, new_path: Path) -> bool:
41
+ """True when old_path and new_path are the same file differing only in case.
42
+
43
+ On case-insensitive filesystems (macOS, Windows) new_path.exists() is True
44
+ for a pure capitalization change, since it resolves to the same file.
45
+ """
46
+ try:
47
+ return old_path.samefile(new_path)
48
+ except OSError:
49
+ return False
50
+
51
+
52
+ def _apply_rename(old_path: Path, new_path: Path) -> None:
53
+ """Rename, forcing a case change on case-insensitive filesystems."""
54
+ try:
55
+ old_path.rename(new_path)
56
+ except OSError:
57
+ tmp_path = old_path.with_name(old_path.name + ".bhtool-tmp")
58
+ old_path.rename(tmp_path)
59
+ try:
60
+ tmp_path.rename(new_path)
61
+ except OSError:
62
+ tmp_path.rename(old_path)
63
+ raise
64
+
65
+
44
66
  def _status_text(status: str) -> Text:
45
67
  if status == "would rename":
46
68
  return Text(status, style="green")
@@ -53,7 +75,10 @@ NORMALIZE_SYSTEM_PROMPT = textwrap.dedent("""
53
75
  You normalize movie and TV directory and file names for a media library.
54
76
 
55
77
  Rules:
56
- - Output the title in Title Case.
78
+ - Output the title in Title Case: capitalize the first and last words and
79
+ all principal words, but keep articles, conjunctions and short
80
+ prepositions (a, an, the, and, or, of, in, on, to, for) lowercase
81
+ unless they are the first or last word (e.g. "The Lord of the Rings").
57
82
  - Keep the year as (YYYY) at the end when present; infer from the name
58
83
  if needed.
59
84
  - For TV series: preserve season markers at the end if present (e.g. S1,
@@ -112,7 +137,7 @@ async def normalize_names_with_llm(
112
137
  if not dir_names and not file_names:
113
138
  return {"directories": [], "files": []}
114
139
 
115
- console = console or _movies_console()
140
+ console = console or Console()
116
141
  dir_list = "\n".join(f"- {n}" for n in dir_names) or "(none)"
117
142
  file_list = "\n".join(f"- {n}" for n in file_names) or "(none)"
118
143
  user_content = NORMALIZE_USER_PROMPT_TEMPLATE.format(
@@ -127,13 +152,17 @@ async def normalize_names_with_llm(
127
152
  try:
128
153
  async with get_llm_client(service) as client:
129
154
  llm_body = Text.assemble(
130
- (f"service [bold]{client.service}[/bold] model [bold]{client.model}[/bold]\n", ""),
155
+ ("service ", ""),
156
+ (client.service, "bold"),
157
+ (" model ", ""),
158
+ (client.model, "bold"),
159
+ ("\n", ""),
131
160
  ("Requesting normalized names from the LLM…", "dim"),
132
161
  )
133
162
  console.print(_panel_lines(llm_body, title="LLM", border="blue"))
134
163
  result = await client.get_completion(messages)
135
164
  except Exception as e:
136
- err = _movies_console(stderr=True)
165
+ err = Console(stderr=True)
137
166
  err.print(CycloptsPanel(str(e), title="Error", style="red"))
138
167
  err.print(
139
168
  CycloptsPanel(
@@ -154,7 +183,7 @@ async def normalize_names_with_llm(
154
183
 
155
184
 
156
185
  def run_normalize_with_llm(root_dir, service="openai", *, console: Console | None = None):
157
- console = console or _movies_console()
186
+ console = console or Console()
158
187
  root = Path(root_dir)
159
188
  video_suffixes = {".avi", ".mkv", ".mp4", ".m4v", ".mov", ".wmv", ".webm"}
160
189
  skip_names = {
@@ -202,7 +231,7 @@ def run_normalize_with_llm(root_dir, service="openai", *, console: Console | Non
202
231
  def rename_movies(
203
232
  root_dir, dry_run=True, mapping=None, *, console: Console | None = None
204
233
  ):
205
- console = console or _movies_console()
234
+ console = console or Console()
206
235
  root = Path(root_dir)
207
236
  if mapping is None:
208
237
  mapping = {"directories": [], "files": []}
@@ -223,7 +252,7 @@ def rename_movies(
223
252
  if table_rows is not None:
224
253
  table_rows.append(("DIR", old_name, new_name, "skip (not found)"))
225
254
  continue
226
- if new_path.exists():
255
+ if new_path.exists() and not _is_case_only_rename(old_path, new_path):
227
256
  skipped_count += 1
228
257
  if table_rows is not None:
229
258
  table_rows.append(("DIR", old_name, new_name, "skip (exists)"))
@@ -231,7 +260,7 @@ def rename_movies(
231
260
  if dry_run:
232
261
  table_rows.append(("DIR", old_name, new_name, "would rename"))
233
262
  else:
234
- old_path.rename(new_path)
263
+ _apply_rename(old_path, new_path)
235
264
  console.print(
236
265
  Text.assemble(
237
266
  ("✅ DIR ", "green"),
@@ -256,7 +285,7 @@ def rename_movies(
256
285
  ("FILE", old_name, new_name + suffix, "skip (not found)")
257
286
  )
258
287
  continue
259
- if new_path.exists():
288
+ if new_path.exists() and not _is_case_only_rename(old_path, new_path):
260
289
  skipped_count += 1
261
290
  if table_rows is not None:
262
291
  table_rows.append(
@@ -266,7 +295,7 @@ def rename_movies(
266
295
  if dry_run:
267
296
  table_rows.append(("FILE", old_name, new_name + suffix, "would rename"))
268
297
  else:
269
- old_path.rename(new_path)
298
+ _apply_rename(old_path, new_path)
270
299
  console.print(
271
300
  Text.assemble(
272
301
  ("✅ FILE ", "green"),
@@ -323,7 +352,7 @@ def _root_dir(movies_dir: str | None = None) -> Path:
323
352
 
324
353
 
325
354
  def rename(root_dir: str | None = None, execute: bool = False):
326
- console = _movies_console()
355
+ console = Console()
327
356
  intro = Text.assemble(
328
357
  ("LLM-normalize names → ", ""),
329
358
  ("movie_mapping.json", "cyan"),
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bhtool"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Assorted CLI utilities for Bosco’s workflows (dev, media, macOS, LLM helpers)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
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