188 lines
5.7 KiB
Python
188 lines
5.7 KiB
Python
"""Data definitions for store clients."""
|
|
|
|
from collections.abc import Iterator, Sequence
|
|
from fin_defs import AssetAmount
|
|
from frozendict import frozendict
|
|
import abc
|
|
import dataclasses
|
|
import datetime
|
|
import enum
|
|
import logging
|
|
|
|
from . import AbstractClient
|
|
|
|
DEFAULT_MAX_RESULTS = 1
|
|
|
|
URL = str
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True, order=True, slots=True)
|
|
class SellerInfo:
|
|
name: str
|
|
url: str
|
|
address: str
|
|
country_alpha2: str
|
|
company_registration_number: int | None = None
|
|
phone_number: str | None = None
|
|
email_address: str | None = None
|
|
validated: bool | None = None
|
|
creation_date: datetime.date | None = None
|
|
|
|
def __post_init__(self):
|
|
assert self.name.strip() == self.name
|
|
assert self.name != ''
|
|
assert self.url.strip() == self.url
|
|
assert self.address.strip() == self.address
|
|
assert len(self.country_alpha2) == 2, 'Invalid ISO 3166-1 alpha-2 country code'
|
|
|
|
|
|
class StoreOfferProperty(enum.Enum):
|
|
AUCTION = enum.auto()
|
|
SOLD_OUT = enum.auto()
|
|
DISCOUNT = enum.auto()
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True, order=True, slots=True)
|
|
class StoreOffer:
|
|
title: str
|
|
description: str
|
|
listed_price: AssetAmount
|
|
source: str
|
|
image_sources: Sequence[str] = ()
|
|
seller: SellerInfo | None = None
|
|
date_published: datetime.datetime | None = None
|
|
date_updated: datetime.datetime | None = None
|
|
properties: frozenset[StoreOfferProperty] = dataclasses.field(
|
|
default_factory=frozenset,
|
|
)
|
|
metadata: frozendict[str, str] = dataclasses.field(default_factory=frozendict)
|
|
price_shipping_estimate: AssetAmount | None = None
|
|
|
|
def __post_init__(self):
|
|
assert self.title.strip() == self.title, self.title
|
|
# assert '\n' not in self.title, self.title
|
|
assert self.description is not None
|
|
assert self.source.strip() == self.source, self.source
|
|
assert None not in self.metadata
|
|
assert None not in self.image_sources
|
|
assert isinstance(self.metadata, frozendict), 'Metadata must be a frozendict'
|
|
assert isinstance(self.image_sources, tuple), 'image_sources must be a tuple'
|
|
assert (
|
|
isinstance(self.listed_price, AssetAmount) or self.listed_price is None
|
|
), f'listed_price must be an AssetAmount, but was {self.listed_price}'
|
|
assert (
|
|
isinstance(self.price_shipping_estimate, AssetAmount)
|
|
or self.price_shipping_estimate is None
|
|
), (
|
|
f'price_shipping_estimate must be an AssetAmount, but was {self.price_shipping_estimate}'
|
|
)
|
|
|
|
@property
|
|
def is_auction(self) -> bool:
|
|
return StoreOfferProperty.AUCTION in self.properties
|
|
|
|
|
|
ALLOWED_CATEGORIES = {None, 'video-games'}
|
|
ALLOWED_REGION_NAMES = frozenset({None, 'japan', 'pal', 'ntsc', 'english asian'})
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True, order=True, slots=True)
|
|
class Product:
|
|
name: str
|
|
category: str
|
|
platform: str | None = None
|
|
edition: str | None = None
|
|
region: str | None = None
|
|
|
|
def __post_init__(self):
|
|
assert self.category in ALLOWED_CATEGORIES, f'Unknown category: {self.category}'
|
|
assert self.region in ALLOWED_REGION_NAMES, f'Unknown region: {self.region}'
|
|
|
|
@staticmethod
|
|
def videogame(name, **kwargs):
|
|
return Product(name, category='video-games', **kwargs)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ProductPrices:
|
|
"""Reference prices for a specific product.
|
|
|
|
Fields:
|
|
- product: Product that this object describes prices for.
|
|
- loose: Price for the product itself without any packaging.
|
|
- complete: Price for the product with all of its packaging and in-box extracts.
|
|
- new: Price of an unopened sealed product, with all of its packaging and in-box extracts.
|
|
- graded: Price of an unopened sealed product that have been graded by a professional.
|
|
- source: URL indicating the source of the pricing information.
|
|
"""
|
|
|
|
product: Product
|
|
loose: AssetAmount
|
|
complete: AssetAmount
|
|
new: AssetAmount
|
|
graded: AssetAmount
|
|
source: str
|
|
|
|
|
|
class ItemSaleStatus(enum.Enum):
|
|
SOLD = enum.auto()
|
|
|
|
|
|
class OfferNoLongerAvailableError(Exception):
|
|
pass
|
|
|
|
|
|
class StoreOfferAccessClient(AbstractClient, abc.ABC):
|
|
"""Client for accessing specific store offers on specific websites."""
|
|
|
|
@abc.abstractmethod
|
|
def supports_url(self, url: URL) -> bool:
|
|
"""Check if this client supports the given URL.
|
|
|
|
This method determines whether the provided URL is supported by this
|
|
client.
|
|
|
|
Args:
|
|
url: The URL to check for support.
|
|
|
|
Returns:
|
|
True if the URL is supported, False otherwise.
|
|
|
|
Performance:
|
|
Lightweight. Implementations should not send any HTTP requests.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def get_store_offer(self, url: URL) -> StoreOffer:
|
|
"""Downloads the description of the store offer."""
|
|
|
|
|
|
class StoreOfferSearchClient(AbstractClient, abc.ABC):
|
|
"""Client for searching for store offers on specific websites."""
|
|
|
|
@abc.abstractmethod
|
|
def search_for_games(
|
|
self,
|
|
max_results: int = DEFAULT_MAX_RESULTS,
|
|
) -> Iterator[StoreOffer]:
|
|
"""Searches for all games offers on the respective website."""
|
|
|
|
@abc.abstractmethod
|
|
def search_for_product(
|
|
self,
|
|
search_term: str,
|
|
max_results: int = DEFAULT_MAX_RESULTS,
|
|
) -> Iterator[StoreOffer]:
|
|
"""Searches for the given named product."""
|
|
|
|
|
|
class ReferencePriceClient(AbstractClient, abc.ABC):
|
|
"""Client for accessing reference prices for specific products."""
|
|
|
|
@abc.abstractmethod
|
|
def get_prices(
|
|
self,
|
|
product: Product,
|
|
) -> ProductPrices | None:
|
|
"""Gets reference prices for the given product."""
|