Compare commits
73
Commits
3a8a12ac6e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1120083e45 | ||
|
|
6ea3187b33 | ||
|
|
7d514a5401 | ||
|
|
f51aed2c9f | ||
|
|
bd5df3a4c2 | ||
|
|
6720592685 | ||
|
|
1b3f860ff3 | ||
|
|
4563cdf920 | ||
|
|
7032f5b9d6 | ||
|
|
b9fbb64364 | ||
|
|
ae637a134f | ||
|
|
4729a62ae2 | ||
|
|
401258f52a | ||
|
|
2bf2c613a9 | ||
|
|
9a34ae5ca6 | ||
|
|
0fceafb364 | ||
|
|
0275f94500 | ||
|
|
69484c5ae0 | ||
|
|
03cc3a35cb | ||
|
|
c9545ec9d8 | ||
|
|
93013c5d55 | ||
|
|
7f30a8a5cc | ||
|
|
39a6c40154 | ||
|
|
87d751cd82 | ||
|
|
1218ba0b3c | ||
|
|
2a5d649f90 | ||
|
|
b599d34e1e | ||
|
|
04ce7b50f3 | ||
|
|
b071ac35bf | ||
|
|
cc46e3d409 | ||
|
|
321546f9b9 | ||
|
|
5db82e3a59 | ||
|
|
0b462aef06 | ||
|
|
95af8a37e1 | ||
|
|
dee090edaf | ||
|
|
2ede6c77c7 | ||
|
|
6c309b1e62 | ||
|
|
b34db200cc | ||
|
|
1f5224a92c | ||
|
|
7c420059ee | ||
|
|
61c4887c38 | ||
|
|
2e6e7ebc69 | ||
|
|
8903be2685 | ||
|
|
aa6ed4cb06 | ||
|
|
3f823b3a6e | ||
|
|
3573551957 | ||
|
|
8a1c1893b7 | ||
|
|
c29032a765 | ||
|
|
1e53904cc6 | ||
|
|
99e68b1fa9 | ||
|
|
b6cd692258 | ||
|
|
763f40988f | ||
|
|
d4f4830ff2 | ||
|
|
4c1033057a | ||
|
|
e9632470fd | ||
|
|
5effb835a7 | ||
|
|
b876c3d7b3 | ||
|
|
9af7e7adcf | ||
|
|
4b63a2d39c | ||
|
|
3cbe5beb43 | ||
|
|
0cfcca01bb | ||
|
|
65ddb40bc3 | ||
|
|
17219507bf | ||
|
|
db947a6354 | ||
|
|
638d8fb359 | ||
|
|
666046bdd1 | ||
|
|
20ad4c6535 | ||
|
|
e9559f3248 | ||
|
|
d307be60b0 | ||
|
|
bb24d0c13a | ||
|
|
3515af6fd8 | ||
|
|
8e077515ff | ||
|
|
1441e7179c |
No files matched your search
+3
-1
@@ -5,4 +5,6 @@ __pycache__
|
|||||||
.idea
|
.idea
|
||||||
monitor_utils.egg-info
|
monitor_utils.egg-info
|
||||||
uv.lock
|
uv.lock
|
||||||
.claude
|
.claude/
|
||||||
|
build/
|
||||||
|
logs/
|
||||||
+25
-5
@@ -5,11 +5,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "monitor-utils"
|
name = "monitor-utils"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11" # asyncio.timeout/TaskGroup, BaseExceptionGroup
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp>=3.14.1",
|
"aiohttp>=3.14.1",
|
||||||
"aioproxies",
|
"aioproxies @ git+https://git.rethinkstudios.io/rethink-public/aioproxies.git",
|
||||||
"curl-cffi>=0.15.0",
|
"aioweb-tls[curl] @ git+https://git.rethinkstudios.io/rethink-public/aioweb_tls.git",
|
||||||
|
"curl-cffi==0.15.0",
|
||||||
"fake-useragent>=2.2.0",
|
"fake-useragent>=2.2.0",
|
||||||
"motor>=3.7.1",
|
"motor>=3.7.1",
|
||||||
"pydantic>=2.13.4",
|
"pydantic>=2.13.4",
|
||||||
@@ -17,11 +18,30 @@ dependencies = [
|
|||||||
"requests>=2.34.2",
|
"requests>=2.34.2",
|
||||||
"tenacity>=9.1.4",
|
"tenacity>=9.1.4",
|
||||||
"log-setup @ git+https://git.rethinkstudios.io/rethink-public/log_setup.git",
|
"log-setup @ git+https://git.rethinkstudios.io/rethink-public/log_setup.git",
|
||||||
|
"httpx>=0.28.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
include = ["monitor_utils*"]
|
include = ["monitor_utils*"]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[dependency-groups]
|
||||||
aioproxies = { git = "https://git.rethinkstudios.io/rethink-public/aioproxies.git" }
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"ruff>=0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py311"
|
||||||
|
# dsql_shortlinks.py is vendored third-party code; leave it untouched.
|
||||||
|
extend-exclude = ["src/monitor_utils/dsql_shortlinks.py"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "W", "UP", "B"]
|
||||||
|
# B905: zip(strict=) is opinionated; the zips here are equal-length by
|
||||||
|
# construction and adding strict= would change working code for no gain.
|
||||||
|
ignore = ["B905"]
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
|
||||||
from monitor_utils.constants import LOGGERS
|
from monitor_utils.constants import LOGGERS
|
||||||
from monitor_utils.database import get_collection
|
from monitor_utils.database import get_collection
|
||||||
|
|
||||||
@@ -26,16 +28,22 @@ def is_production(development_mode: DevelopmentMode) -> bool:
|
|||||||
return development_mode == DevelopmentMode.PRODUCTION
|
return development_mode == DevelopmentMode.PRODUCTION
|
||||||
|
|
||||||
|
|
||||||
async def get_config(monitor_name: str, use_production: bool = False) -> dict:
|
async def get_config(
|
||||||
|
monitor: str,
|
||||||
|
use_production: bool = False,
|
||||||
|
mongo_client: AsyncIOMotorClient = None,
|
||||||
|
) -> dict:
|
||||||
"""Load a monitor's config document from Mongo.
|
"""Load a monitor's config document from Mongo.
|
||||||
|
|
||||||
Reads the configs collection in production and configs_testing in
|
Reads the configs collection in production and configs_testing in
|
||||||
development, so a dev run never reads production routing.
|
development, so a dev run never reads production routing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
monitor_name: Monitor identifier (the config document _id).
|
monitor: Monitor identifier (the config document _id).
|
||||||
use_production: Read the production configs collection instead of
|
use_production: Read the production configs collection instead of
|
||||||
configs_testing.
|
configs_testing.
|
||||||
|
mongo_client: Client to read through; the shared cluster client when
|
||||||
|
None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The config document, including its categories and channels.
|
The config document, including its categories and channels.
|
||||||
@@ -49,9 +57,11 @@ async def get_config(monitor_name: str, use_production: bool = False) -> dict:
|
|||||||
if use_production
|
if use_production
|
||||||
else f"{CONFIGS_COLLECTION_NAME}_testing"
|
else f"{CONFIGS_COLLECTION_NAME}_testing"
|
||||||
)
|
)
|
||||||
config = await get_collection(collection_name).find_one({"_id": monitor_name})
|
config = await get_collection(collection_name, mongo_client=mongo_client).find_one(
|
||||||
|
{"_id": monitor}
|
||||||
|
)
|
||||||
if config is None:
|
if config is None:
|
||||||
raise ConfigNotFoundError(monitor_name)
|
raise ConfigNotFoundError(monitor)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ from json import JSONDecodeError
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import requests
|
import requests
|
||||||
|
from aioweb import AiowebError
|
||||||
from curl_cffi.requests.errors import RequestsError
|
from curl_cffi.requests.errors import RequestsError
|
||||||
from pymongo.errors import PyMongoError
|
from pymongo.errors import PyMongoError
|
||||||
|
|
||||||
MONGO_DATABASE_NAME = "tpv_woot"
|
MONGO_DATABASE_NAME = "tpv"
|
||||||
MONGO_URL = (
|
MONGO_URL = "mongodb://tpv:mvVuBhhUiCabeNikmUt71L8VqJQHh7IF@ridley.data.dev.ca.rethinkstudios.io:27017/tpv?authSource=admin"
|
||||||
"mongodb://tpv:n3%23QI07GnbZCbvGJ@zelda.disqualifier.me:27017/?authSource=tpv_woot"
|
|
||||||
)
|
|
||||||
LOGGING_LEVEL = "INFO"
|
LOGGING_LEVEL = "INFO"
|
||||||
LOGGERS = [
|
LOGGERS = [
|
||||||
"discord",
|
"discord",
|
||||||
@@ -27,12 +26,24 @@ LOGGERS = [
|
|||||||
|
|
||||||
RETRY_LIMIT = 5
|
RETRY_LIMIT = 5
|
||||||
RETRY_WAIT = 3
|
RETRY_WAIT = 3
|
||||||
|
CYCLE_DELAY = 0.30
|
||||||
MIN_BACKOFF_DELAY = 0.1
|
MIN_BACKOFF_DELAY = 0.1
|
||||||
MAX_BACKOFF_DELAY = 2.0
|
MAX_BACKOFF_DELAY = 2.0
|
||||||
FAILURE_DELAY = 3
|
FAILURE_DELAY = 3
|
||||||
|
|
||||||
TIMEOUT_SECONDS = 30
|
TIMEOUT_SECONDS = 30
|
||||||
CLIENT_IDENTIFIERS = ["chrome", "chrome_android", "safari", "safari_ios"]
|
CYCLE_TIMEOUT_SECONDS = 600
|
||||||
|
MAX_CURL_HANDLES = 64
|
||||||
|
SESSION_CLOSE_TIMEOUT_SECONDS = 10
|
||||||
|
CLIENT_IDENTIFIERS = [
|
||||||
|
"chrome119",
|
||||||
|
"chrome120",
|
||||||
|
"chrome124",
|
||||||
|
"chrome131",
|
||||||
|
"chrome131_android",
|
||||||
|
"safari",
|
||||||
|
"safari_ios",
|
||||||
|
]
|
||||||
CLIENT_TIMEOUT = aiohttp.ClientTimeout(
|
CLIENT_TIMEOUT = aiohttp.ClientTimeout(
|
||||||
total=TIMEOUT_SECONDS, sock_connect=30, sock_read=30
|
total=TIMEOUT_SECONDS, sock_connect=30, sock_read=30
|
||||||
)
|
)
|
||||||
@@ -44,6 +55,7 @@ EXPECTED_TASK_ERRORS = (
|
|||||||
requests.HTTPError,
|
requests.HTTPError,
|
||||||
PyMongoError,
|
PyMongoError,
|
||||||
JSONDecodeError,
|
JSONDecodeError,
|
||||||
|
AiowebError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ alert references for new products and price changes.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from functools import cache
|
from functools import cache
|
||||||
@@ -20,18 +21,36 @@ from monitor_utils.models import Product
|
|||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Drain rank for products with no MSRP. Their percentage is 0 by construction, so
|
||||||
|
# without this they sort dead last and starve behind any backlog. 6 puts them
|
||||||
|
# directly under the 70%+ band, tying with 60-69% and broken by createdAt.
|
||||||
|
NO_MSRP_PRIORITY = 6
|
||||||
|
# Floor rank for new listings, which send regardless of discount and would
|
||||||
|
# otherwise sort near 0 and starve the same way MSRP-less products did.
|
||||||
|
NEW_LISTING_PRIORITY = 6
|
||||||
|
|
||||||
def get_collection(collection_name: str) -> AsyncIOMotorCollection:
|
|
||||||
|
def get_collection(
|
||||||
|
collection_name: str,
|
||||||
|
mongo_client: AsyncIOMotorClient = None,
|
||||||
|
database_name=MONGO_DATABASE_NAME,
|
||||||
|
) -> AsyncIOMotorCollection:
|
||||||
|
# rename mongo_client to client and add optional database name arg
|
||||||
"""Return the named collection from the shared client's database.
|
"""Return the named collection from the shared client's database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
collection_name: Name of the collection to return.
|
collection_name: Name of the collection to return.
|
||||||
|
mongo_client: optional client for projects with already configured client
|
||||||
|
database_name: optional name to use besides the default mongo database name
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The requested collection.
|
The requested collection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return get_mongo_client()[MONGO_DATABASE_NAME][collection_name]
|
if mongo_client is None:
|
||||||
|
mongo_client = get_mongo_client()
|
||||||
|
|
||||||
|
return mongo_client[database_name][collection_name]
|
||||||
|
|
||||||
|
|
||||||
def resolve_alerts_collection(
|
def resolve_alerts_collection(
|
||||||
@@ -58,17 +77,22 @@ def resolve_alerts_collection(
|
|||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
def get_mongo_client() -> AsyncIOMotorClient:
|
def get_mongo_client(mongo_url: str = MONGO_URL) -> AsyncIOMotorClient:
|
||||||
"""Return a cached Motor client configured for the shared cluster.
|
"""Return a cached Motor client configured for the shared cluster.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mongo_url: The mongo url to use for connections.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The pooled, timezone-aware Motor client.
|
The pooled, timezone-aware Motor client.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return AsyncIOMotorClient(
|
return AsyncIOMotorClient(
|
||||||
MONGO_URL,
|
mongo_url,
|
||||||
maxPoolSize=200,
|
|
||||||
minPoolSize=10,
|
minPoolSize=10,
|
||||||
|
connectTimeoutMS=20_000,
|
||||||
|
socketTimeoutMS=60_000,
|
||||||
|
serverSelectionTimeoutMS=30_000,
|
||||||
retryWrites=True,
|
retryWrites=True,
|
||||||
retryReads=True,
|
retryReads=True,
|
||||||
tz_aware=True,
|
tz_aware=True,
|
||||||
@@ -89,7 +113,7 @@ async def add_products_to_database(
|
|||||||
products: list[Product],
|
products: list[Product],
|
||||||
collection: AsyncIOMotorCollection,
|
collection: AsyncIOMotorCollection,
|
||||||
category_identifier: str,
|
category_identifier: str,
|
||||||
monitor_name: str,
|
monitor: str,
|
||||||
use_composite: bool = False,
|
use_composite: bool = False,
|
||||||
):
|
):
|
||||||
"""Upsert products, tracking deduplicated price history and emitting alerts.
|
"""Upsert products, tracking deduplicated price history and emitting alerts.
|
||||||
@@ -97,9 +121,12 @@ async def add_products_to_database(
|
|||||||
Rewrites current fields on each upsert. priceBeforeDrop is derived here,
|
Rewrites current fields on each upsert. priceBeforeDrop is derived here,
|
||||||
not by monitors: it is set to the previous stored currentPrice when the new
|
not by monitors: it is set to the previous stored currentPrice when the new
|
||||||
price is lower (a drop) and cleared otherwise, so any price_before_drop on
|
price is lower (a drop) and cleared otherwise, so any price_before_drop on
|
||||||
the incoming product is ignored. Prices are recorded in a pastPrices array
|
the incoming product is ignored. newListing is likewise stamped here, not
|
||||||
|
by monitors: True only on the cycle whose write inserts the document, and
|
||||||
|
copied onto the discovery alert, whose copy never flips. Prices are recorded in a pastPrices array
|
||||||
of price/date entries; a price already present in the array is never
|
of price/date entries; a price already present in the array is never
|
||||||
recorded again, so each distinct price keeps only its first-seen timestamp. New products and never-before-recorded prices
|
recorded again, so each distinct price keeps only its first-seen timestamp.
|
||||||
|
New products and never-before-recorded prices
|
||||||
upsert an alert referencing the product; a price returning to a value
|
upsert an alert referencing the product; a price returning to a value
|
||||||
already in pastPrices emits nothing, so per-source price oscillation
|
already in pastPrices emits nothing, so per-source price oscillation
|
||||||
(member vs non-member quotes for the same SKU) alerts once per distinct
|
(member vs non-member quotes for the same SKU) alerts once per distinct
|
||||||
@@ -112,13 +139,19 @@ async def add_products_to_database(
|
|||||||
products: Products to upsert.
|
products: Products to upsert.
|
||||||
collection: Target MongoDB collection.
|
collection: Target MongoDB collection.
|
||||||
category_identifier: Category suffix appended to composite ids.
|
category_identifier: Category suffix appended to composite ids.
|
||||||
monitor_name: Monitor name stamped onto emitted alerts for consumer
|
monitor: Monitor name stamped onto emitted alerts for consumer
|
||||||
routing.
|
routing.
|
||||||
use_composite: Build composite ids so multi-variant monitors (Woot)
|
use_composite: Build composite ids so multi-variant monitors (Woot)
|
||||||
keep one doc per variant; other monitors collapse to one doc per
|
keep one doc per variant; other monitors collapse to one doc per
|
||||||
product_id.
|
product_id.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Callers are expected to filter empty batches, but guard here too: an empty
|
||||||
|
# products list would make collection.bulk_write([]) raise InvalidOperation,
|
||||||
|
# which callers would then log as a spurious category failure every cycle.
|
||||||
|
if not products:
|
||||||
|
return
|
||||||
|
|
||||||
doc_ids = [
|
doc_ids = [
|
||||||
build_doc_id(p.product_id, category_identifier, use_composite) for p in products
|
build_doc_id(p.product_id, category_identifier, use_composite) for p in products
|
||||||
]
|
]
|
||||||
@@ -134,6 +167,10 @@ async def add_products_to_database(
|
|||||||
document = convert_product_to_mongo(product)
|
document = convert_product_to_mongo(product)
|
||||||
document["_id"] = doc_id
|
document["_id"] = doc_id
|
||||||
existing_doc = existing.get(doc_id)
|
existing_doc = existing.get(doc_id)
|
||||||
|
# Stamped here, not by monitors: only the database knows whether the
|
||||||
|
# doc already exists. Must precede build_alert so the alert's copy of
|
||||||
|
# the flag is taken from the stamped value.
|
||||||
|
document["newListing"] = existing_doc is None
|
||||||
new_price = document.get("currentPrice")
|
new_price = document.get("currentPrice")
|
||||||
previous_price = existing_doc.get("currentPrice") if existing_doc else None
|
previous_price = existing_doc.get("currentPrice") if existing_doc else None
|
||||||
document["priceBeforeDrop"] = resolve_price_before_drop(
|
document["priceBeforeDrop"] = resolve_price_before_drop(
|
||||||
@@ -144,7 +181,7 @@ async def add_products_to_database(
|
|||||||
never_seen_price = new_price is not None and new_price not in recorded_prices
|
never_seen_price = new_price is not None and new_price not in recorded_prices
|
||||||
set_on_insert = {"discoveredDate": now}
|
set_on_insert = {"discoveredDate": now}
|
||||||
if existing_doc is None or never_seen_price:
|
if existing_doc is None or never_seen_price:
|
||||||
alert = build_alert(monitor_name, document, now)
|
alert = build_alert(monitor, document, now)
|
||||||
alert_ops.append(
|
alert_ops.append(
|
||||||
UpdateOne(
|
UpdateOne(
|
||||||
{"product_id": doc_id, "claimed": False},
|
{"product_id": doc_id, "claimed": False},
|
||||||
@@ -243,14 +280,22 @@ def extract_recorded_prices(existing_doc: dict | None) -> set[float]:
|
|||||||
return {entry["price"] for entry in existing_doc.get("pastPrices", [])}
|
return {entry["price"] for entry in existing_doc.get("pastPrices", [])}
|
||||||
|
|
||||||
|
|
||||||
def build_alert(monitor_name: str, document: dict, now: datetime) -> dict:
|
def build_alert(monitor: str, document: dict, now: datetime) -> dict:
|
||||||
"""Build an alert referencing a product for consumer delivery.
|
"""Build an alert referencing a product for consumer delivery.
|
||||||
|
|
||||||
|
The alert carries its own copy of the document's newListing flag and
|
||||||
|
priceBeforeDrop: the product doc is rewritten every cycle, so its flag
|
||||||
|
flips False one cycle after discovery and its priceBeforeDrop is
|
||||||
|
cleared by the next cycle that sees no drop, while an alert may sit in
|
||||||
|
a backed-up queue longer than that. The alert's copies are written once
|
||||||
|
and cannot rot.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
monitor_name: Monitor that produced the product, used by the
|
monitor: Monitor that produced the product, used by the
|
||||||
consumer to resolve the owning collection and thresholds.
|
consumer to resolve the owning collection and thresholds.
|
||||||
document: Product document supplying the id and pricing for the
|
document: Product document supplying the id, pricing for the
|
||||||
priority calculation.
|
priority calculation, and the stamped newListing flag and
|
||||||
|
priceBeforeDrop.
|
||||||
now: Detection timestamp stored as createdAt.
|
now: Detection timestamp stored as createdAt.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -258,12 +303,14 @@ def build_alert(monitor_name: str, document: dict, now: datetime) -> dict:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"monitor": monitor_name,
|
"monitor": monitor,
|
||||||
"product_id": document["_id"],
|
"product_id": document["_id"],
|
||||||
"priority": calculate_priority(document),
|
"priority": calculate_priority(document),
|
||||||
"claimed": False,
|
"claimed": False,
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"createdAt": now,
|
"createdAt": now,
|
||||||
|
"newListing": document.get("newListing", False),
|
||||||
|
"priceBeforeDrop": document.get("priceBeforeDrop"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -272,8 +319,15 @@ def calculate_priority(document: dict) -> int:
|
|||||||
|
|
||||||
Always recalculated from current and original price so the priority
|
Always recalculated from current and original price so the priority
|
||||||
reflects the prices at alert time rather than any stale stored
|
reflects the prices at alert time rather than any stale stored
|
||||||
percentage. Missing prices, zero original prices, and price increases
|
percentage. Missing prices, zero original prices, non-finite prices, a
|
||||||
resolve to priority 0.
|
discount too large to represent, and price increases resolve to priority
|
||||||
|
0, so one product's bad pricing can never raise out of the whole cycle (a
|
||||||
|
retailer field like "Infinity" survives float() and used to crash the
|
||||||
|
monitor with an OverflowError). A product with no MSRP has no percentage
|
||||||
|
to rank on and takes NO_MSRP_PRIORITY instead, since a real 0 would leave
|
||||||
|
it permanently behind every other alert in the claim query's sort. A new
|
||||||
|
listing is floored at NEW_LISTING_PRIORITY for the same reason: it may
|
||||||
|
carry any discount at all, and its percentage rank still wins when higher.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
document: Product document supplying the prices.
|
document: Product document supplying the prices.
|
||||||
@@ -287,8 +341,22 @@ def calculate_priority(document: dict) -> int:
|
|||||||
if current_price is None or not original_price:
|
if current_price is None or not original_price:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
percentage = int(((original_price - current_price) / original_price) * 100)
|
if not (math.isfinite(current_price) and math.isfinite(original_price)):
|
||||||
return min(max(percentage // 10, 0), 10)
|
return 0
|
||||||
|
|
||||||
|
if not document.get("hasMsrp", True):
|
||||||
|
return NO_MSRP_PRIORITY
|
||||||
|
|
||||||
|
discount = ((original_price - current_price) / original_price) * 100
|
||||||
|
if not math.isfinite(discount):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
percentage = int(discount)
|
||||||
|
priority = min(max(percentage // 10, 0), 10)
|
||||||
|
if document.get("newListing"):
|
||||||
|
return max(priority, NEW_LISTING_PRIORITY)
|
||||||
|
|
||||||
|
return priority
|
||||||
|
|
||||||
|
|
||||||
def build_doc_id(
|
def build_doc_id(
|
||||||
@@ -311,8 +379,10 @@ def build_doc_id(
|
|||||||
def convert_product_to_mongo(product: Product) -> dict:
|
def convert_product_to_mongo(product: Product) -> dict:
|
||||||
"""Flatten a Product into its MongoDB document representation.
|
"""Flatten a Product into its MongoDB document representation.
|
||||||
|
|
||||||
Coerces string prices to floats and stamps lastUpdated with the current
|
Stamps lastUpdated with the current UTC time. Prices are already floats via
|
||||||
UTC time. The _id is assigned by the caller via build_doc_id.
|
the Product's pydantic validation (numeric strings coerced, comma strings
|
||||||
|
rejected at construction), so no coercion happens here. The _id is assigned
|
||||||
|
by the caller via build_doc_id.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
product: Product to convert.
|
product: Product to convert.
|
||||||
@@ -328,9 +398,6 @@ def convert_product_to_mongo(product: Product) -> dict:
|
|||||||
availability = data["availability"]
|
availability = data["availability"]
|
||||||
current_price = pricing["current_price"]
|
current_price = pricing["current_price"]
|
||||||
original_price = pricing["original_price"]
|
original_price = pricing["original_price"]
|
||||||
if isinstance(current_price, str):
|
|
||||||
current_price = float(current_price.replace(",", ""))
|
|
||||||
original_price = float(original_price.replace(",", ""))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": data["title"],
|
"title": data["title"],
|
||||||
@@ -341,10 +408,17 @@ def convert_product_to_mongo(product: Product) -> dict:
|
|||||||
"customizationIndex": metadata["customization_index"],
|
"customizationIndex": metadata["customization_index"],
|
||||||
"memberNonMemberComparison": metadata["member_non_member_comparison"],
|
"memberNonMemberComparison": metadata["member_non_member_comparison"],
|
||||||
"overstockText": metadata["overstock_text"],
|
"overstockText": metadata["overstock_text"],
|
||||||
|
"affiliateUrl": metadata["affiliate_url"],
|
||||||
|
"promoCode": metadata["promo_code"],
|
||||||
|
"promoPercentage": metadata["promo_percentage"],
|
||||||
|
"promoDollarOff": metadata["promo_dollar_off"],
|
||||||
|
"promoMinSpend": metadata["promo_min_spend"],
|
||||||
|
"asin": metadata["asin"],
|
||||||
"startDate": timing["start_date"],
|
"startDate": timing["start_date"],
|
||||||
"endDate": timing["end_date"],
|
"endDate": timing["end_date"],
|
||||||
"priceBeforeDrop": pricing["price_before_drop"],
|
"priceBeforeDrop": pricing["price_before_drop"],
|
||||||
"eduPrice": pricing["edu_price"],
|
"eduPrice": pricing["edu_price"],
|
||||||
|
"hasMsrp": pricing["has_msrp"],
|
||||||
"available": availability["available"],
|
"available": availability["available"],
|
||||||
"availabilityLastChecked": availability["availability_last_checked"],
|
"availabilityLastChecked": availability["availability_last_checked"],
|
||||||
"condition": availability["condition"],
|
"condition": availability["condition"],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# Idk who made this i was provided it by louis
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,30 @@ from datetime import datetime
|
|||||||
from pydantic.dataclasses import dataclass
|
from pydantic.dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CycleResult:
|
||||||
|
"""Tallied outcome of one monitor cycle.
|
||||||
|
|
||||||
|
Pure counts for the caller to log or act on as it sees fit; no policy is
|
||||||
|
baked in.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
successes: Category tasks that completed without an expected error.
|
||||||
|
failures: Category tasks that raised an expected error.
|
||||||
|
skipped: True when pre_task_func failed and no category tasks ran.
|
||||||
|
"""
|
||||||
|
|
||||||
|
successes: int = 0
|
||||||
|
failures: int = 0
|
||||||
|
skipped: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConsumerConfig:
|
class ConsumerConfig:
|
||||||
"""Configuration for a single retailer's consumer.
|
"""Configuration for a single retailer's consumer.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
monitor_name: Retailer name (matches setup_config key).
|
monitor: Retailer name.
|
||||||
collection: MongoDB collection for this retailer's products.
|
collection: MongoDB collection for this retailer's products.
|
||||||
channel_ids: Discord webhook/channel routing config.
|
channel_ids: Discord webhook/channel routing config.
|
||||||
min_percentage: Minimum discount % to trigger a ping.
|
min_percentage: Minimum discount % to trigger a ping.
|
||||||
@@ -20,7 +38,7 @@ class ConsumerConfig:
|
|||||||
the secondary rule.
|
the secondary rule.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
monitor_name: str
|
monitor: str
|
||||||
collection: object
|
collection: object
|
||||||
channel_ids: dict
|
channel_ids: dict
|
||||||
min_percentage: int
|
min_percentage: int
|
||||||
@@ -36,12 +54,15 @@ class PricingInfo:
|
|||||||
current_price: Current listed price.
|
current_price: Current listed price.
|
||||||
price_before_drop: Stored price prior to the last detected drop.
|
price_before_drop: Stored price prior to the last detected drop.
|
||||||
edu_price: Education-store price when offered.
|
edu_price: Education-store price when offered.
|
||||||
|
past_prices: Prior price history as price/date entries, oldest first.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
original_price: float
|
original_price: float
|
||||||
current_price: float
|
current_price: float
|
||||||
price_before_drop: float | None = None
|
price_before_drop: float | None = None
|
||||||
edu_price: float | None = None
|
edu_price: float | None = None
|
||||||
|
past_prices: list[dict] | None = None
|
||||||
|
has_msrp: bool = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -79,6 +100,16 @@ class ProductMetadata:
|
|||||||
overstock_text: Overstock label text when present.
|
overstock_text: Overstock label text when present.
|
||||||
percentage: Computed discount percentage.
|
percentage: Computed discount percentage.
|
||||||
affiliate_url: Affiliate link when available.
|
affiliate_url: Affiliate link when available.
|
||||||
|
promo_code: Event-level checkout code (e.g. "BIRTHDAY26") when the
|
||||||
|
product's event advertises one, else None.
|
||||||
|
promo_percentage: Advertised percent off from a percentage code
|
||||||
|
("extra 22% off" -> 22); None for dollar codes or no code. This is
|
||||||
|
the claimed discount, not the actual price delta.
|
||||||
|
promo_dollar_off: Advertised dollar amount off from a spend-threshold
|
||||||
|
code ("get an extra $22 off" -> 22); None for percentage codes or
|
||||||
|
no code. Pairs with promo_min_spend.
|
||||||
|
promo_min_spend: Spend threshold a dollar code requires ("Spend $100"
|
||||||
|
-> 100); None unless promo_dollar_off is set.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
image_url: str | None = None
|
image_url: str | None = None
|
||||||
@@ -88,6 +119,12 @@ class ProductMetadata:
|
|||||||
overstock_text: str | None = None
|
overstock_text: str | None = None
|
||||||
percentage: int | None = None
|
percentage: int | None = None
|
||||||
affiliate_url: str | None = None
|
affiliate_url: str | None = None
|
||||||
|
promo_code: str | None = None
|
||||||
|
promo_percentage: int | None = None
|
||||||
|
promo_dollar_off: int | None = None
|
||||||
|
promo_min_spend: int | None = None
|
||||||
|
new_listing: bool | None = None
|
||||||
|
asin: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+331
-181
@@ -1,12 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Awaitable, Callable, Iterable
|
import math
|
||||||
|
from collections.abc import Awaitable, Callable, Iterable
|
||||||
|
from contextlib import asynccontextmanager, nullcontext
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aioproxies import AioProxies
|
from aioproxies import AioProxies
|
||||||
from curl_cffi.requests import AsyncSession
|
from curl_cffi.requests import AsyncSession
|
||||||
|
from curl_cffi.requests.impersonate import BrowserTypeLiteral
|
||||||
from log_setup import setup_logging
|
from log_setup import setup_logging
|
||||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
|
||||||
from monitor_utils.config import (
|
from monitor_utils.config import (
|
||||||
DevelopmentMode,
|
DevelopmentMode,
|
||||||
@@ -14,36 +18,43 @@ from monitor_utils.config import (
|
|||||||
is_production,
|
is_production,
|
||||||
set_logger_levels,
|
set_logger_levels,
|
||||||
)
|
)
|
||||||
from monitor_utils.constants import EXPECTED_TASK_ERRORS, LOGGING_LEVEL
|
from monitor_utils.constants import (
|
||||||
|
CYCLE_TIMEOUT_SECONDS,
|
||||||
|
EXPECTED_TASK_ERRORS,
|
||||||
|
LOGGING_LEVEL,
|
||||||
|
MAX_CURL_HANDLES,
|
||||||
|
TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
from monitor_utils.database import get_collection
|
from monitor_utils.database import get_collection
|
||||||
|
from monitor_utils.sessions import get_default_session
|
||||||
from monitor_utils.models import (
|
from monitor_utils.models import (
|
||||||
AvailabilityInfo,
|
AvailabilityInfo,
|
||||||
|
CycleResult,
|
||||||
PricingInfo,
|
PricingInfo,
|
||||||
Product,
|
Product,
|
||||||
ProductMetadata,
|
ProductMetadata,
|
||||||
ProductTiming,
|
ProductTiming,
|
||||||
)
|
)
|
||||||
from monitor_utils.sessions import get_base_session, get_tls_session
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL):
|
def setup_monitor_logging(monitor: str, logging_level=LOGGING_LEVEL):
|
||||||
"""Configure JSON logging for a monitor and quiet noisy libraries.
|
"""Configure JSON logging for a monitor and quiet noisy libraries.
|
||||||
|
|
||||||
Call once at module import, at global scope, before creating module loggers,
|
Call once at module import, at global scope, before creating module loggers,
|
||||||
so logging is configured synchronously at startup rather than inside the
|
so logging is configured synchronously at startup rather than inside the
|
||||||
async init_monitor. Logs go to logs/latest.log with rotated history keyed by
|
async init_monitor. Logs go to logs/latest.log with rotated history keyed by
|
||||||
monitor_name.
|
monitor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
monitor_name: Monitor name used as the rotated-history base.
|
monitor: Monitor name used as the rotated-history base.
|
||||||
logging_level: Root logging level.
|
logging_level: Root logging level.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
setup_logging(
|
setup_logging(
|
||||||
name="logs/latest.log",
|
name="logs/latest.log",
|
||||||
history_name=monitor_name,
|
history_name=monitor,
|
||||||
level=logging_level,
|
level=logging_level,
|
||||||
output="json",
|
output="json",
|
||||||
)
|
)
|
||||||
@@ -51,7 +62,7 @@ def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL):
|
|||||||
|
|
||||||
|
|
||||||
async def init_monitor(
|
async def init_monitor(
|
||||||
monitor_name: str,
|
monitor: str,
|
||||||
development_mode: DevelopmentMode,
|
development_mode: DevelopmentMode,
|
||||||
production_proxy_path: str = "proxies.txt",
|
production_proxy_path: str = "proxies.txt",
|
||||||
testing_proxy_path: str = "tests/configs/proxies.txt",
|
testing_proxy_path: str = "tests/configs/proxies.txt",
|
||||||
@@ -63,7 +74,7 @@ async def init_monitor(
|
|||||||
by setup_monitor_logging at module scope and is not touched here.
|
by setup_monitor_logging at module scope and is not touched here.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
monitor_name: Monitor identifier used for config and collection lookup.
|
monitor: Monitor identifier used for config and collection lookup.
|
||||||
development_mode: Selects production or testing config, collection,
|
development_mode: Selects production or testing config, collection,
|
||||||
and proxy path.
|
and proxy path.
|
||||||
production_proxy_path: Proxy file path used in production mode.
|
production_proxy_path: Proxy file path used in production mode.
|
||||||
@@ -75,7 +86,7 @@ async def init_monitor(
|
|||||||
|
|
||||||
use_production = is_production(development_mode)
|
use_production = is_production(development_mode)
|
||||||
category_names, _, collection = await setup_config(
|
category_names, _, collection = await setup_config(
|
||||||
monitor_name=monitor_name, development_mode=development_mode
|
monitor=monitor, development_mode=development_mode
|
||||||
)
|
)
|
||||||
proxy_path = production_proxy_path if use_production else testing_proxy_path
|
proxy_path = production_proxy_path if use_production else testing_proxy_path
|
||||||
proxies = AioProxies.from_file(path=proxy_path)
|
proxies = AioProxies.from_file(path=proxy_path)
|
||||||
@@ -83,7 +94,9 @@ async def init_monitor(
|
|||||||
|
|
||||||
|
|
||||||
async def setup_config(
|
async def setup_config(
|
||||||
monitor_name: str, development_mode: DevelopmentMode
|
monitor: str,
|
||||||
|
development_mode: DevelopmentMode,
|
||||||
|
mongo_client: AsyncIOMotorClient = None,
|
||||||
) -> tuple[
|
) -> tuple[
|
||||||
list[str] | dict[str, str], dict[str, str | list[str]], AsyncIOMotorCollection
|
list[str] | dict[str, str], dict[str, str | list[str]], AsyncIOMotorCollection
|
||||||
]:
|
]:
|
||||||
@@ -94,9 +107,11 @@ async def setup_config(
|
|||||||
during development.
|
during development.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
monitor_name: Monitor identifier used for config and collection lookup.
|
monitor: Monitor identifier used for config and collection lookup.
|
||||||
development_mode: Selects the production or testing config and
|
development_mode: Selects the production or testing config and
|
||||||
collection.
|
collection.
|
||||||
|
mongo_client: Client the config read and the returned collection use;
|
||||||
|
the shared cluster client when None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of category names, channel id mapping, and Mongo collection.
|
Tuple of category names, channel id mapping, and Mongo collection.
|
||||||
@@ -104,32 +119,65 @@ async def setup_config(
|
|||||||
|
|
||||||
use_production = is_production(development_mode)
|
use_production = is_production(development_mode)
|
||||||
config = await get_config(
|
config = await get_config(
|
||||||
monitor_name=monitor_name,
|
monitor=monitor,
|
||||||
use_production=use_production,
|
use_production=use_production,
|
||||||
|
mongo_client=mongo_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
category_names = config["categories"]
|
category_names = config["categories"]
|
||||||
collection = get_collection(
|
collection = get_collection(
|
||||||
monitor_name if use_production else f"{monitor_name}_testing"
|
monitor if use_production else f"{monitor}_testing",
|
||||||
|
mongo_client=mongo_client,
|
||||||
)
|
)
|
||||||
return category_names, config["channels"], collection
|
return category_names, config["channels"], collection
|
||||||
|
|
||||||
|
|
||||||
|
def combined_discount(base_percentage: int, promo_percentage: int | None) -> int:
|
||||||
|
"""Combine an on-sale discount with a percentage code applied at checkout.
|
||||||
|
|
||||||
|
A "extra N% off" code discounts the already-reduced sale price, so the two
|
||||||
|
compound rather than add: 20% off then an extra 30% code is 44% off list,
|
||||||
|
not 50%. Returns base_percentage unchanged when there is no percentage code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_percentage: The on-sale discount off list, as a whole percent.
|
||||||
|
promo_percentage: The code's advertised percent off, or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The combined discount off list, rounded to a whole percent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not promo_percentage:
|
||||||
|
return base_percentage
|
||||||
|
|
||||||
|
return round(100 - (100 - base_percentage) * (100 - promo_percentage) / 100)
|
||||||
|
|
||||||
|
|
||||||
def build_product_details(
|
def build_product_details(
|
||||||
product: dict,
|
product: dict,
|
||||||
min_percentage: int,
|
min_percentage: int,
|
||||||
min_price_drop: float | None = None,
|
min_price_drop: float | None = None,
|
||||||
|
bypass_thresholds: bool = False,
|
||||||
) -> Product | None:
|
) -> Product | None:
|
||||||
"""Build a Product from a Mongo document if it passes discount thresholds.
|
"""Build a Product from a Mongo document if it passes discount thresholds.
|
||||||
|
|
||||||
Skips documents with a missing or zero original price. Passing requires
|
Skips documents with a missing or zero original price, a non-finite
|
||||||
meeting min_percentage, or min_price_drop when provided. Channel routing
|
price, or a discount too large to represent: a retailer field like
|
||||||
is owned by the consumer bot; no channel id is attached here.
|
"Infinity" survives float(), and the percentage math used to raise
|
||||||
|
OverflowError out of every caller. Passing requires
|
||||||
|
meeting min_percentage, or min_price_drop when provided. The caller may
|
||||||
|
bypass both thresholds (new listings send at any discount for monitors
|
||||||
|
that opt in); which alerts qualify is the caller's decision, not read
|
||||||
|
from the document, so per-monitor rules stay out of this function.
|
||||||
|
Channel routing is owned by the consumer bot; no channel id is attached
|
||||||
|
here.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
product: Mongo document for the product.
|
product: Mongo document for the product.
|
||||||
min_percentage: Minimum discount percentage to pass.
|
min_percentage: Minimum discount percentage to pass.
|
||||||
min_price_drop: Optional minimum absolute price drop to pass.
|
min_price_drop: Optional minimum absolute price drop to pass.
|
||||||
|
bypass_thresholds: Build the product even when it fails both
|
||||||
|
thresholds. The missing- and non-finite-price skips still apply.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The built Product, or None when skipped or below thresholds.
|
The built Product, or None when skipped or below thresholds.
|
||||||
@@ -138,6 +186,8 @@ def build_product_details(
|
|||||||
product_id = product["_id"]
|
product_id = product["_id"]
|
||||||
current_price = product["currentPrice"]
|
current_price = product["currentPrice"]
|
||||||
original_price = product["originalPrice"]
|
original_price = product["originalPrice"]
|
||||||
|
new_listing = product.get("newListing")
|
||||||
|
|
||||||
if not original_price:
|
if not original_price:
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"missing or zero original price, skipping",
|
"missing or zero original price, skipping",
|
||||||
@@ -145,14 +195,49 @@ def build_product_details(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
percentage = int(((original_price - current_price) / original_price) * 100)
|
# Skipped on BOTH paths, not just the percentage one: an MSRP-less product
|
||||||
price_drop = original_price - current_price
|
# would otherwise carry the infinite price into its payload. Only floats can
|
||||||
passes_percentage = percentage >= min_percentage
|
# be non-finite, so ints and other types keep their existing behaviour.
|
||||||
passes_price_drop = min_price_drop is not None and price_drop >= min_price_drop
|
if any(
|
||||||
if not passes_percentage and not passes_price_drop:
|
isinstance(price, float) and not math.isfinite(price)
|
||||||
|
for price in (current_price, original_price)
|
||||||
|
):
|
||||||
|
LOGGER.warning(
|
||||||
|
"non-finite price, skipping",
|
||||||
|
extra={"product_id": product_id},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
built = Product(
|
percentage = 0
|
||||||
|
# Defaulted, not indexed: documents written before the field existed have no
|
||||||
|
# hasMsrp at all, and stale ones that drop out of a monitor's feed are never
|
||||||
|
# re-upserted, so they would KeyError here forever. True keeps them on the
|
||||||
|
# pre-existing threshold path rather than silently routing them as MSRP-less.
|
||||||
|
has_msrp = product.get("hasMsrp", True)
|
||||||
|
if has_msrp:
|
||||||
|
discount = ((original_price - current_price) / original_price) * 100
|
||||||
|
if not math.isfinite(discount):
|
||||||
|
LOGGER.warning(
|
||||||
|
"discount not representable, skipping",
|
||||||
|
extra={"product_id": product_id},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
percentage = int(discount)
|
||||||
|
price_drop = original_price - current_price
|
||||||
|
# A percentage code compounds onto the sale price, so an item can clear the
|
||||||
|
# threshold on the combined discount even when the base discount would not.
|
||||||
|
# Dollar codes do not map to a percent and never affect the threshold.
|
||||||
|
|
||||||
|
effective_percentage = combined_discount(
|
||||||
|
percentage, product.get("promoPercentage")
|
||||||
|
)
|
||||||
|
passes_percentage = effective_percentage >= min_percentage
|
||||||
|
passes_price_drop = min_price_drop is not None and price_drop >= min_price_drop
|
||||||
|
if not bypass_thresholds and not passes_percentage and not passes_price_drop:
|
||||||
|
return
|
||||||
|
|
||||||
|
built_product = Product(
|
||||||
product_id=product_id,
|
product_id=product_id,
|
||||||
title=product["title"],
|
title=product["title"],
|
||||||
url=product["url"],
|
url=product["url"],
|
||||||
@@ -163,6 +248,12 @@ def build_product_details(
|
|||||||
overstock_text=product["overstockText"],
|
overstock_text=product["overstockText"],
|
||||||
percentage=percentage,
|
percentage=percentage,
|
||||||
affiliate_url=product.get("affiliateUrl"),
|
affiliate_url=product.get("affiliateUrl"),
|
||||||
|
promo_code=product.get("promoCode"),
|
||||||
|
promo_percentage=product.get("promoPercentage"),
|
||||||
|
promo_dollar_off=product.get("promoDollarOff"),
|
||||||
|
promo_min_spend=product.get("promoMinSpend"),
|
||||||
|
new_listing=new_listing,
|
||||||
|
asin=product.get("asin"),
|
||||||
),
|
),
|
||||||
timing=ProductTiming(
|
timing=ProductTiming(
|
||||||
discovered_date=product["discoveredDate"],
|
discovered_date=product["discoveredDate"],
|
||||||
@@ -174,6 +265,8 @@ def build_product_details(
|
|||||||
original_price=original_price,
|
original_price=original_price,
|
||||||
price_before_drop=product["priceBeforeDrop"],
|
price_before_drop=product["priceBeforeDrop"],
|
||||||
edu_price=product["eduPrice"],
|
edu_price=product["eduPrice"],
|
||||||
|
past_prices=product.get("pastPrices"),
|
||||||
|
has_msrp=has_msrp,
|
||||||
),
|
),
|
||||||
availability=AvailabilityInfo(
|
availability=AvailabilityInfo(
|
||||||
available=product["available"],
|
available=product["available"],
|
||||||
@@ -183,107 +276,187 @@ def build_product_details(
|
|||||||
member_deal=product["memberDeal"],
|
member_deal=product["memberDeal"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return built
|
return built_product
|
||||||
|
|
||||||
|
|
||||||
async def execute_monitor_logic(
|
async def run_monitor_cycle(
|
||||||
category_identifiers: list[str] | dict[str, str],
|
category_identifiers: list[str] | dict[str, str],
|
||||||
task_func: Callable[..., Awaitable[None]],
|
task_func: Callable[..., Awaitable[None]],
|
||||||
|
session: aiohttp.ClientSession | AsyncSession,
|
||||||
|
collection: AsyncIOMotorCollection,
|
||||||
*,
|
*,
|
||||||
proxies: AioProxies,
|
|
||||||
headers: dict | None = None,
|
|
||||||
cookies: dict | None = None,
|
|
||||||
pre_task_func: Callable[..., Awaitable[None]] | None = None,
|
pre_task_func: Callable[..., Awaitable[None]] | None = None,
|
||||||
concurrent_pre_task_func: Callable[..., Awaitable[None]] | None = None,
|
concurrent_pre_task_func: Callable[..., Awaitable[None]] | None = None,
|
||||||
use_tls: bool = False,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
) -> CycleResult:
|
||||||
"""Run the monitor's category tasks in a loop with a fresh session per cycle.
|
"""Run one monitor cycle over a caller-supplied session and report the outcome.
|
||||||
|
|
||||||
Each cycle opens a TLS or base session, awaits pre_task_func if given,
|
Awaits pre_task_func if given, dispatches task_func per category
|
||||||
dispatches task_func per category concurrently, logs expected failures,
|
concurrently, logs expected failures, and raises unexpected ones. The
|
||||||
raises unexpected ones, then sleeps briefly before the next cycle.
|
caller owns the loop, the session lifetime, and the inter-cycle delay: wrap
|
||||||
|
this in a `while True` that opens a fresh session per cycle and sleeps
|
||||||
|
between cycles — or use run_bounded_cycle, which owns the session and a
|
||||||
|
hard cycle budget and leaves only the loop and the sleep to the caller.
|
||||||
|
The returned CycleResult is pure counts for the caller to log or act on;
|
||||||
|
this function applies no policy to it.
|
||||||
|
|
||||||
|
session and collection are the two per-cycle resources, passed to every
|
||||||
|
callback (pre_task_func, concurrent_pre_task_func, and each task_func) just
|
||||||
|
like each other. A callback that does not need one still receives it, so
|
||||||
|
every callback must accept **kwargs to absorb the ones it ignores.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
category_identifiers: List of category ids, or a mapping of category
|
category_identifiers: List of category ids, or a mapping of category
|
||||||
id to query id; mapping entries pass query_category_id to
|
id to query id; mapping entries pass query_category_id to
|
||||||
task_func.
|
task_func.
|
||||||
task_func: Awaitable invoked per category with the identifier,
|
task_func: Awaitable invoked per category with the identifier, session,
|
||||||
session, and **kwargs.
|
collection, and **kwargs.
|
||||||
proxies: Proxies applied to the session.
|
session: Open HTTP session for this cycle, built and closed by the
|
||||||
headers: Headers applied to the session.
|
caller. Proxies are the caller's responsibility, bound to the
|
||||||
cookies: Cookies applied to the base session; ignored for TLS.
|
session (see get_default_session).
|
||||||
pre_task_func: Optional awaitable run once per cycle, awaited before any
|
collection: Mongo collection the tasks persist products to; forwarded to
|
||||||
category tasks are dispatched and receiving session and **kwargs.
|
every callback so any that writes has it.
|
||||||
Use it for setup the category tasks depend on (session headers,
|
pre_task_func: Optional awaitable run once at the start of the cycle,
|
||||||
cookies, fetched state); if it raises an expected task error the
|
awaited before any category tasks are dispatched and receiving
|
||||||
cycle is skipped and retried after the usual delay.
|
session, collection, and **kwargs. Use it for setup the category
|
||||||
|
tasks depend on (session headers, cookies, fetched state); if it
|
||||||
|
raises an expected task error the cycle is skipped and a skipped
|
||||||
|
CycleResult returned.
|
||||||
concurrent_pre_task_func: Optional awaitable run once per cycle, gathered
|
concurrent_pre_task_func: Optional awaitable run once per cycle, gathered
|
||||||
alongside the category tasks and receiving session and **kwargs.
|
alongside the category tasks and receiving session, collection, and
|
||||||
Category tasks must not depend on its side effects (session
|
**kwargs. Category tasks must not depend on its side effects
|
||||||
headers, cookies, fetched state) since no ordering is guaranteed.
|
(session headers, cookies, fetched state) since no ordering is
|
||||||
use_tls: Use the TLS fingerprint session instead of the base session.
|
guaranteed.
|
||||||
**kwargs: Forwarded verbatim to pre_task_func, concurrent_pre_task_func,
|
**kwargs: Forwarded verbatim to pre_task_func, concurrent_pre_task_func,
|
||||||
and every task_func call.
|
and every task_func call.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A CycleResult tallying task successes and expected-error failures, or a
|
||||||
|
skipped result when pre_task_func failed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: Any unexpected (non-EXPECTED_TASK_ERRORS) error raised by a
|
||||||
|
task or pre_task_func propagates, failing loud so the caller's loop
|
||||||
|
crashes rather than silently retrying a broken monitor.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
while True:
|
if pre_task_func:
|
||||||
session_ctx = (
|
try:
|
||||||
get_tls_session(
|
await pre_task_func(session=session, collection=collection, **kwargs)
|
||||||
headers=headers,
|
except EXPECTED_TASK_ERRORS as error:
|
||||||
proxies=proxies,
|
LOGGER.warning("pre task failed, skipping cycle", exc_info=error)
|
||||||
|
return CycleResult(skipped=True)
|
||||||
|
|
||||||
|
LOGGER.info("starting tasks")
|
||||||
|
if isinstance(category_identifiers, dict):
|
||||||
|
tasks = [
|
||||||
|
task_func(
|
||||||
|
category_id, session, collection, query_category_id=query, **kwargs
|
||||||
)
|
)
|
||||||
if use_tls
|
for category_id, query in category_identifiers.items()
|
||||||
else get_base_session(headers=headers, cookies=cookies, proxies=proxies)
|
]
|
||||||
|
category_keys = list(category_identifiers.keys())
|
||||||
|
else:
|
||||||
|
tasks = [
|
||||||
|
task_func(category_identifier, session, collection, **kwargs)
|
||||||
|
for category_identifier in category_identifiers
|
||||||
|
]
|
||||||
|
category_keys = list(category_identifiers)
|
||||||
|
|
||||||
|
if concurrent_pre_task_func:
|
||||||
|
tasks.append(
|
||||||
|
concurrent_pre_task_func(session=session, collection=collection, **kwargs)
|
||||||
)
|
)
|
||||||
async with session_ctx as session:
|
category_keys.append("pre_task")
|
||||||
skip_cycle = False
|
|
||||||
if pre_task_func:
|
|
||||||
try:
|
|
||||||
await pre_task_func(session=session, **kwargs)
|
|
||||||
except EXPECTED_TASK_ERRORS as error:
|
|
||||||
LOGGER.warning("pre task failed, skipping cycle", exc_info=error)
|
|
||||||
skip_cycle = True
|
|
||||||
|
|
||||||
if not skip_cycle:
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
LOGGER.info("starting tasks")
|
successes = 0
|
||||||
if isinstance(category_identifiers, dict):
|
failures = 0
|
||||||
tasks = [
|
for category_identifier, result in zip(category_keys, results):
|
||||||
task_func(
|
if isinstance(result, EXPECTED_TASK_ERRORS):
|
||||||
category_id, session, query_category_id=query, **kwargs
|
LOGGER.warning(
|
||||||
)
|
"failed to get products",
|
||||||
for category_id, query in category_identifiers.items()
|
extra={"category": category_identifier},
|
||||||
]
|
exc_info=result,
|
||||||
category_keys = list(category_identifiers.keys())
|
)
|
||||||
else:
|
failures += 1
|
||||||
tasks = [
|
elif isinstance(result, Exception):
|
||||||
task_func(category_identifier, session, **kwargs)
|
LOGGER.error(
|
||||||
for category_identifier in category_identifiers
|
"unexpected error",
|
||||||
]
|
extra={"category": category_identifier},
|
||||||
category_keys = list(category_identifiers)
|
exc_info=result,
|
||||||
|
)
|
||||||
|
raise result
|
||||||
|
else:
|
||||||
|
successes += 1
|
||||||
|
|
||||||
if concurrent_pre_task_func:
|
return CycleResult(successes=successes, failures=failures)
|
||||||
tasks.append(concurrent_pre_task_func(session=session, **kwargs))
|
|
||||||
category_keys.append("pre_task")
|
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
for category_identifier, result in zip(category_keys, results):
|
|
||||||
if isinstance(result, EXPECTED_TASK_ERRORS):
|
|
||||||
LOGGER.warning(
|
|
||||||
"failed to get products",
|
|
||||||
extra={"category": category_identifier},
|
|
||||||
exc_info=result,
|
|
||||||
)
|
|
||||||
elif isinstance(result, Exception):
|
|
||||||
LOGGER.error(
|
|
||||||
"unexpected error",
|
|
||||||
extra={"category": category_identifier},
|
|
||||||
exc_info=result,
|
|
||||||
)
|
|
||||||
raise result
|
|
||||||
|
|
||||||
LOGGER.info("sleeping before next tasks", extra={"delay": 0.30})
|
async def run_bounded_cycle(
|
||||||
await asyncio.sleep(0.30)
|
category_identifiers: list[str] | dict[str, str],
|
||||||
|
task_func: Callable[..., Awaitable[None]],
|
||||||
|
proxies: AioProxies,
|
||||||
|
collection: AsyncIOMotorCollection,
|
||||||
|
*,
|
||||||
|
headers: dict | None = None,
|
||||||
|
session_timeout: int = TIMEOUT_SECONDS,
|
||||||
|
max_clients: int = MAX_CURL_HANDLES,
|
||||||
|
cycle_timeout: int = CYCLE_TIMEOUT_SECONDS,
|
||||||
|
impersonate: BrowserTypeLiteral | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> CycleResult | None:
|
||||||
|
"""Run one monitor cycle on a fresh session under a hard time budget.
|
||||||
|
|
||||||
|
The session is opened and closed inside the call, so curl multi-handle
|
||||||
|
state cannot outlive one cycle: a wedged transfer dies with its session
|
||||||
|
instead of parking the process. The budget turns an engine-level lost
|
||||||
|
wakeup into a caught TimeoutError and an abandoned cycle, never a
|
||||||
|
permanent wedge; the session's own close is bounded by
|
||||||
|
get_default_session, so the exit cannot park past the budget either.
|
||||||
|
|
||||||
|
The caller still owns the loop and the inter-cycle delay:
|
||||||
|
|
||||||
|
while True:
|
||||||
|
await run_bounded_cycle(ids, task_func, proxies, collection)
|
||||||
|
await asyncio.sleep(CYCLE_DELAY)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category_identifiers: Categories, passed through to run_monitor_cycle.
|
||||||
|
task_func: Awaitable invoked per category.
|
||||||
|
proxies: Pool bound to each cycle's fresh session.
|
||||||
|
collection: Mongo collection forwarded to every callback.
|
||||||
|
headers: Monitor-wide static headers merged into every request.
|
||||||
|
session_timeout: Per-request timeout on the cycle's session.
|
||||||
|
max_clients: Curl handle pool size. Keep at or above the monitor's
|
||||||
|
whole fan-out; see get_default_session for why.
|
||||||
|
cycle_timeout: Hard budget in seconds for the whole cycle.
|
||||||
|
**kwargs: Forwarded to run_monitor_cycle (pre_task_func,
|
||||||
|
concurrent_pre_task_func, and per-task kwargs).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The cycle's CycleResult, or None when the budget expired and the
|
||||||
|
cycle was abandoned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with get_default_session(
|
||||||
|
proxies, headers, timeout=session_timeout, max_clients=max_clients
|
||||||
|
) as session:
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(cycle_timeout):
|
||||||
|
return await run_monitor_cycle(
|
||||||
|
category_identifiers,
|
||||||
|
task_func,
|
||||||
|
session=session,
|
||||||
|
collection=collection,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
LOGGER.error(
|
||||||
|
"cycle exceeded budget, abandoning cycle",
|
||||||
|
extra={"timeout": cycle_timeout},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def execute_pagination(
|
async def execute_pagination(
|
||||||
@@ -293,7 +466,7 @@ async def execute_pagination(
|
|||||||
func: Callable[..., Awaitable[None]],
|
func: Callable[..., Awaitable[None]],
|
||||||
session: aiohttp.ClientSession | AsyncSession,
|
session: aiohttp.ClientSession | AsyncSession,
|
||||||
*,
|
*,
|
||||||
semaphore: asyncio.Semaphore,
|
semaphore: asyncio.Semaphore | None = None,
|
||||||
start_offset: int = 0,
|
start_offset: int = 0,
|
||||||
pagination_mode: str = "offset",
|
pagination_mode: str = "offset",
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -311,11 +484,12 @@ async def execute_pagination(
|
|||||||
func: Awaitable task invoked per page; receives session plus either
|
func: Awaitable task invoked per page; receives session plus either
|
||||||
offset or page_number depending on pagination_mode.
|
offset or page_number depending on pagination_mode.
|
||||||
session: HTTP session forwarded to each func call.
|
session: HTTP session forwarded to each func call.
|
||||||
max_concurrent_tasks: Semaphore limit for concurrent page fetches, used
|
semaphore: Optional bound on concurrent page fetches. Omit it to let a
|
||||||
only when semaphore is not supplied. Each execute_pagination call
|
monitor with few pages fan out unbounded; pass one to cap monitors
|
||||||
builds its own semaphore, so this bounds one category in isolation;
|
with many pages. Pass a single shared semaphore across all categories
|
||||||
with many categories running concurrently the effective limit is
|
so the limit caps total fan-out; a per-call semaphore would instead
|
||||||
this value times the category count.
|
bound each category in isolation, making the effective limit this
|
||||||
|
value times the category count.
|
||||||
start_offset: Additive base applied to every offset, for APIs whose
|
start_offset: Additive base applied to every offset, for APIs whose
|
||||||
offset space does not begin at zero. Page N requests offset
|
offset space does not begin at zero. Page N requests offset
|
||||||
start_offset + (N - 1) * products_per_page.
|
start_offset + (N - 1) * products_per_page.
|
||||||
@@ -331,16 +505,12 @@ async def execute_pagination(
|
|||||||
"executing pagination",
|
"executing pagination",
|
||||||
extra={"category": category_identifier, "total": total_pages},
|
extra={"category": category_identifier, "total": total_pages},
|
||||||
)
|
)
|
||||||
stop_event = asyncio.Event()
|
|
||||||
pagination_label = "offset" if pagination_mode == "offset" else "page"
|
pagination_label = "offset" if pagination_mode == "offset" else "page"
|
||||||
page_range = range(2, total_pages + 1)
|
page_range = range(2, total_pages + 1)
|
||||||
|
|
||||||
async def fetch_page(page_index: int):
|
async def fetch_page(page_index: int):
|
||||||
"""Fetch a single page or offset under the shared semaphore."""
|
"""Fetch a single page or offset, optionally under the shared semaphore."""
|
||||||
async with semaphore:
|
async with semaphore or nullcontext():
|
||||||
if stop_event.is_set():
|
|
||||||
return
|
|
||||||
|
|
||||||
if pagination_mode == "offset":
|
if pagination_mode == "offset":
|
||||||
current_offset = start_offset + (page_index - 1) * products_per_page
|
current_offset = start_offset + (page_index - 1) * products_per_page
|
||||||
await func(
|
await func(
|
||||||
@@ -358,14 +528,9 @@ async def execute_pagination(
|
|||||||
failed_pages = await fetch_all_pages(
|
failed_pages = await fetch_all_pages(
|
||||||
page_range, fetch_page, category_identifier, pagination_label
|
page_range, fetch_page, category_identifier, pagination_label
|
||||||
)
|
)
|
||||||
if failed_pages and not stop_event.is_set():
|
if failed_pages:
|
||||||
failed_pages = await retry_failed_pages(
|
|
||||||
failed_pages, fetch_page, category_identifier, pagination_label
|
|
||||||
)
|
|
||||||
|
|
||||||
if failed_pages and not stop_event.is_set():
|
|
||||||
LOGGER.error(
|
LOGGER.error(
|
||||||
"failed to get products after retries",
|
"failed to get products from failed pages",
|
||||||
extra={
|
extra={
|
||||||
"category": category_identifier,
|
"category": category_identifier,
|
||||||
"label": pagination_label,
|
"label": pagination_label,
|
||||||
@@ -373,9 +538,6 @@ async def execute_pagination(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if stop_event.is_set():
|
|
||||||
LOGGER.info("pagination stopped early", extra={"category": category_identifier})
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_all_pages(
|
async def fetch_all_pages(
|
||||||
page_range: Iterable[int],
|
page_range: Iterable[int],
|
||||||
@@ -395,11 +557,11 @@ async def fetch_all_pages(
|
|||||||
Page numbers whose fetch raised an expected error.
|
Page numbers whose fetch raised an expected error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tasks = [fetch_func(page) for page in page_range]
|
# list once: a one-shot iterable would be exhausted before the result pairing
|
||||||
|
pages = list(page_range)
|
||||||
|
tasks = [fetch_func(page) for page in pages]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
return handle_page_results(
|
return handle_page_results(pages, results, category_identifier, pagination_label)
|
||||||
list(page_range), results, category_identifier, pagination_label
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_page_results(
|
def handle_page_results(
|
||||||
@@ -407,20 +569,17 @@ def handle_page_results(
|
|||||||
results: list,
|
results: list,
|
||||||
category_identifier: str,
|
category_identifier: str,
|
||||||
pagination_label: str,
|
pagination_label: str,
|
||||||
retry_attempt: int | None = None,
|
|
||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""Log page results and collect failures, raising on unexpected errors.
|
"""Log page results and collect failures, raising on unexpected errors.
|
||||||
|
|
||||||
Expected errors are logged as warnings and their pages collected for
|
Expected errors are logged as warnings and their pages collected and
|
||||||
retry. Unexpected exceptions are logged and re-raised. Successful pages
|
returned. Unexpected exceptions are logged and re-raised.
|
||||||
are logged only when part of a retry attempt.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
page_numbers: Page numbers matching results by position.
|
page_numbers: Page numbers matching results by position.
|
||||||
results: Gathered results or exceptions per page.
|
results: Gathered results or exceptions per page.
|
||||||
category_identifier: Label used for logging.
|
category_identifier: Label used for logging.
|
||||||
pagination_label: Either offset or page, used for logging.
|
pagination_label: Either offset or page, used for logging.
|
||||||
retry_attempt: Retry attempt number when handling retry results.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Page numbers whose fetch raised an expected error.
|
Page numbers whose fetch raised an expected error.
|
||||||
@@ -435,7 +594,6 @@ def handle_page_results(
|
|||||||
"category": category_identifier,
|
"category": category_identifier,
|
||||||
"label": pagination_label,
|
"label": pagination_label,
|
||||||
"page": page_number,
|
"page": page_number,
|
||||||
"retry": retry_attempt,
|
|
||||||
},
|
},
|
||||||
exc_info=result,
|
exc_info=result,
|
||||||
)
|
)
|
||||||
@@ -447,66 +605,58 @@ def handle_page_results(
|
|||||||
"category": category_identifier,
|
"category": category_identifier,
|
||||||
"label": pagination_label,
|
"label": pagination_label,
|
||||||
"page": page_number,
|
"page": page_number,
|
||||||
"retry": retry_attempt,
|
|
||||||
},
|
},
|
||||||
exc_info=result,
|
exc_info=result,
|
||||||
)
|
)
|
||||||
raise result
|
raise result
|
||||||
elif retry_attempt:
|
|
||||||
LOGGER.info(
|
|
||||||
"retry succeeded",
|
|
||||||
extra={
|
|
||||||
"category": category_identifier,
|
|
||||||
"label": pagination_label,
|
|
||||||
"page": page_number,
|
|
||||||
"retry": retry_attempt,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return failed_pages
|
return failed_pages
|
||||||
|
|
||||||
|
|
||||||
async def retry_failed_pages(
|
def first_error(group_exc: BaseExceptionGroup) -> BaseException:
|
||||||
failed_pages: list[int],
|
"""Return a leaf exception of a possibly nested group, preferring an
|
||||||
fetch_func: Callable[[int], Awaitable[None]],
|
unexpected one so a real bug is never masked by a routine network error."""
|
||||||
category_identifier: str,
|
_, unexpected = group_exc.split(EXPECTED_TASK_ERRORS)
|
||||||
pagination_label: str,
|
if unexpected is not None:
|
||||||
max_retries: int = 3,
|
group_exc = unexpected
|
||||||
) -> list[int]:
|
|
||||||
"""Retry failed pages with exponential backoff until exhausted or clear.
|
|
||||||
|
|
||||||
Sleeps 2**attempt seconds before each round and re-dispatches only the
|
error = group_exc.exceptions[0]
|
||||||
pages that failed the previous round.
|
while isinstance(error, BaseExceptionGroup):
|
||||||
|
error = error.exceptions[0]
|
||||||
|
|
||||||
Args:
|
return error
|
||||||
failed_pages: Page numbers to retry.
|
|
||||||
fetch_func: Awaitable invoked per page number.
|
|
||||||
category_identifier: Label used for logging.
|
|
||||||
pagination_label: Either offset or page, used for logging.
|
|
||||||
max_retries: Maximum retry rounds.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Page numbers still failing after all retries.
|
@asynccontextmanager
|
||||||
|
async def fan_out_group():
|
||||||
|
"""A TaskGroup for page-one fan-out whose failure surfaces unwrapped.
|
||||||
|
|
||||||
|
Use it where one page's processing must be bound to the pagination that
|
||||||
|
page spawns: create the processing task on the yielded group, then await
|
||||||
|
execute_pagination in the body. The group cancels whichever sibling is
|
||||||
|
still running when the other fails, so a dead category stops doing work
|
||||||
|
nobody will use, and a cancelled cycle takes both down together.
|
||||||
|
|
||||||
|
The unwrapping is the reason this is shared rather than inlined. A bare
|
||||||
|
TaskGroup always raises an ExceptionGroup, and run_monitor_cycle classifies
|
||||||
|
task results with isinstance against EXPECTED_TASK_ERRORS -- an
|
||||||
|
ExceptionGroup matches neither branch as intended, so a routine page
|
||||||
|
failure would be reclassified as an unexpected one and kill the monitor.
|
||||||
|
first_error unwraps to a leaf, preferring an unexpected exception so a real
|
||||||
|
bug is never masked by a network error.
|
||||||
|
|
||||||
|
Note the caller's own body is inside the group, so a plain exception raised
|
||||||
|
there (not from a child) propagates unchanged rather than being wrapped,
|
||||||
|
and an early `return` still awaits the child tasks.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
async with fan_out_group() as group:
|
||||||
|
group.create_task(process_page(data, ...))
|
||||||
|
await execute_pagination(...)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
retry_attempt = 1
|
try:
|
||||||
while failed_pages and retry_attempt <= max_retries:
|
async with asyncio.TaskGroup() as group:
|
||||||
await asyncio.sleep(2**retry_attempt)
|
yield group
|
||||||
LOGGER.info(
|
except BaseExceptionGroup as group_exc:
|
||||||
"retrying failed pages",
|
raise first_error(group_exc) from None
|
||||||
extra={
|
|
||||||
"category": category_identifier,
|
|
||||||
"label": pagination_label,
|
|
||||||
"count": len(failed_pages),
|
|
||||||
"attempt": retry_attempt,
|
|
||||||
"max_retries": max_retries,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
tasks = [fetch_func(page_number) for page_number in failed_pages]
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
failed_pages = handle_page_results(
|
|
||||||
failed_pages, results, category_identifier, pagination_label, retry_attempt
|
|
||||||
)
|
|
||||||
retry_attempt += 1
|
|
||||||
|
|
||||||
return failed_pages
|
|
||||||
+162
-168
@@ -1,225 +1,219 @@
|
|||||||
"""Shared HTTP session helpers for all monitors.
|
"""Shared HTTP session helpers for all monitors.
|
||||||
|
|
||||||
Known gap: UA/TLS fingerprint does not always match headers/header order,
|
A single aioweb_tls TLSSession (curl_cffi backend, random client fingerprint per
|
||||||
but targeted antibots do not currently seem to care.
|
session) replaces the old aiohttp/curl_cffi split. send_request wraps aioweb's
|
||||||
|
request_with_retries, adding per-request proxy rotation and old-style JSON/text
|
||||||
|
return values so monitor call sites change as little as possible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import requests
|
|
||||||
from aioproxies import AioProxies
|
from aioproxies import AioProxies
|
||||||
from curl_cffi.requests import AsyncSession
|
from aioweb import Response
|
||||||
from curl_cffi.requests.errors import RequestsError
|
from aioweb_tls import CurlCffi, TLSSession
|
||||||
from curl_cffi.requests.exceptions import Timeout as CurlTimeout
|
from curl_cffi.requests.impersonate import BrowserTypeLiteral
|
||||||
from fake_useragent import FakeUserAgent
|
|
||||||
from tenacity import (
|
|
||||||
after_log,
|
|
||||||
before_log,
|
|
||||||
retry,
|
|
||||||
retry_if_exception_type,
|
|
||||||
stop_after_attempt,
|
|
||||||
wait_fixed,
|
|
||||||
)
|
|
||||||
|
|
||||||
from monitor_utils.constants import (
|
from monitor_utils.constants import (
|
||||||
CLIENT_IDENTIFIERS,
|
CLIENT_IDENTIFIERS,
|
||||||
CLIENT_TIMEOUT,
|
MAX_CURL_HANDLES,
|
||||||
RETRY_LIMIT,
|
RETRY_LIMIT,
|
||||||
RETRY_WAIT,
|
SESSION_CLOSE_TIMEOUT_SECONDS,
|
||||||
TIMEOUT_SECONDS,
|
TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
UA = FakeUserAgent()
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
class BoundedCloseSession(TLSSession):
|
||||||
async def get_base_session(
|
"""A TLSSession whose close cannot outlive SESSION_CLOSE_TIMEOUT_SECONDS.
|
||||||
headers: dict | None = None,
|
|
||||||
cookies: dict | None = None,
|
|
||||||
connector: aiohttp.TCPConnector | None = None,
|
|
||||||
timeout: aiohttp.ClientTimeout = CLIENT_TIMEOUT,
|
|
||||||
proxies: AioProxies | None = None,
|
|
||||||
) -> AsyncGenerator[aiohttp.ClientSession, None]:
|
|
||||||
"""Yield an aiohttp session with a random User-Agent and attached proxy pool.
|
|
||||||
|
|
||||||
The proxy pool is stored on the session as proxy_pool for send_request to
|
curl_cffi's close awaits the multi handle with no timeout of its own, so a
|
||||||
rotate per request. The session is always closed on exit.
|
wedged transfer parks whatever is closing the session. A caller-side budget
|
||||||
|
cannot cover this reliably: asyncio.timeout cancels its task once, so a
|
||||||
|
cycle budget that has already fired leaves the close running as a fresh,
|
||||||
|
uncancelled await, and the process parks in the `async with` exit that was
|
||||||
|
meant to reclaim it.
|
||||||
|
|
||||||
Args:
|
Bounding it here keeps that guarantee attached to the session itself, so
|
||||||
headers: Headers applied to the session; User-Agent is defaulted if
|
every `async with get_default_session(...)` gets it without the call site
|
||||||
absent.
|
having to arrange anything. Abandoning a session leaks its handle until the
|
||||||
cookies: Cookies applied to the session.
|
process restarts, which is strictly better than a wedged monitor.
|
||||||
connector: Optional TCP connector.
|
|
||||||
timeout: Client timeout applied to the session.
|
|
||||||
proxies: Proxy pool attached to the session.
|
|
||||||
|
|
||||||
Yields:
|
Known inconsistency, deliberate: an abandoned session reports itself closed
|
||||||
The configured aiohttp session.
|
while its curl handle is still open. TLSSession.close sets _closed before
|
||||||
|
awaiting the backend, so _is_closed() returns True on this path, and since
|
||||||
|
aioweb's __del__ only warns when _is_closed() is False, its leak warning is
|
||||||
|
suppressed for exactly the sessions that leaked. The "session close exceeded
|
||||||
|
budget" error logged below is therefore the only signal that it happened —
|
||||||
|
alert on it rather than on the library's warning.
|
||||||
|
|
||||||
|
Correcting the flag would mean overriding _is_closed too, and aioweb_tls is
|
||||||
|
a third-party library: this class deliberately overrides one documented
|
||||||
|
lifecycle method and nothing private, so an upstream release has one narrow
|
||||||
|
way to break it. A stale flag on a session discarded the same instant is the
|
||||||
|
cheaper side of that trade.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
headers = headers if headers else {}
|
async def close(self) -> None:
|
||||||
headers.setdefault("User-Agent", UA.random)
|
try:
|
||||||
session = aiohttp.ClientSession(
|
async with asyncio.timeout(SESSION_CLOSE_TIMEOUT_SECONDS):
|
||||||
headers=headers, cookies=cookies, connector=connector, timeout=timeout
|
await super().close()
|
||||||
)
|
except TimeoutError:
|
||||||
session.proxy_pool = proxies
|
LOGGER.error(
|
||||||
try:
|
"session close exceeded budget, abandoning session",
|
||||||
yield session
|
extra={"timeout": SESSION_CLOSE_TIMEOUT_SECONDS},
|
||||||
finally:
|
)
|
||||||
await session.close()
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
def get_default_session(
|
||||||
async def get_tls_session(
|
proxies: AioProxies,
|
||||||
headers: dict | None = None,
|
headers: dict | None = None,
|
||||||
timeout: int = TIMEOUT_SECONDS,
|
timeout: int = TIMEOUT_SECONDS,
|
||||||
proxies: AioProxies | None = None,
|
max_clients: int = MAX_CURL_HANDLES,
|
||||||
) -> AsyncGenerator[AsyncSession, None]:
|
impersonate: BrowserTypeLiteral | None = None,
|
||||||
"""Yield a curl_cffi session impersonating a random client fingerprint.
|
) -> TLSSession:
|
||||||
|
"""Return a TLSSession impersonating a random client fingerprint.
|
||||||
|
|
||||||
The proxy pool is stored on the session as proxy_pool for send_request to
|
Fingerprint rotation lives here so every monitor rotates the same way; the
|
||||||
rotate per request.
|
caller owns the session lifetime via ``async with``.
|
||||||
|
|
||||||
|
Cookies are not set here: aioweb has no constructor cookie path, and
|
||||||
|
monitors usually obtain them at runtime, so call session.set_cookie(name,
|
||||||
|
value, domain=...) where they are fetched (e.g. a pre_task_func) instead.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
headers: Headers applied to the session; a Chrome User-Agent is
|
proxies: Pool attached to the session as proxy_pool, which send_request
|
||||||
defaulted if absent.
|
rotates through per request. Required so a session can never send
|
||||||
timeout: Request timeout in seconds.
|
un-proxied.
|
||||||
proxies: Proxy pool attached to the session.
|
headers: Default headers merged into every request by aioweb. Use for
|
||||||
|
monitor-wide static headers; per-request headers still go through
|
||||||
|
send_request. The curl_cffi impersonation already supplies a
|
||||||
|
matching User-Agent, so one is not defaulted here.
|
||||||
|
timeout: Total request timeout in seconds, defaulting to TIMEOUT_SECONDS.
|
||||||
|
aioweb's own default is only 10s, so this restores the wider budget
|
||||||
|
the old sessions used and slow retailers depend on.
|
||||||
|
max_clients: Size of the curl handle pool, defaulting to
|
||||||
|
MAX_CURL_HANDLES. This is a hard concurrency ceiling: a request
|
||||||
|
finding the pool empty waits in pop_curl's untimed pool.get(), and
|
||||||
|
`timeout` does not cover that wait, only the transfer after
|
||||||
|
checkout. Keep it at or above the monitor's real fan-out (the sum
|
||||||
|
of its pagination semaphores plus any unsemaphored per-cycle
|
||||||
|
requests) so the wait is never reachable.
|
||||||
|
impersonate: A curl_cffi profile to pin instead of drawing one from
|
||||||
|
CLIENT_IDENTIFIERS, for a retailer whose bot protection only
|
||||||
|
tolerates some profiles from a given IP (B&H's Cloudflare refuses
|
||||||
|
every chrome profile on a flagged IP but still accepts safari).
|
||||||
|
|
||||||
Yields:
|
Returns:
|
||||||
The configured curl_cffi session.
|
A BoundedCloseSession (a TLSSession whose close is bounded) over a
|
||||||
|
curl_cffi backend with a random impersonation, with the proxy pool
|
||||||
|
attached as proxy_pool.
|
||||||
"""
|
"""
|
||||||
|
impersonate = impersonate or random.choice(CLIENT_IDENTIFIERS)
|
||||||
headers = headers if headers else {}
|
session = BoundedCloseSession(
|
||||||
headers.setdefault("User-Agent", UA.chrome)
|
backend=CurlCffi(impersonate=impersonate),
|
||||||
async with AsyncSession(
|
|
||||||
impersonate=random.choice(CLIENT_IDENTIFIERS),
|
|
||||||
timeout=timeout,
|
|
||||||
headers=headers,
|
headers=headers,
|
||||||
) as session:
|
timeout=timeout,
|
||||||
session.proxy_pool = proxies
|
max_clients=max_clients,
|
||||||
yield session
|
)
|
||||||
|
session.proxy_pool = proxies
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
def build_session_details(
|
|
||||||
session: aiohttp.ClientSession | AsyncSession, headers: dict = None
|
|
||||||
):
|
|
||||||
"""Merge headers into an existing session, defaulting a random User-Agent.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Session whose headers are updated in place.
|
|
||||||
headers: Headers to merge; User-Agent is defaulted if absent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
headers = headers or {}
|
|
||||||
headers.setdefault("User-Agent", UA.random)
|
|
||||||
session.headers.update(headers)
|
|
||||||
|
|
||||||
|
|
||||||
@retry(
|
|
||||||
stop=stop_after_attempt(RETRY_LIMIT),
|
|
||||||
wait=wait_fixed(RETRY_WAIT),
|
|
||||||
retry=retry_if_exception_type(
|
|
||||||
(
|
|
||||||
aiohttp.ClientError,
|
|
||||||
asyncio.TimeoutError,
|
|
||||||
RequestsError,
|
|
||||||
requests.Timeout,
|
|
||||||
CurlTimeout,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
before=before_log(LOGGER, logging.DEBUG),
|
|
||||||
after=after_log(LOGGER, logging.DEBUG),
|
|
||||||
reraise=True,
|
|
||||||
)
|
|
||||||
async def send_request(
|
async def send_request(
|
||||||
session: aiohttp.ClientSession | AsyncSession,
|
session: TLSSession,
|
||||||
url: str,
|
url: str,
|
||||||
method: str,
|
method: str,
|
||||||
|
*,
|
||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
json_data: dict | None = None,
|
json_data: dict | None = None,
|
||||||
data: dict | None = None,
|
data: dict | None = None,
|
||||||
headers: dict | None = None,
|
headers: dict | None = None,
|
||||||
reraise_json_error: bool = False,
|
) -> Response:
|
||||||
development_proxy: str | None = None,
|
"""Send a request through a TLSSession with retries and proxy rotation.
|
||||||
) -> dict | str:
|
|
||||||
"""Send a request through either session type with retries and proxy rotation.
|
|
||||||
|
|
||||||
Retries transport errors and timeouts up to RETRY_LIMIT with a fixed wait.
|
Wraps aioweb's request_with_retries, which retries transport errors,
|
||||||
When development_proxy is set it overrides the session's proxy pool and
|
timeouts, and retryable statuses (429/5xx) up to RETRY_LIMIT attempts with
|
||||||
disables TLS verification for local traffic inspection; otherwise the next
|
exponential backoff. The next proxy is drawn from the pool per call and
|
||||||
proxy is drawn from the session's proxy_pool when present. Responses are
|
applied to both schemes. A non-2xx response or a fully failed request raises
|
||||||
parsed as JSON when possible.
|
AiowebError, an expected task error, so a failed fetch surfaces the same way
|
||||||
|
the old HTTPError path did. On success the aioweb Response is returned for
|
||||||
|
the caller to read via response.json(), response.text(), etc.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: aiohttp or curl_cffi session to send through.
|
session: The TLSSession to send through.
|
||||||
url: Request URL.
|
url: Request URL.
|
||||||
method: HTTP method.
|
method: HTTP method.
|
||||||
params: Query parameters.
|
params: Query parameters.
|
||||||
json_data: JSON body.
|
json_data: JSON body. A dict here is sent as JSON with the
|
||||||
data: Form body.
|
application/json content-type set by aioweb.
|
||||||
headers: Per-request headers merged over the session headers,
|
data: Raw request body, passed through unchanged (e.g. a url-encoded
|
||||||
avoiding mutation of shared session state from concurrent tasks.
|
form string). The caller owns the matching content-type via headers.
|
||||||
reraise_json_error: Raise JSONDecodeError instead of returning raw
|
A dict is JSON-encoded by aioweb, so use json_data for JSON bodies.
|
||||||
text when the response is not valid JSON.
|
headers: Per-request headers merged over the session defaults.
|
||||||
development_proxy: Local proxy URL that overrides the pool and
|
|
||||||
disables verification.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed JSON dict, or raw response text when parsing fails and
|
The aioweb Response, guaranteed 2xx (non-2xx raises). Read the body with
|
||||||
reraise_json_error is False.
|
response.json() (dict/list, or None if not valid JSON) or
|
||||||
|
response.text().
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
json.JSONDecodeError: When parsing fails and reraise_json_error is
|
AiowebError: On a non-2xx response or after all retries fail.
|
||||||
True.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
verify = True
|
proxy_url = session.proxy_pool.next().url()
|
||||||
if development_proxy:
|
proxy = {"http": proxy_url, "https": proxy_url}
|
||||||
proxy = development_proxy
|
|
||||||
verify = False
|
|
||||||
else:
|
|
||||||
manager = getattr(session, "proxy_pool", None)
|
|
||||||
proxy = manager.next().url() if manager else None
|
|
||||||
|
|
||||||
if isinstance(session, aiohttp.ClientSession):
|
body = json_data if json_data is not None else data
|
||||||
async with session.request(
|
response = await session.request_with_retries(
|
||||||
method=method,
|
method.upper(),
|
||||||
url=url,
|
url,
|
||||||
params=params,
|
params=params,
|
||||||
json=json_data,
|
data=body,
|
||||||
data=data,
|
headers=headers,
|
||||||
headers=headers,
|
proxies=proxy,
|
||||||
proxy=proxy,
|
attempts=RETRY_LIMIT,
|
||||||
ssl=verify,
|
)
|
||||||
) as response:
|
response.raise_for_status()
|
||||||
response.raise_for_status()
|
return response
|
||||||
text = await response.text()
|
|
||||||
else:
|
|
||||||
response = await session.request(
|
|
||||||
method=method.upper(),
|
|
||||||
url=url,
|
|
||||||
params=params,
|
|
||||||
json=json_data,
|
|
||||||
data=data,
|
|
||||||
headers=headers,
|
|
||||||
proxy=proxy,
|
|
||||||
verify=verify,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
text = response.text
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
if reraise_json_error:
|
|
||||||
raise
|
|
||||||
|
|
||||||
return text
|
def get_response_json(
|
||||||
|
response: Response,
|
||||||
|
*,
|
||||||
|
logger: logging.Logger = LOGGER,
|
||||||
|
expected: type | tuple[type, ...] = dict,
|
||||||
|
**context,
|
||||||
|
) -> dict | list | None:
|
||||||
|
"""Parse a response body as JSON, or log it and return None.
|
||||||
|
|
||||||
|
aioweb's response.json() yields None for a non-JSON body, so a single
|
||||||
|
isinstance check covers both the empty/invalid case and a body of the
|
||||||
|
wrong shape. Callers stop on None the same way they did with the inline
|
||||||
|
check this replaces.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: A 2xx aioweb Response from send_request.
|
||||||
|
logger: Logger the warning is emitted on; pass the monitor's own so
|
||||||
|
the record stays attributed to that monitor.
|
||||||
|
expected: Type(s) the parsed body must be an instance of.
|
||||||
|
**context: Extra fields for the warning record (category, page, ...).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The parsed body when it matches `expected`, else None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if isinstance(data, expected):
|
||||||
|
return data
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"unexpected response",
|
||||||
|
extra={
|
||||||
|
**context,
|
||||||
|
"body_type": type(data).__name__,
|
||||||
|
"body": str(data)[:200],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""unit test: calculate_priority ranks MSRP-less products instead of burying them
|
||||||
|
|
||||||
|
run from the repo root:
|
||||||
|
uv run --with pytest pytest tests/test_alert_priority.py
|
||||||
|
|
||||||
|
pure and offline — passes plain dicts shaped like product documents; touches no
|
||||||
|
Mongo. The claim query sorts priority descending, so a priority of 0 means an
|
||||||
|
alert is only drained once everything else is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from monitor_utils.database import (
|
||||||
|
NEW_LISTING_PRIORITY,
|
||||||
|
NO_MSRP_PRIORITY,
|
||||||
|
calculate_priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def document(current, original, **overrides) -> dict:
|
||||||
|
doc = {"_id": "p1", "currentPrice": current, "originalPrice": original}
|
||||||
|
doc.update(overrides)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_msrp_takes_its_own_rank():
|
||||||
|
assert calculate_priority(document(50.0, 50.0, hasMsrp=False)) == NO_MSRP_PRIORITY
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_msrp_rank_sits_directly_under_the_seventy_band():
|
||||||
|
"""It must outrank every sub-70% product and lose to every 70%+ one."""
|
||||||
|
assert calculate_priority(document(25.0, 100.0)) == 7 # 75% off
|
||||||
|
assert NO_MSRP_PRIORITY < 7
|
||||||
|
assert NO_MSRP_PRIORITY > calculate_priority(document(45.0, 100.0)) # 55% off
|
||||||
|
|
||||||
|
|
||||||
|
def test_msrp_products_are_unaffected():
|
||||||
|
assert calculate_priority(document(25.0, 100.0, hasMsrp=True)) == 7
|
||||||
|
assert calculate_priority(document(50.0, 100.0, hasMsrp=True)) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_documents_without_the_field_keep_the_percentage_path():
|
||||||
|
"""Documents written before hasMsrp existed must not all jump the queue."""
|
||||||
|
assert calculate_priority(document(50.0, 100.0)) == 5
|
||||||
|
assert calculate_priority(document(100.0, 100.0)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_prices_still_outrank_nothing():
|
||||||
|
"""A missing or zero original price is a data defect, not a missing MSRP."""
|
||||||
|
assert calculate_priority(document(50.0, None, hasMsrp=False)) == 0
|
||||||
|
assert calculate_priority(document(50.0, 0, hasMsrp=False)) == 0
|
||||||
|
assert calculate_priority(document(None, 100.0, hasMsrp=False)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_finite_prices_resolve_to_zero_instead_of_raising():
|
||||||
|
"""float("Infinity") from a retailer field once crashed the whole monitor."""
|
||||||
|
inf, nan = float("inf"), float("nan")
|
||||||
|
assert calculate_priority(document(50.0, inf)) == 0
|
||||||
|
assert calculate_priority(document(inf, 100.0)) == 0
|
||||||
|
assert calculate_priority(document(-inf, 100.0)) == 0
|
||||||
|
assert calculate_priority(document(nan, 100.0)) == 0
|
||||||
|
assert calculate_priority(document(50.0, nan)) == 0
|
||||||
|
assert calculate_priority(document(50.0, inf, newListing=True)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_discount_too_large_to_represent_resolves_to_zero():
|
||||||
|
"""Finite prices can still overflow the ratio (a subnormal original)."""
|
||||||
|
assert calculate_priority(document(-1e308, 5e-324)) == 0
|
||||||
|
assert calculate_priority(document(1e308, 5e-324)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_listing_is_floored_not_buried():
|
||||||
|
"""A low-discount new listing sends regardless, so it must not sort last."""
|
||||||
|
assert (
|
||||||
|
calculate_priority(document(95.0, 100.0, newListing=True))
|
||||||
|
== NEW_LISTING_PRIORITY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_listing_keeps_a_higher_percentage_rank():
|
||||||
|
"""The floor is a floor: a 75%-off new listing still ranks as 75% off."""
|
||||||
|
assert calculate_priority(document(25.0, 100.0, newListing=True)) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeat_alerts_keep_the_percentage_path():
|
||||||
|
assert calculate_priority(document(95.0, 100.0, newListing=False)) == 0
|
||||||
|
assert calculate_priority(document(95.0, 100.0)) == 0
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""unit test: build_product_details thresholding, promo read, and combined_discount
|
||||||
|
|
||||||
|
run from the repo root:
|
||||||
|
uv run pytest tests/test_build_product_details.py
|
||||||
|
|
||||||
|
pure and offline — no Mongo. Builds a Product from a raw doc dict and checks the
|
||||||
|
discount threshold (with percentage codes compounding onto the sale price), the
|
||||||
|
promo fields read through via .get, and the combined_discount helper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from monitor_utils.runner import build_product_details, combined_discount
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(**overrides) -> dict:
|
||||||
|
"""A well-formed product doc; override prices/promo per test."""
|
||||||
|
doc = {
|
||||||
|
"_id": "p1_tools",
|
||||||
|
"title": "Widget",
|
||||||
|
"url": "http://x/p1",
|
||||||
|
"currentPrice": 80.0,
|
||||||
|
"originalPrice": 100.0,
|
||||||
|
"imageUrl": "http://img/1",
|
||||||
|
"customizationIndex": None,
|
||||||
|
"memberNonMemberComparison": None,
|
||||||
|
"overstockText": None,
|
||||||
|
"discoveredDate": datetime(2026, 7, 26, tzinfo=timezone.utc),
|
||||||
|
"startDate": None,
|
||||||
|
"endDate": None,
|
||||||
|
"priceBeforeDrop": None,
|
||||||
|
"eduPrice": None,
|
||||||
|
"pastPrices": [
|
||||||
|
{"price": 80.0, "date": datetime(2026, 7, 26, tzinfo=timezone.utc)}
|
||||||
|
],
|
||||||
|
"available": True,
|
||||||
|
"condition": None,
|
||||||
|
"appExclusive": None,
|
||||||
|
"memberPick": None,
|
||||||
|
"memberDeal": None,
|
||||||
|
}
|
||||||
|
doc.update(overrides)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ combined_discount
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_discount_no_promo_is_base():
|
||||||
|
assert combined_discount(24, None) == 24
|
||||||
|
assert combined_discount(24, 0) == 24
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_discount_compounds_not_adds():
|
||||||
|
# 20% off then extra 30% code -> 44% off list (customer pays $56), not 50%
|
||||||
|
assert combined_discount(20, 30) == 44
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_discount_rounds_to_nearest():
|
||||||
|
# 24% sale + 22% code: 1 - .76*.78 = .4072 -> 41 (rounded, not truncated 40)
|
||||||
|
assert combined_discount(24, 22) == 41
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- build_product_details
|
||||||
|
|
||||||
|
|
||||||
|
def test_promo_fields_read_through():
|
||||||
|
doc = _doc(
|
||||||
|
promoCode="BIRTHDAY26",
|
||||||
|
promoPercentage=22,
|
||||||
|
promoDollarOff=None,
|
||||||
|
promoMinSpend=None,
|
||||||
|
)
|
||||||
|
product = build_product_details(doc, min_percentage=0)
|
||||||
|
assert product.metadata.promo_code == "BIRTHDAY26"
|
||||||
|
assert product.metadata.promo_percentage == 22
|
||||||
|
# base percentage is the raw price delta, unchanged by the code
|
||||||
|
assert product.metadata.percentage == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_asin_reads_through():
|
||||||
|
"""asin round-trips: convert_product_to_mongo writes it, so the consumer
|
||||||
|
must read it back for the SellerAmp lookup"""
|
||||||
|
product = build_product_details(_doc(asin="B0CHX3QBCH"), min_percentage=0)
|
||||||
|
assert product.metadata.asin == "B0CHX3QBCH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_asin_defaults_to_none():
|
||||||
|
assert build_product_details(_doc(), min_percentage=0).metadata.asin is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_promo_keys_default_to_none():
|
||||||
|
"""a doc from a lagging monitor lacks promo keys -> None, not KeyError"""
|
||||||
|
product = build_product_details(_doc(), min_percentage=0)
|
||||||
|
assert product.metadata.promo_code is None
|
||||||
|
assert product.metadata.promo_percentage is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_percentage_code_lifts_item_over_threshold():
|
||||||
|
# 30% base fails min 40, but +22% code -> 45% combined clears it
|
||||||
|
doc = _doc(currentPrice=70.0, originalPrice=100.0, promoPercentage=22)
|
||||||
|
assert build_product_details(doc, min_percentage=40) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_below_threshold_without_code_is_skipped():
|
||||||
|
doc = _doc(currentPrice=70.0, originalPrice=100.0) # 30%, no code
|
||||||
|
assert build_product_details(doc, min_percentage=40) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dollar_code_does_not_affect_threshold():
|
||||||
|
# a dollar code has no promoPercentage, so 30% base stays below min 40
|
||||||
|
doc = _doc(
|
||||||
|
currentPrice=70.0,
|
||||||
|
originalPrice=100.0,
|
||||||
|
promoCode="STORE22",
|
||||||
|
promoDollarOff=22,
|
||||||
|
promoMinSpend=100,
|
||||||
|
)
|
||||||
|
assert build_product_details(doc, min_percentage=40) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- pastPrices
|
||||||
|
|
||||||
|
|
||||||
|
def test_past_prices_read_through():
|
||||||
|
"""Price history passes through unchanged."""
|
||||||
|
|
||||||
|
history = [
|
||||||
|
{"price": 95.0, "date": datetime(2026, 7, 20, tzinfo=timezone.utc)},
|
||||||
|
{"price": 90.0, "date": datetime(2026, 7, 24, tzinfo=timezone.utc)},
|
||||||
|
]
|
||||||
|
|
||||||
|
product = build_product_details(_doc(pastPrices=history), min_percentage=0)
|
||||||
|
|
||||||
|
assert product.pricing.past_prices == history
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- bypass_thresholds
|
||||||
|
|
||||||
|
|
||||||
|
def test_bypass_builds_a_below_threshold_product():
|
||||||
|
"""A new listing sends at any discount when the caller says so."""
|
||||||
|
|
||||||
|
doc = _doc(currentPrice=95.0, originalPrice=100.0) # 5%, way under 40
|
||||||
|
product = build_product_details(doc, min_percentage=40, bypass_thresholds=True)
|
||||||
|
assert product is not None
|
||||||
|
assert product.metadata.percentage == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_bypass_defaults_off():
|
||||||
|
doc = _doc(currentPrice=95.0, originalPrice=100.0)
|
||||||
|
assert build_product_details(doc, min_percentage=40) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bypass_does_not_rescue_broken_prices():
|
||||||
|
"""The missing-price skip is a data guard, not a threshold."""
|
||||||
|
|
||||||
|
doc = _doc(currentPrice=95.0, originalPrice=None)
|
||||||
|
assert build_product_details(doc, min_percentage=40, bypass_thresholds=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- newListing
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_listing_flag_reads_through():
|
||||||
|
product = build_product_details(_doc(newListing=True), min_percentage=0)
|
||||||
|
assert product.metadata.new_listing is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_docs_without_the_flag_build_as_none():
|
||||||
|
"""Documents written before newListing existed must not KeyError."""
|
||||||
|
|
||||||
|
product = build_product_details(_doc(), min_percentage=0)
|
||||||
|
assert product.metadata.new_listing is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ msrp-less guard
|
||||||
|
|
||||||
|
|
||||||
|
def test_msrpless_doc_with_missing_original_price_skips_not_crashes():
|
||||||
|
"""Regression: the guard lived inside the has_msrp branch, so this doc
|
||||||
|
shape raised ValidationError (not an expected error) and killed the
|
||||||
|
consumer."""
|
||||||
|
|
||||||
|
doc = _doc(hasMsrp=False, originalPrice=None)
|
||||||
|
assert build_product_details(doc, min_percentage=40) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_msrpless_doc_with_zero_original_price_skips():
|
||||||
|
doc = _doc(hasMsrp=False, originalPrice=0)
|
||||||
|
assert build_product_details(doc, min_percentage=40) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bypass_does_not_rescue_msrpless_broken_prices():
|
||||||
|
doc = _doc(hasMsrp=False, originalPrice=None)
|
||||||
|
assert build_product_details(doc, min_percentage=40, bypass_thresholds=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_msrpless_doc_with_prices_builds_unthresholded():
|
||||||
|
"""A priced MSRP-less doc still skips the threshold gate entirely."""
|
||||||
|
|
||||||
|
doc = _doc(hasMsrp=False, currentPrice=95.0, originalPrice=100.0) # 5% < 40
|
||||||
|
product = build_product_details(doc, min_percentage=40)
|
||||||
|
assert product is not None
|
||||||
|
assert product.pricing.has_msrp is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ non-finite prices
|
||||||
|
|
||||||
|
|
||||||
|
def test_infinite_price_skips_instead_of_raising():
|
||||||
|
"""Regression (2026-10-09 06:25Z): float("Infinity") from a retailer field
|
||||||
|
raised OverflowError here and stopped the consumer's API deal loop."""
|
||||||
|
|
||||||
|
inf = float("inf")
|
||||||
|
assert build_product_details(_doc(originalPrice=inf), min_percentage=40) is None
|
||||||
|
assert build_product_details(_doc(currentPrice=inf), min_percentage=40) is None
|
||||||
|
assert build_product_details(_doc(currentPrice=-inf), min_percentage=40) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_nan_price_skips_instead_of_raising():
|
||||||
|
nan = float("nan")
|
||||||
|
assert build_product_details(_doc(currentPrice=nan), min_percentage=40) is None
|
||||||
|
assert build_product_details(_doc(originalPrice=nan), min_percentage=40) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_msrpless_infinite_price_skips_rather_than_reaching_the_payload():
|
||||||
|
"""Without the skip the MSRP-less path would carry inf into the Product."""
|
||||||
|
|
||||||
|
doc = _doc(hasMsrp=False, currentPrice=float("inf"))
|
||||||
|
assert build_product_details(doc, min_percentage=40) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bypass_does_not_rescue_a_non_finite_price():
|
||||||
|
doc = _doc(originalPrice=float("inf"))
|
||||||
|
assert build_product_details(doc, min_percentage=40, bypass_thresholds=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrepresentable_discount_skips_instead_of_raising():
|
||||||
|
"""Finite prices can still overflow the ratio (a subnormal original)."""
|
||||||
|
|
||||||
|
doc = _doc(currentPrice=-1e308, originalPrice=5e-324)
|
||||||
|
assert build_product_details(doc, min_percentage=40) is None
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""unit test: convert_product_to_mongo flattens a Product to its Mongo document
|
||||||
|
|
||||||
|
run from the repo root:
|
||||||
|
uv run pytest tests/test_convert_product_to_mongo.py
|
||||||
|
|
||||||
|
pure and offline — no Mongo. Also pins the fact that prices are already floats
|
||||||
|
by the time they reach the flattener (pydantic coerces numeric strings on the
|
||||||
|
Product), which is why convert_product_to_mongo no longer coerces them itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from monitor_utils.database import convert_product_to_mongo
|
||||||
|
from monitor_utils.models import PricingInfo, Product, ProductMetadata
|
||||||
|
|
||||||
|
|
||||||
|
def _product(metadata: ProductMetadata | None = None, **pricing) -> Product:
|
||||||
|
return Product(
|
||||||
|
product_id="p1",
|
||||||
|
title="Widget",
|
||||||
|
url="http://x/p1",
|
||||||
|
pricing=PricingInfo(**pricing),
|
||||||
|
metadata=metadata or ProductMetadata(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_numeric_string_prices_arrive_as_floats():
|
||||||
|
"""a numeric-string price is a float in the doc (pydantic coerced it on init)"""
|
||||||
|
|
||||||
|
doc = convert_product_to_mongo(_product(original_price="200", current_price=150.0))
|
||||||
|
assert doc["originalPrice"] == 200.0
|
||||||
|
assert isinstance(doc["originalPrice"], float)
|
||||||
|
assert doc["currentPrice"] == 150.0
|
||||||
|
assert isinstance(doc["currentPrice"], float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lastupdated_is_stamped():
|
||||||
|
"""every document gets a UTC lastUpdated timestamp"""
|
||||||
|
|
||||||
|
doc = convert_product_to_mongo(_product(original_price=200.0, current_price=150.0))
|
||||||
|
assert isinstance(doc["lastUpdated"], datetime)
|
||||||
|
|
||||||
|
|
||||||
|
def test_core_fields_map_through():
|
||||||
|
"""title and url pass straight through to the document"""
|
||||||
|
|
||||||
|
doc = convert_product_to_mongo(_product(original_price=200.0, current_price=150.0))
|
||||||
|
assert doc["title"] == "Widget"
|
||||||
|
assert doc["url"] == "http://x/p1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_promo_and_affiliate_fields_serialize():
|
||||||
|
"""promo specifics + affiliate url reach the document under camelCase keys"""
|
||||||
|
|
||||||
|
doc = convert_product_to_mongo(
|
||||||
|
_product(
|
||||||
|
metadata=ProductMetadata(
|
||||||
|
affiliate_url="http://aff/p1",
|
||||||
|
promo_code="BIRTHDAY26",
|
||||||
|
promo_percentage=22,
|
||||||
|
promo_dollar_off=None,
|
||||||
|
promo_min_spend=None,
|
||||||
|
),
|
||||||
|
original_price=200.0,
|
||||||
|
current_price=150.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert doc["affiliateUrl"] == "http://aff/p1"
|
||||||
|
assert doc["promoCode"] == "BIRTHDAY26"
|
||||||
|
assert doc["promoPercentage"] == 22
|
||||||
|
assert doc["promoDollarOff"] is None
|
||||||
|
assert doc["promoMinSpend"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dollar_promo_fields_serialize():
|
||||||
|
"""a spend-threshold code stores amount + min spend, not a percentage"""
|
||||||
|
|
||||||
|
doc = convert_product_to_mongo(
|
||||||
|
_product(
|
||||||
|
metadata=ProductMetadata(
|
||||||
|
promo_code="STORE22",
|
||||||
|
promo_dollar_off=22,
|
||||||
|
promo_min_spend=100,
|
||||||
|
),
|
||||||
|
original_price=200.0,
|
||||||
|
current_price=150.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert doc["promoCode"] == "STORE22"
|
||||||
|
assert doc["promoDollarOff"] == 22
|
||||||
|
assert doc["promoMinSpend"] == 100
|
||||||
|
assert doc["promoPercentage"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_promo_fields_default_to_none():
|
||||||
|
"""a product with no promo still carries the keys as None (uniform doc shape)"""
|
||||||
|
|
||||||
|
doc = convert_product_to_mongo(_product(original_price=200.0, current_price=150.0))
|
||||||
|
for key in ("promoCode", "promoPercentage", "promoDollarOff", "promoMinSpend"):
|
||||||
|
assert doc[key] is None
|
||||||
|
# percentage and channelId stay OUT of the doc on purpose
|
||||||
|
assert "percentage" not in doc
|
||||||
|
assert "channelId" not in doc
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""unit test: fan_out_group binds sibling tasks and surfaces errors unwrapped
|
||||||
|
|
||||||
|
run from the repo root:
|
||||||
|
uv run pytest tests/test_fan_out_group.py
|
||||||
|
|
||||||
|
offline — no network. These lock the contract monitors depend on: a bare
|
||||||
|
TaskGroup always raises an ExceptionGroup, which run_monitor_cycle would
|
||||||
|
misclassify (it matches neither the EXPECTED_TASK_ERRORS branch nor the
|
||||||
|
unexpected-Exception branch as intended), so the unwrapping is load-bearing and
|
||||||
|
not cosmetic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from monitor_utils.runner import fan_out_group
|
||||||
|
|
||||||
|
|
||||||
|
async def _boom(error: BaseException):
|
||||||
|
raise error
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_error_surfaces_unwrapped():
|
||||||
|
"""A failing child arrives as itself, not wrapped in an ExceptionGroup."""
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with fan_out_group() as group:
|
||||||
|
group.create_task(_boom(RuntimeError("child")))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc_info:
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
assert not isinstance(exc_info.value, BaseExceptionGroup)
|
||||||
|
assert str(exc_info.value) == "child"
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_error_surfaces_unwrapped():
|
||||||
|
"""An error raised by the caller's own body propagates unchanged."""
|
||||||
|
|
||||||
|
async def ok():
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with fan_out_group() as group:
|
||||||
|
group.create_task(ok())
|
||||||
|
raise ValueError("body")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
assert not isinstance(exc_info.value, BaseExceptionGroup)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_error_is_preferred_over_an_expected_one():
|
||||||
|
"""first_error must not let a routine network error mask a real bug."""
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with fan_out_group() as group:
|
||||||
|
group.create_task(_boom(aiohttp.ClientError("routine")))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
raise ValueError("real bug")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_early_return_still_awaits_children():
|
||||||
|
"""A `return` inside the body must not abandon an in-flight child task."""
|
||||||
|
|
||||||
|
finished = []
|
||||||
|
|
||||||
|
async def child():
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
finished.append("done")
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with fan_out_group() as group:
|
||||||
|
group.create_task(child())
|
||||||
|
return "returned"
|
||||||
|
|
||||||
|
assert asyncio.run(scenario()) == "returned"
|
||||||
|
assert finished == ["done"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parked_sibling_is_cancelled_when_the_body_fails():
|
||||||
|
"""The binding is the point: a child that never finishes must not hang.
|
||||||
|
|
||||||
|
Without the group, a create_task + `finally: await` shape would park here
|
||||||
|
forever. The deadline is the assertion — a hang fails this test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def parked():
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with asyncio.timeout(2):
|
||||||
|
async with fan_out_group() as group:
|
||||||
|
group.create_task(parked())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
raise RuntimeError("body")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_children_run_concurrently_with_the_body():
|
||||||
|
"""Both siblings make progress; the group is not a sequencing device."""
|
||||||
|
|
||||||
|
order = []
|
||||||
|
|
||||||
|
async def child():
|
||||||
|
order.append("child-start")
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
order.append("child-end")
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with fan_out_group() as group:
|
||||||
|
group.create_task(child())
|
||||||
|
order.append("body")
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
assert order == ["body", "child-start", "child-end"]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""unit test: fetch_all_pages result accounting for any iterable input
|
||||||
|
|
||||||
|
run from the repo root:
|
||||||
|
uv run pytest tests/test_fetch_all_pages.py
|
||||||
|
|
||||||
|
pure and offline — no HTTP. Regression: a generator page_range was exhausted
|
||||||
|
building the tasks, so the result pairing dropped every failure and swallowed
|
||||||
|
unexpected exceptions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aioweb import AiowebError
|
||||||
|
|
||||||
|
from monitor_utils.runner import fetch_all_pages
|
||||||
|
|
||||||
|
|
||||||
|
async def _failing_fetch(page: int):
|
||||||
|
raise AiowebError(f"page {page} failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def _crashing_fetch(page: int):
|
||||||
|
raise ValueError(f"page {page} crashed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generator_input_still_reports_failed_pages():
|
||||||
|
failed = asyncio.run(
|
||||||
|
fetch_all_pages((p for p in [2, 3]), _failing_fetch, "cat", "page")
|
||||||
|
)
|
||||||
|
assert failed == [2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generator_input_still_raises_unexpected_errors():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
asyncio.run(fetch_all_pages((p for p in [2]), _crashing_fetch, "cat", "page"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_input_reports_failed_pages():
|
||||||
|
"""The re-iterable path the in-repo caller uses keeps working."""
|
||||||
|
|
||||||
|
failed = asyncio.run(fetch_all_pages(range(2, 4), _failing_fetch, "cat", "page"))
|
||||||
|
assert failed == [2, 3]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""unit tests: sessions.get_response_json parses a body or logs and returns None
|
||||||
|
|
||||||
|
run from the repo root:
|
||||||
|
uv run pytest tests/test_response_json.py
|
||||||
|
|
||||||
|
offline — the aioweb Response is faked at the leaf; nothing is sent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from monitor_utils.sessions import get_response_json
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, body):
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self.body
|
||||||
|
|
||||||
|
|
||||||
|
def warnings(caplog):
|
||||||
|
return [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dict_body_is_returned_without_logging(caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
|
||||||
|
assert get_response_json(FakeResponse({"a": 1})) == {"a": 1}
|
||||||
|
assert warnings(caplog) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_body_logs_and_returns_none(caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
|
||||||
|
assert get_response_json(FakeResponse(None), category="c1", page=2) is None
|
||||||
|
(record,) = warnings(caplog)
|
||||||
|
assert record.message == "unexpected response"
|
||||||
|
assert record.body_type == "NoneType"
|
||||||
|
assert record.body == "None"
|
||||||
|
assert (record.category, record.page) == ("c1", 2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", [[1, 2], "text", 42, b"bytes"])
|
||||||
|
def test_wrong_shape_logs_type_and_truncated_body(body, caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
|
||||||
|
assert get_response_json(FakeResponse(body)) is None
|
||||||
|
(record,) = warnings(caplog)
|
||||||
|
assert record.body_type == type(body).__name__
|
||||||
|
assert record.body == str(body)[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_preview_is_capped_at_200_chars(caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
|
||||||
|
get_response_json(FakeResponse("x" * 1000))
|
||||||
|
(record,) = warnings(caplog)
|
||||||
|
assert len(record.body) == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_accepts_other_types(caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
|
||||||
|
assert get_response_json(FakeResponse([1]), expected=list) == [1]
|
||||||
|
assert get_response_json(FakeResponse([1]), expected=(dict, list)) == [1]
|
||||||
|
assert get_response_json(FakeResponse({}), expected=list) is None
|
||||||
|
assert len(warnings(caplog)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_goes_to_the_passed_logger(caplog):
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
get_response_json(
|
||||||
|
FakeResponse(None), logger=logging.getLogger("woot.woot_monitor")
|
||||||
|
)
|
||||||
|
(record,) = warnings(caplog)
|
||||||
|
assert record.name == "woot.woot_monitor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_dict_counts_as_a_dict(caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger="monitor_utils.sessions"):
|
||||||
|
assert get_response_json(FakeResponse({})) == {}
|
||||||
|
assert warnings(caplog) == []
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""unit test: add_products_to_database stamps newListing and copies it onto alerts
|
||||||
|
|
||||||
|
run from the repo root:
|
||||||
|
uv run pytest tests/test_new_listing_stamp.py
|
||||||
|
|
||||||
|
offline — drives add_products_to_database against fake collections that record
|
||||||
|
the UpdateOne ops instead of touching Mongo. The recorded ops are inspected via
|
||||||
|
UpdateOne's private _doc/_filter, the same fields its own __eq__ compares.
|
||||||
|
|
||||||
|
The stamp is the database layer's job: only it knows whether the doc already
|
||||||
|
exists, so monitors never set the flag. The alert carries its own copy because
|
||||||
|
the product doc is rewritten every cycle (its flag flips False one cycle after
|
||||||
|
discovery) while a discovery alert may outlive that in a backed-up queue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from monitor_utils.database import add_products_to_database, build_alert
|
||||||
|
from monitor_utils.models import PricingInfo, Product
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 10, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __init__(self, docs):
|
||||||
|
self._docs = list(docs)
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
if not self._docs:
|
||||||
|
raise StopAsyncIteration
|
||||||
|
return self._docs.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCollection:
|
||||||
|
"""Records bulk_write ops; doubles as its own .database for alerts lookup."""
|
||||||
|
|
||||||
|
def __init__(self, existing_docs=(), name="products"):
|
||||||
|
self.name = name
|
||||||
|
self._existing = list(existing_docs)
|
||||||
|
self.bulk_writes = []
|
||||||
|
self.database = self
|
||||||
|
self.alerts = None
|
||||||
|
|
||||||
|
def __getitem__(self, name):
|
||||||
|
if self.alerts is None:
|
||||||
|
self.alerts = FakeCollection(name=name)
|
||||||
|
return self.alerts
|
||||||
|
|
||||||
|
def find(self, _filter, _projection):
|
||||||
|
return FakeCursor(self._existing)
|
||||||
|
|
||||||
|
async def bulk_write(self, ops, ordered=True):
|
||||||
|
self.bulk_writes.append(list(ops))
|
||||||
|
|
||||||
|
|
||||||
|
def _product(product_id="p1", current=95.0, original=100.0) -> Product:
|
||||||
|
return Product(
|
||||||
|
product_id=product_id,
|
||||||
|
title="Widget",
|
||||||
|
url="http://x/p1",
|
||||||
|
pricing=PricingInfo(current_price=current, original_price=original),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run(collection, products):
|
||||||
|
asyncio.run(
|
||||||
|
add_products_to_database(products, collection, "tools", "bestbuy")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def product_update(collection):
|
||||||
|
return collection.bulk_writes[0][0]._doc
|
||||||
|
|
||||||
|
|
||||||
|
def alert_doc(collection):
|
||||||
|
return collection.alerts.bulk_writes[0][0]._doc["$setOnInsert"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_stamps_new_listing_true_on_doc_and_alert():
|
||||||
|
collection = FakeCollection()
|
||||||
|
run(collection, [_product()])
|
||||||
|
|
||||||
|
assert product_update(collection)["$set"]["newListing"] is True
|
||||||
|
assert alert_doc(collection)["newListing"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_doc_with_new_price_stamps_false():
|
||||||
|
existing = {"_id": "p1", "currentPrice": 100.0, "pastPrices": []}
|
||||||
|
collection = FakeCollection([existing])
|
||||||
|
run(collection, [_product(current=95.0)])
|
||||||
|
|
||||||
|
assert product_update(collection)["$set"]["newListing"] is False
|
||||||
|
# a never-seen price still alerts, but not as a new listing
|
||||||
|
assert alert_doc(collection)["newListing"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_alert_carries_price_before_drop():
|
||||||
|
"""the doc's priceBeforeDrop is cleared by the next no-drop cycle, so the
|
||||||
|
alert keeps its own copy from emit time — a drop claimed late still knows
|
||||||
|
what it dropped from"""
|
||||||
|
|
||||||
|
existing = {"_id": "p1", "currentPrice": 100.0, "pastPrices": []}
|
||||||
|
collection = FakeCollection([existing])
|
||||||
|
run(collection, [_product(current=95.0)])
|
||||||
|
|
||||||
|
assert alert_doc(collection)["priceBeforeDrop"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_drop_alert_carries_none():
|
||||||
|
"""a never-seen higher price alerts without a drop; discoveries have no
|
||||||
|
prior price at all"""
|
||||||
|
|
||||||
|
existing = {"_id": "p1", "currentPrice": 100.0, "pastPrices": []}
|
||||||
|
collection = FakeCollection([existing])
|
||||||
|
run(collection, [_product(current=120.0)])
|
||||||
|
assert alert_doc(collection)["priceBeforeDrop"] is None
|
||||||
|
|
||||||
|
discovery = FakeCollection()
|
||||||
|
run(discovery, [_product()])
|
||||||
|
assert alert_doc(discovery)["priceBeforeDrop"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_doc_with_known_price_emits_no_alert():
|
||||||
|
existing = {
|
||||||
|
"_id": "p1",
|
||||||
|
"currentPrice": 95.0,
|
||||||
|
"pastPrices": [{"price": 95.0, "date": NOW}],
|
||||||
|
}
|
||||||
|
collection = FakeCollection([existing])
|
||||||
|
run(collection, [_product(current=95.0)])
|
||||||
|
|
||||||
|
assert product_update(collection)["$set"]["newListing"] is False
|
||||||
|
assert collection.alerts is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_alert_defaults_missing_fields():
|
||||||
|
"""Direct callers passing an unstamped document must not KeyError."""
|
||||||
|
|
||||||
|
alert = build_alert("bestbuy", {"_id": "p1"}, NOW)
|
||||||
|
assert alert["newListing"] is False
|
||||||
|
assert alert["priceBeforeDrop"] is None
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"""unit test: execute_pagination honours a caller-supplied shared semaphore
|
"""unit test: execute_pagination honours a caller-supplied shared semaphore
|
||||||
|
|
||||||
run from the repo root:
|
run from the repo root:
|
||||||
uv run python tests/test_pagination_semaphore.py
|
uv run pytest tests/test_pagination_semaphore.py
|
||||||
|
|
||||||
offline — no network. A fake page func records peak concurrency so we can
|
offline — no network. A fake page func records peak concurrency so we can
|
||||||
assert the fan-out limit actually holds, both for the shared-semaphore path
|
assert the fan-out limit actually holds, both for the shared-semaphore path
|
||||||
(one instance capping many categories at once) and the default per-call path.
|
(one instance capping many categories at once) and a single call bounding one
|
||||||
|
category with its own semaphore.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -62,8 +63,33 @@ def test_shared_semaphore_caps_total_concurrency_across_categories():
|
|||||||
assert state["peak"] <= limit, f"peak {state['peak']} exceeded shared limit {limit}"
|
assert state["peak"] <= limit, f"peak {state['peak']} exceeded shared limit {limit}"
|
||||||
|
|
||||||
|
|
||||||
def test_default_falls_back_to_max_concurrent_tasks():
|
def test_single_call_respects_its_semaphore():
|
||||||
"""Without a semaphore, max_concurrent_tasks still bounds a single call."""
|
"""A single call bounds its own fan-out to the supplied semaphore limit."""
|
||||||
|
|
||||||
|
limit = 2
|
||||||
|
state = {"live": 0, "peak": 0}
|
||||||
|
func = _make_tracking_func(state)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
await execute_pagination(
|
||||||
|
"cat",
|
||||||
|
total_products=300,
|
||||||
|
products_per_page=30,
|
||||||
|
func=func,
|
||||||
|
session=None,
|
||||||
|
semaphore=asyncio.Semaphore(limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert state["peak"] <= limit, f"peak {state['peak']} over limit {limit}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_omitted_semaphore_runs_unbounded():
|
||||||
|
"""Omitting the semaphore lets all pages fan out concurrently.
|
||||||
|
|
||||||
|
With no semaphore the only bound is the page count; pages 2..10 (nine pages)
|
||||||
|
should all be in flight at once, so peak concurrency reaches nine.
|
||||||
|
"""
|
||||||
|
|
||||||
state = {"live": 0, "peak": 0}
|
state = {"live": 0, "peak": 0}
|
||||||
func = _make_tracking_func(state)
|
func = _make_tracking_func(state)
|
||||||
@@ -75,13 +101,7 @@ def test_default_falls_back_to_max_concurrent_tasks():
|
|||||||
products_per_page=30,
|
products_per_page=30,
|
||||||
func=func,
|
func=func,
|
||||||
session=None,
|
session=None,
|
||||||
max_concurrent_tasks=2,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
assert state["peak"] <= 2, f"peak {state['peak']} exceeded max_concurrent_tasks 2"
|
assert state["peak"] == 9, f"expected all 9 pages concurrent, got {state['peak']}"
|
||||||
|
|
||||||
|
|
||||||
test_shared_semaphore_caps_total_concurrency_across_categories()
|
|
||||||
test_default_falls_back_to_max_concurrent_tasks()
|
|
||||||
print("pagination semaphore test passed")
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""unit test: resolve_price_before_drop detects a drop from the prior stored price
|
"""unit test: resolve_price_before_drop detects a drop from the prior stored price
|
||||||
|
|
||||||
run from the repo root:
|
run from the repo root:
|
||||||
uv run python tests/test_price_before_drop.py
|
uv run pytest tests/test_price_before_drop.py
|
||||||
|
|
||||||
pure and offline — no Mongo, no product construction; just the drop rule
|
pure and offline — no Mongo, no product construction; just the drop rule
|
||||||
add_products_to_database relies on
|
add_products_to_database relies on
|
||||||
@@ -23,8 +23,3 @@ def test_non_drops_return_none():
|
|||||||
assert resolve_price_before_drop(150.0, 150.0) is None # unchanged
|
assert resolve_price_before_drop(150.0, 150.0) is None # unchanged
|
||||||
assert resolve_price_before_drop(150.0, 200.0) is None # price rose
|
assert resolve_price_before_drop(150.0, 200.0) is None # price rose
|
||||||
assert resolve_price_before_drop(200.0, None) is None # missing new price
|
assert resolve_price_before_drop(200.0, None) is None # missing new price
|
||||||
|
|
||||||
|
|
||||||
test_drop_returns_previous_price()
|
|
||||||
test_non_drops_return_none()
|
|
||||||
print("price before drop test passed")
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""unit test: run_bounded_cycle session lifetime, budget, and pass-through
|
||||||
|
|
||||||
|
run from the repo root:
|
||||||
|
uv run pytest tests/test_run_bounded_cycle.py
|
||||||
|
|
||||||
|
offline — get_default_session is faked at the runner's namespace so no proxies
|
||||||
|
or network are touched; run_monitor_cycle underneath runs real. Gap: the real
|
||||||
|
session wiring (timeout/max_clients reaching curl) is covered by the monitors'
|
||||||
|
own pool-size tests, not here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from monitor_utils import runner
|
||||||
|
from monitor_utils.models import CycleResult
|
||||||
|
from monitor_utils.runner import run_bounded_cycle
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def stub_session(monkeypatch) -> dict:
|
||||||
|
"""Fake get_default_session, recording each call's kwargs and session."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake(proxies, headers=None, timeout=None, max_clients=None):
|
||||||
|
session = FakeSession()
|
||||||
|
calls.append(
|
||||||
|
{
|
||||||
|
"headers": headers,
|
||||||
|
"timeout": timeout,
|
||||||
|
"max_clients": max_clients,
|
||||||
|
"session": session,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner, "get_default_session", fake)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_cycle_result_and_closes_session(monkeypatch):
|
||||||
|
calls = stub_session(monkeypatch)
|
||||||
|
|
||||||
|
async def task(category, session, collection, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = asyncio.run(run_bounded_cycle(["a", "b"], task, None, None))
|
||||||
|
|
||||||
|
assert result == CycleResult(successes=2, failures=0)
|
||||||
|
assert calls[0]["session"].closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_params_reach_the_factory(monkeypatch):
|
||||||
|
calls = stub_session(monkeypatch)
|
||||||
|
|
||||||
|
async def task(category, session, collection, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
run_bounded_cycle(
|
||||||
|
["a"],
|
||||||
|
task,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
headers={"referer": "https://x/"},
|
||||||
|
session_timeout=8,
|
||||||
|
max_clients=81,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls[0]["headers"] == {"referer": "https://x/"}
|
||||||
|
assert calls[0]["timeout"] == 8
|
||||||
|
assert calls[0]["max_clients"] == 81
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_budget_returns_none_not_raise(monkeypatch, caplog):
|
||||||
|
"""A cycle past its budget is abandoned and logged, and the loop lives on."""
|
||||||
|
|
||||||
|
calls = stub_session(monkeypatch)
|
||||||
|
|
||||||
|
async def parked(category, session, collection, **kwargs):
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="monitor_utils.runner"):
|
||||||
|
result = asyncio.run(
|
||||||
|
run_bounded_cycle(["a"], parked, None, None, cycle_timeout=0.05)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert calls[0]["session"].closed
|
||||||
|
assert any(
|
||||||
|
r.message == "cycle exceeded budget, abandoning cycle" for r in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_task_error_counts_as_failure(monkeypatch):
|
||||||
|
stub_session(monkeypatch)
|
||||||
|
|
||||||
|
async def flaky(category, session, collection, **kwargs):
|
||||||
|
raise aiohttp.ClientError("routine")
|
||||||
|
|
||||||
|
result = asyncio.run(run_bounded_cycle(["a"], flaky, None, None))
|
||||||
|
|
||||||
|
assert result == CycleResult(successes=0, failures=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_error_propagates_and_closes_session(monkeypatch):
|
||||||
|
"""fail loud: a real bug must escape the helper, not vanish into a log."""
|
||||||
|
|
||||||
|
calls = stub_session(monkeypatch)
|
||||||
|
|
||||||
|
async def broken(category, session, collection, **kwargs):
|
||||||
|
raise RuntimeError("bug")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
asyncio.run(run_bounded_cycle(["a"], broken, None, None))
|
||||||
|
|
||||||
|
assert calls[0]["session"].closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_call_opens_a_fresh_session(monkeypatch):
|
||||||
|
calls = stub_session(monkeypatch)
|
||||||
|
|
||||||
|
async def task(category, session, collection, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def two_cycles():
|
||||||
|
await run_bounded_cycle(["a"], task, None, None)
|
||||||
|
await run_bounded_cycle(["a"], task, None, None)
|
||||||
|
|
||||||
|
asyncio.run(two_cycles())
|
||||||
|
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert calls[0]["session"] is not calls[1]["session"]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""unit test: a session's close cannot park the caller indefinitely
|
||||||
|
|
||||||
|
run from the repo root:
|
||||||
|
uv run pytest tests/test_session_close_budget.py
|
||||||
|
|
||||||
|
offline — no network. curl_cffi's close awaits the multi handle with no timeout
|
||||||
|
of its own, so a wedged transfer would park whatever closes the session,
|
||||||
|
including an `async with` exit. These build a real session and wedge the
|
||||||
|
backend's close, asserting the bound lives on the session itself so every call
|
||||||
|
site gets it without arranging anything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aioproxies import AioProxies
|
||||||
|
|
||||||
|
from monitor_utils import sessions
|
||||||
|
from monitor_utils.sessions import BoundedCloseSession, get_default_session
|
||||||
|
|
||||||
|
|
||||||
|
def _proxies(tmp_path) -> AioProxies:
|
||||||
|
"""A one-entry pool in the host:port:user:pass format monitors ship."""
|
||||||
|
proxy_file = tmp_path / "proxies.txt"
|
||||||
|
proxy_file.write_text("proxy.example.io:8080:user:secret\n")
|
||||||
|
return AioProxies.from_file(path=str(proxy_file))
|
||||||
|
|
||||||
|
|
||||||
|
async def _wedge_close(session) -> asyncio.Event:
|
||||||
|
"""Replace the backend's close with one that never returns.
|
||||||
|
|
||||||
|
Builds the backend session first so TLSSession.close actually reaches the
|
||||||
|
backend rather than short-circuiting on an unbuilt session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
session.session # force lazy construction
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
async def wedged(inner_session):
|
||||||
|
started.set()
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
session.backend.close = wedged
|
||||||
|
return started
|
||||||
|
|
||||||
|
|
||||||
|
async def _really_close(session, original_close):
|
||||||
|
"""Close for real, so the test does not leak the curl handle it allocated."""
|
||||||
|
session.backend.close = original_close
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_is_abandoned_when_it_exceeds_the_budget(monkeypatch, caplog, tmp_path):
|
||||||
|
"""A close that never returns is abandoned and logged, not awaited forever."""
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions, "SESSION_CLOSE_TIMEOUT_SECONDS", 0.05)
|
||||||
|
proxies = _proxies(tmp_path)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
session = get_default_session(proxies, timeout=8)
|
||||||
|
original_close = session.backend.close
|
||||||
|
started = await _wedge_close(session)
|
||||||
|
|
||||||
|
# wait_for is the assertion: an unbounded close never returns here
|
||||||
|
await asyncio.wait_for(session.close(), timeout=5)
|
||||||
|
|
||||||
|
assert started.is_set()
|
||||||
|
await _really_close(session, original_close)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="monitor_utils.sessions"):
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
record.message == "session close exceeded budget, abandoning session"
|
||||||
|
for record in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_with_exit_is_bounded(monkeypatch, caplog, tmp_path):
|
||||||
|
"""The bound applies through `async with`, which is how monitors close."""
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions, "SESSION_CLOSE_TIMEOUT_SECONDS", 0.05)
|
||||||
|
proxies = _proxies(tmp_path)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
session = get_default_session(proxies, timeout=8)
|
||||||
|
original_close = session.backend.close
|
||||||
|
started = await _wedge_close(session)
|
||||||
|
|
||||||
|
async with session:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert started.is_set()
|
||||||
|
await _really_close(session, original_close)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="monitor_utils.sessions"):
|
||||||
|
asyncio.run(asyncio.wait_for(run(), timeout=5))
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
record.message == "session close exceeded budget, abandoning session"
|
||||||
|
for record in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthy_close_completes_and_is_not_logged(caplog, tmp_path):
|
||||||
|
"""The normal path closes fully and reports nothing."""
|
||||||
|
|
||||||
|
proxies = _proxies(tmp_path)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
session = get_default_session(proxies, timeout=8)
|
||||||
|
session.session # force lazy construction so close has work to do
|
||||||
|
await session.close()
|
||||||
|
return session
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="monitor_utils.sessions"):
|
||||||
|
session = asyncio.run(run())
|
||||||
|
|
||||||
|
assert session._is_closed()
|
||||||
|
assert not caplog.records
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_session_returns_a_bounded_session(tmp_path):
|
||||||
|
"""The factory hands out the bounded type, not a bare TLSSession."""
|
||||||
|
|
||||||
|
proxies = _proxies(tmp_path)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
session = get_default_session(proxies, timeout=8)
|
||||||
|
try:
|
||||||
|
return isinstance(session, BoundedCloseSession)
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
assert asyncio.run(run())
|
||||||
Reference in new issue
Block a user