betty.project.url - Betty documentation

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

# Source code for betty.project.url

    """
    URL generators for project resources.
    """

    from __future__ import annotations

    from typing import final, Any, Self, TYPE_CHECKING
    from urllib.parse import quote, urlparse

    from typing_extensions import override

    from betty.media_type.media_types import HTML, JSON, JSON_LD
    from betty.project.factory import ProjectDependentFactory
    from betty.string import camel_case_to_kebab_case
    from betty.typing import private
    from betty.url import (
        generate_from_path,
        LocalizedUrlGenerator as StdLocalizedUrlGenerator,
        StaticUrlGenerator as StdStaticUrlGenerator,
        PassthroughUrlGenerator,
        InvalidMediaType,
        UrlGenerator,
    )
    from betty.url.proxy import ProxyLocalizedUrlGenerator, ProxyUrlGenerator
    from betty.model import Entity
    from betty.warnings import deprecated

    if TYPE_CHECKING:
        from betty.ancestry import Ancestry
        from betty.media_type import MediaType
        from betty.project import Project
        from betty.locale import Localey
        from collections.abc import Mapping

    class _ProjectUrlGenerator(ProjectDependentFactory):
        def __init__(
            self,
            base_url: str,
            root_path: str,
            locales: Mapping[str, str],
            clean_urls: bool,
        ):
            self._base_url = base_url
            self._root_path = root_path
            self._locales = locales
            assert len(locales)
            self._default_locale = next(iter(locales))
            self._clean_urls = clean_urls

        @classmethod
        async def new_for_project(cls, project: Project) -> Self:
            """
            Create a new instance using the given project.
            """
            return cls(
                project.configuration.base_url,
                project.configuration.root_path,
                {
                    locale_configuration.locale: locale_configuration.alias
                    for locale_configuration in project.configuration.locales.values()
                },
                project.configuration.clean_urls,
            )

        def _generate_from_path(
            self, path: str, *, absolute: bool = False, locale: Localey | None = None
        ) -> str:
            return generate_from_path(
                path,
                absolute=absolute,
                locale=locale,
                base_url=self._base_url,
                root_path=self._root_path,
                locales=self._locales,
                clean_urls=self._clean_urls,
            )

        def _generate_from_entity(
            self,
            entity: Entity,
            pattern: str,
            *,
            media_type: MediaType | None,
            locale: Localey | None,
            absolute: bool,
        ) -> str:
            if media_type not in [HTML, JSON_LD, JSON]:
                raise InvalidMediaType.new(entity, media_type)
            extension, locale = _get_extension_and_locale(
                media_type, self._default_locale, locale=locale
            )
            return self._generate_from_path(
                pattern.format(
                    entity_type=camel_case_to_kebab_case(entity.plugin_id()),
                    entity_id=quote(entity.id),
                    extension=extension,
                ),
                absolute=absolute,
                locale=locale,
            )

        def _generate_from_entity_type(
            self,
            entity_type: type[Entity],
            pattern: str,
            *,
            media_type: MediaType | None,
            locale: Localey | None,
            absolute: bool,
        ) -> str:
            if media_type not in [HTML, JSON_LD, JSON]:
                raise InvalidMediaType.new(entity_type, media_type)
            extension, locale = _get_extension_and_locale(
                media_type, self._default_locale, locale=locale
            )
            return self._generate_from_path(
                pattern.format(
                    entity_type=camel_case_to_kebab_case(entity_type.plugin_id()),
                    extension=extension,
                ),
                absolute=absolute,
                locale=locale,
            )

    def _supports_path(resource: Any) -> bool:
        return isinstance(resource, str) and resource.startswith("/")

    @final
    class _LocalizedPathUrlGenerator(_ProjectUrlGenerator, StdLocalizedUrlGenerator):
        @override
        def supports(self, resource: Any) -> bool:
            return _supports_path(resource)

        @override
        def generate(
            self,
            resource: Any,
            media_type: MediaType,
            *,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            assert self.supports(resource)
            return self._generate_from_path(
                resource,
                absolute=absolute,
                locale=locale or self._default_locale,
            )

    async def new_project_url_generator(project: Project) -> UrlGenerator:
        """
        Generate URLs for all resources provided by a Betty project.
        """
        entity_url_generator = await _EntityUrlGenerator.new_for_project(project)
        return ProxyUrlGenerator(
            await _EntityTypeUrlGenerator.new_for_project(project),
            entity_url_generator,
            _EntityUrlUrlGenerator(project.ancestry, entity_url_generator),
            await _LocalizedPathUrlUrlGenerator.new_for_project(project),
            await _StaticPathUrlUrlGenerator.new_for_project(project),
            PassthroughUrlGenerator(),
        )

    @deprecated(
        f"This class has been deprecated since Betty 0.4.8, and will be removed in Betty 0.5. Instead use {new_project_url_generator}."
    )
    @final
    class StaticUrlGenerator(_ProjectUrlGenerator, StdStaticUrlGenerator):
        """
        Generate URLs for static (non-localized) file paths.
        """

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

        @override
        def generate(
            self,
            resource: Any,
            *,
            absolute: bool = False,
        ) -> str:
            assert self.supports(resource)
            return self._generate_from_path(resource, absolute=absolute)

    def _get_extension_and_locale(
        media_type: MediaType, default_locale: str, *, locale: Localey | None
    ) -> tuple[str, Localey | None]:
        if media_type == HTML:
            return "html", locale or default_locale
        elif media_type in (JSON, JSON_LD):
            return "json", None
        else:
            raise ValueError(f'Unknown entity media type "{media_type}".')

    class __EntityTypeUrlGenerator(_ProjectUrlGenerator):
        _pattern = "/{entity_type}/index.{extension}"

        def supports(self, resource: Any) -> bool:
            return isinstance(resource, type) and issubclass(resource, Entity)

    @final
    class _EntityTypeUrlGenerator(__EntityTypeUrlGenerator, UrlGenerator):
        @override
        def generate(
            self,
            resource: type[Entity],
            *,
            media_type: MediaType | None = None,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            assert self.supports(resource)
            return self._generate_from_entity_type(
                resource,
                self._pattern,
                media_type=media_type,
                locale=locale,
                absolute=absolute,
            )

    @final
    class _EntityTypeLocalizedUrlGenerator(
        __EntityTypeUrlGenerator, StdLocalizedUrlGenerator
    ):
        @override
        def generate(
            self,
            resource: type[Entity],
            media_type: MediaType,
            *,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            assert self.supports(resource)
            return self._generate_from_entity_type(
                resource,
                self._pattern,
                media_type=media_type,
                locale=locale,
                absolute=absolute,
            )

    class __EntityUrlGenerator(_ProjectUrlGenerator):
        _pattern = "/{entity_type}/{entity_id}/index.{extension}"

        def supports(self, resource: Any) -> bool:
            return isinstance(resource, Entity)

    @final
    class _EntityUrlGenerator(__EntityUrlGenerator, UrlGenerator):
        @override
        def generate(
            self,
            resource: Entity,
            *,
            media_type: MediaType | None = None,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            assert self.supports(resource)
            return self._generate_from_entity(
                resource,
                self._pattern,
                media_type=media_type,
                locale=locale,
                absolute=absolute,
            )

    @final
    class _EntityLocalizedUrlGenerator(__EntityUrlGenerator, StdLocalizedUrlGenerator):
        @override
        def generate(
            self,
            resource: Entity,
            media_type: MediaType,
            *,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            assert self.supports(resource)
            return self._generate_from_entity(
                resource,
                self._pattern,
                media_type=media_type,
                locale=locale,
                absolute=absolute,
            )

    class _EntityUrlUrlGenerator(UrlGenerator):
        def __init__(self, ancestry: Ancestry, entity_url_generator: _EntityUrlGenerator):
            self._ancestry = ancestry
            self._entity_url_generator = entity_url_generator

        @override
        def supports(self, resource: Any) -> bool:
            if not isinstance(resource, str):
                return False
            try:
                parsed_url = urlparse(resource)
            except ValueError:
                return False
            if parsed_url.scheme != "betty-entity":
                return False
            if not parsed_url.netloc:
                return False
            if not len(parsed_url.path) >= 2:
                return False
            return True

        @override
        def generate(
            self,
            resource: str,
            *,
            media_type: MediaType | None = None,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            parsed_url = urlparse(resource)
            entity_type_id = parsed_url.netloc
            entity_id = parsed_url.path[1:]
            entity = self._ancestry[entity_type_id][entity_id]
            return self._entity_url_generator.generate(
                entity, media_type=media_type, absolute=absolute, locale=locale
            )

    class _LocalizedPathUrlUrlGenerator(_ProjectUrlGenerator, UrlGenerator):
        @override
        def supports(self, resource: Any) -> bool:
            if not isinstance(resource, str):
                return False
            try:
                parsed_url = urlparse(resource)
            except ValueError:
                return False
            if parsed_url.scheme != "betty":
                return False
            if not parsed_url.netloc and not parsed_url.path:
                return False
            return True

        @override
        def generate(
            self,
            resource: str,
            *,
            media_type: MediaType | None = None,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            assert self.supports(resource)
            parsed_url = urlparse(resource)
            url_path = "/" + (parsed_url.netloc + parsed_url.path).lstrip("/")
            return self._generate_from_path(
                url_path,
                absolute=absolute,
                locale=locale or self._default_locale,
            )

    class _StaticPathUrlUrlGenerator(_ProjectUrlGenerator, UrlGenerator):
        @override
        def supports(self, resource: Any) -> bool:
            if not isinstance(resource, str):
                return False
            try:
                parsed_url = urlparse(resource)
            except ValueError:
                return False
            if parsed_url.scheme != "betty-static":
                return False
            if not parsed_url.netloc and not parsed_url.path:
                return False
            return True

        @override
        def generate(
            self,
            resource: str,
            *,
            media_type: MediaType | None = None,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            assert self.supports(resource)
            parsed_url = urlparse(resource)
            url_path = "/" + (parsed_url.netloc + parsed_url.path).lstrip("/")
            return self._generate_from_path(url_path, absolute=absolute)

    @deprecated(
        f"This class has been deprecated since Betty 0.4.8, and will be removed in Betty 0.5. Instead use {UrlGenerator}."
    )
    @final
    class LocalizedUrlGenerator(StdLocalizedUrlGenerator, ProjectDependentFactory):
        """
        Generate URLs for all resources provided by a Betty project.
        """

        @private
        def __init__(
            self,
            *upstreams: StdLocalizedUrlGenerator,
        ):
            self._upstream = ProxyLocalizedUrlGenerator(*upstreams)

        @override
        @classmethod
        async def new_for_project(cls, project: Project) -> Self:
            return cls(
                await _EntityTypeLocalizedUrlGenerator.new_for_project(project),
                await _EntityLocalizedUrlGenerator.new_for_project(project),
                await _LocalizedPathUrlGenerator.new_for_project(project),
            )

        @override
        def supports(self, resource: Any) -> bool:
            return self._upstream.supports(resource)

        @override
        def generate(
            self,
            resource: Any,
            media_type: MediaType,
            *,
            absolute: bool = False,
            locale: Localey | None = None,
        ) -> str:
            return self._upstream.generate(
                resource, media_type, absolute=absolute, locale=locale
            )