b38661f588
Python application / build (push) Has been cancelled
- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
262 lines
8.2 KiB
Python
262 lines
8.2 KiB
Python
"""HTTP(S) include cache: index URL → local file, never overwrite existing cache files."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse, urljoin
|
|
|
|
import requests
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
INCLUDE_CACHE_INDEX = "md2gost.include-cache.json"
|
|
INCLUDE_CACHE_DIR = "include-cache"
|
|
MAX_INCLUDE_DEPTH = 8
|
|
|
|
_HTTP_RE = re.compile(r"^https?://", re.I)
|
|
_INCLUDE_LINE_RE = re.compile(
|
|
r"^[ \t]*!(?:includeurl|include)[ \t]+(?P<q>[\"']?)(?P<ref>[^\s\"']+)(?P=q)",
|
|
re.I | re.M,
|
|
)
|
|
|
|
|
|
def app_dir() -> Path:
|
|
"""Directory next to the running application (exe or cwd for python -m)."""
|
|
if getattr(sys, "frozen", False):
|
|
return Path(sys.executable).resolve().parent
|
|
return Path.cwd()
|
|
|
|
|
|
def include_cache_index_path(base: Path | None = None) -> Path:
|
|
return (base or app_dir()) / INCLUDE_CACHE_INDEX
|
|
|
|
|
|
def include_cache_files_dir(base: Path | None = None) -> Path:
|
|
return (base or app_dir()) / INCLUDE_CACHE_DIR
|
|
|
|
|
|
def is_http_url(value: str) -> bool:
|
|
return bool(_HTTP_RE.match((value or "").strip()))
|
|
|
|
|
|
def _load_index(path: Path) -> dict[str, str]:
|
|
if not path.is_file():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
_log.warning("Не удалось прочитать индекс кэша includes: %s", exc)
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
for key, val in data.items():
|
|
if isinstance(key, str) and isinstance(val, str) and is_http_url(key):
|
|
out[key] = val
|
|
return out
|
|
|
|
|
|
def _save_index(path: Path, index: dict[str, str]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps(index, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _unique_cache_path(url: str, files_dir: Path) -> Path:
|
|
"""New file path for URL; never reuse/overwrite an existing cache file."""
|
|
files_dir.mkdir(parents=True, exist_ok=True)
|
|
parsed = urlparse(url)
|
|
base = Path(parsed.path).name or "include.puml"
|
|
if not base.lower().endswith((".puml", ".iuml", ".pu", ".txt")):
|
|
base = base + ".puml" if "." not in base else base
|
|
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:10]
|
|
stem = Path(base).stem
|
|
suffix = Path(base).suffix or ".puml"
|
|
# sanitize stem
|
|
safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", stem)[:40] or "include"
|
|
candidate = files_dir / f"{safe}_{digest}{suffix}"
|
|
if not candidate.exists():
|
|
return candidate
|
|
n = 1
|
|
while True:
|
|
alt = files_dir / f"{safe}_{digest}_{n}{suffix}"
|
|
if not alt.exists():
|
|
return alt
|
|
n += 1
|
|
|
|
|
|
def _download(url: str, dest: Path) -> None:
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
try:
|
|
resp = requests.get(url, timeout=60)
|
|
resp.raise_for_status()
|
|
tmp.write_bytes(resp.content)
|
|
tmp.replace(dest)
|
|
except Exception:
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def resolve_url(
|
|
url: str,
|
|
*,
|
|
base: Path | None = None,
|
|
depth: int = 0,
|
|
) -> Path:
|
|
"""
|
|
Return local path for URL. Uses index if present and file exists;
|
|
otherwise downloads to a *new* file and records the mapping.
|
|
Never overwrites an existing cache file for a different URL.
|
|
"""
|
|
url = url.strip()
|
|
if not is_http_url(url):
|
|
raise ValueError(f"Не URL: {url}")
|
|
if depth > MAX_INCLUDE_DEPTH:
|
|
raise RuntimeError(f"Слишком глубокая цепочка includes ({MAX_INCLUDE_DEPTH}): {url}")
|
|
|
|
root = base or app_dir()
|
|
index_path = include_cache_index_path(root)
|
|
files_dir = include_cache_files_dir(root)
|
|
index = _load_index(index_path)
|
|
|
|
cached = index.get(url)
|
|
if cached:
|
|
path = Path(cached)
|
|
if path.is_file():
|
|
_rewrite_nested_includes(path, parent_url=url, base=root, depth=depth)
|
|
return path
|
|
_log.warning("Файл кэша пропал (%s), скачаю заново: %s", path, url)
|
|
|
|
dest = _unique_cache_path(url, files_dir)
|
|
try:
|
|
_download(url, dest)
|
|
except Exception as exc:
|
|
raise RuntimeError(
|
|
f"Не удалось скачать include {url}: {exc}. "
|
|
"Нет сети и нет кэша — положите файл вручную или проверьте URL."
|
|
) from exc
|
|
|
|
index[url] = str(dest.resolve())
|
|
_save_index(index_path, index)
|
|
_log.info("Include cached: %s → %s", url, dest)
|
|
_rewrite_nested_includes(dest, parent_url=url, base=root, depth=depth)
|
|
return dest
|
|
|
|
|
|
def _absolute_include_url(ref: str, parent_url: str | None) -> str | None:
|
|
"""Turn include ref into absolute http(s) URL, or None if local/stdlib."""
|
|
ref = ref.strip()
|
|
if is_http_url(ref):
|
|
return ref
|
|
if ref.startswith("<") and ref.endswith(">"):
|
|
return None # PlantUML stdlib
|
|
if parent_url and not Path(ref).is_absolute():
|
|
# relative to parent URL directory
|
|
return urljoin(parent_url, ref)
|
|
return None
|
|
|
|
|
|
def _rewrite_nested_includes(
|
|
path: Path, *, parent_url: str, base: Path, depth: int,
|
|
) -> None:
|
|
"""Rewrite http(s) / relative !include inside a cached .puml to local paths."""
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return
|
|
|
|
changed = False
|
|
|
|
def repl(match: re.Match[str]) -> str:
|
|
nonlocal changed
|
|
ref = match.group("ref")
|
|
abs_url = _absolute_include_url(ref, parent_url)
|
|
if not abs_url:
|
|
return match.group(0)
|
|
local = resolve_url(abs_url, base=base, depth=depth + 1)
|
|
changed = True
|
|
local_s = str(local.resolve()).replace("\\", "/")
|
|
return f"!include {local_s}"
|
|
|
|
new_text = _INCLUDE_LINE_RE.sub(repl, text)
|
|
if changed and new_text != text:
|
|
path.write_text(new_text, encoding="utf-8")
|
|
|
|
|
|
def resolve_include_ref(
|
|
ref: str,
|
|
*,
|
|
base: Path | None = None,
|
|
schemes_dir: Path | None = None,
|
|
) -> str:
|
|
"""
|
|
Resolve include entry to a local path string suitable for !include.
|
|
Local paths are resolved relative to schemes_dir / diagrams / cwd.
|
|
"""
|
|
ref = (ref or "").strip()
|
|
if not ref:
|
|
raise ValueError("Пустой include")
|
|
if is_http_url(ref):
|
|
return str(resolve_url(ref, base=base).resolve()).replace("\\", "/")
|
|
|
|
path = Path(ref)
|
|
if path.is_file():
|
|
return str(path.resolve()).replace("\\", "/")
|
|
candidates: list[Path] = []
|
|
if schemes_dir:
|
|
candidates.append(schemes_dir / ref)
|
|
from . import package_dir
|
|
candidates.append(Path(package_dir()) / "diagrams" / ref)
|
|
candidates.append(Path.cwd() / ref)
|
|
for cand in candidates:
|
|
if cand.is_file():
|
|
return str(cand.resolve()).replace("\\", "/")
|
|
raise FileNotFoundError(f"Include не найден: {ref}")
|
|
|
|
|
|
def rewrite_http_includes_in_source(source: str, *, base: Path | None = None) -> str:
|
|
"""Replace http(s) !include / !includeurl in prepared source with cached local paths."""
|
|
|
|
def repl(match: re.Match[str]) -> str:
|
|
ref = match.group("ref")
|
|
if not is_http_url(ref):
|
|
return match.group(0)
|
|
local = str(resolve_url(ref, base=base).resolve()).replace("\\", "/")
|
|
return f"!include {local}"
|
|
|
|
return _INCLUDE_LINE_RE.sub(repl, source)
|
|
|
|
|
|
def reset_include_cache(base: Path | None = None) -> int:
|
|
"""
|
|
Delete index and only files listed in it. Returns number of files removed.
|
|
Other files in include-cache/ are left untouched.
|
|
"""
|
|
root = base or app_dir()
|
|
index_path = include_cache_index_path(root)
|
|
index = _load_index(index_path)
|
|
removed = 0
|
|
for _url, path_s in list(index.items()):
|
|
path = Path(path_s)
|
|
try:
|
|
if path.is_file():
|
|
path.unlink()
|
|
removed += 1
|
|
except OSError as exc:
|
|
_log.warning("Не удалось удалить %s: %s", path, exc)
|
|
if index_path.is_file():
|
|
index_path.unlink(missing_ok=True)
|
|
return removed
|
|
|
|
|
|
def cache_entry_count(base: Path | None = None) -> int:
|
|
return len(_load_index(include_cache_index_path(base or app_dir())))
|