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 ADDED
@@ -0,0 +1,190 @@
1
+ """AI error analysis over any OpenAI-compatible chat API.
2
+
3
+ Single HTTP code path (stdlib ``urllib`` only — no vendor SDKs) for all
4
+ providers in :mod:`logwise.providers`. Gemini works through its
5
+ ``.../v1beta/openai`` endpoint; Ollama/LM Studio work against localhost.
6
+ """
7
+
8
+ import json
9
+ import urllib.error
10
+ import urllib.request
11
+
12
+ from .providers import (
13
+ PROVIDERS,
14
+ key_hint,
15
+ resolve_api_key,
16
+ resolve_base_url,
17
+ resolve_model,
18
+ resolve_provider,
19
+ )
20
+
21
+ TIMEOUT_S = 30
22
+
23
+ _PROMPT_TEMPLATE = """Analyze this command error:
24
+ Command: {command}
25
+ Exit code: {exit_code}
26
+ Stderr: {stderr}
27
+
28
+ Reply in exactly this format, under 200 words:
29
+ Reason: <one or two plain-language sentences>
30
+ Fixes:
31
+ 1. <first step>
32
+ 2. <second step>
33
+ """
34
+
35
+
36
+ def _extract_sse_content(body: str) -> str:
37
+ """Assemble assistant text from a Server-Sent Events stream.
38
+
39
+ Some OpenAI-compatible servers stream even when asked not to.
40
+ Collects ``choices[0].delta.content`` from each ``data:`` chunk,
41
+ ignoring reasoning fields and the ``[DONE]`` terminator.
42
+ """
43
+ parts = []
44
+ for line in body.splitlines():
45
+ line = line.strip()
46
+ if not line.startswith("data:"):
47
+ continue
48
+ data = line[5:].strip()
49
+ if not data or data == "[DONE]":
50
+ continue
51
+ try:
52
+ chunk = json.loads(data)
53
+ except ValueError:
54
+ continue
55
+ try:
56
+ delta = chunk["choices"][0].get("delta", {})
57
+ except (KeyError, IndexError, AttributeError):
58
+ continue
59
+ content = delta.get("content")
60
+ if content:
61
+ parts.append(content)
62
+ return "".join(parts).strip()
63
+
64
+
65
+ def _post_chat_completions(base_url: str, api_key: str | None, model: str, prompt: str) -> str:
66
+ """POST one chat-completions request, return the assistant text."""
67
+ body = {
68
+ "model": model,
69
+ "messages": [{"role": "user", "content": prompt}],
70
+ "temperature": 0.7,
71
+ "max_tokens": 700,
72
+ "stream": False,
73
+ }
74
+ req = urllib.request.Request(
75
+ f"{base_url}/chat/completions",
76
+ data=json.dumps(body).encode("utf-8"),
77
+ headers={"Content-Type": "application/json"},
78
+ )
79
+ if api_key:
80
+ req.add_header("Authorization", f"Bearer {api_key}")
81
+ try:
82
+ with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
83
+ raw = resp.read().decode("utf-8", "replace")
84
+ content_type = resp.headers.get("Content-Type", "")
85
+ except urllib.error.HTTPError as e:
86
+ detail = e.read().decode("utf-8", "replace")[:300]
87
+ raise RuntimeError(f"HTTP {e.code} from {base_url}: {detail}")
88
+ except urllib.error.URLError as e:
89
+ raise RuntimeError(f"Cannot reach {base_url}: {e.reason}")
90
+
91
+ if "text/event-stream" in content_type:
92
+ text = _extract_sse_content(raw)
93
+ if not text:
94
+ raise RuntimeError(f"Empty SSE stream from {base_url}. Preview: {raw[:300]}")
95
+ return text
96
+ try:
97
+ payload = json.loads(raw)
98
+ except ValueError:
99
+ raise RuntimeError(
100
+ f"Non-JSON response from {base_url} "
101
+ f"(content-type: {content_type or 'unknown'}). "
102
+ f"Preview: {raw[:300]}"
103
+ )
104
+ try:
105
+ return payload["choices"][0]["message"]["content"].strip()
106
+ except (KeyError, IndexError, AttributeError):
107
+ raise RuntimeError(f"Unexpected API response shape: {str(payload)[:300]}")
108
+
109
+
110
+ #: Line markers (case-insensitive, markdown-bold tolerant) that start
111
+ #: the fixes section of a model response.
112
+ _FIXES_MARKERS = (
113
+ "step-by-step",
114
+ "steps to fix",
115
+ "fix steps",
116
+ "how to fix",
117
+ "fixes:",
118
+ "solution:",
119
+ "resolution:",
120
+ )
121
+
122
+
123
+ def _split_reason_fixes(analysis: str) -> tuple[str, str]:
124
+ """Split model output into (reason, fixes).
125
+
126
+ Finds the first line starting with a known fixes marker; everything
127
+ before it is the reason (minus a leading ``Reason:`` label), everything
128
+ from it onward is the fixes (minus a bare header line like
129
+ ``**Fix Steps:**``). Falls back to whole-text-as-reason.
130
+ """
131
+ lines = analysis.splitlines()
132
+ for i, line in enumerate(lines):
133
+ clean = line.strip().strip("*").strip().lower()
134
+ if not any(clean.startswith(m) for m in _FIXES_MARKERS):
135
+ continue
136
+ reason = "\n".join(lines[:i])
137
+ for label in ("a simple human-readable reason for the error:", "reason:"):
138
+ bare = reason.lstrip().lstrip("*")
139
+ if bare.lower().startswith(label):
140
+ reason = bare[len(label) :].lstrip("*").strip()
141
+ break
142
+ fix_lines = lines[i:]
143
+ if fix_lines[0].strip().strip("*").strip().endswith(":"):
144
+ fix_lines = fix_lines[1:] # bare header line, drop it
145
+ fixes = "\n".join(fix_lines).strip()
146
+ return reason.strip(), fixes or "No fixes suggested."
147
+ return analysis.strip(), "No fixes suggested."
148
+
149
+
150
+ def ai_analyze_err(
151
+ command: str, stderr: str, exit_code: int, provider: str | None = None, model: str | None = None
152
+ ) -> dict:
153
+ """
154
+ Analyze an error with the selected provider (default: gemini).
155
+
156
+ Returns {"reason", "fixes", "provider", "model"} or {"error": ...}.
157
+ Resolves provider → LOGWISE_PROVIDER env → "gemini";
158
+ model → LOGWISE_MODEL env → provider default;
159
+ key → LOGWISE_API_KEY env → provider key env.
160
+ """
161
+ try:
162
+ canonical, config = resolve_provider(provider)
163
+ except ValueError as e:
164
+ return {"error": str(e)}
165
+ use_model = resolve_model(config, model)
166
+ base_url = resolve_base_url(config)
167
+ api_key = resolve_api_key(canonical, config)
168
+
169
+ keyless_local = "localhost" in base_url or "127.0.0.1" in base_url
170
+ if config["key_env"] is not None and not api_key and not keyless_local:
171
+ return {"error": f"No API key for provider '{canonical}'. {key_hint(canonical, config)}."}
172
+
173
+ prompt = _PROMPT_TEMPLATE.format(command=command, exit_code=exit_code, stderr=stderr)
174
+ try:
175
+ analysis = _post_chat_completions(base_url, api_key, use_model, prompt)
176
+ except Exception as e:
177
+ return {"error": f"AI analysis failed ({canonical}/{use_model}): {e}"}
178
+
179
+ reason, fixes = _split_reason_fixes(analysis)
180
+ return {
181
+ "reason": reason,
182
+ "fixes": fixes,
183
+ "provider": canonical,
184
+ "model": use_model,
185
+ }
186
+
187
+
188
+ def list_providers() -> list:
189
+ """Provider names for CLI help / validation."""
190
+ return sorted(PROVIDERS)
logwise/analyze.py ADDED
@@ -0,0 +1,122 @@
1
+ import json
2
+ import os
3
+
4
+ from .paths import get_log_dir
5
+ from .rules import apply_rules
6
+
7
+
8
+ def analyze_log_in_memory(
9
+ log: dict, use_ai: bool = False, provider: str | None = None, model: str | None = None
10
+ ) -> dict:
11
+ """
12
+ Analyze log dict directly.
13
+ If use_ai=True → AI-only analysis (rules skipped)
14
+ """
15
+ result = {"log": log}
16
+
17
+ # --- AI MODE (explicit override) ---
18
+ if use_ai:
19
+ from .ai import ai_analyze_err # lazy: no network imports unless needed
20
+
21
+ ai_result = ai_analyze_err(
22
+ log["command"],
23
+ log["stderr"],
24
+ log["exit_code"],
25
+ provider=provider,
26
+ model=model,
27
+ )
28
+ if "error" in ai_result:
29
+ result["summary"] = f"AI analysis failed: {ai_result['error']}"
30
+ else:
31
+ result["summary"] = "AI-based analysis requested."
32
+ result["ai_analysis"] = ai_result
33
+ return result
34
+
35
+ # --- DEFAULT MODE (rules first, AI fallback) ---
36
+ issues = apply_rules(log)
37
+ result["issues"] = issues
38
+
39
+ if issues:
40
+ result["summary"] = f"Found {len(issues)} rule-based issue(s)."
41
+ return result
42
+
43
+ from .ai import ai_analyze_err # lazy: no network imports unless needed
44
+
45
+ ai_result = ai_analyze_err(
46
+ log["command"],
47
+ log["stderr"],
48
+ log["exit_code"],
49
+ provider=provider,
50
+ model=model,
51
+ )
52
+ if "error" in ai_result:
53
+ result["summary"] = f"No rule-based issues. AI fallback failed: {ai_result['error']}"
54
+ else:
55
+ result["summary"] = "No rule-based issues. AI fallback used."
56
+ result["ai_analysis"] = ai_result
57
+
58
+ return result
59
+
60
+
61
+ def analyze_log(
62
+ log_file: str, use_ai: bool = False, provider: str | None = None, model: str | None = None
63
+ ) -> dict:
64
+ full_path = os.path.join(get_log_dir(), log_file)
65
+ if not os.path.exists(full_path):
66
+ return {"error": "Log file not found."}
67
+
68
+ with open(full_path) as f:
69
+ log = json.load(f)
70
+
71
+ return analyze_log_in_memory(log, use_ai=use_ai, provider=provider, model=model)
72
+
73
+
74
+ def list_logs() -> list:
75
+ log_dir = get_log_dir()
76
+ if not os.path.isdir(log_dir):
77
+ return []
78
+ return [f for f in os.listdir(log_dir) if f.endswith(".json")]
79
+
80
+
81
+ def prune_logs(
82
+ keep: int | None = None, older_than_days: float | None = None, dry_run: bool = False
83
+ ) -> dict:
84
+ """Delete saved logs by age/count.
85
+
86
+ Returns {"deleted": [...], "kept": n}; with `dry_run=True` nothing is
87
+ removed and the doomed files are reported under "doomed" instead.
88
+
89
+ - `older_than_days` set: only files older than that are eligible.
90
+ - `keep` set: the newest `keep` eligible files are exempt.
91
+ - Both None: nothing is deleted.
92
+ Files are ordered by mtime (oldest first). Unreadable/deleted files
93
+ are skipped, never fatal.
94
+ """
95
+ if keep is None and older_than_days is None:
96
+ return {"deleted": [], "doomed": [], "kept": len(list_logs())}
97
+ log_dir = get_log_dir()
98
+ entries = []
99
+ for name in list_logs():
100
+ try:
101
+ entries.append((os.path.getmtime(os.path.join(log_dir, name)), name))
102
+ except OSError:
103
+ continue
104
+ entries.sort() # oldest first
105
+ if older_than_days is not None:
106
+ import time
107
+
108
+ cutoff = time.time() - older_than_days * 86400
109
+ entries = [(mtime, name) for mtime, name in entries if mtime < cutoff]
110
+ doomed = [name for _, name in entries]
111
+ if keep is not None and keep >= 0:
112
+ doomed = doomed[: max(0, len(doomed) - keep)]
113
+ if dry_run:
114
+ return {"deleted": [], "doomed": doomed, "kept": len(list_logs())}
115
+ deleted = []
116
+ for name in doomed:
117
+ try:
118
+ os.remove(os.path.join(log_dir, name))
119
+ deleted.append(name)
120
+ except OSError:
121
+ continue
122
+ return {"deleted": deleted, "doomed": [], "kept": len(list_logs())}
logwise/capture.py ADDED
@@ -0,0 +1,64 @@
1
+ import datetime
2
+ import json
3
+ import os
4
+ import subprocess
5
+ from contextlib import nullcontext as _nullcontext
6
+
7
+ from . import display
8
+ from .analyze import analyze_log_in_memory
9
+ from .paths import get_log_dir
10
+
11
+
12
+ def capture_and_run(
13
+ command: str,
14
+ use_ai: bool = False,
15
+ provider: str | None = None,
16
+ model: str | None = None,
17
+ no_color: bool = False,
18
+ ) -> str | None:
19
+ """
20
+ Run command, print output if no error else analyze the error.
21
+ Returns the saved log filename on failure, None on success.
22
+ """
23
+ start_time = datetime.datetime.now().isoformat()
24
+ try:
25
+ result = subprocess.run(command, shell=True, capture_output=True, text=True)
26
+ log_entry = {
27
+ "command": command,
28
+ "start_time": start_time,
29
+ "end_time": datetime.datetime.now().isoformat(),
30
+ "stdout": result.stdout.strip(),
31
+ "stderr": result.stderr.strip(),
32
+ "exit_code": result.returncode,
33
+ }
34
+ if result.returncode == 0 and not result.stderr:
35
+ print(result.stdout)
36
+ return None
37
+ else:
38
+ log_dir = get_log_dir()
39
+ os.makedirs(log_dir, exist_ok=True)
40
+ log_name = f"log_{start_time.replace(':', '-')}.json"
41
+ log_file = os.path.join(log_dir, log_name)
42
+ with open(log_file, "w") as f:
43
+ json.dump(log_entry, f, indent=4)
44
+ # Analyze in memory
45
+ console = display.get_console(no_color=no_color)
46
+ plain = not display.use_rich(console)
47
+ with display.ai_progress(console, provider, model, plain) if use_ai else _nullcontext():
48
+ analysis = analyze_log_in_memory(
49
+ log_entry, use_ai=use_ai, provider=provider, model=model
50
+ )
51
+ display.error_header(console, result.returncode, plain)
52
+ display.stderr_block(console, result.stderr, plain)
53
+ display.analysis(
54
+ console,
55
+ analysis["summary"],
56
+ analysis.get("issues", []),
57
+ analysis.get("ai_analysis"),
58
+ plain,
59
+ )
60
+ return log_name
61
+
62
+ except Exception as e:
63
+ print("Execution failed: " + str(e))
64
+ return None
logwise/display.py ADDED
@@ -0,0 +1,221 @@
1
+ """Terminal rendering for LogWise (the only module that knows about color).
2
+
3
+ All failure/analysis output goes through here so `run` and `analyze`
4
+ never drift apart. Two modes:
5
+
6
+ - Rich (terminal): red error header, stderr panel, colored issue rows,
7
+ Markdown AI panel, table-backed `list`.
8
+ - Plain (piped/redirected, `NO_COLOR`/`LOGWISE_NO_COLOR`, or `--no-color`):
9
+ the exact legacy `print()` text, byte-for-byte, so scripts keep working.
10
+
11
+ Success-path stdout never touches this module — it stays a plain print.
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ from contextlib import nullcontext
17
+
18
+ import typer
19
+ from rich.console import Console
20
+ from rich.markdown import Markdown
21
+ from rich.markup import escape
22
+ from rich.panel import Panel
23
+ from rich.table import Table
24
+ from rich.text import Text
25
+
26
+ #: Accent color per AI provider (AI panel, spinner). Unknown → magenta.
27
+ PROVIDER_THEMES = {
28
+ "gemini": "blue",
29
+ "openai": "green",
30
+ "deepseek": "violet",
31
+ "groq": "orange1",
32
+ "openrouter": "cyan",
33
+ "ollama": "grey62",
34
+ }
35
+
36
+
37
+ def provider_color(name: str | None) -> str:
38
+ """Accent color for a provider name (case-insensitive)."""
39
+ return PROVIDER_THEMES.get((name or "").strip().lower(), "magenta")
40
+
41
+
42
+ def get_console(no_color: bool = False) -> Console:
43
+ """Build a Console honoring `--no-color` / `LOGWISE_NO_COLOR` / `NO_COLOR`."""
44
+ return Console(
45
+ no_color=no_color or bool(os.getenv("LOGWISE_NO_COLOR")),
46
+ )
47
+
48
+
49
+ def use_rich(console: Console) -> bool:
50
+ """Rich only when attached to a real terminal with color enabled."""
51
+ return bool(console.is_terminal) and not console.no_color
52
+
53
+
54
+ def interactive(console: Console) -> bool:
55
+ """True when safe to prompt: terminal output AND tty stdin."""
56
+ try:
57
+ stdin_tty = sys.stdin.isatty()
58
+ except Exception:
59
+ stdin_tty = False
60
+ return bool(console.is_terminal) and stdin_tty
61
+
62
+
63
+ def ai_progress(console: Console, provider: str | None, model: str | None, plain: bool):
64
+ """Spinner shown while waiting on an AI provider (no-op when plain).
65
+
66
+ Usage: `with ai_progress(console, provider, model, plain): ...`
67
+ """
68
+ if plain:
69
+ return nullcontext()
70
+ accent = provider_color(provider)
71
+ label = escape(f"{provider or 'ai'} / {model or '?'}")
72
+ return console.status(f"[{accent}]Consulting {label}…[/]", spinner="dots")
73
+
74
+
75
+ def prompt_retry(console: Console) -> bool:
76
+ """Ask whether to re-run the failed command (False when non-interactive)."""
77
+ if not interactive(console):
78
+ return False
79
+ try:
80
+ return typer.confirm("Retry command?", default=False)
81
+ except Exception:
82
+ return False
83
+
84
+
85
+ def prompt_ai(console: Console) -> bool:
86
+ """Offer AI analysis after a non-AI failure (False when non-interactive)."""
87
+ if not interactive(console):
88
+ return False
89
+ try:
90
+ return typer.confirm("Analyze with AI?", default=False)
91
+ except Exception:
92
+ return False
93
+
94
+
95
+ def prompt_rerun(console: Console) -> bool:
96
+ """Offer to re-run a logged command from `analyze`."""
97
+ if not interactive(console):
98
+ return False
99
+ try:
100
+ return typer.confirm("Re-run the logged command?", default=False)
101
+ except Exception:
102
+ return False
103
+
104
+
105
+ def prompt_edit_command(console: Console, current: str) -> str | None:
106
+ """Let the user edit a command before re-running it.
107
+
108
+ Returns the (possibly unchanged) command, or None if the user aborts
109
+ or prompts are unavailable. Enter keeps `current` (typer default).
110
+ """
111
+ if not interactive(console):
112
+ return None
113
+ try:
114
+ return typer.prompt("Edit command", default=current)
115
+ except KeyboardInterrupt:
116
+ return None
117
+ except Exception:
118
+ return None
119
+
120
+
121
+ def error_header(console: Console, exit_code: int, plain: bool) -> None:
122
+ """`Error occurred (exit code: N)` — loud in rich, legacy text in plain."""
123
+ if plain:
124
+ typer.echo(f"Error occurred (exit code: {exit_code})")
125
+ else:
126
+ console.print(f"[bold red]:x: Error occurred[/] [red](exit code: {exit_code})[/]")
127
+
128
+
129
+ def stderr_block(console: Console, stderr: str, plain: bool) -> None:
130
+ """Captured stderr — red panel in rich, labeled text in plain."""
131
+ if plain:
132
+ typer.echo("Stderr captured:")
133
+ typer.echo(stderr)
134
+ else:
135
+ console.print(
136
+ Panel(
137
+ Text(stderr or "(empty)", overflow="fold"),
138
+ title="[red]stderr[/]",
139
+ border_style="red",
140
+ )
141
+ )
142
+
143
+
144
+ def analysis(
145
+ console: Console,
146
+ summary: str,
147
+ issues: list,
148
+ ai_analysis: dict | None,
149
+ plain: bool,
150
+ *,
151
+ show_summary: bool = True,
152
+ ai_label: str = "AI Enhanced Analysis:",
153
+ ) -> None:
154
+ """Summary line + rule issues + optional AI panel.
155
+
156
+ `show_summary=False` skips the summary (for callers that already
157
+ printed it, e.g. `analyze` under its "Log Summary:" label). `ai_label`
158
+ keeps each command's legacy plain-text header byte-identical.
159
+ """
160
+ if plain:
161
+ if show_summary:
162
+ typer.echo("\nAnalysis:")
163
+ typer.echo(summary)
164
+ for issue in issues:
165
+ typer.echo(f"- Reason: {issue['description']} ({issue['root_cause']})")
166
+ typer.echo(f" Steps to fix: {issue['fixes']}")
167
+ if ai_analysis:
168
+ typer.echo(f"\n{ai_label}")
169
+ typer.echo(f"Reason: {ai_analysis['reason']}")
170
+ typer.echo(f"Steps to fix: {ai_analysis['fixes']}")
171
+ return
172
+
173
+ console.print(f"[bold]{escape(summary)}[/]")
174
+ for n, issue in enumerate(issues, 1):
175
+ console.print(
176
+ f"[yellow]:warning: {n}. {escape(issue['description'])}[/]"
177
+ f" [dim]({escape(issue['root_cause'])})[/]"
178
+ f" [dim][{escape(str(issue.get('rule_id', '?')))}][/]"
179
+ )
180
+ console.print(f" [green]->[/] {escape(issue['fixes'])}")
181
+ if ai_analysis:
182
+ provider = escape(str(ai_analysis.get("provider", "?")))
183
+ model = escape(str(ai_analysis.get("model", "?")))
184
+ accent = provider_color(ai_analysis.get("provider"))
185
+ console.print(
186
+ Panel(
187
+ Markdown(f"{ai_analysis['reason']}\n\n{ai_analysis['fixes']}"),
188
+ title=f"[bold {accent}]:sparkles: AI analysis[/]",
189
+ subtitle=f"[dim]{provider} / {model}[/]",
190
+ border_style=accent,
191
+ )
192
+ )
193
+
194
+
195
+ def log_table(console: Console, entries: list, plain: bool) -> None:
196
+ """Saved-logs listing — table in rich, one-per-line in plain.
197
+
198
+ `entries` is a list of {"file", "command", "exit_code"} dicts; any of
199
+ the latter two may be None for unreadable files.
200
+ """
201
+ if plain:
202
+ if not entries:
203
+ typer.echo("No logs found.")
204
+ for entry in entries:
205
+ typer.echo(entry["file"])
206
+ return
207
+
208
+ if not entries:
209
+ console.print("[dim]No logs found.[/]")
210
+ return
211
+ table = Table(title="Captured logs")
212
+ table.add_column("File", style="cyan")
213
+ table.add_column("Command", overflow="fold")
214
+ table.add_column("Exit", justify="right", style="red")
215
+ for entry in entries:
216
+ table.add_row(
217
+ escape(entry["file"]),
218
+ escape(entry.get("command")) if entry.get("command") else "[dim](unreadable)[/]",
219
+ str(entry["exit_code"]) if entry.get("exit_code") is not None else "?",
220
+ )
221
+ console.print(table)
logwise/env.py ADDED
@@ -0,0 +1,98 @@
1
+ """Minimal `.env` file support (stdlib only, no python-dotenv dependency).
2
+
3
+ Loaded once at CLI startup (see ``main.py``). Rules:
4
+
5
+ - Discovery: ``LOGWISE_ENV_FILE`` if set, else the first ``.env`` found
6
+ walking from the current working directory up to the filesystem root.
7
+ (Cwd-based, not package-dir-based, so it works for a pip-installed
8
+ ``logwise`` run from any project directory.)
9
+ - Precedence: real process environment always wins — file values are
10
+ applied with ``os.environ.setdefault`` and never override exports.
11
+ - Format (common subset): ``KEY=VALUE`` lines, optional ``export ``
12
+ prefix, ``#`` comments, single/double-quote stripping. Single-line
13
+ values only; no variable interpolation or multiline values.
14
+
15
+ A missing file is silently ignored, except an explicitly-set but
16
+ missing ``LOGWISE_ENV_FILE``, which warns on stderr.
17
+ """
18
+
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ ENV_FILENAME = ".env"
24
+ ENV_FILE_OVERRIDE_VAR = "LOGWISE_ENV_FILE"
25
+
26
+
27
+ def find_dotenv(start: Path | None = None) -> Path | None:
28
+ """Return the nearest `.env` from `start` upward, or None."""
29
+ current = (start or Path.cwd()).resolve()
30
+ root = current.anchor
31
+ while True:
32
+ candidate = current / ENV_FILENAME
33
+ if candidate.is_file():
34
+ return candidate
35
+ if str(current) == root:
36
+ return None
37
+ current = current.parent
38
+
39
+
40
+ def parse_dotenv_text(text: str) -> dict:
41
+ """Parse `.env` content into a dict (no env interaction)."""
42
+ values: dict = {}
43
+ for raw_line in text.splitlines():
44
+ line = raw_line.strip()
45
+ if not line or line.startswith("#"):
46
+ continue
47
+ if line.lower().startswith("export "):
48
+ line = line[7:].lstrip()
49
+ key, sep, value = line.partition("=")
50
+ if not sep:
51
+ continue
52
+ key = key.strip()
53
+ if not key or key[0].isdigit() or not key.replace("_", "").isalnum():
54
+ continue
55
+ value = value.strip()
56
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
57
+ value = value[1:-1]
58
+ elif "#" in value:
59
+ # Strip trailing inline comments only for unquoted values.
60
+ value = value.split("#", 1)[0].rstrip()
61
+ values[key] = value
62
+ return values
63
+
64
+
65
+ def load_dotenv(path: Path | str | None = None) -> Path | None:
66
+ """Load environment variables from a `.env` file.
67
+
68
+ Returns the path loaded, or None if no file was found. Existing
69
+ process environment variables are never overridden.
70
+ """
71
+ if path is None:
72
+ override = os.getenv(ENV_FILE_OVERRIDE_VAR)
73
+ if override:
74
+ explicit = Path(override).expanduser()
75
+ if not explicit.is_file():
76
+ print(
77
+ f"logwise: {ENV_FILE_OVERRIDE_VAR}={override} not found, skipping.",
78
+ file=sys.stderr,
79
+ )
80
+ return None
81
+ path = explicit
82
+ else:
83
+ found = find_dotenv()
84
+ if found is None:
85
+ return None
86
+ path = found
87
+ else:
88
+ path = Path(path).expanduser()
89
+ if not path.is_file():
90
+ return None
91
+
92
+ try:
93
+ text = path.read_text(encoding="utf-8")
94
+ except OSError:
95
+ return None
96
+ for key, value in parse_dotenv_text(text).items():
97
+ os.environ.setdefault(key, value)
98
+ return path