From acbff854f2da96bddcaede1385e7fefeba0fb34b Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 9 Apr 2026 19:45:45 -0500 Subject: initial commit --- src/srht_contrib/utils/__init__.py | 1 + src/srht_contrib/utils/dates.py | 37 ++++++++++++++++++++++++++++++++++ src/srht_contrib/utils/identity.py | 27 +++++++++++++++++++++++++ src/srht_contrib/utils/repositories.py | 31 ++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 src/srht_contrib/utils/__init__.py create mode 100644 src/srht_contrib/utils/dates.py create mode 100644 src/srht_contrib/utils/identity.py create mode 100644 src/srht_contrib/utils/repositories.py (limited to 'src/srht_contrib/utils') diff --git a/src/srht_contrib/utils/__init__.py b/src/srht_contrib/utils/__init__.py new file mode 100644 index 0000000..e9b9a78 --- /dev/null +++ b/src/srht_contrib/utils/__init__.py @@ -0,0 +1 @@ +"""Utility helpers.""" diff --git a/src/srht_contrib/utils/dates.py b/src/srht_contrib/utils/dates.py new file mode 100644 index 0000000..ad72968 --- /dev/null +++ b/src/srht_contrib/utils/dates.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime, time, timedelta + + +def ensure_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt.astimezone(UTC) + + +def parse_date(value: str) -> date: + return date.fromisoformat(value) + + +def parse_datetime(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + return ensure_utc(datetime.fromisoformat(normalized)) + + +def date_range(start: date, end: date) -> list[date]: + days: list[date] = [] + current = start + while current <= end: + days.append(current) + current += timedelta(days=1) + return days + + +def year_bounds(year: int) -> tuple[date, date]: + return date(year, 1, 1), date(year, 12, 31) + + +def date_to_utc_bounds(value: date) -> tuple[datetime, datetime]: + start = datetime.combine(value, time.min, tzinfo=UTC) + end = datetime.combine(value, time.max, tzinfo=UTC) + return start, end diff --git a/src/srht_contrib/utils/identity.py b/src/srht_contrib/utils/identity.py new file mode 100644 index 0000000..adc9a9d --- /dev/null +++ b/src/srht_contrib/utils/identity.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from srht_contrib.models import ActorAlias + + +class ActorIdentityResolver: + def __init__(self, configured_aliases: dict[str, list[str]] | None = None) -> None: + self.configured_aliases = configured_aliases or {} + + def canonicalize(self, actor: str, db: Session | None = None) -> str: + normalized = actor.strip() + if not normalized: + return normalized + + for canonical, aliases in self.configured_aliases.items(): + if normalized == canonical or normalized in aliases: + return canonical + + if db is not None: + alias = db.scalar(select(ActorAlias).where(ActorAlias.alias == normalized)) + if alias is not None: + return alias.canonical_actor + + return normalized diff --git a/src/srht_contrib/utils/repositories.py b/src/srht_contrib/utils/repositories.py new file mode 100644 index 0000000..f8e65b5 --- /dev/null +++ b/src/srht_contrib/utils/repositories.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from fastapi import HTTPException, status + + +def canonicalize_repository_name(actor: str, repo_name: str) -> str: + normalized_actor = actor.strip() + normalized_repo_name = repo_name.strip() + + if not normalized_actor: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Actor must not be blank.") + if not normalized_repo_name: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Repository name must not be blank.") + + if "/" in normalized_repo_name: + owner, name = normalized_repo_name.split("/", 1) + owner = owner.strip() + name = name.strip() + if not owner or not name or "/" in name: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Repository name must be `name` or `~owner/name`.", + ) + canonical_owner = owner if owner.startswith("~") else f"~{owner}" + return f"{canonical_owner}/{name}" + + if "/" in normalized_actor: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Actor must be a canonical sr.ht user.") + + canonical_actor = normalized_actor if normalized_actor.startswith("~") else f"~{normalized_actor}" + return f"{canonical_actor}/{normalized_repo_name}" -- cgit v1.2.3