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