"""
Provide a URL generation API.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, TYPE_CHECKING, Self
from urllib.parse import urlparse
from typing_extensions import override, deprecated
from betty.locale import negotiate_locale, Localey, to_locale
if TYPE_CHECKING:
from betty.media_type import MediaType
from collections.abc import Mapping
[docs]
class GenerationError(RuntimeError):
"""
A URL generation error.
"""
pass
[docs]
class UnsupportedResource(GenerationError):
"""
Raised when a URL generator cannot generate a URL for a resource.
These are preventable by checking :py:meth:`betty.url.LocalizedUrlGenerator.supports` or
:py:meth:`betty.url.StaticUrlGenerator.supports` first.
"""
[docs]
@classmethod
def new(cls, resource: Any) -> Self:
"""
Create a new instance.
"""
return cls(f"Unsupported resource: {resource}")
class _UrlGenerator(ABC):
"""
Generate URLs for localizable resources.
"""
@abstractmethod
def supports(self, resource: Any) -> bool:
"""
Whether the given resource is supported by this URL generator.
"""
pass
[docs]
class UrlGenerator(_UrlGenerator):
"""
Generate URLs for resources.
"""
[docs]
@abstractmethod
def generate(
self,
resource: Any,
*,
media_type: MediaType | None = None,
absolute: bool = False,
locale: Localey | None = None,
) -> str:
"""
Generate a URL for a resource.
:raise UnsupportedResource:
:raise InvalidMediaType:
"""
pass
[docs]
@deprecated(
f"This class has been deprecated since Betty 0.4.8, and will be removed in Betty 0.5. Instead use {UrlGenerator}."
)
class LocalizedUrlGenerator(_UrlGenerator):
"""
Generate URLs for localizable resources.
"""
[docs]
@abstractmethod
def generate(
self,
resource: Any,
media_type: MediaType,
*,
absolute: bool = False,
locale: Localey | None = None,
) -> str:
"""
Generate a URL for a resource.
:raise UnsupportedResource:
:raise InvalidMediaType:
"""
pass
[docs]
class PassthroughUrlGenerator(UrlGenerator):
"""
Returns resources verbatim if they are absolute URLs already.
"""
[docs]
@override
def supports(self, resource: Any) -> bool:
if not isinstance(resource, str):
return False
try:
return bool(urlparse(resource).scheme)
except ValueError:
return False
[docs]
@override
def generate(
self,
resource: Any,
*,
media_type: MediaType | None = None,
absolute: bool = False,
locale: Localey | None = None,
) -> str:
assert isinstance(resource, str)
return resource
[docs]
@deprecated(
f"This class has been deprecated since Betty 0.4.8, and will be removed in Betty 0.5. Instead use {UrlGenerator}."
)
class StaticUrlGenerator(_UrlGenerator):
"""
Generate URLs for static (non-localizable) resources.
"""
[docs]
@abstractmethod
def generate(
self,
resource: Any,
*,
absolute: bool = False,
) -> str:
"""
Generate a static URL for a static resource.
:raise UnsupportedResource:
:raise InvalidMediaType:
"""
pass
[docs]
def generate_from_path(
path: str,
*,
base_url: str,
root_path: str,
locales: Mapping[str, str],
clean_urls: bool,
absolute: bool = False,
locale: Localey | None = None,
) -> str:
"""
Generate a full URL from a public path.
"""
url = base_url.rstrip("/") if absolute else ""
url += root_path.rstrip("/")
assert path.startswith("/"), (
f'Paths must be root-relative (start with a forward slash), but "{path}" was given'
)
path = path.strip("/")
if locale and len(locales) > 1:
locale = to_locale(locale)
try:
negotiated_locale_data = negotiate_locale(locale, list(locales))
if negotiated_locale_data is None:
raise KeyError
locale_alias = locales[to_locale(negotiated_locale_data)]
except KeyError:
raise ValueError(
f'Cannot generate URLs in "{locale}", because it cannot be resolved to any of the available locales: {", ".join(locales)}'
) from None
url += f"/{locale_alias}"
if path:
url += f"/{path}"
if clean_urls and url.endswith("/index.html"):
url = url[:-11]
# Ensure URLs are root-relative.
if not absolute:
url = f"/{url.lstrip('/')}"
return url