Files
7000pctAUTO 71f7849892
Some checks failed
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
fix: resolve CI type checking issues
- Add return type annotations to __hash__ (-> int) and __eq__ (-> bool) in HistoryEntry
- Add TextIO import and type annotations for file parameters
- Add type ignore comment for fuzzywuzzy import
- Add HistoryEntry import and list type annotations in time_analysis
- Add assert statements for Optional[datetime] timestamps
- Add TypedDict classes for type-safe pattern dictionaries
- Add CommandPattern import and list[CommandPattern] type annotation
- Add -> None return types to all test methods
- Remove unused HistoryEntry import (F401)
2026-01-31 14:19:08 +00:00

176 lines
5.9 KiB
Python

"""Core module for shell history loading and parsing."""
import os
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, TextIO
@dataclass
class HistoryEntry:
"""Represents a single entry in shell history."""
command: str
timestamp: Optional[datetime] = None
line_number: int = 0
shell_type: str = "unknown"
def __hash__(self) -> int:
return hash(self.command)
def __eq__(self, other: object) -> bool:
if isinstance(other, HistoryEntry):
return self.command == other.command
return False
@dataclass
class HistoryStore:
"""Stores and manages shell history entries."""
entries: list[HistoryEntry] = field(default_factory=list)
command_frequency: dict[str, int] = field(default_factory=dict)
def add_entry(self, entry: HistoryEntry) -> None:
"""Add a history entry to the store."""
self.entries.append(entry)
self.command_frequency[entry.command] = self.command_frequency.get(entry.command, 0) + 1
def get_frequency(self, command: str) -> int:
"""Get the frequency of a command."""
return self.command_frequency.get(command, 0)
def get_most_frequent(self, limit: int = 10) -> list[tuple[str, int]]:
"""Get the most frequently used commands."""
sorted_freq = sorted(self.command_frequency.items(), key=lambda x: x[1], reverse=True)
return sorted_freq[:limit]
def get_unique_commands(self) -> list[str]:
"""Get list of unique commands."""
return list(self.command_frequency.keys())
class HistoryLoader:
"""Loads and parses shell history from various shell formats."""
BASH_TIMESTAMP_PATTERN = re.compile(r'^#(\d{10})$')
ZSH_TIMESTAMP_PATTERN = re.compile(r'^: (\d{10}):(\d+);(.*)$')
def __init__(self, history_path: Optional[str] = None):
"""Initialize the history loader.
Args:
history_path: Optional path to history file. If not provided,
will attempt to detect from environment.
"""
self.history_path = history_path
self.shell_type = self._detect_shell()
def _detect_shell(self) -> str:
"""Detect the shell type from environment."""
shell = os.environ.get("SHELL", "")
if "zsh" in shell:
return "zsh"
elif "bash" in shell:
return "bash"
return "bash"
def _get_default_history_path(self) -> Optional[str]:
"""Get the default history file path based on shell type."""
home = os.path.expanduser("~")
if self.shell_type == "zsh":
return os.environ.get("HISTFILE", f"{home}/.zsh_history")
return os.environ.get("HISTFILE", f"{home}/.bash_history")
def load(self) -> HistoryStore:
"""Load history from the configured path.
Returns:
HistoryStore containing all parsed entries.
"""
path = self.history_path or self._get_default_history_path()
if not path or not os.path.exists(path):
raise FileNotFoundError(
f"History file not found: {path}. "
"Set HISTFILE environment variable or use --history flag."
)
store = HistoryStore()
with open(path, "r", encoding="utf-8", errors="replace") as f:
if self.shell_type == "zsh":
self._parse_zsh_history(f, store)
else:
self._parse_bash_history(f, store)
return store
def _parse_bash_history(self, file: TextIO, store: HistoryStore) -> None:
"""Parse bash history format."""
line_number = 0
pending_timestamp: Optional[datetime] = None
for line in file:
line = line.rstrip("\n")
line_number += 1
timestamp_match = self.BASH_TIMESTAMP_PATTERN.match(line)
if timestamp_match:
ts = int(timestamp_match.group(1))
pending_timestamp = datetime.fromtimestamp(ts)
continue
if line and not line.startswith("#"):
command = line
entry = HistoryEntry(
command=command,
timestamp=pending_timestamp,
line_number=line_number,
shell_type="bash"
)
store.add_entry(entry)
pending_timestamp = None
def _parse_zsh_history(self, file: TextIO, store: HistoryStore) -> None:
"""Parse zsh history format."""
line_number = 0
for line in file:
line = line.rstrip("\n")
line_number += 1
timestamp_match = self.ZSH_TIMESTAMP_PATTERN.match(line)
if timestamp_match:
ts = int(timestamp_match.group(1))
command = timestamp_match.group(3)
timestamp = datetime.fromtimestamp(ts)
entry = HistoryEntry(
command=command,
timestamp=timestamp,
line_number=line_number,
shell_type="zsh"
)
store.add_entry(entry)
elif line:
entry = HistoryEntry(
command=line,
timestamp=None,
line_number=line_number,
shell_type="zsh"
)
store.add_entry(entry)
@staticmethod
def from_file(path: str) -> HistoryStore:
"""Convenience method to load history from a specific file.
Args:
path: Path to history file.
Returns:
HistoryStore containing parsed entries.
"""
loader = HistoryLoader(history_path=path)
return loader.load()