eberling1's picture
Update vote processing to default HF_REVISION to 'staging'
593e0a3
Raw
History Blame Contribute Delete
9.07 kB
"""Vote payload validation and commit logic for the climbing-holds dataset."""
from __future__ import annotations
import hashlib
import os
import re
from collections import Counter
from typing import Any
from huggingface_hub import HfApi
import config
import hf_repo
RATING_MIN = 1
RATING_MAX = 5
ISO8601_PATTERN = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"
)
class DuplicateVoteError(Exception):
"""Raised when the same voter has already voted for a given hold."""
def infer_dominant_values(votes: list[dict[str, Any]]) -> tuple[str | None, str | None]:
"""Return the most-voted manufacturer and model from a list of vote entries.
Only non-empty, non-whitespace values are counted. Ties are broken by
picking the lexicographically smallest value so the result is deterministic.
Returns (None, None) when no valid votes exist.
"""
manufacturers: list[str] = []
models: list[str] = []
for vote in votes:
if not isinstance(vote, dict):
continue
m = vote.get("hold_manufacturer")
if isinstance(m, str) and m.strip():
manufacturers.append(m.strip())
mo = vote.get("hold_model")
if isinstance(mo, str) and mo.strip():
models.append(mo.strip())
def _dominant(values: list[str]) -> str | None:
if not values:
return None
counts = Counter(values)
max_count = max(counts.values())
candidates = sorted(k for k, v in counts.items() if v == max_count)
return candidates[0]
return _dominant(manufacturers), _dominant(models)
def _validate_rating(value: Any) -> int:
if not isinstance(value, (int, float)):
raise ValueError("hold_3d_file_rating must be a number")
r = int(value) if isinstance(value, float) else value
if r != value or r < RATING_MIN or r > RATING_MAX:
raise ValueError(f"hold_3d_file_rating must be an integer between {RATING_MIN} and {RATING_MAX}")
return r
def _validate_vote_datetime(value: Any) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError("vote_datetime must be a non-empty string")
s = value.strip()
if not ISO8601_PATTERN.match(s):
raise ValueError("vote_datetime must be ISO 8601 format (e.g. 2025-03-12T14:30:00.000Z)")
return s
def validate_vote_payload(
body: dict[str, Any],
hf_token: str | None = None,
) -> tuple[dict[str, Any], str | None]:
"""
Validate vote payload and build the stored vote entry (no token).
Returns (vote_entry, hf_token_or_none). Raises ValueError on validation error.
The user's hf_token is passed via the Authorization header and must never be persisted.
"""
required = ("hold_id", "hold_manufacturer", "hold_model", "hold_3d_file_rating", "vote_datetime", "anonymous")
for key in required:
if key not in body:
raise ValueError(f"Missing required field: {key}")
hold_id = body["hold_id"]
if not isinstance(hold_id, str) or not hold_id.strip():
raise ValueError("hold_id must be a non-empty string")
hold_id = hold_id.strip()
hold_manufacturer = body["hold_manufacturer"]
if not isinstance(hold_manufacturer, str):
raise ValueError("hold_manufacturer must be a string")
hold_manufacturer = hold_manufacturer.strip()
hold_model = body["hold_model"]
if not isinstance(hold_model, str):
raise ValueError("hold_model must be a string")
hold_model = hold_model.strip()
rating = _validate_rating(body["hold_3d_file_rating"])
vote_datetime = _validate_vote_datetime(body["vote_datetime"])
anonymous = body["anonymous"]
if not isinstance(anonymous, bool):
raise ValueError("anonymous must be a boolean")
resolved_token: str | None = None
if not anonymous:
if not hf_token or not hf_token.strip():
raise ValueError("Authorization header with Bearer token is required when anonymous is false")
resolved_token = hf_token.strip()
entry = {
"hold_id": hold_id,
"hold_manufacturer": hold_manufacturer,
"hold_model": hold_model,
"hold_3d_file_rating": rating,
"vote_datetime": vote_datetime,
"anonymous": anonymous,
}
return entry, resolved_token
def compute_voter_fingerprint(
client_ip: str,
anonymous: bool,
user_token: str | None,
) -> str:
"""Build a unique, stable fingerprint for a voter.
- Non-anonymous with a valid HF token: resolve the HF username via whoami.
- Anonymous or token resolution fails: SHA-256 of the client IP.
"""
if not anonymous and user_token:
try:
info = HfApi().whoami(token=user_token)
username = info.get("name") or info.get("user")
if username:
return f"hf:{username}"
except Exception:
pass
return f"ip:{hashlib.sha256(client_ip.encode()).hexdigest()}"
def _resolve_commit_token(anonymous: bool, user_token: str | None) -> str:
hf_token = os.environ.get("HF_TOKEN")
if anonymous or not user_token:
if not hf_token:
raise RuntimeError("HF_TOKEN is not set (required for anonymous votes or when user token is missing)")
return hf_token
return user_token
def _has_existing_vote(hold_votes: list[dict[str, Any]], fingerprint: str) -> bool:
return any(v.get("voter_fingerprint") == fingerprint for v in hold_votes)
def _build_metadata_update(
repo_id: str,
token: str,
revision: str | None,
hold_id: str,
hold_votes: list[dict[str, Any]],
) -> tuple[str, dict[str, Any]] | None:
"""Load metadata.json for the hold and apply dominant manufacturer/model from votes.
Returns (metadata_path, updated_metadata) if any field changed, else None.
"""
metadata_path = f"{hold_id}/{config.METADATA_FILENAME}"
metadata = hf_repo.load_json_file_optional(
repo_id, metadata_path, token, revision, default=None
)
if not isinstance(metadata, dict):
config.logger.warning(
"Could not load metadata for hold '%s'; skipping metadata update from votes.", hold_id
)
return None
dominant_manufacturer, dominant_model = infer_dominant_values(hold_votes)
updated = dict(metadata)
changed = False
if dominant_manufacturer and updated.get("manufacturer") != dominant_manufacturer:
updated["manufacturer"] = dominant_manufacturer
changed = True
if dominant_model and updated.get("model") != dominant_model:
updated["model"] = dominant_model
changed = True
if not changed:
return None
config.logger.info(
"Updating hold '%s' metadata from votes: manufacturer=%r, model=%r",
hold_id,
dominant_manufacturer,
dominant_model,
)
return metadata_path, updated
def process_vote(
api: HfApi,
repo_id: str,
revision: str | None,
vote_entry: dict[str, Any],
user_token: str | None,
client_ip: str,
) -> dict[str, Any]:
"""
Load hold votes, check for duplicates, append entry, commit votes.json and
metadata.json (with dominant manufacturer/model from all votes) together.
On commit failure with user token, retries with HF_TOKEN.
Raises DuplicateVoteError if the same voter already voted for this hold.
"""
anonymous = vote_entry.get("anonymous", True)
token = _resolve_commit_token(anonymous, user_token)
hold_id = vote_entry["hold_id"]
hold_votes_path = f"{hold_id}/{config.VOTES_FILENAME}"
fingerprint = compute_voter_fingerprint(client_ip, anonymous, user_token)
hold_votes: list[Any] = hf_repo.load_json_file_optional(
repo_id, hold_votes_path, token, revision, default=[]
)
if not isinstance(hold_votes, list):
hold_votes = []
if _has_existing_vote(hold_votes, fingerprint):
raise DuplicateVoteError("You have already voted for this hold")
vote_entry["voter_fingerprint"] = fingerprint
hold_votes.append(vote_entry)
hold_votes_map = {hold_votes_path: hold_votes}
metadata_update = _build_metadata_update(repo_id, token, revision, hold_id, hold_votes)
def _do_commit(commit_token: str) -> None:
hf_repo.commit_vote_updates(
api,
repo_id=repo_id,
token=commit_token,
revision=revision,
hold_votes=hold_votes_map,
metadata_update=metadata_update,
)
try:
_do_commit(token)
return {"status": "success", "message": "Vote recorded"}
except Exception as exc:
if not anonymous and user_token and token == user_token:
hf_token = os.environ.get("HF_TOKEN")
if hf_token and hf_token != user_token:
config.logger.warning("Commit with user token failed, retrying with HF_TOKEN: %s", exc)
_do_commit(hf_token)
return {"status": "success", "message": "Vote recorded"}
raise