bhtool 0.1.10__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.
@@ -0,0 +1,20 @@
1
+ .DS_Store
2
+
3
+ # python generated files
4
+ __pycache__/
5
+ *.py[oc]
6
+ build/
7
+ dist/
8
+ wheels/
9
+ *.egg-info
10
+
11
+ # venv
12
+ .venv
13
+ .idea
14
+ .ruff_cache
15
+ uv.lock
16
+
17
+ requirements*
18
+
19
+ node_modules
20
+ package-lock.json
@@ -0,0 +1 @@
1
+ 3.13
bhtool-0.1.10/PKG-INFO ADDED
@@ -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,32 @@
1
+ # bhtool
2
+
3
+ CLI utilities for version bumping, file conversion, process management, and dev workflows. Python ≥3.12.
4
+
5
+ **Install:** `uv sync` · `uv pip install -e .` · `uv tool install .` (global)
6
+
7
+ | Command | Description |
8
+ |-----------------|-------------------------------------------------------------------|
9
+ | **bumpver** | Bump version in pyproject.toml, commit, push, optionally publish |
10
+ | **a** | Open macOS apps by partial name (`a chrome`, `a cursor`) |
11
+ | **clear_chmod** | Strip execute from non-scripts, group/other write |
12
+ | **npread** | Print shape of .npy files |
13
+ | **dfplot** | Plot parquet/CSV, open figure |
14
+ | **psword** | Find processes by name; `--kill` to kill |
15
+ | **rm_npm** | Recursively remove node_modules and package-lock.json |
16
+ | **text2** | Convert md ↔ html ↔ docx ↔ pug (pandoc) |
17
+ | **todict** | JSON/YAML stdin → Python dict repr |
18
+
19
+ ---
20
+
21
+ # jtools
22
+
23
+ Node CLI: pug formatting, agent config copy. Usage: `jtools <subcommand>`
24
+
25
+ **Install:** `cd jtools && npm install`
26
+
27
+ | Subcommand | Description |
28
+ |----------------|--------------------------------------|
29
+ | **format-pug** | Format pug in ./index.html |
30
+ | **copy-agent** | Copy ~/.claude/CLAUDE.md → ./AGENT.md |
31
+
32
+ `npx jtools format-pug` · `npx jtools copy-agent`
@@ -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}*")
@@ -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}"')
@@ -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()
@@ -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)
@@ -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)
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Print shape of numpy .npy file"""
4
+
5
+ import numpy
6
+
7
+
8
+ def npread_run(files: tuple[str, ...]):
9
+ for arg in files:
10
+ m = numpy.load(arg)
11
+ print("Numpy shape", m.shape)
@@ -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}")
@@ -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()
@@ -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)
@@ -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}")
@@ -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)
@@ -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,92 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+
6
+ const subcommand = process.argv[2];
7
+
8
+ if (!subcommand) {
9
+ console.log("Usage: jtools <command>");
10
+ console.log("");
11
+ console.log("Commands:");
12
+ console.log(" format-pug Format pug in ./index.html (current working dir)");
13
+ console.log(" copy-agent Copy ~/.claude/CLAUDE.md to ./AGENT.md");
14
+ process.exit(0);
15
+ }
16
+
17
+ if (subcommand === "copy-agent") {
18
+ const src = path.resolve(process.env.HOME, ".claude/CLAUDE.md");
19
+ const dest = path.resolve(process.cwd(), "AGENT.md");
20
+ fs.copyFileSync(src, dest);
21
+ console.log("copied CLAUDE.md to AGENT.md");
22
+ process.exit(0);
23
+ }
24
+
25
+ if (subcommand === "format-pug") {
26
+ const HTML_FILE = path.resolve(process.cwd(), "index.html");
27
+ if (!fs.existsSync(HTML_FILE)) {
28
+ console.error("index.html not found in current directory");
29
+ process.exit(1);
30
+ }
31
+ const OPEN_MARKER = "/* pug */`";
32
+
33
+ const content = fs.readFileSync(HTML_FILE, "utf8");
34
+
35
+ const openIdx = content.indexOf(OPEN_MARKER);
36
+ if (openIdx === -1) throw new Error(`marker '${OPEN_MARKER}' not found`);
37
+
38
+ const contentStart = openIdx + OPEN_MARKER.length;
39
+
40
+ let closeIdx = contentStart;
41
+ while (closeIdx < content.length) {
42
+ if (content[closeIdx] === "\\") { closeIdx += 2; continue; }
43
+ if (content[closeIdx] === "`") break;
44
+ closeIdx++;
45
+ }
46
+
47
+ const rawPug = content.slice(contentStart, closeIdx);
48
+
49
+ const trailMatch = rawPug.match(/\n(\s*)$/);
50
+ const trailingWs = trailMatch ? "\n" + trailMatch[1] : "\n";
51
+
52
+ const lines = rawPug.split("\n");
53
+ const nonEmpty = lines.filter((l) => l.trim());
54
+ if (!nonEmpty.length) process.exit(0);
55
+
56
+ const baseIndent = Math.min(...nonEmpty.map((l) => l.match(/^(\s*)/)[1].length));
57
+ const indent = " ".repeat(baseIndent);
58
+
59
+ const stripped = lines
60
+ .map((l) => l.slice(baseIndent))
61
+ .join("\n")
62
+ .trim();
63
+
64
+ const { default: prettier } = await import("prettier");
65
+ const { plugin: pugPlugin } = await import("@prettier/plugin-pug");
66
+
67
+ const formatted = await prettier.format(stripped, {
68
+ parser: "pug",
69
+ plugins: [pugPlugin],
70
+ });
71
+
72
+ const reindented =
73
+ "\n" +
74
+ formatted
75
+ .trimEnd()
76
+ .split("\n")
77
+ .map((l) => (l ? indent + l : ""))
78
+ .join("\n") +
79
+ trailingWs;
80
+
81
+ fs.writeFileSync(
82
+ HTML_FILE,
83
+ content.slice(0, contentStart) + reindented + content.slice(closeIdx),
84
+ );
85
+
86
+ console.log("formatted pug in", path.basename(HTML_FILE));
87
+ process.exit(0);
88
+ }
89
+
90
+ console.error(`Unknown subcommand: ${subcommand}`);
91
+ console.error("Run 'jtools' for usage.");
92
+ process.exit(1);
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "jtools",
3
+ "private": true,
4
+ "type": "module",
5
+ "bin": {
6
+ "jtools": "./jtools.js"
7
+ },
8
+ "devDependencies": {
9
+ "@prettier/plugin-pug": "^3.4.2",
10
+ "prettier": "^3.0.0"
11
+ }
12
+ }
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "bhtool"
3
+ version = "0.1.10"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ authors = [
8
+ { name = "Bosco Ho", email = "apposite@gmail.com" }
9
+ ]
10
+ dependencies = [
11
+ "path>=16.14.0",
12
+ "semver>=3.0.2",
13
+ "numpy>=1.26.4",
14
+ "click>=8.1.7",
15
+ "ruyaml>=0.91.0",
16
+ "pandas>=2.2.2",
17
+ "matplotlib>=3.9.0",
18
+ "pydash>=8.0.3",
19
+ "cyclopts>=4.10.2",
20
+ "python-dotenv>=1.2.2",
21
+ "rich>=15.0.0",
22
+ "microeval>=0.5.0",
23
+ ]
24
+
25
+ [project.scripts]
26
+ b = "bhtool.cli:main"
27
+ bhtool = "bhtool.cli:main"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "ruff>=0.11.10",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.metadata]
39
+ allow-direct-references = true
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["bhtool"]
43
+
44
+ [tool.uv]
45
+ package = true