from typing import Any from discord import Guild from pydantic import BaseModel, ConfigDict, field_validator from ..config import config SUPPORTED_PROVIDER_SERVICES = ["github", "gitlab", "forgejo"] class UnsupportedServiceError(Exception): """Custom exception for unsupported provider services.""" pass class Provider(BaseModel): """Model for a provider configuration. Attributes: id (str): The ID of the provider. This should usually be the same as the URl, just without the scheme (i.e. https://). url (str): The URL of the provider. service (str): The service type of the provider (e.g., `github`, `gitlab`, `forgejo`). Must be one of the supported services. token (str): An optional API token for the provider. If not provided, unauthenticated requests will be used. guild (Guild | None): The Discord guild associated with the provider. If `None`, the provider is considered a global provider. Properties: api_url (str): The API URL for the provider. This is typically the base URL with an API path appended. favicon_url (str): The URL to the provider's favicon. This is typically the base URL with a favicon path appended. Methods: to_config: Save the provider to Red's configuration. Saves to guilds if the `guild` class attribute is not `None`. Class Methods: from_config: Create a Provider instance from the Red configuration. Checks both global and guild providers, prioritizing guild providers. fetch_all: Fetch all providers from the Red configuration. Checks both global and guild providers """ model_config = ConfigDict(arbitrary_types_allowed=True) id: str url: str service: str token: str | None = None guild: Guild | None = None @field_validator("service") @classmethod def _validate_provider(cls, value: Any) -> Any: if value not in SUPPORTED_PROVIDER_SERVICES: raise UnsupportedServiceError("Provider service '%s' is not supported." % value) return value @property def api_url(self) -> str: """Return the API URL for the provider. This is typically the base URL with an API path appended.""" match self.service: case "github": if self.url == "https://github.com": return "https://api.github.com" return self.url.rstrip("/") + "/api/v3" case "gitlab": return self.url.rstrip("/") + "/api/v4" case "forgejo": return self.url.rstrip("/") + "/api/v1" case _: raise UnsupportedServiceError("Unsupported provider service:' '%s'" % self.service) @property def graphql_url(self) -> str: """Return the GraphQL URL for the provider. This is typically the base URL with a GraphQL path appended.""" match self.service: case "github": if self.url == "https://github.com": return "https://api.github.com/graphql" return self.url.rstrip("/") + "/api/graphql" case "gitlab": return self.url.rstrip("/") + "/api/graphql" case "forgejo": raise ValueError("Forgejo does not support GraphQL.") case _: raise UnsupportedServiceError("Unsupported provider service:' '%s'" % self.service) @property def favicon_url(self) -> str: """Return the URL to the provider's favicon. This is typically the base URL with a favicon path appended.""" if self.service == "github": # GitHub's favicon is *actually* an ico, and Discord can't embed those return self.url.rstrip("/") + "/apple-touch-icon.png" return self.url.rstrip("/") + "/favicon.ico" async def to_config(self) -> None: """Save the provider to Red's configuration. Saves to guilds if the `guild` class attribute is not `None`.""" if self.guild: providers = await config.guild(self.guild).providers() else: providers = await config.global_providers() for provider in providers: if provider["url"] == self.url: provider["token"] = self.token if not any(provider["url"] == self.url for provider in providers): providers.append(self.model_dump(mode="json", exclude={"guild"})) if self.guild: await config.guild(self.guild).providers.set(providers) else: await config.global_providers.set(providers) @classmethod async def from_config(cls, provider_id: str, guild: Guild | None = None) -> "Provider": """Create a Provider instance from the Red configuration. Checks both global and guild providers, prioritizing guild providers.""" if guild: guild_providers = await config.guild(guild).providers() for provider in guild_providers: if provider["id"] == provider_id: return cls(guild=guild, **provider) providers = await config.global_providers() for provider in providers: if provider["id"] == provider_id: return cls(guild=None, **provider) raise ValueError("No provider found for ID: %s" % provider_id) @classmethod async def fetch_all(cls, guild: Guild | None = None) -> tuple["Provider"]: """Fetch all providers from the Red configuration. Checks both global and guild providers.""" providers_list = [] if guild: guild_providers = await config.guild(guild).providers() for provider in guild_providers: providers_list.extend((cls(guild=guild, **provider),)) global_providers = await config.global_providers() for provider in global_providers: providers_list.extend((cls(guild=None, **provider),)) return tuple(providers_list)