Skip to content

Commit 06b78ba

Browse files
committed
repl command /count-csv
1 parent f923f27 commit 06b78ba

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- readers provide source information.
1212
- `hyperbase.parsers.badness` module for parser-agnostic combined structural + token-matching validation.
1313
- built-in REPL setting `check_badness` to render a badness panel after each parse, available regardless of the active parser plugin.
14-
- more REPL commands: /load, /save, /search, /count, /types and /transform.
14+
- more REPL commands: /load, /save, /search, /count, /count-csv, /types and /transform.
1515

1616
### Changed
1717

src/hyperbase/cli/repl.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import argparse
22
import contextlib
3+
import csv
34
import json
45
import sys
56
import time
@@ -252,7 +253,7 @@ class CommandCompleter(Completer):
252253
# Commands whose argument is a filesystem path. The completer
253254
# delegates to ``PathCompleter`` once the user has typed past the
254255
# command name.
255-
PATH_ARG_COMMANDS = frozenset({"load", "save"})
256+
PATH_ARG_COMMANDS = frozenset({"load", "save", "count-csv"})
256257

257258
def __init__(self, commands: dict) -> None:
258259
self.commands = commands
@@ -365,6 +366,10 @@ def __init__(self, parser_name: str | None, settings: dict[str, Any]) -> None:
365366
"help": "Count pattern matches across loaded edges (most common first)",
366367
"handler": self.cmd_count,
367368
},
369+
"count-csv": {
370+
"help": "Like /count, but write counts to a .csv file instead",
371+
"handler": self.cmd_count_csv,
372+
},
368373
"types": {
369374
"help": (
370375
"Count atom types across loaded edges "
@@ -1006,6 +1011,79 @@ def cmd_count(self, args: list) -> bool:
10061011
)
10071012
return False
10081013

1014+
def cmd_count_csv(self, args: list) -> bool:
1015+
if len(args) < 2:
1016+
self.console.print(
1017+
"[red]Error:[/red] /count-csv requires a file path and a pattern: "
1018+
"[cyan]/count-csv <path> <pattern>[/cyan]"
1019+
)
1020+
return False
1021+
if not self.edges:
1022+
self.console.print("[yellow]No edges loaded.[/yellow]")
1023+
self.console.print(
1024+
"[dim]Use[/dim] [cyan]/load <path>[/cyan] [dim]first.[/dim]"
1025+
)
1026+
return False
1027+
1028+
csv_path = Path(args[0]).expanduser()
1029+
pattern_text = " ".join(args[1:])
1030+
try:
1031+
pattern = hedge(pattern_text)
1032+
except Exception as e:
1033+
self.console.print(f"[red]Error:[/red] failed to parse pattern: {e}")
1034+
return False
1035+
if pattern is None:
1036+
self.console.print(
1037+
f"[red]Error:[/red] could not parse pattern: "
1038+
f"[cyan]{pattern_text}[/cyan]"
1039+
)
1040+
return False
1041+
1042+
recursive = bool(self.settings.get("search_recursive", True))
1043+
1044+
counter: Counter[Any] = Counter()
1045+
for top_edge in self.edges:
1046+
candidates: Iterable[Hyperedge] = (
1047+
self._iter_subedges_ordered(top_edge) if recursive else [top_edge]
1048+
)
1049+
for sub in candidates:
1050+
bindings_list = sub.match(pattern)
1051+
for b in bindings_list:
1052+
key: Any = tuple(sorted(b.items())) if b else sub
1053+
counter[key] += 1
1054+
1055+
if not counter:
1056+
self.console.print(
1057+
f"[yellow]No matches[/yellow] for [cyan]{pattern_text}[/cyan]"
1058+
)
1059+
return False
1060+
1061+
items = counter.most_common()
1062+
try:
1063+
with open(csv_path, "w", newline="") as f:
1064+
writer = csv.writer(f)
1065+
first_key = items[0][0]
1066+
if isinstance(first_key, tuple):
1067+
var_names = [var for var, _ in first_key]
1068+
writer.writerow(["count", *var_names])
1069+
for key, count in items:
1070+
writer.writerow([count, *(str(val) for _, val in key)])
1071+
else:
1072+
writer.writerow(["count", "edge"])
1073+
for key, count in items:
1074+
writer.writerow([count, str(key)])
1075+
except OSError as e:
1076+
self.console.print(f"[red]Error:[/red] failed to write {csv_path}: {e}")
1077+
return False
1078+
1079+
self.console.print(
1080+
f"[green]✓[/green] Wrote [cyan]{len(items)}[/cyan] row(s) "
1081+
f"([green]{sum(counter.values())}[/green] total match(es), "
1082+
f"{'recursive' if recursive else 'top-level only'}) "
1083+
f"to [cyan]{csv_path}[/cyan]"
1084+
)
1085+
return False
1086+
10091087
def cmd_types(self, args: list) -> bool:
10101088
if not self.edges:
10111089
self.console.print("[yellow]No edges loaded.[/yellow]")

0 commit comments

Comments
 (0)