import abc
from dataclasses import dataclass
from enum import Flag, auto
from typing import Generic, List, Optional, Protocol, Tuple, TypeVar
__all__ = [
"OS",
"DefaultedResult",
"Device",
"Domain",
"Matchers",
"PartialResult",
"Resolver",
"Result",
"UserAgent",
]
[docs]
@dataclass(frozen=True, slots=True)
class UserAgent:
"""Browser ("user agent" aka the software responsible for the request)
information parsed from the user agent string.
"""
family: str = "Other"
major: Optional[str] = None
minor: Optional[str] = None
patch: Optional[str] = None
patch_minor: Optional[str] = None
[docs]
@dataclass(frozen=True, slots=True)
class OS:
"""OS information parsed from the user agent string."""
family: str = "Other"
major: Optional[str] = None
minor: Optional[str] = None
patch: Optional[str] = None
patch_minor: Optional[str] = None
[docs]
@dataclass(frozen=True, slots=True)
class Device:
"""Device information parsed from the user agent string."""
family: str = "Other"
brand: Optional[str] = None
model: Optional[str] = None
[docs]
class Domain(Flag):
"""Hint for selecting which domains are requested when asking for a
:class:`PartialResult`.
"""
#: browser (user agent) domain
USER_AGENT = auto()
#: os domain
OS = auto()
#: device domain
DEVICE = auto()
#: shortcut for all three domains
ALL = USER_AGENT | OS | DEVICE
[docs]
@dataclass(frozen=True, slots=True)
class DefaultedResult:
"""Variant of :class:`Result` where attributes are set
to a default value if their resolution failed.
For all domains, the default value has ``family`` set to
``"Other"`` and every other attribute set to ``None``.
"""
user_agent: UserAgent
os: OS
device: Device
string: str
[docs]
@dataclass(frozen=True, slots=True)
class Result:
"""Complete result.
For each attribute (and domain), either the resolution was a
success (a match was found) and the corresponding data is set, or
it was a failure and the value is `None`.
"""
user_agent: Optional[UserAgent]
os: Optional[OS]
device: Optional[Device]
string: str
[docs]
def with_defaults(self) -> DefaultedResult:
"""Replaces every failed domain by its default value.
Roughly matches pre-1.0 semantics, and can allow for more
uniform handling by the client if they don't want or need the
lookup failure information.
"""
return DefaultedResult(
user_agent=self.user_agent or UserAgent(),
os=self.os or OS(),
device=self.device or Device(),
string=self.string,
)
[docs]
@dataclass(frozen=True, slots=True)
class PartialResult:
"""Potentially partial (incomplete) result.
Domain fields (``user_agent``, ``os``, and ``device``) can be:
- unset if not parsed yet
- set to a parsing failure
- set to a parsing success
The ``domains`` flags specify which is which: if a :class:`Domain`
flag is set, the corresponding attribute was looked up and is
either ``None`` for a resolution failure (no match was found) or a
value for a parsing success.
If the flag is unset, the field has not been looked up yet, in
which case it can be anything (but should usually be ``None``).
"""
domains: Domain
user_agent: Optional[UserAgent]
os: Optional[OS]
device: Optional[Device]
string: str
[docs]
def complete(self) -> Result:
"""Requires that the result be fully resolved (every attribute is set,
even if to a lookup failure).
:raises ValueError: if the result is not fully resolved
"""
if self.domains != Domain.ALL:
raise ValueError("Only a result with all attributes set can be completed")
return Result(
user_agent=self.user_agent,
os=self.os,
device=self.device,
string=self.string,
)
[docs]
class Resolver(Protocol):
"""Resolver()
The resolver is the thin central abstraction of ua-parser, and
used to compose various objects into the resolution stack which
best fits the system's needs.
A resolver is any callable which takes a string ``ua`` and a
:class:`Domain`, and returns a :class:`PartialResult` with at
least the requested domains marked as resolved (whether
successfully or not).
A resolver may resolve more domains than requested, but it needs
to resolve at least the requested domains.
See :class:`PartialResult` for more information about its
working.
"""
[docs]
@abc.abstractmethod
def __call__(self, ua: str, domain: Domain, /) -> PartialResult:
"""Resolves the ``ua``."""
...
T = TypeVar("T")
[docs]
class Matcher(abc.ABC, Generic[T]):
"""A matcher is an individual pattern-rule, able to match a user
agent string and in case of success extract the relevant data.
Matchers need to expose their pattern for bulk resolvers.
"""
[docs]
@abc.abstractmethod
def __call__(self, ua: str) -> Optional[T]:
"""Applies the matcher to an input."""
...
@property
@abc.abstractmethod
def regex(self) -> str:
"""Returns the matcher's pattern."""
...
@property
def flags(self) -> int:
"""Returns the matcher's pattern flags (only
:data:`re.IGNORECASE` is supported, and only for
:class:`Matcher` [:class:`Device`])
"""
return 0
Matchers = Tuple[
List[Matcher[UserAgent]],
List[Matcher[OS]],
List[Matcher[Device]],
]