bhtool 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bhtool-0.3.0 → bhtool-0.4.0}/PKG-INFO +3 -3
- {bhtool-0.3.0 → bhtool-0.4.0}/README.md +1 -1
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/cli.py +8 -2
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/list_movies.py +57 -57
- bhtool-0.4.0/bhtool/llm_services.py +77 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/pyproject.toml +2 -2
- {bhtool-0.3.0 → bhtool-0.4.0}/.gitignore +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/.python-version +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/bumpver.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/clr_chmod.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/gdoc.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/npread.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/portkill.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/psword.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/rm_npm.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/run.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/text.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/todict.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/bhtool/utils.py +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/jtools/jtools.js +0 -0
- {bhtool-0.3.0 → bhtool-0.4.0}/jtools/package.json +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bhtool
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
7
7
|
Requires-Dist: cyclopts>=4.10.2
|
|
8
|
-
Requires-Dist: microeval>=0.5.0
|
|
9
8
|
Requires-Dist: numpy>=1.26.4
|
|
10
9
|
Requires-Dist: path>=16.14.0
|
|
10
|
+
Requires-Dist: pydantic-ai>=2.55.0
|
|
11
11
|
Requires-Dist: pydash>=8.0.3
|
|
12
12
|
Requires-Dist: python-dotenv>=1.2.2
|
|
13
13
|
Requires-Dist: rich>=15.0.0
|
|
@@ -48,7 +48,7 @@ Assorted CLI utilities that are handy in Bosco’s day-to-day work—version bum
|
|
|
48
48
|
|
|
49
49
|
### Movies + LLM
|
|
50
50
|
|
|
51
|
-
`
|
|
51
|
+
`b movies` asks an LLM for normalized names. `--service <name>` selects a provider for one run (`groq`, `google`, `openai`, `anthropic`, `mistral`, `deepseek`, `xai`, `bedrock`, `ollama` — see `b movies --help`). Without it, the provider comes from `LLM_SERVICE`, else from the first API key in the environment, else `ollama`. Set keys in the process environment or in **`bhtool/.env`** (loaded from the installed package directory). Each provider uses one cheap model from `bhtool/llm_services.py` through [pydantic-ai](https://pypi.org/project/pydantic-ai/).
|
|
52
52
|
|
|
53
53
|
---
|
|
54
54
|
|
|
@@ -32,7 +32,7 @@ Assorted CLI utilities that are handy in Bosco’s day-to-day work—version bum
|
|
|
32
32
|
|
|
33
33
|
### Movies + LLM
|
|
34
34
|
|
|
35
|
-
`
|
|
35
|
+
`b movies` asks an LLM for normalized names. `--service <name>` selects a provider for one run (`groq`, `google`, `openai`, `anthropic`, `mistral`, `deepseek`, `xai`, `bedrock`, `ollama` — see `b movies --help`). Without it, the provider comes from `LLM_SERVICE`, else from the first API key in the environment, else `ollama`. Set keys in the process environment or in **`bhtool/.env`** (loaded from the installed package directory). Each provider uses one cheap model from `bhtool/llm_services.py` through [pydantic-ai](https://pypi.org/project/pydantic-ai/).
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
@@ -7,6 +7,7 @@ from typing import Literal
|
|
|
7
7
|
from cyclopts import App
|
|
8
8
|
|
|
9
9
|
from bhtool.gdoc import app as gdoc_app
|
|
10
|
+
from bhtool.llm_services import LLMService
|
|
10
11
|
|
|
11
12
|
app = App(name="bhtool", help="boscoh tools")
|
|
12
13
|
app.command(gdoc_app)
|
|
@@ -106,17 +107,22 @@ def todict_cmd(json: str | None = None, yaml: str | None = None):
|
|
|
106
107
|
|
|
107
108
|
|
|
108
109
|
@app.command(name="movies")
|
|
109
|
-
def movies_cmd(
|
|
110
|
+
def movies_cmd(
|
|
111
|
+
root_dir: str | None = None,
|
|
112
|
+
execute: bool = False,
|
|
113
|
+
service: LLMService | None = None,
|
|
114
|
+
):
|
|
110
115
|
"""Normalize movie names with an LLM.
|
|
111
116
|
|
|
112
117
|
:param root_dir: Root directory containing movies (positional); default is current working directory.
|
|
113
118
|
:param execute: If true, perform renames; otherwise dry run (table output).
|
|
119
|
+
:param service: LLM service for normalization; default is detected from the environment.
|
|
114
120
|
"""
|
|
115
121
|
import bhtool.list_movies as list_movies_mod
|
|
116
122
|
|
|
117
123
|
if root_dir == "rename":
|
|
118
124
|
root_dir = None
|
|
119
|
-
list_movies_mod.rename(root_dir=root_dir, execute=execute)
|
|
125
|
+
list_movies_mod.rename(root_dir=root_dir, execute=execute, service=service)
|
|
120
126
|
|
|
121
127
|
|
|
122
128
|
def main() -> None:
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
-
import re
|
|
7
6
|
import sys
|
|
8
7
|
import textwrap
|
|
9
8
|
|
|
@@ -16,12 +15,19 @@ from rich.panel import Panel
|
|
|
16
15
|
from rich.table import Table
|
|
17
16
|
from rich.text import Text
|
|
18
17
|
|
|
19
|
-
from
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
from pydantic_ai import Agent
|
|
20
|
+
from pydantic_ai.models import infer_model
|
|
21
|
+
|
|
22
|
+
from bhtool.llm_services import OLLAMA_BASE_URL, detect_service, model_for
|
|
20
23
|
|
|
21
24
|
_pkg_root = Path(__file__).parent
|
|
22
25
|
|
|
23
26
|
load_dotenv(_pkg_root / ".env")
|
|
24
27
|
|
|
28
|
+
# Suppress the pydantic-ai first-run banner in this CLI.
|
|
29
|
+
os.environ.setdefault("PYDANTIC_AI_NO_BANNER", "1")
|
|
30
|
+
|
|
25
31
|
_MOVIES_BORDER = "cyan"
|
|
26
32
|
_MOVIES_TITLE = "movies"
|
|
27
33
|
|
|
@@ -90,16 +96,10 @@ NORMALIZE_SYSTEM_PROMPT = textwrap.dedent("""
|
|
|
90
96
|
extension stays unchanged in the output.
|
|
91
97
|
- Preserve the original spelling of the title; only fix casing and
|
|
92
98
|
punctuation.
|
|
93
|
-
- Return valid JSON only, no markdown or extra text.
|
|
94
99
|
""").strip()
|
|
95
100
|
|
|
96
101
|
NORMALIZE_USER_PROMPT_TEMPLATE = textwrap.dedent("""
|
|
97
|
-
Convert these names to normalized form.
|
|
98
|
-
two keys:
|
|
99
|
-
- "directories": list of {{"original": "<dir name>",
|
|
100
|
-
"normalized": "<normalized dir name>"}}
|
|
101
|
-
- "files": list of {{"original": "<filename with extension>",
|
|
102
|
-
"normalized": "<normalized base name without extension>"}}
|
|
102
|
+
Convert these names to normalized form.
|
|
103
103
|
|
|
104
104
|
Directory names:
|
|
105
105
|
{dir_list}
|
|
@@ -109,81 +109,79 @@ NORMALIZE_USER_PROMPT_TEMPLATE = textwrap.dedent("""
|
|
|
109
109
|
""").strip()
|
|
110
110
|
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return data if isinstance(data, dict) else None
|
|
129
|
-
except json.JSONDecodeError:
|
|
130
|
-
return None
|
|
131
|
-
return None
|
|
112
|
+
class NameMapping(BaseModel):
|
|
113
|
+
"""One original name and its normalized form."""
|
|
114
|
+
|
|
115
|
+
original: str = Field(description="The original directory or file name.")
|
|
116
|
+
normalized: str = Field(description="The normalized name.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class NormalizedNameMapping(BaseModel):
|
|
120
|
+
"""Normalized names for the directories and files in one root directory."""
|
|
121
|
+
|
|
122
|
+
directories: list[NameMapping] = Field(
|
|
123
|
+
default_factory=list, description="Normalized directory names."
|
|
124
|
+
)
|
|
125
|
+
files: list[NameMapping] = Field(
|
|
126
|
+
default_factory=list, description="Normalized file names."
|
|
127
|
+
)
|
|
132
128
|
|
|
133
129
|
|
|
134
130
|
async def normalize_names_with_llm(
|
|
135
|
-
dir_names, file_names, service=
|
|
131
|
+
dir_names, file_names, service=None, *, console: Console | None = None
|
|
136
132
|
):
|
|
137
133
|
if not dir_names and not file_names:
|
|
138
134
|
return {"directories": [], "files": []}
|
|
139
135
|
|
|
140
136
|
console = console or Console()
|
|
137
|
+
service = service or detect_service()
|
|
141
138
|
dir_list = "\n".join(f"- {n}" for n in dir_names) or "(none)"
|
|
142
139
|
file_list = "\n".join(f"- {n}" for n in file_names) or "(none)"
|
|
143
140
|
user_content = NORMALIZE_USER_PROMPT_TEMPLATE.format(
|
|
144
141
|
dir_list=dir_list,
|
|
145
142
|
file_list=file_list,
|
|
146
143
|
)
|
|
147
|
-
messages = [
|
|
148
|
-
{"role": "system", "content": NORMALIZE_SYSTEM_PROMPT},
|
|
149
|
-
{"role": "user", "content": user_content},
|
|
150
|
-
]
|
|
151
144
|
|
|
152
145
|
try:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
146
|
+
if service == "ollama":
|
|
147
|
+
os.environ.setdefault("OLLAMA_BASE_URL", OLLAMA_BASE_URL)
|
|
148
|
+
model = infer_model(model_for(service))
|
|
149
|
+
agent = Agent(
|
|
150
|
+
model,
|
|
151
|
+
instructions=NORMALIZE_SYSTEM_PROMPT,
|
|
152
|
+
output_type=NormalizedNameMapping,
|
|
153
|
+
)
|
|
154
|
+
llm_body = Text.assemble(
|
|
155
|
+
("service ", ""),
|
|
156
|
+
(service, "bold"),
|
|
157
|
+
(" model ", ""),
|
|
158
|
+
(model.model_name, "bold"),
|
|
159
|
+
("\n", ""),
|
|
160
|
+
("Requesting normalized names from the LLM…", "dim"),
|
|
161
|
+
)
|
|
162
|
+
console.print(_panel_lines(llm_body, title="LLM", border="blue"))
|
|
163
|
+
result = await agent.run(user_content)
|
|
164
|
+
mapping = result.output
|
|
164
165
|
except Exception as e:
|
|
165
166
|
err = Console(stderr=True)
|
|
166
167
|
err.print(CycloptsPanel(str(e), title="Error", style="red"))
|
|
167
168
|
err.print(
|
|
168
169
|
CycloptsPanel(
|
|
169
|
-
"Set LLM_SERVICE
|
|
170
|
-
"
|
|
170
|
+
"Set LLM_SERVICE or --service and the matching API key. Providers: "
|
|
171
|
+
"groq, google, openai, anthropic, mistral, deepseek, xai, bedrock, "
|
|
172
|
+
"ollama. Use bhtool/.env or your environment — see README.",
|
|
171
173
|
title="Hint",
|
|
172
174
|
style="yellow",
|
|
173
175
|
)
|
|
174
176
|
)
|
|
175
177
|
sys.exit(1)
|
|
176
|
-
if result.get("text", "").strip().startswith("Error:"):
|
|
177
|
-
raise RuntimeError(result["text"])
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
if not data:
|
|
181
|
-
raise RuntimeError("LLM response could not be parsed as JSON")
|
|
182
|
-
return {"directories": data.get("directories", []), "files": data.get("files", [])}
|
|
179
|
+
return mapping.model_dump()
|
|
183
180
|
|
|
184
181
|
|
|
185
|
-
def run_normalize_with_llm(root_dir, service=
|
|
182
|
+
def run_normalize_with_llm(root_dir, service=None, *, console: Console | None = None):
|
|
186
183
|
console = console or Console()
|
|
184
|
+
service = service or detect_service()
|
|
187
185
|
root = Path(root_dir)
|
|
188
186
|
video_suffixes = {".avi", ".mkv", ".mp4", ".m4v", ".mov", ".wmv", ".webm"}
|
|
189
187
|
skip_names = {
|
|
@@ -219,7 +217,7 @@ def run_normalize_with_llm(root_dir, service="openai", *, console: Console | Non
|
|
|
219
217
|
f" {'video file' if len(file_names) == 1 else 'video files'} · ",
|
|
220
218
|
"dim",
|
|
221
219
|
),
|
|
222
|
-
("
|
|
220
|
+
("service ", "dim"),
|
|
223
221
|
(service, "bold"),
|
|
224
222
|
)
|
|
225
223
|
console.print(_panel_lines(scan, title="Scan", border=_MOVIES_BORDER))
|
|
@@ -351,7 +349,9 @@ def _root_dir(movies_dir: str | None = None) -> Path:
|
|
|
351
349
|
return Path.cwd()
|
|
352
350
|
|
|
353
351
|
|
|
354
|
-
def rename(
|
|
352
|
+
def rename(
|
|
353
|
+
root_dir: str | None = None, execute: bool = False, service: str | None = None
|
|
354
|
+
):
|
|
355
355
|
console = Console()
|
|
356
356
|
intro = Text.assemble(
|
|
357
357
|
("LLM-normalize names → ", ""),
|
|
@@ -362,7 +362,7 @@ def rename(root_dir: str | None = None, execute: bool = False):
|
|
|
362
362
|
)
|
|
363
363
|
console.print(_panel_lines(intro, title=_MOVIES_TITLE, border=_MOVIES_BORDER))
|
|
364
364
|
root = _root_dir(root_dir)
|
|
365
|
-
svc =
|
|
365
|
+
svc = service or detect_service()
|
|
366
366
|
mapping = run_normalize_with_llm(root, svc, console=console)
|
|
367
367
|
mapping_path = Path.cwd() / "movie_mapping.json"
|
|
368
368
|
mapping_path.write_text(json.dumps(mapping, indent=2))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Provider defaults for the movies LLM command.
|
|
4
|
+
|
|
5
|
+
One cheap model per provider, picked for the short name-normalization prompt.
|
|
6
|
+
Prices change, so check them before you add a provider. This module has no
|
|
7
|
+
heavy imports, so the CLI can show the list without loading pydantic-ai.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Literal, get_args
|
|
12
|
+
|
|
13
|
+
LLMService = Literal[
|
|
14
|
+
"groq",
|
|
15
|
+
"google",
|
|
16
|
+
"openai",
|
|
17
|
+
"anthropic",
|
|
18
|
+
"mistral",
|
|
19
|
+
"deepseek",
|
|
20
|
+
"xai",
|
|
21
|
+
"bedrock",
|
|
22
|
+
"ollama",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
PROVIDERS = get_args(LLMService)
|
|
26
|
+
|
|
27
|
+
# pydantic-ai model id is "<provider>:<model>".
|
|
28
|
+
# The value is the cheapest model that can follow the normalize instructions.
|
|
29
|
+
PROVIDER_MODELS = {
|
|
30
|
+
"groq": "openai/gpt-oss-20b", # $0.075 / $0.30 per 1M
|
|
31
|
+
"google": "gemini-2.5-flash-lite", # $0.10 / $0.40
|
|
32
|
+
"openai": "gpt-6-luna", # $0.10 / $0.50
|
|
33
|
+
"anthropic": "claude-haiku-5-5", # $0.10 / $0.50
|
|
34
|
+
"mistral": "ministral-3-8b-25-12", # $0.15 / $0.15
|
|
35
|
+
"deepseek": "deepseek-flash", # $0.15 / $0.60 off-peak
|
|
36
|
+
"xai": "grok-4.3", # $1.25 / $2.50
|
|
37
|
+
"bedrock": "amazon.nova-lite-v1:0", # region-dependent
|
|
38
|
+
"ollama": "llama3.2", # free, local
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Environment variable that enables a provider. Auto-detect reads these in order.
|
|
42
|
+
PROVIDER_ENV_VARS = {
|
|
43
|
+
"groq": ("GROQ_API_KEY",),
|
|
44
|
+
"google": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
|
45
|
+
"openai": ("OPENAI_API_KEY",),
|
|
46
|
+
"anthropic": ("ANTHROPIC_API_KEY",),
|
|
47
|
+
"mistral": ("MISTRAL_API_KEY",),
|
|
48
|
+
"deepseek": ("DEEPSEEK_API_KEY",),
|
|
49
|
+
"xai": ("XAI_API_KEY",),
|
|
50
|
+
"bedrock": ("AWS_ACCESS_KEY_ID", "AWS_PROFILE", "AWS_BEARER_TOKEN_BEDROCK"),
|
|
51
|
+
"ollama": ("OLLAMA_BASE_URL",),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# pydantic-ai needs this value for the Ollama provider.
|
|
55
|
+
OLLAMA_BASE_URL = "http://localhost:11434"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def model_for(service: str) -> str:
|
|
59
|
+
"""Return the pydantic-ai model id for a provider, for example 'openai:gpt-6-luna'."""
|
|
60
|
+
try:
|
|
61
|
+
model = PROVIDER_MODELS[service]
|
|
62
|
+
except KeyError:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Unknown service {service!r}; choose one of {', '.join(PROVIDERS)}"
|
|
65
|
+
) from None
|
|
66
|
+
return f"{service}:{model}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def detect_service() -> str:
|
|
70
|
+
"""Pick a provider from LLM_SERVICE, else from the environment, else ollama."""
|
|
71
|
+
env_service = os.environ.get("LLM_SERVICE")
|
|
72
|
+
if env_service:
|
|
73
|
+
return env_service.lower()
|
|
74
|
+
for provider in PROVIDERS:
|
|
75
|
+
if any(os.environ.get(var) for var in PROVIDER_ENV_VARS[provider]):
|
|
76
|
+
return provider
|
|
77
|
+
return "ollama"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "bhtool"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
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"
|
|
@@ -15,7 +15,7 @@ dependencies = [
|
|
|
15
15
|
"cyclopts>=4.10.2",
|
|
16
16
|
"python-dotenv>=1.2.2",
|
|
17
17
|
"rich>=15.0.0",
|
|
18
|
-
"
|
|
18
|
+
"pydantic-ai>=2.55.0",
|
|
19
19
|
]
|
|
20
20
|
|
|
21
21
|
[project.scripts]
|
|
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
|