Files
7000pctAUTO a025fd4956
Some checks failed
CI / test (push) Has been cancelled
Shellhist CI / test (push) Has been cancelled
Shellhist CI / build (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:20 +00:00

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)