| """Trial install registration and the local credential cache. |
| |
| The proof allowance is counted on api.affix-io.com against a subject digest. |
| Files written here are a cache. Deleting them forces re-registration against |
| the same server-side counter and does not restore spent proofs. |
| """ |
|
|
| |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import os |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Any |
|
|
| from .errors import ConfigurationError |
|
|
| TRIAL_PRODUCT = "huggingface" |
|
|
| |
| |
| |
| PUBLIC_TRIAL_API_KEY = "aio_50754e6f66c7a573a46a16a52e1b1edffba48c395b708316" |
|
|
|
|
| @dataclass(frozen=True) |
| class TrialCredentials: |
| """Install credentials issued by AffixIO and cached locally.""" |
|
|
| install_id: str |
| install_secret: str |
| subject_id: str |
| product: str = TRIAL_PRODUCT |
|
|
| def headers(self) -> dict[str, str]: |
| return { |
| "X-Affix-Install-Id": self.install_id, |
| "X-Affix-Install-Secret": self.install_secret, |
| } |
|
|
| def to_dict(self) -> dict[str, str]: |
| return { |
| "install_id": self.install_id, |
| "install_secret": self.install_secret, |
| "subject_id": self.subject_id, |
| "product": self.product, |
| } |
|
|
| @classmethod |
| def from_dict(cls, data: dict[str, Any]) -> TrialCredentials | None: |
| try: |
| return cls( |
| install_id=str(data["install_id"]), |
| install_secret=str(data["install_secret"]), |
| subject_id=str(data["subject_id"]), |
| product=str(data.get("product", TRIAL_PRODUCT)), |
| ) |
| except (KeyError, TypeError): |
| return None |
|
|
|
|
| @dataclass(frozen=True) |
| class TrialQuota: |
| """Allowance reported by AffixIO.""" |
|
|
| limit: int |
| used: int |
| remaining: int |
|
|
| @property |
| def exhausted(self) -> bool: |
| return self.remaining <= 0 |
|
|
| @classmethod |
| def from_headers(cls, headers: Any) -> TrialQuota | None: |
| try: |
| limit = headers.get("X-Affix-Trial-Limit") |
| used = headers.get("X-Affix-Trial-Used") |
| remaining = headers.get("X-Affix-Trial-Remaining") |
| except AttributeError: |
| return None |
| if limit is None or used is None or remaining is None: |
| return None |
| try: |
| return cls(limit=int(limit), used=int(used), remaining=int(remaining)) |
| except (TypeError, ValueError): |
| return None |
|
|
|
|
| def credentials_path() -> Path: |
| """Cache location for install credentials.""" |
| override = os.getenv("AFFIX_INSTALL_FILE") |
| if override: |
| return Path(override).expanduser() |
| base = os.getenv("XDG_CACHE_HOME") |
| root = Path(base).expanduser() if base else Path.home() / ".cache" |
| return root / "affix-huggingface" / "install.json" |
|
|
|
|
| def load_credentials(path: Path | None = None) -> TrialCredentials | None: |
| target = path or credentials_path() |
| try: |
| raw = json.loads(target.read_text(encoding="utf-8")) |
| except (OSError, ValueError): |
| return None |
| if not isinstance(raw, dict): |
| return None |
| return TrialCredentials.from_dict(raw) |
|
|
|
|
| def save_credentials(credentials: TrialCredentials, path: Path | None = None) -> None: |
| target = path or credentials_path() |
| try: |
| target.parent.mkdir(parents=True, exist_ok=True) |
| target.write_text( |
| json.dumps(credentials.to_dict(), indent=2) + "\n", |
| encoding="utf-8", |
| ) |
| target.chmod(0o600) |
| except OSError: |
| |
| |
| return |
|
|
|
|
| def subject_digest(raw: str) -> str: |
| """Hash a subject locally so the identity itself never leaves the host.""" |
| value = raw.strip().lower() |
| if not value: |
| raise ConfigurationError("Trial subject must not be empty") |
| return hashlib.sha256(f"affix-hf-subject:{value}".encode()).hexdigest() |
|
|
|
|
| def resolve_subject( |
| *, |
| subject: str | None = None, |
| hf_token: str | None = None, |
| ) -> str: |
| """ |
| Determine the identity the allowance is counted against. |
| |
| Order of preference: explicit subject, AFFIX_TRIAL_SUBJECT, then the |
| Hugging Face account behind the token. Resolution fails closed, because a |
| random fallback would hand out a fresh allowance on every install. |
| """ |
| explicit = subject or os.getenv("AFFIX_TRIAL_SUBJECT") |
| if explicit: |
| return subject_digest(explicit) |
|
|
| token = hf_token or os.getenv("HF_TOKEN") |
| if not token: |
| raise ConfigurationError( |
| "Cannot determine a trial subject. Set HF_TOKEN so the Hugging Face " |
| "account can be resolved, or pass subject= explicitly." |
| ) |
|
|
| try: |
| from huggingface_hub import whoami |
|
|
| info = whoami(token=token) |
| except Exception as exc: |
| raise ConfigurationError( |
| "Could not resolve the Hugging Face account for the trial allowance. " |
| "Pass subject= explicitly if this host cannot reach huggingface.co." |
| ) from exc |
|
|
| name = None |
| if isinstance(info, dict): |
| name = info.get("id") or info.get("name") |
| else: |
| name = getattr(info, "id", None) or getattr(info, "name", None) |
| if not name: |
| raise ConfigurationError("Hugging Face account lookup returned no identity") |
| return subject_digest(f"hf:{name}") |
|
|