- 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)
282 lines
8.2 KiB
Python
282 lines
8.2 KiB
Python
"""Time-based analysis command."""
|
|
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
import click
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
from shellhist.core import HistoryLoader, HistoryEntry
|
|
|
|
|
|
@click.command("analyze-time")
|
|
@click.option(
|
|
"--history",
|
|
"-H",
|
|
type=str,
|
|
help="Path to history file",
|
|
)
|
|
@click.option(
|
|
"--daily/--no-daily",
|
|
default=False,
|
|
help="Show commands run at similar times daily",
|
|
)
|
|
@click.option(
|
|
"--weekly/--no-weekly",
|
|
default=False,
|
|
help="Show commands run at similar times weekly",
|
|
)
|
|
@click.option(
|
|
"--time-range",
|
|
"-t",
|
|
type=str,
|
|
default="7d",
|
|
help="Time range filter (e.g., '7d', '24h', '30d')",
|
|
)
|
|
@click.option(
|
|
"--shell",
|
|
"-s",
|
|
type=click.Choice(["bash", "zsh"]),
|
|
help="Shell type for parsing",
|
|
)
|
|
@click.pass_context
|
|
def analyze_time_command(
|
|
ctx: click.Context,
|
|
history: Optional[str],
|
|
daily: bool,
|
|
weekly: bool,
|
|
time_range: str,
|
|
shell: Optional[str],
|
|
) -> None:
|
|
"""Analyze time-based patterns in your shell history.
|
|
|
|
Examples:
|
|
|
|
\b
|
|
shellhist analyze-time --daily
|
|
shellhist analyze-time --weekly --time-range 30d
|
|
shellhist analyze-time --daily --time-range 7d
|
|
"""
|
|
console = Console()
|
|
|
|
try:
|
|
loader = HistoryLoader(history_path=history)
|
|
store = loader.load()
|
|
|
|
if not store.entries:
|
|
console.print("[yellow]No entries found in history.[/yellow]")
|
|
return
|
|
|
|
entries_with_time = [
|
|
e for e in store.entries
|
|
if e.timestamp is not None
|
|
]
|
|
|
|
if not entries_with_time:
|
|
console.print(
|
|
"[yellow]No timestamps found in history. "
|
|
"Ensure your history includes timestamps.[/yellow]"
|
|
)
|
|
return
|
|
|
|
cutoff = _parse_time_range(time_range)
|
|
recent_entries = [
|
|
e for e in entries_with_time
|
|
if e.timestamp is not None and e.timestamp >= cutoff
|
|
]
|
|
|
|
if daily:
|
|
_analyze_daily_patterns(console, recent_entries)
|
|
elif weekly:
|
|
_analyze_weekly_patterns(console, recent_entries)
|
|
else:
|
|
_analyze_hourly_distribution(console, recent_entries)
|
|
|
|
except FileNotFoundError as e:
|
|
console.print(f"[red]Error: {e}[/red]")
|
|
ctx.exit(1)
|
|
except Exception as e:
|
|
console.print(f"[red]Error analyzing time patterns: {e}[/red]")
|
|
ctx.exit(1)
|
|
|
|
|
|
def _parse_time_range(time_range: str) -> datetime:
|
|
"""Parse time range string like '7d', '24h', '30d'."""
|
|
value = int(time_range[:-1])
|
|
unit = time_range[-1].lower()
|
|
|
|
now = datetime.now()
|
|
|
|
if unit == 'h':
|
|
return now - timedelta(hours=value)
|
|
elif unit == 'd':
|
|
return now - timedelta(days=value)
|
|
elif unit == 'w':
|
|
return now - timedelta(weeks=value)
|
|
else:
|
|
return now - timedelta(days=7)
|
|
|
|
|
|
def _analyze_hourly_distribution(console: Console, entries: list[HistoryEntry]) -> None:
|
|
"""Analyze command distribution by hour of day."""
|
|
hourly: dict[int, list[str]] = defaultdict(list)
|
|
|
|
for entry in entries:
|
|
assert entry.timestamp is not None
|
|
hour = entry.timestamp.hour
|
|
hourly[hour].append(entry.command)
|
|
|
|
console.print("\n[bold cyan]Hourly Command Distribution[/bold cyan]")
|
|
|
|
table = Table(show_header=True, header_style="bold magenta")
|
|
table.add_column("Hour", width=10)
|
|
table.add_column("Commands", width=10)
|
|
table.add_column("Top Commands", width=60)
|
|
|
|
for hour in range(24):
|
|
cmds = hourly.get(hour, [])
|
|
if cmds:
|
|
top: dict[str, int] = defaultdict(int)
|
|
for cmd in cmds:
|
|
top[cmd] += 1
|
|
top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:2]
|
|
top_str = ", ".join(f"{c}({n})" for c, n in top_sorted)
|
|
else:
|
|
top_str = "-"
|
|
|
|
hour_label = f"{hour:02d}:00"
|
|
table.add_row(hour_label, str(len(cmds)), top_str[:58])
|
|
|
|
console.print(table)
|
|
|
|
|
|
def _analyze_daily_patterns(console: Console, entries: list[HistoryEntry]) -> None:
|
|
"""Analyze commands run at similar times daily."""
|
|
daily_patterns: dict[tuple[int, int], list[str]] = defaultdict(list)
|
|
|
|
for entry in entries:
|
|
assert entry.timestamp is not None
|
|
hour = entry.timestamp.hour
|
|
key = (entry.timestamp.weekday(), hour)
|
|
daily_patterns[key].append(entry.command)
|
|
|
|
console.print("\n[bold cyan]Daily Time Patterns[/bold cyan]")
|
|
|
|
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
|
|
from typing import TypedDict
|
|
|
|
class DailyPattern(TypedDict):
|
|
day: str
|
|
hour: int
|
|
count: int
|
|
top: list[tuple[str, int]]
|
|
|
|
patterns_found: list[DailyPattern] = []
|
|
for (weekday, hour), cmds in daily_patterns.items():
|
|
if len(cmds) >= 2:
|
|
top: dict[str, int] = defaultdict(int)
|
|
for cmd in cmds:
|
|
top[cmd] += 1
|
|
top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:3]
|
|
patterns_found.append({
|
|
"day": day_names[weekday],
|
|
"hour": hour,
|
|
"count": len(cmds),
|
|
"top": top_sorted,
|
|
})
|
|
|
|
if not patterns_found:
|
|
console.print("[yellow]No significant daily patterns found.[/yellow]")
|
|
return
|
|
|
|
patterns_found.sort(key=lambda x: x["count"], reverse=True)
|
|
|
|
table = Table(show_header=True, header_style="bold magenta")
|
|
table.add_column("Day", width=8)
|
|
table.add_column("Hour", width=8)
|
|
table.add_column("Times", width=8)
|
|
table.add_column("Common Commands", width=60)
|
|
|
|
for p in patterns_found[:20]:
|
|
top_str = ", ".join(f"{c}({n})" for c, n in p["top"])
|
|
table.add_row(
|
|
p["day"],
|
|
f"{p['hour']:02d}:00",
|
|
str(p["count"]),
|
|
top_str[:58],
|
|
)
|
|
|
|
console.print(table)
|
|
|
|
|
|
def _analyze_weekly_patterns(console: Console, entries: list[HistoryEntry]) -> None:
|
|
"""Analyze commands run at similar times weekly."""
|
|
weekly_patterns: dict[tuple[int, int, int, int], list[str]] = defaultdict(list)
|
|
|
|
for entry in entries:
|
|
assert entry.timestamp is not None
|
|
key = (
|
|
entry.timestamp.isocalendar().year,
|
|
entry.timestamp.isocalendar().week,
|
|
entry.timestamp.weekday(),
|
|
entry.timestamp.hour,
|
|
)
|
|
weekly_patterns[key].append(entry.command)
|
|
|
|
console.print("\n[bold cyan]Weekly Time Patterns[/bold cyan]")
|
|
|
|
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
|
|
from typing import TypedDict
|
|
|
|
class WeeklyPattern(TypedDict):
|
|
week: int
|
|
day: str
|
|
hour: int
|
|
count: int
|
|
top: list[tuple[str, int]]
|
|
|
|
patterns_found: list[WeeklyPattern] = []
|
|
for key, cmds in weekly_patterns.items():
|
|
year, week, weekday, hour = key
|
|
if len(cmds) >= 2:
|
|
top: dict[str, int] = defaultdict(int)
|
|
for cmd in cmds:
|
|
top[cmd] += 1
|
|
top_sorted = sorted(top.items(), key=lambda x: x[1], reverse=True)[:3]
|
|
patterns_found.append({
|
|
"week": week,
|
|
"day": day_names[weekday],
|
|
"hour": hour,
|
|
"count": len(cmds),
|
|
"top": top_sorted,
|
|
})
|
|
|
|
if not patterns_found:
|
|
console.print("[yellow]No significant weekly patterns found.[/yellow]")
|
|
return
|
|
|
|
patterns_found.sort(key=lambda x: x["count"], reverse=True)
|
|
|
|
table = Table(show_header=True, header_style="bold magenta")
|
|
table.add_column("Week", width=8)
|
|
table.add_column("Day", width=8)
|
|
table.add_column("Hour", width=8)
|
|
table.add_column("Times", width=8)
|
|
table.add_column("Common Commands", width=50)
|
|
|
|
for p in patterns_found[:20]:
|
|
top_str = ", ".join(f"{c}({n})" for c, n in p["top"])
|
|
table.add_row(
|
|
str(p["week"]),
|
|
p["day"],
|
|
f"{p['hour']:02d}:00",
|
|
str(p["count"]),
|
|
top_str[:48],
|
|
)
|
|
|
|
console.print(table)
|