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.
@@ -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