bhtool 0.1.10__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.
- bhtool/bumpver.py +51 -0
- bhtool/clear_chmod.py +23 -0
- bhtool/cli.py +108 -0
- bhtool/dfplot.py +90 -0
- bhtool/list_movies.py +243 -0
- bhtool/npread.py +11 -0
- bhtool/psword.py +23 -0
- bhtool/rm_npm.py +27 -0
- bhtool/run.py +23 -0
- bhtool/text2.py +68 -0
- bhtool/todict.py +78 -0
- bhtool/utils.py +54 -0
- bhtool-0.1.10.dist-info/METADATA +52 -0
- bhtool-0.1.10.dist-info/RECORD +16 -0
- bhtool-0.1.10.dist-info/WHEEL +4 -0
- bhtool-0.1.10.dist-info/entry_points.txt +3 -0
bhtool/bumpver.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Bump version in pyproject.toml, commit, push, and optionally publish to PyPI."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from path import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from bhtool.utils import run
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def bump_version(part: Literal["major", "minor", "patch"] | None = None, *, publish: bool = True):
|
|
14
|
+
"""Bump version in pyproject.toml, commit, push, and publish."""
|
|
15
|
+
if part is None:
|
|
16
|
+
from bhtool.cli import app
|
|
17
|
+
|
|
18
|
+
app["bumpver"].help_print()
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
pyproject = Path("pyproject.toml")
|
|
22
|
+
if not pyproject.exists():
|
|
23
|
+
print("Could not find pyproject.toml")
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
text = pyproject.read_text()
|
|
27
|
+
match = re.search(r'^version = "(\d+)\.(\d+)\.(\d+)"', text, re.MULTILINE)
|
|
28
|
+
if not match:
|
|
29
|
+
print("Could not find version in pyproject.toml")
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
33
|
+
print(f"Current version: {major}.{minor}.{patch}")
|
|
34
|
+
|
|
35
|
+
if part == "major":
|
|
36
|
+
major, minor, patch = major + 1, 0, 0
|
|
37
|
+
elif part == "minor":
|
|
38
|
+
minor, patch = minor + 1, 0
|
|
39
|
+
else:
|
|
40
|
+
patch += 1
|
|
41
|
+
|
|
42
|
+
new_version = f"{major}.{minor}.{patch}"
|
|
43
|
+
pyproject.write_text(text.replace(match.group(0), f'version = "{new_version}"'))
|
|
44
|
+
print(f"Version bumped to {new_version}")
|
|
45
|
+
|
|
46
|
+
run(f'git commit -am "Bump version to {new_version}"')
|
|
47
|
+
run("git push")
|
|
48
|
+
|
|
49
|
+
if publish:
|
|
50
|
+
run("uv build")
|
|
51
|
+
run(f"uv publish dist/*-{new_version}*")
|
bhtool/clear_chmod.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Reclaim for user any files that are not shell scripts"""
|
|
4
|
+
|
|
5
|
+
from path import Path
|
|
6
|
+
|
|
7
|
+
from bhtool.utils import run
|
|
8
|
+
|
|
9
|
+
skip_directories = ["__pycache__", "node_modules", ".git"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def clear_chmod_run():
|
|
13
|
+
for f in Path(".").iterdir():
|
|
14
|
+
if f.name.startswith("."):
|
|
15
|
+
continue
|
|
16
|
+
if f.is_dir():
|
|
17
|
+
if f.name in skip_directories:
|
|
18
|
+
continue
|
|
19
|
+
run(f'chmod og-w "{f}"')
|
|
20
|
+
else:
|
|
21
|
+
if not f.endswith("sh") and not f.endswith("bat"):
|
|
22
|
+
run(f'chmod a-x "{f}"')
|
|
23
|
+
run(f'chmod og-w "{f}"')
|
bhtool/cli.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Unified bhtool CLI (`b` and `bhtool` on PATH)."""
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from cyclopts import App
|
|
8
|
+
|
|
9
|
+
from bhtool.run import open_apps
|
|
10
|
+
from bhtool.bumpver import bump_version
|
|
11
|
+
from bhtool.clear_chmod import clear_chmod_run
|
|
12
|
+
from bhtool.npread import npread_run
|
|
13
|
+
from bhtool.psword import psword_run
|
|
14
|
+
from bhtool.rm_npm import rm_npm_run
|
|
15
|
+
from bhtool.text2 import text2_run
|
|
16
|
+
from bhtool.todict import todict_run
|
|
17
|
+
|
|
18
|
+
app = App(name="bhtool", help="bhtool utilities")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command(name="run")
|
|
22
|
+
def run(*params: str):
|
|
23
|
+
"""Open macOS applications by partial name match."""
|
|
24
|
+
if not params:
|
|
25
|
+
app["run"].help_print()
|
|
26
|
+
return
|
|
27
|
+
open_apps(*params)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command
|
|
31
|
+
def bumpver(part: Literal["major", "minor", "patch"] | None = None, *, publish: bool = True):
|
|
32
|
+
"""Bump version in pyproject.toml, commit, push, and optionally publish to PyPI."""
|
|
33
|
+
bump_version(part, publish=publish)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command
|
|
37
|
+
def clear_chmod():
|
|
38
|
+
"""Remove execute permissions from non-script files and write permissions from group/other."""
|
|
39
|
+
clear_chmod_run()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command
|
|
43
|
+
def npread(*files: str):
|
|
44
|
+
"""Print shape of numpy .npy files."""
|
|
45
|
+
if not files:
|
|
46
|
+
app["npread"].help_print()
|
|
47
|
+
return
|
|
48
|
+
npread_run(files)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command(name="dfplot")
|
|
52
|
+
def dfplot_cmd():
|
|
53
|
+
"""Find and plot parquet files from data/results directory."""
|
|
54
|
+
from bhtool.dfplot import dfplot_run
|
|
55
|
+
|
|
56
|
+
dfplot_run()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command
|
|
60
|
+
def psword(*words: str, kill: bool = False):
|
|
61
|
+
"""Find processes by name and optionally kill them."""
|
|
62
|
+
if not words:
|
|
63
|
+
app["psword"].help_print()
|
|
64
|
+
return
|
|
65
|
+
psword_run(words, kill=kill)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command
|
|
69
|
+
def rm_npm():
|
|
70
|
+
"""Recursively remove node_modules directories and package-lock.json files."""
|
|
71
|
+
rm_npm_run()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command
|
|
75
|
+
def text2(in_fname: str, out_fname: str):
|
|
76
|
+
"""Interconvert text-based formats: md, pug, docx, html."""
|
|
77
|
+
text2_run(in_fname, out_fname)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command
|
|
81
|
+
def todict(json: str | None = None, yaml: str | None = None):
|
|
82
|
+
"""Convert JSON or YAML to Python dict format."""
|
|
83
|
+
todict_run(json=json, yaml=yaml)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
movies_app = App(
|
|
87
|
+
name="movies",
|
|
88
|
+
help="Manage movie directories and files: normalize names with LLM.",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@movies_app.command
|
|
93
|
+
def rename(root_dir: str | None = None, execute: bool = False):
|
|
94
|
+
"""Rename movie dirs and files to normalized names using mapping from LLM.
|
|
95
|
+
|
|
96
|
+
:param root_dir: Root directory containing movies (positional); default is current working directory.
|
|
97
|
+
:param execute: If true, perform renames; otherwise dry run (table output).
|
|
98
|
+
"""
|
|
99
|
+
from bhtool.list_movies import movies_rename_impl
|
|
100
|
+
|
|
101
|
+
movies_rename_impl(root_dir=root_dir, execute=execute)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
app.command(movies_app)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
app()
|
bhtool/dfplot.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Plot parquet files"""
|
|
4
|
+
|
|
5
|
+
from path import Path
|
|
6
|
+
|
|
7
|
+
import matplotlib as mpl
|
|
8
|
+
import pandas
|
|
9
|
+
from matplotlib import pyplot as plt
|
|
10
|
+
from pandas.plotting import register_matplotlib_converters
|
|
11
|
+
|
|
12
|
+
from bhtool.utils import open_path
|
|
13
|
+
|
|
14
|
+
register_matplotlib_converters()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def plot_df_columns_as_scatter(df):
|
|
18
|
+
mpl.rcParams["xtick.labelsize"] = 6
|
|
19
|
+
mpl.rcParams["ytick.labelsize"] = 6
|
|
20
|
+
|
|
21
|
+
xlim = [df["time"].min(), df["time"].max()]
|
|
22
|
+
cols = [c for c in df.columns.values if c != "time"]
|
|
23
|
+
colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]
|
|
24
|
+
fig, axes = plt.subplots(len(cols), 1, sharex=True, figsize=(7, len(cols) * 1))
|
|
25
|
+
for i, col in enumerate(cols):
|
|
26
|
+
ax = axes[i]
|
|
27
|
+
color = colors[i % len(colors)]
|
|
28
|
+
ax.scatter(
|
|
29
|
+
df["time"].values,
|
|
30
|
+
df[col].values,
|
|
31
|
+
label=col,
|
|
32
|
+
s=3,
|
|
33
|
+
c=color,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
ax.set_title(col, color=color, fontdict={"fontsize": 10}, pad=0, loc="right")
|
|
37
|
+
|
|
38
|
+
ax_color = (0.4, 0.4, 0.4)
|
|
39
|
+
|
|
40
|
+
ax.tick_params(axis="x", colors=ax_color)
|
|
41
|
+
ax.tick_params(axis="y", colors=ax_color)
|
|
42
|
+
ax.yaxis.label.set_color(ax_color)
|
|
43
|
+
ax.xaxis.label.set_color(ax_color)
|
|
44
|
+
|
|
45
|
+
grid_box_border_color = (0.9, 0.9, 0.9)
|
|
46
|
+
ax.spines["bottom"].set_color(grid_box_border_color)
|
|
47
|
+
ax.spines["top"].set_color(grid_box_border_color)
|
|
48
|
+
ax.spines["right"].set_color(grid_box_border_color)
|
|
49
|
+
ax.spines["left"].set_color(grid_box_border_color)
|
|
50
|
+
|
|
51
|
+
plt.subplots_adjust(hspace=0.4)
|
|
52
|
+
plt.xlim(xlim)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resample_df(df, interval="1s"):
|
|
56
|
+
df = df.set_index("time")
|
|
57
|
+
df.sort_index(inplace=True)
|
|
58
|
+
df = df.resample(interval).mean()
|
|
59
|
+
df.dropna(how="all", inplace=True)
|
|
60
|
+
df.reset_index(inplace=True)
|
|
61
|
+
return df
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _first_data_results_dir() -> Path:
|
|
65
|
+
for p in Path().walkdirs():
|
|
66
|
+
parts = p.parts()
|
|
67
|
+
if len(parts) >= 2 and parts[-2] == "data" and parts[-1] == "results":
|
|
68
|
+
return p
|
|
69
|
+
raise FileNotFoundError("Could not find a directory matching **/data/results")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dfplot_run():
|
|
73
|
+
d = _first_data_results_dir()
|
|
74
|
+
print(f"Found (**/data/results) directory: {d}")
|
|
75
|
+
|
|
76
|
+
n = len(list(d.walkfiles("*.parquet")))
|
|
77
|
+
print(f"Loading {n} parquet files...")
|
|
78
|
+
|
|
79
|
+
df = pandas.read_parquet(d)
|
|
80
|
+
|
|
81
|
+
if isinstance(df.index, pandas.DatetimeIndex):
|
|
82
|
+
df = resample_df(df, "1s")
|
|
83
|
+
|
|
84
|
+
out_f = Path("data_logger_results.png")
|
|
85
|
+
print(f"Saving {out_f}")
|
|
86
|
+
mpl.use("Agg")
|
|
87
|
+
plot_df_columns_as_scatter(df)
|
|
88
|
+
plt.savefig(out_f)
|
|
89
|
+
|
|
90
|
+
open_path(out_f)
|
bhtool/list_movies.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import textwrap
|
|
8
|
+
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
from path import Path
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from microeval.llm import get_llm_client
|
|
15
|
+
|
|
16
|
+
_pkg_root = Path(__file__).parent
|
|
17
|
+
|
|
18
|
+
load_dotenv(_pkg_root / ".env")
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
MOVIE_MAPPING_FILE = _pkg_root / "movie_mapping.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
NORMALIZE_SYSTEM_PROMPT = textwrap.dedent("""
|
|
26
|
+
You normalize movie and TV directory and file names for a media library.
|
|
27
|
+
|
|
28
|
+
Rules:
|
|
29
|
+
- Output the title in Title Case.
|
|
30
|
+
- Keep the year as (YYYY) at the end when present; infer from the name
|
|
31
|
+
if needed.
|
|
32
|
+
- For TV series: preserve season markers at the end if present (e.g. S1,
|
|
33
|
+
S2, S01, S02, Season 1, Season 2). Keep them at the end of the
|
|
34
|
+
normalized name.
|
|
35
|
+
- Strip release/quality tags: [1080p], [720p], [WEBRip], [BluRay],
|
|
36
|
+
[YTS.MX], codec names, group names, etc.
|
|
37
|
+
- For files: normalize only the base name (before the extension); the
|
|
38
|
+
extension stays unchanged in the output.
|
|
39
|
+
- Preserve the original spelling of the title; only fix casing and
|
|
40
|
+
punctuation.
|
|
41
|
+
- Return valid JSON only, no markdown or extra text.
|
|
42
|
+
""").strip()
|
|
43
|
+
|
|
44
|
+
NORMALIZE_USER_PROMPT_TEMPLATE = textwrap.dedent("""
|
|
45
|
+
Convert these names to normalized form. Return a single JSON object with
|
|
46
|
+
two keys:
|
|
47
|
+
- "directories": list of {{"original": "<dir name>",
|
|
48
|
+
"normalized": "<normalized dir name>"}}
|
|
49
|
+
- "files": list of {{"original": "<filename with extension>",
|
|
50
|
+
"normalized": "<normalized base name without extension>"}}
|
|
51
|
+
|
|
52
|
+
Directory names:
|
|
53
|
+
{dir_list}
|
|
54
|
+
|
|
55
|
+
File names:
|
|
56
|
+
{file_list}
|
|
57
|
+
""").strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_llm_json_object(result: dict) -> dict | None:
|
|
61
|
+
text = (result.get("text") or "").strip()
|
|
62
|
+
if not text:
|
|
63
|
+
return None
|
|
64
|
+
fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
|
65
|
+
if fence:
|
|
66
|
+
text = fence.group(1).strip()
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(text)
|
|
69
|
+
return data if isinstance(data, dict) else None
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
pass
|
|
72
|
+
start, end = text.find("{"), text.rfind("}")
|
|
73
|
+
if start != -1 and end > start:
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(text[start : end + 1])
|
|
76
|
+
return data if isinstance(data, dict) else None
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
return None
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def normalize_names_with_llm(dir_names, file_names, service="openai"):
|
|
83
|
+
if not dir_names and not file_names:
|
|
84
|
+
return {"directories": [], "files": []}
|
|
85
|
+
|
|
86
|
+
dir_list = "\n".join(f"- {n}" for n in dir_names) or "(none)"
|
|
87
|
+
file_list = "\n".join(f"- {n}" for n in file_names) or "(none)"
|
|
88
|
+
user_content = NORMALIZE_USER_PROMPT_TEMPLATE.format(
|
|
89
|
+
dir_list=dir_list,
|
|
90
|
+
file_list=file_list,
|
|
91
|
+
)
|
|
92
|
+
messages = [
|
|
93
|
+
{"role": "system", "content": NORMALIZE_SYSTEM_PROMPT},
|
|
94
|
+
{"role": "user", "content": user_content},
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
logger.info("Initializing LLM (service=%s)", service)
|
|
98
|
+
async with get_llm_client(service) as client:
|
|
99
|
+
logger.info("Calling LLM (service=%s) for normalize mapping", service)
|
|
100
|
+
result = await client.get_completion(messages)
|
|
101
|
+
logger.info("LLM response received (%s chars)", len(result.get("text", "")))
|
|
102
|
+
if result.get("text", "").strip().startswith("Error:"):
|
|
103
|
+
raise RuntimeError(result["text"])
|
|
104
|
+
|
|
105
|
+
data = _parse_llm_json_object(result)
|
|
106
|
+
if not data:
|
|
107
|
+
raise RuntimeError("LLM response could not be parsed as JSON")
|
|
108
|
+
return {"directories": data.get("directories", []), "files": data.get("files", [])}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_normalize_with_llm(root_dir, service="openai"):
|
|
112
|
+
root = Path(root_dir)
|
|
113
|
+
video_suffixes = {".avi", ".mkv", ".mp4", ".m4v", ".mov", ".wmv", ".webm"}
|
|
114
|
+
skip_names = {".DS_Store", "list_movies.py", "pyproject.toml", ".venv", ".ruff_cache", "__pycache__"}
|
|
115
|
+
dir_names = []
|
|
116
|
+
file_names = []
|
|
117
|
+
for p in root.iterdir():
|
|
118
|
+
if p.name.startswith(".") or p.name in skip_names:
|
|
119
|
+
continue
|
|
120
|
+
if p.is_dir():
|
|
121
|
+
dir_names.append(p.name)
|
|
122
|
+
elif p.suffix.lower() in video_suffixes:
|
|
123
|
+
file_names.append(p.name)
|
|
124
|
+
dir_names.sort()
|
|
125
|
+
file_names.sort()
|
|
126
|
+
return asyncio.run(normalize_names_with_llm(dir_names, file_names, service))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def rename_movies(root_dir, dry_run=True, mapping=None):
|
|
130
|
+
root = Path(root_dir)
|
|
131
|
+
if mapping is None:
|
|
132
|
+
mapping = {"directories": [], "files": []}
|
|
133
|
+
dirs = mapping["directories"]
|
|
134
|
+
files = mapping["files"]
|
|
135
|
+
renamed_count = 0
|
|
136
|
+
skipped_count = 0
|
|
137
|
+
table_rows = [] if dry_run else None
|
|
138
|
+
|
|
139
|
+
for e in dirs:
|
|
140
|
+
old_name, new_name = e["original"], e["normalized"]
|
|
141
|
+
if old_name == new_name:
|
|
142
|
+
continue
|
|
143
|
+
old_path = root / old_name
|
|
144
|
+
new_path = root / new_name
|
|
145
|
+
if not old_path.exists():
|
|
146
|
+
skipped_count += 1
|
|
147
|
+
if table_rows is not None:
|
|
148
|
+
table_rows.append(("DIR", old_name, new_name, "skip (not found)"))
|
|
149
|
+
elif dry_run:
|
|
150
|
+
print(f"⚠️ SKIP: {old_name} (not found)")
|
|
151
|
+
continue
|
|
152
|
+
if new_path.exists():
|
|
153
|
+
skipped_count += 1
|
|
154
|
+
if table_rows is not None:
|
|
155
|
+
table_rows.append(("DIR", old_name, new_name, "skip (exists)"))
|
|
156
|
+
elif dry_run:
|
|
157
|
+
print(f"⚠️ SKIP: {old_name} → {new_name} (target exists)")
|
|
158
|
+
continue
|
|
159
|
+
if dry_run:
|
|
160
|
+
if table_rows is not None:
|
|
161
|
+
table_rows.append(("DIR", old_name, new_name, "would rename"))
|
|
162
|
+
else:
|
|
163
|
+
print(f"📁 WOULD RENAME: {old_name}")
|
|
164
|
+
print(f" TO: {new_name}")
|
|
165
|
+
else:
|
|
166
|
+
old_path.rename(new_path)
|
|
167
|
+
print(f"✅ RENAMED: {old_name}")
|
|
168
|
+
print(f" TO: {new_name}")
|
|
169
|
+
renamed_count += 1
|
|
170
|
+
|
|
171
|
+
for e in files:
|
|
172
|
+
old_name, new_name = e["original"], e["normalized"]
|
|
173
|
+
old_path = root / old_name
|
|
174
|
+
suffix = Path(old_name).suffix
|
|
175
|
+
new_path = root / (new_name + suffix)
|
|
176
|
+
if old_name == new_name + suffix:
|
|
177
|
+
continue
|
|
178
|
+
if not old_path.exists():
|
|
179
|
+
skipped_count += 1
|
|
180
|
+
if table_rows is not None:
|
|
181
|
+
table_rows.append(("FILE", old_name, new_name + suffix, "skip (not found)"))
|
|
182
|
+
elif dry_run:
|
|
183
|
+
print(f"⚠️ SKIP: {old_name} (not found)")
|
|
184
|
+
continue
|
|
185
|
+
if new_path.exists():
|
|
186
|
+
skipped_count += 1
|
|
187
|
+
if table_rows is not None:
|
|
188
|
+
table_rows.append(("FILE", old_name, new_name + suffix, "skip (exists)"))
|
|
189
|
+
elif dry_run:
|
|
190
|
+
print(f"⚠️ SKIP: {old_name} → {new_name}{suffix} (target exists)")
|
|
191
|
+
continue
|
|
192
|
+
if dry_run:
|
|
193
|
+
if table_rows is not None:
|
|
194
|
+
table_rows.append(("FILE", old_name, new_name + suffix, "would rename"))
|
|
195
|
+
else:
|
|
196
|
+
print(f"📄 WOULD RENAME: {old_name}")
|
|
197
|
+
print(f" TO: {new_name}{suffix}")
|
|
198
|
+
else:
|
|
199
|
+
old_path.rename(new_path)
|
|
200
|
+
print(f"✅ RENAMED: {old_name}")
|
|
201
|
+
print(f" TO: {new_name}{suffix}")
|
|
202
|
+
renamed_count += 1
|
|
203
|
+
|
|
204
|
+
if table_rows is not None:
|
|
205
|
+
console = Console()
|
|
206
|
+
tbl = Table(title="Rename (dry run)", show_header=True, header_style="bold")
|
|
207
|
+
tbl.add_column("Type", style="dim")
|
|
208
|
+
tbl.add_column("From")
|
|
209
|
+
tbl.add_column("To")
|
|
210
|
+
tbl.add_column("Status")
|
|
211
|
+
for row in table_rows:
|
|
212
|
+
tbl.add_row(*row)
|
|
213
|
+
console.print(tbl)
|
|
214
|
+
console.print(f"\n[dim]{len(table_rows)} items[/dim]")
|
|
215
|
+
elif not dry_run:
|
|
216
|
+
print("\n" + "=" * 80)
|
|
217
|
+
print(f"COMPLETE: {renamed_count} items renamed, {skipped_count} skipped")
|
|
218
|
+
print("=" * 80)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _root_dir(movies_dir: str | None = None) -> Path:
|
|
222
|
+
if movies_dir is not None:
|
|
223
|
+
return Path(movies_dir)
|
|
224
|
+
return Path.cwd()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def movies_rename_impl(root_dir: str | None = None, execute: bool = False):
|
|
228
|
+
import logging
|
|
229
|
+
import os
|
|
230
|
+
import sys
|
|
231
|
+
|
|
232
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
|
|
233
|
+
root = _root_dir(root_dir)
|
|
234
|
+
svc = os.environ.get("LLM_SERVICE", "openai")
|
|
235
|
+
print(f"Getting mapping from LLM (service={svc})...\n")
|
|
236
|
+
mapping = run_normalize_with_llm(root, svc)
|
|
237
|
+
MOVIE_MAPPING_FILE.write_text(json.dumps(mapping, indent=2))
|
|
238
|
+
print(f"Saved to {MOVIE_MAPPING_FILE}\n")
|
|
239
|
+
if execute:
|
|
240
|
+
print("⚠️ PERFORMING ACTUAL RENAME")
|
|
241
|
+
rename_movies(root, dry_run=False, mapping=mapping)
|
|
242
|
+
else:
|
|
243
|
+
rename_movies(root, dry_run=True, mapping=mapping)
|
bhtool/npread.py
ADDED
bhtool/psword.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Find processes with name"""
|
|
4
|
+
|
|
5
|
+
from bhtool.utils import run, run_output
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def psword_run(words: tuple[str, ...], kill: bool = False):
|
|
9
|
+
for word in words:
|
|
10
|
+
txt = run_output(f"ps aux | grep {word}")
|
|
11
|
+
for line in txt.splitlines():
|
|
12
|
+
tokens = line.split()
|
|
13
|
+
i_process = tokens[1]
|
|
14
|
+
cmd = " ".join(tokens[10:])
|
|
15
|
+
if len(cmd) > 80:
|
|
16
|
+
cmd = cmd[:80] + "..."
|
|
17
|
+
if "psword" in line:
|
|
18
|
+
continue
|
|
19
|
+
if kill:
|
|
20
|
+
print(f"kill target ({cmd})")
|
|
21
|
+
run(f"kill -9 {i_process}")
|
|
22
|
+
else:
|
|
23
|
+
print(f"process {i_process}: {cmd}")
|
bhtool/rm_npm.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Remove node_modules and package-lock.json from directories"""
|
|
4
|
+
|
|
5
|
+
from path import Path
|
|
6
|
+
|
|
7
|
+
from bhtool.utils import walk_paths
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def rm_npm_run():
|
|
11
|
+
print("Checking for npm modules", Path(".").resolve())
|
|
12
|
+
|
|
13
|
+
node_modules = []
|
|
14
|
+
package_locks = []
|
|
15
|
+
for d in walk_paths("."):
|
|
16
|
+
if d.name.endswith("node_modules"):
|
|
17
|
+
node_modules.append(d)
|
|
18
|
+
if d.name.endswith("package-lock.json"):
|
|
19
|
+
package_locks.append(d)
|
|
20
|
+
|
|
21
|
+
for f in reversed(package_locks):
|
|
22
|
+
print("remove file", f)
|
|
23
|
+
f.unlink()
|
|
24
|
+
|
|
25
|
+
for d in reversed(node_modules):
|
|
26
|
+
print("remove directory", d)
|
|
27
|
+
d.rmtree()
|
bhtool/run.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Quick open applications in Darwin"""
|
|
4
|
+
|
|
5
|
+
from path import Path
|
|
6
|
+
|
|
7
|
+
from bhtool.utils import run
|
|
8
|
+
|
|
9
|
+
search_dirs = [Path("/Applications"), Path.home() / "MyApps"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def open_apps(*params: str):
|
|
13
|
+
params = list(params)
|
|
14
|
+
use_app = params[0].lower()
|
|
15
|
+
for search_dir in search_dirs:
|
|
16
|
+
names = [p.stem.lower() for p in search_dir.glob("*")]
|
|
17
|
+
if match_app := next((name for name in names if use_app in name), None):
|
|
18
|
+
params[0] = match_app
|
|
19
|
+
break
|
|
20
|
+
|
|
21
|
+
params = [f'"{p}"' for p in params]
|
|
22
|
+
cmd = "open -a " + " ".join(params)
|
|
23
|
+
run(cmd)
|
bhtool/text2.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""cli convert between md|html|docx|pug"""
|
|
4
|
+
|
|
5
|
+
from path import Path
|
|
6
|
+
|
|
7
|
+
from bhtool.utils import run, run_output
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def convert_md_to_html(md, html):
|
|
11
|
+
run(f"pandoc --from markdown --to html '{md}' -o '{html}'")
|
|
12
|
+
txt = run_output(f"beautify {html}")
|
|
13
|
+
Path(html).write_text(txt)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def convert_md_to_docx(md, docx):
|
|
17
|
+
run(f"pandoc --from markdown --to docx '{md}' -o '{docx}'")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def convert_docx_to_md(docx, md):
|
|
21
|
+
run(f"pandoc --from docx --to markdown '{docx}' -o '{md}'")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def convert_html_to_pug(html, pug):
|
|
25
|
+
run(f"html2pug < {html} > {pug}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def convert_pug_to_html(pug, html):
|
|
29
|
+
temp_pug = Path(pug).with_suffix(".temp.pug")
|
|
30
|
+
Path(pug).copy2(temp_pug)
|
|
31
|
+
temp_html = temp_pug.with_suffix(".html")
|
|
32
|
+
if temp_html.exists():
|
|
33
|
+
temp_html.unlink()
|
|
34
|
+
run(f"pug {temp_pug} ")
|
|
35
|
+
txt = run_output(f"beautify {temp_html}")
|
|
36
|
+
Path(html).write_text(txt)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def convert_html_to_md(html, md):
|
|
40
|
+
run(f"pandoc --from html --to markdown {html} -o {md}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def text2_run(in_fname: str, out_fname: str):
|
|
44
|
+
in_fname = Path(in_fname)
|
|
45
|
+
out_fname = Path(out_fname)
|
|
46
|
+
|
|
47
|
+
in_ext = in_fname.suffix.lower()
|
|
48
|
+
out_ext = out_fname.suffix.lower()
|
|
49
|
+
if (in_ext, out_ext) == (".md", ".pug"):
|
|
50
|
+
temp_html_fname = in_fname.with_suffix(".temp.html")
|
|
51
|
+
convert_md_to_html(in_fname, temp_html_fname)
|
|
52
|
+
convert_html_to_pug(temp_html_fname, out_fname)
|
|
53
|
+
elif (in_ext, out_ext) == (".pug", ".md"):
|
|
54
|
+
temp_html_fname = in_fname.with_suffix(".temp.html")
|
|
55
|
+
convert_pug_to_html(in_fname, temp_html_fname)
|
|
56
|
+
convert_html_to_md(temp_html_fname, out_fname)
|
|
57
|
+
elif (in_ext, out_ext) == (".pug", ".html"):
|
|
58
|
+
convert_pug_to_html(in_fname, out_fname)
|
|
59
|
+
elif (in_ext, out_ext) == (".md", ".html"):
|
|
60
|
+
convert_md_to_html(in_fname, out_fname)
|
|
61
|
+
elif (in_ext, out_ext) == (".md", ".docx"):
|
|
62
|
+
convert_md_to_docx(in_fname, out_fname)
|
|
63
|
+
elif (in_ext, out_ext) == (".docx", ".md"):
|
|
64
|
+
convert_docx_to_md(in_fname, out_fname)
|
|
65
|
+
else:
|
|
66
|
+
print(f"Error: can't handle .suffix: {in_fname} -> {out_fname}")
|
|
67
|
+
|
|
68
|
+
print(f">>> Made {out_fname}")
|
bhtool/todict.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Convert json to python dict repr"""
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import sys
|
|
7
|
+
import typing
|
|
8
|
+
from json import load as json_load
|
|
9
|
+
from json import loads as json_loads
|
|
10
|
+
|
|
11
|
+
from path import Path
|
|
12
|
+
from ruyaml import YAML
|
|
13
|
+
|
|
14
|
+
indent = " "
|
|
15
|
+
|
|
16
|
+
output = io.StringIO()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def print_out(*args, **kwargs):
|
|
20
|
+
print(*args, file=output, **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def walk_d(o, level=0, skip_first_indent=False):
|
|
24
|
+
curr_space = indent * level
|
|
25
|
+
if not skip_first_indent:
|
|
26
|
+
print_out(curr_space, end="")
|
|
27
|
+
if isinstance(o, typing.Mapping):
|
|
28
|
+
print_out("dict(")
|
|
29
|
+
next_space = " " * (level + 1)
|
|
30
|
+
for i, (key, value) in enumerate(o.items()):
|
|
31
|
+
print_out(next_space, end="")
|
|
32
|
+
print_out(f"{key}=", end="")
|
|
33
|
+
if isinstance(value, typing.Mapping):
|
|
34
|
+
walk_d(value, level + 1, True)
|
|
35
|
+
else:
|
|
36
|
+
walk_d(value, level + 1, True)
|
|
37
|
+
print_out(curr_space, end="")
|
|
38
|
+
if level > 0:
|
|
39
|
+
print_out("),", end="")
|
|
40
|
+
else:
|
|
41
|
+
print_out(")", end="")
|
|
42
|
+
print_out()
|
|
43
|
+
elif isinstance(o, typing.List):
|
|
44
|
+
print_out("[", end="")
|
|
45
|
+
print_out()
|
|
46
|
+
for item in o:
|
|
47
|
+
walk_d(item, level + 1)
|
|
48
|
+
print_out(curr_space, end="")
|
|
49
|
+
print_out("],", end="")
|
|
50
|
+
print_out()
|
|
51
|
+
elif isinstance(o, str):
|
|
52
|
+
print_out(f'"{o}",', end="")
|
|
53
|
+
print_out()
|
|
54
|
+
else:
|
|
55
|
+
print_out(f"{o},", end="")
|
|
56
|
+
print_out()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def todict_run(json: str | None = None, yaml: str | None = None):
|
|
60
|
+
if json:
|
|
61
|
+
incoming_json = json_loads(Path(json).read_text())
|
|
62
|
+
elif yaml:
|
|
63
|
+
yaml_reader = YAML(typ="safe")
|
|
64
|
+
yaml_reader.default_flow_style = False
|
|
65
|
+
incoming_json = yaml_reader.load(Path(yaml).read_text())
|
|
66
|
+
elif not sys.stdin.isatty():
|
|
67
|
+
incoming_json = json_load(sys.stdin)
|
|
68
|
+
else:
|
|
69
|
+
from bhtool.cli import app
|
|
70
|
+
|
|
71
|
+
app["todict"].help_print()
|
|
72
|
+
return
|
|
73
|
+
global output
|
|
74
|
+
output = io.StringIO()
|
|
75
|
+
walk_d(incoming_json)
|
|
76
|
+
contents = output.getvalue()
|
|
77
|
+
output.close()
|
|
78
|
+
print(contents)
|
bhtool/utils.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Shared utilities for bhtool scripts."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from path import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(cmd: str, env=None) -> None:
|
|
14
|
+
"""Run a shell command, printing it first. Exits on failure.
|
|
15
|
+
|
|
16
|
+
:param cmd: Shell command to run
|
|
17
|
+
:param env: Optional environment variables dict
|
|
18
|
+
"""
|
|
19
|
+
print(f">> {cmd}")
|
|
20
|
+
result = subprocess.run(cmd, shell=True, env=env)
|
|
21
|
+
if result.returncode != 0:
|
|
22
|
+
sys.exit(result.returncode)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_output(cmd: str) -> str:
|
|
26
|
+
"""Run a shell command and return its output as text.
|
|
27
|
+
|
|
28
|
+
:param cmd: Shell command to run
|
|
29
|
+
:return: stdout+stderr output, or empty string on error
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT)
|
|
33
|
+
return result.decode("utf-8", errors="ignore")
|
|
34
|
+
except subprocess.CalledProcessError:
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def open_path(filepath: str) -> None:
|
|
39
|
+
"""Open a path with the platform default handler (Finder, explorer, xdg-open)."""
|
|
40
|
+
p = Path(filepath)
|
|
41
|
+
system = platform.system()
|
|
42
|
+
if system == "Darwin":
|
|
43
|
+
subprocess.run(["open", p], check=False)
|
|
44
|
+
elif system == "Windows":
|
|
45
|
+
os.startfile(p)
|
|
46
|
+
else:
|
|
47
|
+
subprocess.run(["xdg-open", p], check=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def walk_paths(root: str) -> Iterator[Path]:
|
|
51
|
+
"""Depth-first walk of ``root`` (files and dirs), using ``path.Path.walk``."""
|
|
52
|
+
base = Path(root)
|
|
53
|
+
if base.is_dir():
|
|
54
|
+
yield from base.walk()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bhtool
|
|
3
|
+
Version: 0.1.10
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: Bosco Ho <apposite@gmail.com>
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: click>=8.1.7
|
|
8
|
+
Requires-Dist: cyclopts>=4.10.2
|
|
9
|
+
Requires-Dist: matplotlib>=3.9.0
|
|
10
|
+
Requires-Dist: microeval>=0.5.0
|
|
11
|
+
Requires-Dist: numpy>=1.26.4
|
|
12
|
+
Requires-Dist: pandas>=2.2.2
|
|
13
|
+
Requires-Dist: path>=16.14.0
|
|
14
|
+
Requires-Dist: pydash>=8.0.3
|
|
15
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
16
|
+
Requires-Dist: rich>=15.0.0
|
|
17
|
+
Requires-Dist: ruyaml>=0.91.0
|
|
18
|
+
Requires-Dist: semver>=3.0.2
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# bhtool
|
|
22
|
+
|
|
23
|
+
CLI utilities for version bumping, file conversion, process management, and dev workflows. Python ≥3.12.
|
|
24
|
+
|
|
25
|
+
**Install:** `uv sync` · `uv pip install -e .` · `uv tool install .` (global)
|
|
26
|
+
|
|
27
|
+
| Command | Description |
|
|
28
|
+
|-----------------|-------------------------------------------------------------------|
|
|
29
|
+
| **bumpver** | Bump version in pyproject.toml, commit, push, optionally publish |
|
|
30
|
+
| **a** | Open macOS apps by partial name (`a chrome`, `a cursor`) |
|
|
31
|
+
| **clear_chmod** | Strip execute from non-scripts, group/other write |
|
|
32
|
+
| **npread** | Print shape of .npy files |
|
|
33
|
+
| **dfplot** | Plot parquet/CSV, open figure |
|
|
34
|
+
| **psword** | Find processes by name; `--kill` to kill |
|
|
35
|
+
| **rm_npm** | Recursively remove node_modules and package-lock.json |
|
|
36
|
+
| **text2** | Convert md ↔ html ↔ docx ↔ pug (pandoc) |
|
|
37
|
+
| **todict** | JSON/YAML stdin → Python dict repr |
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
# jtools
|
|
42
|
+
|
|
43
|
+
Node CLI: pug formatting, agent config copy. Usage: `jtools <subcommand>`
|
|
44
|
+
|
|
45
|
+
**Install:** `cd jtools && npm install`
|
|
46
|
+
|
|
47
|
+
| Subcommand | Description |
|
|
48
|
+
|----------------|--------------------------------------|
|
|
49
|
+
| **format-pug** | Format pug in ./index.html |
|
|
50
|
+
| **copy-agent** | Copy ~/.claude/CLAUDE.md → ./AGENT.md |
|
|
51
|
+
|
|
52
|
+
`npx jtools format-pug` · `npx jtools copy-agent`
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
bhtool/bumpver.py,sha256=oCxQxg0Pm9IX-fAlLJ_F-pZfhPOSSPgQWUt0btlrfK0,1487
|
|
2
|
+
bhtool/clear_chmod.py,sha256=SYOo8Su9dnY-JBZAguaCRCM3xatXWzYAuqoac7xpj1o,594
|
|
3
|
+
bhtool/cli.py,sha256=UOYM1I6sr3PJXDq4cwDPsfPUNLSJs4oNKyKHNL89xlo,2741
|
|
4
|
+
bhtool/dfplot.py,sha256=9o-wOlvCmI3R-dopubW_sefQmUghZY9zEXeONy0BQTI,2542
|
|
5
|
+
bhtool/list_movies.py,sha256=nXOcLfTFWrC70yYyQqCfGhA9OYUqwzDKvWesm-kTpyw,8654
|
|
6
|
+
bhtool/npread.py,sha256=iAEKc1koAWyHnM55F4671xRb8_-AMlmg4vgQdRBejOk,205
|
|
7
|
+
bhtool/psword.py,sha256=6OVGJl8ysPZkzCWvGaOq-8eexYzKWYg7Qdx387-renc,687
|
|
8
|
+
bhtool/rm_npm.py,sha256=-Jy2ptHhBs6cF8rQ7yjsLm2u_4OCLrkj7mCtvr1n6YY,652
|
|
9
|
+
bhtool/run.py,sha256=4pBxO9yH2SKMPsDOQM9vWnAWXCoi6ILbTGPsNjy9jaM,594
|
|
10
|
+
bhtool/text2.py,sha256=TuWeinEALAP6YUwZpXlYclUgIL3tWdnkQ-VEi5-qrLA,2126
|
|
11
|
+
bhtool/todict.py,sha256=q3G1bPCfY-gL6IrGqfYWLXUes2vRkcQABnC_48tCDws,2061
|
|
12
|
+
bhtool/utils.py,sha256=aAJsF4aTIZu2e52kI6m93MpsVXCIXqzCTPh4HnhYmXM,1513
|
|
13
|
+
bhtool-0.1.10.dist-info/METADATA,sha256=iLz4Be4Z5R4IJrRr14yw6YmDr8Dalr_g77U9BmuTvIU,2154
|
|
14
|
+
bhtool-0.1.10.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
bhtool-0.1.10.dist-info/entry_points.txt,sha256=_YDz264h1EQ8DOBgvc7uIEa2z7Sg76nCmYF40sDyZBg,63
|
|
16
|
+
bhtool-0.1.10.dist-info/RECORD,,
|