from __future__ import annotations
import datetime
from collections.abc import Mapping
from typing import TYPE_CHECKING
from lxml import html
from ._selectors import SERIES_SELECTORS
from .abc import BookmarkableMixin, Page, SubscribableMixin
from .errors import UnloadedError
from .object import Object
from .user import User
from .utils import cached_slot_property, int_or_none
if TYPE_CHECKING:
from .http import HTTPClient
from .work import Work
else:
HTTPClient = Work = object
__all__ = ("Series",)
[docs]
class Series(Page, BookmarkableMixin, SubscribableMixin):
"""A series on AO3.
This implements the following:
- :class:`Page`
- :class:`BookmarkableMixin`
- :class:`SubscribableMixin`
"""
__slots__ = (
"_id",
"_http",
"_element",
"_authenticity_token",
"_cs_bookmark_id",
"_cs_sub_id",
"_cs_name",
"_cs_creators",
"_cs_date_begun",
"_cs_date_updated",
"_cs_description",
"_cs_notes",
"_cs_nwords",
"_cs_nworks",
"_cs_is_complete",
"_cs_nbookmarks",
"_cs_works_list",
)
def __init__(
self,
http: HTTPClient,
*,
payload: Mapping[str, object] | None = None,
element: html.HtmlElement | None = None,
) -> None:
self._http = http
if payload:
for attr, val in payload.items():
setattr(self, attr, val)
self._element = element
def __eq__(self, __value: object) -> bool:
if isinstance(__value, self.__class__):
return __value.id == self.id
return NotImplemented
def __hash__(self) -> int:
return hash((self.__class__.__name__, self.id))
def __repr__(self) -> str:
return f"{type(self).__name__}(name={self.name!r} id={self.id!r})"
@property
def id(self) -> int:
""":class:`int`: The series's ID."""
return self._id
@property
def subable_type(self) -> str:
return "Series"
@cached_slot_property("_cs_sub_id")
def sub_id(self) -> int | None:
if self.raw_element is None or not self._http.state:
return None
try:
sub_el = SERIES_SELECTORS["sub_btn"](self.raw_element)[0]
return int(text.split("/")[-1]) if (text := sub_el.get("action", None)) else None
except (IndexError, ValueError):
return None
@property
def url(self) -> str:
""":class:`str`: The series's base URL."""
return f"https://archiveofourown.org/series/{self.id}"
@cached_slot_property("_cs_name")
def name(self) -> str:
""":class:`str`: The series name."""
if self.raw_element is None:
raise UnloadedError
try:
return str(SERIES_SELECTORS["name"](self.raw_element)[0].text).strip()
except (IndexError, ValueError):
return ""
@cached_slot_property("_cs_creators")
def creators(self) -> tuple[Object, ...]:
"""tuple[:class:`Object`, ...]: The series's creators, minimized as :class:`ao3.Object` instances."""
if self.raw_element is None:
raise UnloadedError
return tuple(
Object(name=el.get("href", "").split("/")[1], type=User)
for el in SERIES_SELECTORS["creators"](self.raw_element)
)
@cached_slot_property("_cs_date_begun")
def date_begun(self) -> datetime.datetime | None:
""":class:`datetime.datetime` | None: The date the series began.
Might be None, which means unknown.
"""
if self.raw_element is None:
raise UnloadedError
try:
text = str(SERIES_SELECTORS["dates"](self.raw_element)[1].text)
return datetime.datetime.strptime(text or "", "%Y-%m-%d").astimezone()
except (IndexError, ValueError):
return None
@cached_slot_property("_cs_date_updated")
def date_updated(self) -> datetime.datetime | None:
""":class:`datetime.datetime` | None: The date the series was last updated.
Might be None, which means unknown.
"""
if self.raw_element is None:
raise UnloadedError
try:
text = str(SERIES_SELECTORS["dates"](self.raw_element)[2].text)
return datetime.datetime.strptime(text or "", "%Y-%m-%d").astimezone()
except (IndexError, ValueError):
return None
@cached_slot_property("_cs_description")
def description(self) -> str:
""":class:`str`: The series's description."""
if self.raw_element is None:
return ""
try:
return str(SERIES_SELECTORS["descr"](self.raw_element)[0].text_content())
except (IndexError, ValueError):
return ""
@cached_slot_property("_cs_notes")
def notes(self) -> str:
""":class:`str`: Any notes the creators have written for the series."""
if self.raw_element is None:
return ""
try:
return str(SERIES_SELECTORS["descr"](self.raw_element)[1].text_content())
except (IndexError, ValueError):
return ""
@cached_slot_property("_cs_nwords")
def nwords(self) -> int:
""":class:`int`: The total number of words in the series so far."""
if self.raw_element is None:
raise UnloadedError
try:
text = str(SERIES_SELECTORS["stats"](self.raw_element)[0].text_content())
return num if (num := int_or_none(text)) else 0
except (IndexError, ValueError):
return 0
@cached_slot_property("_cs_nworks")
def nworks(self) -> int:
""":class:`int`: The number of works in the series so far."""
if self.raw_element is None:
raise UnloadedError
try:
text = str(SERIES_SELECTORS["stats"](self.raw_element)[1].text_content())
return num if (num := int_or_none(text)) else 0
except (IndexError, ValueError):
return 0
@cached_slot_property("_cs_nworks")
def is_complete(self) -> bool:
""":class:`bool`: Whether the series is complete. Defaults to False if unknown."""
if self.raw_element is None:
raise UnloadedError
try:
text = str(SERIES_SELECTORS["stats"](self.raw_element)[2].text_content())
except (IndexError, ValueError):
return False
else:
return text == "No"
@cached_slot_property("_cs_nbookmarks")
def nbookmarks(self) -> int:
""":class:`int`: The number of bookmarks on this series."""
if self.raw_element is None:
raise UnloadedError
try:
text = str(SERIES_SELECTORS["stats"](self.raw_element)[3].text_content())
return result if (result := int_or_none(text)) else 0
except (IndexError, ValueError):
return 0
@property
def stats(self) -> tuple[int, int, bool, int]:
"""tuple[:class:`int`, :class:`int`, :class:`bool`, :class:`int`]: A tuple with the most common series stats.
This includes the number of words, number of works, completion status, and number of bookmarks.
"""
return (self.nwords, self.nworks, self.is_complete, self.nbookmarks)
@cached_slot_property("_cs_works_list")
def works_list(self) -> tuple[Work, ...]:
"""tuple[:class:`Work`, ...]: A tuple of works that make up this series.
This may take time to load, but will be cached for later references.
"""
from .work import Work # To avoid an import cycle.
if self.raw_element is None:
raise UnloadedError
return tuple(
Work._from_banner(self._http, el, self.authenticity_token)
for el in SERIES_SELECTORS["works"](self.raw_element)
)
[docs]
async def reload(self) -> None:
text = await self._http.get_series(self.id)
self._element = html.fromstring(text)
# Reset cached properties.
slots = set(self.__slots__).difference(("_id", "_http", "_element"))
for attr in slots:
delattr(self, attr)