Files
hockey_new/broadcast_settings/transform.py
2026-08-19 15:08:39 +03:00

2122 lines
82 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import builtins
import io
import re
import tokenize
import warnings
from datetime import datetime
from typing import Any, Callable
from .joins import apply_join_rules
ROUND_KEYS = {"round", "rounds", "round_id", "roundId", "roundTitle", "round_title"}
SCORE_KEYS = {
"score", "scores", "total", "to_par", "toPar", "par", "holes", "hole", "thru", "position", "place", "rank"
}
PLAYER_KEYS = {
"player", "players", "athlete", "athletes", "participant", "participants", "name", "fullName", "lastName", "firstName"
}
def as_list(data: Any) -> list[Any]:
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("data", "items", "tournaments", "results", "rows", "scores", "list"):
value = data.get(key)
if isinstance(value, list):
return value
return []
def get_any(d: dict[str, Any], *keys: str, default: Any = "") -> Any:
for key in keys:
if key in d and d[key] not in (None, ""):
return d[key]
return default
def get_scalar_any(d: dict[str, Any], *keys: str, default: Any = "") -> Any:
for key in keys:
if key in d and d[key] not in (None, "") and not isinstance(d[key], (dict, list)):
return d[key]
return default
def normalize_title(item: dict[str, Any]) -> str:
return str(get_any(item, "title", "name", "tournamentTitle", "competitionTitle", "eventTitle", "shortTitle", default="Без названия"))
def normalize_tournaments(data: Any) -> list[dict[str, Any]]:
items = as_list(data)
result: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
tournament_id = get_any(item, "id", "tournamentId", "competitionId", "eventId")
if tournament_id == "":
continue
result.append({
"id": tournament_id,
"title": normalize_title(item),
"date_start": get_any(item, "dateStart", "date_start", "startDate", "start_date", "dateFrom", default=""),
"date_end": get_any(item, "dateEnd", "date_end", "endDate", "end_date", "dateTo", default=""),
"place": get_any(item, "place", "venue", "club", "city", "location", default=""),
"status": get_any(item, "status", "state", "isActive", "active", default=""),
"raw": item,
})
return result
def _walk(obj: Any, path: str = "root"):
yield path, obj
if isinstance(obj, dict):
for key, value in obj.items():
yield from _walk(value, f"{path}.{key}")
elif isinstance(obj, list):
for index, value in enumerate(obj):
yield from _walk(value, f"{path}[{index}]")
def _score_dict_for_round_candidate(item: dict[str, Any], path: str = "") -> int:
keys = set(item.keys())
text = " ".join(map(str, keys)).lower()
path_text = path.lower()
score = 0
if keys & ROUND_KEYS:
score += 8
if "round" in text or "раунд" in text:
score += 8
if "round" in path_text or "раунд" in path_text:
score += 10
if any(k.lower() in text for k in ("title", "name", "date", "id", "number", "no")):
score += 3
if get_any(item, "id", "roundId", "round_id") != "":
score += 4
return score
def extract_rounds(data: Any) -> list[dict[str, Any]]:
"""Find round-like objects inside any tournament JSON."""
candidates: list[dict[str, Any]] = []
for path, obj in _walk(data):
if not isinstance(obj, list):
continue
if not obj or not all(isinstance(x, dict) for x in obj):
continue
scored = []
for item in obj:
s = _score_dict_for_round_candidate(item, path)
if s >= 8:
scored.append((s, item, path))
if scored:
for _, item, found_path in scored:
round_id = get_any(item, "id", "roundId", "round_id")
title = get_any(
item,
"title", "name", "roundTitle", "round_title", "label", "caption",
default=f"Раунд {get_any(item, 'number', 'roundNumber', 'round_number', default=round_id)}",
)
if round_id != "":
candidates.append({
"id": round_id,
"title": str(title),
"number": get_any(item, "number", "roundNumber", "round_number", "no", default=""),
"date": get_any(item, "date", "dateStart", "startDate", "startedAt", default=""),
"status": get_any(item, "status", "state", "isActive", "active", default=""),
"source_path": found_path,
"raw": item,
})
unique: dict[str, dict[str, Any]] = {}
for item in candidates:
unique[str(item["id"])] = item
return list(unique.values())
def flatten_dict(obj: Any, prefix: str = "") -> dict[str, Any]:
out: dict[str, Any] = {}
if isinstance(obj, dict):
for key, value in obj.items():
clean_key = str(key)
next_prefix = f"{prefix}_{clean_key}" if prefix else clean_key
if isinstance(value, dict):
out.update(flatten_dict(value, next_prefix))
elif isinstance(value, list):
# Keep compact representation for small primitive lists, flatten hole arrays separately later.
if all(not isinstance(x, (dict, list)) for x in value):
out[next_prefix] = " | ".join(map(str, value))
else:
out[next_prefix] = value
else:
out[next_prefix] = value
else:
out[prefix or "value"] = obj
return out
def _candidate_score_for_scores_list(items: list[dict[str, Any]]) -> int:
if not items:
return 0
keys: set[str] = set()
for item in items[:5]:
keys |= set(map(str, item.keys()))
text = " ".join(keys).lower()
score = 0
if keys & SCORE_KEYS:
score += 10
if keys & PLAYER_KEYS:
score += 10
if any(word in text for word in ("score", "hole", "thru", "total", "par", "player", "golfer", "participant", "result")):
score += 8
score += min(len(items), 100) // 10
return score
def find_scores_list(data: Any) -> list[dict[str, Any]]:
"""Find the most likely list of score rows inside raw scores JSON."""
best_score = -1
best_items: list[dict[str, Any]] = []
direct = as_list(data)
if direct and all(isinstance(x, dict) for x in direct):
best_items = direct # good fallback
best_score = _candidate_score_for_scores_list(direct)
for _, obj in _walk(data):
if not isinstance(obj, list):
continue
if not obj or not all(isinstance(x, dict) for x in obj):
continue
score = _candidate_score_for_scores_list(obj)
if score > best_score:
best_score = score
best_items = obj
return best_items
def _join_name(*parts: Any) -> str:
cleaned = [str(p).strip() for p in parts if p not in (None, "") and str(p).strip()]
return " ".join(cleaned)
def _extract_player_name(flat: dict[str, Any]) -> str:
# Common direct fields
direct = get_any(
flat,
"player", "player_name", "player_fullName", "player_full_name", "participant_name",
"athlete_name", "name", "fullName", "full_name", default=""
)
if direct:
return str(direct)
# Common first/last combinations
combos = [
("lastName", "firstName"),
("last_name", "first_name"),
("player_lastName", "player_firstName"),
("player_last_name", "player_first_name"),
("participant_lastName", "participant_firstName"),
]
for last_key, first_key in combos:
joined = _join_name(flat.get(last_key), flat.get(first_key))
if joined:
return joined
# Fuzzy fallback
last = ""
first = ""
for key, value in flat.items():
lk = key.lower()
if (not last) and ("lastname" in lk or "last_name" in lk):
last = str(value)
if (not first) and ("firstname" in lk or "first_name" in lk):
first = str(value)
return _join_name(last, first)
def _to_number(value: Any) -> float | None:
if value in (None, ""):
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", ".")
match = re.search(r"[-+]?\d+(?:\.\d+)?", text)
if not match:
return None
try:
return float(match.group(0))
except ValueError:
return None
def _get_first_like(flat: dict[str, Any], needles: tuple[str, ...], *, prefer_id: bool = False) -> Any:
"""Find first value in flattened dict by fuzzy key matching."""
for key, value in flat.items():
if value in (None, "") or isinstance(value, (dict, list)):
continue
lk = str(key).lower()
if all(n in lk for n in needles):
if prefer_id and not (lk.endswith("id") or lk.endswith("_id") or lk.endswith("id") or "id" in lk):
continue
return value
return ""
def _player_name_parts(flat: dict[str, Any]) -> tuple[str, str]:
last = get_any(flat, "lastName", "last_name", "surname", "familyName", "family_name", "player_lastName", "player_last_name", "participant_lastName", "participant_last_name", default="")
first = get_any(flat, "firstName", "first_name", "givenName", "given_name", "player_firstName", "player_first_name", "participant_firstName", "participant_first_name", default="")
if not last:
last = _get_first_like(flat, ("last", "name")) or _get_first_like(flat, ("surname",))
if not first:
first = _get_first_like(flat, ("first", "name")) or _get_first_like(flat, ("given",))
return str(last or "").strip(), str(first or "").strip()
def _clean_hole_no(value: Any) -> str:
if value in (None, ""):
return ""
num = _to_number(value)
if num is None:
return ""
n = int(num)
if 1 <= n <= 18:
return str(n)
return ""
def _classify_hole_to_par(value: Any) -> tuple[str, str]:
num = _to_number(value)
if num is None:
return "", ""
n = int(num)
if n <= -3:
return "albatros", "Albatros"
if n == -2:
return "eagle", "Eagle"
if n == -1:
return "birdie", "Birdie"
if n == 0:
return "par", "Par"
if n == 1:
return "bogey", "Bogey"
if n == 2:
return "double-bogey", "Dbl (+2)"
return "triple-bogey", f"+{n}"
def _hole_score_from_flat(flat: dict[str, Any]) -> Any:
return get_any(
flat,
"score", "strokes", "stroke", "gross", "grossScore", "gross_score",
"result", "value", "count", "total", default="",
)
def _hole_par_from_flat(flat: dict[str, Any]) -> Any:
return get_any(flat, "par", "holePar", "hole_par", "parValue", "par_value", default="")
def _hole_to_par_from_flat(flat: dict[str, Any], score: Any = "", par: Any = "") -> Any:
direct = get_any(
flat,
"toPar", "to_par", "relativeToPar", "relative_to_par", "scoreToPar", "score_to_par",
"diff", "difference", "parDiff", "par_diff", default="",
)
if direct not in (None, ""):
return direct
score_num = _to_number(score)
par_num = _to_number(par)
if score_num is not None and par_num is not None:
return int(score_num - par_num)
return ""
def _hole_points_from_flat(flat: dict[str, Any]) -> Any:
return get_any(
flat,
"points", "point", "stableford", "stablefordPoints", "stableford_points",
"scorePoints", "score_points", default="",
)
def _looks_like_holes_array(key: str, items: list[Any]) -> bool:
key_lower = str(key).lower()
if any(word in key_lower for word in ("hole", "holes", "score", "scores", "card", "scorecard")):
return True
sample_keys: set[str] = set()
for item in items[:5]:
if isinstance(item, dict):
sample_keys |= set(k.lower() for k in map(str, item.keys()))
return bool(sample_keys & {"hole", "holenumber", "hole_number", "number", "no"}) and bool(sample_keys & {"score", "strokes", "gross", "par", "points", "value"})
def _extract_holes_recursive(obj: Any, prefix: str = "") -> dict[str, dict[str, Any]]:
"""Extract hole-by-hole data from many possible API shapes.
RusGolf pages can expose hole data as:
- a list of dicts: [{hole: 1, score: 4, par: 4, points: 2}, ...]
- a dict keyed by hole number: {"1": 4, "2": 5} or {"1": {score: 4, par: 4}}
- flat/array values: scores: [4,5,...], points: [2,1,...], par: [4,4,...]
This parser keeps score/par/to_par/points separately so the visual table can show
the full scorecard even if the endpoint does not use the exact `hole_1` names.
"""
holes: dict[str, dict[str, Any]] = {}
def merge(hole_no: str, data: dict[str, Any]) -> None:
hole_no = _clean_hole_no(hole_no)
if not hole_no:
return
dest = holes.setdefault(hole_no, {})
for k, v in data.items():
if v not in (None, ""):
dest[k] = v
def kind_from_key(key: str) -> str:
lk = str(key).lower()
if "point" in lk or "stableford" in lk:
return "points"
if "par" in lk and "topar" not in lk and "to_par" not in lk:
return "par"
if "topar" in lk or "to_par" in lk or "diff" in lk or "relative" in lk:
return "to_par"
if "class" in lk:
return "class"
if "label" in lk or "type" in lk:
return "label"
return "score"
if isinstance(obj, dict):
for key, value in obj.items():
key_s = str(key)
# Shape: {"1": 4, "2": 5} or {"h1": 4}
direct_match = re.fullmatch(r"(?:h|hole)?[_\- ]?(\d{1,2})", key_s, flags=re.IGNORECASE)
if direct_match and not isinstance(value, (dict, list)):
merge(direct_match.group(1), {"score": value})
continue
# Shape: {"1": {score/par/points...}}
if direct_match and isinstance(value, dict):
flat_item = flatten_dict(value)
score = _hole_score_from_flat(flat_item)
par = _hole_par_from_flat(flat_item)
to_par = _hole_to_par_from_flat(flat_item, score, par)
points = _hole_points_from_flat(flat_item)
css, label = _classify_hole_to_par(to_par)
merge(direct_match.group(1), {"score": score, "par": par, "to_par": to_par, "points": points, "class": css, "label": label})
# Shape: holeScores: [4, 5, ...] / points: [2, 1, ...] / par: [4, 4, ...]
if isinstance(value, list) and value and all(not isinstance(x, (dict, list)) for x in value):
lk = key_s.lower()
if len(value) >= 9 and any(word in lk for word in ("hole", "score", "stroke", "gross", "point", "stableford", "par")):
kind = kind_from_key(key_s)
for idx, item_value in enumerate(value[:18], start=1):
merge(str(idx), {kind: item_value})
# Shape: holes: [{...}, {...}]
if isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
if _looks_like_holes_array(str(key), value):
for idx, item in enumerate(value, start=1):
flat_item = flatten_dict(item)
hole_no = get_any(flat_item, "hole", "holeNumber", "hole_number", "holeNo", "hole_no", "number", "no", default="")
hole_no = _clean_hole_no(hole_no) or _clean_hole_no(idx)
if not hole_no:
continue
score = _hole_score_from_flat(flat_item)
par = _hole_par_from_flat(flat_item)
to_par = _hole_to_par_from_flat(flat_item, score, par)
points = _hole_points_from_flat(flat_item)
css, label = _classify_hole_to_par(to_par)
merge(hole_no, {"score": score, "par": par, "to_par": to_par, "points": points, "class": css, "label": label})
if isinstance(value, (dict, list)):
nested = _extract_holes_recursive(value, f"{prefix}.{key}" if prefix else str(key))
for hole_no, data in nested.items():
merge(hole_no, data)
elif isinstance(obj, list):
# Shape: [4,5,4,...] directly under a score-like prefix.
if obj and all(not isinstance(x, (dict, list)) for x in obj):
kind = kind_from_key(prefix)
if len(obj) >= 9 and any(word in prefix.lower() for word in ("hole", "score", "stroke", "gross", "point", "stableford", "par")):
for idx, item_value in enumerate(obj[:18], start=1):
merge(str(idx), {kind: item_value})
for item in obj:
nested = _extract_holes_recursive(item, prefix)
for hole_no, data in nested.items():
merge(hole_no, data)
# Fill labels/classes when score and par exist but endpoint did not provide explicit to_par.
for hole_no, data in holes.items():
if data.get("to_par") in (None, ""):
data["to_par"] = _hole_to_par_from_flat({}, data.get("score", ""), data.get("par", ""))
if data.get("class") in (None, "") or data.get("label") in (None, ""):
css, label = _classify_hole_to_par(data.get("to_par"))
data.setdefault("class", css)
data.setdefault("label", label)
return holes
def _extract_flat_holes(flat: dict[str, Any]) -> dict[str, dict[str, Any]]:
holes: dict[str, dict[str, Any]] = {}
for key, value in flat.items():
lk = str(key).lower()
match = re.search(r"(?:^|_)(?:hole|h)[_\- ]?(\d{1,2})(?:_|$)", lk)
if not match:
match = re.search(r"(?:^|_)(\d{1,2})[_\- ]?(?:score|strokes|par|points)(?:_|$)", lk)
if not match:
continue
hole_no = _clean_hole_no(match.group(1))
if not hole_no:
continue
holes.setdefault(hole_no, {})
if "par" in lk and "to_par" not in lk and "topar" not in lk:
holes[hole_no]["par"] = value
elif "point" in lk or "stableford" in lk:
holes[hole_no]["points"] = value
elif "to_par" in lk or "topar" in lk or "diff" in lk:
holes[hole_no]["to_par"] = value
elif "class" in lk:
holes[hole_no]["class"] = value
elif "label" in lk or "type" in lk:
holes[hole_no]["label"] = value
else:
holes[hole_no]["score"] = value
return holes
def _sum_numeric(values: list[Any]) -> Any:
nums = [_to_number(v) for v in values]
nums = [n for n in nums if n is not None]
if not nums:
return ""
total = sum(nums)
return int(total) if float(total).is_integer() else round(total, 3)
def _row_matches_category(flat: dict[str, Any], category_id: Any) -> bool:
if category_id in (None, ""):
return True
needle = str(category_id).strip()
category_values: list[str] = []
for key, value in flat.items():
lk = str(key).lower()
if "cat" in lk or "category" in lk or "division" in lk or "group" in lk:
if value not in (None, "") and not isinstance(value, (dict, list)):
category_values.append(str(value).strip())
if not category_values:
# If API already filtered by ?cat=... and rows have no category field, keep rows.
return True
return needle in category_values
def _score_dict_for_player_candidate(item: dict[str, Any], path: str = "") -> int:
"""Score a dict/list as a likely player profile object, not just a score row."""
flat = flatten_dict(item)
keys = list(map(str, flat.keys()))
text = " ".join(keys + [path]).lower()
score = 0
strong_words = (
"player", "golfer", "participant", "athlete", "member", "person",
"lastname", "last_name", "surname", "firstname", "first_name", "fullname", "full_name",
)
extra_words = (
"club", "team", "country", "region", "city", "gender", "sex", "handicap", "hcp",
"birth", "birthday", "dob", "photo", "avatar", "image", "category", "division", "group",
)
for word in strong_words:
if word in text:
score += 7
for word in extra_words:
if word in text:
score += 3
# Player-like paths are strong; scoring paths are weaker unless they contain actual first/last/name fields.
path_l = path.lower()
if any(word in path_l for word in ("players", "golfers", "participants", "athletes", "members")):
score += 18
if any(word in path_l for word in ("score", "scores", "result", "leaderboard")):
score -= 5
if get_scalar_any(flat, "id", "playerId", "player_id", "participantId", "participant_id", "golferId", "golfer_id", "memberId", "member_id", default="") != "":
score += 4
if _extract_player_name(flat):
score += 10
if _player_name_parts(flat) != ("", ""):
score += 8
return score
def _extract_photo(flat: dict[str, Any]) -> Any:
for key, value in flat.items():
if value in (None, "") or isinstance(value, (dict, list)):
continue
lk = str(key).lower()
if any(word in lk for word in ("photo", "avatar", "image", "picture", "portrait")):
return value
return ""
def _extract_handicap(flat: dict[str, Any]) -> Any:
return get_scalar_any(
flat,
"handicap", "hcp", "player_handicap", "player_hcp", "exactHandicap", "exact_handicap",
default=_get_first_like(flat, ("handicap",)) or _get_first_like(flat, ("hcp",)),
)
def _extract_birth_date(flat: dict[str, Any]) -> Any:
return get_scalar_any(
flat,
"birthDate", "birth_date", "birthday", "dob", "dateOfBirth", "date_of_birth",
default=_get_first_like(flat, ("birth",)) or _get_first_like(flat, ("dob",)),
)
def _normalize_player_profile(item: dict[str, Any], *, source_path: str = "") -> dict[str, Any]:
flat = flatten_dict(item)
last_name, first_name = _player_name_parts(flat)
full_name = _extract_player_name(flat) or _join_name(last_name, first_name)
player_id = get_scalar_any(
flat,
"player_id", "playerId", "participant_id", "participantId", "golferId", "golfer_id",
"memberId", "member_id", "personId", "person_id", "id",
default="",
)
category_id = get_scalar_any(
flat,
"category_id", "categoryId", "cat_id", "catId", "divisionId", "division_id", "groupId", "group_id",
default="",
)
category_title = get_scalar_any(
flat,
"categoryTitle", "category_title", "category_name", "categoryName", "division", "divisionTitle", "group", "groupTitle",
default="",
)
out = {
"player_id": player_id,
"player": full_name,
"player_full_name": full_name,
"player_last_name": last_name,
"player_first_name": first_name,
"player_middle_name": get_scalar_any(flat, "middleName", "middle_name", "patronymic", default=""),
"player_gender": get_scalar_any(flat, "gender", "sex", "player_gender", default=""),
"player_birth_date": _extract_birth_date(flat),
"player_handicap": _extract_handicap(flat),
"player_photo": _extract_photo(flat),
"country": get_scalar_any(flat, "country", "countryName", "country_name", "countryCode", "country_code", "countryISO", "player_country", default=""),
"club": get_scalar_any(flat, "club", "clubName", "club_name", "team", "teamName", "region", "regionName", default=""),
"city": get_scalar_any(flat, "city", "town", "location", default=""),
"category_id": category_id,
"category_title": category_title,
"source_path": source_path,
}
for key, value in flat.items():
if isinstance(value, (dict, list)):
continue
safe_key = str(key).replace(".", "_")
out.setdefault(safe_key, value)
out.setdefault(f"player_api_{safe_key}", value)
return out
def _dedupe_players(players: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for item in players:
pid = str(item.get("player_id") or item.get("id") or "").strip()
name_key = str(item.get("player_full_name") or item.get("player") or "").strip().lower()
key = f"id:{pid}" if pid else f"name:{name_key}"
if not key or key == "name:":
key = f"row:{len(result)}"
if key not in result:
result[key] = dict(item)
else:
# Merge non-empty values, keeping the first source as primary.
for k, v in item.items():
if result[key].get(k) in (None, "") and v not in (None, ""):
result[key][k] = v
return list(result.values())
def _player_matches_category(profile: dict[str, Any], category_id: int | str | None) -> bool:
if category_id in (None, ""):
return True
needle = str(category_id).strip()
id_values: list[str] = []
for key, value in profile.items():
lk = str(key).lower()
if ("cat" in lk or "category" in lk or "division" in lk or "group" in lk) and ("id" in lk or lk.endswith("_id")):
if value not in (None, "") and not isinstance(value, (dict, list)):
id_values.append(str(value).strip())
# If the API profile does not expose category id, do not drop the player.
if not id_values:
return True
return needle in id_values
def extract_players(data: Any, category_id: int | str | None = None) -> list[dict[str, Any]]:
"""Find and normalize player profile data inside tournament/round JSON.
RusGolf tournament JSON can keep players in different nested arrays depending on event type.
This extractor is deliberately fuzzy: it scans the whole JSON, finds likely player arrays,
normalizes the common fields, and keeps raw fields with player_api_* prefixes.
"""
candidates: list[tuple[int, dict[str, Any]]] = []
for path, obj in _walk(data):
if isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj[: min(len(obj), 10)]):
scored: list[tuple[int, dict[str, Any]]] = []
for item in obj:
if not isinstance(item, dict):
continue
score = _score_dict_for_player_candidate(item, path)
if score >= 18:
scored.append((score, item))
if scored:
for score, item in scored:
normalized = _normalize_player_profile(item, source_path=path)
if _player_matches_category(normalized, category_id):
candidates.append((score, normalized))
# Fallback: if the root itself is a player-like object/list.
direct = as_list(data)
if direct and all(isinstance(x, dict) for x in direct):
for item in direct:
score = _score_dict_for_player_candidate(item, "root")
if score >= 18:
normalized = _normalize_player_profile(item, source_path="root")
if _player_matches_category(normalized, category_id):
candidates.append((score, normalized))
candidates.sort(key=lambda x: -x[0])
return _dedupe_players([item for _, item in candidates])
def enrich_rows_with_players(rows: list[dict[str, Any]], players: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Merge player profile fields into score rows by id or normalized full name."""
if not rows or not players:
return rows
by_id: dict[str, dict[str, Any]] = {}
by_name: dict[str, dict[str, Any]] = {}
for player in players:
pid = str(player.get("player_id") or player.get("id") or "").strip()
if pid:
by_id[pid] = player
name = str(player.get("player_full_name") or player.get("player") or "").strip().lower()
if name:
by_name[name] = player
enriched: list[dict[str, Any]] = []
for row in rows:
out = dict(row)
pid = str(out.get("player_id") or out.get("id") or "").strip()
name = str(out.get("player_full_name") or out.get("player") or "").strip().lower()
profile = by_id.get(pid) if pid else None
if not profile and name:
profile = by_name.get(name)
if profile:
# Add profile fields with explicit prefix and fill empty normalized fields.
for key, value in profile.items():
if isinstance(value, (dict, list)):
continue
out.setdefault(f"profile_{key}", value)
if out.get(key) in (None, "") and value not in (None, ""):
out[key] = value
out["player_profile_found"] = True
out["player_profile_source"] = profile.get("source_path", "")
else:
out.setdefault("player_profile_found", False)
out.setdefault("player_profile_source", "")
enriched.append(out)
return enriched
def extract_categories(*sources: Any) -> list[dict[str, Any]]:
"""Find category-like objects in tournament/round JSON."""
found: dict[str, dict[str, Any]] = {}
for source in sources:
for path, obj in _walk(source):
if isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj[: min(len(obj), 10)]):
path_l = path.lower()
if not any(word in path_l for word in ("cat", "categor", "division", "group")):
sample_keys = " ".join(str(k).lower() for item in obj[:5] for k in (item.keys() if isinstance(item, dict) else []))
if not any(word in sample_keys for word in ("category", "cat", "division", "group")):
continue
for item in obj:
if not isinstance(item, dict):
continue
flat = flatten_dict(item)
category_id = get_any(flat, "id", "categoryId", "category_id", "catId", "cat_id", "divisionId", "groupId", default="")
title = get_any(flat, "title", "name", "caption", "label", "category", "categoryTitle", "category_title", default="")
if category_id == "" or title == "":
continue
key = str(category_id)
found[key] = {
"id": category_id,
"title": str(title),
"source_path": path,
"raw": item,
}
return list(found.values())
def _extract_holes(row: dict[str, Any], flat: dict[str, Any]) -> dict[str, Any]:
holes = _extract_holes_recursive(row)
flat_holes = _extract_flat_holes(flat)
for hole_no, data in flat_holes.items():
holes.setdefault(hole_no, {}).update({k: v for k, v in data.items() if v not in (None, "")})
result: dict[str, Any] = {}
stroke_values: list[Any] = []
point_values: list[Any] = []
par_values: list[Any] = []
for n in range(1, 19):
key = str(n)
data = holes.get(key, {})
score = data.get("score", "")
par = data.get("par", "")
to_par = data.get("to_par", "")
if to_par in (None, ""):
to_par = _hole_to_par_from_flat({}, score, par)
css, label = _classify_hole_to_par(to_par)
css = data.get("class") or css
label = data.get("label") or label
points = data.get("points", "")
result[f"hole_{n}"] = score
result[f"hole_{n}_par"] = par
result[f"hole_{n}_to_par"] = to_par
result[f"hole_{n}_points"] = points
result[f"hole_{n}_class"] = css
result[f"hole_{n}_label"] = label
if score not in (None, ""):
stroke_values.append(score)
if points not in (None, ""):
point_values.append(points)
if par not in (None, ""):
par_values.append(par)
result["out"] = _sum_numeric([result.get(f"hole_{n}") for n in range(1, 10)])
result["in"] = _sum_numeric([result.get(f"hole_{n}") for n in range(10, 19)])
result["scorecard_total"] = _sum_numeric([result.get(f"hole_{n}") for n in range(1, 19)])
result["out_par"] = _sum_numeric([result.get(f"hole_{n}_par") for n in range(1, 10)])
result["in_par"] = _sum_numeric([result.get(f"hole_{n}_par") for n in range(10, 19)])
result["round_par"] = _sum_numeric([result.get(f"hole_{n}_par") for n in range(1, 19)])
result["points_total"] = _sum_numeric([result.get(f"hole_{n}_points") for n in range(1, 19)])
total_score = _to_number(result.get("scorecard_total"))
total_par = _to_number(result.get("round_par"))
if total_score is not None and total_par is not None:
result["scorecard_to_par"] = int(total_score - total_par)
result["scorecard_to_par_text"] = _plus_minus_value(result["scorecard_to_par"], zero="E")
else:
result["scorecard_to_par"] = ""
result["scorecard_to_par_text"] = ""
return result
def normalize_scores(data: Any, category_id: int | str | None = None) -> list[dict[str, Any]]:
rows = find_scores_list(data)
normalized: list[dict[str, Any]] = []
for index, row in enumerate(rows, start=1):
if not isinstance(row, dict):
continue
flat = flatten_dict(row)
if not _row_matches_category(flat, category_id):
continue
player = _extract_player_name(flat)
last_name, first_name = _player_name_parts(flat)
position = get_scalar_any(flat, "position", "place", "rank", "pos", "standing", default=index)
total = get_scalar_any(flat, "total", "totalScore", "total_score", "score_total", "result", "strokes", default="")
to_par = get_scalar_any(flat, "toPar", "to_par", "par", "relativeToPar", "scoreToPar", default="")
thru = get_scalar_any(flat, "thru", "through", "holesPlayed", "playedHoles", default="")
today = get_scalar_any(flat, "today", "roundScore", "round_score", "currentRound", default="")
country = get_scalar_any(flat, "country", "countryName", "country_code", "countryISO", "player_country", default="")
club = get_scalar_any(flat, "club", "clubName", "team", "region", default="")
player_id = get_scalar_any(flat, "player_id", "playerId", "participant_id", "participantId", "golferId", "golfer_id", "memberId", "member_id", "id", default="")
category_value = get_scalar_any(flat, "category_id", "categoryId", "cat_id", "catId", "category", "categoryTitle", "category_title", default="")
out = {
"position": position,
"player": player,
"player_full_name": player,
"player_last_name": last_name,
"player_first_name": first_name,
"player_id": player_id,
"category_id": category_value,
"country": country,
"club": club,
"total": total,
"to_par": to_par,
"today": today,
"thru": thru,
}
out.update(_extract_holes(row, flat))
if out.get("scorecard_total") not in (None, "") and out.get("total") in (None, ""):
out["total"] = out.get("scorecard_total")
if out.get("scorecard_to_par") not in (None, "") and out.get("to_par") in (None, ""):
out["to_par"] = out.get("scorecard_to_par")
if out.get("points_total") not in (None, ""):
out.setdefault("points", out.get("points_total"))
# Add flat raw fields after normalized fields, without overwriting normalized names.
for key, value in flat.items():
if isinstance(value, (dict, list)):
continue
safe_key = str(key).replace(".", "_")
if safe_key not in out:
out[safe_key] = value
normalized.append(out)
return normalized
def sort_leaderboard(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
def key_func(row: dict[str, Any]):
pos = _to_number(row.get("position"))
total = _to_number(row.get("total"))
to_par = _to_number(row.get("to_par"))
return (
9999 if pos is None else pos,
9999 if to_par is None else to_par,
9999 if total is None else total,
str(row.get("player", "")),
)
return sorted(rows, key=key_func)
def select_columns(rows: list[dict[str, Any]], columns: str | None) -> list[dict[str, Any]]:
if not columns or columns.strip().lower() == "all":
return rows
wanted = [c.strip() for c in columns.split(",") if c.strip()]
return [{key: row.get(key, "") for key in wanted} for row in rows]
def _get_nested_value(row: dict[str, Any], source: str, default: Any = "") -> Any:
"""Read a value by plain key or dotted path. Falls back to default."""
if not source:
return default
if source in row:
return row.get(source, default)
current: Any = row
for part in str(source).split("."):
if isinstance(current, dict) and part in current:
current = current[part]
else:
return default
return current
def _render_template(template: str, row: dict[str, Any]) -> str:
result = template
for key, value in row.items():
if isinstance(value, (dict, list)):
continue
result = result.replace("{" + str(key) + "}", str(value if value is not None else ""))
return result
def _safe_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _normalize_search_text(value: Any, ignore_case: bool = True) -> str:
text = _safe_text(value)
return text.lower() if ignore_case else text
def contains_value(value: Any, needle: Any, ignore_case: bool = True) -> bool:
search = _normalize_search_text(needle, ignore_case)
return bool(search) and search in _normalize_search_text(value, ignore_case)
def equals_value(value: Any, expected: Any, ignore_case: bool = True) -> bool:
return _normalize_search_text(value, ignore_case).strip() == _normalize_search_text(expected, ignore_case).strip()
def regex_value(value: Any, pattern: Any, ignore_case: bool = True) -> bool:
try:
return re.search(_safe_text(pattern), _safe_text(value), re.IGNORECASE if ignore_case else 0) is not None
except Exception:
return False
def regex_replace_value(value: Any, pattern: Any, replacement: Any = "", count: int = 0, ignore_case: bool = True) -> str:
try:
return re.sub(_safe_text(pattern), _safe_text(replacement), _safe_text(value), count=int(count or 0), flags=re.IGNORECASE if ignore_case else 0)
except Exception:
return _safe_text(value)
def regex_extract_value(value: Any, pattern: Any, group: Any = 1, default: Any = "", ignore_case: bool = True) -> Any:
try:
match = re.search(_safe_text(pattern), _safe_text(value), re.IGNORECASE if ignore_case else 0)
if not match:
return default
if group in (None, ""):
return match.group(0)
try:
return match.group(int(group))
except Exception:
return match.group(_safe_text(group))
except Exception:
return default
def first_match_value(value: Any, rules: Any, default: Any = "", ignore_case: bool = True, regex: bool = False) -> Any:
if not rules:
return default
iterable = rules.items() if isinstance(rules, dict) else rules
for item in iterable:
try:
needle, result = item
except Exception:
continue
matched = regex_value(value, needle, ignore_case) if regex else contains_value(value, needle, ignore_case)
if matched:
return result
return default
def template_value(template: Any, row: dict[str, Any] | None = None) -> str:
row = row or {}
class SafeDict(dict):
def __missing__(self, key):
return ""
try:
return str(template).format_map(SafeDict({str(k): _safe_text(v) for k, v in row.items()}))
except Exception:
return ""
def _number_value(value: Any, default: Any = 0) -> Any:
try:
return float(_safe_text(value).replace(",", ".").strip())
except Exception:
return default
def _format_distance(value: Any) -> str:
text = _safe_text(value)
return regex_replace_value(text, r"(\d)(?=(\d{3})+\b)", r"\1.")
def _parse_time_seconds(value: Any, default: Any = "") -> Any:
if value in (None, ""):
return default
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
text = _safe_text(value).strip().replace(",", ".")
match = re.search(r"-?\d+(?::\d{1,2}){1,2}(?:\.\d+)?", text)
if match:
token = match.group(0)
sign = -1 if token.startswith("-") else 1
parts = token.lstrip("-").split(":")
try:
seconds = float(parts[-1])
multiplier = 60.0
for part in reversed(parts[:-1]):
seconds += float(part) * multiplier
multiplier *= 60.0
return sign * seconds
except Exception:
return default
match = re.search(r"-?\d+(?:\.\d+)?", text)
if match:
try:
return float(match.group(0))
except Exception:
return default
return default
def _format_time(value: Any, fmt: str = "auto", decimals: Any = None, default: Any = "") -> Any:
seconds = _parse_time_seconds(value, default=None)
if seconds is None:
return default
seconds = float(seconds)
negative = seconds < 0
seconds = abs(seconds)
fmt_text = _safe_text(fmt or "auto").lower()
if decimals not in (None, ""):
try:
precision = int(decimals)
except Exception:
precision = 2
elif "tt" in fmt_text or ".2" in fmt_text:
precision = 2
elif "_t" in fmt_text or ".t" in fmt_text or ".1" in fmt_text:
precision = 1
else:
precision = 2
precision = max(0, min(6, precision))
sign = "-" if negative else ""
if fmt_text.startswith("s") or seconds < 60 and fmt_text.startswith("auto"):
return f"{sign}{seconds:.{precision}f}" if precision else f"{sign}{round(seconds):.0f}"
total_int = int(seconds)
frac = seconds - total_int
minutes = total_int // 60
sec_value = (total_int % 60) + frac
if precision:
return f"{sign}{minutes}:{sec_value:0{3 + precision}.{precision}f}"
return f"{sign}{minutes}:{int(round(sec_value)):02d}"
def _join_values(sep: Any, *values: Any) -> str:
return _safe_text(sep).join(_safe_text(value) for value in values if value not in (None, ""))
def _split_value(value: Any, sep: Any = None, index: Any = None) -> Any:
parts = _safe_text(value).split(None if sep is None else _safe_text(sep))
if index in (None, ""):
return parts
try:
return parts[int(index)]
except Exception:
return ""
def _left_value(value: Any, count: Any) -> str:
try:
return _safe_text(value)[:int(count)]
except Exception:
return ""
def _right_value(value: Any, count: Any) -> str:
try:
count = int(count)
except Exception:
return ""
if count <= 0:
return ""
return _safe_text(value)[-count:]
def _mid_value(value: Any, start: Any, count: Any = None) -> str:
try:
start = int(start)
text = _safe_text(value)
if count in (None, ""):
return text[start:]
return text[start:start + int(count)]
except Exception:
return ""
def _coalesce_value(*values: Any) -> Any:
for value in values:
if value not in (None, ""):
return value
return ""
def _bool_value(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
text = _safe_text(value).strip().lower()
if text in ("", "0", "false", "no", "none", "null", "нет", "ложь", "off"):
return False
return True
def startswith_value(value: Any, prefix: Any, ignore_case: bool = True) -> bool:
text = _normalize_search_text(value, ignore_case)
search = _normalize_search_text(prefix, ignore_case)
return bool(search) and text.startswith(search)
def endswith_value(value: Any, suffix: Any, ignore_case: bool = True) -> bool:
text = _normalize_search_text(value, ignore_case)
search = _normalize_search_text(suffix, ignore_case)
return bool(search) and text.endswith(search)
def regex_replace_first_value(value: Any, pattern: Any, replacement: Any = "", ignore_case: bool = True) -> str:
return regex_replace_value(value, pattern, replacement, 1, ignore_case)
def replace_if_contains_value(value: Any, needle: Any, new_value: Any, default: Any = None, ignore_case: bool = True) -> Any:
if contains_value(value, needle, ignore_case):
return new_value
return value if default is None else default
def _empty_value(value: Any, *also_empty: Any) -> bool:
empties = {""}
empties.update(_safe_text(x) for x in also_empty)
if value is None:
return True
if isinstance(value, str):
return value.strip() in empties
return _safe_text(value) in empties
def _filled_value(value: Any, *also_empty: Any) -> bool:
return not _empty_value(value, *also_empty)
def _if_empty_value(value: Any, true_value: Any, false_value: Any = "", *also_empty: Any) -> Any:
return true_value if _empty_value(value, *also_empty) else false_value
def _if_filled_value(value: Any, true_value: Any, false_value: Any = "", *also_empty: Any) -> Any:
return true_value if _filled_value(value, *also_empty) else false_value
def _format_thousands_value(value: Any, separator: Any = ".") -> str:
text = _safe_text(value).strip()
if not text:
return ""
sep = _safe_text(separator or ".")
sign = ""
if text.startswith(("+", "-")):
sign, text = text[0], text[1:]
# keep decimal part
decimal = ""
if "," in text and "." not in text:
integer, decimal = text.split(",", 1)
decimal = "," + decimal
elif "." in text:
integer, decimal = text.split(".", 1)
decimal = "." + decimal
else:
integer = text
integer = re.sub(r"\D", "", integer)
if not integer:
return sign + text
groups = []
while integer:
groups.append(integer[-3:])
integer = integer[:-3]
return sign + sep.join(reversed(groups)) + decimal
def _format_distance(value: Any, separator: Any = ".") -> str:
return _format_thousands_value(value, separator)
def _time_diff_value(value: Any, previous: Any, fmt: str = "auto_tt", default: Any = "") -> Any:
left = _parse_time_seconds(value, default=None)
right = _parse_time_seconds(previous, default=None)
if left is None or right is None:
return default
return _format_time(float(left) - float(right), fmt=fmt, default=default)
def _parse_date_value(value: Any, default: Any = None) -> Any:
if value in (None, ""):
return default
if hasattr(value, "year") and hasattr(value, "month") and hasattr(value, "day"):
return value
text = _safe_text(value).strip()
if not text:
return default
if "T" in text:
text = text.split("T", 1)[0]
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%Y/%m/%d", "%Y%m%d"):
try:
return datetime.strptime(text, fmt).date()
except Exception:
pass
match = re.search(r"(\d{4})[-./]?(\d{2})[-./]?(\d{2})", text)
if match:
try:
return datetime(int(match.group(1)), int(match.group(2)), int(match.group(3))).date()
except Exception:
pass
match = re.search(r"(\d{2})[./-](\d{2})[./-](\d{4})", text)
if match:
try:
return datetime(int(match.group(3)), int(match.group(2)), int(match.group(1))).date()
except Exception:
pass
return default
def _today_value(fmt: Any = "%Y-%m-%d") -> str:
try:
return datetime.now().strftime(_safe_text(fmt or "%Y-%m-%d"))
except Exception:
return datetime.now().strftime("%Y-%m-%d")
def _age_value(birth_date: Any, on_date: Any = None, default: Any = "") -> Any:
born = _parse_date_value(birth_date)
if not born:
return default
at_date = _parse_date_value(on_date) if on_date not in (None, "") else datetime.now().date()
if not at_date:
return default
try:
years = int(at_date.year) - int(born.year)
if (int(at_date.month), int(at_date.day)) < (int(born.month), int(born.day)):
years -= 1
return max(0, years)
except Exception:
return default
def _birth_year_value(birth_date: Any, default: Any = "") -> Any:
parsed = _parse_date_value(birth_date)
return parsed.year if parsed else default
def _plus_minus_value(value: Any, zero: Any = "0", default: Any = "") -> str:
number = _number_value(value, default=None)
if number is None:
return _safe_text(default if default not in (None, "") else value)
if number == 0:
return _safe_text(zero)
if number > 0:
if float(number).is_integer():
return f"+{int(number)}"
return f"+{number:g}"
if float(number).is_integer():
return str(int(number))
return f"{number:g}"
def _zero_as_value(value: Any, zero_text: Any = "E") -> Any:
number = _number_value(value, default=None)
if number == 0:
return zero_text
return value
def _golf_score_value(value: Any, even_text: Any = "E") -> str:
return _plus_minus_value(value, zero=even_text, default="")
def _extract_python_string_token(token_text: Any):
match = re.match(r"(?is)^([rubf]*)(\'\'\'|\"\"\"|\'|\")", str(token_text or ""))
if not match:
return None
prefix = match.group(1) or ""
quote = match.group(2)
start = len(prefix) + len(quote)
end = -len(quote)
if not str(token_text).endswith(quote):
return None
return prefix, quote, str(token_text)[start:end]
def _literal_text_from_string_inner(inner: Any, quote: str) -> str:
text = str(inner or "")
quote_char = quote[0] if quote else "'"
out: list[str] = []
i = 0
while i < len(text):
ch = text[i]
if ch == "\\" and i + 1 < len(text):
nxt = text[i + 1]
if nxt == quote_char or nxt == "\\":
out.append(nxt)
i += 2
continue
out.append("\\")
out.append(nxt)
i += 2
continue
out.append(ch)
i += 1
return "".join(out)
def _repair_trailing_backslash_before_quote(source: Any) -> str:
text = str(source or "")
out: list[str] = []
in_quote = None
triple = False
i = 0
while i < len(text):
ch = text[i]
if not in_quote:
if ch in ("'", '"'):
if text[i:i + 3] == ch * 3:
in_quote = ch
triple = True
out.append(ch * 3)
i += 3
continue
in_quote = ch
triple = False
out.append(ch)
i += 1
continue
if triple:
if text[i:i + 3] == in_quote * 3:
out.append(in_quote * 3)
i += 3
in_quote = None
triple = False
continue
out.append(ch)
i += 1
continue
if ch == "\\" and i + 1 < len(text) and text[i + 1] == in_quote:
j = i + 2
while j < len(text) and text[j].isspace():
j += 1
if j >= len(text) or text[j] in ",)]}:+-*/%<>=!&|":
out.append("\\\\")
out.append(in_quote)
i += 2
in_quote = None
continue
if ch == in_quote:
out.append(ch)
i += 1
in_quote = None
continue
out.append(ch)
i += 1
return "".join(out)
def _normalize_vmix_expression_string_literals(expr: Any) -> str:
source = _repair_trailing_backslash_before_quote(str(expr or ""))
try:
tokens = []
stream = io.StringIO(source).readline
for tok in tokenize.generate_tokens(stream):
if tok.type == tokenize.STRING:
parsed = _extract_python_string_token(tok.string)
if parsed:
prefix, quote, inner = parsed
prefix_lower = prefix.lower()
if "r" not in prefix_lower and "f" not in prefix_lower and "b" not in prefix_lower:
literal_text = _literal_text_from_string_inner(inner, quote)
tok = tokenize.TokenInfo(tok.type, repr(literal_text), tok.start, tok.end, tok.line)
tokens.append(tok)
return tokenize.untokenize(tokens)
except Exception:
return source
def _safe_eval_expr(expr: str, row: dict[str, Any], all_rows: list[dict[str, Any]] | None = None, row_index: int = 0) -> Any:
"""Expression engine compatible with eTiming vmixTransforms + project extensions."""
expr = str(expr or "").strip()
if not expr:
return ""
def get_value(name: Any, default: Any = "") -> Any:
return _get_nested_value(row, _safe_text(name), default)
def field_empty(name: Any, *also_empty: Any) -> bool:
return _empty_value(get_value(name), *also_empty)
def field_filled(name: Any, *also_empty: Any) -> bool:
return _filled_value(get_value(name), *also_empty)
rows_context = all_rows or []
def column_values(name: Any, numeric: Any = True) -> list[Any]:
key = _safe_text(name)
values: list[Any] = []
for item in rows_context:
raw = _get_nested_value(item, key, "")
if _empty_value(raw):
continue
if _bool_value(numeric):
num = _number_value(raw, default=None)
if num is not None:
values.append(num)
else:
values.append(raw)
return values
def column_avg(name: Any, default: Any = "") -> Any:
values = column_values(name, True)
return (sum(values) / len(values)) if values else default
def column_max(*args: Any) -> Any:
if len(args) == 1 and isinstance(args[0], str):
values = column_values(args[0], True)
return builtins.max(values) if values else ""
if len(args) == 1:
try:
return builtins.max(args[0])
except Exception:
return args[0]
return builtins.max(args)
def column_min(*args: Any) -> Any:
if len(args) == 1 and isinstance(args[0], str):
values = column_values(args[0], True)
return builtins.min(values) if values else ""
if len(args) == 1:
try:
return builtins.min(args[0])
except Exception:
return args[0]
return builtins.min(args)
def first_value(name: Any, default: Any = "") -> Any:
key = _safe_text(name)
if not rows_context:
return default
return _get_nested_value(rows_context[0], key, default)
def diff_from_first(name: Any, decimals: Any = None, default: Any = "") -> Any:
current = _number_value(get_value(name), default=None)
first = _number_value(first_value(name), default=None)
if current is None or first is None:
return default
value = current - first
if decimals not in (None, ""):
try:
return round(value, int(decimals))
except Exception:
return value
return value
def gap_from_first(name: Any, decimals: Any = 2, zero: Any = "0", default: Any = "") -> Any:
value = diff_from_first(name, decimals, default=None)
if value is None:
return default
return _plus_minus_value(value, zero=zero, default=default)
safe_globals: dict[str, Any] = {
"__builtins__": {},
"str": str,
"int": int,
"float": float,
"len": len,
"min": column_min,
"max": column_max,
"avg": column_avg,
"average": column_avg,
"mean": column_avg,
"sum": sum,
"round": round,
"abs": abs,
"True": True,
"False": False,
"None": None,
"get": get_value,
"val": get_value,
"concat": lambda *parts: "".join(_safe_text(p) for p in parts if p is not None),
"join": _join_values,
"split": _split_value,
"part": _split_value,
"left": _left_value,
"right": _right_value,
"mid": _mid_value,
"replace": lambda v, old, new="": _safe_text(v).replace(_safe_text(old), _safe_text(new)),
"swap": lambda v, sep=" ": _safe_text(sep).join(reversed([p for p in _safe_text(v).split(_safe_text(sep)) if p])),
"upper": lambda v: _safe_text(v).upper(),
"lower": lambda v: _safe_text(v).lower(),
"title_case": lambda v: _safe_text(v).title(),
"to_title": lambda v: _safe_text(v).title(),
"strip": lambda v: _safe_text(v).strip(),
"coalesce": _coalesce_value,
"tpl": lambda template: template_value(template, row),
"template": lambda template: template_value(template, row),
"number": _number_value,
"num": _number_value,
"today": _today_value,
"date_today": _today_value,
"age": _age_value,
"age_at": _age_value,
"years_old": _age_value,
"birth_year": _birth_year_value,
"time_seconds": _parse_time_seconds,
"parse_time": _parse_time_seconds,
"seconds": _parse_time_seconds,
"format_time": _format_time,
"time_format": _format_time,
"fmt_time": _format_time,
"time_diff": _time_diff_value,
"format_time_diff": _time_diff_value,
"thousands": _format_thousands_value,
"format_thousands": _format_thousands_value,
"format_distance": _format_distance,
"distance": _format_distance,
"bool": _bool_value,
"case": lambda condition, yes, no="": yes if _bool_value(condition) else no,
"iif": lambda condition, yes, no="": yes if _bool_value(condition) else no,
"empty": _empty_value,
"blank": _empty_value,
"is_empty": _empty_value,
"is_blank": _empty_value,
"filled": _filled_value,
"not_empty": _filled_value,
"is_filled": _filled_value,
"if_empty": _if_empty_value,
"if_blank": _if_empty_value,
"if_filled": _if_filled_value,
"if_not_empty": _if_filled_value,
"field_empty": field_empty,
"field_blank": field_empty,
"field_filled": field_filled,
"field_not_empty": field_filled,
"if_field_empty": lambda key, yes, no="", *also_empty: yes if field_empty(key, *also_empty) else no,
"if_field_blank": lambda key, yes, no="", *also_empty: yes if field_empty(key, *also_empty) else no,
"if_field_filled": lambda key, yes, no="", *also_empty: yes if field_filled(key, *also_empty) else no,
"if_field_not_empty": lambda key, yes, no="", *also_empty: yes if field_filled(key, *also_empty) else no,
"contains": contains_value,
"not_contains": lambda v, n, ignore_case=True: not contains_value(v, n, ignore_case),
"starts": startswith_value,
"startswith": startswith_value,
"ends": endswith_value,
"endswith": endswith_value,
"equals": equals_value,
"eq": equals_value,
"regex": regex_value,
"match": regex_value,
"re_replace": regex_replace_value,
"regex_replace": regex_replace_value,
"regex_sub": regex_replace_value,
"rx_replace": regex_replace_value,
"re_replace_first": regex_replace_first_value,
"regex_replace_first": regex_replace_first_value,
"re_remove": lambda v, p, count=0, ignore_case=True: regex_replace_value(v, p, "", count, ignore_case),
"regex_remove": lambda v, p, count=0, ignore_case=True: regex_replace_value(v, p, "", count, ignore_case),
"rx_remove": lambda v, p, count=0, ignore_case=True: regex_replace_value(v, p, "", count, ignore_case),
"re_extract": regex_extract_value,
"regex_extract": regex_extract_value,
"rx_extract": regex_extract_value,
"if_contains": lambda v, n, yes, no="", ignore_case=True: yes if contains_value(v, n, ignore_case) else no,
"if_not_contains": lambda v, n, yes, no="", ignore_case=True: yes if not contains_value(v, n, ignore_case) else no,
"if_regex": lambda v, p, yes, no="", ignore_case=True: yes if regex_value(v, p, ignore_case) else no,
"if_match": lambda v, p, yes, no="", ignore_case=True: yes if regex_value(v, p, ignore_case) else no,
"if_equals": lambda v, e, yes, no="", ignore_case=True: yes if equals_value(v, e, ignore_case) else no,
"if_eq": lambda v, e, yes, no="", ignore_case=True: yes if equals_value(v, e, ignore_case) else no,
"first_match": first_match_value,
"map_contains": first_match_value,
"replace_if_contains": replace_if_contains_value,
"plus_minus": _plus_minus_value,
"zero_as": _zero_as_value,
"golf_score": _golf_score_value,
"values": column_values,
"column_values": column_values,
"first_value": first_value,
"first": first_value,
"row_index": lambda: row_index,
"row_number": lambda: row_index + 1,
"diff_first": diff_from_first,
"diff_from_first": diff_from_first,
"gap_first": gap_from_first,
"gap_from_first": gap_from_first,
}
class SafeEvalLocals(dict):
def __missing__(self, key: str) -> Any:
if key in safe_globals:
raise KeyError(key)
return ""
reserved = set(safe_globals.keys())
safe_locals = SafeEvalLocals({
str(k): ("" if v is None else v)
for k, v in row.items()
if re.fullmatch(r"[A-Za-z_А-Яа-яЁё][\wА-Яа-яЁё]*", str(k)) and str(k) not in reserved
})
try:
normalized_expr = _normalize_vmix_expression_string_literals(expr)
with warnings.catch_warnings():
warnings.simplefilter("ignore", SyntaxWarning)
code = compile(normalized_expr, "<vmix-json-expr>", "eval")
return eval(code, safe_globals, safe_locals)
except Exception:
return ""
def _apply_expression_columns(row: dict[str, Any], columns_config: list[dict[str, Any]] | None, all_rows: list[dict[str, Any]] | None = None, row_index: int = 0) -> dict[str, Any]:
working = dict(row)
for column in columns_config or []:
if not column.get("enabled", True):
continue
mode = str(column.get("mode") or "").lower()
expr = str(column.get("expr") or "").strip()
source = str(column.get("source") or "").strip()
if not expr and source.startswith("expr:"):
expr = source[5:].strip()
elif not expr and source.startswith("="):
expr = source[1:].strip()
if mode == "expr" or expr:
key = str(column.get("key") or "").strip()
if key:
value = _safe_eval_expr(expr or source, working, all_rows=all_rows, row_index=row_index)
if value in (None, ""):
value = column.get("default", "")
working[key] = value
return working
def apply_column_config(rows: list[dict[str, Any]], columns_config: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""
Build rows for vMix by configurable column rules.
Column format:
- key: output field name
- source: input field name/dotted path, template like "{position} {player}", or expression if mode="expr"
- expr: formula for computed columns
- mode: "field" | "template" | "expr". Empty mode is auto.
- default: fallback value
- enabled: false hides the column
"""
if not columns_config:
return rows
enabled_columns = [c for c in columns_config if c.get("enabled", True) and c.get("key")]
out_rows: list[dict[str, Any]] = []
for row_index, base_row in enumerate(rows):
row = _apply_expression_columns(base_row, enabled_columns, all_rows=rows, row_index=row_index)
out: dict[str, Any] = {}
for column in enabled_columns:
key = str(column.get("key", "")).strip()
source = str(column.get("source", key) or key).strip()
default = column.get("default", "")
mode = str(column.get("mode") or "").lower()
expr = str(column.get("expr") or "").strip()
if mode == "expr" or expr or source.startswith("expr:") or source.startswith("="):
# Expression columns have already been evaluated into row[key].
value = row.get(key, default)
elif mode == "template" or ("{" in source and "}" in source):
value = _render_template(source, row)
else:
value = _get_nested_value(row, source, default)
if value in (None, ""):
value = default
out[key] = value
out_rows.append(out)
return out_rows
def apply_configured_json(
rows: list[dict[str, Any]],
config: dict[str, Any],
limit: int | None = None,
source_resolver: Callable[[dict[str, Any]], Any] | None = None,
) -> list[dict[str, Any]]:
# 1) First merge additional JSON sources, so formulas/columns can use joined fields.
joins = config.get("joins") or []
if joins and source_resolver is not None:
rows = apply_join_rules(rows, joins, source_resolver)
# 2) Then apply limit. URL ?limit=... has priority; config.default_limit works silently.
configured_limit = int(config.get("default_limit") or 0)
final_limit = configured_limit if limit is None else limit
if final_limit:
rows = rows[:final_limit]
# 3) Finally build output columns/formulas.
columns = config.get("columns") or []
output_mode = str(config.get("output_mode") or "columns").lower()
if output_mode == "all":
return [_apply_expression_columns(row, columns, all_rows=rows, row_index=index) for index, row in enumerate(rows)]
return apply_column_config(rows, columns)
# ---------------------------------------------------------------------------
# Full RusGolf data aggregation helpers
# ---------------------------------------------------------------------------
def _merge_rows_by_identity(*row_groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Merge rows from scores/results/profile sources by player id or normalized name."""
merged: dict[str, dict[str, Any]] = {}
order: list[str] = []
def identity(row: dict[str, Any]) -> str:
pid = str(row.get("player_id") or row.get("id") or row.get("profile_player_id") or "").strip()
if pid:
return f"id:{pid}"
name = str(row.get("player_full_name") or row.get("player") or "").strip().lower()
if name:
return f"name:{re.sub(r'\\s+', ' ', name)}"
number = str(row.get("number") or row.get("player_number") or row.get("bib") or "").strip()
return f"row:{number}:{len(order)}"
for rows in row_groups:
for row in rows or []:
if not isinstance(row, dict):
continue
key = identity(row)
if key not in merged:
merged[key] = {}
order.append(key)
dest = merged[key]
for field, value in row.items():
if isinstance(value, (dict, list)):
continue
if dest.get(field) in (None, "") and value not in (None, ""):
dest[field] = value
elif field not in dest:
dest[field] = value
# raw-prefixed fields are always preserved if unique enough.
for field, value in row.items():
if isinstance(value, (dict, list)):
continue
if str(field).startswith(("score_", "result_", "profile_")) and value not in (None, ""):
dest.setdefault(field, value)
return [merged[key] for key in order]
def _extract_number(flat: dict[str, Any]) -> Any:
return get_scalar_any(
flat,
"number", "no", "bib", "startNumber", "start_number", "playerNumber", "player_number",
"participantNumber", "participant_number", default="",
)
def _extract_points(flat: dict[str, Any]) -> Any:
return get_scalar_any(
flat,
"points_total", "totalPoints", "total_points", "points", "scorePoints", "score_points",
"stablefordPoints", "stableford_points", "resultPoints", "result_points", default="",
)
def _last_name_for_sort(row: dict[str, Any]) -> str:
last = str(row.get("player_last_name") or "").strip()
if last:
return last.lower()
player = str(row.get("player") or row.get("player_full_name") or "").strip()
return player.split()[0].lower() if player else ""
def sort_full_rows(rows: list[dict[str, Any]], sort_by: str = "total_points", sort_dir: str = "desc") -> list[dict[str, Any]]:
sort_by = str(sort_by or "total_points").lower()
reverse = str(sort_dir or "desc").lower() == "desc"
def numeric(value: Any) -> float | None:
return _to_number(value)
def key(row: dict[str, Any]):
if sort_by in {"number", "player_number", "bib"}:
n = numeric(row.get("number") or row.get("player_number") or row.get("bib"))
return (999999 if n is None else n, _last_name_for_sort(row))
if sort_by in {"lastname", "last_name", "surname", "player_last_name", "name"}:
return (_last_name_for_sort(row), str(row.get("player_first_name") or "").lower())
if sort_by in {"total", "score", "strokes"}:
n = numeric(row.get("scorecard_total") or row.get("total"))
return (-999999 if n is None else n, _last_name_for_sort(row))
# default: points desc, then score asc if points missing/equal.
points = numeric(row.get("points_total") or row.get("points") or row.get("total_points"))
total = numeric(row.get("scorecard_total") or row.get("total"))
# reverse=True outside; use total as negative to keep lower score better when points equal.
return (-999999 if points is None else points, 999999 if total is None else -total, _last_name_for_sort(row))
return sorted(rows, key=key, reverse=reverse)
def normalize_full_individual_rows(
tournament: Any,
round_data: Any,
scores: Any,
results: Any | None = None,
players: list[dict[str, Any]] | None = None,
category_id: int | str | None = None,
sort_by: str = "total_points",
sort_dir: str = "desc",
) -> list[dict[str, Any]]:
"""Build one rich player table from tournament, round, scores and results JSON."""
score_rows = normalize_scores(scores, category_id=category_id)
for row in score_rows:
for key, value in list(row.items()):
if key not in row and value not in (None, ""):
row[f"score_{key}"] = value
result_rows = normalize_scores(results, category_id=category_id) if results is not None else []
prefixed_results: list[dict[str, Any]] = []
for row in result_rows:
prefixed = dict(row)
for key, value in row.items():
if not isinstance(value, (dict, list)):
prefixed.setdefault(f"result_{key}", value)
prefixed_results.append(prefixed)
profile_rows = players or []
rows = _merge_rows_by_identity(score_rows, prefixed_results)
rows = enrich_rows_with_players(rows, profile_rows)
# Final field cleanup / derived columns.
normalized: list[dict[str, Any]] = []
for index, row in enumerate(rows, start=1):
out = dict(row)
flat = flatten_dict(out)
out.setdefault("position", get_scalar_any(flat, "position", "place", "rank", default=index))
out.setdefault("number", _extract_number(flat))
out.setdefault("player_number", out.get("number", ""))
out.setdefault("points_total", _extract_points(flat) or out.get("points_total", ""))
out.setdefault("total_points", out.get("points_total", ""))
if out.get("player_last_name") in (None, "") or out.get("player_first_name") in (None, ""):
last, first = _player_name_parts(flat)
if out.get("player_last_name") in (None, ""):
out["player_last_name"] = last
if out.get("player_first_name") in (None, ""):
out["player_first_name"] = first
if out.get("player") in (None, ""):
out["player"] = _join_name(out.get("player_last_name"), out.get("player_first_name")) or _extract_player_name(flat)
out["player_full_name"] = out.get("player_full_name") or out.get("player") or ""
if out.get("scorecard_total") not in (None, ""):
out.setdefault("total", out.get("scorecard_total"))
if out.get("points_total") not in (None, ""):
out.setdefault("points", out.get("points_total"))
out["leaderboard_type"] = "individual"
normalized.append(out)
return sort_full_rows(normalized, sort_by=sort_by, sort_dir=sort_dir)
def _candidate_score_for_team_list(items: list[dict[str, Any]], path: str = "") -> int:
if not items:
return 0
keys: set[str] = set()
for item in items[:10]:
if isinstance(item, dict):
keys |= set(map(str, flatten_dict(item).keys()))
text = " ".join(keys).lower() + " " + path.lower()
score = 0
for word in ("team", "teams", "match", "matches", "club", "points", "wins", "loss", "draw"):
if word in text:
score += 7
if "team" in path.lower():
score += 20
if any(word in text for word in ("player", "hole", "scorecard")):
score -= 3
return score + min(len(items), 50)
def find_team_like_list(data: Any, preferred: str = "team") -> list[dict[str, Any]]:
direct = as_list(data)
best_items = direct if direct and all(isinstance(x, dict) for x in direct) else []
best_score = _candidate_score_for_team_list(best_items, "root") if best_items else -1
for path, obj in _walk(data):
if isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj[: min(len(obj), 10)]):
score = _candidate_score_for_team_list(obj, path)
if preferred and preferred.lower() in path.lower():
score += 10
if score > best_score:
best_score = score
best_items = obj
return best_items
def _extract_team_name(flat: dict[str, Any]) -> str:
return str(get_scalar_any(
flat,
"team", "teamName", "team_name", "name", "title", "club", "clubName", "club_name", default="",
) or "").strip()
def _extract_team_id(flat: dict[str, Any]) -> Any:
return get_scalar_any(flat, "team_id", "teamId", "id", "club_id", "clubId", default="")
def _team_identity(row: dict[str, Any]) -> str:
tid = str(row.get("team_id") or row.get("id") or "").strip()
if tid:
return f"id:{tid}"
name = str(row.get("team") or row.get("team_name") or "").strip().lower()
return f"name:{name}" if name else f"row:{id(row)}"
def normalize_team_rows(teams_data: Any, team_matches_data: Any | None = None, category_id: int | str | None = None, sort_by: str = "points_total", sort_dir: str = "desc") -> list[dict[str, Any]]:
"""Normalize team standings and team match rows into one team leaderboard."""
team_items = find_team_like_list(teams_data, preferred="team")
match_items = find_team_like_list(team_matches_data, preferred="match") if team_matches_data is not None else []
teams: dict[str, dict[str, Any]] = {}
order: list[str] = []
def ensure_team(flat: dict[str, Any], prefix: str = "team") -> dict[str, Any]:
team_id = _extract_team_id(flat)
team_name = _extract_team_name(flat)
key = f"id:{team_id}" if team_id not in (None, "") else f"name:{team_name.lower()}"
if not key or key == "name:":
key = f"row:{len(order)}"
if key not in teams:
teams[key] = {
"position": get_scalar_any(flat, "position", "place", "rank", default=len(order) + 1),
"team_id": team_id,
"team": team_name,
"team_name": team_name,
"club": get_scalar_any(flat, "club", "clubName", "region", default=""),
"points_total": _extract_points(flat),
"total_points": _extract_points(flat),
"matches_count": 0,
"wins": get_scalar_any(flat, "wins", "win", "w", default=""),
"draws": get_scalar_any(flat, "draws", "draw", "d", default=""),
"losses": get_scalar_any(flat, "losses", "loss", "l", default=""),
"leaderboard_type": "team",
}
order.append(key)
dest = teams[key]
for k, v in flat.items():
if isinstance(v, (dict, list)):
continue
safe = str(k).replace(".", "_")
dest.setdefault(safe, v)
dest.setdefault(f"{prefix}_{safe}", v)
return dest
for item in team_items:
if not isinstance(item, dict):
continue
flat = flatten_dict(item)
if not _row_matches_category(flat, category_id):
continue
ensure_team(flat, prefix="team")
# Attach match data to teams when possible, and keep a compact match list.
for item in match_items:
if not isinstance(item, dict):
continue
flat = flatten_dict(item)
if not _row_matches_category(flat, category_id):
continue
# Try every team-like subobject first.
attached = False
for path, obj in _walk(item):
if isinstance(obj, dict):
f = flatten_dict(obj)
if _extract_team_name(f) or _extract_team_id(f):
dest = ensure_team(f, prefix="match_team")
dest["matches_count"] = int(_to_number(dest.get("matches_count")) or 0) + 1
attached = True
if not attached:
dest = ensure_team(flat, prefix="match")
dest["matches_count"] = int(_to_number(dest.get("matches_count")) or 0) + 1
# Store common match-level info on all matching teams if keys were direct.
match_no = get_scalar_any(flat, "match", "matchNo", "match_no", "round", "roundNumber", default="")
for dest in teams.values():
dest.setdefault("last_match_no", match_no)
rows = list(teams.values())
for idx, row in enumerate(rows, start=1):
row.setdefault("position", idx)
if row.get("points_total") in (None, ""):
row["points_total"] = _extract_points(row)
row["total_points"] = row.get("points_total", "")
return sort_team_rows(rows, sort_by=sort_by, sort_dir=sort_dir)
def sort_team_rows(rows: list[dict[str, Any]], sort_by: str = "points_total", sort_dir: str = "desc") -> list[dict[str, Any]]:
sort_by = str(sort_by or "points_total").lower()
reverse = str(sort_dir or "desc").lower() == "desc"
def key(row: dict[str, Any]):
if sort_by in {"team", "team_name", "name"}:
return str(row.get("team") or row.get("team_name") or "").lower()
if sort_by in {"number", "team_id", "id"}:
n = _to_number(row.get("team_id") or row.get("id"))
return -999999 if n is None else n
points = _to_number(row.get("points_total") or row.get("total_points") or row.get("points"))
pos = _to_number(row.get("position"))
return (-999999 if points is None else points, -999999 if pos is None else -pos)
return sorted(rows, key=key, reverse=reverse)
def build_unified_full_json(
*,
competition_id: int,
round_id: int | None,
category_id: int | str | None = None,
tournament: Any,
round_data: Any | None = None,
scores: Any | None = None,
results: Any | None = None,
players: list[dict[str, Any]] | None = None,
team_matches: Any | None = None,
teams: Any | None = None,
sort_by: str = "total_points",
sort_dir: str = "desc",
mode: str = "auto",
) -> dict[str, Any]:
"""Build a single rich JSON for both individual and team competitions."""
individual_rows = normalize_full_individual_rows(
tournament=tournament,
round_data=round_data,
scores=scores or [],
results=results,
players=players or [],
category_id=category_id,
sort_by=sort_by,
sort_dir=sort_dir,
) if scores is not None else []
team_rows = normalize_team_rows(teams, team_matches, category_id=category_id, sort_by=sort_by, sort_dir=sort_dir) if (teams is not None or team_matches is not None) else []
detected = "team" if team_rows and (str(mode).lower() == "team" or len(team_rows) >= max(1, len(individual_rows) // 4)) else "individual"
if str(mode).lower() in {"individual", "team"}:
detected = str(mode).lower()
primary_rows = team_rows if detected == "team" else individual_rows
return {
"competition_id": competition_id,
"round_id": round_id,
"category_id": category_id,
"competition_type": detected,
"sort_by": sort_by,
"sort_dir": sort_dir,
"counts": {
"players": len(individual_rows),
"teams": len(team_rows),
"primary": len(primary_rows),
},
"sources": {
"tournament": f"/api/livescoring/{competition_id}",
"round": f"/api/livescoring/{competition_id}/rounds/{round_id}" if round_id else "",
"scores": f"/api/livescoring/{competition_id}/rounds/{round_id}/scores" if round_id else "",
"results": f"/api/livescoring/{competition_id}/rounds/{round_id}/results" if round_id else f"/api/livescoring/{competition_id}/results",
"team_matches": f"/api/livescoring/{competition_id}/results/team-matches",
"teams": f"/api/livescoring/{competition_id}/results/teams",
},
"tournament": tournament,
"round": round_data,
"players": individual_rows,
"teams": team_rows,
"leaderboard": primary_rows,
"raw": {
"scores": scores,
"results": results,
"team_matches": team_matches,
"teams": teams,
},
}