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(
monitor_name: str,
monitor: str,
use_production: bool = False,
mongo_client: AsyncIOMotorClient = None,
) -> dict:
@@ -39,7 +39,7 @@ async def get_config(
development, so a dev run never reads production routing.
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
configs_testing.
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"
)
config = await get_collection(collection_name, mongo_client=mongo_client).find_one(
{"_id": monitor_name}
{"_id": monitor}
)
if config is None:
raise ConfigNotFoundError(monitor_name)
raise ConfigNotFoundError(monitor)
return config
+6 -6
View File
@@ -112,7 +112,7 @@ async def add_products_to_database(
products: list[Product],
collection: AsyncIOMotorCollection,
category_identifier: str,
monitor_name: str,
monitor: str,
use_composite: bool = False,
):
"""Upsert products, tracking deduplicated price history and emitting alerts.
@@ -138,7 +138,7 @@ async def add_products_to_database(
products: Products to upsert.
collection: Target MongoDB collection.
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.
use_composite: Build composite ids so multi-variant monitors (Woot)
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
set_on_insert = {"discoveredDate": now}
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(
UpdateOne(
{"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", [])}
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.
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.
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.
document: Product document supplying the id, pricing for the
priority calculation, and the stamped newListing flag and
@@ -302,7 +302,7 @@ def build_alert(monitor_name: str, document: dict, now: datetime) -> dict:
"""
return {
"monitor": monitor_name,
"monitor": monitor,
"product_id": document["_id"],
"priority": calculate_priority(document),
"claimed": False,
+11 -11
View File
@@ -37,22 +37,22 @@ from monitor_utils.models import (
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.
Call once at module import, at global scope, before creating module loggers,
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
monitor_name.
monitor.
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.
"""
setup_logging(
name="logs/latest.log",
history_name=monitor_name,
history_name=monitor,
level=logging_level,
output="json",
)
@@ -60,7 +60,7 @@ def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL):
async def init_monitor(
monitor_name: str,
monitor: str,
development_mode: DevelopmentMode,
production_proxy_path: str = "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.
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,
and proxy path.
production_proxy_path: Proxy file path used in production mode.
@@ -84,7 +84,7 @@ async def init_monitor(
use_production = is_production(development_mode)
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
proxies = AioProxies.from_file(path=proxy_path)
@@ -92,7 +92,7 @@ async def init_monitor(
async def setup_config(
monitor_name: str,
monitor: str,
development_mode: DevelopmentMode,
mongo_client: AsyncIOMotorClient = None,
) -> tuple[
@@ -105,7 +105,7 @@ async def setup_config(
during development.
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
collection.
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)
config = await get_config(
monitor_name=monitor_name,
monitor=monitor,
use_production=use_production,
mongo_client=mongo_client,
)
category_names = config["categories"]
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
+40
View File
@@ -171,3 +171,43 @@ async def send_request(
)
response.raise_for_status()
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) == []