python-log-viewer 0.1.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.
@@ -0,0 +1,39 @@
1
+ """
2
+ HTTP Basic Authentication helpers (stdlib only).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import secrets
9
+ from typing import Optional, Tuple
10
+
11
+
12
+ def decode_basic_auth(header_value: str) -> Optional[Tuple[str, str]]:
13
+ """Decode an ``Authorization: Basic ...`` header.
14
+
15
+ Returns ``(username, password)`` or ``None`` on failure.
16
+ """
17
+ if not header_value.startswith("Basic "):
18
+ return None
19
+ try:
20
+ decoded = base64.b64decode(header_value[6:]).decode("utf-8")
21
+ username, password = decoded.split(":", 1)
22
+ return username, password
23
+ except Exception:
24
+ return None
25
+
26
+
27
+ def check_credentials(
28
+ header_value: str,
29
+ expected_username: str,
30
+ expected_password: str,
31
+ ) -> bool:
32
+ """Verify Basic-Auth credentials using constant-time comparison."""
33
+ creds = decode_basic_auth(header_value)
34
+ if creds is None:
35
+ return False
36
+ username, password = creds
37
+ return secrets.compare_digest(username, expected_username) and secrets.compare_digest(
38
+ password, expected_password
39
+ )
@@ -0,0 +1 @@
1
+ # Framework integrations for python-log-viewer.
@@ -0,0 +1,4 @@
1
+ # Django integration for python-log-viewer.
2
+ #
3
+ # Add ``"python_log_viewer.contrib.django"`` to ``INSTALLED_APPS`` and include
4
+ # ``python_log_viewer.contrib.django.urls`` in your URL configuration.
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class LogViewerConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "python_log_viewer.contrib.django"
7
+ label = "python_log_viewer"
8
+ verbose_name = "Log Viewer"
@@ -0,0 +1,19 @@
1
+ from django.urls import path
2
+
3
+ from .views import (
4
+ log_viewer_page,
5
+ get_log_files,
6
+ get_log_content,
7
+ delete_log_file,
8
+ clear_log_file,
9
+ )
10
+
11
+ app_name = "log_viewer"
12
+
13
+ urlpatterns = [
14
+ path("", log_viewer_page, name="log_viewer"),
15
+ path("api/files", get_log_files, name="log_viewer_files"),
16
+ path("api/content", get_log_content, name="log_viewer_content"),
17
+ path("api/file", delete_log_file, name="log_viewer_delete"),
18
+ path("api/clear", clear_log_file, name="log_viewer_clear"),
19
+ ]
@@ -0,0 +1,163 @@
1
+ """
2
+ Django views for the log viewer.
3
+
4
+ Settings (all optional, set in ``settings.py``)::
5
+
6
+ LOG_VIEWER_DIR = BASE_DIR / "logs" # path to log directory
7
+ LOG_VIEWER_AUTO_REFRESH = True
8
+ LOG_VIEWER_REFRESH_TIMER = 5000 # ms
9
+ LOG_VIEWER_AUTO_SCROLL = True
10
+ LOG_VIEWER_COLORIZE = True
11
+ LOG_VIEWER_USERNAME = None # set both to enable Basic Auth
12
+ LOG_VIEWER_PASSWORD = None
13
+ LOG_VIEWER_SUPERUSER_ACCESS = True # allow Django superusers without Basic Auth
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from functools import wraps
20
+
21
+ from django.conf import settings
22
+ from django.http import HttpResponse, JsonResponse
23
+ from django.views.decorators.csrf import csrf_exempt
24
+ from django.views.decorators.http import require_GET, require_POST, require_http_methods
25
+
26
+ from python_log_viewer.auth import check_credentials
27
+ from python_log_viewer.core import LogDirectory, LogReader
28
+ from python_log_viewer._html import render_html
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Lazy singletons – created once, reused across requests.
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def _get_log_dir() -> LogDirectory:
36
+ path = getattr(settings, "LOG_VIEWER_DIR", None)
37
+ if path is None:
38
+ path = os.path.join(settings.BASE_DIR, "logs")
39
+ return LogDirectory(str(path))
40
+
41
+
42
+ def _get_reader() -> LogReader:
43
+ return LogReader(_get_log_dir())
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Authentication decorator
48
+ # ---------------------------------------------------------------------------
49
+
50
+ def _basic_auth_required(view_func):
51
+ """Decorator: enforce HTTP Basic Auth when credentials are configured.
52
+
53
+ Authenticated Django superusers are allowed through automatically when
54
+ ``LOG_VIEWER_SUPERUSER_ACCESS`` is ``True`` (the default).
55
+ """
56
+
57
+ @wraps(view_func)
58
+ def _wrapped(request, *args, **kwargs):
59
+ # Allow Django superusers to bypass Basic Auth
60
+ superuser_access = getattr(settings, "LOG_VIEWER_SUPERUSER_ACCESS", True)
61
+ if superuser_access:
62
+ user = getattr(request, "user", None)
63
+ if user is not None and getattr(user, "is_authenticated", False) and getattr(user, "is_superuser", False):
64
+ return view_func(request, *args, **kwargs)
65
+
66
+ username = getattr(settings, "LOG_VIEWER_USERNAME", None) or os.getenv("LOG_VIEWER_USERNAME")
67
+ password = getattr(settings, "LOG_VIEWER_PASSWORD", None) or os.getenv("LOG_VIEWER_PASSWORD")
68
+
69
+ if username and password:
70
+ auth = request.META.get("HTTP_AUTHORIZATION", "")
71
+ if not check_credentials(auth, username, password):
72
+ return HttpResponse(
73
+ "Authentication required",
74
+ status=401,
75
+ headers={"WWW-Authenticate": 'Basic realm="Log Viewer"'},
76
+ )
77
+
78
+ return view_func(request, *args, **kwargs)
79
+
80
+ return _wrapped
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Views
85
+ # ---------------------------------------------------------------------------
86
+
87
+ @_basic_auth_required
88
+ @require_GET
89
+ def log_viewer_page(request):
90
+ """Serve the log viewer HTML page."""
91
+ # Read the URL prefix from the request path
92
+ # (strip trailing /api/... or trailing / to get the mount point)
93
+ prefix = request.path.rstrip("/")
94
+
95
+ html = render_html(
96
+ base_url=prefix,
97
+ auto_refresh=getattr(settings, "LOG_VIEWER_AUTO_REFRESH", True),
98
+ refresh_timer=getattr(settings, "LOG_VIEWER_REFRESH_TIMER", 5000),
99
+ auto_scroll=getattr(settings, "LOG_VIEWER_AUTO_SCROLL", True),
100
+ colorize=getattr(settings, "LOG_VIEWER_COLORIZE", True),
101
+ )
102
+ return HttpResponse(html, content_type="text/html")
103
+
104
+
105
+ @_basic_auth_required
106
+ @require_GET
107
+ def get_log_files(request):
108
+ """Return a list of available log files with metadata."""
109
+ try:
110
+ files = _get_log_dir().list_files()
111
+ return JsonResponse(
112
+ {"files": [{"name": f.name, "size": f.size, "modified": f.modified} for f in files]}
113
+ )
114
+ except Exception as e:
115
+ return JsonResponse({"files": [], "error": str(e)})
116
+
117
+
118
+ @_basic_auth_required
119
+ @require_GET
120
+ def get_log_content(request):
121
+ """Return log lines from the selected file as JSON."""
122
+ try:
123
+ result = _get_reader().read(
124
+ file=request.GET.get("file", "app.log"),
125
+ lines=int(request.GET.get("lines", "500")),
126
+ level=request.GET.get("level", ""),
127
+ search=request.GET.get("search", ""),
128
+ )
129
+ return JsonResponse(result)
130
+ except Exception as e:
131
+ return JsonResponse({"lines": [f"Error reading log file: {e}"], "total": 0})
132
+
133
+
134
+ @csrf_exempt
135
+ @_basic_auth_required
136
+ @require_http_methods(["DELETE"])
137
+ def delete_log_file(request):
138
+ """Permanently delete a log file."""
139
+ try:
140
+ file_param = request.GET.get("file", "")
141
+ if _get_log_dir().delete_file(file_param):
142
+ return JsonResponse(
143
+ {"success": True, "message": f"{os.path.basename(file_param)} deleted"}
144
+ )
145
+ return JsonResponse({"success": False, "error": "Invalid or missing file"}, status=404)
146
+ except Exception as e:
147
+ return JsonResponse({"success": False, "error": str(e)}, status=500)
148
+
149
+
150
+ @csrf_exempt
151
+ @_basic_auth_required
152
+ @require_POST
153
+ def clear_log_file(request):
154
+ """Clear the contents of a log file (truncate to 0 bytes)."""
155
+ try:
156
+ file_param = request.GET.get("file", "")
157
+ if _get_log_dir().clear_file(file_param):
158
+ return JsonResponse(
159
+ {"success": True, "message": f"{os.path.basename(file_param)} cleared"}
160
+ )
161
+ return JsonResponse({"success": False, "error": "Invalid or missing file"}, status=404)
162
+ except Exception as e:
163
+ return JsonResponse({"success": False, "error": str(e)}, status=500)
@@ -0,0 +1,124 @@
1
+ """
2
+ FastAPI integration for python-log-viewer.
3
+
4
+ Usage::
5
+
6
+ from fastapi import FastAPI
7
+ from python_log_viewer.contrib.fastapi import create_log_viewer_router
8
+
9
+ app = FastAPI()
10
+ app.include_router(
11
+ create_log_viewer_router(
12
+ log_dir="./logs",
13
+ prefix="/logs",
14
+ username="admin",
15
+ password="secret",
16
+ )
17
+ )
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from typing import Optional
24
+
25
+ from python_log_viewer.core import LogDirectory, LogReader
26
+ from python_log_viewer._html import render_html
27
+
28
+
29
+ def create_log_viewer_router(
30
+ log_dir: str = "./logs",
31
+ prefix: str = "/logs",
32
+ username: Optional[str] = None,
33
+ password: Optional[str] = None,
34
+ auto_refresh: bool = True,
35
+ refresh_timer: int = 5000,
36
+ auto_scroll: bool = True,
37
+ colorize: bool = True,
38
+ ):
39
+ """Create and return a FastAPI :class:`~fastapi.APIRouter`.
40
+
41
+ Parameters
42
+ ----------
43
+ log_dir:
44
+ Path to the directory containing log files.
45
+ prefix:
46
+ URL prefix to mount the router at (e.g. ``/logs``).
47
+ username / password:
48
+ Enable HTTP Basic Auth when both are provided.
49
+ auto_refresh / refresh_timer / auto_scroll / colorize:
50
+ UI defaults.
51
+ """
52
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
53
+ from fastapi.responses import HTMLResponse, JSONResponse
54
+ from fastapi.security import HTTPBasic, HTTPBasicCredentials
55
+
56
+ from python_log_viewer.auth import check_credentials as _check
57
+
58
+ import secrets as _secrets
59
+
60
+ directory = LogDirectory(log_dir)
61
+ reader = LogReader(directory)
62
+ router = APIRouter(prefix=prefix, tags=["python-log-viewer"])
63
+
64
+ # ---- auth dependency ------------------------------------------------
65
+
66
+ _security = HTTPBasic(auto_error=False)
67
+
68
+ async def _verify(credentials: Optional[HTTPBasicCredentials] = Depends(_security)):
69
+ if username and password:
70
+ if credentials is None:
71
+ raise HTTPException(
72
+ status_code=401,
73
+ detail="Authentication required",
74
+ headers={"WWW-Authenticate": 'Basic realm="Log Viewer"'},
75
+ )
76
+ if not (
77
+ _secrets.compare_digest(credentials.username, username)
78
+ and _secrets.compare_digest(credentials.password, password)
79
+ ):
80
+ raise HTTPException(
81
+ status_code=401,
82
+ detail="Invalid credentials",
83
+ headers={"WWW-Authenticate": 'Basic realm="Log Viewer"'},
84
+ )
85
+
86
+ # ---- routes ---------------------------------------------------------
87
+
88
+ @router.get("/", response_class=HTMLResponse, dependencies=[Depends(_verify)])
89
+ async def index():
90
+ return render_html(
91
+ base_url=prefix,
92
+ auto_refresh=auto_refresh,
93
+ refresh_timer=refresh_timer,
94
+ auto_scroll=auto_scroll,
95
+ colorize=colorize,
96
+ )
97
+
98
+ @router.get("/api/files", dependencies=[Depends(_verify)])
99
+ async def api_files():
100
+ files = directory.list_files()
101
+ return {"files": [{"name": f.name, "size": f.size, "modified": f.modified} for f in files]}
102
+
103
+ @router.get("/api/content", dependencies=[Depends(_verify)])
104
+ async def api_content(
105
+ file: str = Query("app.log"),
106
+ lines: int = Query(500),
107
+ level: str = Query(""),
108
+ search: str = Query(""),
109
+ ):
110
+ return reader.read(file=file, lines=lines, level=level, search=search)
111
+
112
+ @router.delete("/api/file", dependencies=[Depends(_verify)])
113
+ async def api_delete(file: str = Query("")):
114
+ if directory.delete_file(file):
115
+ return {"success": True, "message": f"{os.path.basename(file)} deleted"}
116
+ return JSONResponse({"success": False, "error": "Invalid or missing file"}, status_code=404)
117
+
118
+ @router.post("/api/clear", dependencies=[Depends(_verify)])
119
+ async def api_clear(file: str = Query("")):
120
+ if directory.clear_file(file):
121
+ return {"success": True, "message": f"{os.path.basename(file)} cleared"}
122
+ return JSONResponse({"success": False, "error": "Invalid or missing file"}, status_code=404)
123
+
124
+ return router
@@ -0,0 +1,124 @@
1
+ """
2
+ Flask integration for python-log-viewer.
3
+
4
+ Usage::
5
+
6
+ from flask import Flask
7
+ from python_log_viewer.contrib.flask import create_log_viewer_blueprint
8
+
9
+ app = Flask(__name__)
10
+ app.register_blueprint(
11
+ create_log_viewer_blueprint(
12
+ log_dir="./logs",
13
+ url_prefix="/logs",
14
+ username="admin",
15
+ password="secret",
16
+ )
17
+ )
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from functools import wraps
24
+ from typing import Optional
25
+
26
+ from python_log_viewer.core import LogDirectory, LogReader
27
+ from python_log_viewer._html import render_html
28
+
29
+
30
+ def create_log_viewer_blueprint(
31
+ log_dir: str = "./logs",
32
+ url_prefix: str = "/logs",
33
+ username: Optional[str] = None,
34
+ password: Optional[str] = None,
35
+ auto_refresh: bool = True,
36
+ refresh_timer: int = 5000,
37
+ auto_scroll: bool = True,
38
+ colorize: bool = True,
39
+ ):
40
+ """Create and return a Flask :class:`~flask.Blueprint` for the log viewer.
41
+
42
+ Parameters
43
+ ----------
44
+ log_dir:
45
+ Path to the directory containing log files.
46
+ url_prefix:
47
+ URL prefix to mount the blueprint at (e.g. ``/logs``).
48
+ username / password:
49
+ Enable HTTP Basic Auth when both are provided.
50
+ auto_refresh / refresh_timer / auto_scroll / colorize:
51
+ UI defaults.
52
+ """
53
+ from flask import Blueprint, jsonify, request, Response
54
+
55
+ from python_log_viewer.auth import check_credentials
56
+
57
+ directory = LogDirectory(log_dir)
58
+ reader = LogReader(directory)
59
+ bp = Blueprint("log_viewer", __name__, url_prefix=url_prefix)
60
+
61
+ # ---- auth decorator -------------------------------------------------
62
+
63
+ def _auth_required(fn):
64
+ @wraps(fn)
65
+ def wrapper(*args, **kwargs):
66
+ if username and password:
67
+ auth = request.headers.get("Authorization", "")
68
+ if not check_credentials(auth, username, password):
69
+ return Response(
70
+ "Authentication required",
71
+ 401,
72
+ {"WWW-Authenticate": 'Basic realm="Log Viewer"'},
73
+ )
74
+ return fn(*args, **kwargs)
75
+ return wrapper
76
+
77
+ # ---- routes ---------------------------------------------------------
78
+
79
+ @bp.route("/", methods=["GET"])
80
+ @_auth_required
81
+ def index():
82
+ html = render_html(
83
+ base_url=url_prefix,
84
+ auto_refresh=auto_refresh,
85
+ refresh_timer=refresh_timer,
86
+ auto_scroll=auto_scroll,
87
+ colorize=colorize,
88
+ )
89
+ return Response(html, content_type="text/html")
90
+
91
+ @bp.route("/api/files", methods=["GET"])
92
+ @_auth_required
93
+ def api_files():
94
+ files = directory.list_files()
95
+ return jsonify({"files": [{"name": f.name, "size": f.size, "modified": f.modified} for f in files]})
96
+
97
+ @bp.route("/api/content", methods=["GET"])
98
+ @_auth_required
99
+ def api_content():
100
+ result = reader.read(
101
+ file=request.args.get("file", "app.log"),
102
+ lines=int(request.args.get("lines", "500")),
103
+ level=request.args.get("level", ""),
104
+ search=request.args.get("search", ""),
105
+ )
106
+ return jsonify(result)
107
+
108
+ @bp.route("/api/file", methods=["DELETE"])
109
+ @_auth_required
110
+ def api_delete():
111
+ file_param = request.args.get("file", "")
112
+ if directory.delete_file(file_param):
113
+ return jsonify({"success": True, "message": f"{os.path.basename(file_param)} deleted"})
114
+ return jsonify({"success": False, "error": "Invalid or missing file"}), 404
115
+
116
+ @bp.route("/api/clear", methods=["POST"])
117
+ @_auth_required
118
+ def api_clear():
119
+ file_param = request.args.get("file", "")
120
+ if directory.clear_file(file_param):
121
+ return jsonify({"success": True, "message": f"{os.path.basename(file_param)} cleared"})
122
+ return jsonify({"success": False, "error": "Invalid or missing file"}), 404
123
+
124
+ return bp
@@ -0,0 +1,165 @@
1
+ """
2
+ Framework-agnostic core logic for reading and managing log files.
3
+
4
+ No external dependencies – only the Python standard library.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from typing import List, Optional
12
+
13
+
14
+ @dataclass
15
+ class LogFileInfo:
16
+ """Metadata for a single log file."""
17
+
18
+ name: str # relative path from the log directory
19
+ size: int
20
+ modified: float
21
+
22
+
23
+ class LogDirectory:
24
+ """Represents a directory tree of log files.
25
+
26
+ Parameters
27
+ ----------
28
+ path:
29
+ Absolute or relative path to the root log directory.
30
+ """
31
+
32
+ def __init__(self, path: str) -> None:
33
+ self.path = os.path.abspath(path)
34
+
35
+ # ------------------------------------------------------------------
36
+ # Listing
37
+ # ------------------------------------------------------------------
38
+
39
+ def list_files(self) -> List[LogFileInfo]:
40
+ """Walk *self.path* and return metadata for every regular file."""
41
+ if not os.path.isdir(self.path):
42
+ return []
43
+
44
+ files: list[LogFileInfo] = []
45
+ for root, _dirs, filenames in os.walk(self.path):
46
+ for entry in sorted(filenames):
47
+ filepath = os.path.join(root, entry)
48
+ if os.path.isfile(filepath):
49
+ rel = os.path.relpath(filepath, self.path)
50
+ stat = os.stat(filepath)
51
+ files.append(
52
+ LogFileInfo(name=rel, size=stat.st_size, modified=stat.st_mtime)
53
+ )
54
+ files.sort(key=lambda f: f.name)
55
+ return files
56
+
57
+ # ------------------------------------------------------------------
58
+ # Safe path resolution
59
+ # ------------------------------------------------------------------
60
+
61
+ def _safe_resolve(self, relative: str) -> Optional[str]:
62
+ """Return the absolute path if *relative* stays inside *self.path*.
63
+
64
+ Returns ``None`` when the path escapes the log directory.
65
+ """
66
+ safe = os.path.normpath(relative)
67
+ if safe.startswith("..") or os.path.isabs(safe):
68
+ return None
69
+ full = os.path.realpath(os.path.join(self.path, safe))
70
+ if not full.startswith(os.path.realpath(self.path)):
71
+ return None
72
+ if not os.path.isfile(full):
73
+ return None
74
+ return full
75
+
76
+ # ------------------------------------------------------------------
77
+ # File mutations
78
+ # ------------------------------------------------------------------
79
+
80
+ def delete_file(self, relative: str) -> bool:
81
+ """Permanently remove a log file. Returns *True* on success."""
82
+ resolved = self._safe_resolve(relative)
83
+ if resolved is None:
84
+ return False
85
+ os.remove(resolved)
86
+ return True
87
+
88
+ def clear_file(self, relative: str) -> bool:
89
+ """Truncate a log file to zero bytes. Returns *True* on success."""
90
+ resolved = self._safe_resolve(relative)
91
+ if resolved is None:
92
+ return False
93
+ open(resolved, "w", encoding="utf-8").close()
94
+ return True
95
+
96
+
97
+ class LogReader:
98
+ """Read and filter log entries from a file inside a :class:`LogDirectory`.
99
+
100
+ Parameters
101
+ ----------
102
+ log_dir:
103
+ A :class:`LogDirectory` instance.
104
+ """
105
+
106
+ _LEVEL_KEYWORDS = frozenset({"INFO", "WARNING", "ERROR", "DEBUG", "CRITICAL"})
107
+
108
+ def __init__(self, log_dir: LogDirectory) -> None:
109
+ self.log_dir = log_dir
110
+
111
+ def read(
112
+ self,
113
+ file: str,
114
+ *,
115
+ lines: int = 500,
116
+ level: str = "",
117
+ search: str = "",
118
+ ) -> dict:
119
+ """Return filtered log entries as a dict.
120
+
121
+ Returns
122
+ -------
123
+ dict
124
+ ``{"lines": [...], "total": int}`` on success, or
125
+ ``{"lines": [...], "total": 0, "error": "..."}`` on failure.
126
+ """
127
+ resolved = self.log_dir._safe_resolve(file)
128
+ if resolved is None:
129
+ return {"lines": [], "total": 0, "error": "Invalid or missing file"}
130
+
131
+ try:
132
+ with open(resolved, "r", encoding="utf-8") as fh:
133
+ raw_lines = fh.readlines()
134
+ except Exception as exc:
135
+ return {"lines": [f"Error reading log file: {exc}"], "total": 0}
136
+
137
+ # Group multi-line entries
138
+ entries: list[str] = []
139
+ for line in raw_lines:
140
+ stripped = line.rstrip()
141
+ if stripped and (
142
+ stripped[0].isdigit()
143
+ or stripped.split()[0] in self._LEVEL_KEYWORDS
144
+ ):
145
+ entries.append(stripped)
146
+ elif entries:
147
+ entries[-1] += "\n" + stripped
148
+ else:
149
+ entries.append(stripped)
150
+
151
+ # Level filter
152
+ if level:
153
+ upper = level.upper()
154
+ entries = [e for e in entries if upper in e]
155
+
156
+ # Text search
157
+ if search:
158
+ lower = search.lower()
159
+ entries = [e for e in entries if lower in e.lower()]
160
+
161
+ total = len(entries)
162
+ if lines > 0:
163
+ entries = entries[-lines:]
164
+
165
+ return {"lines": entries, "total": total}