logwise-cli 0.2.0__py3-none-any.whl
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.
- logwise/ai.py +190 -0
- logwise/analyze.py +122 -0
- logwise/capture.py +64 -0
- logwise/display.py +221 -0
- logwise/env.py +98 -0
- logwise/main.py +206 -0
- logwise/paths.py +34 -0
- logwise/providers.py +83 -0
- logwise/rules.py +42 -0
- logwise_cli-0.2.0.dist-info/METADATA +438 -0
- logwise_cli-0.2.0.dist-info/RECORD +15 -0
- logwise_cli-0.2.0.dist-info/WHEEL +5 -0
- logwise_cli-0.2.0.dist-info/entry_points.txt +2 -0
- logwise_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- logwise_cli-0.2.0.dist-info/top_level.txt +1 -0
logwise/main.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from contextlib import nullcontext
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from . import display
|
|
9
|
+
from .analyze import analyze_log, list_logs, prune_logs
|
|
10
|
+
from .capture import capture_and_run
|
|
11
|
+
from .env import load_dotenv
|
|
12
|
+
from .paths import ensure_log_dir, get_log_dir
|
|
13
|
+
from .providers import PROVIDERS
|
|
14
|
+
|
|
15
|
+
load_dotenv() # .env / LOGWISE_ENV_FILE, before any resolve_*() runs
|
|
16
|
+
ensure_log_dir() # logs/ exists before any command runs
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ensure_utf8_output() -> None:
|
|
20
|
+
"""Use UTF-8 for CLI output so model text never crashes Windows consoles."""
|
|
21
|
+
for stream in (sys.stdout, sys.stderr):
|
|
22
|
+
try:
|
|
23
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_ensure_utf8_output()
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(help="LogWise: Intelligent Log Analyzer")
|
|
31
|
+
|
|
32
|
+
_PROVIDER_HELP = f"AI provider ({', '.join(sorted(PROVIDERS))}). Env: LOGWISE_PROVIDER."
|
|
33
|
+
_NO_COLOR_HELP = "Disable colored output. Env: LOGWISE_NO_COLOR."
|
|
34
|
+
_NO_PROMPT_HELP = "Never prompt (retry / edit / AI offer / rerun). Env: LOGWISE_NO_PROMPT."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _prompts_enabled(no_prompt: bool) -> bool:
|
|
38
|
+
return not no_prompt and not os.getenv("LOGWISE_NO_PROMPT")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def run(
|
|
43
|
+
command: str,
|
|
44
|
+
ai: bool = typer.Option(False, "--ai", help="Use AI enhanced analysis on errors"),
|
|
45
|
+
provider: str = typer.Option(None, "--provider", help=_PROVIDER_HELP),
|
|
46
|
+
model: str = typer.Option(None, "--model", help="Model override. Env: LOGWISE_MODEL."),
|
|
47
|
+
no_color: bool = typer.Option(False, "--no-color", help=_NO_COLOR_HELP),
|
|
48
|
+
no_prompt: bool = typer.Option(False, "--no-prompt", help=_NO_PROMPT_HELP),
|
|
49
|
+
):
|
|
50
|
+
"""Run a command: Output normally on success, analyze errors with reasons/fixes"""
|
|
51
|
+
console = display.get_console(no_color=no_color)
|
|
52
|
+
log_name = capture_and_run(
|
|
53
|
+
command, use_ai=ai, provider=provider, model=model, no_color=no_color
|
|
54
|
+
)
|
|
55
|
+
if log_name is None or not _prompts_enabled(no_prompt):
|
|
56
|
+
return
|
|
57
|
+
current = command
|
|
58
|
+
while display.prompt_retry(console):
|
|
59
|
+
edited = display.prompt_edit_command(console, current)
|
|
60
|
+
if edited is None:
|
|
61
|
+
break
|
|
62
|
+
current = edited
|
|
63
|
+
log_name = capture_and_run(
|
|
64
|
+
current, use_ai=ai, provider=provider, model=model, no_color=no_color
|
|
65
|
+
)
|
|
66
|
+
if log_name is None:
|
|
67
|
+
return
|
|
68
|
+
if not ai and display.prompt_ai(console):
|
|
69
|
+
plain = not display.use_rich(console)
|
|
70
|
+
with display.ai_progress(console, provider, model, plain):
|
|
71
|
+
result = analyze_log(log_name, use_ai=True, provider=provider, model=model)
|
|
72
|
+
display.analysis(
|
|
73
|
+
console,
|
|
74
|
+
result.get("summary", ""),
|
|
75
|
+
result.get("issues", []),
|
|
76
|
+
result.get("ai_analysis"),
|
|
77
|
+
plain,
|
|
78
|
+
show_summary=not plain,
|
|
79
|
+
ai_label="AI Analysis:",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command()
|
|
84
|
+
def analyze(
|
|
85
|
+
log_file: str = typer.Argument(..., help="Log file to analyze"),
|
|
86
|
+
ai: bool = typer.Option(False, "--ai", help="Use AI for enchanced analysis"),
|
|
87
|
+
provider: str = typer.Option(None, "--provider", help=_PROVIDER_HELP),
|
|
88
|
+
model: str = typer.Option(None, "--model", help="Model override. Env: LOGWISE_MODEL."),
|
|
89
|
+
no_color: bool = typer.Option(False, "--no-color", help=_NO_COLOR_HELP),
|
|
90
|
+
no_prompt: bool = typer.Option(False, "--no-prompt", help=_NO_PROMPT_HELP),
|
|
91
|
+
):
|
|
92
|
+
"""
|
|
93
|
+
Analyze a captured log for errors
|
|
94
|
+
"""
|
|
95
|
+
console = display.get_console(no_color=no_color)
|
|
96
|
+
plain = not display.use_rich(console)
|
|
97
|
+
if ai:
|
|
98
|
+
context = display.ai_progress(console, provider, model, plain)
|
|
99
|
+
else:
|
|
100
|
+
context = nullcontext()
|
|
101
|
+
with context:
|
|
102
|
+
result = analyze_log(log_file, use_ai=ai, provider=provider, model=model)
|
|
103
|
+
if "error" in result:
|
|
104
|
+
typer.echo(result["error"])
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if not plain:
|
|
108
|
+
console.print("[bold]Log Summary:[/]")
|
|
109
|
+
else:
|
|
110
|
+
typer.echo("Log Summary:")
|
|
111
|
+
typer.echo(result["summary"])
|
|
112
|
+
display.analysis(
|
|
113
|
+
console,
|
|
114
|
+
result["summary"],
|
|
115
|
+
result.get("issues", []),
|
|
116
|
+
result.get("ai_analysis"),
|
|
117
|
+
plain,
|
|
118
|
+
show_summary=not plain,
|
|
119
|
+
ai_label="AI Analysis:",
|
|
120
|
+
)
|
|
121
|
+
if (
|
|
122
|
+
_prompts_enabled(no_prompt)
|
|
123
|
+
and result.get("log")
|
|
124
|
+
and result["log"].get("command")
|
|
125
|
+
and display.prompt_rerun(console)
|
|
126
|
+
):
|
|
127
|
+
edited = display.prompt_edit_command(console, result["log"]["command"])
|
|
128
|
+
if edited is not None:
|
|
129
|
+
capture_and_run(
|
|
130
|
+
edited,
|
|
131
|
+
use_ai=ai,
|
|
132
|
+
provider=provider,
|
|
133
|
+
model=model,
|
|
134
|
+
no_color=no_color,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def list(
|
|
140
|
+
no_color: bool = typer.Option(False, "--no-color", help=_NO_COLOR_HELP),
|
|
141
|
+
):
|
|
142
|
+
"""
|
|
143
|
+
List all captured logs.
|
|
144
|
+
"""
|
|
145
|
+
log_dir = get_log_dir()
|
|
146
|
+
entries = []
|
|
147
|
+
for name in list_logs():
|
|
148
|
+
entry = {"file": name, "command": None, "exit_code": None}
|
|
149
|
+
try:
|
|
150
|
+
with open(os.path.join(log_dir, name)) as f:
|
|
151
|
+
log = json.load(f)
|
|
152
|
+
entry["command"] = log.get("command")
|
|
153
|
+
entry["exit_code"] = log.get("exit_code")
|
|
154
|
+
except (OSError, ValueError):
|
|
155
|
+
pass
|
|
156
|
+
entries.append(entry)
|
|
157
|
+
console = display.get_console(no_color=no_color)
|
|
158
|
+
display.log_table(console, entries, not display.use_rich(console))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command()
|
|
162
|
+
def prune(
|
|
163
|
+
keep: int = typer.Option(50, "--keep", help="Keep the newest N eligible logs."),
|
|
164
|
+
older_than: float = typer.Option(
|
|
165
|
+
None, "--older-than", help="Only delete logs older than N days."
|
|
166
|
+
),
|
|
167
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Delete without asking."),
|
|
168
|
+
no_color: bool = typer.Option(False, "--no-color", help=_NO_COLOR_HELP),
|
|
169
|
+
):
|
|
170
|
+
"""
|
|
171
|
+
Delete old logs (by count and/or age). Asks first unless --yes.
|
|
172
|
+
"""
|
|
173
|
+
preview = prune_logs(keep=keep, older_than_days=older_than, dry_run=True)
|
|
174
|
+
doomed = preview["doomed"]
|
|
175
|
+
console = display.get_console(no_color=no_color)
|
|
176
|
+
plain = not display.use_rich(console)
|
|
177
|
+
if not doomed:
|
|
178
|
+
if plain:
|
|
179
|
+
typer.echo("Nothing to prune.")
|
|
180
|
+
else:
|
|
181
|
+
console.print("[dim]Nothing to prune.[/]")
|
|
182
|
+
return
|
|
183
|
+
if plain:
|
|
184
|
+
typer.echo(f"Would delete {len(doomed)} log(s):")
|
|
185
|
+
for name in doomed:
|
|
186
|
+
typer.echo(f" {name}")
|
|
187
|
+
else:
|
|
188
|
+
console.print(f"[bold]Would delete {len(doomed)} log(s):[/]")
|
|
189
|
+
for name in doomed:
|
|
190
|
+
console.print(f" [cyan]{name}[/]")
|
|
191
|
+
if not yes:
|
|
192
|
+
try:
|
|
193
|
+
confirmed = display.interactive(console) and typer.confirm(
|
|
194
|
+
"Delete these logs?", default=False
|
|
195
|
+
)
|
|
196
|
+
except Exception:
|
|
197
|
+
confirmed = False
|
|
198
|
+
if not confirmed:
|
|
199
|
+
typer.echo("Aborted.")
|
|
200
|
+
return
|
|
201
|
+
result = prune_logs(keep=keep, older_than_days=older_than)
|
|
202
|
+
typer.echo(f"Deleted {len(result['deleted'])} log(s), {result['kept']} kept.")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
app()
|
logwise/paths.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Filesystem locations for LogWise runtime data.
|
|
2
|
+
|
|
3
|
+
``get_log_dir()`` resolves per call (not at import time) so library users
|
|
4
|
+
and tests that change cwd get the right directory.
|
|
5
|
+
|
|
6
|
+
Resolution: ``LOGWISE_LOG_DIR`` env var, else ``logs/`` under the current
|
|
7
|
+
working directory. Cwd-based (not package-dir-based): a pip-installed
|
|
8
|
+
``logwise`` must never write into ``site-packages``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_log_dir() -> Path:
|
|
16
|
+
"""Return the directory where failure logs are stored."""
|
|
17
|
+
override = os.getenv("LOGWISE_LOG_DIR")
|
|
18
|
+
if override:
|
|
19
|
+
return Path(override).expanduser()
|
|
20
|
+
return Path.cwd() / "logs"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ensure_log_dir() -> Path:
|
|
24
|
+
"""Best-effort creation of the log dir (never raises).
|
|
25
|
+
|
|
26
|
+
Called once at CLI startup so `list` works before any failure.
|
|
27
|
+
Read-only locations are silently ignored — commands cope already.
|
|
28
|
+
"""
|
|
29
|
+
log_dir = get_log_dir()
|
|
30
|
+
try:
|
|
31
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
return log_dir
|
logwise/providers.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Provider registry for LogWise AI analysis.
|
|
2
|
+
|
|
3
|
+
Every provider here speaks the OpenAI-compatible
|
|
4
|
+
``POST {base_url}/chat/completions`` API — including Gemini (via its
|
|
5
|
+
OpenAI-compat endpoint) and local servers like Ollama. Adding a new
|
|
6
|
+
vendor is data-only: one dict entry, zero new dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
PROVIDERS = {
|
|
12
|
+
"gemini": {
|
|
13
|
+
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
14
|
+
"key_env": "GEMINI_API_KEY",
|
|
15
|
+
"default_model": "gemini-2.0-flash",
|
|
16
|
+
},
|
|
17
|
+
"openai": {
|
|
18
|
+
"base_url": "https://api.openai.com/v1",
|
|
19
|
+
"key_env": "OPENAI_API_KEY",
|
|
20
|
+
"default_model": "gpt-4o-mini",
|
|
21
|
+
},
|
|
22
|
+
"deepseek": {
|
|
23
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
24
|
+
"key_env": "DEEPSEEK_API_KEY",
|
|
25
|
+
"default_model": "deepseek-chat",
|
|
26
|
+
},
|
|
27
|
+
"groq": {
|
|
28
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
29
|
+
"key_env": "GROQ_API_KEY",
|
|
30
|
+
"default_model": "llama-3.3-70b-versatile",
|
|
31
|
+
},
|
|
32
|
+
"openrouter": {
|
|
33
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
34
|
+
"key_env": "OPENROUTER_API_KEY",
|
|
35
|
+
"default_model": "openai/gpt-4o-mini",
|
|
36
|
+
},
|
|
37
|
+
# Local, keyless. Requires `ollama serve` + a pulled model.
|
|
38
|
+
"ollama": {
|
|
39
|
+
"base_url": "http://localhost:11434/v1",
|
|
40
|
+
"key_env": None,
|
|
41
|
+
"default_model": "llama3.1",
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
DEFAULT_PROVIDER = "gemini"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_provider(name: str | None) -> tuple[str, dict]:
|
|
49
|
+
"""Return (canonical_name, config). Raises ValueError on unknown name."""
|
|
50
|
+
raw = (name or os.getenv("LOGWISE_PROVIDER") or DEFAULT_PROVIDER).strip().lower()
|
|
51
|
+
if raw not in PROVIDERS:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Unknown provider '{raw}'. Valid options: {', '.join(sorted(PROVIDERS))}."
|
|
54
|
+
)
|
|
55
|
+
return raw, PROVIDERS[raw]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_model(config: dict, model: str | None) -> str:
|
|
59
|
+
"""CLI flag → LOGWISE_MODEL env → provider default."""
|
|
60
|
+
return (model or os.getenv("LOGWISE_MODEL") or config["default_model"]).strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_base_url(config: dict) -> str:
|
|
64
|
+
"""LOGWISE_BASE_URL env overrides the provider default (custom servers)."""
|
|
65
|
+
return (os.getenv("LOGWISE_BASE_URL") or config["base_url"]).rstrip("/")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_api_key(canonical_name: str, config: dict) -> str | None:
|
|
69
|
+
"""LOGWISE_API_KEY env overrides the provider-specific key env.
|
|
70
|
+
|
|
71
|
+
Returns None for keyless providers (e.g. ollama) or when no key is set
|
|
72
|
+
(caller turns that into a clean error message).
|
|
73
|
+
"""
|
|
74
|
+
if config["key_env"] is None:
|
|
75
|
+
return os.getenv("LOGWISE_API_KEY") # optional even for local servers
|
|
76
|
+
return os.getenv("LOGWISE_API_KEY") or os.getenv(config["key_env"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def key_hint(canonical_name: str, config: dict) -> str:
|
|
80
|
+
"""Human-readable hint for the missing-key error message."""
|
|
81
|
+
if config["key_env"] is None:
|
|
82
|
+
return "no API key needed (local server)"
|
|
83
|
+
return f"Set env var {config['key_env']} (or generic LOGWISE_API_KEY)"
|
logwise/rules.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
ERROR_RULES = [
|
|
2
|
+
{
|
|
3
|
+
"id": "non_zero_exit",
|
|
4
|
+
"condition": lambda log: log["exit_code"] != 0,
|
|
5
|
+
"description": "Command failed with non-zero exit code.",
|
|
6
|
+
"root_cause": (
|
|
7
|
+
"Possible reasons: Invalid arguments, missing dependencies, "
|
|
8
|
+
"or runtime errors. Check stderr for details."
|
|
9
|
+
),
|
|
10
|
+
"fixes": "Verify command syntax, install missing packages, or debug the script.",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "permission_denied",
|
|
14
|
+
"condition": lambda log: "permission denied" in log["stderr"].lower(),
|
|
15
|
+
"description": "Permission denied error detected.",
|
|
16
|
+
"root_cause": "Insufficient permissions.",
|
|
17
|
+
"fixes": "Run with sudo, change file ownership (chown), or adjust permissions (chmod).",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "file_not_found",
|
|
21
|
+
"condition": lambda log: "no such file or directory" in log["stderr"].lower(),
|
|
22
|
+
"description": "File or directory not found.",
|
|
23
|
+
"root_cause": "Path issue.",
|
|
24
|
+
"fixes": "Check if the file exists (ls), correct the path, or create the missing item.",
|
|
25
|
+
},
|
|
26
|
+
# Add more as needed
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def apply_rules(log: dict) -> list:
|
|
31
|
+
issues = []
|
|
32
|
+
for rule in ERROR_RULES:
|
|
33
|
+
if rule["condition"](log):
|
|
34
|
+
issues.append(
|
|
35
|
+
{
|
|
36
|
+
"rule_id": rule["id"],
|
|
37
|
+
"description": rule["description"],
|
|
38
|
+
"root_cause": rule["root_cause"],
|
|
39
|
+
"fixes": rule["fixes"],
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
return issues
|