from __future__ import annotations
import datetime
import re
from collections.abc import Mapping
from typing import TYPE_CHECKING
from lxml import html
from ._selectors import USER_SELECTORS
from .abc import Page, SubscribableMixin
from .errors import UnloadedError
from .utils import cached_slot_property
if TYPE_CHECKING:
from .http import HTTPClient
else:
HTTPClient = object
__all__ = ("User",)
NUM_MATCH = re.compile(r".*\((?P<id>\d*)\)")
[docs]
class User(Page, SubscribableMixin):
"""A user on AO3.
This implements the following:
- :class:`Page`
- :class:`SubscribableMixin`
Attributes
----------
username: :class:`str`
The unique name of the user on AO3.
"""
__slots__ = (
"username",
"_id",
"_http",
"_element",
"_authenticity_token",
"_cs_sub_id",
"_cs_avatar_url",
"_cs_pseuds",
"_cs_date_joined",
"_cs_nworks",
"_cs_nseries",
"_cs_nbookmarks",
"_cs_ncollections",
"_cs_ngifts",
)
username: str
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.username == self.username
return NotImplemented
def __hash__(self) -> int:
return hash((self.__class__.__name__, self.username))
def __repr__(self) -> str:
return f"{type(self).__name__}(username={self.username!r} id={self.id!r})"
@cached_slot_property("_id")
def id(self) -> int:
""":class:`int`: The user's ID."""
if self.raw_element is None:
raise UnloadedError
try:
return int(str(USER_SELECTORS["profile_info"](self.raw_element)[2].text))
except (IndexError, ValueError):
return 0
@property
def subable_type(self) -> str:
return "User"
@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 = USER_SELECTORS["sub_id"](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 user's base URL."""
return f"https://archiveofourown.org/users/{self.username}"
@cached_slot_property("_cs_avatar_url")
def avatar_url(self) -> str:
"""class:`str` The URL for the user's main avatar."""
if self.raw_element is None:
raise UnloadedError
try:
return str(USER_SELECTORS["avatar"](self.raw_element)[0].get("src", None))
except (IndexError, ValueError):
return ""
@cached_slot_property("_cs_pseuds")
def pseuds(self) -> tuple[str, ...]:
"""tuple[:class:`str`, ...]: A tuple of the user's pseud names."""
if self.raw_element is None:
raise UnloadedError
return tuple(str(el.text) for el in USER_SELECTORS["pseuds"](self.raw_element))
@cached_slot_property("_cs_date_joined")
def date_joined(self) -> datetime.datetime | None:
""":class:`datetime.datetime` | None: The date the user joined AO3. Might be None."""
if self.raw_element is None:
raise UnloadedError
try:
text = str(USER_SELECTORS["profile_info"](self.raw_element)[1].text)
return datetime.datetime.strptime(text, "%Y-%m-%d").astimezone()
except (IndexError, ValueError):
return None
@cached_slot_property("_cs_bio")
def bio(self) -> str:
""":class:`str`: The bio blurb on the user's profile page."""
if self.raw_element is None:
raise UnloadedError
try:
return str(USER_SELECTORS["bio"](self.raw_element)[0].text_content())
except (IndexError, ValueError):
return ""
@cached_slot_property("_cs_nworks")
def nworks(self) -> int:
""":class:`int`: The number of works this user has written."""
if self.raw_element is None:
raise UnloadedError
try:
el_text = str(USER_SELECTORS["nworks"](self.raw_element)[0].text_content())
num_match = NUM_MATCH.search(el_text)
return int(num_match[1]) if num_match else 0
except (IndexError, ValueError):
return 0
@cached_slot_property("_cs_nseries")
def nseries(self) -> int:
""":class:`int`: The number of series this user has written."""
if self.raw_element is None:
raise UnloadedError
try:
el_text = str(USER_SELECTORS["nseries"](self.raw_element)[0].text_content())
num_match = NUM_MATCH.search(el_text)
return int(num_match[1]) if num_match else 0
except (IndexError, ValueError):
return 0
@cached_slot_property("_cs_nbookmarks")
def nbookmarks(self) -> int:
""":class:`int`: The number of bookmarks this user has."""
if self.raw_element is None:
raise UnloadedError
try:
el_text = str(USER_SELECTORS["nbookmarks"](self.raw_element)[0].text_content())
num_match = NUM_MATCH.search(el_text)
return int(num_match[1]) if num_match else 0
except (IndexError, ValueError):
return 0
@cached_slot_property("_cs_ncollections")
def ncollections(self) -> int:
""":class:`int`: The number of collections this user has."""
if self.raw_element is None:
raise UnloadedError
try:
el_text = str(USER_SELECTORS["ncollections"](self.raw_element)[0].text_content())
num_match = NUM_MATCH.search(el_text)
return int(num_match[1]) if num_match else 0
except (IndexError, ValueError):
return 0
@cached_slot_property("_cs_ngifts")
def ngifts(self) -> int:
""":class:`int`: The number of gifts this user has been given."""
if self.raw_element is None:
raise UnloadedError
try:
el_text = str(USER_SELECTORS["ngifts"](self.raw_element)[0].text_content())
num_match = NUM_MATCH.search(el_text)
return int(num_match[1]) if num_match else 0
except (IndexError, ValueError):
return 0
[docs]
async def reload(self) -> None:
text = await self._http.get_user(self.username)
self._element = html.fromstring(text)
# Reset relevant cached properties.
slots = set(self.__slots__).difference(("username", "_id", "_http", "_element"))
for attr in slots:
delattr(self, attr)