feat: initial iLSP project scaffolding
- Python LSP (pylsp + pylsp_i80 plugin): i80 pypi package completions - Bicep LSP (asyncio TCP proxy → Bicep.LangServer.dll): LRU module injection - Health HTTP endpoint (:2089) for Consul/Nomad checks - Startup catalog fetch from pypi-server.i80.dk + DevOpsMCP (no volume needed) - Multi-stage Dockerfile: downloads Bicep LS at build time, dotnet-runtime-8.0 + python3.12 - Nomad job: static TCP ports 2087/2088, health check on 2089 - Gitea Actions CI: build + push + deploy pipeline - Editor configs: Helix / nvim / LSP4IJ / VS Code
This commit is contained in:
94
ilsp/python_lsp/catalog.py
Normal file
94
ilsp/python_lsp/catalog.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
pypi-server.i80.dk catalog fetcher with in-memory TTL cache.
|
||||
|
||||
Fetches the package list at startup and refreshes every hour.
|
||||
No persistent storage needed — all data lives in memory.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from lsprotocol.types import CompletionItem, CompletionItemKind, MarkupContent, MarkupKind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PYPI_SIMPLE_URL = "https://pypi-server.i80.dk/simple/"
|
||||
PYPI_BASE_URL = "https://pypi-server.i80.dk"
|
||||
REFRESH_INTERVAL = 3600 # seconds
|
||||
|
||||
|
||||
class PypiCatalog:
|
||||
"""Thread-safe singleton catalog for pypi-server.i80.dk packages."""
|
||||
|
||||
_packages: list[dict[str, Any]] = []
|
||||
_last_refresh: float = 0
|
||||
_lock = asyncio.Lock()
|
||||
_refresh_task: asyncio.Task | None = None
|
||||
|
||||
@classmethod
|
||||
async def get_packages(cls) -> list[dict[str, Any]]:
|
||||
if not cls._packages or time.time() - cls._last_refresh > REFRESH_INTERVAL:
|
||||
await cls._refresh()
|
||||
return cls._packages
|
||||
|
||||
@classmethod
|
||||
async def start_background_refresh(cls) -> None:
|
||||
if cls._refresh_task is None or cls._refresh_task.done():
|
||||
cls._refresh_task = asyncio.create_task(cls._refresh_loop())
|
||||
|
||||
@classmethod
|
||||
async def _refresh_loop(cls) -> None:
|
||||
while True:
|
||||
await cls._refresh()
|
||||
await asyncio.sleep(REFRESH_INTERVAL)
|
||||
|
||||
@classmethod
|
||||
async def _refresh(cls) -> None:
|
||||
async with cls._lock:
|
||||
try:
|
||||
packages = await cls._fetch()
|
||||
cls._packages = packages
|
||||
cls._last_refresh = time.time()
|
||||
logger.info("PyPI catalog refreshed: %d packages", len(packages))
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh PyPI catalog")
|
||||
|
||||
@classmethod
|
||||
async def _fetch(cls) -> list[dict[str, Any]]:
|
||||
packages = []
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
|
||||
async with session.get(PYPI_SIMPLE_URL) as resp:
|
||||
resp.raise_for_status()
|
||||
html = await resp.text()
|
||||
|
||||
# Parse simple index HTML — each <a href="/simple/pkg-name/"> pkg-name </a>
|
||||
import re
|
||||
for match in re.finditer(r'href="[^"]+/([^/]+)/"[^>]*>([^<]+)<', html):
|
||||
pkg_name = match.group(2).strip()
|
||||
packages.append({
|
||||
"name": pkg_name,
|
||||
"label": pkg_name,
|
||||
"detail": f"i80 package — pypi-server.i80.dk",
|
||||
"sort_prefix": "0_i80_", # sorts before standard packages
|
||||
})
|
||||
|
||||
return packages
|
||||
|
||||
@classmethod
|
||||
def as_completion_items(cls) -> list[CompletionItem]:
|
||||
return [
|
||||
CompletionItem(
|
||||
label=pkg["name"],
|
||||
kind=CompletionItemKind.Module,
|
||||
detail=pkg["detail"],
|
||||
sort_text=f"{pkg['sort_prefix']}{pkg['name']}",
|
||||
documentation=MarkupContent(
|
||||
kind=MarkupKind.Markdown,
|
||||
value=f"**{pkg['name']}**\n\nCustom i80/LRU package from `pypi-server.i80.dk`",
|
||||
),
|
||||
)
|
||||
for pkg in cls._packages
|
||||
]
|
||||
Reference in New Issue
Block a user