clients-protocol/clients_protocol/stores.py
Jon Michael Aanes 80b0600b45
Some checks failed
Run Python tests (through Pytest) / Test (push) Failing after 24s
Verify Python project can be installed, loaded and have version checked / Test (push) Failing after 22s
Moved stores definitions over from clients.
2025-07-04 00:12:04 +02:00

187 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."""