quickshell-mcp 1.4.1__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.
- quickshell_mcp/__init__.py +3 -0
- quickshell_mcp/__main__.py +4 -0
- quickshell_mcp/caches.py +96 -0
- quickshell_mcp/config.py +40 -0
- quickshell_mcp/extraction.py +31 -0
- quickshell_mcp/server.py +586 -0
- quickshell_mcp/sources/__init__.py +1 -0
- quickshell_mcp/sources/docs.py +238 -0
- quickshell_mcp/sources/examples.py +78 -0
- quickshell_mcp/sources/explain_error.py +608 -0
- quickshell_mcp/sources/find_pattern.py +261 -0
- quickshell_mcp/sources/implementations.py +345 -0
- quickshell_mcp/sources/qt_docs.py +171 -0
- quickshell_mcp/sources/search_all.py +337 -0
- quickshell_mcp/sources/validate.py +1442 -0
- quickshell_mcp/utils.py +70 -0
- quickshell_mcp/versions.py +74 -0
- quickshell_mcp-1.4.1.dist-info/METADATA +189 -0
- quickshell_mcp-1.4.1.dist-info/RECORD +22 -0
- quickshell_mcp-1.4.1.dist-info/WHEEL +4 -0
- quickshell_mcp-1.4.1.dist-info/entry_points.txt +2 -0
- quickshell_mcp-1.4.1.dist-info/licenses/LICENSE +21 -0
quickshell_mcp/caches.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Caching: a 30-minute in-memory layer for every fetch, plus a disk layer
|
|
2
|
+
that keeps the expensive bulk doc indexes (guide/type/qt) warm across server
|
|
3
|
+
restarts. Raw HTML is never persisted; disk holds only the built indexes."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import _CACHE_TTL
|
|
13
|
+
|
|
14
|
+
_log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_DEFAULT_DISK_TTL_HOURS = 720 # 30 days; published versions are effectively frozen
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class _CacheEntry:
|
|
21
|
+
value: object
|
|
22
|
+
ts: float = field(default_factory=time.time)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_cache: dict[str, _CacheEntry] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cache_get(key: str):
|
|
29
|
+
entry = _cache.get(key)
|
|
30
|
+
if entry and (time.time() - entry.ts) < _CACHE_TTL:
|
|
31
|
+
return entry.value
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cache_set(key: str, value: object):
|
|
36
|
+
_cache[key] = _CacheEntry(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _disk_dir() -> Path | None:
|
|
40
|
+
"""Cache root, or None when disabled via QUICKSHELL_DOCS_MCP_DISK_CACHE.
|
|
41
|
+
Read per call so tests can redirect it without re-importing."""
|
|
42
|
+
override = os.environ.get("QUICKSHELL_DOCS_MCP_DISK_CACHE", "").strip()
|
|
43
|
+
if override.lower() in ("off", "0", "false"):
|
|
44
|
+
return None
|
|
45
|
+
if override:
|
|
46
|
+
return Path(override)
|
|
47
|
+
xdg = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
48
|
+
return Path(xdg) / "quickshell-mcp"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _disk_ttl_seconds() -> float:
|
|
52
|
+
raw = os.environ.get("QUICKSHELL_DOCS_MCP_DISK_TTL_HOURS", "").strip()
|
|
53
|
+
try:
|
|
54
|
+
hours = float(raw) if raw else _DEFAULT_DISK_TTL_HOURS
|
|
55
|
+
except ValueError:
|
|
56
|
+
log_warning = logging.getLogger("quickshell-mcp")
|
|
57
|
+
log_warning.warning("invalid QUICKSHELL_DOCS_MCP_DISK_TTL_HOURS=%r; using default", raw)
|
|
58
|
+
hours = _DEFAULT_DISK_TTL_HOURS
|
|
59
|
+
return max(hours, 0.0) * 3600
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _disk_path(key: str) -> Path | None:
|
|
63
|
+
directory = _disk_dir()
|
|
64
|
+
if directory is None:
|
|
65
|
+
return None
|
|
66
|
+
# ':' would be legal on Linux but smells like a Windows portability trap.
|
|
67
|
+
return directory / (key.replace(":", "_") + ".json")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _disk_get(key: str) -> object | None:
|
|
71
|
+
path = _disk_path(key)
|
|
72
|
+
if path is None:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
envelope = json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
except (OSError, ValueError):
|
|
77
|
+
return None
|
|
78
|
+
saved_at = envelope.get("saved_at", 0)
|
|
79
|
+
if not isinstance(saved_at, (int, float)):
|
|
80
|
+
return None
|
|
81
|
+
if time.time() - saved_at > _disk_ttl_seconds():
|
|
82
|
+
return None
|
|
83
|
+
return envelope.get("value")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _disk_set(key: str, value: object) -> None:
|
|
87
|
+
path = _disk_path(key)
|
|
88
|
+
if path is None:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
envelope = {"saved_at": time.time(), "value": value}
|
|
93
|
+
path.write_text(json.dumps(envelope), encoding="utf-8")
|
|
94
|
+
except OSError as error:
|
|
95
|
+
# Disk cache is an optimization only; never fail a request over it.
|
|
96
|
+
_log.warning("disk cache write failed for %s: %s", key, error)
|
quickshell_mcp/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Upstream locations, identities, and tuning constants for every source."""
|
|
2
|
+
|
|
3
|
+
BASE = "https://quickshell.org"
|
|
4
|
+
QT_DOCS_BASE = "https://doc.qt.io/qt-6"
|
|
5
|
+
EXAMPLES_REPO_WEB = "https://git.outfoxxed.me/quickshell/quickshell-examples"
|
|
6
|
+
EXAMPLES_REPO_API = "https://git.outfoxxed.me/api/v1/repos/quickshell/quickshell-examples"
|
|
7
|
+
|
|
8
|
+
USER_AGENT = "quickshell-mcp/0.1 (+https://github.com/; contact: local use)"
|
|
9
|
+
|
|
10
|
+
_CACHE_TTL = 60 * 30 # 30 minutes; long enough to be polite, short enough to track site updates
|
|
11
|
+
|
|
12
|
+
_RETRY_ATTEMPTS = 3
|
|
13
|
+
|
|
14
|
+
# Version numbers are vX.Y.Z today but tolerate prerelease suffixes (v0.4.0-rc1)
|
|
15
|
+
# so "latest" doesn't silently lag behind when the site publishes one.
|
|
16
|
+
_VERSION_NUM = r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)*"
|
|
17
|
+
|
|
18
|
+
# doc.qt.io hosts its page body inside <article class="b-sidebar__content...">,
|
|
19
|
+
# so the generic [class*=sidebar] strip rule would delete the actual content.
|
|
20
|
+
_STRIP_SELECTORS = [
|
|
21
|
+
"nav",
|
|
22
|
+
"header",
|
|
23
|
+
"footer",
|
|
24
|
+
"aside",
|
|
25
|
+
"script",
|
|
26
|
+
"style",
|
|
27
|
+
"[class*=sidebar]",
|
|
28
|
+
"[class*=toc]",
|
|
29
|
+
"[class*=search]",
|
|
30
|
+
"[class*=version-switch]",
|
|
31
|
+
"[class*=breadcrumb]",
|
|
32
|
+
]
|
|
33
|
+
_QT_STRIP_SELECTORS = ["script", "style"]
|
|
34
|
+
|
|
35
|
+
IMPLEMENTATION_REPOS: dict[str, dict[str, str]] = {
|
|
36
|
+
"caelestia": {"owner": "caelestia-dots", "repo": "shell"},
|
|
37
|
+
# The Quickshell shell lives on the legacy-v4 branch; noctalia's main
|
|
38
|
+
# branch moved to a C++ compositor project, so the ref is pinned here.
|
|
39
|
+
"noctalia": {"owner": "noctalia-dev", "repo": "noctalia", "branch": "legacy-v4"},
|
|
40
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""HTML -> Markdown extraction, with per-source strip rules."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from bs4 import BeautifulSoup
|
|
6
|
+
from markdownify import markdownify as html_to_md
|
|
7
|
+
|
|
8
|
+
from . import utils
|
|
9
|
+
from .config import _QT_STRIP_SELECTORS, _STRIP_SELECTORS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _extract_main_content(html: str, strip: list[str] | None = None) -> str:
|
|
13
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
14
|
+
|
|
15
|
+
for selector in strip if strip is not None else _STRIP_SELECTORS:
|
|
16
|
+
for tag in soup.select(selector):
|
|
17
|
+
tag.decompose()
|
|
18
|
+
|
|
19
|
+
main = soup.find("main") or soup.find("article") or soup.body or soup
|
|
20
|
+
markdown = html_to_md(str(main), heading_style="ATX", bullets="-")
|
|
21
|
+
# Collapse excessive blank lines left over from stripped nav junk.
|
|
22
|
+
markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip()
|
|
23
|
+
return markdown
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fetch_page_markdown(url: str) -> str:
|
|
27
|
+
return _extract_main_content(utils._fetch_raw(url))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fetch_qt_page_markdown(url: str) -> str:
|
|
31
|
+
return _extract_main_content(utils._fetch_raw(url), strip=_QT_STRIP_SELECTORS)
|