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


[docs] 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(), )
[docs] @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. """
[docs] @override def supports(self, resource: Any) -> bool: return _supports_path(resource)
[docs] @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)
[docs] @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. """
[docs] @private def __init__( self, *upstreams: StdLocalizedUrlGenerator, ): self._upstream = ProxyLocalizedUrlGenerator(*upstreams)
[docs] @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), )
[docs] @override def supports(self, resource: Any) -> bool: return self._upstream.supports(resource)
[docs] @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 )