diff --git a/docs/MAPS.md b/docs/MAPS.md index 79959ac..9f4d107 100644 --- a/docs/MAPS.md +++ b/docs/MAPS.md @@ -23,6 +23,7 @@ docker compose -f docker-compose.maps.yml logs -f maps | Variante haute densité | `/tiles/{couche}/{z}/{x}/{y}@2x.webp` — 512 px (interface interne) | | Zooms | 5 → 19 natif (0,2 m/px ≈ z19 en France) ; au-delà, sur-zoom côté client | | Hors emprise | tuile entièrement transparente (superposable), en-tête `X-Tile-Empty: 1` | +| En attente (cache seule) | tuile transparente, en-têtes `X-Tile-Empty: 1` + `X-Tile-Pending: 1`, jamais mémorisée par le navigateur | | CORS | `Access-Control-Allow-Origin: *` sur `/tiles/*` | | Attribution | `LIDAR_ATTRIBUTION`, défaut « LiDAR HD © IGN — Licence Ouverte 2.0 » | @@ -164,8 +165,7 @@ Le service est borné à chaque étage, rien n'est illimité : Les requêtes en excès attendent le sémaphore **avant** tout décodage : elles ne consomment ni CPU ni mémoire. Un navigateur en HTTP/1.1 n'ouvre de toute façon -que 6 connexions par origine, toutes couches confondues ; derrière un proxy -HTTP/2 ce plafond disparaît et seuls ces réglages tiennent la charge. +que 6 connexions par origine, toutes couches confondues ; derrière un proxy HTTP/2 ce plafond disparaît et seuls ces réglages tiennent la charge. Le pré-chauffage (`/api/map/warm`) est séquentiel : il ne peut pas saturer la machine, seulement prendre du temps. @@ -173,6 +173,47 @@ machine, seulement prendre du temps. Sur un Raspberry Pi 2 Go, `LIDAR_TILE_WORKERS=1` et `LIDAR_TILE_SOURCE_CACHE_MB=64` restent confortables. +## Cache seule + maintenance de fond (petit Raspberry Pi) + +Sur une machine qui ne doit jamais calculer au fil de la navigation (un Pi qui +tient aussi d'autres services), deux variables inversent la charge : + +```yaml +environment: + - LIDAR_TILE_CACHE_ONLY=1 # la navigation ne rend plus rien + - LIDAR_TILE_BACKGROUND=1 # une tâche de fond entretient la pyramide +``` + +- **`LIDAR_TILE_CACHE_ONLY=1`** — une tuile absente ou périmée est servie + transparente avec `X-Tile-Pending: 1` et `Cache-Control: no-store` (le + navigateur la redemande : dès que la maintenance l'a rendue, elle apparaît). + Plus aucun rendu, ni rapatriement amont, sur le chemin des requêtes. +- **`LIDAR_TILE_BACKGROUND=1`** — un sondeur rescane les dalles à intervalle + régulier (`LIDAR_TILE_BACKGROUND_INTERVAL`, 120 s) ; chaque dalle nouvelle ou + régénérée (le worker vient de produire, le cache à la demande vient de + rapatrier) met sa pyramide en file d'attente. Des rendeurs à basse priorité + (`os.nice`) la vident au rythme d'une tuile par + `LIDAR_TILE_BACKGROUND_PAUSE` seconde (1 s). Premier démarrage : TOUTES les + dalles sont inconnues, la pyramide se reconstruit entière — les tuiles déjà + fraîches ne coûtent qu'un `stat`. Les niveaux partent en file du plus petit + zoom au plus grand : la carte se remplit d'abord grossièrement. + +| Variable | Défaut | Rôle | +|---|---|---| +| `LIDAR_TILE_BACKGROUND_MAX_Z` | `16` | niveau maximal entretenu en fond (le natif 17–19 reste au pré-chauffage manuel) | +| `LIDAR_TILE_BACKGROUND_SCALE` | `2` | tuiles entretenues : 2 = 512 px (celles de l'interface) | +| `LIDAR_TILE_BACKGROUND_FMT` | `webp` | format des tuiles entretenues (celui de l'interface) | +| `LIDAR_TILE_BACKGROUND_PAUSE` | `1.0` | pause (s) entre deux rendus — le levier de la discrétion | +| `LIDAR_TILE_BACKGROUND_INTERVAL` | `120` | secondes entre deux scans des dalles | +| `LIDAR_TILE_BACKGROUND_QUEUE_MAX` | `4096` | file d'attente bornée (au-delà : ignoré jusqu'au prochain scan) | + +Pilotage : `GET /api/map/background` (état, compteurs, file), `POST +/api/map/background` (scan immédiat), `POST /api/map/warm` (pré-calcul manuel +exhaustif, tous zooms/formats, cf. ci-dessous). Enfin, plafonner le conteneur +lui-même (`mem_limit` + `memswap_limit` dans un override compose) garantit +qu'un rendu déréglé ne peut plus emporter la machine : le tueur OOM ne +toucherait que `lidar-maps`. + ## Pré-chauffage ```bash diff --git a/lidar_pipeline/mapserve.py b/lidar_pipeline/mapserve.py index d28db48..095f7b6 100644 --- a/lidar_pipeline/mapserve.py +++ b/lidar_pipeline/mapserve.py @@ -18,6 +18,19 @@ Les tuiles sont rendues à la demande puis mises en cache sur disque (`output/index_xyz/`). En mode deux machines, `LIDAR_MAPS_URL` désigne un serveur amont : une tuile absente localement y est rapatriée (quelques dizaines de Ko) au lieu de rapatrier les dalles entières. + +Sur une petite machine (Raspberry Pi), deux réglages inversent la charge : + +- `LIDAR_TILE_CACHE_ONLY=1` — la navigation ne rend plus RIEN : une tuile + absente est servie transparente (`X-Tile-Pending: 1`, jamais mémorisée par + le navigateur) et apparaît après passage de la maintenance ; +- `LIDAR_TILE_BACKGROUND=1` — une tâche de fond surveille les dalles + (nouvelles ou régénérées) et met leur pyramide à jour en file d'attente, + un rendu à la fois, à basse priorité (`os.nice`) et cadencé + (`LIDAR_TILE_BACKGROUND_PAUSE`). Premier démarrage = reconstruction de + toute la pyramide jusqu'à `LIDAR_TILE_BACKGROUND_MAX_Z` (les tuiles déjà + fraîches ne coûtent qu'un `stat`). `POST /api/map/background` force un scan, + `POST /api/map/warm` reste le pré-calcul manuel exhaustif. """ import json @@ -30,6 +43,9 @@ import time import urllib.parse import urllib.request import xml.sax.saxutils as _xml +from collections import deque +from contextlib import asynccontextmanager +from functools import lru_cache from pathlib import Path from typing import Optional @@ -59,6 +75,201 @@ ATTRIBUTION_TEXT = re.sub(r"<[^>]+>", "", ATTRIBUTION).replace("©", "©").r TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2)) _render_sem = threading.Semaphore(TILE_WORKERS) + +# --------------------------------------------------------------------------- +# Maintenance de la pyramide en tâche de fond (petites machines) +# --------------------------------------------------------------------------- +# +# `LIDAR_TILE_BACKGROUND=1` + `LIDAR_TILE_CACHE_ONLY=1` inversent la charge : +# la navigation ne rend plus rien (lecture cache seule), une sondeur rescane +# les dalles à intervalle régulier et chaque dalle nouvelle/régénérée met sa +# pyramide de tuiles en file ; des rendeurs à basse priorité (os.nice) la +# vident au rythme d'une tuile par LIDAR_TILE_BACKGROUND_PAUSE secondes. +# Premier passage = dalle inconnue : toute la pyramide est reconstruite (les +# tuiles déjà fraîches ne coûtent qu'un stat). + +def _env_flag(name): + """Indicateur d'environnement (1/true/yes/on/oui). Relu à chaque appel : + testable sans recharger le module.""" + return (os.environ.get(name, "").strip().lower() + in ("1", "true", "yes", "on", "oui")) + + +def _bg_conf(): + """Réglages de la maintenance (relus au démarrage des fils, cf. doc).""" + return { + "interval": max(10.0, float(os.environ.get( + "LIDAR_TILE_BACKGROUND_INTERVAL", "120") or 120)), + "max_z": max(tiles_mod.TILE_MIN_Z, int(os.environ.get( + "LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)), + "scale": 1 if (os.environ.get( + "LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2, + "fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "webp") + or "webp").strip(), + "pause": max(0.0, float(os.environ.get( + "LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)), + "queue_max": max(64, int(os.environ.get( + "LIDAR_TILE_BACKGROUND_QUEUE_MAX", "4096") or 4096)), + } + + +_bg = {"threads": [], "stop": threading.Event(), "queue": deque(), + "queued": set(), "guard": threading.Lock(), "snapshot": None, + "running": False, + "stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0, + "dernier_scan": None, "ajouts_dernier_scan": 0}} + + +def _bg_enqueue(items): + """Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée.""" + added = 0 + with _bg["guard"]: + for item in items: + if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]: + continue + _bg["queue"].append(item) + _bg["queued"].add(item) + added += 1 + return added + + +def _bg_pop(): + with _bg["guard"]: + if _bg["queue"]: + item = _bg["queue"].popleft() + _bg["queued"].discard(item) + return item + return None + + +def background_scan(output_dir=None): + """Détecte les dalles nouvelles ou régénérées, met leurs tuiles en file. + + Retourne le nombre de tuiles ajoutées. La comparaison se fait sur le + mtime le plus récent des sources de chaque dalle : une dalle régénérée + n'invalide que ses propres tuiles (même règle que le cache). + """ + output_dir = Path(output_dir or OUTPUT_DIR) + conf = _bg_conf() + snapshot = {} + changed = {} + for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items(): + for (col, row), tiers in per_cell.items(): + newest = 0.0 + for group in tiers: + for src in group: + m = src.mtime() + if m and m > newest: + newest = m + snapshot[(layer, col, row)] = newest + if (_bg["snapshot"] is not None + and _bg["snapshot"].get((layer, col, row)) == newest): + continue + changed.setdefault(layer, []).append((col, row)) + _bg["snapshot"] = snapshot + items = [] + for layer, cells in changed.items(): + for col, row in cells: + bbox = tiles_mod.cell_bounds_wgs84(col, row) + for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1): + if not tiles_mod.zoom_supported(z, conf["scale"]): + continue + for x, y in tiles_mod.tiles_in_bounds(bbox, z): + items.append((layer, z, x, y)) + items.sort(key=lambda t: (t[1], t[0], t[2], t[3])) # bas niveaux d'abord + added = _bg_enqueue(items) + _bg["stats"]["scans"] += 1 + _bg["stats"]["dernier_scan"] = time.time() + _bg["stats"]["ajouts_dernier_scan"] = added + cells_changed = sum(len(v) for v in changed.values()) + if items: + logger.info(f"Maintenance des tuiles : {cells_changed} dalle(s) " + f"modifiée(s), {added} tuile(s) en file") + return added + + +def _bg_process_one(): + """Traite une tuile de la file. Vrai si elle a dû être rendue.""" + item = _bg_pop() + if item is None: + return False + layer, z, x, y = item + conf = _bg_conf() + _data, state = tiles_mod.cached_tile(OUTPUT_DIR, layer, z, x, y, + conf["scale"], conf["fmt"]) + if state != "pending": + _bg["stats"]["a_jour" if state == "fresh" else "vides"] += 1 + return False + tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"]) + _bg["stats"]["rendues"] += 1 + return True + + +def _bg_worker(): + try: + os.nice(10) # consommation minimale : le rendu cède aux autres usages + except (AttributeError, OSError): # pragma: no cover — nice indisponible + pass + while not _bg["stop"].is_set(): + try: + rendered = _bg_process_one() + except Exception as e: # noqa: BLE001 — une tuile n'arrête pas la file + logger.warning(f"Maintenance des tuiles : rendu impossible ({e})") + rendered = False + if rendered: + _bg["stop"].wait(_bg_conf()["pause"]) # cadence : un rendu, une pause + else: + _bg["stop"].wait(0.5) + + +def _bg_poller(): + while not _bg["stop"].is_set(): + try: + background_scan() + except Exception as e: # noqa: BLE001 — prochain scan dans INTERVAL + logger.warning(f"Maintenance des tuiles : scan impossible ({e})") + _bg["stop"].wait(_bg_conf()["interval"]) + + +def start_background(): + """Démarre la maintenance : 1 sondeur + TILE_WORKERS rendeurs cadencés.""" + if _bg["running"]: + return + _bg["stop"].clear() + _bg["running"] = True + threads = [threading.Thread(target=_bg_poller, daemon=True, + name="lidar-bg-scan")] + threads += [threading.Thread(target=_bg_worker, daemon=True, + name=f"lidar-bg-render-{i}") + for i in range(TILE_WORKERS)] + for t in threads: + t.start() + _bg["threads"] = threads + conf = _bg_conf() + logger.info(f"Maintenance des tuiles active : z ≤ {conf['max_z']} " + f"@{conf['scale']}x {conf['fmt']}, scan toutes les " + f"{conf['interval']:.0f} s, pause {conf['pause']:.1f} s " + f"entre rendus") + + +def stop_background(): + _bg["stop"].set() + _bg["running"] = False + + +@lru_cache(maxsize=8) +def _transparent_tile(scale=1, fmt="png"): + return tiles_mod.transparent_tile(scale, fmt) + + +@asynccontextmanager +async def _lifespan(_app): + if _env_flag("LIDAR_TILE_BACKGROUND"): + start_background() + yield + stop_background() + + _LAYER_RE = re.compile(r"^[a-z0-9_]{1,40}$") _NAME_RE = re.compile(r"^(\d+)(@2x)?\.(png|webp|avif)$") _MAX_UPSTREAM_BYTES = 8 * 1024 * 1024 @@ -70,7 +281,7 @@ if not logger.handlers: logger.addHandler(_h) logger.setLevel(logging.INFO) -app = FastAPI(title="Carte LiDAR — tuiles XYZ") +app = FastAPI(title="Carte LiDAR — tuiles XYZ", lifespan=_lifespan) # Assets de l'interface : bâchés dans l'image au build, régénérés ici en repli # (dev, paquet installé sans bake). @@ -255,14 +466,28 @@ async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None): if not (0 <= x < n and 0 <= y < n): raise HTTPException(404, "tuile hors grille") - data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt) - empty = data is None - if empty: - data = tiles_mod.transparent_tile(scale, fmt) + if _env_flag("LIDAR_TILE_CACHE_ONLY"): + # Mode cache seule : aucune navigation ne déclenche de rendu (ni amont). + data, state = await run_in_threadpool( + tiles_mod.cached_tile, OUTPUT_DIR, layer, z, x, y, scale, fmt) + pending = state == "pending" + empty = state == "empty" + else: + data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt) + pending = False + empty = data is None + if empty or pending: + data = _transparent_tile(scale, fmt) headers = dict(_cors_headers()) - headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v - else "public, max-age=300, must-revalidate") - headers["X-Tile-Empty"] = "1" if empty else "0" + if pending: + # Tuile à venir (rendue par la maintenance) : jamais mémorisée par le + # navigateur, la prochaine navigation la redemande et la trouve prête. + headers["Cache-Control"] = "no-store" + headers["X-Tile-Pending"] = "1" + else: + headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v + else "public, max-age=300, must-revalidate") + headers["X-Tile-Empty"] = "1" if (empty or pending) else "0" return Response(content=data, media_type=_MEDIA[fmt], headers=headers) @@ -586,6 +811,24 @@ def map_warm_status(): return dict(_warm) +@app.get("/api/map/background") +def background_status(): + """État de la maintenance de fond : file, compteurs, configuration.""" + with _bg["guard"]: + queued = len(_bg["queue"]) + return {"active": _bg["running"], "en_file": queued, + "configuration": _bg_conf(), "stats": dict(_bg["stats"])} + + +@app.post("/api/map/background") +def background_trigger(): + """Force un scan immédiat des dalles (rendu ensuite en tâche de fond).""" + if not _env_flag("LIDAR_TILE_BACKGROUND"): + raise HTTPException(409, "maintenance de fond inactive " + "(LIDAR_TILE_BACKGROUND)") + return {"tuiles_en_file": background_scan()} + + @app.get("/healthz") def healthz(): return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))} diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index fcbbdf4..0ade8c1 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -530,3 +530,68 @@ def test_worker_limits_are_configurable(monkeypatch): monkeypatch.delenv("LIDAR_TILE_FETCH_WORKERS", raising=False) importlib.reload(tiles) importlib.reload(mapserve) + + +def _reset_bg(mapserve): + mapserve._bg["queue"].clear() + mapserve._bg["queued"].clear() + mapserve._bg["snapshot"] = None + mapserve._bg["stats"].update(rendues=0, a_jour=0, vides=0, scans=0, + dernier_scan=None, ajouts_dernier_scan=0) + + +def test_tile_cache_only_never_renders(tmp_path, monkeypatch): + """Mode cache seule : tuile manquante = transparente non mémorisable.""" + import lidar_pipeline.mapserve as mapserve + from lidar_pipeline import tiles + _make_dalle(tmp_path, 1054, 6882, ["slope"]) + monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path) + tiles.source_index(tmp_path, force=True) + monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1") + z = 15 + x, y = _tile_of_cell(1054, 6882, z) + resp = asyncio.run(mapserve.tile("slope", z, x, f"{y}.png")) + assert resp.headers["X-Tile-Pending"] == "1" + assert resp.headers["X-Tile-Empty"] == "1" + assert resp.headers["Cache-Control"] == "no-store" + assert not tiles.tile_cache_path(tmp_path, "slope", z, x, y).exists() + # Une fois rendue par ailleurs : servie depuis le cache, mémorisable + tiles.get_tile(tmp_path, "slope", z, x, y) + resp = asyncio.run(mapserve.tile("slope", z, x, f"{y}.png", v="s")) + assert resp.headers["X-Tile-Empty"] == "0" + assert "X-Tile-Pending" not in resp.headers + assert "immutable" in resp.headers["Cache-Control"] + + +def test_background_scan_detects_and_fills(tmp_path, monkeypatch): + """La maintenance détecte les dalles, remplit la pyramide, puis se tait.""" + import lidar_pipeline.mapserve as mapserve + from lidar_pipeline import tiles + _make_dalle(tmp_path, 1054, 6882, ["slope"]) + monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path) + monkeypatch.setenv("LIDAR_TILE_BACKGROUND_MAX_Z", "14") + monkeypatch.setenv("LIDAR_TILE_BACKGROUND_SCALE", "1") + monkeypatch.setenv("LIDAR_TILE_BACKGROUND_FMT", "png") + monkeypatch.setenv("LIDAR_TILE_BACKGROUND_PAUSE", "0") + _reset_bg(mapserve) + try: + # Premier scan : tout est nouveau, bas niveaux en tête de file + assert mapserve.background_scan(tmp_path) > 0 + zs = [t[1] for t in mapserve._bg["queue"]] + assert zs == sorted(zs) and max(zs) <= 14 + processed = 0 + while mapserve._bg["queue"] and processed < 1000: + mapserve._bg_process_one() + processed += 1 + assert not mapserve._bg["queue"] + assert mapserve._bg["stats"]["rendues"] > 0 + x, y = _tile_of_cell(1054, 6882, 14) + assert tiles.tile_cache_path( + tmp_path, "slope", 14, x, y, 1, "png").exists() + # Rien n'a changé : plus aucun ajout + assert mapserve.background_scan(tmp_path) == 0 + # Une dalle nouvelle (worker) : sa pyramide repart seule en file + _make_dalle(tmp_path, 1055, 6882, ["slope"]) + assert mapserve.background_scan(tmp_path) > 0 + finally: + _reset_bg(mapserve) diff --git a/lidar_pipeline/tests/test_tiles.py b/lidar_pipeline/tests/test_tiles.py index a7dc6ec..31acb62 100644 --- a/lidar_pipeline/tests/test_tiles.py +++ b/lidar_pipeline/tests/test_tiles.py @@ -487,3 +487,20 @@ def test_remote_index_failure_keeps_local(tmp_path, monkeypatch): monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: None) layers = tiles.source_index(tmp_path, force=True) assert set(layers) == {"slope"} + + +def test_cached_tile_states(tmp_path): + """`cached_tile` : lecture cache seule — fresh / pending / empty, sans rendu.""" + from lidar_pipeline import tiles + _make_dalle(tmp_path, 1054, 6882, ["slope"]) + x, y = _tile_of_cell(1054, 6882, 14) + data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y) + assert state == "pending" and data is None + assert not tiles.tile_cache_path(tmp_path, "slope", 14, x, y).exists() + tiles.get_tile(tmp_path, "slope", 14, x, y) # rendu (maintenance, warm…) + data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y) + assert state == "fresh" and data + # Hors données : état empty + marqueur posé, toujours sans rendu + data, state = tiles.cached_tile(tmp_path, "slope", 14, 0, 0) + assert state == "empty" and data is None + assert tiles.empty_marker_exists(tmp_path, "slope", 14, 0, 0) diff --git a/lidar_pipeline/tiles.py b/lidar_pipeline/tiles.py index ae2cff8..7a7e88a 100644 --- a/lidar_pipeline/tiles.py +++ b/lidar_pipeline/tiles.py @@ -325,6 +325,15 @@ def _cell_bounds(col, row): return (col * 1000.0, (row - 1) * 1000.0, (col + 1) * 1000.0, row * 1000.0) +def cell_bounds_wgs84(col, row): + """Emprise WGS84 [ouest, sud, est, nord] d'une dalle LHD (4 coins L93).""" + min_x, min_y, max_x, max_y = _cell_bounds(col, row) + xs = (min_x, min_x, max_x, max_x) + ys = (min_y, max_y, min_y, max_y) + lons, lats = _transformer("EPSG:2154", "EPSG:4326").transform(xs, ys) + return (min(lons), min(lats), max(lons), max(lats)) + + def _dir_mtime(path): try: return path.stat().st_mtime @@ -780,6 +789,37 @@ def empty_marker_exists(output_dir, layer, z, x, y, scale=1, fmt="png"): scale, fmt).with_suffix(".empty").exists() +def cached_tile(output_dir, layer, z, x, y, scale=1, fmt="png"): + """Tuile depuis le cache UNIQUEMENT — jamais de rendu (mode cache seule). + + Retourne ``(data, état)`` : + - ``"fresh"`` : tuile en cache et plus récente que ses dalles sources ; + - ``"empty"`` : aucune source n'intersecte (marqueur `.empty` posé) ; + - ``"pending"``: donnée présente mais tuile absente ou périmée — à rendre + ailleurs (tâche de fond, pré-chauffage), pas au fil de la navigation. + """ + cache = tile_cache_path(output_dir, layer, z, x, y, scale, fmt) + empty = cache.with_suffix(".empty") + sources = _contributing(output_dir, layer, z, x, y, scale) + if not sources: + if not empty.exists(): + _write_atomic(empty, b"") + return None, "empty" + if empty.exists(): + return None, "empty" + newest = 0.0 + for src in sources: + m = src.mtime() + if m and m > newest: + newest = m + try: + if cache.stat().st_mtime >= newest: + return cache.read_bytes(), "fresh" + except OSError: + pass + return None, "pending" + + def transparent_tile(scale=1, fmt="png"): """Tuile entièrement transparente (zone sans donnée).""" from PIL import Image