Source code for betty.url

"""
Provide a URL generation API.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Any, TYPE_CHECKING, final
from urllib.parse import quote

from typing_extensions import override

from betty import model
from betty.asyncio import wait_to_thread
from betty.locale import negotiate_locale, Localey, to_locale
from betty.string import camel_case_to_kebab_case

if TYPE_CHECKING:
    from betty.model import Entity
    from betty.project import ProjectConfiguration, Project


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 LocalizedUrlGenerator(_UrlGenerator): """ Generate URLs for localizable resources. """
[docs] @abstractmethod def generate( self, resource: Any, media_type: str, absolute: bool = False, locale: Localey | None = None, ) -> str: """ Generate a URL for a resource. """ pass
[docs] class StaticUrlGenerator(_UrlGenerator): """ Generate URLs for static (non-localizable) resources. """
[docs] @abstractmethod def generate( self, resource: Any, absolute: bool = False, ) -> str: """ Generate a URL for a resource. """ pass
[docs] @final class LocalizedPathUrlGenerator(LocalizedUrlGenerator): """ Generate URLs for localizable file paths. """
[docs] def __init__(self, project: Project): self._project = project
[docs] @override def supports(self, resource: Any) -> bool: return isinstance(resource, str)
[docs] @override def generate( self, resource: Any, media_type: str, absolute: bool = False, locale: Localey | None = None, ) -> str: assert self.supports(resource) return _generate_from_path( self._project.configuration, resource, absolute, self._project.app.localizer.locale if locale is None else locale, )
[docs] @final class StaticPathUrlGenerator(StaticUrlGenerator): """ Generate URLs for static (non-localized) file paths. """
[docs] def __init__(self, configuration: ProjectConfiguration): self._configuration = configuration
[docs] @override def supports(self, resource: Any) -> bool: return isinstance(resource, str)
[docs] @override def generate( self, resource: Any, absolute: bool = False, ) -> str: assert self.supports(resource) return _generate_from_path(self._configuration, resource, absolute)
@final class _EntityUrlGenerator(LocalizedUrlGenerator): def __init__(self, project: Project, entity_type: type[Entity]): self._project = project self._entity_type = entity_type self._pattern = f"{camel_case_to_kebab_case(entity_type.plugin_id())}/{{entity_id}}/index.{{extension}}" @override def supports(self, resource: Any) -> bool: return isinstance(resource, self._entity_type) @override def generate( self, resource: Entity, media_type: str, absolute: bool = False, locale: Localey | None = None, ) -> str: assert self.supports(resource) if media_type == "text/html": extension = "html" if locale is None: locale = self._project.app.localizer.locale elif media_type == "application/json": extension = "json" locale = None else: raise ValueError(f'Unknown entity media type "{media_type}".') return _generate_from_path( self._project.configuration, self._pattern.format( entity_id=quote(resource.id), extension=extension, ), absolute, locale, )
[docs] @final class ProjectUrlGenerator(LocalizedUrlGenerator): """ Generate URLs for all resources provided by a Betty project. """
[docs] def __init__(self, project: Project): self._generators = [ *( _EntityUrlGenerator(project, entity_type) for entity_type in wait_to_thread(model.ENTITY_TYPE_REPOSITORY.select()) ), LocalizedPathUrlGenerator(project), ]
def _generator(self, resource: Any) -> LocalizedUrlGenerator | None: for generator in self._generators: if generator.supports(resource): return generator return None
[docs] @override def supports(self, resource: Any) -> bool: return self._generator(resource) is not None
[docs] @override def generate( self, resource: Any, media_type: str, absolute: bool = False, locale: Localey | None = None, ) -> str: generator = self._generator(resource) assert generator is not None return generator.generate(resource, media_type, absolute, locale)
def _generate_from_path( configuration: ProjectConfiguration, path: str, absolute: bool = False, localey: Localey | None = None, ) -> str: url = configuration.base_url if absolute else "" url += "/" if configuration.root_path: url += configuration.root_path + "/" if localey and configuration.locales.multilingual: locale = to_locale(localey) try: locale_configuration = configuration.locales[locale] except KeyError: project_locales = [ locale_configuration.locale for locale_configuration in configuration.locales ] try: negotiated_locale_data = negotiate_locale(locale, project_locales) if negotiated_locale_data is None: raise KeyError locale_configuration = configuration.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 enabled project locales: {", ".join(project_locales)}' ) from None url += locale_configuration.alias + "/" url += path.strip("/") if configuration.clean_urls and url.endswith("/index.html"): url = url[:-10] return url.rstrip("/")