Spaces:
Running
Running
File size: 12,920 Bytes
e58615a 0f7ec0b e58615a 0f7ec0b 1badbeb e58615a 1badbeb 8cc06a0 1badbeb 8cc06a0 1badbeb 8cc06a0 e58615a 0f7ec0b e58615a 0f7ec0b 5409901 0f7ec0b 5409901 0f7ec0b 5409901 0f7ec0b 5409901 0f7ec0b 5409901 0f7ec0b 5409901 0f7ec0b 5409901 0f7ec0b 5409901 e58615a 0f7ec0b e58615a 64a7749 e58615a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 | from __future__ import annotations
import os
import shutil
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import ClientSecretCredential, DefaultAzureCredential
from azure.storage.filedatalake import DataLakeServiceClient
from racing_reports import vendor_env
from racing_reports.config import DEFAULT_SETTINGS, Settings
from racing_reports.utils import slugify
# Cache (a nivel módulo, persiste entre requests) del mapa de preprocessed en Azure.
_PRE_MAP_TTL = 1800.0
_PRE_MAP_CACHE: dict[str, object] = {"ts": 0.0, "value": None}
def _sas_expired(sas: str) -> bool:
"""True si el SAS tiene un 'se' (expiry) ya vencido. Si no se puede parsear,
asume válido (False) para no romper tokens sin ese campo."""
from datetime import datetime, timezone
from urllib.parse import parse_qs
try:
se = parse_qs(sas.lstrip("?")).get("se", [None])[0]
if not se:
return False
exp = datetime.fromisoformat(se.replace("Z", "+00:00"))
return exp < datetime.now(timezone.utc)
except Exception:
return False
def _azure_credential():
"""Orden de auth: SAS token (vigente) > Service Principal > DefaultAzureCredential.
- ``AZURE_SAS_TOKEN``: SAS de Storage Explorer/portal. Si está **vencido** se
ignora (parseando su ``se=``), para que NO bloquee el fallback al Service
Principal — error frecuente cuando el SAS dura poco.
- Service Principal (3 env vars): "production-grade", no vence tan seguido.
- DefaultAzureCredential: en local con ``az login``.
"""
sas = os.getenv("AZURE_SAS_TOKEN")
if sas and not _sas_expired(sas):
# Las query strings de SAS pueden venir con o sin '?' inicial.
return sas.lstrip("?")
tenant = os.getenv("AZURE_TENANT_ID")
client_id = os.getenv("AZURE_CLIENT_ID")
client_secret = os.getenv("AZURE_CLIENT_SECRET")
if tenant and client_id and client_secret:
return ClientSecretCredential(
tenant_id=tenant, client_id=client_id, client_secret=client_secret
)
return DefaultAzureCredential()
@dataclass
class DataStore:
settings: Settings = DEFAULT_SETTINGS
def __post_init__(self) -> None:
self.settings.preprocessed_cache_dir.mkdir(parents=True, exist_ok=True)
self.settings.artifacts_cache_dir.mkdir(parents=True, exist_ok=True)
# ── ubicaciones en cache ──────────────────────────────────────────────
def _csv_path(self, league: str, season: str, name: str) -> Path:
return (
self.settings.preprocessed_cache_dir
/ slugify(league)
/ season
/ name
)
def preprocessed_path(self, league: str, season: str) -> Path:
return self._csv_path(league, season, self._preprocessed_filename(league, season))
def labeled_path(self, league: str, season: str) -> Path:
return self._csv_path(league, season, self.settings.labeled_preprocessed_name)
def artifact_path(self, relative_path: str) -> Path:
return self.settings.artifacts_cache_dir / relative_path
# ── modo local (sin Azure, para dev/test) ─────────────────────────────
@property
def local_data_dir(self) -> Path | None:
raw = self.settings.local_data_dir
return Path(raw).expanduser() if raw else None
# ── requires (sin descargar) ──────────────────────────────────────────
def require_preprocessed(self, league: str, season: str) -> Path:
local = self.local_data_dir
if local is not None:
cand = local / self.settings.preprocessed_name
if cand.exists():
return cand
path = self.preprocessed_path(league, season)
if not path.exists():
raise FileNotFoundError(
f"No existe preprocessed cacheado para {league} {season}: {path}. "
f"Corré: racing-reports sync --league {league!r} --season {season}"
)
return path
def require_labeled(self, league: str, season: str) -> Path:
"""Devuelve el labeled si existe; si no, cae al preprocessed base.
El preprocessed base ya trae ``phaseLabel`` (columnas ``w_*`` y
``label_after_rules`` se derivan en el reporte). Mantenemos el labeled
como override por compatibilidad con flujos viejos.
"""
local = self.local_data_dir
if local is not None:
cand = local / self.settings.labeled_preprocessed_name
if cand.exists():
return cand
path = self.labeled_path(league, season)
if path.exists():
return path
return self.require_preprocessed(league, season)
# ── sync desde Azure ──────────────────────────────────────────────────
def _filesystem_client(self):
service = DataLakeServiceClient(
account_url=self.settings.account_url, credential=_azure_credential()
)
return service.get_file_system_client(self.settings.azure_filesystem)
def available_preprocessed_map(self) -> dict[str, dict[str, str]] | None:
"""{liga: {temporada: nombre_archivo}} de los preprocessed subidos a Azure.
El nombre del preprocessed es por liga (``preprocessed_<ABBR>_<temporada>.csv``),
así que lo descubrimos listando Azure en vez de asumir un nombre fijo.
Cacheado a nivel módulo (TTL). Devuelve None si no se puede listar
(sin credenciales / dev local / error): el caller lo interpreta como
"desconocido, no filtrar".
"""
if self.local_data_dir is not None:
return None
now = time.time()
if _PRE_MAP_CACHE["value"] is not None and now - float(_PRE_MAP_CACHE["ts"]) < _PRE_MAP_TTL:
return _PRE_MAP_CACHE["value"] # type: ignore[return-value]
root = self.settings.azure_preprocessed_root.strip("/")
try:
fs = self._filesystem_client()
found: dict[str, dict[str, str]] = {}
for p in fs.get_paths(path=root, recursive=True):
if getattr(p, "is_directory", False):
continue
path_name = getattr(p, "name", "") or ""
rel = path_name[len(root):].lstrip("/") if path_name.startswith(root) else path_name
parts = rel.split("/")
if len(parts) < 3:
continue
fname = parts[-1]
if not (fname.startswith("preprocessed_") and fname.endswith(".csv")):
continue
if "etiquetado" in fname: # variante labeled, no la base
continue
found.setdefault(parts[-3], {})[parts[-2]] = fname
result = found or None
except Exception:
result = None
_PRE_MAP_CACHE["value"] = result
_PRE_MAP_CACHE["ts"] = now
return result
def available_league_seasons(self) -> dict[str, list[str]] | None:
"""{liga: [temporadas]} con preprocessed en Azure (None si desconocido)."""
m = self.available_preprocessed_map()
if not m:
return None
return {lg: sorted(seasons.keys(), reverse=True) for lg, seasons in sorted(m.items())}
def _preprocessed_filename(self, league: str, season: str) -> str:
"""Nombre real del preprocessed para esa liga/temporada (fallback al fijo)."""
m = self.available_preprocessed_map()
if m:
fname = m.get(league, {}).get(str(season))
if fname:
return fname
return self.settings.preprocessed_name
def _sync_named_csv(self, league: str, season: str, name: str, force: bool) -> Path:
local = self._csv_path(league, season, name)
if local.exists() and not force:
return local
if self.local_data_dir is not None:
src = self.local_data_dir / name
if src.exists():
local.parent.mkdir(parents=True, exist_ok=True)
if local.resolve() != src.resolve():
shutil.copy2(src, local)
return local
remote = f"{self.settings.azure_preprocessed_root}/{league}/{season}/{name}"
fs = self._filesystem_client()
self._download(fs, remote, local)
return local
def sync_preprocessed(self, league: str, season: str, force: bool = False) -> Path:
return self._sync_named_csv(
league, season, self._preprocessed_filename(league, season), force
)
def sync_labeled(self, league: str, season: str, force: bool = False) -> Path:
"""Sincroniza el labeled si existe en Azure; si no, cae al preprocessed base.
El base ya trae ``phaseLabel`` para derivar bloques en runtime.
"""
try:
return self._sync_named_csv(
league, season, self.settings.labeled_preprocessed_name, force
)
except ResourceNotFoundError:
return self.sync_preprocessed(league, season, force=force)
def sync_artifact(self, relative_path: str, force: bool = False) -> Path:
# 1) bundleado en el repo (CORE artifacts viven acá para no requerir Azure)
if not force:
bundled = vendor_env.bundled_artifact_path(relative_path)
if bundled is not None:
return bundled
# 2) cache local previo
local = self.artifact_path(relative_path)
if local.exists() and not force:
return local
# 3) carpeta override para dev (RACING_LOCAL_DATA_DIR)
if self.local_data_dir is not None:
src = self.local_data_dir / relative_path
if src.exists():
local.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, local)
return local
# 4) descargar de Azure
remote = f"{self.settings.azure_report_artifacts_root}/{relative_path}".replace(
"//", "/"
)
fs = self._filesystem_client()
self._download(fs, remote, local)
return local
def sync_artifacts(self, force: bool = False) -> list[Path]:
return [
self.sync_artifact(rel, force=force)
for rel in vendor_env.ARTIFACT_RELATIVE_PATHS
]
# ── preparación de vendor/data para los scripts reales ────────────────
def prepare_vendor(
self, report_types: list[str], league: str, season: str, force: bool = False
) -> dict[str, Path]:
"""Asegura artifacts + CSV necesarios y los linkea en vendor/data.
Devuelve paths resueltos: preprocessed_csv, labeled_csv, matches_csv.
Descarga perezosa según el tipo de reporte pedido.
"""
vendor_env.add_scripts_to_path()
# artifacts chicos (siempre baratos) + linkeo a vendor/data
for rel in vendor_env.ARTIFACT_RELATIVE_PATHS:
src = self.sync_artifact(rel, force=force)
vendor_env.link_artifact(rel, src)
resolved: dict[str, Path] = {
"matches_csv": vendor_env.DATA_DIR / "matches.csv",
}
need_pre = any(r in report_types for r in ("post_match", "triple_multitag", "triple_centros", "recuperaciones"))
need_labeled = "block_zscore" in report_types
if need_pre:
resolved["preprocessed_csv"] = self.sync_preprocessed(
league, season, force=force
)
if need_labeled:
resolved["labeled_csv"] = self.sync_labeled(league, season, force=force)
return resolved
@staticmethod
def _download(fs, remote_path: str, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
downloader = fs.get_file_client(remote_path).download_file()
tmp = local_path.with_suffix(local_path.suffix + ".part")
with tmp.open("wb") as handle:
downloader.readinto(handle)
tmp.replace(local_path)
@staticmethod
def _latest_csv(fs, remote_dir: str) -> str:
paths = [
p
for p in fs.get_paths(path=remote_dir, recursive=False)
if not p.is_directory and p.name.endswith(".csv")
]
if not paths:
raise FileNotFoundError(f"No se encontraron CSV en Azure: {remote_dir}")
paths.sort(key=lambda p: p.last_modified or datetime.min, reverse=True)
return paths[0].name
|