Compare commits

..
3 Commits
Author SHA1 Message Date
TPV 7032f5b9d6 Merge pull request 'added get_response_json function to return json from aioweb' (#22) from development into main
Reviewed-on: #22
2026-09-26 03:00:57 +00:00
TPV b9fbb64364 added get_response_json function to return json from aioweb 2026-09-25 20:00:19 -07:00
TPV ae637a134f rename monitor_name parameters to monitor
Applies to setup_monitor_logging, init_monitor, setup_config, get_config,
add_products_to_database and build_alert. Breaking for callers passing
monitor_name by keyword; every monitor repo is updated alongside.
2026-09-25 19:02:09 -07:00
5 changed files with 141 additions and 21 deletions

No files matched your search

+4 -4
View File
@@ -29,7 +29,7 @@ def is_production(development_mode: DevelopmentMode) -> bool:
async def get_config( async def get_config(
monitor_name: str, monitor: str,
use_production: bool = False, use_production: bool = False,
mongo_client: AsyncIOMotorClient = None, mongo_client: AsyncIOMotorClient = None,
) -> dict: ) -> dict:
@@ -39,7 +39,7 @@ async def get_config(
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 mongo_client: Client to read through; the shared cluster client when
@@ -58,10 +58,10 @@ async def get_config(
else f"{CONFIGS_COLLECTION_NAME}_testing" else f"{CONFIGS_COLLECTION_NAME}_testing"
) )
config = await get_collection(collection_name, mongo_client=mongo_client).find_one( config = await get_collection(collection_name, mongo_client=mongo_client).find_one(
{"_id": monitor_name} {"_id": monitor}
) )
if config is None: if config is None:
raise ConfigNotFoundError(monitor_name) raise ConfigNotFoundError(monitor)
return config return config
+6 -6
View File
@@ -112,7 +112,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.
@@ -138,7 +138,7 @@ 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
@@ -180,7 +180,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},
@@ -279,7 +279,7 @@ 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 The alert carries its own copy of the document's newListing flag and
@@ -290,7 +290,7 @@ def build_alert(monitor_name: str, document: dict, now: datetime) -> dict:
and cannot rot. 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, pricing for the document: Product document supplying the id, pricing for the
priority calculation, and the stamped newListing flag and priority calculation, and the stamped newListing flag and
@@ -302,7 +302,7 @@ 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,
+11 -11
View File
@@ -37,22 +37,22 @@ from monitor_utils.models import (
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",
) )
@@ -60,7 +60,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",
@@ -72,7 +72,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.
@@ -84,7 +84,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)
@@ -92,7 +92,7 @@ async def init_monitor(
async def setup_config( async def setup_config(
monitor_name: str, monitor: str,
development_mode: DevelopmentMode, development_mode: DevelopmentMode,
mongo_client: AsyncIOMotorClient = None, mongo_client: AsyncIOMotorClient = None,
) -> tuple[ ) -> tuple[
@@ -105,7 +105,7 @@ 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; mongo_client: Client the config read and the returned collection use;
@@ -117,14 +117,14 @@ 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, 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, mongo_client=mongo_client,
) )
return category_names, config["channels"], collection return category_names, config["channels"], collection
+40
View File
@@ -171,3 +171,43 @@ async def send_request(
) )
response.raise_for_status() response.raise_for_status()
return response return response
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
+80
View File
@@ -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) == []