Source code for litsync.utils
from __future__ import annotations
import contextlib
import datetime as dt
import fcntl
import gzip
import hashlib
import logging
import os
import re
import tarfile
from pathlib import Path
from typing import Optional
LOG = logging.getLogger("litsync")
CHUNK = 1 << 20 # 1 MiB
[docs]
def utcnow() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
[docs]
def new_src_stats() -> dict:
return {"new": 0, "existing": 0, "skipped": 0,
"downloaded": 0, "verified": 0, "failed": 0, "bytes": 0, "articles": 0}
[docs]
def human_bytes(n) -> str:
size = float(n or 0)
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1024:
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} PB"
[docs]
def partial_note(counted: int, files: int) -> str:
return "" if counted >= files else f" [partial: {counted}/{files} files counted]"
[docs]
def md5_file(path: Path) -> str:
h = hashlib.md5()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(CHUNK), b""):
h.update(chunk)
return h.hexdigest()
[docs]
def parse_md5(text: str) -> str:
"""NCBI .md5 sidecars look like 'MD5(file.xml.gz)= <hex>' or '<hex> file'."""
m = re.search(r"=\s*([0-9a-fA-F]{32})", text)
if m:
return m.group(1)
m = re.search(r"\b([0-9a-fA-F]{32})\b", text)
if not m:
raise ValueError(f"could not parse md5 from: {text[:120]!r}")
return m.group(1)
[docs]
@contextlib.contextmanager
def run_lock(path: Path):
"""Prevent overlapping runs via an exclusive file lock."""
path.parent.mkdir(parents=True, exist_ok=True)
fh = open(path, "w")
try:
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise SystemExit("another litsync run is already in progress; exiting")
fh.write(f"{os.getpid()} {utcnow()}\n")
fh.flush()
yield
finally:
with contextlib.suppress(Exception):
fcntl.flock(fh, fcntl.LOCK_UN)
fh.close()
[docs]
def count_articles(path: Path, source: str) -> int:
"""Cheaply count the articles/records inside one downloaded file."""
name = path.name.lower()
if source.startswith("pubmed"):
if not name.endswith(".xml.gz"):
return 0
needle = b"<PubmedArticle>"
overlap = b""
count = 0
with gzip.open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(CHUNK), b""):
buf = overlap + chunk
count += buf.count(needle)
overlap = buf[-(len(needle) - 1):]
return count
if source.startswith("pmc"):
if not name.endswith(".tar.gz"):
return 0
count = 0
with tarfile.open(path, mode="r|gz") as tar:
for member in tar:
low = member.name.lower()
if member.isfile() and (low.endswith(".xml") or low.endswith(".nxml")):
count += 1
return count
if source.startswith("fda"):
if not name.endswith(".zip"):
return 0
import json
extract_dir = path.parent / name[:-9] if name.endswith(".json.zip") else path.with_suffix("")
count = 0
for json_file in extract_dir.rglob("*.json"):
try:
data = json.loads(json_file.read_text(encoding="utf-8"))
count += len(data.get("results", []))
except Exception:
pass
return count
if source.startswith("clinicaltrials"):
if not name.endswith(".zip"):
return 0
extract_dir = path.with_suffix("")
return sum(1 for p in extract_dir.rglob("*.xml") if p.is_file())
return 0