sipa/flatpages.py
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from functools import cached_property, lru_cache
from operator import attrgetter
from os.path import basename, dirname, splitext
from babel.core import Locale, UnknownLocaleError, negotiate_locale
from flask import abort, request
from flask_babel import get_babel
from flask_flatpages import FlatPages, Page
from yaml.scanner import ScannerError
from sipa.babel import possible_locales, preferred_locales
logger = logging.getLogger(__name__)
@lru_cache(maxsize=128)
def cached_negotiate_locale(
preferred_locales: tuple[str], available_locales: tuple[str]
) -> str | None:
return negotiate_locale(
preferred_locales,
available_locales,
sep="-",
)
# NB: Node is meant to be a union `Article | Category`.
@dataclass
class Node:
"""An abstract object with a parent and an id"""
parent: Category | None
id: str
#: Only used for initialization.
#: determines the default page of an article.
default_locale: Locale
@dataclass
class Article(Node):
"""The Article class
An article provides the possibility to access multiple versions of
a Page. In this case, :py:attr:`localized_pages` is a dict where
a locale string points to a :py:obj:`Page`. The latter represents
the actual markdown file located in the repository.
After the initialization, which consists of adding pages with
:py:meth:`add_page`, internal methods access only the page with
the correct locale, which is proxied by :py:attr:`localized_page`.
Besides that, :py:meth:`__getattr__` comfortably passes queries to
the :py:obj:`localized_page.meta` dict.
"""
#: The dict containing the localized pages of this article
localized_pages: dict[str, Page] = field(init=False, default_factory=dict)
#: The default page
default_page: Page | None = field(init=False, default=None)
def add_page(self, page: Page, locale: Locale) -> None:
"""Add a page to the pages list.
If the name is not ``index`` and the validation via
:py:meth:`validate_page_meta` fails, skip this.
If no :py:attr:`default_page` is set or the locale equals
:py:obj:`babel.default_locale`, set :py:attr:`default_page` to
the given page.
Update the :py:attr:`localized_pages` dict at the
``str(locale)`` key to ``page``.
:param page: The page to add
:param locale: The locale of this page
"""
if not (self.id == "index" or validate_page_meta(page)):
return
self.localized_pages[str(locale)] = page
if self.default_page is None or locale == self.default_locale:
self.default_page = page
@property
def rank(self) -> int:
"""The rank of the :py:attr:`localized_page`
This is what is given in the page's ``rank`` meta-attribute if
available else ``100``.
:returns: The :py:attr:`localized_page` s rank
"""
return self.localized_page.meta.get('rank', 100)
@property
def html(self) -> str:
"""The :py:attr:`localized_page` as html
:returns: The :py:attr:`localized_page` converted to html
"""
return self.localized_page.html
@property
def link(self) -> str | None:
"""A valid link to this article
:returns: The URL or ``None`` if the link starts with ``"/"``
:raises: :py:obj:`AttributeError` if :py:attr:`localized_page`
doesn't have a link in the meta section.
"""
raw_link = self.localized_page.meta.get('link', None)
if raw_link and raw_link[0] == "/":
return dirname(request.url_root) + raw_link
return None
@property
def hidden(self) -> bool:
"""The hidden state of the :py:attr:`localized_page`
This controls whether the page should be displayed in listings.
:returns: The :py:attr:`localized_page` s hidden state
"""
return self.localized_page.meta.get('hidden', False)
@property
def icon(self) -> str:
return self.localized_page.meta.get("icon")
def __getattr__(self, attr: str) -> str:
"""Return the meta attribute of the localized page
:param attr: The meta attribute to access
:returns: The meta attribute of :py:attr:`localized_page`
:raises: :py:obj:`AttributeError` if :py:obj:`attr` doesn't
exist in the page's meta
"""
try:
return self.localized_page.meta[attr]
except KeyError as e:
raise AttributeError(
f"{type(self).__name__!r} object has no attribute {attr!r}"
) from e
@cached_property
def available_locales(self) -> tuple[str]:
return tuple(self.localized_pages.keys())
@property
def localized_page(self) -> Page:
"""The current localized page
This is the flatpage of the first available locale from
:py:func:`~sipa.babel.locale_preferences`, or
:py:attr:`default_page`.
:returns: The localized page
"""
negotiated_locale = cached_negotiate_locale(
tuple(preferred_locales()),
self.available_locales,
)
if negotiated_locale is not None:
return self.localized_pages[negotiated_locale]
return self.default_page
@property
def file_basename(self) -> str:
"""The basename of the localized page without extension.
Example: `categ/article.en.md` → `article.en`
:returns: The basename of the :py:attr:`localized_page`
"""
return splitext(basename(self.localized_page.path))[0]
def validate_page_meta(page: Page) -> bool:
"""Validate that the pages meta-section.
This function is necessary because a page with incorrect
metadata will raise some Errors when trying to access them.
Note that this is done rather early as pages are cached.
:param page: The page to validate
:returns: Whether the page is valid
"""
try:
return "title" in page.meta
except ScannerError:
return False
@dataclass
class Category(Node):
"""The Category class
* What's it used for?
- Containing articles → should be iterable!
"""
categories: dict = field(init=False, default_factory=dict)
_articles: dict = field(init=False, default_factory=dict)
@property
def articles(self):
"""Return an iterator over the articles sorted by rank
Only used for building the navigation bar
"""
return iter(sorted(self._articles.values(), key=attrgetter('rank')))
def __getattr__(self, attr):
"""An attribute interface.
- Used for: ['rank', 'index', 'id', 'name']
"""
try:
index = self._articles['index']
except KeyError as e:
raise AttributeError(
f"{type(self).__name__!r} object has no attribute {attr!r}"
) from e
return getattr(index, attr)
def add_child_category(self, id):
"""Create a new Category from an id, keep it and return it.
If the category already exists, return it instead and do nothing.
"""
category = self.categories.get(id)
if category is not None:
return category
category = Category(
parent=self,
id=id,
default_locale=self.default_locale,
)
self.categories[id] = category
return category
def _parse_page_basename(self, basename):
"""Split the page basename into the article id and locale.
`basename` is (supposed to be) of the form
`<article_id>.<locale>`, e.g. `news.en`.
If either there is no dot or the locale is unknown,
the `default_locale` of babel is used.
:return: The tuple `(article_id, locale)`.
"""
default_locale = self.default_locale
article_id, sep, locale_identifier = basename.rpartition('.')
if sep == '':
return basename, default_locale
try:
locale = Locale(locale_identifier)
except UnknownLocaleError:
logger.error("Unknown locale %s of arcticle %s",
locale_identifier, basename)
return basename, default_locale
if locale not in possible_locales():
logger.warning("Locale %s of article is not a possible locale",
locale_identifier, basename)
return basename, default_locale
return article_id, locale
def add_article(self, prefix, page):
"""Add a page to an article and create the latter if nonexistent.
Firstly, the article_id is being extracted according to
above scheme. If an `Article` of this id already exists, it
is asked to add the page accordingly.
"""
article_id, locale = self._parse_page_basename(prefix)
article = self._articles.get(article_id)
if article is None:
article = Article(
parent=self,
id=article_id,
default_locale=self.default_locale,
)
self._articles[article_id] = article
article.add_page(page, locale)
class CategorizedFlatPages:
"""The main interface to gather pages and categories
* What is it used for?
- Looping: E.g. In the navbar
- get news → get_articles_of_category('news')
- get static page → get_or_404()
"""
def __init__(self):
self.flat_pages = FlatPages()
self.root_category = None
self.app = None
def init_app(self, app):
assert self.app is None, "Already initialized with an app"
app.config.setdefault('FLATPAGES_LEGACY_META_PARSER', True)
self.app = app
app.cf_pages = self
self.flat_pages.init_app(app)
babel = get_babel(app)
self.root_category = Category(
parent=None,
id="<root>",
default_locale=babel.default_locale,
)
self._init_categories()
@property
def categories(self):
"""Yield all categories as an iterable
"""
return sorted(self.root_category.categories.values(),
key=attrgetter('rank'))
def get(self, category_id, article_id):
category = self.root_category.categories.get(category_id)
if category is None:
return None
return category._articles.get(article_id)
def get_category(self, category_id):
"""Return the `Category` object from a given name (id)
"""
return self.root_category.categories.get(category_id)
def get_articles_of_category(self, category_id):
"""Get the articles of a category
- ONLY used for fetching news
"""
category = self.get_category(category_id)
if category is None:
return []
return [article for article in category._articles.values()
if article.id != 'index']
def get_or_404(self, category_id, article_id):
"""Fetch a static page"""
page = self.get(category_id, article_id)
if page is None:
abort(404)
return page
def _init_categories(self):
# TODO: Store categories, not articles
for page in self.flat_pages:
# get category + page name
# plus, assert that there is nothing more to that.
components = page.path.split('/')
parent = self.root_category
for category_id in components[:-1]:
parent = parent.add_child_category(category_id)
prefix = components[-1]
parent.add_article(prefix, page)
def reload(self):
self.flat_pages.reload()
self._init_categories()