Hide navigation sidebar
Hide table of contents sidebar
Skip to content
Back to top
Toggle table of contents sidebar

# 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

    class LocalizedUrlGenerator(_UrlGenerator):
        """
        Generate URLs for localizable resources.
        """

        @abstractmethod
        def generate(
            self,
            resource: Any,
            media_type: str,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            """
            Generate a URL for a resource.
            """
            pass

    class StaticUrlGenerator(_UrlGenerator):
        """
        Generate URLs for static (non-localizable) resources.
        """

        @abstractmethod
        def generate(
            self,
            resource: Any,
            absolute: bool = False,
        ) -> str:
            """
            Generate a URL for a resource.
            """
            pass

    @final
    class LocalizedPathUrlGenerator(LocalizedUrlGenerator):
        """
        Generate URLs for localizable file paths.
        """

        def __init__(self, project: Project):
            self._project = project

        @override
        def supports(self, resource: Any) -> bool:
            return isinstance(resource, str)

        @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,
            )

    @final
    class StaticPathUrlGenerator(StaticUrlGenerator):
        """
        Generate URLs for static (non-localized) file paths.
        """

        def __init__(self, configuration: ProjectConfiguration):
            self._configuration = configuration

        @override
        def supports(self, resource: Any) -> bool:
            return isinstance(resource, str)

        @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,
            )

    @final
    class ProjectUrlGenerator(LocalizedUrlGenerator):
        """
        Generate URLs for all resources provided by a Betty project.
        """

        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

        @override
        def supports(self, resource: Any) -> bool:
            return self._generator(resource) is not None

        @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("/")