logscope-cli 0.4.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 João Vinny
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: logscope-cli
3
+ Version: 0.4.1
4
+ Summary: LogScope — Beautiful log viewer for the terminal
5
+ License-File: LICENSE
6
+ Author: vinnytherobot
7
+ Requires-Python: >=3.9,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: rich (>=13.0.0,<14.0.0)
16
+ Requires-Dist: typer (>=0.15.1,<0.16.0)
17
+ Requires-Dist: typing-extensions (>=4.0.0,<5.0.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ <div align="center">
21
+ <img src="./assets/logscope-logo.png" alt="LogScope Logo" width="500" height="500">
22
+
23
+ # LogScope
24
+
25
+ **Beautiful, simple, and powerful log viewer for the terminal.**
26
+
27
+ [![Python](https://img.shields.io/badge/Python-3.9%2B-blue)](https://python.org)
28
+ [![Typer](https://img.shields.io/badge/CLI-Typer-green)](https://typer.tiangolo.com/)
29
+ [![Rich](https://img.shields.io/badge/UI-Rich-magenta)](https://rich.readthedocs.io/)
30
+ [![License](https://img.shields.io/badge/License-MIT-gray.svg)](#)
31
+ </div>
32
+
33
+ <p align="center">
34
+ A modern CLI tool that turns boring text logs or messy JSON lines into stunning, structured, and colorful terminal outputs—complete with a live dashboard, smart highlighting, and HTML exporting.
35
+ </p>
36
+
37
+ ---
38
+
39
+ ## Features that shine
40
+
41
+ * **Fast & Lightweight**: Tail files natively or stream huge data directly via pipes (`cat server.log | logscope`).
42
+ * **Colored & Structured Logs**: Automatically identifies `INFO`, `WARNING`, `ERROR`, `CRITICAL`, and `DEBUG`, applying beautiful typography.
43
+ * **Universal Parser**: Reads typical bracket logs (`[INFO]`) **and** parses modern NDJSON / JSON logs out of the box (e.g., Kubernetes, Docker).
44
+ * **Auto-Highlighting**: Magically highlights `IPs`, `URLs`, `Dates/Timestamps`, `UUIDs`, and `E-Mails` with dynamic colors.
45
+ * **Custom Keyword Highlighting**: Highlight specific keywords in log messages with `--highlight` and customize colors with `--highlight-color`.
46
+ * **Live Dashboard**: Watch logs stream in real-time alongside a live statistics panel keeping track of Error vs Info counts (`--dashboard`).
47
+ * **HTML Export**: Loved your console output so much you want to share it? Export the beautiful log structure directly to an HTML file to share with your team! (`--export-html results.html`)
48
+ * **Filtering**: Filter by one or more levels (`--level ERROR` or `--level ERROR,WARN,INFO`). Search by substring (`--search`) or regular expression (`--regex` / `-e`), with optional **case-sensitive** matching and **invert match** (`--invert-match` / `-v`, grep-style) to hide matching lines.
49
+ * **Themes**: Choose from 6 beautiful themes (`default`, `neon`, `ocean`, `forest`, `minimal`, `spectra`) or create custom themes via config file.
50
+ * **Plain output**: Use `--no-color` when you need unstyled text (e.g. piping to other tools or logs without ANSI codes).
51
+ * **Gzip logs**: Read `.gz` files directly—LogScope opens them as text without a manual `zcat` pipe.
52
+
53
+ ---
54
+
55
+ ## Installation
56
+
57
+ Ensure you have Python 3.9+ and pip installed.
58
+
59
+ ```bash
60
+ # Clone the repository
61
+ git clone https://github.com/vinnytherobot/logscope.git
62
+ cd logscope
63
+
64
+ # Install via Poetry
65
+ poetry install
66
+ poetry run logscope --help
67
+
68
+ # Or install globally via pip
69
+ pip install -e .
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Usage & Examples
75
+
76
+ ### Using a File
77
+ ```bash
78
+ # Basic colorized look
79
+ logscope /var/log/syslog
80
+
81
+ # Tailing a log in real-time (like tail -f)
82
+ logscope backend.log --follow
83
+
84
+ # Filter only errors
85
+ logscope production.log --level ERROR
86
+
87
+ # Multiple levels (comma-separated)
88
+ logscope production.log --level ERROR,WARN,INFO
89
+
90
+ # Search text dynamically
91
+ logscope server.log --search "Connection Timeout"
92
+
93
+ # Regex search (requires --search)
94
+ logscope server.log --search "timeout|refused|ECONNRESET" --regex
95
+
96
+ # Hide lines that match a pattern
97
+ logscope noisy.log --search "healthcheck" --invert-match
98
+
99
+ # Case-sensitive search
100
+ logscope app.log --search "UserID" --case-sensitive
101
+
102
+ # Highlight specific keywords
103
+ logscope server.log --highlight "timeout" --highlight-color "bold red"
104
+
105
+ # No colors (plain terminal output)
106
+ logscope app.log --no-color
107
+
108
+ # Compressed log file
109
+ logscope archive/app.log.gz
110
+ ```
111
+
112
+ ### Piping from other commands (Stdin support)
113
+ LogScope acts as a brilliant text reformatter for other tools!
114
+
115
+ ```bash
116
+ kubectl logs my-pod -f | logscope
117
+ docker logs api-gateway | logscope --level CRITICAL
118
+ cat nginx.log | grep -v GET | logscope --dashboard
119
+ ```
120
+
121
+ ### The Live Dashboard Mode
122
+ Monitor your logs like a pro with a live dashboard tracking error occurrences.
123
+
124
+ ```bash
125
+ logscope app.log --dashboard --follow
126
+ ```
127
+
128
+ ### Exporting to HTML
129
+ Need to attach the logs to a Jira ticket or Slack message but want to keep the formatting?
130
+
131
+ ```bash
132
+ logscope failed_job.log --export-html bug_report.html
133
+ ```
134
+
135
+ ### Themes
136
+ Choose from 6 beautiful themes: `default`, `neon`, `ocean`, `forest`, `minimal`, `spectra`.
137
+
138
+ ```bash
139
+ logscope app.log --theme neon
140
+ ```
141
+
142
+ Create a `.logscoperc` file to set your preferred theme:
143
+
144
+ ```json
145
+ {
146
+ "theme": "neon",
147
+ "custom_themes": {
148
+ "my-theme": {
149
+ "levels": {
150
+ "ERROR": ("✖", "bold red")
151
+ }
152
+ }
153
+ }
154
+ }
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Stack
160
+
161
+ * [**Rich**](https://github.com/Textualize/rich) -> UI Layouts, Colors, Highlighters, HTML Export.
162
+ * [**Typer**](https://github.com/tiangolo/typer) -> Modern, fast, and robust CLI creation.
163
+ * [**typing-extensions**](https://github.com/python/typing_extensions) -> Typed CLI annotations on Python 3.9.
164
+ * **Pathlib / Sys / gzip** -> File and standard input streaming; gzip text logs.
165
+
166
+ ## Contributing
167
+ Open an issue or submit a pull request! Tests are written using `pytest`.
168
+
169
+ ```bash
170
+ # Running tests
171
+ pytest tests/
172
+ ```
173
+
174
+ ## License
175
+ MIT License.
176
+
177
+ Made by [vinnytherobot](https://github.com/vinnytherobot)
@@ -0,0 +1,158 @@
1
+ <div align="center">
2
+ <img src="./assets/logscope-logo.png" alt="LogScope Logo" width="500" height="500">
3
+
4
+ # LogScope
5
+
6
+ **Beautiful, simple, and powerful log viewer for the terminal.**
7
+
8
+ [![Python](https://img.shields.io/badge/Python-3.9%2B-blue)](https://python.org)
9
+ [![Typer](https://img.shields.io/badge/CLI-Typer-green)](https://typer.tiangolo.com/)
10
+ [![Rich](https://img.shields.io/badge/UI-Rich-magenta)](https://rich.readthedocs.io/)
11
+ [![License](https://img.shields.io/badge/License-MIT-gray.svg)](#)
12
+ </div>
13
+
14
+ <p align="center">
15
+ A modern CLI tool that turns boring text logs or messy JSON lines into stunning, structured, and colorful terminal outputs—complete with a live dashboard, smart highlighting, and HTML exporting.
16
+ </p>
17
+
18
+ ---
19
+
20
+ ## Features that shine
21
+
22
+ * **Fast & Lightweight**: Tail files natively or stream huge data directly via pipes (`cat server.log | logscope`).
23
+ * **Colored & Structured Logs**: Automatically identifies `INFO`, `WARNING`, `ERROR`, `CRITICAL`, and `DEBUG`, applying beautiful typography.
24
+ * **Universal Parser**: Reads typical bracket logs (`[INFO]`) **and** parses modern NDJSON / JSON logs out of the box (e.g., Kubernetes, Docker).
25
+ * **Auto-Highlighting**: Magically highlights `IPs`, `URLs`, `Dates/Timestamps`, `UUIDs`, and `E-Mails` with dynamic colors.
26
+ * **Custom Keyword Highlighting**: Highlight specific keywords in log messages with `--highlight` and customize colors with `--highlight-color`.
27
+ * **Live Dashboard**: Watch logs stream in real-time alongside a live statistics panel keeping track of Error vs Info counts (`--dashboard`).
28
+ * **HTML Export**: Loved your console output so much you want to share it? Export the beautiful log structure directly to an HTML file to share with your team! (`--export-html results.html`)
29
+ * **Filtering**: Filter by one or more levels (`--level ERROR` or `--level ERROR,WARN,INFO`). Search by substring (`--search`) or regular expression (`--regex` / `-e`), with optional **case-sensitive** matching and **invert match** (`--invert-match` / `-v`, grep-style) to hide matching lines.
30
+ * **Themes**: Choose from 6 beautiful themes (`default`, `neon`, `ocean`, `forest`, `minimal`, `spectra`) or create custom themes via config file.
31
+ * **Plain output**: Use `--no-color` when you need unstyled text (e.g. piping to other tools or logs without ANSI codes).
32
+ * **Gzip logs**: Read `.gz` files directly—LogScope opens them as text without a manual `zcat` pipe.
33
+
34
+ ---
35
+
36
+ ## Installation
37
+
38
+ Ensure you have Python 3.9+ and pip installed.
39
+
40
+ ```bash
41
+ # Clone the repository
42
+ git clone https://github.com/vinnytherobot/logscope.git
43
+ cd logscope
44
+
45
+ # Install via Poetry
46
+ poetry install
47
+ poetry run logscope --help
48
+
49
+ # Or install globally via pip
50
+ pip install -e .
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Usage & Examples
56
+
57
+ ### Using a File
58
+ ```bash
59
+ # Basic colorized look
60
+ logscope /var/log/syslog
61
+
62
+ # Tailing a log in real-time (like tail -f)
63
+ logscope backend.log --follow
64
+
65
+ # Filter only errors
66
+ logscope production.log --level ERROR
67
+
68
+ # Multiple levels (comma-separated)
69
+ logscope production.log --level ERROR,WARN,INFO
70
+
71
+ # Search text dynamically
72
+ logscope server.log --search "Connection Timeout"
73
+
74
+ # Regex search (requires --search)
75
+ logscope server.log --search "timeout|refused|ECONNRESET" --regex
76
+
77
+ # Hide lines that match a pattern
78
+ logscope noisy.log --search "healthcheck" --invert-match
79
+
80
+ # Case-sensitive search
81
+ logscope app.log --search "UserID" --case-sensitive
82
+
83
+ # Highlight specific keywords
84
+ logscope server.log --highlight "timeout" --highlight-color "bold red"
85
+
86
+ # No colors (plain terminal output)
87
+ logscope app.log --no-color
88
+
89
+ # Compressed log file
90
+ logscope archive/app.log.gz
91
+ ```
92
+
93
+ ### Piping from other commands (Stdin support)
94
+ LogScope acts as a brilliant text reformatter for other tools!
95
+
96
+ ```bash
97
+ kubectl logs my-pod -f | logscope
98
+ docker logs api-gateway | logscope --level CRITICAL
99
+ cat nginx.log | grep -v GET | logscope --dashboard
100
+ ```
101
+
102
+ ### The Live Dashboard Mode
103
+ Monitor your logs like a pro with a live dashboard tracking error occurrences.
104
+
105
+ ```bash
106
+ logscope app.log --dashboard --follow
107
+ ```
108
+
109
+ ### Exporting to HTML
110
+ Need to attach the logs to a Jira ticket or Slack message but want to keep the formatting?
111
+
112
+ ```bash
113
+ logscope failed_job.log --export-html bug_report.html
114
+ ```
115
+
116
+ ### Themes
117
+ Choose from 6 beautiful themes: `default`, `neon`, `ocean`, `forest`, `minimal`, `spectra`.
118
+
119
+ ```bash
120
+ logscope app.log --theme neon
121
+ ```
122
+
123
+ Create a `.logscoperc` file to set your preferred theme:
124
+
125
+ ```json
126
+ {
127
+ "theme": "neon",
128
+ "custom_themes": {
129
+ "my-theme": {
130
+ "levels": {
131
+ "ERROR": ("✖", "bold red")
132
+ }
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Stack
141
+
142
+ * [**Rich**](https://github.com/Textualize/rich) -> UI Layouts, Colors, Highlighters, HTML Export.
143
+ * [**Typer**](https://github.com/tiangolo/typer) -> Modern, fast, and robust CLI creation.
144
+ * [**typing-extensions**](https://github.com/python/typing_extensions) -> Typed CLI annotations on Python 3.9.
145
+ * **Pathlib / Sys / gzip** -> File and standard input streaming; gzip text logs.
146
+
147
+ ## Contributing
148
+ Open an issue or submit a pull request! Tests are written using `pytest`.
149
+
150
+ ```bash
151
+ # Running tests
152
+ pytest tests/
153
+ ```
154
+
155
+ ## License
156
+ MIT License.
157
+
158
+ Made by [vinnytherobot](https://github.com/vinnytherobot)
@@ -0,0 +1,4 @@
1
+ """
2
+ LogScope — Beautiful log viewer for the terminal
3
+ """
4
+ __version__ = "0.4.1"
@@ -0,0 +1,187 @@
1
+ import sys
2
+ import gzip
3
+ import typer
4
+ import re
5
+ import json
6
+ from pathlib import Path
7
+ from datetime import datetime, timedelta
8
+ from typing import Optional
9
+ from typing_extensions import Annotated
10
+ from .viewer import stream_logs, run_dashboard, manager
11
+
12
+ app = typer.Typer(
13
+ help="LogScope — Beautiful log viewer for the terminal",
14
+ add_completion=False,
15
+ rich_markup_mode="rich"
16
+ )
17
+
18
+ def parse_relative_time(time_str: str) -> Optional[datetime]:
19
+ """Parse relative time like '10m', '1h', '2d' or ISO strings."""
20
+ if not time_str:
21
+ return None
22
+
23
+ # Check if absolute ISO format first
24
+ try:
25
+ return datetime.fromisoformat(time_str.replace('Z', '+00:00'))
26
+ except ValueError:
27
+ pass
28
+
29
+ # Regex for relative formats: <digit><unit>
30
+ match = re.match(r'^(\d+)([smhd])$', time_str.lower())
31
+ if not match:
32
+ return None
33
+
34
+ value, unit = int(match.group(1)), match.group(2)
35
+ now = datetime.now()
36
+
37
+ if unit == 's':
38
+ return now - timedelta(seconds=value)
39
+ if unit == 'm':
40
+ return now - timedelta(minutes=value)
41
+ if unit == 'h':
42
+ return now - timedelta(hours=value)
43
+ if unit == 'd':
44
+ return now - timedelta(days=value)
45
+
46
+ return None
47
+
48
+ def load_theme(requested_theme: Optional[str], no_color: bool = False):
49
+ """Load theme from config file or use requested/default. Persist if requested."""
50
+ config_file = Path(".logscoperc")
51
+ if not config_file.exists():
52
+ config_file = Path.home() / ".logscoperc"
53
+
54
+ config = {}
55
+ if config_file.exists():
56
+ try:
57
+ with open(config_file, "r", encoding="utf-8") as f:
58
+ config = json.load(f)
59
+ except Exception:
60
+ pass
61
+
62
+ # If requested via CLI, save it to the config for future use
63
+ if requested_theme:
64
+ config["theme"] = requested_theme
65
+ try:
66
+ # We prefer saving to local .logscoperc if it exists, otherwise home.
67
+ # If neither exists, we'll create one in the home directory for persistence.
68
+ save_path = Path(".logscoperc") if Path(".logscoperc").exists() else Path.home() / ".logscoperc"
69
+ with open(save_path, "w", encoding="utf-8") as f:
70
+ json.dump(config, f, indent=4)
71
+ manager.console.print(f"[bold green]✅ Theme '{requested_theme}' saved as your default preference![/bold green]")
72
+ except Exception as e:
73
+ manager.console.print(f"[dim red]⚠️ Failed to save theme preference: {e}[/dim red]")
74
+ else:
75
+ requested_theme = config.get("theme", "default")
76
+
77
+ if "custom_themes" in config:
78
+ # Merge custom themes into a copy of DEFAULT_THEMES to avoid mutating the module global
79
+ from .themes import DEFAULT_THEMES
80
+ themes_copy = dict(DEFAULT_THEMES)
81
+ themes_copy.update(config["custom_themes"])
82
+ manager.apply_theme(requested_theme, custom_themes=themes_copy, no_color=no_color)
83
+ else:
84
+ manager.apply_theme(requested_theme, no_color=no_color)
85
+
86
+ @app.command()
87
+ def main(
88
+ log_file: Annotated[Optional[Path], typer.Argument(help="Path to the log file (leave empty to read from STDIN via pipe)")] = None,
89
+ follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow log output in real-time (like tail -f)")] = False,
90
+ level: Annotated[Optional[str], typer.Option("--level", "-l", help="Filter by level; comma-separated for multiple (e.g. ERROR,WARN,INFO)")] = None,
91
+ min_level: Annotated[Optional[str], typer.Option("--min-level", "-m", help="Show logs at or above this level threshold (e.g. WARN shows WARN, ERROR, CRITICAL, ALERT, FATAL)")] = None,
92
+ search: Annotated[Optional[str], typer.Option("--search", "-s", help="Search string to filter logs (substring unless --regex)")] = None,
93
+ dashboard: Annotated[bool, typer.Option("--dashboard", "-d", help="Open visual dashboard showing log statistics")] = False,
94
+ export_html: Annotated[Optional[Path], typer.Option("--export-html", help="Export the beautiful log output to an HTML file")] = None,
95
+ line_numbers: Annotated[bool, typer.Option("--line-numbers", "-n", help="Show line numbers for each log message")] = False,
96
+ since: Annotated[Optional[str], typer.Option("--since", help="Show logs since a point in time (e.g. '1h', '30m', '2026-01-01T00:00:00')")] = None,
97
+ until: Annotated[Optional[str], typer.Option("--until", help="Show logs until a point in time")] = None,
98
+ theme: Annotated[Optional[str], typer.Option("--theme", "-t", help="Choose a theme for colors and emojis (default, neon, ocean, forest, minimal)")] = None,
99
+ use_regex: Annotated[bool, typer.Option("--regex", "-e", help="Treat --search as a regular expression")] = False,
100
+ case_sensitive: Annotated[bool, typer.Option("--case-sensitive", help="Case-sensitive substring or regex search")] = False,
101
+ invert_match: Annotated[bool, typer.Option("--invert-match", "-v", help="Hide lines that match --search (grep -v)")] = False,
102
+ no_color: Annotated[bool, typer.Option("--no-color", help="Disable colors and terminal highlighting")] = False,
103
+ highlight: Annotated[Optional[str], typer.Option("--highlight", "-H", help="Highlight specific keyword in log messages (can be used multiple times)")] = None,
104
+ highlight_color: Annotated[str, typer.Option("--highlight-color", help="Rich style for highlighted keywords (default: bold magenta)")] = "bold magenta",
105
+ ):
106
+ """
107
+ [blue]LogScope[/blue] parses standard logs and makes them [bold]beautiful[/bold] and [bold]readable[/bold].
108
+ """
109
+ if use_regex and not search:
110
+ typer.echo("❌ Error: --regex requires --search.", err=True)
111
+ raise typer.Exit(1)
112
+ if invert_match and not search:
113
+ typer.echo("❌ Error: --invert-match requires --search.", err=True)
114
+ raise typer.Exit(1)
115
+
116
+ search_pattern = None
117
+ if search and use_regex:
118
+ try:
119
+ flags = 0 if case_sensitive else re.IGNORECASE
120
+ search_pattern = re.compile(search, flags)
121
+ except re.error as exc:
122
+ typer.echo(f"❌ Error: invalid regular expression: {exc}", err=True)
123
+ raise typer.Exit(1)
124
+
125
+ if log_file is None:
126
+ if sys.stdin.isatty():
127
+ typer.echo("❌ Error: Please provide a log file path or pipe data to STDIN (cat file | logscope).", err=True)
128
+ raise typer.Exit(1)
129
+ file_obj = sys.stdin
130
+ else:
131
+ if log_file.suffix.lower() == ".gz":
132
+ file_obj = gzip.open(log_file, "rt", encoding="utf-8", errors="replace")
133
+ else:
134
+ file_obj = open(log_file, "r", encoding="utf-8", errors="replace")
135
+
136
+ since_dt = parse_relative_time(since) if since else None
137
+ until_dt = parse_relative_time(until) if until else None
138
+
139
+ load_theme(theme, no_color=no_color)
140
+
141
+ # Inform user about themes only if they are using default and haven't hidden the tip by having a config
142
+ has_config = Path(".logscoperc").exists() or (Path.home() / ".logscoperc").exists()
143
+ if not theme and not has_config:
144
+ manager.console.print("[dim]💡 Tip: Use '--theme' or create a '.logscoperc' file to change colors. Themes: neon, ocean, forest, minimal[/dim]\n")
145
+
146
+ try:
147
+ if dashboard:
148
+ run_dashboard(
149
+ file_obj,
150
+ follow=follow,
151
+ level_filter=level,
152
+ search_filter=search,
153
+ show_line_numbers=line_numbers,
154
+ since=since_dt,
155
+ until=until_dt,
156
+ use_regex=use_regex,
157
+ search_pattern=search_pattern,
158
+ case_sensitive=case_sensitive,
159
+ invert_match=invert_match,
160
+ highlight=highlight,
161
+ highlight_color=highlight_color,
162
+ min_level=min_level,
163
+ )
164
+ else:
165
+ stream_logs(
166
+ file_obj,
167
+ follow=follow,
168
+ level=level,
169
+ search=search,
170
+ export_html=export_html,
171
+ show_line_numbers=line_numbers,
172
+ since=since_dt,
173
+ until=until_dt,
174
+ use_regex=use_regex,
175
+ search_pattern=search_pattern,
176
+ case_sensitive=case_sensitive,
177
+ invert_match=invert_match,
178
+ highlight=highlight,
179
+ highlight_color=highlight_color,
180
+ min_level=min_level,
181
+ )
182
+ finally:
183
+ if log_file is not None:
184
+ file_obj.close()
185
+
186
+ if __name__ == "__main__":
187
+ app()
@@ -0,0 +1,188 @@
1
+ import re
2
+ import json
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Optional, Tuple
6
+
7
+ # Compiled regex patterns for performance
8
+ _BRACKET_LEVEL_PATTERN = re.compile(
9
+ r'\[(TRACE|DEBUG|INFO|NOTICE|WARN|WARNING|ERROR|ERR|CRITICAL|ALERT|FATAL|EMERGENCY)\]',
10
+ re.IGNORECASE
11
+ )
12
+ _BRACKETLESS_LEVEL_PATTERN = re.compile(
13
+ r'\b(TRACE|DEBUG|INFO|NOTICE|WARN|WARNING|ERROR|ERR|CRITICAL|ALERT|FATAL|EMERGENCY)\b',
14
+ re.IGNORECASE
15
+ )
16
+
17
+ # Multiple timestamp patterns for different log formats
18
+ _TIMESTAMP_PATTERNS = [
19
+ # ISO 8601: 2026-03-21T10:00:00Z or 2026-03-21T10:00:00.123Z or 2026-03-21T10:00:00+00:00
20
+ re.compile(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)'),
21
+ # ISO-like with space: 2026-03-21 10:00:00 or 2026-03-21 10:00:00.123
22
+ re.compile(r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)'),
23
+ # Common Log Format / Apache: 21/Mar/2026:10:00:00 +0000 or [21/Mar/2026:10:00:00 +0000]
24
+ re.compile(r'(\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}(?:\s+[+-]\d{4})?)'),
25
+ # Syslog-style: Mar 21 10:00:00 (year is assumed current year)
26
+ re.compile(r'([A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})'),
27
+ # Unix timestamp: 1711054800 (10 digits for seconds)
28
+ re.compile(r'\b(\d{10})\b'),
29
+ ]
30
+
31
+ # Month name mapping for parsing
32
+ _MONTH_MAP = {
33
+ 'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
34
+ 'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12
35
+ }
36
+
37
+ @dataclass
38
+ class LogEntry:
39
+ level: str
40
+ message: str
41
+ raw: str
42
+ timestamp: Optional[datetime] = None
43
+ service: Optional[str] = None
44
+ trace_id: Optional[str] = None
45
+ span_id: Optional[str] = None
46
+
47
+
48
+ # Level normalization constants
49
+ _NORMALIZE_LEVEL_MAP = {
50
+ "WARNING": "WARN",
51
+ "EMERGENCY": "FATAL",
52
+ "ERR": "ERROR",
53
+ }
54
+
55
+
56
+ def _normalize_level(level: str) -> str:
57
+ """Normalize log level aliases to canonical forms."""
58
+ return _NORMALIZE_LEVEL_MAP.get(level.upper(), level.upper())
59
+
60
+
61
+ def _extract_json_observability(data: dict) -> Tuple[Optional[str], Optional[str], Optional[str]]:
62
+ """Pull service / trace / span from common JSON log shapes (K8s, OTel, Docker)."""
63
+ k8s = data.get("kubernetes")
64
+ k8s_d: dict = k8s if isinstance(k8s, dict) else {}
65
+ pod_name = k8s_d.get("pod_name")
66
+ if not pod_name and isinstance(k8s_d.get("pod"), dict):
67
+ pod_name = k8s_d["pod"].get("name")
68
+
69
+ service = (
70
+ data.get("service")
71
+ or data.get("service.name")
72
+ or data.get("service_name")
73
+ or pod_name
74
+ or k8s_d.get("container_name")
75
+ or data.get("container")
76
+ or data.get("container.name")
77
+ or data.get("logger")
78
+ or data.get("logger.name")
79
+ )
80
+ if service is not None:
81
+ service = str(service)
82
+
83
+ trace_id = data.get("trace_id") or data.get("traceId") or data.get("trace.id")
84
+ if not trace_id and isinstance(data.get("trace"), dict):
85
+ trace_id = data["trace"].get("id")
86
+ if not trace_id and isinstance(data.get("otelTraceID"), str):
87
+ trace_id = data["otelTraceID"]
88
+ if trace_id is not None:
89
+ trace_id = str(trace_id)
90
+
91
+ span_id = data.get("span_id") or data.get("spanId") or data.get("span.id")
92
+ if span_id is not None:
93
+ span_id = str(span_id)
94
+
95
+ return service, trace_id, span_id
96
+
97
+ def parse_line(line: str) -> LogEntry:
98
+ """Parse a single line of log and extract severity level."""
99
+ line = line.strip()
100
+
101
+ # 1. Check if JSON log object (common in docker/kubernetes/modern APIs)
102
+ if line.startswith('{') and line.endswith('}'):
103
+ try:
104
+ data = json.loads(line)
105
+ # Find level key
106
+ level = _normalize_level(data.get('level', data.get('severity', data.get('log.level', 'UNKNOWN'))))
107
+ # Find message key
108
+ message = str(data.get('message', data.get('msg', data.get('text', line))))
109
+
110
+ # Find timestamp
111
+ timestamp_str = data.get('timestamp', data.get('time', data.get('@timestamp')))
112
+ timestamp = None
113
+ if timestamp_str:
114
+ try:
115
+ # Basic ISO parsing
116
+ timestamp = datetime.fromisoformat(str(timestamp_str).replace('Z', '+00:00'))
117
+ except ValueError:
118
+ pass
119
+
120
+ svc, tid, sid = _extract_json_observability(data)
121
+ return LogEntry(
122
+ level=level,
123
+ message=message,
124
+ raw=line,
125
+ timestamp=timestamp,
126
+ service=svc,
127
+ trace_id=tid,
128
+ span_id=sid,
129
+ )
130
+ except json.JSONDecodeError:
131
+ pass
132
+
133
+ # 2. Try typical log formats like [INFO], (WARN), ERROR:
134
+ match = _BRACKET_LEVEL_PATTERN.search(line)
135
+ if not match:
136
+ # Try finding without brackets as a fallback, e.g. "INFO:" or "INFO - "
137
+ match = _BRACKETLESS_LEVEL_PATTERN.search(line)
138
+
139
+ if match:
140
+ level = _normalize_level(match.group(1))
141
+
142
+ # Remove the [LEVEL] part from the message for cleaner display
143
+ message = line.replace(match.group(0), '', 1).strip()
144
+
145
+ # Clean up common separators left behind like ": " or "- "
146
+ if message.startswith(':') or message.startswith('-'):
147
+ message = message[1:].strip()
148
+
149
+ return LogEntry(level=level, message=message, raw=line, timestamp=extract_timestamp(line))
150
+
151
+ # fallback
152
+ return LogEntry(level="UNKNOWN", message=line, raw=line, timestamp=extract_timestamp(line))
153
+
154
+ def extract_timestamp(text: str) -> Optional[datetime]:
155
+ """Extract a timestamp from a raw string using multiple format patterns."""
156
+ for pattern in _TIMESTAMP_PATTERNS:
157
+ match = pattern.search(text)
158
+ if match:
159
+ ts_str = match.group(1)
160
+ try:
161
+ # Try ISO format first (handles most cases)
162
+ if '-' in ts_str and ('T' in ts_str or ts_str[10:11] == ' '):
163
+ # Handle ISO-like with space instead of T
164
+ return datetime.fromisoformat(ts_str.replace('Z', '+00:00').replace(' ', 'T'))
165
+ # Handle Common Log Format: 21/Mar/2026:10:00:00 +0000
166
+ elif '/' in ts_str:
167
+ parts = ts_str.split()
168
+ main_part = parts[0]
169
+ # Parse: DD/Mon/YYYY:HH:MM:SS
170
+ match_parts = re.match(r'(\d{2})/([A-Za-z]{3})/(\d{4}):(\d{2}):(\d{2}):(\d{2})', main_part)
171
+ if match_parts:
172
+ day, month_str, year, hour, minute, second = match_parts.groups()
173
+ month = _MONTH_MAP.get(month_str, 1)
174
+ return datetime(int(year), month, int(day), int(hour), int(minute), int(second))
175
+ # Handle Syslog-style: Mar 21 10:00:00
176
+ elif ts_str[0].isalpha():
177
+ match_parts = re.match(r'([A-Za-z]{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})', ts_str)
178
+ if match_parts:
179
+ month_str, day, hour, minute, second = match_parts.groups()
180
+ month = _MONTH_MAP.get(month_str, 1)
181
+ year = datetime.now().year # Assume current year
182
+ return datetime(year, month, int(day), int(hour), int(minute), int(second))
183
+ # Handle Unix timestamp
184
+ elif ts_str.isdigit():
185
+ return datetime.fromtimestamp(int(ts_str))
186
+ except (ValueError, OSError):
187
+ continue
188
+ return None
@@ -0,0 +1,160 @@
1
+ # Default themes and mappings for LogScope
2
+
3
+ DEFAULT_THEMES = {
4
+ "default": {
5
+ "levels": {
6
+ "TRACE": ("🔍", "dim white"),
7
+ "DEBUG": ("🐛", "bold blue"),
8
+ "INFO": ("🔵", "bold green"),
9
+ "NOTICE": ("🔔", "bold cyan"),
10
+ "WARN": ("🟡", "bold yellow"),
11
+ "ERROR": ("🔴", "bold red"),
12
+ "CRITICAL": ("💥", "bold magenta"),
13
+ "ALERT": ("🚨", "bold color(208)"),
14
+ "FATAL": ("💀", "bold dark_red"),
15
+ "UNKNOWN": ("⚪", "dim white")
16
+ },
17
+ "highlights": {
18
+ "logscope.ip": "bold green",
19
+ "logscope.url": "underline blue",
20
+ "logscope.timestamp": "cyan",
21
+ "logscope.uuid": "bold magenta",
22
+ "logscope.email": "underline yellow",
23
+ "logscope.path": "dim blue",
24
+ "logscope.status_ok": "bold green",
25
+ "logscope.status_warn": "bold yellow",
26
+ "logscope.status_err": "bold red",
27
+ "logscope.method": "bold cyan"
28
+ }
29
+ },
30
+ "neon": {
31
+ "levels": {
32
+ "TRACE": ("·", "magenta"),
33
+ "DEBUG": ("⚡", "bold magenta"),
34
+ "INFO": ("✨", "bold pink1"),
35
+ "NOTICE": ("🌌", "bold purple"),
36
+ "WARN": ("🔥", "bold yellow1"),
37
+ "ERROR": ("🧨", "bold red1"),
38
+ "CRITICAL": ("⬢", "bold magenta"),
39
+ "ALERT": ("📣", "bold orange1"),
40
+ "FATAL": ("☣️", "bold dark_red"),
41
+ "UNKNOWN": ("?", "dim white")
42
+ },
43
+ "highlights": {
44
+ "logscope.ip": "bold pink1",
45
+ "logscope.url": "italic underline magenta",
46
+ "logscope.timestamp": "bold purple",
47
+ "logscope.uuid": "bold yellow",
48
+ "logscope.email": "bold pink1",
49
+ "logscope.path": "italic magenta",
50
+ "logscope.status_ok": "bold pink1",
51
+ "logscope.status_warn": "bold yellow",
52
+ "logscope.status_err": "bold red",
53
+ "logscope.method": "bold cyan"
54
+ }
55
+ },
56
+ "ocean": {
57
+ "levels": {
58
+ "TRACE": ("⚓", "dim cyan"),
59
+ "DEBUG": ("🌊", "blue"),
60
+ "INFO": ("💧", "bold blue"),
61
+ "NOTICE": ("🐳", "cyan"),
62
+ "WARN": ("🐚", "yellow"),
63
+ "ERROR": ("🌋", "red"),
64
+ "CRITICAL": ("⛈️ ", "bold blue"),
65
+ "ALERT": ("🚩", "bold orange1"),
66
+ "FATAL": ("🌪️ ", "bold red"),
67
+ "UNKNOWN": ("?", "dim cyan")
68
+ },
69
+ "highlights": {
70
+ "logscope.ip": "bold cyan",
71
+ "logscope.url": "underline deep_sky_blue1",
72
+ "logscope.timestamp": "blue",
73
+ "logscope.uuid": "bold deep_sky_blue3",
74
+ "logscope.email": "underline cyan",
75
+ "logscope.path": "dim cyan",
76
+ "logscope.status_ok": "bold green",
77
+ "logscope.status_warn": "bold yellow",
78
+ "logscope.status_err": "bold red",
79
+ "logscope.method": "bold cyan"
80
+ }
81
+ },
82
+ "forest": {
83
+ "levels": {
84
+ "TRACE": ("🍃", "dim green"),
85
+ "DEBUG": ("🌿", "green"),
86
+ "INFO": ("🌳", "bold green"),
87
+ "NOTICE": ("🍄", "magenta"),
88
+ "WARN": ("🍂", "yellow"),
89
+ "ERROR": ("🪵", "red"),
90
+ "CRITICAL": ("🌲", "bold white"),
91
+ "ALERT": ("🐺", "bold orange1"),
92
+ "FATAL": ("💀", "bold dark_red"),
93
+ "UNKNOWN": ("?", "dim green")
94
+ },
95
+ "highlights": {
96
+ "logscope.ip": "bold green3",
97
+ "logscope.url": "underline dark_green",
98
+ "logscope.timestamp": "yellow4",
99
+ "logscope.uuid": "bold orange4",
100
+ "logscope.email": "underline green",
101
+ "logscope.path": "dim green3",
102
+ "logscope.status_ok": "bold green",
103
+ "logscope.status_warn": "bold yellow",
104
+ "logscope.status_err": "bold red",
105
+ "logscope.method": "bold green1"
106
+ }
107
+ },
108
+ "minimal": {
109
+ "levels": {
110
+ "TRACE": (" ", "dim"),
111
+ "DEBUG": (" ", "dim"),
112
+ "INFO": (" ", "white"),
113
+ "NOTICE": ("!", "cyan"),
114
+ "WARN": ("?", "yellow"),
115
+ "ERROR": ("#", "red"),
116
+ "CRITICAL": ("*", "bold red"),
117
+ "ALERT": ("!", "bold yellow"),
118
+ "FATAL": ("X", "bold red"),
119
+ "UNKNOWN": ("?", "dim")
120
+ },
121
+ "highlights": {
122
+ "logscope.ip": "bold",
123
+ "logscope.url": "underline",
124
+ "logscope.timestamp": "dim",
125
+ "logscope.uuid": "dim",
126
+ "logscope.email": "underline",
127
+ "logscope.path": "italic",
128
+ "logscope.status_ok": "green",
129
+ "logscope.status_warn": "yellow",
130
+ "logscope.status_err": "red",
131
+ "logscope.method": "bold"
132
+ }
133
+ },
134
+ "spectra": {
135
+ "levels": {
136
+ "TRACE": ("◦", "dim cyan"),
137
+ "DEBUG": ("◇", "cyan"),
138
+ "INFO": ("◆", "bold bright_cyan"),
139
+ "NOTICE": ("▹", "bold turquoise2"),
140
+ "WARN": ("⚠", "bold bright_yellow"),
141
+ "ERROR": ("✖", "bold bright_red"),
142
+ "CRITICAL": ("⬢", "bold magenta"),
143
+ "ALERT": ("⚡", "bold color(214)"),
144
+ "FATAL": ("☠", "bold red3"),
145
+ "UNKNOWN": ("·", "dim white")
146
+ },
147
+ "highlights": {
148
+ "logscope.ip": "bold bright_cyan",
149
+ "logscope.url": "underline bright_magenta",
150
+ "logscope.timestamp": "bold cyan",
151
+ "logscope.uuid": "bold bright_white",
152
+ "logscope.email": "underline turquoise2",
153
+ "logscope.path": "italic cyan",
154
+ "logscope.status_ok": "bold spring_green1",
155
+ "logscope.status_warn": "bold gold1",
156
+ "logscope.status_err": "bold red1",
157
+ "logscope.method": "bold deep_sky_blue1"
158
+ }
159
+ },
160
+ }
@@ -0,0 +1,401 @@
1
+ import sys
2
+ import time
3
+ from pathlib import Path
4
+ from datetime import datetime
5
+ from typing import Optional, List, TextIO, Set, Pattern
6
+
7
+ from rich.console import Console, Group
8
+ from rich.live import Live
9
+ from rich.layout import Layout
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+ from rich.highlighter import RegexHighlighter
14
+ from rich.theme import Theme
15
+
16
+ from .parser import parse_line, LogEntry, _normalize_level
17
+ from .themes import DEFAULT_THEMES
18
+
19
+ # Level severity order (lowest to highest)
20
+ # Used for --min-level threshold filtering
21
+ LEVEL_ORDER = {
22
+ "TRACE": 0,
23
+ "DEBUG": 1,
24
+ "INFO": 2,
25
+ "NOTICE": 3,
26
+ "WARN": 4,
27
+ "ERROR": 5,
28
+ "CRITICAL": 6,
29
+ "ALERT": 7,
30
+ "FATAL": 8,
31
+ "UNKNOWN": 0, # Treat as lowest
32
+ }
33
+
34
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
35
+ sys.stdout.reconfigure(encoding="utf-8")
36
+
37
+ class LogScopeHighlighter(RegexHighlighter):
38
+ """Apply style to anything that looks like an IP address, URL, or timestamp."""
39
+ base_style = "logscope."
40
+ highlights = [
41
+ r"(?P<ip>\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b)",
42
+ r"(?P<url>https?://[a-zA-Z0-9./?=#_%:-]+)",
43
+ r"(?P<timestamp>\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)",
44
+ r"(?P<uuid>\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b)",
45
+ r"(?P<email>\b[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+\b)",
46
+ r"(?P<path>(?:[a-zA-Z]:|\/)[a-zA-Z0-9._\-\/\\ ]+)",
47
+ r"(?P<status_ok>\b(200|201|204)\b)",
48
+ r"(?P<status_warn>\b(301|302|400|401|403|404)\b)",
49
+ r"(?P<status_err>\b(500|502|503|504)\b)",
50
+ r"(?P<method>\b(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\b)",
51
+ ]
52
+
53
+
54
+ class LogScopeManager:
55
+ """Manages the console state and current theme."""
56
+ def __init__(self, theme_name: str = "default"):
57
+ self._no_color = False
58
+ self.apply_theme(theme_name)
59
+
60
+ def apply_theme(self, theme_name_or_dict, no_color: bool = False, custom_themes: Optional[dict] = None):
61
+ self._no_color = no_color
62
+ themes = custom_themes if custom_themes is not None else DEFAULT_THEMES
63
+ if isinstance(theme_name_or_dict, str):
64
+ theme_config = themes.get(theme_name_or_dict, themes["default"])
65
+ else:
66
+ theme_config = theme_name_or_dict
67
+
68
+ self.level_mapping = theme_config["levels"]
69
+ self.rich_theme = Theme(theme_config["highlights"])
70
+ self.console = Console(
71
+ theme=self.rich_theme,
72
+ highlighter=LogScopeHighlighter(),
73
+ no_color=no_color,
74
+ )
75
+
76
+ def format_log(self, entry: LogEntry, line_number: Optional[int] = None, highlight: Optional[str] = None, highlight_color: str = "bold magenta", case_sensitive: bool = False) -> Text:
77
+ """Format a log entry with current theme's colors and emojis."""
78
+ icon, style = self.level_mapping.get(entry.level, self.level_mapping.get("UNKNOWN", ("⚪", "dim white")))
79
+
80
+ text = Text()
81
+ if line_number is not None:
82
+ text.append(f"{line_number:>4} │ ", style="dim")
83
+
84
+ text.append(f"{icon} {entry.level:<7} ", style=style)
85
+
86
+ # Apply custom highlight to message if keyword is specified
87
+ if highlight and highlight.strip():
88
+ message = entry.message
89
+ keyword = highlight.strip()
90
+ if self._no_color:
91
+ text.append(message)
92
+ elif case_sensitive:
93
+ # Case-sensitive: simple split
94
+ parts = message.split(keyword)
95
+ if len(parts) > 1:
96
+ for i, part in enumerate(parts):
97
+ text.append(part)
98
+ if i < len(parts) - 1:
99
+ text.append(keyword, style=highlight_color)
100
+ else:
101
+ text.append(message)
102
+ else:
103
+ # Case-insensitive: use regex to find matches and preserve original case
104
+ import re
105
+ pattern = re.compile(re.escape(keyword), re.IGNORECASE)
106
+ last_end = 0
107
+ for match in pattern.finditer(message):
108
+ text.append(message[last_end:match.start()])
109
+ text.append(match.group(), style=highlight_color)
110
+ last_end = match.end()
111
+ text.append(message[last_end:])
112
+ else:
113
+ text.append(entry.message)
114
+
115
+ return text
116
+
117
+ # Global manager instance
118
+ manager = LogScopeManager()
119
+
120
+
121
+ def parse_level_filter(level: Optional[str]) -> Optional[Set[str]]:
122
+ if not level or not level.strip():
123
+ return None
124
+ parts = {_normalize_level(p.strip()) for p in level.split(",") if p.strip()}
125
+ return parts or None
126
+
127
+
128
+ def line_passes_level(entry_level: str, allowed: Optional[Set[str]]) -> bool:
129
+ if not allowed:
130
+ return True
131
+ return entry_level in allowed
132
+
133
+
134
+ def line_passes_min_level(entry_level: str, min_level: Optional[str]) -> bool:
135
+ """Check if entry level meets minimum severity threshold."""
136
+ if not min_level:
137
+ return True
138
+ entry_severity = LEVEL_ORDER.get(entry_level, 0)
139
+ min_severity = LEVEL_ORDER.get(_normalize_level(min_level), 0)
140
+ return entry_severity >= min_severity
141
+
142
+
143
+ def line_passes_search(
144
+ line: str,
145
+ search: Optional[str],
146
+ *,
147
+ pattern: Optional[Pattern[str]],
148
+ use_regex: bool,
149
+ case_sensitive: bool,
150
+ invert_match: bool,
151
+ ) -> bool:
152
+ if not search:
153
+ return True
154
+ if use_regex and pattern is not None:
155
+ matched = pattern.search(line) is not None
156
+ elif case_sensitive:
157
+ matched = search in line
158
+ else:
159
+ matched = search.lower() in line.lower()
160
+ if invert_match:
161
+ return not matched
162
+ return matched
163
+
164
+
165
+ def line_passes_filters(
166
+ entry: LogEntry,
167
+ level_set: Optional[Set[str]],
168
+ search: Optional[str],
169
+ since: Optional[datetime],
170
+ until: Optional[datetime],
171
+ *,
172
+ pattern: Optional[Pattern[str]],
173
+ use_regex: bool,
174
+ case_sensitive: bool,
175
+ invert_match: bool,
176
+ min_level: Optional[str] = None,
177
+ ) -> bool:
178
+ """Check if an entry passes all filters (level, min_level, search, timestamp)."""
179
+ if not line_passes_level(entry.level, level_set):
180
+ return False
181
+ if not line_passes_min_level(entry.level, min_level):
182
+ return False
183
+ if not line_passes_search(
184
+ entry.raw,
185
+ search,
186
+ pattern=pattern,
187
+ use_regex=use_regex,
188
+ case_sensitive=case_sensitive,
189
+ invert_match=invert_match,
190
+ ):
191
+ return False
192
+ if entry.timestamp:
193
+ if since and entry.timestamp.replace(tzinfo=None) < since.replace(tzinfo=None):
194
+ return False
195
+ if until and entry.timestamp.replace(tzinfo=None) > until.replace(tzinfo=None):
196
+ return False
197
+ return True
198
+
199
+
200
+ def get_lines(file: TextIO, follow: bool):
201
+ """Generator that yields (line_number, line) tuples from a file, optionally tailing it."""
202
+ line_number = 0
203
+ # yield existing lines
204
+ for line in file:
205
+ line_number += 1
206
+ if line.strip():
207
+ yield line_number, line
208
+
209
+ if not follow:
210
+ return
211
+
212
+ # tailing
213
+ manager.console.print("[dim]-- 🔭 Tailing new logs... (Press Ctrl+C to exit) --[/dim]")
214
+ try:
215
+ while True:
216
+ line = file.readline()
217
+ if not line:
218
+ time.sleep(0.1)
219
+ continue
220
+ line_number += 1
221
+ if line.strip():
222
+ yield line_number, line
223
+ except KeyboardInterrupt:
224
+ return
225
+
226
+
227
+ def stream_logs(
228
+ file: TextIO,
229
+ follow: bool,
230
+ level: Optional[str] = None,
231
+ search: Optional[str] = None,
232
+ export_html: Optional[Path] = None,
233
+ show_line_numbers: bool = False,
234
+ since: Optional[datetime] = None,
235
+ until: Optional[datetime] = None,
236
+ *,
237
+ use_regex: bool = False,
238
+ search_pattern: Optional[Pattern[str]] = None,
239
+ case_sensitive: bool = False,
240
+ invert_match: bool = False,
241
+ highlight: Optional[str] = None,
242
+ highlight_color: str = "bold magenta",
243
+ min_level: Optional[str] = None,
244
+ ):
245
+ """Basic console mode: prints directly to stdout, supporting tails."""
246
+ if export_html:
247
+ manager.console.record = True
248
+
249
+ level_set = parse_level_filter(level)
250
+
251
+ line_count = 0
252
+ try:
253
+ for line_number, line in get_lines(file, follow):
254
+ line_count = line_number
255
+ entry = parse_line(line)
256
+
257
+ if not line_passes_filters(
258
+ entry,
259
+ level_set,
260
+ search,
261
+ since,
262
+ until,
263
+ pattern=search_pattern,
264
+ use_regex=use_regex,
265
+ case_sensitive=case_sensitive,
266
+ invert_match=invert_match,
267
+ min_level=min_level,
268
+ ):
269
+ continue
270
+
271
+ formatted = manager.format_log(
272
+ entry,
273
+ line_number=line_count if show_line_numbers else None,
274
+ highlight=highlight,
275
+ highlight_color=highlight_color,
276
+ case_sensitive=case_sensitive,
277
+ )
278
+ manager.console.print(formatted)
279
+ finally:
280
+ if export_html:
281
+ manager.console.save_html(str(export_html), clear=False)
282
+ manager.console.print(f"\n[bold green]✅ Logs exported successfully to {export_html}[/bold green]")
283
+
284
+
285
+ def run_dashboard(
286
+ file: TextIO,
287
+ follow: bool,
288
+ level_filter: Optional[str] = None,
289
+ search_filter: Optional[str] = None,
290
+ show_line_numbers: bool = False,
291
+ since: Optional[datetime] = None,
292
+ until: Optional[datetime] = None,
293
+ *,
294
+ use_regex: bool = False,
295
+ search_pattern: Optional[Pattern[str]] = None,
296
+ case_sensitive: bool = False,
297
+ invert_match: bool = False,
298
+ highlight: Optional[str] = None,
299
+ highlight_color: str = "bold magenta",
300
+ min_level: Optional[str] = None,
301
+ ):
302
+ """Dashboard mode: Shows a summary stats panel and recent logs layout."""
303
+
304
+ level_set = parse_level_filter(level_filter)
305
+
306
+ stats = {
307
+ "FATAL": 0,
308
+ "ALERT": 0,
309
+ "CRITICAL": 0,
310
+ "ERROR": 0,
311
+ "WARN": 0,
312
+ "NOTICE": 0,
313
+ "INFO": 0,
314
+ "DEBUG": 0,
315
+ "TRACE": 0,
316
+ "UNKNOWN": 0
317
+ }
318
+
319
+ total_processed = 0
320
+ recent_logs: List[Text] = []
321
+ MAX_LOGS = 25 # Number of lines to keep in the scrolling window
322
+
323
+ def generate_layout() -> Layout:
324
+ layout = Layout()
325
+ layout.split_column(
326
+ Layout(name="header", size=5),
327
+ Layout(name="body")
328
+ )
329
+
330
+ # Stats table
331
+ table = Table(show_header=False, expand=True, border_style="dim", box=None)
332
+ table.add_column("C1", justify="center")
333
+ table.add_column("C2", justify="center")
334
+ table.add_column("C3", justify="center")
335
+ table.add_column("C4", justify="center")
336
+
337
+ table.add_row(
338
+ f"[bold dark_red]💀 Fatal: {stats.get('FATAL', 0)}[/bold dark_red]",
339
+ f"[bold magenta]💥 Critical: {stats.get('CRITICAL', 0)}[/bold magenta]",
340
+ f"[bold red]🔴 Errors: {stats.get('ERROR', 0)}[/bold red]",
341
+ f"[bold yellow]🟡 Warns: {stats.get('WARN', 0)}[/bold yellow]"
342
+ )
343
+ table.add_row(
344
+ f"[bold green]🔵 Info: {stats.get('INFO', 0)}[/bold green]",
345
+ f"[bold blue]🐛 Debug: {stats.get('DEBUG', 0)}[/bold blue]",
346
+ f"[dim white]🔍 Trace: {stats.get('TRACE', 0)}[/dim white]",
347
+ f"[dim white]⚪ Unknown: {stats.get('UNKNOWN', 0)}[/dim white]"
348
+ )
349
+
350
+ layout["header"].update(Panel(table, title=f"[bold]✨ LogScope Live Dashboard — Total: {total_processed}[/bold]", border_style="cyan"))
351
+
352
+ # Logs
353
+ log_group = Group(*recent_logs)
354
+ title = "Recent Logs (Auto-highlight enabled)"
355
+ if follow:
356
+ title += " - [blink green]● LIVE[/blink green]"
357
+
358
+ layout["body"].update(Panel(log_group, title=title))
359
+
360
+ return layout
361
+
362
+ manager.console.clear()
363
+
364
+ try:
365
+ with Live(generate_layout(), console=manager.console, refresh_per_second=10) as live:
366
+ for line_number, line in get_lines(file, follow):
367
+ total_processed = line_number
368
+ entry = parse_line(line)
369
+
370
+ if not line_passes_filters(
371
+ entry,
372
+ level_set,
373
+ search_filter,
374
+ since,
375
+ until,
376
+ pattern=search_pattern,
377
+ use_regex=use_regex,
378
+ case_sensitive=case_sensitive,
379
+ invert_match=invert_match,
380
+ min_level=min_level,
381
+ ):
382
+ continue
383
+
384
+ # Update stats tally
385
+ entry_level = entry.level if entry.level in stats else "UNKNOWN"
386
+ stats[entry_level] += 1
387
+
388
+ formatted = manager.format_log(
389
+ entry,
390
+ line_number=total_processed if show_line_numbers else None,
391
+ highlight=highlight,
392
+ highlight_color=highlight_color,
393
+ case_sensitive=case_sensitive,
394
+ )
395
+ recent_logs.append(formatted)
396
+ if len(recent_logs) > MAX_LOGS:
397
+ recent_logs.pop(0)
398
+
399
+ live.update(generate_layout())
400
+ except KeyboardInterrupt:
401
+ pass
@@ -0,0 +1,41 @@
1
+ [tool.poetry]
2
+ name = "logscope-cli"
3
+ version = "0.4.1"
4
+ description = "LogScope — Beautiful log viewer for the terminal"
5
+ authors = ["vinnytherobot"]
6
+ readme = "README.md"
7
+ packages = [{include = "logscope"}]
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.9"
11
+ rich = "^13.0.0"
12
+ typer = "^0.15.1"
13
+ typing-extensions = "^4.0.0"
14
+
15
+ [tool.poetry.group.dev.dependencies]
16
+ pytest = "^8.0.0"
17
+ pytest-cov = "^4.0.0"
18
+ ruff = "^0.15.0"
19
+
20
+ [tool.poetry.scripts]
21
+ logscope = "logscope.cli:app"
22
+
23
+ [build-system]
24
+ requires = ["poetry-core"]
25
+ build-backend = "poetry.core.masonry.api"
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+ addopts = "--tb=short"
30
+
31
+ [tool.coverage.run]
32
+ source = ["logscope"]
33
+ branch = true
34
+
35
+ [tool.coverage.report]
36
+ fail_under = 50
37
+ show_missing = true
38
+
39
+ [tool.ruff]
40
+ line-length = 100
41
+ target-version = "py39"