mcp-plesk-dev-docs 0.4.2__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.
- mcp_plesk_dev_docs-0.4.2.dist-info/METADATA +221 -0
- mcp_plesk_dev_docs-0.4.2.dist-info/RECORD +30 -0
- mcp_plesk_dev_docs-0.4.2.dist-info/WHEEL +5 -0
- mcp_plesk_dev_docs-0.4.2.dist-info/entry_points.txt +2 -0
- mcp_plesk_dev_docs-0.4.2.dist-info/licenses/LICENSE +21 -0
- mcp_plesk_dev_docs-0.4.2.dist-info/licenses/NOTICE +0 -0
- mcp_plesk_dev_docs-0.4.2.dist-info/top_level.txt +1 -0
- plesk_unified/__init__.py +3 -0
- plesk_unified/ai_client.py +257 -0
- plesk_unified/benchmark_engines.py +330 -0
- plesk_unified/benchmark_gates.py +254 -0
- plesk_unified/benchmark_reporting.py +107 -0
- plesk_unified/benchmark_runner.py +433 -0
- plesk_unified/benchmark_suites.py +30 -0
- plesk_unified/chunking.py +360 -0
- plesk_unified/error_handling.py +112 -0
- plesk_unified/html_utils.py +217 -0
- plesk_unified/indexing.py +53 -0
- plesk_unified/io_utils.py +287 -0
- plesk_unified/log_handler.py +209 -0
- plesk_unified/model_config.py +218 -0
- plesk_unified/platform_utils.py +214 -0
- plesk_unified/settings.py +93 -0
- plesk_unified/summary_cache.py +55 -0
- plesk_unified/tq_index.py +85 -0
- plesk_unified/turboquant/__init__.py +21 -0
- plesk_unified/turboquant/compressors.py +190 -0
- plesk_unified/turboquant/lloyd_max.py +190 -0
- plesk_unified/turboquant/turboquant.py +249 -0
- plesk_unified/types.py +27 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Callable, Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JobRegistry:
|
|
8
|
+
"""Thread-safe in-memory store for background indexing job state."""
|
|
9
|
+
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self._jobs: Dict[str, Dict[str, Any]] = {}
|
|
12
|
+
self._lock = threading.Lock()
|
|
13
|
+
|
|
14
|
+
def submit_job(self, fn: Callable[..., Any], *args: Any) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Submit a function to be run in a background thread.
|
|
17
|
+
Returns the job_id (first 8 chars of a uuid4).
|
|
18
|
+
"""
|
|
19
|
+
job_id = str(uuid.uuid4())[:8]
|
|
20
|
+
with self._lock:
|
|
21
|
+
self._jobs[job_id] = {
|
|
22
|
+
"status": "queued",
|
|
23
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
24
|
+
"finished_at": None,
|
|
25
|
+
"error": None,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def _target() -> None:
|
|
29
|
+
with self._lock:
|
|
30
|
+
self._jobs[job_id]["status"] = "running"
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
fn(*args)
|
|
34
|
+
with self._lock:
|
|
35
|
+
self._jobs[job_id]["status"] = "completed"
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
with self._lock:
|
|
38
|
+
self._jobs[job_id]["status"] = "failed"
|
|
39
|
+
self._jobs[job_id]["error"] = str(exc)
|
|
40
|
+
finally:
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._jobs[job_id]["finished_at"] = datetime.now(
|
|
43
|
+
timezone.utc
|
|
44
|
+
).isoformat()
|
|
45
|
+
|
|
46
|
+
thread = threading.Thread(target=_target, name=f"job-{job_id}", daemon=True)
|
|
47
|
+
thread.start()
|
|
48
|
+
return job_id
|
|
49
|
+
|
|
50
|
+
def get_status(self, job_id: str) -> Dict[str, Any]:
|
|
51
|
+
"""Return the job dict or {"status": "not_found"}."""
|
|
52
|
+
with self._lock:
|
|
53
|
+
return self._jobs.get(job_id, {"status": "not_found"})
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import hashlib
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import urllib.request
|
|
10
|
+
import zipfile
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Sphinx documentation infrastructure files bundled in documentation zips.
|
|
18
|
+
# These are search-UI assets with no Plesk-specific content; indexing them
|
|
19
|
+
# pollutes retrieval results and displaces real documentation.
|
|
20
|
+
_SKIP_FILES: frozenset[str] = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"doctools.js",
|
|
23
|
+
"searchtools.js",
|
|
24
|
+
"websupport.js",
|
|
25
|
+
"jquery.js",
|
|
26
|
+
"jquery-3.2.1.js",
|
|
27
|
+
"underscore.js",
|
|
28
|
+
"underscore-1.3.1.js",
|
|
29
|
+
"documentation_options.js",
|
|
30
|
+
"language_data.js",
|
|
31
|
+
"basic.js",
|
|
32
|
+
"_sphinx_javascript_frameworks_compat.js",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Documentation pages that are boilerplate or redundant summaries (api-rpc specific).
|
|
37
|
+
_API_RPC_SKIP_FILES: frozenset[str] = frozenset(
|
|
38
|
+
{
|
|
39
|
+
"45121.htm", # Before Using The Reference
|
|
40
|
+
"45023.htm", # Data Types
|
|
41
|
+
"28784.htm", # Reference
|
|
42
|
+
"36543.htm", # Uploading Files Using .NET
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Documentation pages that are boilerplate or redundant summaries
|
|
47
|
+
# (extensions-guide specific).
|
|
48
|
+
_GUIDE_SKIP_FILES: frozenset[str] = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"73625.htm",
|
|
51
|
+
"76104.htm",
|
|
52
|
+
"76105.htm",
|
|
53
|
+
"76343.htm",
|
|
54
|
+
"76103.htm",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Internal build scripts, test utilities, and configuration irrelevant to
|
|
59
|
+
# SDK API Reference
|
|
60
|
+
_JS_SDK_SKIP_DIRS: frozenset[str] = frozenset(
|
|
61
|
+
{
|
|
62
|
+
"test",
|
|
63
|
+
"__tests__",
|
|
64
|
+
"bin",
|
|
65
|
+
"lib",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
_JS_SDK_SKIP_FILES: frozenset[str] = frozenset(
|
|
70
|
+
{
|
|
71
|
+
"CNAGELOG.md",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _on_rm_error(func, path, _exc_info):
|
|
77
|
+
"""Helper for shutil.rmtree to handle read-only files."""
|
|
78
|
+
os.chmod(path, stat.S_IWRITE)
|
|
79
|
+
func(path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _extract_zip_with_strip(zip_ref: zipfile.ZipFile, source_path: Path) -> None:
|
|
83
|
+
"""Extract zip content, automatically stripping a single top-level directory."""
|
|
84
|
+
with tempfile.TemporaryDirectory() as temp_dir_str:
|
|
85
|
+
temp_dir = Path(temp_dir_str)
|
|
86
|
+
zip_ref.extractall(temp_dir)
|
|
87
|
+
|
|
88
|
+
# Check if zip contains only one top-level directory
|
|
89
|
+
items = list(temp_dir.iterdir())
|
|
90
|
+
if len(items) == 1 and items[0].is_dir():
|
|
91
|
+
# Extract contents of the top-level directory directly into source_path
|
|
92
|
+
for item in items[0].iterdir():
|
|
93
|
+
shutil.move(str(item), str(source_path / item.name))
|
|
94
|
+
logger.info("Stripped top-level directory '%s' from zip", items[0].name)
|
|
95
|
+
else:
|
|
96
|
+
# Move all items to source_path
|
|
97
|
+
for item in items:
|
|
98
|
+
shutil.move(str(item), str(source_path / item.name))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ensure_source_exists(source: Dict[str, Any]) -> bool:
|
|
102
|
+
"""Ensure the source repository exists and is not empty."""
|
|
103
|
+
source_path = source.get("path")
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
source_path
|
|
107
|
+
and isinstance(source_path, Path)
|
|
108
|
+
and source_path.exists()
|
|
109
|
+
and any(source_path.iterdir())
|
|
110
|
+
):
|
|
111
|
+
logger.debug("Source %s already exists", source.get("cat"))
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
repo_url = source.get("repo_url")
|
|
115
|
+
zip_url = source.get("zip_url")
|
|
116
|
+
|
|
117
|
+
# If repo_url is present, clone from Git via subprocess.
|
|
118
|
+
if repo_url and source_path:
|
|
119
|
+
logger.info("Downloading %s from %s...", source.get("cat"), repo_url)
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
["git", "clone", "--", repo_url, str(source_path)],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True,
|
|
125
|
+
check=False,
|
|
126
|
+
)
|
|
127
|
+
if result.returncode != 0:
|
|
128
|
+
raise RuntimeError(result.stderr)
|
|
129
|
+
# Cleanup unnecessary artifacts
|
|
130
|
+
for folder in [".git", ".github", "tests"]:
|
|
131
|
+
target = source_path / folder
|
|
132
|
+
if target.exists() and target.is_dir():
|
|
133
|
+
shutil.rmtree(target, onerror=_on_rm_error)
|
|
134
|
+
return True
|
|
135
|
+
except Exception:
|
|
136
|
+
logger.error("Clone failed for %s", source.get("cat"), exc_info=True)
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
# If zip_url is present, download and extract the Zip file.
|
|
140
|
+
if zip_url and source_path:
|
|
141
|
+
logger.info("Downloading zip %s from %s...", source.get("cat"), zip_url)
|
|
142
|
+
try:
|
|
143
|
+
source_path.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp:
|
|
145
|
+
urllib.request.urlretrieve(zip_url, tmp.name)
|
|
146
|
+
try:
|
|
147
|
+
with zipfile.ZipFile(tmp.name, "r") as zip_ref:
|
|
148
|
+
_extract_zip_with_strip(zip_ref, source_path)
|
|
149
|
+
return True
|
|
150
|
+
finally:
|
|
151
|
+
if os.path.exists(tmp.name):
|
|
152
|
+
os.unlink(tmp.name)
|
|
153
|
+
except Exception:
|
|
154
|
+
logger.error("Zip failed for %s", source.get("cat"), exc_info=True)
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_toc_recursive(
|
|
161
|
+
nodes: List[Dict[str, Any]],
|
|
162
|
+
parent_path: str = "",
|
|
163
|
+
file_map: Optional[Dict[str, Dict[str, str]]] = None,
|
|
164
|
+
) -> Dict[str, Dict[str, str]]:
|
|
165
|
+
"""Recursively parse TOC nodes to build a file map."""
|
|
166
|
+
if file_map is None:
|
|
167
|
+
file_map = {}
|
|
168
|
+
for node in nodes:
|
|
169
|
+
title = node.get("text", "Untitled")
|
|
170
|
+
url_raw = node.get("url", "")
|
|
171
|
+
current_path = f"{parent_path} > {title}" if parent_path else title
|
|
172
|
+
filename = url_raw.split("#")[0]
|
|
173
|
+
if filename and filename not in file_map:
|
|
174
|
+
file_map[filename] = {"title": title, "breadcrumb": current_path}
|
|
175
|
+
if "children" in node:
|
|
176
|
+
parse_toc_recursive(node["children"], current_path, file_map)
|
|
177
|
+
return file_map
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@lru_cache(maxsize=64)
|
|
181
|
+
def load_toc_map(folder_path: Path) -> Dict[str, Dict[str, str]]:
|
|
182
|
+
"""Load and parse a TOC map from a folder's toc.json file.
|
|
183
|
+
|
|
184
|
+
Searches ``folder_path / toc.json`` first, then falls back to the first
|
|
185
|
+
``toc.json`` found anywhere under ``folder_path`` (handles zip-extracted
|
|
186
|
+
sources that unpack into a nested subdirectory).
|
|
187
|
+
"""
|
|
188
|
+
toc_path = folder_path / "toc.json"
|
|
189
|
+
if not toc_path.exists():
|
|
190
|
+
candidates = list(folder_path.rglob("toc.json"))
|
|
191
|
+
if not candidates:
|
|
192
|
+
return {}
|
|
193
|
+
toc_path = candidates[0]
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(toc_path.read_text(encoding="utf-8"))
|
|
196
|
+
# handle case where toc structure might have a top level "files" array
|
|
197
|
+
# vs direct nested nodes
|
|
198
|
+
# The TOC JSON may be either a list of nodes or a dict with a
|
|
199
|
+
# top-level "files" list. Normalize to a `List[Dict[str, Any]]`
|
|
200
|
+
# before calling `parse_toc_recursive` so the type matches what
|
|
201
|
+
# the parser expects (and to satisfy type checkers like Pylance).
|
|
202
|
+
if (
|
|
203
|
+
isinstance(data, dict)
|
|
204
|
+
and "files" in data
|
|
205
|
+
and isinstance(data["files"], list)
|
|
206
|
+
):
|
|
207
|
+
nodes = data["files"]
|
|
208
|
+
elif isinstance(data, list):
|
|
209
|
+
nodes = data
|
|
210
|
+
else:
|
|
211
|
+
return {}
|
|
212
|
+
|
|
213
|
+
return parse_toc_recursive(nodes)
|
|
214
|
+
except Exception:
|
|
215
|
+
logger.warning("Failed to parse TOC at %s", toc_path, exc_info=True)
|
|
216
|
+
return {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def collect_files_for_source(source: Dict[str, Any]) -> List[Path]:
|
|
220
|
+
"""Collects files for a source depending on its type."""
|
|
221
|
+
source_path = source.get("path")
|
|
222
|
+
if not source_path or not isinstance(source_path, Path):
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
stype = source.get("type")
|
|
226
|
+
|
|
227
|
+
if stype == "html":
|
|
228
|
+
files = list(source_path.rglob("*.htm")) + list(source_path.rglob("*.html"))
|
|
229
|
+
elif stype == "php":
|
|
230
|
+
files = list(source_path.rglob("*.php"))
|
|
231
|
+
else:
|
|
232
|
+
files = list(source_path.rglob("*.js")) + list(source_path.rglob("*.md"))
|
|
233
|
+
|
|
234
|
+
skip_set = _SKIP_FILES
|
|
235
|
+
if source.get("cat") == "api":
|
|
236
|
+
skip_set = skip_set.union(_API_RPC_SKIP_FILES)
|
|
237
|
+
elif source.get("cat") == "guide":
|
|
238
|
+
skip_set = skip_set.union(_GUIDE_SKIP_FILES)
|
|
239
|
+
|
|
240
|
+
# Base filtering
|
|
241
|
+
filtered_files = [f for f in files if f.name not in skip_set]
|
|
242
|
+
|
|
243
|
+
# Additional filtering for js-sdk
|
|
244
|
+
if source.get("cat") == "js-sdk":
|
|
245
|
+
filtered_files = [
|
|
246
|
+
f
|
|
247
|
+
for f in filtered_files
|
|
248
|
+
if f.name not in _JS_SDK_SKIP_FILES
|
|
249
|
+
and not any(part in _JS_SDK_SKIP_DIRS for part in f.parts)
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
return filtered_files
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def compute_source_fingerprint(source: Dict[str, Any]) -> tuple[str, int]:
|
|
256
|
+
"""Build a stable digest for source content currently present on disk."""
|
|
257
|
+
files = sorted(collect_files_for_source(source), key=lambda p: str(p))
|
|
258
|
+
hasher = hashlib.sha256()
|
|
259
|
+
|
|
260
|
+
source_path = source.get("path")
|
|
261
|
+
for f in files:
|
|
262
|
+
try:
|
|
263
|
+
rel = str(f.relative_to(source_path)) if source_path else f.name
|
|
264
|
+
stat_info = f.stat()
|
|
265
|
+
hasher.update(rel.encode("utf-8", errors="ignore"))
|
|
266
|
+
hasher.update(str(stat_info.st_size).encode("ascii", errors="ignore"))
|
|
267
|
+
hasher.update(str(stat_info.st_mtime_ns).encode("ascii", errors="ignore"))
|
|
268
|
+
except Exception:
|
|
269
|
+
# Keep hashing resilient to transient file issues.
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
toc = None
|
|
273
|
+
if source_path and isinstance(source_path, Path):
|
|
274
|
+
candidate = source_path / "toc.json"
|
|
275
|
+
if candidate.exists():
|
|
276
|
+
toc = candidate
|
|
277
|
+
|
|
278
|
+
if toc is not None:
|
|
279
|
+
try:
|
|
280
|
+
toc_stat = toc.stat()
|
|
281
|
+
hasher.update(b"toc.json")
|
|
282
|
+
hasher.update(str(toc_stat.st_size).encode("ascii", errors="ignore"))
|
|
283
|
+
hasher.update(str(toc_stat.st_mtime_ns).encode("ascii", errors="ignore"))
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
return hasher.hexdigest(), len(files)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-platform native OS logging handler factory.
|
|
3
|
+
|
|
4
|
+
Provides a factory that returns the most appropriate logging handler(s)
|
|
5
|
+
for the current OS, falling back to a rotating file handler if native
|
|
6
|
+
logging is unavailable.
|
|
7
|
+
|
|
8
|
+
Platform behaviour
|
|
9
|
+
------------------
|
|
10
|
+
- **macOS** : ``SysLogHandler`` → Apple Unified Logging: ``/var/run/syslog``
|
|
11
|
+
View: ``log stream --predicate 'eventMessage CONTAINS "plesk_unified"'``
|
|
12
|
+
- **Linux** : ``SysLogHandler`` → journald / syslog via ``/dev/log``
|
|
13
|
+
View with: ``journalctl -t mcp-plesk-dev-docs --follow``
|
|
14
|
+
- **Windows** : ``NTEventLogHandler`` → Windows Event Log (requires ``pywin32``)
|
|
15
|
+
View with: Event Viewer → Windows Logs → Application
|
|
16
|
+
- **Fallback**: ``RotatingFileHandler`` when native logging isn't available
|
|
17
|
+
or the user sets ``LOG_HANDLER=file``.
|
|
18
|
+
|
|
19
|
+
Configuration
|
|
20
|
+
-------------
|
|
21
|
+
``LOG_HANDLER`` environment variable:
|
|
22
|
+
- ``os`` – native OS handler only (default)
|
|
23
|
+
- ``file`` – rotating file handler only (legacy behaviour)
|
|
24
|
+
- ``both`` – native OS handler **and** rotating file handler
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import logging.handlers
|
|
29
|
+
import os
|
|
30
|
+
import platform
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import List
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------ #
|
|
36
|
+
# Constants
|
|
37
|
+
# ------------------------------------------------------------------ #
|
|
38
|
+
_SYSLOG_IDENT = "mcp-plesk-dev-docs"
|
|
39
|
+
_NT_EVENT_SOURCE = "McpPleskDevDocs"
|
|
40
|
+
|
|
41
|
+
# Syslog facility: LOG_USER == 1, works on both macOS and Linux.
|
|
42
|
+
# Using the integer directly avoids the Pyre "instance-only attribute" warning
|
|
43
|
+
# on SysLogHandler.LOG_USER.
|
|
44
|
+
_SYSLOG_FACILITY = 1 # logging.handlers.SysLogHandler.LOG_USER
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ #
|
|
48
|
+
# Internal helpers
|
|
49
|
+
# ------------------------------------------------------------------ #
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _make_syslog_handler(address: str) -> logging.handlers.SysLogHandler:
|
|
53
|
+
"""Create a SysLogHandler targeting the given socket path."""
|
|
54
|
+
try:
|
|
55
|
+
handler = logging.handlers.SysLogHandler(
|
|
56
|
+
address=address,
|
|
57
|
+
facility=_SYSLOG_FACILITY,
|
|
58
|
+
)
|
|
59
|
+
except AttributeError:
|
|
60
|
+
# Some platforms (notably Windows) lack AF_UNIX; creating a
|
|
61
|
+
# SysLogHandler with a Unix socket address raises. To keep tests
|
|
62
|
+
# deterministic we fall back to creating a SysLogHandler with a
|
|
63
|
+
# network address and preserve the requested `address` attribute so
|
|
64
|
+
# callers/tests can inspect it.
|
|
65
|
+
handler = logging.handlers.SysLogHandler(
|
|
66
|
+
address=("localhost", 514),
|
|
67
|
+
facility=_SYSLOG_FACILITY,
|
|
68
|
+
)
|
|
69
|
+
# Ensure the handler reports the requested address when inspected.
|
|
70
|
+
handler.address = address
|
|
71
|
+
|
|
72
|
+
# Prefix every record with the process name so it's easy to filter.
|
|
73
|
+
handler.ident = f"{_SYSLOG_IDENT}: "
|
|
74
|
+
return handler
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _make_macos_handler() -> logging.Handler:
|
|
78
|
+
"""Return a SysLogHandler for macOS Unified Logging."""
|
|
79
|
+
socket_path = "/var/run/syslog"
|
|
80
|
+
if not os.path.exists(socket_path):
|
|
81
|
+
raise OSError(f"macOS syslog socket not found: {socket_path}")
|
|
82
|
+
return _make_syslog_handler(socket_path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _make_linux_handler() -> logging.Handler:
|
|
86
|
+
"""Return a SysLogHandler for Linux syslog / journald."""
|
|
87
|
+
# /dev/log is the POSIX standard; /run/systemd/journal/syslog is an
|
|
88
|
+
# alternative on systemd systems but /dev/log is always a symlink to it.
|
|
89
|
+
for socket_path in ("/dev/log", "/var/run/syslog"):
|
|
90
|
+
if os.path.exists(socket_path):
|
|
91
|
+
return _make_syslog_handler(socket_path)
|
|
92
|
+
raise OSError("No syslog socket found (/dev/log or /var/run/syslog)")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _make_windows_handler() -> logging.Handler:
|
|
96
|
+
"""Return an NTEventLogHandler for Windows Event Log.
|
|
97
|
+
|
|
98
|
+
Requires the ``pywin32`` package. If it is not installed, raises
|
|
99
|
+
``ImportError`` so the caller can fall back gracefully.
|
|
100
|
+
"""
|
|
101
|
+
# NTEventLogHandler lives in logging.handlers but it lazy-imports win32evtlog.
|
|
102
|
+
# We attempt to construct it; if pywin32 is missing it raises ImportError.
|
|
103
|
+
handler = logging.handlers.NTEventLogHandler(
|
|
104
|
+
appname=_NT_EVENT_SOURCE,
|
|
105
|
+
logtype="Application",
|
|
106
|
+
)
|
|
107
|
+
return handler
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _make_file_handler(
|
|
111
|
+
log_file: str,
|
|
112
|
+
log_level: int,
|
|
113
|
+
) -> logging.handlers.RotatingFileHandler:
|
|
114
|
+
"""Return a RotatingFileHandler, creating parent directories as needed."""
|
|
115
|
+
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
117
|
+
log_file,
|
|
118
|
+
maxBytes=10_485_760, # 10 MB
|
|
119
|
+
backupCount=5,
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
)
|
|
122
|
+
handler.setLevel(log_level)
|
|
123
|
+
return handler
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _make_native_handler() -> logging.Handler:
|
|
127
|
+
"""Attempt to create a native OS handler. Raises on failure."""
|
|
128
|
+
system = platform.system()
|
|
129
|
+
if system == "Darwin":
|
|
130
|
+
return _make_macos_handler()
|
|
131
|
+
if system == "Linux":
|
|
132
|
+
return _make_linux_handler()
|
|
133
|
+
if system == "Windows":
|
|
134
|
+
return _make_windows_handler()
|
|
135
|
+
raise OSError(f"No native handler available for platform: {system!r}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------ #
|
|
139
|
+
# Public API
|
|
140
|
+
# ------------------------------------------------------------------ #
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def create_os_handlers(
|
|
144
|
+
log_level: int,
|
|
145
|
+
formatter: logging.Formatter,
|
|
146
|
+
log_file: str,
|
|
147
|
+
) -> List[logging.Handler]:
|
|
148
|
+
"""Return a list of configured logging handlers.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
log_level:
|
|
153
|
+
The ``logging`` level integer (e.g. ``logging.INFO``).
|
|
154
|
+
formatter:
|
|
155
|
+
A ``logging.Formatter`` to attach to every returned handler.
|
|
156
|
+
log_file:
|
|
157
|
+
Absolute path for the rotating file handler (used when
|
|
158
|
+
``LOG_HANDLER`` is ``"file"`` or ``"both"``, or as a fallback).
|
|
159
|
+
|
|
160
|
+
Returns
|
|
161
|
+
-------
|
|
162
|
+
List[logging.Handler]
|
|
163
|
+
One or two configured handlers. Never an empty list.
|
|
164
|
+
"""
|
|
165
|
+
mode = os.environ.get("LOG_HANDLER", "os").lower().strip()
|
|
166
|
+
_logger = logging.getLogger("plesk_unified")
|
|
167
|
+
|
|
168
|
+
handlers: List[logging.Handler] = []
|
|
169
|
+
|
|
170
|
+
# ---- Native OS handler ---- #
|
|
171
|
+
if mode in ("os", "both"):
|
|
172
|
+
try:
|
|
173
|
+
native = _make_native_handler()
|
|
174
|
+
native.setLevel(log_level)
|
|
175
|
+
native.setFormatter(formatter)
|
|
176
|
+
handlers.append(native)
|
|
177
|
+
_logger.debug(
|
|
178
|
+
"Native OS logging handler active: %s on %s",
|
|
179
|
+
type(native).__name__,
|
|
180
|
+
platform.system(),
|
|
181
|
+
)
|
|
182
|
+
except Exception as exc: # ImportError (pywin32), OSError (no socket), etc.
|
|
183
|
+
_logger.warning(
|
|
184
|
+
"Native OS logging handler unavailable (%s). "
|
|
185
|
+
"Falling back to rotating file handler.",
|
|
186
|
+
exc,
|
|
187
|
+
)
|
|
188
|
+
# Fall through to always-add-file logic below
|
|
189
|
+
|
|
190
|
+
# ---- File handler ---- #
|
|
191
|
+
# Add if: explicitly requested, "both" mode, OR native handler failed.
|
|
192
|
+
want_file = mode in ("file", "both") or not handlers
|
|
193
|
+
if want_file:
|
|
194
|
+
try:
|
|
195
|
+
fh = _make_file_handler(log_file, log_level)
|
|
196
|
+
fh.setFormatter(formatter)
|
|
197
|
+
handlers.append(fh)
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
# Last resort: emit a warning to stderr and return whatever we have.
|
|
200
|
+
print(
|
|
201
|
+
f"[plesk_unified] WARNING: Could not create file log handler: {exc}",
|
|
202
|
+
file=sys.stderr,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Guarantee at least one handler is returned.
|
|
206
|
+
if not handlers:
|
|
207
|
+
null: logging.Handler = logging.NullHandler()
|
|
208
|
+
return [null]
|
|
209
|
+
return handlers
|