diff --git a/CLAUDE.md b/CLAUDE.md index 4dc5844..f222524 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,28 +14,28 @@ python3 -m venv venv && source venv/bin/activate pip install -r requirements.txt # Local dev — hot reload -uvicorn main:app --reload +uvicorn palmer.main:app --reload # Expose to Twilio for local dev ngrok http 8000 # point Twilio SMS webhook at https:///sms -# Tests (pytest — no config file, discovers test_*.py) +# Tests (config in pyproject.toml; all offline, a few seconds) pytest -pytest test_morning_schedule.py::TestSendWindow::test_before_time_no_send # single test +pytest tests/test_morning_schedule.py::TestSendWindow::test_before_time_no_send # single test # Preview a morning briefing without sending curl 'http://localhost:8000/preview?phone=+15551234567' # Trigger the morning job once (bypasses schedule; still respects per-user send window + per-day guard) -python send_morning.py +python -m scripts.send_morning # Trigger reminder delivery once -python send_reminders.py +python -m scripts.send_reminders -# Import main without starting the job loop (tests, shells, one-off scripts). +# Import palmer.main without starting the job loop (tests, shells, one-off scripts). # Without this, importing main starts APScheduler and send_due_reminders will # send REAL SMS on a 1-minute interval. -PALMER_NO_SCHEDULER=1 python -c "import main" +PALMER_NO_SCHEDULER=1 python -c "import palmer.main" ``` `.env` variables (see README for full table): `ANTHROPIC_API_KEY`, `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER`, `TAVILY_API_KEY`, `SERP_API_KEY`, `TMDB_API_KEY`, `TICKETMASTER_API_KEY`, `TOMTOM_API_KEY`, `GIPHY_API_KEY`, `APP_URL`, `DATABASE_URL` (optional locally — falls back to SQLite `palmer.db`). @@ -100,7 +100,7 @@ Because the bar is low and fires both ways, the rate limits are what keep it civ **`run_price_watches` is on a cron trigger, and must stay one.** An APScheduler interval job's first run is scheduled at `start + interval`, and that clock restarts on every dyno boot — which means every deploy. At a twice-daily cadence it made the job a function of deploy history rather than of the clock: on a day with four deploys it never ran at all, and since a tick that finds no qualifying price change logs nothing, it failed invisibly. The two slots are 16h and 8h apart rather than evenly split, deliberately — the budget constraint is runs per day, while the hour is the part users feel, and no strict 12h split lands in waking hours for both timezones served. `test_price_watches.py::TestPriceWatchSchedule` guards the phase-independence property. -Morning briefings are sent by APScheduler at each user's chosen local time (default 7:00). No Heroku Scheduler job is required; if one runs `send_morning.py`, the per-user sent-date guard prevents double-sends. +Morning briefings are sent by APScheduler at each user's chosen local time (default 7:00). No Heroku Scheduler job is required; if one runs `scripts/send_morning.py`, the per-user sent-date guard prevents double-sends. ### Reminders repeat; morning topics are the other kind of repeating `reminders.recurrence` is NULL for a one-shot or one of `timeutil.RECURRENCES` @@ -165,7 +165,7 @@ Two properties are load-bearing: `generate_morning_line` drafts the text on Sonnet through `_build_system` like every other user-facing message. It builds a REQUIRED list from what the payload actually has (weather is basically always there once a city is known; commute only when `traffic` is populated, which only happens when the profile has an address; opening only when `opening_snapshot` returned rows) and tells the model every item on that list must appear — with real specifics, not a vague gesture at the category — plus at most one more sentence about something else on the page if it's genuinely notable. Two rules are enforced in code rather than trusted to the prompt, because the model breaks both: `_strip_link_placeholder` removes "[link]"-style stand-ins and any invented URL, and `_NAMES_THE_LINK` triggers exactly one redraft when the line says "page"/"link"/"dashboard" — that phrasing turns a text from a friend into a push notification. -Opening is no longer opt-in for this reason — `home._fetch_opening` fetches it by default for any user with a city (a user can still be excluded with `morning_prefs.opening = False`). It shipped off at first specifically so a bad metro's rows could be caught with `preview_opening.py` before anyone saw them; that review still matters, it just now happens after rollout instead of gating it. +Opening is no longer opt-in for this reason — `home._fetch_opening` fetches it by default for any user with a city (a user can still be excluded with `morning_prefs.opening = False`). It shipped off at first specifically so a bad metro's rows could be caught with `scripts/preview_opening.py` before anyone saw them; that review still matters, it just now happens after rollout instead of gating it. Every failure falls back to the full text briefing (`generate_morning`, still used by `/preview?full=1`): no APP_URL, an empty page, or a failed draft. A user never gets a link to nothing. @@ -635,7 +635,7 @@ It ships **on** by default — the morning text is required to carry 1-2 opening highlights for every user, so this can no longer be opt-in. `morning_prefs["opening"] is False` excludes a specific user, nested so it needs no `PROFILE_FIELDS` entry. The risk here is taste, not correctness, so a bad metro is still worth checking -with `preview_opening.py` — that review now happens after rollout rather than +with `scripts/preview_opening.py` — that review now happens after rollout rather than gating it. TMDB's terms require the notice *"This product uses the TMDB API but is not @@ -1025,7 +1025,7 @@ A field written by tool dispatch also stays **out of `EXTRACT_PROMPT`** (`followed_teams`, `shows`, `commute`). Listed there, Haiku fills it with prose and the code reading it gets strings where it expects dicts. -`migrate_profile_prune.py` cleans rows that grew before the allow-list existed. It folds the stray keys into canonical fields with a Sonnet pass before dropping them, so real facts survive. Dry run by default; `--apply` writes. +`scripts/migrate_profile_prune.py` cleans rows that grew before the allow-list existed. It folds the stray keys into canonical fields with a Sonnet pass before dropping them, so real facts survive. Dry run by default; `--apply` writes. ### DB access patterns - `get_all_profiles()` returns every `(phone, profile)` in ONE query. The scheduler jobs use it. Do not loop over phones calling `get_profile(phone)` per user — `_conn()` opens a fresh connection per call, so that is N+1 per tick. diff --git a/Procfile b/Procfile index 0e04840..ba64fb3 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: uvicorn main:app --host 0.0.0.0 --port $PORT +web: uvicorn palmer.main:app --host 0.0.0.0 --port $PORT diff --git a/README.md b/README.md index d09c522..fe5dccb 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ cp .env.example .env **3. Run locally** ```bash -uvicorn main:app --reload +uvicorn palmer.main:app --reload ``` **4. Expose for Twilio (local dev)** @@ -125,7 +125,7 @@ git push heroku main Set your Twilio SMS webhook to `https://.herokuapp.com/sms` (POST). Set your Twilio status callback to `https://.herokuapp.com/sms-status` (POST). -**Morning briefings** are sent by APScheduler inside the web dyno (checked every 5 minutes) at each user's chosen local time — 7am by default, changeable by texting Palmer. No Heroku Scheduler job is required; if one exists running `python send_morning.py`, it's a harmless redundant backup (the per-user sent-date guard prevents double-sends). +**Morning briefings** are sent by APScheduler inside the web dyno (checked every 5 minutes) at each user's chosen local time — 7am by default, changeable by texting Palmer. No Heroku Scheduler job is required; if one exists running `python -m scripts.send_morning`, it's a harmless redundant backup (the per-user sent-date guard prevents double-sends). --- diff --git a/palmer/__init__.py b/palmer/__init__.py new file mode 100644 index 0000000..d7f6a67 --- /dev/null +++ b/palmer/__init__.py @@ -0,0 +1 @@ +"""Palmer — a personal AI delivered over SMS. See README.md.""" diff --git a/agent.py b/palmer/agent.py similarity index 96% rename from agent.py rename to palmer/agent.py index 53b7b55..d278dab 100644 --- a/agent.py +++ b/palmer/agent.py @@ -13,21 +13,21 @@ import traceback from datetime import datetime, timedelta, timezone -from db import ( +from palmer.db import ( init_db, get_history, save_message, get_profile, upsert_profile, save_reminder, HISTORY_LIMIT, save_watch, get_user_watches, cancel_watches, save_price_watch, get_user_price_watches, cancel_price_watches, set_price_watch_baseline, ) # --- used directly by the orchestration below --- -from llm import client, SONNET_MODEL -from prompts import SYSTEM_PROMPT -from tools_def import TOOLS -from smstext import _sms_clean, _normalize_hhmm -from userprofile import topic_already_covered -from weather import _get_weather -from datafeeds import _search, _get_price, _get_gif, _fetch_media -from userprofile import _update_profile, _consolidate_history +from palmer.llm import client, SONNET_MODEL +from palmer.prompts import SYSTEM_PROMPT +from palmer.tools_def import TOOLS +from palmer.smstext import _sms_clean, _normalize_hhmm +from palmer.userprofile import topic_already_covered +from palmer.weather import _get_weather +from palmer.datafeeds import _search, _get_price, _get_gif, _fetch_media +from palmer.userprofile import _update_profile, _consolidate_history init_db() @@ -126,12 +126,12 @@ def _prompt_safe_profile(profile: dict) -> dict: # the two ends of one subtraction used different calendars. After 17:00 # Pacific a fact asserted minutes ago came back to the model as # days_old: 1, and a volatile field was dropped a day before its life ran out. - from timeutil import local_today - from userprofile import fresh_profile_for_prompt + from palmer.timeutil import local_today + from palmer.userprofile import fresh_profile_for_prompt safe = fresh_profile_for_prompt(profile, local_today((profile or {}).get("timezone"))) topics = safe.get("morning_topics") if topics: - from morning import _is_directive + from palmer.morning import _is_directive kept = [t for t in topics if t and not _is_directive(t)] if len(kept) != len(topics): safe["morning_topics"] = kept @@ -145,7 +145,7 @@ def base_system() -> str: SYSTEM_PROMPT is a template — passing it unformatted ships literal "{profile_block}" to the model — so a caller cannot simply fall back to the constant itself.""" - from timeutil import clock_block + from palmer.timeutil import clock_block return SYSTEM_PROMPT.format( # No profile means no timezone, which is exactly the case clock_block's # no-zone form is for: state the server clock, assert no local day. @@ -158,7 +158,7 @@ def _build_system(phone: str, include_recent: bool = False, is_new_user: bool = profile = get_profile(phone) profile_block = ("What you know about them:\n" + json.dumps(_prompt_safe_profile(profile), indent=2) if profile else "You don't know much about this person yet. Learn as you go.") - from tapback import reaction_block + from palmer.tapback import reaction_block profile_block += reaction_block(profile) if (profile or {}).get("morning_topics"): # The profile is dumped as raw JSON above, so anything phrased as an @@ -193,7 +193,7 @@ def _build_system(phone: str, include_recent: bool = False, is_new_user: bool = "asked for directly outranks whatever you inferred from how they text. Adjusting " "register never means dropping your spine." ) - from timeutil import clock_block + from palmer.timeutil import clock_block system = SYSTEM_PROMPT.format( # The profile is already read above, so the zone is in hand. Handing the # model the UTC date instead is what filed "remind me tomorrow" a day @@ -271,8 +271,8 @@ def _build_system(phone: str, include_recent: bool = False, is_new_user: bool = # I got on" had nothing to answer from and "cancel my 4pm one" was a guess # against twenty messages of history, against a tool that deletes. try: - from db import get_pending_reminders - from timeutil import valid_zone, _zone + from palmer.db import get_pending_reminders + from palmer.timeutil import valid_zone, _zone pending = get_pending_reminders(phone) except Exception as e: print(f"_build_system: could not read reminders for {phone}: {e}") @@ -381,7 +381,7 @@ def _resolve_asset(asset: str) -> str: company must be private. Resolution goes through tickers.py so the tool and the page's Markets section agree on what a name means, with Yahoo's own search as the fallback for names the curated map doesn't carry.""" - from tickers import resolve_asset_name, resolve_company_ticker + from palmer.tickers import resolve_asset_name, resolve_company_ticker if not asset: return asset return resolve_asset_name(asset) or resolve_company_ticker(asset) or asset @@ -400,7 +400,7 @@ def _normalize_price_topic(topic: str) -> str: are added rarely and read on every page view. Returns the topic unchanged when there is nothing tradeable in it — SpaceX is private, "AI news" is a subject, and neither should grow a fake ticker.""" - from tickers import resolve_topic_asset, resolve_company_ticker, looks_like_price_topic + from palmer.tickers import resolve_topic_asset, resolve_company_ticker, looks_like_price_topic if not topic or not looks_like_price_topic(topic): return topic if resolve_topic_asset(topic): @@ -435,7 +435,7 @@ def _city_from_weather_topic(topic: str) -> str | None: if not topic or not any(w in topic.lower() for w in _WEATHER_TOPIC_WORDS): return None # Local import: morning imports _build_system from here at module level. - from morning import _infer_city_from_topics + from palmer.morning import _infer_city_from_topics return _infer_city_from_topics([topic]) @@ -450,7 +450,7 @@ def _apply_opening_kinds(profile: dict, tool_input: dict, updates: dict) -> str: Absent means all three, so the list is only written once a user actually trims or restores something. Returns a short note for the tool result so Palmer can confirm in its own words.""" - from opening import ALL_KINDS, KIND_WORDS + from palmer.opening import ALL_KINDS, KIND_WORDS add = [KIND_WORDS.get(str(w).lower()) for w in (tool_input.get("opening_add") or [])] drop = [KIND_WORDS.get(str(w).lower()) for w in (tool_input.get("opening_remove") or [])] add = [k for k in add if k] @@ -498,8 +498,8 @@ def _normalize_due_at(phone: str, raw: str) -> tuple[str | None, str, str | None thinking in the user's day, and reading it as UTC would move the reminder by the whole offset. With no timezone on file there is nothing to read it against and UTC is the only answer left.""" - from db import _parse_due - from timeutil import valid_zone, _zone + from palmer.db import _parse_due + from palmer.timeutil import valid_zone, _zone tz_name = valid_zone(get_profile(phone).get("timezone")) parsed = _parse_due(raw) @@ -629,7 +629,7 @@ def _finalize(text: str, system: str, messages: list, gif_url): on a REPLY the user is waiting on an answer, and a block there means main.py's falsy-send path hands them FALLBACK_SMS instead. Redrafting keeps the answer; the send_sms block stays as the last resort behind it.""" - from guards import (redirects_elsewhere, leaks_deliberation, denies_capability, + from palmer.guards import (redirects_elsewhere, leaks_deliberation, denies_capability, REDIRECT_CORRECTION, DELIBERATION_CORRECTION, DENIAL_CORRECTION) reply = _sms_clean(text) @@ -738,7 +738,7 @@ def _dispatch(b): elif b.name == "get_price": result = _get_price(_resolve_asset(b.input["asset"])) elif b.name == "add_weather_location": - from weather import (resolve_weather_location, ambiguous_location, + from palmer.weather import (resolve_weather_location, ambiguous_location, same_place, WEATHER_LOCATIONS_MAX) profile = get_profile(phone_number) current = list(profile.get("weather_locations") or []) @@ -769,7 +769,7 @@ def _dispatch(b): current.append(resolved) upsert_profile(phone_number, {"weather_locations": current}) try: - from home import invalidate + from palmer.home import invalidate invalidate(phone_number, ("weather_extra",)) except Exception as e: print(f"home.invalidate after add_weather_location failed: {e}") @@ -785,14 +785,14 @@ def _dispatch(b): if dropped: upsert_profile(phone_number, {"weather_locations": kept}) try: - from home import invalidate + from palmer.home import invalidate invalidate(phone_number, ("weather_extra",)) except Exception as e: print(f"home.invalidate after remove_weather_location failed: {e}") result = (f"Removed {dropped} location(s)." if dropped else "No extra weather location matched that.") elif b.name == "set_commute": - import traffic as _traffic + from palmer import traffic as _traffic origin = (b.input.get("origin") or "").strip() destination = (b.input.get("destination") or "").strip() leave_raw = (b.input.get("leave_time") or "").strip() @@ -826,7 +826,7 @@ def _dispatch(b): commute["leave_time"] = leave_time upsert_profile(phone_number, {"commute": commute}) try: - from home import invalidate + from palmer.home import invalidate invalidate(phone_number, ("traffic",)) except Exception as e: print(f"home.invalidate after set_commute failed: {e}") @@ -844,7 +844,7 @@ def _dispatch(b): elif b.name == "clear_commute": upsert_profile(phone_number, {"commute": None}) try: - from home import invalidate + from palmer.home import invalidate invalidate(phone_number, ("traffic",)) except Exception as e: print(f"home.invalidate after clear_commute failed: {e}") @@ -863,7 +863,7 @@ def _dispatch(b): # one text — the precise outcome SYSTEM_PROMPT and the tool # description both promise will never happen. Refuse on the # write path, where the model can still fix it this turn. - from timeutil import RECURRENCES + from palmer.timeutil import RECURRENCES if recurrence: # Normalize the same way next_occurrence does, so what is # stored is what the send path will accept. @@ -920,7 +920,7 @@ def _dispatch(b): # weather and nothing else — an empty News card and no Markets # on day one. Seed a baseline instead of waiting to be asked. if b.input.get("enabled") is True and not topics: - from morning import default_topics + from palmer.morning import default_topics topics = default_topics(profile.get("city")) updates: dict = {"morning_topics": topics, "morning_onboarded": True} # Asking for a city's weather sets the city every weather pull @@ -947,7 +947,7 @@ def _dispatch(b): # stamp, a ticker the user just added does not appear until the # cooldown lapses, which reads as "it didn't work". try: - from home import invalidate + from palmer.home import invalidate # weather too when the city moved: the page caches it for 10 # minutes, and a stale stamp would serve the old city's # forecast right after the user corrected it. @@ -978,7 +978,7 @@ def _dispatch(b): f"mention the overlap in one line and ask if they want to " f"drop one. Do not remove anything yourself.") elif b.name == "arrange_page": - from page import DEFAULT_SECTION_ORDER, SECTION_WORDS + from palmer.page import DEFAULT_SECTION_ORDER, SECTION_WORDS profile = get_profile(phone_number) prefs = dict(profile.get("morning_prefs") or {}) unknown: list[str] = [] @@ -1036,7 +1036,7 @@ def _sections(words): # visibility need no invalidate: they are render-time, # carried onto the payload on every view. try: - from home import invalidate + from palmer.home import invalidate invalidate(phone_number, ("prices",)) except Exception as e: print(f"home.invalidate after arrange_page failed: {e}") @@ -1054,7 +1054,7 @@ def _sections(words): else: result = f"Invalid time {b.input.get('time')!r} — must be 24-hour HH:MM, e.g. 07:00." elif b.name == "cancel_reminders": - from db import cancel_reminders_named + from palmer.db import cancel_reminders_named match = b.input.get("text_match") gone = cancel_reminders_named(phone_number, match) if not gone: @@ -1075,7 +1075,7 @@ def _sections(words): count = cancel_watches(phone_number, b.input.get("text_match")) result = f"Cancelled {count} watch(es)." elif b.name == "search_shopping": - from shopping import search_shopping + from palmer.shopping import search_shopping result = search_shopping( b.input["query"], b.input.get("max_price"), @@ -1083,10 +1083,10 @@ def _sections(words): include_link=b.input.get("include_link", False), ) elif b.name == "browse_shop": - from shopping import browse_shop + from palmer.shopping import browse_shop result = browse_shop(b.input["query"]) elif b.name == "search_flights": - from flights import search_flights + from palmer.flights import search_flights result = search_flights( b.input["origin"], b.input["destination"], @@ -1094,7 +1094,7 @@ def _sections(words): b.input.get("return_date"), ) elif b.name == "follow_team": - from sports import find_teams, live_mode, LIVE_MODES, FOLLOW_MAX as TEAM_MAX + from palmer.sports import find_teams, live_mode, LIVE_MODES, FOLLOW_MAX as TEAM_MAX profile = get_profile(phone_number) current = [dict(t) for t in (profile.get("followed_teams") or [])] asked = (b.input.get("name") or "").strip() @@ -1132,7 +1132,7 @@ def _sections(words): current.append(entry) upsert_profile(phone_number, {"followed_teams": current}) try: - from home import invalidate + from palmer.home import invalidate invalidate(phone_number, ("scores",)) except Exception as e: print(f"home.invalidate after follow_team failed: {e}") @@ -1163,14 +1163,14 @@ def _sections(words): if dropped: upsert_profile(phone_number, {"followed_teams": kept}) try: - from home import invalidate + from palmer.home import invalidate invalidate(phone_number, ("scores",)) except Exception as e: print(f"home.invalidate after unfollow_team failed: {e}") result = (f"Unfollowed {dropped} team(s); their games leave the morning and the page." if dropped else "No followed team matched that.") elif b.name == "set_score_updates": - from sports import LIVE_MODES + from palmer.sports import LIVE_MODES profile = get_profile(phone_number) current = [dict(t) for t in (profile.get("followed_teams") or [])] mode = (b.input.get("mode") or "").strip().lower() @@ -1194,7 +1194,7 @@ def _sections(words): result = (f"{names}: {_live_desc(mode)}. They still follow the team — " f"it stays in the morning update and on their page.") elif b.name == "get_score": - from sports import find_teams, team_game, describe + from palmer.sports import find_teams, team_game, describe matches = find_teams(b.input.get("team", "")) if not matches: result = (f"No team matches {b.input.get('team')!r}. Ask them to confirm it.") @@ -1206,7 +1206,7 @@ def _sections(words): result = (f"{matches[0]['name']}: {describe(game)}" if game else f"{matches[0]['name']} have no game today.") elif b.name == "follow_show": - from shows import find_shows, ambiguous_shows, describe_show, FOLLOW_MAX + from palmer.shows import find_shows, ambiguous_shows, describe_show, FOLLOW_MAX profile = get_profile(phone_number) current = list(profile.get("shows") or []) asked = (b.input.get("name") or "").strip() @@ -1236,7 +1236,7 @@ def _sections(words): current.append({"id": found["id"], "name": found["name"]}) upsert_profile(phone_number, {"shows": current}) try: - from home import invalidate + from palmer.home import invalidate invalidate(phone_number, ("opening",)) except Exception as e: print(f"home.invalidate after follow_show failed: {e}") @@ -1255,14 +1255,14 @@ def _sections(words): if dropped: upsert_profile(phone_number, {"shows": kept}) try: - from home import invalidate + from palmer.home import invalidate invalidate(phone_number, ("opening",)) except Exception as e: print(f"home.invalidate after unfollow_show failed: {e}") result = (f"Unfollowed {dropped} show(s)." if dropped else "No followed show matched that.") elif b.name == "add_flight_watch": - from db import save_flight_watch, FLIGHT_WATCH_MAX + from palmer.db import save_flight_watch, FLIGHT_WATCH_MAX ok = save_flight_watch( phone_number, b.input["origin"], b.input["destination"], b.input["outbound_date"], b.input.get("return_date"), @@ -1278,12 +1278,12 @@ def _sections(words): f"they are at the limit of {FLIGHT_WATCH_MAX} flight watches. " f"Tell them which, and offer to cancel one.") elif b.name == "cancel_flight_watch": - from db import cancel_flight_watches + from palmer.db import cancel_flight_watches n = cancel_flight_watches(phone_number, b.input.get("text_match")) result = (f"Cancelled {n} flight watch(es)." if n else "No matching flight watch to cancel.") elif b.name == "search_hotels": - from hotels import search_hotels + from palmer.hotels import search_hotels result = search_hotels( b.input["location"], b.input["check_in_date"], @@ -1292,7 +1292,7 @@ def _sections(words): b.input.get("min_rating"), ) elif b.name == "add_price_watch": - import shopping + from palmer import shopping product_name = b.input["product_name"] watch_id = save_price_watch( phone_number, @@ -1331,7 +1331,7 @@ def _sections(words): f"12 hours and text if it hits{target_str}." ) elif b.name == "add_amazon_watch": - import amazon + from palmer import amazon query = b.input["product_query"] target = b.input.get("target_price") resolved = amazon.resolve_asin(query) @@ -1380,13 +1380,13 @@ def _sections(words): lines.append(" — ".join(bits)) result = "\n".join(lines) elif b.name == "get_my_page": - from home import ensure_fresh + from palmer.home import ensure_fresh profile = get_profile(phone_number) # No city on file means there is no page to build yet — every # section keys off it. The same address serves their setup # form until they submit it, and their page forever after. if not profile.get("city"): - from onboard import start as _setup_start + from palmer.onboard import start as _setup_start url = _setup_start(phone_number) or "" if url: result = ( @@ -1413,10 +1413,10 @@ def _sections(words): "(APP_URL is not configured). Answer their question " "directly instead and do not mention a page.") elif b.name == "get_travel_time": - from traffic import get_travel_time + from palmer.traffic import get_travel_time result = get_travel_time(b.input["origin"], b.input["destination"]) elif b.name == "get_city_traffic": - from traffic import city_traffic + from palmer.traffic import city_traffic asked_city = b.input["city"] line, why = city_traffic(asked_city) if line: diff --git a/amazon.py b/palmer/amazon.py similarity index 98% rename from amazon.py rename to palmer/amazon.py index b2c2f82..2089940 100644 --- a/amazon.py +++ b/palmer/amazon.py @@ -20,8 +20,8 @@ import requests as _requests -import serpapi -from llm import client, HAIKU_MODEL +from palmer import serpapi +from palmer.llm import client, HAIKU_MODEL # /dp/ and /gp/product/ are the two canonical Amazon product paths. @@ -229,6 +229,6 @@ def check_price(watch: dict) -> dict | None: def draft_alert(product_name: str, current: dict, watch: dict, reason: str) -> str: """Amazon price hit. Shared drafter, plus the dp/ASIN URL — those are clean permalinks worth sending.""" - from price_alert import draft_price_alert + from palmer.price_alert import draft_price_alert return draft_price_alert(product_name, current, watch, reason, link=current["url"], source_label="Amazon") diff --git a/artifacts.py b/palmer/artifacts.py similarity index 97% rename from artifacts.py rename to palmer/artifacts.py index 6591e9c..992dc50 100644 --- a/artifacts.py +++ b/palmer/artifacts.py @@ -30,7 +30,7 @@ import secrets import threading -from db import get_artifact +from palmer.db import get_artifact _APP_URL = os.environ.get("APP_URL", "").rstrip("/") @@ -82,7 +82,7 @@ def _card_now(payload: dict): naive datetime.now() — UTC on the dyno — so a zoneless reader got a date that was simply wrong for half of every day, and the page (which now omits it) and the card would disagree besides.""" - from timeutil import valid_zone, local_now + from palmer.timeutil import valid_zone, local_now tz = payload.get("timezone") if not valid_zone(tz): return None @@ -135,7 +135,7 @@ def render_png(token: str, payload: dict) -> bytes: hit = _png_cache.get(key) if hit is not None: return hit - from cards import render_dashboard + from palmer.cards import render_dashboard inputs = _card_inputs(payload) inputs.pop("_date", None) # No resolvable zone means no day we can stand behind, so the masthead diff --git a/cards.py b/palmer/cards.py similarity index 99% rename from cards.py rename to palmer/cards.py index 3331688..55fa5a4 100644 --- a/cards.py +++ b/palmer/cards.py @@ -252,7 +252,7 @@ def render_dashboard(*, city: str, weather: dict | None, traffic: dict | None, # The 42pt figure runs to about +66, so the line sits at +72 and # pushes the meter down by the same 10px; a live commute keeps the # band exactly as it was. - from timeutil import friendly_hhmm + from palmer.timeutil import friendly_hhmm d.text((right_x, content_top + 72), f"{friendly_hhmm(traffic['depart_at'])} -> ~{friendly_hhmm(traffic.get('arrive_at'))}", font=_mono(15), fill=MUTED) diff --git a/datafeeds.py b/palmer/datafeeds.py similarity index 99% rename from datafeeds.py rename to palmer/datafeeds.py index 445aa20..130a25a 100644 --- a/datafeeds.py +++ b/palmer/datafeeds.py @@ -12,9 +12,9 @@ import requests as _requests from tavily import TavilyClient -import sources -from smstext import _parse_published -from timeutil import local_today +from palmer import sources +from palmer.smstext import _parse_published +from palmer.timeutil import local_today # US equities trade on New York's calendar, not the server's and not the # reader's. timeutil imports nothing from Palmer, so this adds no cycle. diff --git a/db.py b/palmer/db.py similarity index 99% rename from db.py rename to palmer/db.py index bb25d27..cb93b99 100644 --- a/db.py +++ b/palmer/db.py @@ -17,7 +17,7 @@ def _conn(): return psycopg2.connect(_DATABASE_URL, cursor_factory=psycopg2.extras.RealDictCursor) else: import sqlite3 - _DB_PATH = Path(__file__).parent / "palmer.db" + _DB_PATH = Path(__file__).resolve().parents[1] / "palmer.db" # repo root, not inside the package PH = "?" ID_COL = "INTEGER PRIMARY KEY AUTOINCREMENT" BLOB_COL = "BLOB" diff --git a/flights.py b/palmer/flights.py similarity index 99% rename from flights.py rename to palmer/flights.py index 541f55d..7327803 100644 --- a/flights.py +++ b/palmer/flights.py @@ -11,7 +11,7 @@ import urllib.parse from datetime import datetime -from netutil import _http_get_json +from palmer.netutil import _http_get_json SERP_API_KEY = os.environ.get("SERP_API_KEY", "") _SERPAPI_BASE = "https://serpapi.com/search.json" diff --git a/flightwatch.py b/palmer/flightwatch.py similarity index 93% rename from flightwatch.py rename to palmer/flightwatch.py index 100724f..965b869 100644 --- a/flightwatch.py +++ b/palmer/flightwatch.py @@ -18,7 +18,7 @@ from datetime import date -from db import (get_active_flight_watches, update_flight_watch_price) +from palmer.db import (get_active_flight_watches, update_flight_watch_price) # A fare has to move by more than this to be worth a text. Flights are volatile # by tens of dollars daily, so the flat $2 rule that governs product watches @@ -40,7 +40,7 @@ def _route(watch: dict) -> str: def _cheapest(watch: dict) -> float | None: """Lowest current fare for the route, or None if the search gave nothing.""" - from flights import _serpapi_flights_search + from palmer.flights import _serpapi_flights_search try: results = _serpapi_flights_search( watch["origin"], watch["destination"], @@ -78,11 +78,11 @@ def _should_alert(watch: dict, current: float) -> str | None: def run_flight_watches() -> None: """Check every active flight watch once. Never raises.""" - from sms_util import send_sms - from db import save_message - from price_alert import draft_price_alert - from userprofile import _is_duplicate_subject - from db import cancel_flight_watches + from palmer.sms_util import send_sms + from palmer.db import save_message + from palmer.price_alert import draft_price_alert + from palmer.userprofile import _is_duplicate_subject + from palmer.db import cancel_flight_watches try: watches = get_active_flight_watches() diff --git a/followup.py b/palmer/followup.py similarity index 95% rename from followup.py rename to palmer/followup.py index 3068d3d..f598028 100644 --- a/followup.py +++ b/palmer/followup.py @@ -23,12 +23,12 @@ from dataclasses import dataclass from datetime import datetime -from agent import _build_system -from llm import client, HAIKU_MODEL, SONNET_MODEL -from smstext import _sms_clean -from userprofile import _is_duplicate_subject -from db import get_all_profiles, upsert_profile, save_message, get_history, claim_daily_guard -from morning import _local_now, _local_today +from palmer.agent import _build_system +from palmer.llm import client, HAIKU_MODEL, SONNET_MODEL +from palmer.smstext import _sms_clean +from palmer.userprofile import _is_duplicate_subject +from palmer.db import get_all_profiles, upsert_profile, save_message, get_history, claim_daily_guard +from palmer.morning import _local_now, _local_today # Days between check-ins for a user with no negative reactions on record; # tapback.pacing_factor stretches it, GAP_MAX_DAYS caps the stretch. @@ -66,7 +66,7 @@ def _should_send_followup(profile: dict) -> bool: last_sent = profile.get("followup_sent_date") if last_sent: try: - from tapback import pacing_factor + from palmer.tapback import pacing_factor gap = min(round(GAP_DAYS * pacing_factor(profile)), GAP_MAX_DAYS) last_date = datetime.fromisoformat(last_sent).date() if (local_now.date() - last_date).days < gap: @@ -87,7 +87,7 @@ def _load_payload(profile: dict) -> dict | None: if not token: return None try: - from home import load + from palmer.home import load return load(token) except Exception: return None @@ -107,7 +107,7 @@ def _candidates(profile: dict, payload: dict | None, today) -> list[Subject]: teams = [t for t in (profile.get("followed_teams") or []) if t.get("abbrev")] if teams: try: - from sports import team_day, result_line + from palmer.sports import team_day, result_line for team in teams: day = team_day(team, today) name = team.get("name") or team.get("abbrev") @@ -226,7 +226,7 @@ def run_followups(): last real send — and _should_send_followup measures the pacing gap against exactly that field. The gap is the thing standing between "a check-in" and "a drumbeat".""" - from sms_util import send_sms + from palmer.sms_util import send_sms for phone, profile in get_all_profiles(): try: diff --git a/guards.py b/palmer/guards.py similarity index 100% rename from guards.py rename to palmer/guards.py diff --git a/home.py b/palmer/home.py similarity index 96% rename from home.py rename to palmer/home.py index b3915d5..6f7e52f 100644 --- a/home.py +++ b/palmer/home.py @@ -40,8 +40,8 @@ import os import time -from db import get_profile, upsert_profile, save_artifact, get_artifact -from artifacts import new_token +from palmer.db import get_profile, upsert_profile, save_artifact, get_artifact +from palmer.artifacts import new_token _APP_URL = os.environ.get("APP_URL", "").rstrip("/") @@ -102,7 +102,7 @@ def _now() -> float: def _fetch_weather(profile: dict) -> dict | None: - from weather import weather_snapshot + from palmer.weather import weather_snapshot city = profile.get("city") return weather_snapshot(city, profile.get("timezone")) if city else None @@ -115,7 +115,7 @@ def _fetch_weather_extra(profile: dict) -> list[dict]: stale row over a vanished one. Page-only: the PNG card has no room for it (see weather.WEATHER_LOCATIONS_MAX) and the morning text is basics plus a link, not a full briefing.""" - from weather import weather_snapshot + from palmer.weather import weather_snapshot out = [] for loc in (profile.get("weather_locations") or []): snap = weather_snapshot(loc, profile.get("timezone")) @@ -139,8 +139,8 @@ def _commute_depart_at(commute: dict, tz_name: str | None, now=None): Pure given `now`, so it is testable without freezing a clock.""" from datetime import timedelta - from smstext import _normalize_hhmm - from timeutil import local_now, valid_zone + from palmer.smstext import _normalize_hhmm + from palmer.timeutil import local_now, valid_zone leave = _normalize_hhmm((commute or {}).get("leave_time") or "") if not leave: return None @@ -159,7 +159,7 @@ def _commute_depart_at(commute: dict, tz_name: str | None, now=None): def _fetch_traffic(profile: dict) -> dict | None: - from traffic import traffic_snapshot + from palmer.traffic import traffic_snapshot c = profile.get("commute") or {} if not (c.get("origin") and c.get("destination")): return None @@ -191,8 +191,8 @@ def _fetch_prices(profile: dict, previous: list[dict] | None = None) -> list[dic transient blip silently deletes a ticker the user is tracking — which looks exactly like Palmer forgetting, and is far worse than a slightly stale number sitting under a visible "N min ago" stamp.""" - from datafeeds import price_snapshot - from tickers import resolve_topic_asset + from palmer.datafeeds import price_snapshot + from palmer.tickers import resolve_topic_asset stale = {(p.get("label") or "").lower(): p for p in (previous or [])} assets, seen = [], set() for topic in (profile.get("morning_topics") or []): @@ -230,8 +230,8 @@ def _fetch_scores(profile: dict) -> list[dict]: teams = [t for t in (profile.get("followed_teams") or []) if t.get("abbrev")] if not teams: return [] - from sports import team_day - from timeutil import local_today + from palmer.sports import team_day + from palmer.timeutil import local_today today = local_today(profile.get("timezone")) out = [] for team in teams: @@ -263,11 +263,11 @@ def _fetch_headlines(profile: dict) -> list[dict]: and the morning briefing keep tier 3 as a last resort, because an obscure-but-real source beats "nothing found" when someone asked. """ - from datafeeds import _search_raw - from morning import _is_directive, _rotated_topics, _WEATHER_KEYWORDS, _TRAFFIC_KEYWORDS - from sources import canonical_domain - from timeutil import local_today - from tickers import resolve_topic_asset + from palmer.datafeeds import _search_raw + from palmer.morning import _is_directive, _rotated_topics, _WEATHER_KEYWORDS, _TRAFFIC_KEYWORDS + from palmer.sources import canonical_domain + from palmer.timeutil import local_today + from palmer.tickers import resolve_topic_asset auto = _WEATHER_KEYWORDS + _TRAFFIC_KEYWORDS # A topic that resolves to a ticker is already answered by the Markets # section, so searching news for it buys a second, paid answer to the same @@ -345,7 +345,7 @@ def _fetch_opening(profile: dict) -> list[dict]: # on write. if (profile.get("morning_prefs") or {}).get("opening") is False: return [] - from opening import opening_snapshot + from palmer.opening import opening_snapshot try: return opening_snapshot(profile) except Exception as e: @@ -370,7 +370,7 @@ def _tracking(phone: str, profile: dict | None = None) -> dict: Takes the caller's profile when it already has one — this runs on every page view, and re-reading the profile here would open a second connection for a row the caller is already holding.""" - from db import get_user_watches, get_user_price_watches + from palmer.db import get_user_watches, get_user_price_watches try: watches = [{"description": w.get("description"), "cooldown_hours": w.get("cooldown_hours"), "url": w.get("last_alert_url"), "source": w.get("last_alert_domain")} diff --git a/hotels.py b/palmer/hotels.py similarity index 99% rename from hotels.py rename to palmer/hotels.py index ed58369..7c86f13 100644 --- a/hotels.py +++ b/palmer/hotels.py @@ -9,7 +9,7 @@ import os import urllib.parse -from netutil import _http_get_json +from palmer.netutil import _http_get_json SERP_API_KEY = os.environ.get("SERP_API_KEY", "") _SERPAPI_BASE = "https://serpapi.com/search.json" diff --git a/llm.py b/palmer/llm.py similarity index 100% rename from llm.py rename to palmer/llm.py diff --git a/main.py b/palmer/main.py similarity index 94% rename from main.py rename to palmer/main.py index abfcecf..a034a21 100644 --- a/main.py +++ b/palmer/main.py @@ -9,16 +9,16 @@ from twilio.request_validator import RequestValidator from twilio.twiml.messaging_response import MessagingResponse from apscheduler.schedulers.background import BackgroundScheduler -from agent import get_reply, save_assistant_turn -from smstext import shorten_message -from morning import generate_morning, send_morning_messages, send_missing_data_asks -from followup import run_followups -from db import get_profile, upsert_profile, save_message, get_history, HISTORY_LIMIT -from send_reminders import send_due_reminders -from watches import run_watches -from shopping import run_price_watches -from sms_util import ensure_sms, send_sms, FALLBACK_SMS -from tapback import parse_reaction, record_reaction, interpret_reaction, learn_from_reactions +from palmer.agent import get_reply, save_assistant_turn +from palmer.smstext import shorten_message +from palmer.morning import generate_morning, send_morning_messages, send_missing_data_asks +from palmer.followup import run_followups +from palmer.db import get_profile, upsert_profile, save_message, get_history, HISTORY_LIMIT +from palmer.reminders import send_due_reminders +from palmer.watches import run_watches +from palmer.shopping import run_price_watches +from palmer.sms_util import ensure_sms, send_sms, FALLBACK_SMS +from palmer.tapback import parse_reaction, record_reaction, interpret_reaction, learn_from_reactions app = FastAPI() @@ -112,7 +112,7 @@ # sends, so the forecast logged is the one users are about to be told, and it is # the same calendar date in both zones, which is what keeps target_date honest. # Sends nothing and touches no user path; a failure is a gap in the log. -from wxaudit import run_forecast_audit +from palmer.wxaudit import run_forecast_audit _scheduler.add_job( run_forecast_audit, "cron", hour="11", timezone="Etc/UTC", misfire_grace_time=3600, @@ -123,7 +123,7 @@ # input and the account is on 250 searches/month, so each active watch costs # ~30 and db.FLIGHT_WATCH_MAX caps a user at three. 13:00 UTC is mid-morning # US-wide, late enough that a fare alert does not arrive before the briefing. -from flightwatch import run_flight_watches +from palmer.flightwatch import run_flight_watches _scheduler.add_job( run_flight_watches, "cron", hour="13", timezone="Etc/UTC", misfire_grace_time=3600, @@ -140,7 +140,7 @@ # There is deliberately no daily "a friend would text this" news alert. It # texted on Palmer's own judgment; run_followups is the one paced, unprompted # text, and it draws on teams and news as well as personal threads. -from scorewatch import run_score_alerts +from palmer.scorewatch import run_score_alerts _scheduler.add_job(run_score_alerts, "interval", minutes=2, misfire_grace_time=60) if _SCHEDULER_ENABLED: _scheduler.start() @@ -348,7 +348,7 @@ async def artifact_png(token: str): Public by necessity — Twilio fetches MMS media and the recipient's phone fetches the og:image, neither of which can carry auth. The token is the protection; see artifacts.py.""" - from artifacts import load, render_png + from palmer.artifacts import load, render_png payload = load(token) if payload is None: raise HTTPException(status_code=404) @@ -367,8 +367,8 @@ async def artifact_png(token: str): async def artifact_page(token: str): """The interactive briefing. An MMS card is a bitmap with no tap targets, so this is where headlines and tickers become links.""" - from artifacts import load, image_url, page_url - from page import render + from palmer.artifacts import load, image_url, page_url + from palmer.page import render payload = load(token) if payload is None: raise HTTPException(status_code=404) @@ -400,8 +400,8 @@ async def home_png(token: str): Answers HEAD as well as GET: link-preview scrapers and proxies commonly probe with HEAD before fetching an image, and a 405 there can cost the preview outright.""" - from home import load, refresh_stale - from artifacts import render_png + from palmer.home import load, refresh_stale + from palmer.artifacts import render_png payload = load(token) if payload is None: raise HTTPException(status_code=404) @@ -410,7 +410,7 @@ async def home_png(token: str): if payload.get("setup_pending") or not payload.get("built_at"): raise HTTPException(status_code=404) payload = refresh_stale(token, payload) - from artifacts import _card_fingerprint + from palmer.artifacts import _card_fingerprint stamp = _card_fingerprint(payload) return FileResponse( content=render_png(token, payload), @@ -433,8 +433,8 @@ async def home_setup_submit(token: str, request: Request): Guarded by onboard.apply's one-shot rule rather than by auth — the token has always been the page's only protection, and this keeps it a read key everywhere except the single submission it was minted for.""" - from home import load - from onboard import apply, needs_setup + from palmer.home import load + from palmer.onboard import apply, needs_setup payload = load(token) if payload is None: raise HTTPException(status_code=404) @@ -451,8 +451,8 @@ async def home_setup_submit(token: str, request: Request): async def home_page(token: str): """The user's live page. No login — the token is the whole protection, so this shows only briefing-grade content (see home.py).""" - from home import load, refresh_stale - from page import render + from palmer.home import load, refresh_stale + from palmer.page import render payload = load(token) if payload is None: raise HTTPException(status_code=404) @@ -461,7 +461,7 @@ async def home_page(token: str): # and the first payload it is a holding page. Both return before # refresh_stale, which would otherwise spend on sections for a user whose # city is still unknown. - from onboard import needs_setup, render_setup, render_building + from palmer.onboard import needs_setup, render_setup, render_building if needs_setup(payload): return FileResponse( content=render_setup(token, action=f"{base}/h/{token}"), @@ -483,7 +483,7 @@ async def home_page(token: str): # faithfully; nobody was ever asking for it. A fingerprint in the URL makes # each day's link a different image to a cache, and an unchanged day stays # cheap because the fingerprint is unchanged too. - from artifacts import _card_fingerprint + from palmer.artifacts import _card_fingerprint stamp = _card_fingerprint(payload) return FileResponse( content=render(payload, token=token, @@ -505,6 +505,6 @@ async def preview_morning(phone: str, full: bool = False): is now only the fallback for when the page can't be built.""" if full: return PlainTextResponse(generate_morning(phone)) - from morning import _compose_morning + from palmer.morning import _compose_morning message, _ = _compose_morning(phone) return PlainTextResponse(message) diff --git a/morning.py b/palmer/morning.py similarity index 97% rename from morning.py rename to palmer/morning.py index 3d50c8d..b3de9da 100644 --- a/morning.py +++ b/palmer/morning.py @@ -1,14 +1,14 @@ import os import re from datetime import datetime, date as date_type, timedelta, timezone -from agent import _build_system -from llm import client, HAIKU_MODEL, SONNET_MODEL -from smstext import _sms_clean -from weather import _weather_report -from datafeeds import _search_raw, _get_price -from userprofile import _derive_timezone -from db import get_profile, upsert_profile, get_all_profiles, save_message, get_history, claim_daily_guard -from traffic import get_city_traffic, get_travel_time +from palmer.agent import _build_system +from palmer.llm import client, HAIKU_MODEL, SONNET_MODEL +from palmer.smstext import _sms_clean +from palmer.weather import _weather_report +from palmer.datafeeds import _search_raw, _get_price +from palmer.userprofile import _derive_timezone +from palmer.db import get_profile, upsert_profile, get_all_profiles, save_message, get_history, claim_daily_guard +from palmer.traffic import get_city_traffic, get_travel_time DEFAULT_MORNING_TIME = "07:00" @@ -42,7 +42,7 @@ def default_topics(city: str | None) -> list[str]: # here, on the seeding path, and cached — never on read. place = city try: - from opening import _metro + from palmer.opening import _metro place = _metro(city) or city except Exception as e: print(f"default_topics: metro lookup failed for {city!r}: {type(e).__name__}: {e}") @@ -132,7 +132,7 @@ def _price_asset_for_topic(topic: str) -> str | None: Resolution lives in tickers.py so the text briefing and the page's Markets section can never disagree about what a topic means.""" - from tickers import resolve_topic_asset + from palmer.tickers import resolve_topic_asset got = resolve_topic_asset(topic) return got[0] if got else None @@ -154,7 +154,7 @@ def _topic_digest(topic: str) -> str | None: Returns None rather than the string "No results found." so a thin topic is left out of the data entirely instead of being handed to the model to silently skip.""" - from sources import canonical_domain + from palmer.sources import canonical_domain results = _search_raw(topic, days=1, max_age_hours=_TOPIC_MAX_AGE_HOURS, min_score=0.5) if not results: return None @@ -236,7 +236,7 @@ def _gather_morning_data(profile: dict) -> list[str]: # optional: returns None most days, and the drafting prompt treats it as # droppable, so a miss costs nothing. try: - from trends import adjacent_story + from palmer.trends import adjacent_story pick = adjacent_story(topics or [], covered) if pick: sections.append( @@ -261,7 +261,7 @@ def _recent_assistant_texts(phone: str, n: int = 4) -> list[str]: conversation and almost never against yesterday's morning. The `kind` column is what makes the right query possible; fall back to any assistant message for users whose history predates it.""" - from db import get_recent_messages_of_kind + from palmer.db import get_recent_messages_of_kind texts = get_recent_messages_of_kind(phone, "morning", limit=n) if texts: return texts @@ -367,7 +367,7 @@ def generate_morning(phone: str) -> str: def _local_now(tz_name: str) -> datetime: - from timeutil import local_now + from palmer.timeutil import local_now return local_now(tz_name) @@ -375,7 +375,7 @@ def _local_today(tz_name: str | None) -> date_type: """The user's local calendar date; falls back to server date if tz is missing/bad. Thin wrapper over timeutil.local_today so existing imports (followup.py) keep working while the canonical helper lives in one place.""" - from timeutil import local_today + from palmer.timeutil import local_today return local_today(tz_name) @@ -404,7 +404,7 @@ def score_lines(payload: dict) -> list[str]: Shared by the morning digest and the check-in job so the two describe a game in the same words.""" - from sports import result_line + from palmer.sports import result_line out = [] for row in (payload.get("scores") or []): team = {"abbrev": row.get("abbrev"), "name": row.get("team")} @@ -453,7 +453,7 @@ def _payload_digest(payload: dict) -> str: lines.append(f"Weather in {where}: " + ", ".join(bits)) t = payload.get("traffic") or {} if t.get("live_min"): - from timeutil import friendly_hhmm + from palmer.timeutil import friendly_hhmm delay = t.get("delay_min") or 0 # Say which moment the number is FOR. A commute routed for the user's # leave time is a forecast for that departure, not current traffic, @@ -648,7 +648,7 @@ def _shaped(t: str) -> bool: # numbers and trailing clauses differ — so this compares the SHAPE of the # opening instead. Suppressing the message would be wrong: they asked for a # daily briefing. Only the phrasing has to move. - from guards import repeats_opening + from palmer.guards import repeats_opening if recent and repeats_opening(line, recent): again = _draft( "\n\nYou just wrote: " + repr(line) + "\nThat opens the same way as a " @@ -689,7 +689,7 @@ def _compose_morning(phone: str) -> tuple[str, bool]: can't be drafted — a user is never left holding a link to nothing. Returns (message, carries_link); the caller needs the flag because the /sms-status shorten-and-retry would happily truncate a URL into garbage.""" - from home import ensure_fresh, load, home_token + from palmer.home import ensure_fresh, load, home_token url = ensure_fresh(phone) if not url.startswith("http"): print(f"_compose_morning: APP_URL not configured for {phone}, sending text briefing") @@ -710,7 +710,7 @@ def _compose_morning(phone: str) -> tuple[str, bool]: # (keyed to the user's local date) makes extra invocations harmless, so the old # hourly Heroku Scheduler job can stay on as a redundant backup or be removed. def send_morning_messages(): - from sms_util import send_sms + from palmer.sms_util import send_sms for phone, profile in get_all_profiles(): try: @@ -806,7 +806,7 @@ def _in_data_ask_window() -> bool: def _draft_city_ask(phone: str) -> str: """One or two-sentence ask in Palmer's voice, explaining why he needs the city.""" - from agent import _build_system + from palmer.agent import _build_system system = _build_system(phone, include_recent=True) try: response = client.messages.create( @@ -836,7 +836,7 @@ def send_missing_data_asks(): Set DATA_ASK_DRY_RUN=1 to log matches + drafted messages without sending or writing cooldown state — useful for verifying who'd be contacted before turning it loose.""" - from sms_util import send_sms + from palmer.sms_util import send_sms dry_run = os.environ.get("DATA_ASK_DRY_RUN") == "1" diff --git a/netutil.py b/palmer/netutil.py similarity index 100% rename from netutil.py rename to palmer/netutil.py diff --git a/onboard.py b/palmer/onboard.py similarity index 96% rename from onboard.py rename to palmer/onboard.py index 78ebf9f..1287486 100644 --- a/onboard.py +++ b/palmer/onboard.py @@ -72,7 +72,7 @@ def start(phone: str) -> str | None: if not app_url: return None try: - from home import home_token, load, save + from palmer.home import home_token, load, save token = home_token(phone) if load(token) is None: save(token, {"phone": phone, "setup_pending": True}) @@ -91,9 +91,9 @@ def apply(token: str, payload: dict, form: dict) -> bool: an onboarded list to add to. The eager page build there is skipped — the parked stub is still on the token at that point — and `_build_async` below is the one build.""" - from home import save - from userprofile import _apply_profile_updates - from db import get_profile + from palmer.home import save + from palmer.userprofile import _apply_profile_updates + from palmer.db import get_profile phone = payload.get("phone") if not phone or not payload.get("setup_pending"): @@ -111,7 +111,7 @@ def apply(token: str, payload: dict, form: dict) -> bool: topics = _topics_for(city, follows) updates: dict = {"morning_topics": topics, "morning_onboarded": True, "morning_enabled": mornings, "setup_done": True} - from db import upsert_profile + from palmer.db import upsert_profile upsert_profile(phone, updates) # name/city go through _apply_profile_updates rather than a direct write so @@ -133,7 +133,7 @@ def _topics_for(city: str, follows: str) -> list[str]: is the whole point of collecting it here — the same call made from `update_morning_briefing`'s dispatch runs before the extractor has written a city and so silently seeds national news alone.""" - from morning import default_topics + from palmer.morning import default_topics topics = list(default_topics(city or None)) for item in split_follows(follows): if not any(item.lower() == t.lower() for t in topics): @@ -151,7 +151,7 @@ def split_follows(text: str) -> list[str]: if not item: continue try: - from agent import _normalize_price_topic + from palmer.agent import _normalize_price_topic item = _normalize_price_topic(item) except Exception: pass @@ -169,7 +169,7 @@ def _build_async(phone: str) -> None: `built_at` appears.""" def _run(): try: - from home import rebuild + from palmer.home import rebuild rebuild(phone, refresh_news=True) except Exception as e: print(f"onboard build failed for {phone}: {type(e).__name__}: {e}") @@ -205,7 +205,7 @@ def _run(): def render_setup(token: str, *, action: str) -> str: """The form. No JS, no external requests — same constraints as page.py, because it opens on a phone over a cell connection and nowhere else.""" - from page import CSS + from palmer.page import CSS e = lambda v: html.escape(str(v), quote=True) # noqa: E731 return ( '' @@ -246,7 +246,7 @@ def render_building(page_url: str) -> str: """Shown between submit and the first real payload. Refreshes itself rather than making the user tap anything — the build is seconds away and the point of the flow is that the page appears without further work.""" - from page import CSS + from palmer.page import CSS e = lambda v: html.escape(str(v), quote=True) # noqa: E731 return ( '' diff --git a/opening.py b/palmer/opening.py similarity index 98% rename from opening.py rename to palmer/opening.py index ee76e4d..62b1895 100644 --- a/opening.py +++ b/palmer/opening.py @@ -41,8 +41,8 @@ import urllib.parse from datetime import date, datetime, timedelta -from llm import client, HAIKU_MODEL, _parse_json -from netutil import _http_get_json +from palmer.llm import client, HAIKU_MODEL, _parse_json +from palmer.netutil import _http_get_json TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "") TICKETMASTER_API_KEY = os.environ.get("TICKETMASTER_API_KEY", "") @@ -182,8 +182,8 @@ def _local_candidates(metro: str) -> list[dict]: trusted_sources.json specifically so this search can pass trusted_only and still return something; before they were added it returned nothing at all. """ - from datafeeds import _search_raw - from sources import canonical_domain + from palmer.datafeeds import _search_raw + from palmer.sources import canonical_domain out = [] for query in (f"new restaurant openings {metro}", f"festivals and events happening in {metro} this week"): @@ -509,7 +509,7 @@ def _first_clause(text: str, limit: int = 90) -> str: def _source_of(url: str) -> str: - from sources import canonical_domain + from palmer.sources import canonical_domain return canonical_domain(url) if url else "" @@ -538,13 +538,13 @@ def opening_snapshot(profile: dict) -> list[dict]: if not kinds: return [] # they removed every kind — same as switching it off try: - from weather import _geocode + from palmer.weather import _geocode lat, lon, _resolved = _geocode(city) except Exception as e: print(f"opening: geocode failed for {city!r}: {type(e).__name__}: {e}") return [] - from timeutil import local_today + from palmer.timeutil import local_today today = local_today(profile.get("timezone")) bucket = _bucket(lat, lon) week = _week_key(today) @@ -631,7 +631,7 @@ def opening_snapshot(profile: dict) -> list[dict]: # about which kinds of discovery you want, and a followed show is not # discovery. Unfollowing is its control, not opening_remove. try: - from shows import episode_rows + from palmer.shows import episode_rows episodes = episode_rows(profile, today)[:MAX_EPISODES] except Exception as e: print(f"opening: episode rows unavailable: {type(e).__name__}: {e}") diff --git a/page.py b/palmer/page.py similarity index 99% rename from page.py rename to palmer/page.py index 1191cf0..ccefddd 100644 --- a/page.py +++ b/palmer/page.py @@ -16,7 +16,7 @@ import os from urllib.parse import quote -from timeutil import friendly_hhmm +from palmer.timeutil import friendly_hhmm # A newspaper page, not a dashboard: flat paper white, ink-black type, thin # hairline rules instead of glass panels. Color is rationed to the two places @@ -141,7 +141,7 @@ def _local_day(tz_name: str | None) -> str: A user west of UTC with no zone on file saw tomorrow's date on their own page from 5pm. The subhead drops an empty string, so the honest form here is simply to say nothing.""" - from timeutil import valid_zone, local_now + from palmer.timeutil import valid_zone, local_now if not valid_zone(tz_name): return "" try: @@ -174,7 +174,7 @@ def _price_link(p: dict) -> str: # last row when those were raised. Same reasoning as home importing # cards.MAX_PRICES — the page and the producer render from one payload and must # not disagree about how much of it survives. -from opening import MAX_ROWS as OPENING_ROW_CAP +from palmer.opening import MAX_ROWS as OPENING_ROW_CAP WATCH_CHIP_CAP = 4 PWATCH_CHIP_CAP = 4 TOPIC_CHIP_CAP = 6 @@ -392,7 +392,7 @@ def render(payload: dict, *, token: str, image_url: str, page_url: str) -> str: # Yesterday's result and today's game, one row per followed team. The # same rows the morning and evening texts are drafted from; a team # with nothing on either day is simply absent (home._fetch_scores). - from sports import result_line + from palmer.sports import result_line sec = ['
Scores' f'{e(_ago(fetched.get("scores")))}
'] for row in scores: diff --git a/price_alert.py b/palmer/price_alert.py similarity index 97% rename from price_alert.py rename to palmer/price_alert.py index 1ae0d29..c2c9a0f 100644 --- a/price_alert.py +++ b/palmer/price_alert.py @@ -11,9 +11,9 @@ """ from __future__ import annotations -from agent import _build_system, base_system -from llm import client, SONNET_MODEL -from smstext import _sms_clean +from palmer.agent import _build_system, base_system +from palmer.llm import client, SONNET_MODEL +from palmer.smstext import _sms_clean MAX_CHARS = 180 diff --git a/prompts.py b/palmer/prompts.py similarity index 100% rename from prompts.py rename to palmer/prompts.py diff --git a/send_reminders.py b/palmer/reminders.py similarity index 92% rename from send_reminders.py rename to palmer/reminders.py index b924c55..ae6ceb9 100644 --- a/send_reminders.py +++ b/palmer/reminders.py @@ -1,10 +1,10 @@ from dotenv import load_dotenv load_dotenv() -from db import claim_due_reminders, save_message, get_profile -from agent import _build_system -from llm import client, SONNET_MODEL -from smstext import _sms_clean +from palmer.db import claim_due_reminders, save_message, get_profile +from palmer.agent import _build_system +from palmer.llm import client, SONNET_MODEL +from palmer.smstext import _sms_clean def _personalize_reminder(phone: str, text: str, profile: dict) -> str: @@ -49,9 +49,9 @@ def send_due_reminders(): firing in the same minute, which is a within-tick problem, so the guard is scoped to the tick. """ - from sms_util import send_sms - from db import rearm_reminder, _similar_reminder_text, _parse_due - from timeutil import next_occurrence + from palmer.sms_util import send_sms + from palmer.db import rearm_reminder, _similar_reminder_text, _parse_due + from palmer.timeutil import next_occurrence reminders = claim_due_reminders() if not reminders: diff --git a/rubrics.py b/palmer/rubrics.py similarity index 99% rename from rubrics.py rename to palmer/rubrics.py index 2788c3f..4a90703 100644 --- a/rubrics.py +++ b/palmer/rubrics.py @@ -16,7 +16,7 @@ import threading -from llm import client, HAIKU_MODEL +from palmer.llm import client, HAIKU_MODEL VALID_GENRES = ( diff --git a/scorewatch.py b/palmer/scorewatch.py similarity index 96% rename from scorewatch.py rename to palmer/scorewatch.py index c44084e..25e33e0 100644 --- a/scorewatch.py +++ b/palmer/scorewatch.py @@ -27,8 +27,8 @@ """ from __future__ import annotations -import sports -from db import get_all_profiles, get_game_alert, record_game_alert +from palmer import sports +from palmer.db import get_all_profiles, get_game_alert, record_game_alert # Leagues seen with a live game last tick, so the next tick knows how hard to # look. Process-local and safe for the same reason every other cache here is: @@ -46,9 +46,9 @@ def _draft(phone: str, game: dict, team: dict, reason: str, prev: dict | None = """The alert, in Palmer's voice. Falls back to the plain line.""" plain = _plain(game, team, reason) try: - from agent import _build_system - from llm import client, SONNET_MODEL - from smstext import _sms_clean + from palmer.agent import _build_system + from palmer.llm import client, SONNET_MODEL + from palmer.smstext import _sms_clean side = sports.side_of(game, team["abbrev"]) other = "away" if side == "home" else "home" mine, theirs = game[side]["score"], game[other]["score"] @@ -110,7 +110,7 @@ def _plain(game: dict, team: dict, reason: str) -> str: def run_score_alerts() -> None: """One pass over every team someone asked live texts for. Never raises.""" - from sms_util import send_sms + from palmer.sms_util import send_sms try: profiles = [(p, prof) for p, prof in get_all_profiles() if live_teams(prof)] except Exception as e: diff --git a/serpapi.py b/palmer/serpapi.py similarity index 95% rename from serpapi.py rename to palmer/serpapi.py index ac9295e..fdb4888 100644 --- a/serpapi.py +++ b/palmer/serpapi.py @@ -11,7 +11,7 @@ import os import urllib.parse -from netutil import _http_get_json +from palmer.netutil import _http_get_json API_KEY = os.environ.get("SERP_API_KEY", "") BASE = "https://serpapi.com/search.json" diff --git a/shopping.py b/palmer/shopping.py similarity index 97% rename from shopping.py rename to palmer/shopping.py index 833c4c9..b87bd79 100644 --- a/shopping.py +++ b/palmer/shopping.py @@ -16,8 +16,8 @@ import urllib.parse from datetime import datetime, timezone -import serpapi -from llm import client, HAIKU_MODEL +from palmer import serpapi +from palmer.llm import client, HAIKU_MODEL # Materiality is a flat dollar amount, in EITHER direction: any move of more # than this much is worth a text, on any product at any price. Deliberately not @@ -380,7 +380,7 @@ def _daily_ok(watch: dict, today: str | None = None) -> bool: def _draft_alert(product_name: str, current: dict, watch: dict, reason: str) -> str: """Google Shopping price hit. Shared drafter; no link — Google Shopping URLs are aggregator redirects, not permalinks worth texting.""" - from price_alert import draft_price_alert + from palmer.price_alert import draft_price_alert return draft_price_alert(product_name, current, watch, reason) @@ -388,22 +388,22 @@ def run_price_watches(): """Scheduler job, twice daily on a cron (see main.py): check each active price watch, alert on target-hit or any move over MOVE_MIN_ABS from baseline. Silent-skip on any per-watch failure. Dispatches to shopping (Google Shopping) or amazon (Amazon by ASIN) based on w['source'].""" - from userprofile import _is_duplicate_subject - from db import ( + from palmer.userprofile import _is_duplicate_subject + from palmer.db import ( get_active_price_watches, set_price_watch_baseline, update_price_watch_alerted, claim_price_watch_alert, release_price_watch_claim, ) - from sms_util import send_sms - from db import save_message - import amazon + from palmer.sms_util import send_sms + from palmer.db import save_message + from palmer import amazon watches = get_active_price_watches() # One batched read for every profile, not one per watch — _conn() opens a # fresh connection per call, so the inline version is N+1 per tick. days = {} try: - from db import get_all_profiles - from timeutil import local_today + from palmer.db import get_all_profiles + from palmer.timeutil import local_today days = {phone: local_today((profile or {}).get("timezone")).isoformat() for phone, profile in get_all_profiles()} except Exception as e: diff --git a/shows.py b/palmer/shows.py similarity index 100% rename from shows.py rename to palmer/shows.py diff --git a/sms_util.py b/palmer/sms_util.py similarity index 97% rename from sms_util.py rename to palmer/sms_util.py index ddfa160..5883e19 100644 --- a/sms_util.py +++ b/palmer/sms_util.py @@ -2,7 +2,7 @@ from twilio.rest import Client as TwilioClient -from smstext import URL_RE, _sms_clean, shorten_message, truncate_preserving_urls +from palmer.smstext import URL_RE, _sms_clean, shorten_message, truncate_preserving_urls _twilio = TwilioClient(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"]) _APP_URL = os.environ.get("APP_URL", "").rstrip("/") @@ -58,7 +58,7 @@ def send_sms(to: str, body: str, *, add_status_callback: bool = True, media_url: # what it said, silently, is what it was trying to do. Checked here because # this is the one function every outbound message passes through, and the # guard it replaces lived in morning.py where four other senders never saw it. - from guards import leaks_deliberation + from palmer.guards import leaks_deliberation if body and leaks_deliberation(body): print(f"BLOCKED internal deliberation to {to}: {body[:110]!r}") return False diff --git a/smstext.py b/palmer/smstext.py similarity index 99% rename from smstext.py rename to palmer/smstext.py index 915a27d..0006291 100644 --- a/smstext.py +++ b/palmer/smstext.py @@ -12,7 +12,7 @@ import re from datetime import datetime, timezone -from llm import client, HAIKU_MODEL +from palmer.llm import client, HAIKU_MODEL _UNICODE_MAP = str.maketrans({ diff --git a/sources.py b/palmer/sources.py similarity index 100% rename from sources.py rename to palmer/sources.py diff --git a/sports.py b/palmer/sports.py similarity index 100% rename from sports.py rename to palmer/sports.py diff --git a/tapback.py b/palmer/tapback.py similarity index 99% rename from tapback.py rename to palmer/tapback.py index 49a92c6..01e77bf 100644 --- a/tapback.py +++ b/palmer/tapback.py @@ -27,8 +27,8 @@ import re from datetime import datetime, timezone -from llm import client, HAIKU_MODEL, _parse_json -from db import get_profile, upsert_profile +from palmer.llm import client, HAIKU_MODEL, _parse_json +from palmer.db import get_profile, upsert_profile MAX_STORED_REACTIONS = 10 # Fold reactions into communication_style once this many have piled up since diff --git a/tickers.py b/palmer/tickers.py similarity index 99% rename from tickers.py rename to palmer/tickers.py index a85fa71..5b71305 100644 --- a/tickers.py +++ b/palmer/tickers.py @@ -138,7 +138,7 @@ def resolve_topic_asset(topic: str) -> tuple[str, str] | None: """(symbol, display_label) for a topic that is asking for a price, else None. Free and deterministic — safe to call on every page view.""" - from datafeeds import _CRYPTO_IDS + from palmer.datafeeds import _CRYPTO_IDS if not topic: return None low = topic.lower() @@ -229,7 +229,7 @@ def search_symbol(name: str) -> str | None: unresolved topic costs one missing price row, which is cheap and visible, where a wrong symbol is silent.""" import urllib.parse - from netutil import _http_get_json + from palmer.netutil import _http_get_json query = _search_query(name) if not query: return None diff --git a/timeutil.py b/palmer/timeutil.py similarity index 100% rename from timeutil.py rename to palmer/timeutil.py diff --git a/tools_def.py b/palmer/tools_def.py similarity index 100% rename from tools_def.py rename to palmer/tools_def.py diff --git a/traffic.py b/palmer/traffic.py similarity index 99% rename from traffic.py rename to palmer/traffic.py index d4deb8b..426b855 100644 --- a/traffic.py +++ b/palmer/traffic.py @@ -14,8 +14,8 @@ import urllib.parse from datetime import datetime, timedelta, timezone -from llm import client, HAIKU_MODEL -from netutil import _http_get_json +from palmer.llm import client, HAIKU_MODEL +from palmer.netutil import _http_get_json TOMTOM_API_KEY = os.environ.get("TOMTOM_API_KEY", "") _TOMTOM_BASE = "https://api.tomtom.com" @@ -342,7 +342,7 @@ def traffic_snapshot(origin: str, destination: str, *, delay_s = max(live - free, 0) else: delay_s = summary.get("trafficDelayInSeconds") or 0 - from timeutil import local_now, valid_zone + from palmer.timeutil import local_now, valid_zone # The arrival is a clock on the reader's wall. With no resolvable zone # there is no such clock — local_now would hand back UTC and the digest # would say "arriving about 15:42" to someone at 8am — so the field is diff --git a/trends.py b/palmer/trends.py similarity index 98% rename from trends.py rename to palmer/trends.py index f896535..061b2a4 100644 --- a/trends.py +++ b/palmer/trends.py @@ -18,9 +18,9 @@ import threading from datetime import date -import serpapi -from llm import client, HAIKU_MODEL, _parse_json -from datafeeds import _search_raw +from palmer import serpapi +from palmer.llm import client, HAIKU_MODEL, _parse_json +from palmer.datafeeds import _search_raw # Trending is identical for every user in a geo, so one fetch serves the whole # morning run. Keyed by (geo, local date) — same pattern as rubrics.py. diff --git a/trusted_sources.json b/palmer/trusted_sources.json similarity index 100% rename from trusted_sources.json rename to palmer/trusted_sources.json diff --git a/userprofile.py b/palmer/userprofile.py similarity index 98% rename from userprofile.py rename to palmer/userprofile.py index 7b0a7bd..84af79e 100644 --- a/userprofile.py +++ b/palmer/userprofile.py @@ -5,11 +5,11 @@ """ import json -from db import ( +from palmer.db import ( get_profile, upsert_profile, get_message_count, get_older_messages, HISTORY_LIMIT, ) -from llm import client, HAIKU_MODEL, _parse_json -from prompts import EXTRACT_PROMPT, CONSOLIDATE_PROMPT +from palmer.llm import client, HAIKU_MODEL, _parse_json +from palmer.prompts import EXTRACT_PROMPT, CONSOLIDATE_PROMPT # Canonical profile schema. Everything reads these keys; aliases are normalized on write. @@ -201,7 +201,7 @@ def _eager_build_home(phone: str) -> None: if not os.environ.get("APP_URL"): return # nowhere to serve the page; don't spend on a link nobody can open try: - from home import rebuild, load, home_token + from palmer.home import rebuild, load, home_token token = home_token(phone) existing = load(token) # A parked setup form (onboard.start) is not a page: someone who got @@ -219,7 +219,7 @@ def _stamp_volatile(profile: dict, updates: dict) -> None: Merged into `updates` so it rides the same write — a separate upsert would open a window where the value is new and its date is not.""" - from timeutil import local_today + from palmer.timeutil import local_today touched = [k for k in updates if k in VOLATILE_FIELDS and updates[k] is not None] if not touched: return @@ -274,7 +274,7 @@ def _apply_profile_updates(phone: str, profile: dict, updates: dict) -> dict: if not updates: return profile updates = _canonical_updates(updates) - from timeutil import valid_zone + from palmer.timeutil import valid_zone # 1. The extractor. `timezone` is named in EXTRACT_PROMPT's schema, so Haiku # can write any string it likes — "Pacific Time", "PST", a guess. Only @@ -341,7 +341,7 @@ def _seed_local_topic(phone: str, city: str) -> None: profile = get_profile(phone) if not profile.get("morning_onboarded"): return - from morning import default_topics + from palmer.morning import default_topics national = set(default_topics(None)) local = [t for t in default_topics(city) if t not in national] topics = list(profile.get("morning_topics") or []) @@ -439,7 +439,7 @@ def _user_already_covered(phone: str, candidate: str, window_hours: float = 12) suppresses a legitimate alert. Called after the topical significance gates already passed, so cost is bounded to the small set of would-be sends.""" from datetime import datetime, timezone, timedelta - from db import get_recent_user_messages + from palmer.db import get_recent_user_messages cutoff = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).isoformat() recent = get_recent_user_messages(phone, cutoff) @@ -533,14 +533,14 @@ def _is_duplicate_subject(phone: str, new_text: str, window_hours: float = 6) -> that per-job cooldowns can't see, since each job only checks its own history. Fails open (False) on any error so a broken check never blocks a real send.""" from datetime import datetime, timezone, timedelta - from db import get_recent_assistant_messages + from palmer.db import get_recent_assistant_messages # A verbatim repeat needs no model and no six-hour window. One user got the # identical followup twice — "yo how'd practice look today? hurts moving # like they said?" — because the followup job runs every four hours, its # subject stayed live for days, and this check only ever looked back six. # The lexical pass is free, so it looks back much further. - from guards import near_duplicate + from palmer.guards import near_duplicate wide_cutoff = (datetime.now(timezone.utc) - timedelta(hours=max(window_hours, VERBATIM_WINDOW_HOURS))).isoformat() wide = get_recent_assistant_messages(phone, wide_cutoff) diff --git a/watches.py b/palmer/watches.py similarity index 96% rename from watches.py rename to palmer/watches.py index cad0a32..21e837e 100644 --- a/watches.py +++ b/palmer/watches.py @@ -2,17 +2,17 @@ import urllib.error from datetime import datetime, timezone, timedelta, date as _date -from agent import _build_system, base_system -from llm import client, HAIKU_MODEL, SONNET_MODEL -from smstext import _sms_clean, URL_RE -from datafeeds import _search_raw -from sources import source_tier, canonical_domain, corroborated -from userprofile import _is_duplicate_subject, _user_already_covered -from db import ( +from palmer.agent import _build_system, base_system +from palmer.llm import client, HAIKU_MODEL, SONNET_MODEL +from palmer.smstext import _sms_clean, URL_RE +from palmer.datafeeds import _search_raw +from palmer.sources import source_tier, canonical_domain, corroborated +from palmer.userprofile import _is_duplicate_subject, _user_already_covered +from palmer.db import ( get_active_watches, update_watch_alerted, get_messages_after, claim_watch_alert, set_watch_genre, update_watch_story, get_profile, ) -from rubrics import classify_genre, rubric_for +from palmer.rubrics import classify_genre, rubric_for DAILY_ALERT_MAX = 4 @@ -244,8 +244,8 @@ def _draft_alert(phone: str, watch: dict, top: dict, fallback: str) -> str: def run_watches(): """Check all active watches and send alerts if triggered. Called every 30 minutes by APScheduler.""" - from sms_util import send_sms - from db import save_message + from palmer.sms_util import send_sms + from palmer.db import save_message watches = get_active_watches() now = datetime.now(timezone.utc) @@ -253,8 +253,8 @@ def run_watches(): # One profile read per USER, not per watch. get_profile opens a fresh DB # connection per call and this loop covers every watch for every user, so # doing it inline cost N connections a tick for N watches. - from tapback import pacing_factor - from timeutil import local_today + from palmer.tapback import pacing_factor + from palmer.timeutil import local_today caps = {} days = {} for phone in {w["phone"] for w in watches}: diff --git a/weather.py b/palmer/weather.py similarity index 99% rename from weather.py rename to palmer/weather.py index 372c868..58fec2e 100644 --- a/weather.py +++ b/palmer/weather.py @@ -2,8 +2,8 @@ import os from datetime import datetime, timedelta, timezone, date as _date -from netutil import _http_get_json_retry -from timeutil import resolve_day_delta +from palmer.netutil import _http_get_json_retry +from palmer.timeutil import resolve_day_delta _WMO_DESCRIPTIONS = { @@ -214,7 +214,7 @@ def _grid_now(series: dict) -> float | None: def _grid_max_today(series: dict, tz: str | None) -> float | None: """Largest gridpoint value starting on the user's local today.""" - from timeutil import local_today as _lt + from palmer.timeutil import local_today as _lt target = _lt(tz) vals = [] for v in (series or {}).get("values") or []: @@ -331,7 +331,7 @@ def _hour0() -> dict | None: delta = 0 if delta < 0: raise ValueError("Past date") - from timeutil import valid_zone, local_today as _lt + from palmer.timeutil import valid_zone, local_today as _lt # Anchor on the READER's calendar. delta was computed in the user's zone and # then added to the FORECAST LOCATION's day, so asking from Los Angeles at # 10pm about New York landed a day off. With no zone on file there is no @@ -517,7 +517,7 @@ def _proven_source(city: str) -> str | None: import here would be a cycle. Never raises — an audit that cannot answer must not cost anyone their forecast.""" try: - from wxaudit import best_source + from palmer.wxaudit import best_source return best_source(city) except Exception as e: print(f"weather: source selection unavailable for {city!r}: {type(e).__name__}: {e}") @@ -649,7 +649,7 @@ def _gust_str(gust_max, wind_max) -> str: delta = 0 if delta < 0 or delta >= len(daily["time"]): return f"No forecast available for that date in {resolved} — forecast covers 8 days out." - from timeutil import local_today as _lt + from palmer.timeutil import local_today as _lt target_date = _lt(tz) + timedelta(days=delta) hi = daily["temperature_2m_max"][delta] lo = daily["temperature_2m_min"][delta] diff --git a/wxaudit.py b/palmer/wxaudit.py similarity index 96% rename from wxaudit.py rename to palmer/wxaudit.py index 7273979..661c750 100644 --- a/wxaudit.py +++ b/palmer/wxaudit.py @@ -24,7 +24,7 @@ import urllib.request from datetime import datetime, timedelta, timezone -from db import get_all_profiles, record_forecast, record_actual, pending_actuals +from palmer.db import get_all_profiles, record_forecast, record_actual, pending_actuals ARCHIVE = "https://archive-api.open-meteo.com/v1/archive" FORECAST = "https://api.open-meteo.com/v1/forecast" @@ -50,7 +50,7 @@ def _get(url: str, params: dict) -> dict | None: def _cities() -> dict[str, tuple[float, float]]: """Every distinct city Palmer serves, with coordinates. One geocode each — weather._geocode caches, so this is usually free.""" - from weather import _geocode + from palmer.weather import _geocode out: dict[str, tuple[float, float]] = {} for _phone, profile in get_all_profiles(): city = (profile or {}).get("city") @@ -65,7 +65,7 @@ def _cities() -> dict[str, tuple[float, float]]: def _log_todays_forecasts(city: str, lat: float, lon: float, today: str) -> None: - from weather import weather_snapshot, _is_us_coords + from palmer.weather import weather_snapshot, _is_us_coords if _is_us_coords(lat, lon): try: snap = weather_snapshot(city) @@ -161,7 +161,7 @@ def best_source(city: str, incumbent: str = "nws", today=None) -> str | None: choice = None try: - from db import forecast_scores + from palmer.db import forecast_scores rows = [r for r in forecast_scores(LOOKBACK_DAYS) if (r.get("city") or "").strip().lower() == key[0] and (r.get("n") or 0) >= MIN_SAMPLES and r.get("mae") is not None] @@ -187,7 +187,7 @@ def _clear_best_cache() -> None: def report(days: int = 30) -> str: """Human-readable scoreboard. Run with `python wxaudit.py`.""" - from db import forecast_scores + from palmer.db import forecast_scores rows = forecast_scores(days) if not rows: return "No scored forecasts yet — actuals lag reanalysis by a day or two." diff --git a/ruff.toml b/pyproject.toml similarity index 55% rename from ruff.toml rename to pyproject.toml index b4840f6..730d928 100644 --- a/ruff.toml +++ b/pyproject.toml @@ -1,7 +1,15 @@ +# Tool configuration only. Palmer is deployed from requirements.txt (Heroku) +# and run from the repo root; it is not built or published as a package. + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] line-length = 100 target-version = "py311" -[lint] +[tool.ruff.lint] select = ["E", "F", "W"] ignore = [ "E501", # long lines are mostly prose in docstrings and prompts diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrate_profile_prune.py b/scripts/migrate_profile_prune.py similarity index 96% rename from migrate_profile_prune.py rename to scripts/migrate_profile_prune.py index 2a84159..f6ca93e 100644 --- a/migrate_profile_prune.py +++ b/scripts/migrate_profile_prune.py @@ -37,9 +37,9 @@ import argparse import json -from db import get_all_profiles, upsert_profile, get_profile -from llm import client, SONNET_MODEL, _parse_json -from userprofile import PROFILE_FIELDS, prune_profile +from palmer.db import get_all_profiles, upsert_profile, get_profile +from palmer.llm import client, SONNET_MODEL, _parse_json +from palmer.userprofile import PROFILE_FIELDS, prune_profile # Fields the consolidation pass is allowed to rewrite. Deliberately excludes # scheduling and bookkeeping keys — folding stray notes must never move someone's @@ -99,7 +99,7 @@ def _clean_topics(profile: dict) -> dict: now, but they still cost prompt tokens and still read as instructions. A route is promoted to the structured `commute` field rather than dropped. """ - from morning import _is_directive, _parse_commute_topic + from palmer.morning import _is_directive, _parse_commute_topic topics = profile.get("morning_topics") or [] if not topics: return {} diff --git a/preview_opening.py b/scripts/preview_opening.py similarity index 94% rename from preview_opening.py rename to scripts/preview_opening.py index fcf08ee..0cce1d6 100644 --- a/preview_opening.py +++ b/scripts/preview_opening.py @@ -14,11 +14,11 @@ """ import sys -from opening import opening_snapshot, _clear_caches, TMDB_API_KEY, TICKETMASTER_API_KEY +from palmer.opening import opening_snapshot, _clear_caches, TMDB_API_KEY, TICKETMASTER_API_KEY def _cities_on_file() -> list[str]: - from db import get_all_profiles + from palmer.db import get_all_profiles seen = [] for _phone, profile in get_all_profiles(): city = (profile or {}).get("city") diff --git a/scripts/send_morning.py b/scripts/send_morning.py new file mode 100644 index 0000000..7fa094c --- /dev/null +++ b/scripts/send_morning.py @@ -0,0 +1,15 @@ +"""Trigger the morning job once: `python -m scripts.send_morning`. + +Respects each user's send window and the per-day guard, so running it by hand +cannot double-send.""" +from dotenv import load_dotenv + + +def main() -> None: + load_dotenv() + from palmer.morning import send_morning_messages + send_morning_messages() + + +if __name__ == "__main__": + main() diff --git a/scripts/send_reminders.py b/scripts/send_reminders.py new file mode 100644 index 0000000..a23f2a1 --- /dev/null +++ b/scripts/send_reminders.py @@ -0,0 +1,12 @@ +"""Deliver due reminders once: `python -m scripts.send_reminders`.""" +from dotenv import load_dotenv + + +def main() -> None: + load_dotenv() + from palmer.reminders import send_due_reminders + send_due_reminders() + + +if __name__ == "__main__": + main() diff --git a/send_morning.py b/send_morning.py deleted file mode 100644 index 881a2a7..0000000 --- a/send_morning.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Trigger the morning job once. Respects each user's send window and the -per-day guard, so running it by hand cannot double-send.""" -from dotenv import load_dotenv - - -def main() -> None: - load_dotenv() - from morning import send_morning_messages - send_morning_messages() - - -if __name__ == "__main__": - main() diff --git a/test_subject_dedup.py b/test_subject_dedup.py deleted file mode 100644 index 2a77236..0000000 --- a/test_subject_dedup.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Tests for cross-job subject-dedup: userprofile._is_duplicate_subject and its wiring -into watches.py and followup.py. Pure logic + mocked LLM/DB — no real network -or LLM calls. Run: pytest test_subject_dedup.py""" -from dotenv import load_dotenv -load_dotenv() - -from unittest.mock import patch, MagicMock - -import userprofile -import watches -import followup - - -def _haiku_response(text: str) -> MagicMock: - resp = MagicMock() - resp.content = [MagicMock(text=text)] - return resp - - -class TestIsDuplicateSubject: - def test_no_recent_messages_returns_false_without_llm_call(self): - with patch("db.get_recent_assistant_messages", return_value=[]), \ - patch("userprofile.client") as mock_client: - assert userprofile._is_duplicate_subject("+15551234567", "new message") is False - mock_client.messages.create.assert_not_called() - - def test_haiku_says_yes_returns_true(self): - with patch("db.get_recent_assistant_messages", return_value=["Hurts practice update"]), \ - patch("userprofile.client") as mock_client: - mock_client.messages.create.return_value = _haiku_response("YES") - assert userprofile._is_duplicate_subject("+15551234567", "Hurts camp footage") is True - - def test_haiku_says_no_returns_false(self): - with patch("db.get_recent_assistant_messages", return_value=["weather update"]), \ - patch("userprofile.client") as mock_client: - mock_client.messages.create.return_value = _haiku_response("NO") - assert userprofile._is_duplicate_subject("+15551234567", "bitcoin price") is False - - def test_llm_failure_fails_open(self): - with patch("db.get_recent_assistant_messages", return_value=["something"]), \ - patch("userprofile.client") as mock_client: - mock_client.messages.create.side_effect = Exception("API down") - assert userprofile._is_duplicate_subject("+15551234567", "new message") is False - - -def _watch(**overrides): - base = { - "id": 1, "phone": "+15551234567", "description": "Eagles Hurts injury", - "queries": ["Jalen Hurts injury"], "cooldown_hours": 4, - "last_alerted": None, "last_alert_summary": None, - "daily_alert_count": 0, "daily_alert_date": None, "recent_summaries": [], - } - base.update(overrides) - return base - - -_RAW_RESULTS = [ - {"title": "Hurts injury update", "url": "https://example1.com/a", - "content": "details", "published_date": "2026-08-13", "score": 1.0}, - {"title": "Hurts injury update 2", "url": "https://example2.com/b", - "content": "more details", "published_date": "2026-08-13", "score": 1.0}, -] - - -class TestWatchesSubjectDedup: - def test_skips_send_when_duplicate_subject(self): - with patch("watches.get_active_watches", return_value=[_watch()]), \ - patch("watches._search_raw", return_value=_RAW_RESULTS), \ - patch("watches._check_watch_hit", return_value=True), \ - patch("watches._best_result", return_value=_RAW_RESULTS[0]), \ - patch("watches._is_duplicate_subject", return_value=True) as dedup, \ - patch("watches.claim_watch_alert") as mock_claim, \ - patch("watches.update_watch_alerted") as mock_update, \ - patch("sms_util.send_sms") as mock_send: - watches.run_watches() - dedup.assert_called_once() - mock_claim.assert_not_called() - mock_send.assert_not_called() - mock_update.assert_not_called() - - def test_sends_normally_when_not_duplicate(self): - with patch("watches.get_active_watches", return_value=[_watch()]), \ - patch("watches._search_raw", return_value=_RAW_RESULTS), \ - patch("watches._check_watch_hit", return_value=True), \ - patch("watches._best_result", return_value=_RAW_RESULTS[0]), \ - patch("watches._is_duplicate_subject", return_value=False), \ - patch("watches.claim_watch_alert", return_value=True), \ - patch("watches._draft_alert", return_value="Hurts is out. https://example.com/a"), \ - patch("watches.update_watch_alerted") as mock_update, \ - patch("sms_util.send_sms") as mock_send: - watches.run_watches() - mock_send.assert_called_once() - mock_update.assert_called_once() - # What goes out is the drafted line, not the raw headline. - assert mock_send.call_args[0][1] == "Hurts is out. https://example.com/a" - - def test_alert_persists_the_fired_url_and_domain(self): - """The 'Watching' page section links a watch to the article - that fired it — update_watch_alerted must be called with that URL/domain, - not just the dedup title.""" - with patch("watches.get_active_watches", return_value=[_watch()]), \ - patch("watches._search_raw", return_value=_RAW_RESULTS), \ - patch("watches._check_watch_hit", return_value=True), \ - patch("watches._best_result", return_value=_RAW_RESULTS[0]), \ - patch("watches._is_duplicate_subject", return_value=False), \ - patch("watches.claim_watch_alert", return_value=True), \ - patch("watches._draft_alert", return_value="drafted line https://example.com/a"), \ - patch("watches.update_watch_alerted") as mock_update, \ - patch("sms_util.send_sms"): - watches.run_watches() - _, kwargs = mock_update.call_args - assert kwargs["url"] == "https://example1.com/a" - assert kwargs["domain"] == "example1.com" - - -class TestFollowupSubjectDedup: - def _profile(self): - return {"morning_onboarded": True, "timezone": "America/Chicago", - "ongoing_threads": ["a big life event"]} - - def test_skips_send_and_releases_claim_when_duplicate(self): - with patch("followup.get_all_profiles", - return_value=[("+15551234567", self._profile())]), \ - patch("followup._should_send_followup", return_value=True), \ - patch("followup.claim_daily_guard", return_value=True), \ - patch("followup.get_history", return_value=[]), \ - patch("followup._candidates", return_value=[followup.Subject("thread", "a big life event")]), \ - patch("followup._pick_subject", return_value=followup.Subject("thread", "a big life event")), \ - patch("followup._draft_followup", return_value="hey how'd that go?"), \ - patch("followup._is_duplicate_subject", return_value=True) as dedup, \ - patch("followup.upsert_profile") as mock_upsert, \ - patch("followup._local_today") as mock_today, \ - patch("sms_util.send_sms") as mock_send: - mock_today.return_value.isoformat.return_value = "2026-08-13" - followup.run_followups() - dedup.assert_called_once() - mock_send.assert_not_called() - assert any( - c.args[1] == {"followup_sent_date": None} for c in mock_upsert.call_args_list - ), "expected the daily claim to be released" - - def test_sends_normally_when_not_duplicate(self): - with patch("followup.get_all_profiles", - return_value=[("+15551234567", self._profile())]), \ - patch("followup._should_send_followup", return_value=True), \ - patch("followup.claim_daily_guard", return_value=True), \ - patch("followup.get_history", return_value=[]), \ - patch("followup._candidates", return_value=[followup.Subject("thread", "a big life event")]), \ - patch("followup._pick_subject", return_value=followup.Subject("thread", "a big life event")), \ - patch("followup._draft_followup", return_value="hey how'd that go?"), \ - patch("followup._is_duplicate_subject", return_value=False), \ - patch("followup.save_message") as mock_save, \ - patch("followup._local_today") as mock_today, \ - patch("sms_util.send_sms") as mock_send: - mock_today.return_value.isoformat.return_value = "2026-08-13" - followup.run_followups() - mock_send.assert_called_once() - mock_save.assert_called_once() diff --git a/test_add_price_watch_tool.py b/tests/test_add_price_watch_tool.py similarity index 95% rename from test_add_price_watch_tool.py rename to tests/test_add_price_watch_tool.py index 3489242..59e9f24 100644 --- a/test_add_price_watch_tool.py +++ b/tests/test_add_price_watch_tool.py @@ -7,7 +7,7 @@ same as Amazon, so a bad match is visible immediately instead of silent.""" from unittest.mock import patch -import agent +from palmer import agent class _Block: @@ -36,7 +36,7 @@ def _create(**kw): patch.object(agent, "get_profile", return_value={"timezone": "America/Chicago"}), \ patch.object(agent, "save_price_watch", return_value=42) as save, \ patch.object(agent, "set_price_watch_baseline") as set_baseline, \ - patch("shopping.check_price", return_value=check_price_result), \ + patch("palmer.shopping.check_price", return_value=check_price_result), \ patch.object(agent.client.messages, "create", side_effect=_create): agent.get_reply("+1555", "track this for me") result = calls[1]["messages"][-1]["content"][0]["content"] @@ -66,7 +66,7 @@ class TestTheWatchNamesWhatItMatched: def test_the_matched_title_is_in_the_tool_result(self): import inspect - import agent + from palmer import agent block = inspect.getsource(agent.get_reply).split('"add_price_watch"')[1] \ .split("elif b.name")[0] assert 'current.get("title")' in block @@ -76,7 +76,7 @@ def test_the_model_is_told_to_say_it_out_loud(self): """A resolved thing named only in the tool result is still invisible to the person who can correct it.""" import inspect - import agent + from palmer import agent block = inspect.getsource(agent.get_reply).split('"add_price_watch"')[1] \ .split("elif b.name")[0] assert "correct you" in block diff --git a/test_amazon_watches.py b/tests/test_amazon_watches.py similarity index 76% rename from test_amazon_watches.py rename to tests/test_amazon_watches.py index 9c0dabf..c33d3ce 100644 --- a/test_amazon_watches.py +++ b/tests/test_amazon_watches.py @@ -5,8 +5,8 @@ from unittest.mock import patch, MagicMock -import amazon -import shopping +from palmer import amazon +from palmer import shopping class TestExtractPrice: @@ -42,16 +42,16 @@ def _payload(self, items): return {"organic_results": items} def test_missing_key_returns_empty(self): - with patch("serpapi.API_KEY", ""): + with patch("palmer.serpapi.API_KEY", ""): assert amazon._serpapi_search("protein") == [] def test_empty_query_returns_empty(self): - with patch("serpapi.API_KEY", "fake"): + with patch("palmer.serpapi.API_KEY", "fake"): assert amazon._serpapi_search("") == [] def test_http_failure_returns_empty(self): - with patch("serpapi.API_KEY", "fake"), \ - patch("serpapi._http_get_json", return_value=None): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=None): assert amazon._serpapi_search("protein") == [] def test_skips_items_without_asin(self): @@ -59,8 +59,8 @@ def test_skips_items_without_asin(self): {"title": "no asin here", "extracted_price": 20.0}, {"asin": "B01", "title": "has asin", "extracted_price": 25.0}, ]) - with patch("serpapi.API_KEY", "fake"), \ - patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): out = amazon._serpapi_search("thing") assert len(out) == 1 assert out[0]["asin"] == "B01" @@ -70,8 +70,8 @@ def test_skips_items_without_price(self): {"asin": "B01", "title": "priced", "extracted_price": 20.0}, {"asin": "B02", "title": "no price"}, ]) - with patch("serpapi.API_KEY", "fake"), \ - patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): out = amazon._serpapi_search("thing") assert [r["asin"] for r in out] == ["B01"] @@ -79,8 +79,8 @@ def test_falls_back_to_dp_url_when_missing_link(self): payload = self._payload([ {"asin": "B0ABC123", "title": "t", "extracted_price": 10.0}, ]) - with patch("serpapi.API_KEY", "fake"), \ - patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): out = amazon._serpapi_search("thing") assert out[0]["url"] == "https://www.amazon.com/dp/B0ABC123" @@ -103,23 +103,23 @@ def test_empty_candidates_returns_none(self): assert amazon._pick_best_match("whey", []) is None def test_picks_indexed_candidate(self): - with patch("amazon.client") as mock_client: + with patch("palmer.amazon.client") as mock_client: mock_client.messages.create.return_value = self._haiku_reply("0") out = amazon._pick_best_match("Optimum Nutrition 5lb", self._candidates()) assert out["asin"] == "B01" def test_none_reply_returns_none(self): - with patch("amazon.client") as mock_client: + with patch("palmer.amazon.client") as mock_client: mock_client.messages.create.return_value = self._haiku_reply("NONE") assert amazon._pick_best_match("random", self._candidates()) is None def test_out_of_range_index_returns_none(self): - with patch("amazon.client") as mock_client: + with patch("palmer.amazon.client") as mock_client: mock_client.messages.create.return_value = self._haiku_reply("99") assert amazon._pick_best_match("q", self._candidates()) is None def test_haiku_exception_returns_none(self): - with patch("amazon.client") as mock_client: + with patch("palmer.amazon.client") as mock_client: mock_client.messages.create.side_effect = RuntimeError("boom") assert amazon._pick_best_match("q", self._candidates()) is None @@ -151,23 +151,23 @@ def test_lowercase_asin_does_not_match(self): def test_a_co_short_url_follows_redirect(self): # Simulate a.co redirecting to the canonical amazon.com/dp/… URL - with patch("amazon._resolve_short_url", + with patch("palmer.amazon._resolve_short_url", return_value="https://www.amazon.com/dp/B0XYZ12345?ref=short"): assert amazon._extract_asin("https://a.co/d/08q64W9B") == "B0XYZ12345" def test_amzn_to_short_url_follows_redirect(self): - with patch("amazon._resolve_short_url", + with patch("palmer.amazon._resolve_short_url", return_value="https://www.amazon.com/gp/product/B0AMZTOAAA"): assert amazon._extract_asin("here: https://amzn.to/3abcXYZ") == "B0AMZTOAAA" def test_short_url_redirect_failure_returns_none(self): - with patch("amazon._resolve_short_url", return_value=None): + with patch("palmer.amazon._resolve_short_url", return_value=None): assert amazon._extract_asin("https://a.co/d/broken") is None def test_short_url_redirects_to_non_product_page(self): # a.co could resolve to an amazon.com homepage / cart / list URL — # if there's no /dp/ in the final URL, return None. - with patch("amazon._resolve_short_url", + with patch("palmer.amazon._resolve_short_url", return_value="https://www.amazon.com/hz/wishlist/ls/XYZ"): assert amazon._extract_asin("https://a.co/d/anythg") is None @@ -177,15 +177,15 @@ def _search_result(self): return [{"asin": "B0ABC", "title": "thing", "price": 42.0, "url": "https://amazon.com/dp/B0ABC"}] def test_no_match_returns_none(self): - with patch("amazon._extract_asin", return_value=None), \ - patch("amazon._serpapi_search", return_value=[]), \ - patch("amazon._pick_best_match", return_value=None): + with patch("palmer.amazon._extract_asin", return_value=None), \ + patch("palmer.amazon._serpapi_search", return_value=[]), \ + patch("palmer.amazon._pick_best_match", return_value=None): assert amazon.resolve_asin("nonsense") is None def test_pick_returns_watchable_dict(self): - with patch("amazon._extract_asin", return_value=None), \ - patch("amazon._serpapi_search", return_value=self._search_result()), \ - patch("amazon._pick_best_match", return_value=self._search_result()[0]): + with patch("palmer.amazon._extract_asin", return_value=None), \ + patch("palmer.amazon._serpapi_search", return_value=self._search_result()), \ + patch("palmer.amazon._pick_best_match", return_value=self._search_result()[0]): out = amazon.resolve_asin("thing") assert out["asin"] == "B0ABC" assert out["title"] == "thing" @@ -197,10 +197,10 @@ def test_url_fast_path_skips_search(self): """When the user pastes an Amazon URL, resolve_asin extracts the ASIN and hits amazon_product directly — no SerpAPI search, no Haiku pick.""" product = {"title": "Optimum Nutrition Gold Standard", "price": 54.0} - with patch("amazon._extract_asin", return_value="B000QSNYGI"), \ - patch("amazon._amazon_product", return_value=product) as amp, \ - patch("amazon._serpapi_search") as search, \ - patch("amazon._pick_best_match") as pick: + with patch("palmer.amazon._extract_asin", return_value="B000QSNYGI"), \ + patch("palmer.amazon._amazon_product", return_value=product) as amp, \ + patch("palmer.amazon._serpapi_search") as search, \ + patch("palmer.amazon._pick_best_match") as pick: out = amazon.resolve_asin("https://www.amazon.com/dp/B000QSNYGI") assert out == { "asin": "B000QSNYGI", @@ -217,9 +217,9 @@ def test_url_fast_path_product_lookup_fails(self): """If the URL parses but amazon_product returns None, don't silently fall back to searching the URL string — return None so the caller can tell the user something went wrong with this specific listing.""" - with patch("amazon._extract_asin", return_value="B0DEADBEEF"), \ - patch("amazon._amazon_product", return_value=None), \ - patch("amazon._serpapi_search") as search: + with patch("palmer.amazon._extract_asin", return_value="B0DEADBEEF"), \ + patch("palmer.amazon._amazon_product", return_value=None), \ + patch("palmer.amazon._serpapi_search") as search: assert amazon.resolve_asin("https://www.amazon.com/dp/B0DEADBEEF") is None search.assert_not_called() @@ -233,22 +233,22 @@ def _watch(self, **kw): return base def test_missing_asin_returns_none(self): - with patch("serpapi.API_KEY", "fake"): + with patch("palmer.serpapi.API_KEY", "fake"): assert amazon.check_price(self._watch(asin=None)) is None def test_missing_key_returns_none(self): - with patch("serpapi.API_KEY", ""): + with patch("palmer.serpapi.API_KEY", ""): assert amazon.check_price(self._watch()) is None def test_http_failure_returns_none(self): - with patch("serpapi.API_KEY", "fake"), \ - patch("serpapi._http_get_json", return_value=None): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=None): assert amazon.check_price(self._watch()) is None def test_reads_product_results_price(self): payload = {"product_results": {"title": "Full title", "extracted_price": 42.0}} - with patch("serpapi.API_KEY", "fake"), \ - patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): out = amazon.check_price(self._watch()) assert out["price"] == 42.0 assert out["title"] == "Full title" @@ -257,15 +257,15 @@ def test_reads_product_results_price(self): def test_falls_back_to_buybox_when_product_results_priceless(self): payload = {"product_results": {"title": "T"}, "buybox_winner": {"extracted_price": 39.99}} - with patch("serpapi.API_KEY", "fake"), \ - patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): out = amazon.check_price(self._watch()) assert out["price"] == 39.99 def test_no_price_anywhere_returns_none(self): payload = {"product_results": {"title": "T"}} - with patch("serpapi.API_KEY", "fake"), \ - patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", return_value=payload): assert amazon.check_price(self._watch()) is None def test_uses_asin_not_query(self): @@ -275,8 +275,8 @@ def test_uses_asin_not_query(self): def _capture(url, timeout): captured["url"] = url return {"product_results": {"extracted_price": 10.0}} - with patch("serpapi.API_KEY", "fake"), \ - patch("serpapi._http_get_json", side_effect=_capture): + with patch("palmer.serpapi.API_KEY", "fake"), \ + patch("palmer.serpapi._http_get_json", side_effect=_capture): amazon.check_price(self._watch(asin="B0XYZ", product_name="totally different")) assert "asin=B0XYZ" in captured["url"] assert "totally+different" not in captured["url"] @@ -297,7 +297,7 @@ def _current(self, **kw): # to patch lives there — patching amazon.client here silently let these make # real API calls. def test_appends_url_even_on_draft_failure(self): - with patch("price_alert.client") as mock_client: + with patch("palmer.price_alert.client") as mock_client: mock_client.messages.create.side_effect = RuntimeError("boom") body = amazon.draft_alert("Protein", self._current(), self._watch(), "drop") assert body.endswith("https://www.amazon.com/dp/B0PROT") @@ -307,7 +307,7 @@ def test_appends_url_on_success(self): block.text = "your protein just dropped to $42, was $60" resp = MagicMock() resp.content = [block] - with patch("price_alert.client") as mock_client: + with patch("palmer.price_alert.client") as mock_client: mock_client.messages.create.return_value = resp body = amazon.draft_alert("Protein", self._current(), self._watch(), "drop") assert body.endswith("https://www.amazon.com/dp/B0PROT") @@ -343,11 +343,11 @@ def test_amazon_row_routes_to_amazon_check_price(self): shp = self._shopping_watch() current = {"price": 40.0, "title": "t", "merchant": "Amazon", "url": "https://www.amazon.com/dp/B0ABC"} - with patch("db.get_active_price_watches", return_value=[amz, shp]), \ - patch("amazon.check_price", return_value=current) as amz_check, \ - patch("shopping.check_price", return_value=None) as shp_check, \ - patch("db.set_price_watch_baseline") as set_baseline, \ - patch("sms_util.ensure_sms", return_value=True): + with patch("palmer.db.get_active_price_watches", return_value=[amz, shp]), \ + patch("palmer.amazon.check_price", return_value=current) as amz_check, \ + patch("palmer.shopping.check_price", return_value=None) as shp_check, \ + patch("palmer.db.set_price_watch_baseline") as set_baseline, \ + patch("palmer.sms_util.ensure_sms", return_value=True): shopping.run_price_watches() # Amazon path called with the full watch dict (needs the ASIN) amz_check.assert_called_once_with(amz) @@ -362,10 +362,10 @@ def test_missing_source_defaults_to_shopping_path(self): set. Dispatch must treat them as shopping, not skip or crash.""" legacy = self._shopping_watch() legacy.pop("source") # simulate a pre-migration row shape - with patch("db.get_active_price_watches", return_value=[legacy]), \ - patch("amazon.check_price") as amz_check, \ - patch("shopping.check_price", return_value=None) as shp_check, \ - patch("db.set_price_watch_baseline"): + with patch("palmer.db.get_active_price_watches", return_value=[legacy]), \ + patch("palmer.amazon.check_price") as amz_check, \ + patch("palmer.shopping.check_price", return_value=None) as shp_check, \ + patch("palmer.db.set_price_watch_baseline"): shopping.run_price_watches() amz_check.assert_not_called() shp_check.assert_called_once_with(legacy["product_name"]) @@ -377,13 +377,13 @@ def test_amazon_alert_uses_amazon_draft_alert(self): amz["baseline_price"] = 60.0 # already seeded; a drop should alert current = {"price": 40.0, "title": "t", "merchant": "Amazon", "url": "https://www.amazon.com/dp/B0ABC"} - with patch("db.get_active_price_watches", return_value=[amz]), \ - patch("amazon.check_price", return_value=current), \ - patch("amazon.draft_alert", return_value="alert body https://www.amazon.com/dp/B0ABC") as amz_draft, \ - patch("shopping._draft_alert") as shop_draft, \ - patch("sms_util.ensure_sms", return_value=True), \ - patch("db.claim_price_watch_alert", return_value=True), \ - patch("db.update_price_watch_alerted"): + with patch("palmer.db.get_active_price_watches", return_value=[amz]), \ + patch("palmer.amazon.check_price", return_value=current), \ + patch("palmer.amazon.draft_alert", return_value="alert body https://www.amazon.com/dp/B0ABC") as amz_draft, \ + patch("palmer.shopping._draft_alert") as shop_draft, \ + patch("palmer.sms_util.ensure_sms", return_value=True), \ + patch("palmer.db.claim_price_watch_alert", return_value=True), \ + patch("palmer.db.update_price_watch_alerted"): shopping.run_price_watches() amz_draft.assert_called_once() shop_draft.assert_not_called() diff --git a/test_arrange_page_tool.py b/tests/test_arrange_page_tool.py similarity index 98% rename from test_arrange_page_tool.py rename to tests/test_arrange_page_tool.py index 909a354..25fc68c 100644 --- a/test_arrange_page_tool.py +++ b/tests/test_arrange_page_tool.py @@ -4,7 +4,7 @@ order and visibility are render-time and need no invalidate.""" from unittest.mock import patch -import agent +from palmer import agent class _Block: @@ -32,7 +32,7 @@ def _create(**kw): patch.object(agent, "get_history", return_value=[]), \ patch.object(agent, "get_profile", return_value=profile or {}), \ patch.object(agent, "upsert_profile") as upsert, \ - patch("home.invalidate") as invalidate, \ + patch("palmer.home.invalidate") as invalidate, \ patch.object(agent.client.messages, "create", side_effect=_create): agent.get_reply("+1555", "arrange my page") result = calls[1]["messages"][-1]["content"][0]["content"] diff --git a/test_briefing_shape.py b/tests/test_briefing_shape.py similarity index 95% rename from test_briefing_shape.py rename to tests/test_briefing_shape.py index 0c00ac7..6d810fa 100644 --- a/test_briefing_shape.py +++ b/tests/test_briefing_shape.py @@ -10,9 +10,9 @@ """ from unittest.mock import patch -import agent -import morning -import prompts +from palmer import agent +from palmer import morning +from palmer import prompts class TestGreetingIsNotABriefing: @@ -97,7 +97,7 @@ def test_directive_never_reaches_the_news_search(self): ]} with patch.object(morning, "_topic_digest", side_effect=lambda t: searched.append(t) or "x"), \ patch.object(morning, "_get_price", return_value="x"), \ - patch("trends.adjacent_story", return_value=None): + patch("palmer.trends.adjacent_story", return_value=None): morning._gather_morning_data(profile) assert "SpaceX news" in searched assert not any("Format:" in s for s in searched), \ @@ -114,7 +114,7 @@ def test_all_topics_up_to_the_cap_are_pulled(self): profile = {"city": "", "morning_topics": [f"topic {i}" for i in range(8)]} with patch.object(morning, "_topic_digest", side_effect=lambda t: searched.append(t) or "x"), \ patch.object(morning, "_get_price", return_value="x"), \ - patch("trends.adjacent_story", return_value=None): + patch("palmer.trends.adjacent_story", return_value=None): morning._gather_morning_data(profile) assert len(searched) == morning.MAX_TOPICS @@ -157,7 +157,7 @@ def test_uses_route_not_city_when_available(self): patch.object(morning, "get_city_traffic", return_value="Roads are clear.") as city, \ patch.object(morning, "_weather_report", return_value="warm"), \ patch.object(morning, "_topic_digest", return_value="x"), \ - patch("trends.adjacent_story", return_value=None): + patch("palmer.trends.adjacent_story", return_value=None): out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": [self.TOPIC]}) route.assert_called_once() city.assert_not_called() @@ -168,7 +168,7 @@ def test_falls_back_to_city_without_a_route(self): patch.object(morning, "get_city_traffic", return_value="Roads are clear.") as city, \ patch.object(morning, "_weather_report", return_value="warm"), \ patch.object(morning, "_topic_digest", return_value="x"), \ - patch("trends.adjacent_story", return_value=None): + patch("palmer.trends.adjacent_story", return_value=None): morning._gather_morning_data({"city": "Kirkwood", "morning_topics": ["SpaceX news"]}) route.assert_not_called() city.assert_called_once() @@ -183,7 +183,7 @@ def test_routing_failure_falls_back_instead_of_leaking_the_error(self): patch.object(morning, "get_city_traffic", return_value="Roads are clear.") as city, \ patch.object(morning, "_weather_report", return_value="warm"), \ patch.object(morning, "_topic_digest", return_value="x"), \ - patch("trends.adjacent_story", return_value=None): + patch("palmer.trends.adjacent_story", return_value=None): out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": [self.TOPIC]}) city.assert_called_once() assert not any("Couldn't find" in s or "Routing failed" in s for s in out) @@ -199,8 +199,8 @@ def test_commute_is_a_tool_not_an_extracted_field(self): nothing about, so the field left EXTRACT_PROMPT the way followed_teams and shows did: a Haiku write of {origin, destination} would replace the tool's dict and drop both.""" - import prompts - from tools_def import TOOLS + from palmer import prompts + from palmer.tools_def import TOOLS assert '"commute"' not in prompts.EXTRACT_PROMPT assert "set_commute" in prompts.SYSTEM_PROMPT assert any(t["name"] == "set_commute" for t in TOOLS) diff --git a/test_calibration.py b/tests/test_calibration.py similarity index 95% rename from test_calibration.py rename to tests/test_calibration.py index 7b1aada..43986df 100644 --- a/test_calibration.py +++ b/tests/test_calibration.py @@ -10,9 +10,9 @@ """ from unittest.mock import patch, MagicMock -import agent -import prompts -import send_reminders +from palmer import agent +from palmer import prompts +from palmer import reminders def _render() -> str: @@ -167,10 +167,10 @@ def _fake_create(**kwargs): resp.content = [block] return resp - with patch.object(send_reminders, "_build_system", + with patch.object(reminders, "_build_system", return_value="SYSTEM WITH CALIBRATION READ: blunt") as bs, \ - patch.object(send_reminders.client.messages, "create", side_effect=_fake_create): - out = send_reminders._personalize_reminder( + patch.object(reminders.client.messages, "create", side_effect=_fake_create): + out = reminders._personalize_reminder( "+15550001111", "dentist at 3", {"communication_style": "blunt, asked for just the facts"}, ) @@ -178,14 +178,14 @@ def _fake_create(**kwargs): assert out bs.assert_called_once() assert captured["system"] == "SYSTEM WITH CALIBRATION READ: blunt" - assert captured["model"] == send_reminders.SONNET_MODEL, \ + assert captured["model"] == reminders.SONNET_MODEL, \ "user-facing drafting belongs on Sonnet" def test_reminder_survives_a_drafting_failure(self): - with patch.object(send_reminders, "_build_system", return_value="SYS"), \ - patch.object(send_reminders.client.messages, "create", + with patch.object(reminders, "_build_system", return_value="SYS"), \ + patch.object(reminders.client.messages, "create", side_effect=RuntimeError("boom")): - out = send_reminders._personalize_reminder("+15550001111", "dentist at 3", {}) + out = reminders._personalize_reminder("+15550001111", "dentist at 3", {}) assert "dentist at 3" in out, "a failed draft must still deliver the reminder" @@ -201,7 +201,7 @@ class TestThePromptDescribesTheProductThatExists: """ def test_the_price_bar_is_the_one_the_code_uses(self): - import shopping + from palmer import shopping block = agent.SYSTEM_PROMPT.split("PRICE WATCHES")[1].split("USE THE RIGHT TOOL")[0] assert f"${shopping.MOVE_MIN_ABS:.0f}" in block @@ -235,7 +235,7 @@ def test_the_prompt_names_every_tool_it_ships_with(self): from its own description alone — and add_watch's description tells it to fire on "a team, a story, a market", which is exactly what the block assigns to update_morning_briefing and follow_team.""" - from tools_def import TOOLS + from palmer.tools_def import TOOLS missing = [t["name"] for t in TOOLS if t["name"] not in agent.SYSTEM_PROMPT] assert missing == [], f"tools with no routing guidance: {missing}" diff --git a/test_cards.py b/tests/test_cards.py similarity index 97% rename from test_cards.py rename to tests/test_cards.py index 72234fe..8504075 100644 --- a/test_cards.py +++ b/tests/test_cards.py @@ -9,10 +9,10 @@ from PIL import Image import io -import cards -import datafeeds -import traffic as traffic_mod -import weather as weather_mod +from palmer import cards +from palmer import datafeeds +from palmer import traffic as traffic_mod +from palmer import weather as weather_mod WEATHER = {"resolved": "Kirkwood, Missouri", "temp_now": 81.0, "feels_like": 84.0, "humidity": 55, "wind": 7.0, "weather_code": 3, "description": "Overcast", @@ -117,7 +117,7 @@ class TestCardCacheKey: "headlines": [{"title": h} for h in HEADS], "built_at": 1} def _fp(self, **over): - import artifacts + from palmer import artifacts return artifacts._card_fingerprint(dict(self.PAYLOAD, **over)) def test_identical_content_is_a_cache_hit(self): @@ -143,21 +143,21 @@ def test_something_not_drawn_does_not_move_the_key(self): assert self._fp(tracking={"topics": ["new"]}) == self._fp() def test_render_png_reuses_the_image_for_identical_content(self): - import artifacts + from palmer import artifacts artifacts._png_cache.clear() a = artifacts.render_png("tok", dict(self.PAYLOAD)) b = artifacts.render_png("tok", dict(self.PAYLOAD)) assert a is b def test_render_png_redraws_when_the_content_moves(self): - import artifacts + from palmer import artifacts artifacts._png_cache.clear() a = artifacts.render_png("tok", dict(self.PAYLOAD)) b = artifacts.render_png("tok", dict(self.PAYLOAD, weather=dict(WEATHER, temp_now=12.0))) assert a != b def test_opening_reaches_the_renderer(self): - import artifacts + from palmer import artifacts assert "opening" in artifacts._card_inputs(self.PAYLOAD) diff --git a/test_clock_correctness.py b/tests/test_clock_correctness.py similarity index 95% rename from test_clock_correctness.py rename to tests/test_clock_correctness.py index 63a10ab..e0f1503 100644 --- a/test_clock_correctness.py +++ b/tests/test_clock_correctness.py @@ -4,14 +4,15 @@ failing: the output names the date, so the user reads a specific claim that does not match reality. """ +import importlib from datetime import date, datetime, timezone from unittest.mock import patch -import datafeeds -import timeutil -import userprofile -import weather +from palmer import datafeeds +from palmer import timeutil +from palmer import userprofile +from palmer import weather class TestTheMarketDayIsNewYorks: @@ -24,7 +25,7 @@ def test_after_the_utc_rollover_it_is_still_the_same_trading_day(self): # 00:30Z on Aug 31 is 20:30 ET on Aug 30 — the afternoon's close is # TODAY's, and the old date.today() labelled it "yesterday". instant = datetime(2026, 8, 31, 0, 30, tzinfo=timezone.utc) - with patch("timeutil.datetime") as dt: + with patch("palmer.timeutil.datetime") as dt: dt.now.side_effect = lambda tz=None: instant.astimezone(tz) if tz else instant assert timeutil.local_today(datafeeds._MARKET_TZ) == date(2026, 8, 30) assert timeutil.local_today(None) == date(2026, 8, 31) @@ -132,7 +133,7 @@ def test_the_forecast_correction_path_does_not_reach_this_function(self): """CLAUDE.md: correcting a weather city must not move the hour the morning arrives. That write calls upsert_profile directly.""" import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) assert "_city_from_weather_topic" in src assert "_apply_profile_updates" not in src @@ -159,7 +160,7 @@ def test_it_runs_once_the_gap_is_wide_enough(self): older.assert_called_once() def test_the_watermark_is_bookkeeping_not_an_extraction_field(self): - import prompts + from palmer import prompts assert "consolidated_at_count" in userprofile.PROFILE_FIELDS assert "consolidated_at_count" not in prompts.EXTRACT_PROMPT @@ -167,11 +168,11 @@ def test_the_watermark_is_bookkeeping_not_an_extraction_field(self): class TestCurationIsToldTheReadersDate: def test_curate_takes_a_date(self): import inspect - sig = inspect.signature(__import__("opening")._curate) + sig = inspect.signature(importlib.import_module("palmer.opening")._curate) assert "today" in sig.parameters def test_the_snapshot_passes_the_local_day(self): import inspect - import opening + from palmer import opening src = inspect.getsource(opening.opening_snapshot) assert "_curate(metro or _metro(city), pool, today=today)" in src diff --git a/test_commute.py b/tests/test_commute.py similarity index 97% rename from test_commute.py rename to tests/test_commute.py index eae714b..24347be 100644 --- a/test_commute.py +++ b/tests/test_commute.py @@ -14,16 +14,16 @@ from unittest.mock import patch from zoneinfo import ZoneInfo -import agent -import artifacts -import home -import morning -import page -import prompts -import traffic -import userprofile -from timeutil import friendly_hhmm -from tools_def import TOOLS +from palmer import agent +from palmer import artifacts +from palmer import home +from palmer import morning +from palmer import page +from palmer import prompts +from palmer import traffic +from palmer import userprofile +from palmer.timeutil import friendly_hhmm +from palmer.tools_def import TOOLS CHI = ZoneInfo("America/Chicago") ROUTE = {"routes": [{"summary": {"travelTimeInSeconds": 1020, @@ -198,7 +198,7 @@ def test_fetch_forwards_the_stored_route_and_departure(self): "origin_ll": [38.5, -90.4], "dest_ll": [38.6, -90.2]}} depart = self._at("08:30") with patch.object(home, "_commute_depart_at", return_value=depart), \ - patch("traffic.traffic_snapshot", return_value={"live_min": 3}) as snap: + patch("palmer.traffic.traffic_snapshot", return_value={"live_min": 3}) as snap: assert home._fetch_traffic(profile) == {"live_min": 3} kw = snap.call_args.kwargs assert kw["depart_at"] == depart and kw["tz_name"] == "America/Chicago" @@ -206,7 +206,7 @@ def test_fetch_forwards_the_stored_route_and_departure(self): def test_a_legacy_commute_passes_no_coordinates(self): profile = {"commute": {"origin": ORIGIN, "destination": DEST}} - with patch("traffic.traffic_snapshot", return_value=None) as snap: + with patch("palmer.traffic.traffic_snapshot", return_value=None) as snap: home._fetch_traffic(profile) kw = snap.call_args.kwargs assert kw["origin_ll"] is None and kw["dest_ll"] is None and kw["depart_at"] is None @@ -331,7 +331,7 @@ def _create(**kw): patch.object(agent, "upsert_profile") as upsert, \ patch.object(traffic, "TOMTOM_API_KEY", key), \ patch.object(traffic, "_geocode_address", side_effect=list(geocodes)), \ - patch("home.invalidate") as invalidate, \ + patch("palmer.home.invalidate") as invalidate, \ patch.object(agent.client.messages, "create", side_effect=_create): agent.get_reply("+1555", "my commute") result = calls[1]["messages"][-1]["content"][0]["content"] diff --git a/test_corroboration.py b/tests/test_corroboration.py similarity index 97% rename from test_corroboration.py rename to tests/test_corroboration.py index c358e87..2cb06d0 100644 --- a/test_corroboration.py +++ b/tests/test_corroboration.py @@ -1,6 +1,6 @@ """Tests for the news-quality gate in watches.corroborated(). Every unprompted news text — a watch alert, a headline on the page — has to clear it.""" -from watches import corroborated +from palmer.watches import corroborated def _r(url: str, score: float = 0.7) -> dict: diff --git a/test_db_claims.py b/tests/test_db_claims.py similarity index 99% rename from test_db_claims.py rename to tests/test_db_claims.py index 03574c9..852d08f 100644 --- a/test_db_claims.py +++ b/tests/test_db_claims.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone -import db +from palmer import db def _fresh_db(tmp_path, monkeypatch): @@ -163,7 +163,7 @@ def test_alert_moves_the_baseline(self, tmp_path, monkeypatch): def test_same_price_no_longer_qualifies_after_alert(self, tmp_path, monkeypatch): _fresh_db(tmp_path, monkeypatch) - from shopping import _should_alert + from palmer.shopping import _should_alert wid = self._watch() row = [w for w in db.get_active_price_watches() if w["id"] == wid][0] assert _should_alert(row, 47.98) == "drop" diff --git a/test_followup.py b/tests/test_followup.py similarity index 96% rename from test_followup.py rename to tests/test_followup.py index 3e19ff1..34324e0 100644 --- a/test_followup.py +++ b/tests/test_followup.py @@ -17,9 +17,9 @@ from unittest.mock import MagicMock, patch from zoneinfo import ZoneInfo -import db -import followup -from followup import Subject +from palmer import db +from palmer import followup +from palmer.followup import Subject PHONE = "+15550001111" @@ -93,7 +93,7 @@ def test_threads_teams_and_headlines_all_become_subjects(self): payload = {"fetched": {"headlines": datetime.now().timestamp()}, "headlines": [{"title": "Fed holds rates", "url": "https://reuters.com/x", "source": "reuters.com", "topic": "Fed"}]} - with patch("sports.team_day", return_value={"last": GAME_LAST, "today": None}): + with patch("palmer.sports.team_day", return_value={"last": GAME_LAST, "today": None}): got = followup._candidates(profile, payload, FROZEN.date()) kinds = [s.kind for s in got] assert kinds == ["thread", "thread", "team", "news"] @@ -107,11 +107,11 @@ def test_stale_headlines_are_not_brought_up(self): assert followup._candidates({}, payload, FROZEN.date()) == [] def test_a_team_with_no_game_adds_nothing(self): - with patch("sports.team_day", return_value={"last": None, "today": None}): + with patch("palmer.sports.team_day", return_value={"last": None, "today": None}): assert followup._candidates({"followed_teams": [CARDS]}, None, FROZEN.date()) == [] def test_a_sports_outage_costs_only_the_team_rows(self): - with patch("sports.team_day", side_effect=RuntimeError("espn down")): + with patch("palmer.sports.team_day", side_effect=RuntimeError("espn down")): got = followup._candidates({"ongoing_threads": THREADS, "followed_teams": [CARDS]}, None, FROZEN.date()) assert [s.kind for s in got] == ["thread", "thread"] @@ -196,7 +196,7 @@ def _run(self, tmp_path, monkeypatch, *, subject, candidates=None, patch.object(followup, "_pick_subject", return_value=subject), \ patch.object(followup, "_draft_followup", return_value=drafted), \ patch.object(followup, "_is_duplicate_subject", return_value=dup), \ - patch("sms_util.send_sms", return_value=sent): + patch("palmer.sms_util.send_sms", return_value=sent): followup.run_followups() return db.get_profile(PHONE) @@ -257,7 +257,7 @@ def test_a_thread_subject_may_ask_one_question(self): assert THREADS[0] in body and "ask one short question" in body def test_the_bookkeeping_field_is_allow_listed_but_not_extractable(self): - import userprofile - import prompts + from palmer import userprofile + from palmer import prompts assert "followup_last_thread" in userprofile.PROFILE_FIELDS assert "followup_last_thread" not in prompts.EXTRACT_PROMPT diff --git a/test_forecast_audit.py b/tests/test_forecast_audit.py similarity index 93% rename from test_forecast_audit.py rename to tests/test_forecast_audit.py index fe4f11b..e8da2b9 100644 --- a/test_forecast_audit.py +++ b/tests/test_forecast_audit.py @@ -17,10 +17,10 @@ """ from unittest.mock import patch -import db -import morning -import weather -import wxaudit +from palmer import db +from palmer import morning +from palmer import weather +from palmer import wxaudit def _fresh_db(tmp_path, monkeypatch): @@ -99,14 +99,14 @@ def test_the_prompt_carries_the_hedging_rule(self): def test_the_page_shows_the_same_range(self): """The page, the card and the text render from one payload and must not disagree about how sure Palmer is.""" - import page + from palmer import page html = page.render(dict(self.UNSURE, prices=[], headlines=[], opening=[], tracking={"watches": [], "price_watches": [], "topics": []}, fetched={}), token="t", image_url="i", page_url="p") assert "H 94-110" in html def test_a_settled_high_stays_a_single_number_on_the_page(self): - import page + from palmer import page html = page.render(dict(self.SURE, prices=[], headlines=[], opening=[], tracking={"watches": [], "price_watches": [], "topics": []}, fetched={}), token="t", image_url="i", page_url="p") @@ -196,7 +196,7 @@ def test_the_answer_is_cached_per_city_per_day(self, tmp_path, monkeypatch): self._seed(tmp_path, monkeypatch, [("WH", "nws", 11, 6), ("WH", "ecmwf_ifs025", 3, 6)]) wxaudit.best_source("WH") - with patch("db.forecast_scores", side_effect=AssertionError("should not re-query")): + with patch("palmer.db.forecast_scores", side_effect=AssertionError("should not re-query")): assert wxaudit.best_source("WH") == "ecmwf_ifs025" def test_no_city_and_no_data_are_safe(self, tmp_path, monkeypatch): @@ -207,7 +207,7 @@ def test_no_city_and_no_data_are_safe(self, tmp_path, monkeypatch): class TestAProvenSourceIsUsedAndTrusted: def test_the_snapshot_switches_to_the_proven_model(self): - with patch("wxaudit.best_source", return_value="ecmwf_ifs025"), \ + with patch("palmer.wxaudit.best_source", return_value="ecmwf_ifs025"), \ patch.object(weather, "_geocode", return_value=(34.17, -118.61, "Woodland Hills")), \ patch.object(weather, "_fetch_openmeteo", return_value={ "current": {"temperature_2m": 80, "weather_code": 0}, @@ -231,7 +231,7 @@ def test_a_proven_source_costs_no_ensemble_call(self): http.assert_not_called() def test_nothing_proven_leaves_todays_behaviour_alone(self): - with patch("wxaudit.best_source", return_value=None), \ + with patch("palmer.wxaudit.best_source", return_value=None), \ patch.object(weather, "_geocode", return_value=(34.17, -118.61, "WH")), \ patch.object(weather, "_nws_snapshot", return_value={"high": 103, "source": "nws"}), \ patch.object(weather, "_ensemble_spread", return_value={"high_confident": False}): @@ -239,36 +239,36 @@ def test_nothing_proven_leaves_todays_behaviour_alone(self): assert snap["source"] == "nws" and snap["high_confident"] is False def test_a_broken_audit_never_costs_anyone_their_forecast(self): - with patch("wxaudit.best_source", side_effect=RuntimeError("db down")): + with patch("palmer.wxaudit.best_source", side_effect=RuntimeError("db down")): assert weather._proven_source("Anywhere") is None class TestTheAuditJobIsSafe: def test_it_never_raises(self): - with patch("wxaudit._cities", side_effect=RuntimeError("db down")): - import wxaudit + with patch("palmer.wxaudit._cities", side_effect=RuntimeError("db down")): + from palmer import wxaudit wxaudit.run_forecast_audit() # must not propagate def test_it_sends_nothing(self): import inspect - import wxaudit + from palmer import wxaudit src = inspect.getsource(wxaudit) for forbidden in ("send_sms", "ensure_sms", "messages.create"): assert forbidden not in src, "the audit is observation only" def test_one_city_is_logged_once_however_many_users_share_it(self): - import wxaudit + from palmer import wxaudit profiles = [("+1", {"city": "Kirkwood, MO"}), ("+2", {"city": "Kirkwood, MO"}), ("+3", {"city": "Culver City"}), ("+4", {})] - with patch("wxaudit.get_all_profiles", return_value=profiles), \ - patch("weather._geocode", side_effect=lambda c: (1.0, 2.0, c)): + with patch("palmer.wxaudit.get_all_profiles", return_value=profiles), \ + patch("palmer.weather._geocode", side_effect=lambda c: (1.0, 2.0, c)): assert sorted(wxaudit._cities()) == ["Culver City", "Kirkwood, MO"] def test_a_scheduled_cron_not_an_interval(self): """Once a day on an interval makes the phase a function of deploy history, and a skipped day is a hole in the record.""" import inspect - import main + from palmer import main src = inspect.getsource(main) block = src.split("run_forecast_audit,")[1][:120] assert '"cron"' in block and "misfire_grace_time" in block diff --git a/test_guards_and_flights.py b/tests/test_guards_and_flights.py similarity index 94% rename from test_guards_and_flights.py rename to tests/test_guards_and_flights.py index 1e6539f..cbad1bc 100644 --- a/test_guards_and_flights.py +++ b/tests/test_guards_and_flights.py @@ -15,9 +15,9 @@ """ from unittest.mock import patch, MagicMock -import db -import agent -import guards +from palmer import db +from palmer import agent +from palmer import guards # Verbatim from production. Every one of these went to a real user. @@ -76,7 +76,7 @@ class TestTheRedraft: it is the same problem: a rule the prompt states and the model breaks.""" def _finalize(self, first, *retries): - import agent + from palmer import agent calls = [] def _create(**kw): @@ -107,7 +107,7 @@ def test_a_redraft_that_still_hands_off_is_not_preferred(self): assert out == "Tool's down - try Google Maps." def test_a_failed_redraft_keeps_the_original(self): - import agent + from palmer import agent with patch.object(agent.client.messages, "create", side_effect=RuntimeError("api down")): out, _ = agent._finalize("check Google Maps for that", "sys", [], None) assert out == "check Google Maps for that" @@ -123,24 +123,24 @@ class TestFailureStringsDoNotDisclaimCapability: the model paraphrased into "I can't do flights" and then a competitor.""" def test_flight_failure_says_the_capability_exists(self): - import flights + from palmer import flights with patch.object(flights, "SERP_API_KEY", ""): out = flights.search_flights("LAX", "MXP", "2026-09-18") assert "DO have flight search" in out assert not guards.redirects_elsewhere(out) def test_traffic_failure_asks_rather_than_redirects(self): - import traffic + from palmer import traffic out = traffic.get_travel_time("", "") assert "ask the user" in out.lower() assert not guards.redirects_elsewhere(out) def test_no_failure_string_names_a_competitor(self): - import flights - import hotels - import serpapi - import shopping - import traffic + from palmer import flights + from palmer import hotels + from palmer import serpapi + from palmer import shopping + from palmer import traffic # shopping gates on serpapi.API_KEY, not a local constant — patching the # wrong one lets the test out to the live network. with patch.object(flights, "SERP_API_KEY", ""), \ @@ -161,7 +161,7 @@ def test_an_empty_result_is_not_a_broken_tool(self): empty often and by design — the recency window and the source floor throw most of a page away — so this is the string that has to be right. """ - import datafeeds + from palmer import datafeeds fake = MagicMock() fake.search.return_value = {"results": []} with patch.object(datafeeds, "_tavily", fake): @@ -174,7 +174,7 @@ def test_an_empty_result_is_not_a_broken_tool(self): def test_a_failed_price_lookup_does_not_teach_the_model_the_company_is_private(self): """The failed lookup used to confirm the model's own stale prior, and Palmer told a user SpaceX was private while SPCX was trading.""" - import datafeeds + from palmer import datafeeds ticker = MagicMock() ticker.fast_info.last_price = None with patch("yfinance.Ticker", return_value=ticker): @@ -183,7 +183,7 @@ def test_a_failed_price_lookup_does_not_teach_the_model_the_company_is_private(s assert not guards.redirects_elsewhere(out) def test_a_city_palmer_cannot_place_is_asked_about_not_disclaimed(self): - import traffic + from palmer import traffic with patch.object(traffic, "_geocode_city", return_value=None): line, why = traffic.city_traffic("Nowheresville") assert line is None and why == "unknown_city" @@ -232,41 +232,41 @@ def test_an_alert_rebaselines(self, tmp_path, monkeypatch): class TestFlightAlertThresholds: - import flightwatch as fw + from palmer import flightwatch as fw def test_first_sighting_is_a_baseline_not_news(self): - import flightwatch + from palmer import flightwatch assert flightwatch._should_alert({"baseline_price": None}, 800) is None def test_a_target_hit_fires(self): - import flightwatch + from palmer import flightwatch assert flightwatch._should_alert({"target_price": 800, "baseline_price": 900}, 780) == "target" def test_noise_below_the_bar_is_silent(self): """Fares wobble tens of dollars daily; the flat $2 product rule would page someone every morning.""" - import flightwatch + from palmer import flightwatch assert flightwatch._should_alert({"baseline_price": 900}, 880) is None def test_a_real_move_fires_in_both_directions(self): - import flightwatch + from palmer import flightwatch assert flightwatch._should_alert({"baseline_price": 900}, 840) == "drop" assert flightwatch._should_alert({"baseline_price": 900}, 960) == "rise" def test_a_departed_flight_stops_costing_searches(self): - import flightwatch + from palmer import flightwatch assert flightwatch._expired({"outbound_date": "2020-01-01"}) assert not flightwatch._expired({"outbound_date": "2099-01-01"}) assert not flightwatch._expired({"outbound_date": None}) def test_the_job_never_raises(self): - import flightwatch - with patch("db.get_active_flight_watches", side_effect=RuntimeError("db down")): + from palmer import flightwatch + with patch("palmer.db.get_active_flight_watches", side_effect=RuntimeError("db down")): flightwatch.run_flight_watches() def test_it_is_scheduled_on_cron(self): import inspect - import main + from palmer import main block = inspect.getsource(main).split("run_flight_watches,")[1][:120] assert '"cron"' in block and "misfire_grace_time" in block @@ -277,7 +277,7 @@ def test_an_overlap_is_reported_to_palmer_not_acted_on(self): duplicate of "Philadelphia Eagles news" and is not — so silently dropping what someone asked for is the wrong failure.""" import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] assert "topic_already_covered" in block @@ -286,7 +286,7 @@ def test_an_overlap_is_reported_to_palmer_not_acted_on(self): def test_the_page_will_not_render_one_article_twice(self): import inspect - import home + from palmer import home src = inspect.getsource(home._fetch_headlines) assert "seen_urls" in src @@ -394,11 +394,11 @@ def test_empty_input_is_safe(self): def test_no_failure_string_in_the_codebase_trips_it(self): """The tool failure strings are what the model paraphrases. If one of them reads as a denial, the guard is policing a problem we wrote.""" - import flights - import hotels - import serpapi - import shopping - import traffic + from palmer import flights + from palmer import hotels + from palmer import serpapi + from palmer import shopping + from palmer import traffic with patch.object(flights, "SERP_API_KEY", ""), \ patch.object(hotels, "SERP_API_KEY", ""), \ patch.object(serpapi, "API_KEY", ""): diff --git a/test_home.py b/tests/test_home.py similarity index 95% rename from test_home.py rename to tests/test_home.py index 23a93f9..f16da5d 100644 --- a/test_home.py +++ b/tests/test_home.py @@ -17,8 +17,8 @@ from datetime import datetime, timezone from unittest.mock import patch -import datafeeds -import home +from palmer import datafeeds +from palmer import home PROFILE = {"city": "Kirkwood, MO", "timezone": "America/Chicago", @@ -214,8 +214,8 @@ def _search(topic, **kw): calls.append(kw) return untrusted if kw.get("trusted_only") is False else trusted - with patch("datafeeds._search_raw", side_effect=_search), \ - patch("morning._rotated_topics", side_effect=lambda t, d: t): + with patch("palmer.datafeeds._search_raw", side_effect=_search), \ + patch("palmer.morning._rotated_topics", side_effect=lambda t, d: t): out = home._fetch_headlines({"morning_topics": ["Philadelphia Eagles news"], "timezone": "America/Chicago"}) return out, calls @@ -251,7 +251,7 @@ def test_nothing_anywhere_drops_the_topic(self): def test_content_mills_are_blocked_structurally(self): """vocal.media cleared the old 0.5 floor at 0.52. The score bar cuts it and the blocklist backs that up on the paths that do not use one.""" - import sources + from palmer import sources assert sources.is_blocked("https://vocal.media/history/some-fact") @@ -370,26 +370,26 @@ class TestPricesSurviveAFailure: PREV = [{"label": "Bitcoin", "price": 77000.0}, {"label": "NVDA", "price": 214.0}] def test_a_failed_symbol_keeps_its_last_row(self): - with patch("datafeeds.price_snapshot", side_effect=[None, {"label": "NVDA", "price": 215.0}]): + with patch("palmer.datafeeds.price_snapshot", side_effect=[None, {"label": "NVDA", "price": 215.0}]): out = home._fetch_prices(self.PROF, self.PREV) assert [p["label"] for p in out] == ["Bitcoin", "NVDA"] assert out[0]["price"] == 77000.0, "kept the stale row" assert out[1]["price"] == 215.0, "took the fresh one" def test_a_fresh_fetch_wins_over_the_stale_row(self): - with patch("datafeeds.price_snapshot", return_value={"label": "Bitcoin", "price": 80000.0}): + with patch("palmer.datafeeds.price_snapshot", return_value={"label": "Bitcoin", "price": 80000.0}): out = home._fetch_prices({"morning_topics": ["Bitcoin price"]}, self.PREV) assert out[0]["price"] == 80000.0 def test_with_no_history_a_failure_simply_drops_out(self): """First ever build has nothing to fall back to; better an absent row than a fabricated one.""" - with patch("datafeeds.price_snapshot", return_value=None): + with patch("palmer.datafeeds.price_snapshot", return_value=None): assert home._fetch_prices(self.PROF, None) == [] def test_a_removed_ticker_does_not_come_back_from_the_stale_set(self): """The stale rows are a fallback, never a source of symbols.""" - with patch("datafeeds.price_snapshot", return_value={"label": "NVDA", "price": 215.0}): + with patch("palmer.datafeeds.price_snapshot", return_value={"label": "NVDA", "price": 215.0}): out = home._fetch_prices({"morning_topics": ["Nvidia stock"]}, self.PREV) assert [p["label"] for p in out] == ["NVDA"] @@ -522,8 +522,8 @@ def test_refresh_news_false_reuses_previous_headlines(self): assert out["fetched"]["headlines"] == 123, "freshness stamp must not be faked forward" def test_tracking_survives_a_db_failure(self): - with patch("db.get_user_watches", side_effect=RuntimeError("db down")), \ - patch("db.get_user_price_watches", side_effect=RuntimeError("db down")), \ + with patch("palmer.db.get_user_watches", side_effect=RuntimeError("db down")), \ + patch("palmer.db.get_user_price_watches", side_effect=RuntimeError("db down")), \ patch.object(home, "get_profile", return_value=PROFILE): t = home._tracking("+1555") assert t["watches"] == [] and t["price_watches"] == [] @@ -536,19 +536,19 @@ class TestOpeningDefault: A user can still be excluded with morning_prefs.opening = False.""" def test_opening_fetches_by_default(self): - with patch("opening.opening_snapshot", return_value=[{"title": "Mamele's"}]) as snap: + with patch("palmer.opening.opening_snapshot", return_value=[{"title": "Mamele's"}]) as snap: rows = home._fetch_opening({"city": "Kirkwood, MO"}) snap.assert_called_once() assert rows == [{"title": "Mamele's"}] def test_opening_still_fetches_for_a_profile_with_no_prefs_at_all(self): - with patch("opening.opening_snapshot", return_value=[]) as snap: + with patch("palmer.opening.opening_snapshot", return_value=[]) as snap: home._fetch_opening({"city": "Kirkwood, MO"}) snap.assert_called_once() def test_explicit_false_opts_a_user_out(self): profile = {"city": "Kirkwood, MO", "morning_prefs": {"opening": False}} - with patch("opening.opening_snapshot") as snap: + with patch("palmer.opening.opening_snapshot") as snap: rows = home._fetch_opening(profile) snap.assert_not_called() assert rows == [] @@ -560,27 +560,27 @@ class TestTrackingLinks: the merchant page they last saw a price on.""" def test_watch_exposes_its_last_alert_url_and_domain(self): - with patch("db.get_user_watches", return_value=[ + with patch("palmer.db.get_user_watches", return_value=[ {"description": "Iran and US strikes", "cooldown_hours": 4, "last_alert_url": "https://apnews.com/x", "last_alert_domain": "apnews.com"}]), \ - patch("db.get_user_price_watches", return_value=[]), \ + patch("palmer.db.get_user_price_watches", return_value=[]), \ patch.object(home, "get_profile", return_value=PROFILE): t = home._tracking("+1555") assert t["watches"][0]["url"] == "https://apnews.com/x" assert t["watches"][0]["source"] == "apnews.com" def test_watch_never_alerted_has_no_url(self): - with patch("db.get_user_watches", return_value=[ + with patch("palmer.db.get_user_watches", return_value=[ {"description": "brand new watch", "cooldown_hours": 4, "last_alert_url": None, "last_alert_domain": None}]), \ - patch("db.get_user_price_watches", return_value=[]), \ + patch("palmer.db.get_user_price_watches", return_value=[]), \ patch.object(home, "get_profile", return_value=PROFILE): t = home._tracking("+1555") assert t["watches"][0]["url"] is None def test_price_watch_exposes_url_and_merchant_not_the_dead_engine_field(self): - with patch("db.get_user_watches", return_value=[]), \ - patch("db.get_user_price_watches", return_value=[ + with patch("palmer.db.get_user_watches", return_value=[]), \ + patch("palmer.db.get_user_price_watches", return_value=[ {"product_name": "AirPods Pro", "target_price": 199.0, "last_seen_price": 220.0, "last_seen_url": "https://www.amazon.com/x", "last_seen_merchant": "amazon.com", "source": "amazon"}]), \ @@ -681,7 +681,7 @@ def _fetch(self, prefs): prof = dict(self.PROF) if prefs: prof["morning_prefs"] = prefs - with patch("datafeeds.price_snapshot", side_effect=list(self.ROWS)): + with patch("palmer.datafeeds.price_snapshot", side_effect=list(self.ROWS)): return [p["label"] for p in home._fetch_prices(prof, None)] def test_absent_prefs_keep_topic_order(self): @@ -699,7 +699,7 @@ def test_a_stale_fallback_row_sorts_with_the_rest(self): bypass the sort — a kept row with no pct sorts as a 0% mover.""" prev = [{"label": "Bitcoin", "price": 77000.0}] prof = dict(self.PROF, morning_prefs={"markets_sort": "movers"}) - with patch("datafeeds.price_snapshot", + with patch("palmer.datafeeds.price_snapshot", side_effect=[None, self.ROWS[1], self.ROWS[2]]): out = [p["label"] for p in home._fetch_prices(prof, prev)] assert out == ["NVDA", "AAPL", "Bitcoin"] diff --git a/test_link_integrity.py b/tests/test_link_integrity.py similarity index 98% rename from test_link_integrity.py rename to tests/test_link_integrity.py index b98a3e5..95ea8cf 100644 --- a/test_link_integrity.py +++ b/tests/test_link_integrity.py @@ -13,8 +13,8 @@ And the /sms-status retry fed a live message back through the first and third of those. morning.py opted out of that callback by hand; nothing else did. """ -import sms_util -import smstext +from palmer import sms_util +from palmer import smstext URL = "https://palmer.example/h/AbC123_xyz" @@ -145,7 +145,7 @@ class _R: class TestTheFlightRouteIsAscii: def test_no_glyph_is_deleted_by_the_fold(self): - import flightwatch + from palmer import flightwatch route = flightwatch._route( {"origin": "LAX", "destination": "MXP", "outbound_date": "2026-09-10"}) assert "LAX to MXP" in route diff --git a/test_local_day_guards.py b/tests/test_local_day_guards.py similarity index 94% rename from test_local_day_guards.py rename to tests/test_local_day_guards.py index 653c455..a559afb 100644 --- a/test_local_day_guards.py +++ b/tests/test_local_day_guards.py @@ -10,8 +10,8 @@ yesterday's morning — which is the failure it was written for. """ -import db -import morning +from palmer import db +from palmer import morning PHONE = "+15550001111" @@ -46,7 +46,7 @@ def test_ordering_is_oldest_first(self, tmp_path, monkeypatch): def test_the_repetition_guard_now_sees_yesterday(self, tmp_path, monkeypatch): """guards.repeats_opening exists for three consecutive mornings that all opened the same way; it could not see them past a chatty user.""" - import guards + from palmer import guards _fresh(tmp_path, monkeypatch) db.save_message(PHONE, "assistant", "103 today in Woodland Hills, stay inside", kind="morning") @@ -66,14 +66,14 @@ class TestTheAlertCapsUseTheReadersDay: """ def test_a_news_watch_cap_reads_the_day_it_is_given(self): - import watches + from palmer import watches watch = {"daily_alert_date": "2026-08-31", "daily_alert_count": 99} # Their day is still the 30th while the server has rolled to the 31st. assert watches._daily_ok(watch, cap=1, today="2026-08-30") is True assert watches._daily_ok(watch, cap=1, today="2026-08-31") is False def test_a_price_watch_cap_reads_the_day_it_is_given(self): - import shopping + from palmer import shopping watch = {"daily_alert_date": "2026-08-31", "daily_alert_count": shopping.PRICE_DAILY_ALERT_MAX} assert shopping._daily_ok(watch, today="2026-08-30") is True @@ -83,7 +83,7 @@ def test_the_watch_loop_derives_the_day_from_the_profile_it_already_reads(self): """One profile read per user already happens for the pacing cap; the local day rides along on it rather than costing another connection.""" import inspect - import watches + from palmer import watches src = inspect.getsource(watches.run_watches) assert "local_today" in src assert "today=today" in src @@ -91,14 +91,14 @@ def test_the_watch_loop_derives_the_day_from_the_profile_it_already_reads(self): def test_the_price_loop_reads_every_profile_in_one_query(self): """Never `for phone in ...: get_profile(phone)` — that is N+1 a tick.""" import inspect - import shopping + from palmer import shopping src = inspect.getsource(shopping.run_price_watches) assert "get_all_profiles" in src assert "get_profile(" not in src def test_the_write_side_takes_the_same_day_as_the_read(self): import inspect - import db + from palmer import db for fn in (db.update_watch_alerted, db.update_price_watch_alerted): assert "today" in inspect.signature(fn).parameters @@ -111,14 +111,14 @@ class TestProfileFactsAgeOnTheReadersCalendar: def test_the_prompt_profile_is_aged_on_the_users_day(self): import inspect - import agent + from palmer import agent src = inspect.getsource(agent._prompt_safe_profile) assert "local_today" in src assert "fresh_profile_for_prompt(profile)" not in src, \ "the default argument is date.today() — the dyno's day" def test_a_fact_stamped_today_is_not_a_day_old(self): - import userprofile + from palmer import userprofile from datetime import date as _d profile = {"stressed_about": "the move", "field_dates": {"stressed_about": "2026-08-30"}} diff --git a/test_morning_link.py b/tests/test_morning_link.py similarity index 96% rename from test_morning_link.py rename to tests/test_morning_link.py index a33e6f5..d31fadf 100644 --- a/test_morning_link.py +++ b/tests/test_morning_link.py @@ -13,7 +13,7 @@ import pytest -import morning +from palmer import morning PAYLOAD = { @@ -46,9 +46,9 @@ def test_empty_payload_is_falsy_so_callers_can_gate_on_it(self): class TestComposeShape: def _compose(self, line="Cool 71 and clear, but your drive is 9 min heavier than usual."): - with patch("home.ensure_fresh", return_value=URL), \ - patch("home.load", return_value=PAYLOAD), \ - patch("home.home_token", return_value="tok"), \ + with patch("palmer.home.ensure_fresh", return_value=URL), \ + patch("palmer.home.load", return_value=PAYLOAD), \ + patch("palmer.home.home_token", return_value="tok"), \ patch.object(morning, "generate_morning_line", return_value=line), \ patch.object(morning, "generate_morning") as full: msg, carries = morning._compose_morning("+1555") @@ -78,9 +78,9 @@ class TestFallbacks: def _compose_with(self, **patches): defaults = {"ensure_fresh": URL, "load": PAYLOAD, "home_token": "tok"} defaults.update(patches) - with patch("home.ensure_fresh", return_value=defaults["ensure_fresh"]), \ - patch("home.load", return_value=defaults["load"]), \ - patch("home.home_token", return_value=defaults["home_token"]), \ + with patch("palmer.home.ensure_fresh", return_value=defaults["ensure_fresh"]), \ + patch("palmer.home.load", return_value=defaults["load"]), \ + patch("palmer.home.home_token", return_value=defaults["home_token"]), \ patch.object(morning, "generate_morning", return_value="the long briefing"), \ patch.object(morning, "generate_morning_line", side_effect=defaults.get("line_error") or (lambda *a, **k: "a line")): @@ -231,7 +231,7 @@ def test_nothing_loaded_means_a_plain_greeting_is_fine(self): assert out == "Morning Jeff." and len(calls) == 1 def test_the_bullets_survive_the_sms_cleaner(self): - from smstext import _sms_clean + from palmer.smstext import _sms_clean assert _sms_clean(GOOD) == GOOD assert _sms_clean("- first\n* second") == "first\nsecond" diff --git a/test_morning_schedule.py b/tests/test_morning_schedule.py similarity index 95% rename from test_morning_schedule.py rename to tests/test_morning_schedule.py index 40e0f3c..7d8c309 100644 --- a/test_morning_schedule.py +++ b/tests/test_morning_schedule.py @@ -5,8 +5,8 @@ from datetime import datetime from zoneinfo import ZoneInfo -from morning import _in_send_window, _parse_morning_time, _local_today, DEFAULT_MORNING_TIME -from smstext import _normalize_hhmm, _parse_published +from palmer.morning import _in_send_window, _parse_morning_time, _local_today, DEFAULT_MORNING_TIME +from palmer.smstext import _normalize_hhmm, _parse_published def _dt(hour, minute, tz="America/Chicago"): diff --git a/test_my_page_tool.py b/tests/test_my_page_tool.py similarity index 92% rename from test_my_page_tool.py rename to tests/test_my_page_tool.py index 14ff3f1..1ee27c5 100644 --- a/test_my_page_tool.py +++ b/tests/test_my_page_tool.py @@ -12,9 +12,9 @@ from unittest.mock import patch -import agent -import prompts -from tools_def import TOOLS +from palmer import agent +from palmer import prompts +from palmer.tools_def import TOOLS URL = "https://palmer.example.com/h/AbC123xyz" @@ -78,7 +78,7 @@ def _create(**kw): with patch.object(agent, "_build_system", return_value="sys"), \ patch.object(agent, "get_history", return_value=[]), \ patch.object(agent, "get_profile", return_value={"timezone": "America/Chicago"}), \ - patch("home.ensure_fresh", return_value=url) as ensure, \ + patch("palmer.home.ensure_fresh", return_value=url) as ensure, \ patch.object(agent.client.messages, "create", side_effect=_create): text, _gif = agent.get_reply("+1555", "send me my page") # the tool_result the model saw, on the second request @@ -123,27 +123,27 @@ class TestPriceTopicNormalization: def test_a_topic_the_map_already_covers_is_untouched(self): """No model call: it already resolves.""" - with patch("llm.client") as client: + with patch("palmer.llm.client") as client: assert agent._normalize_price_topic("Nvidia stock") == "Nvidia stock" client.messages.create.assert_not_called() def test_an_unmapped_company_gains_its_ticker(self): - with patch("tickers.resolve_company_ticker", return_value="LULU"): + with patch("palmer.tickers.resolve_company_ticker", return_value="LULU"): assert agent._normalize_price_topic("Lululemon shares") == "Lululemon shares (LULU)" def test_an_unverifiable_company_gains_nothing(self): """No ticker is appended when nothing tradeable can be confirmed.""" - with patch("tickers.resolve_company_ticker", return_value=None): + with patch("palmer.tickers.resolve_company_ticker", return_value=None): assert agent._normalize_price_topic("Stripe stock") == "Stripe stock" def test_a_news_topic_never_pays_for_a_lookup(self): - with patch("llm.client") as client: + with patch("palmer.llm.client") as client: for t in ("AI news", "St. Louis Cardinals", "Kirkwood weather"): assert agent._normalize_price_topic(t) == t client.messages.create.assert_not_called() def test_an_unresolvable_topic_is_left_alone(self): - with patch("tickers.resolve_company_ticker", return_value=None): + with patch("palmer.tickers.resolve_company_ticker", return_value=None): assert agent._normalize_price_topic("some obscure stock") == "some obscure stock" def test_empty_input_is_safe(self): @@ -175,13 +175,13 @@ def test_a_failure_to_invalidate_does_not_break_the_reply(self): assert "except" in block, "the reply matters more than the cache stamp" def test_the_tool_description_covers_site_vocabulary(self): - from tools_def import TOOLS + from palmer.tools_def import TOOLS d = next(t for t in TOOLS if t["name"] == "update_morning_briefing")["description"].lower() for word in ("markets", "site", "page", "morning"): assert word in d, f"users say {word!r} and mean this tool" def test_the_prompt_separates_asking_a_price_from_tracking_one(self): - import prompts + from palmer import prompts block = prompts.SYSTEM_PROMPT.lower() assert "one-off" in block and "update_morning_briefing" in block @@ -197,10 +197,10 @@ class TestPalmerCanSeeTheReminders: def _system(self, pending): from unittest.mock import patch - import agent + from palmer import agent with patch.object(agent, "get_profile", return_value={"timezone": "America/Chicago"}), \ - patch("db.get_pending_reminders", return_value=pending), \ + patch("palmer.db.get_pending_reminders", return_value=pending), \ patch.object(agent, "get_user_watches", return_value=[]), \ patch.object(agent, "get_user_price_watches", return_value=[]): return agent._build_system(self.PHONE) @@ -234,9 +234,9 @@ def test_no_reminders_adds_nothing(self): def test_a_failed_read_does_not_break_the_turn(self): from unittest.mock import patch - import agent + from palmer import agent with patch.object(agent, "get_profile", return_value={}), \ - patch("db.get_pending_reminders", side_effect=RuntimeError("db down")), \ + patch("palmer.db.get_pending_reminders", side_effect=RuntimeError("db down")), \ patch.object(agent, "get_user_watches", return_value=[]), \ patch.object(agent, "get_user_price_watches", return_value=[]): assert agent._build_system(self.PHONE) @@ -250,32 +250,32 @@ class TestACancelSaysWhatItTook: PHONE = "+15550008888" def _seed(self): - import db + from palmer import db db.cancel_reminders(self.PHONE) for text in ("call mom", "call the vet", "move the car"): db.save_reminder(self.PHONE, text, "2099-01-01T12:00:00+00:00") def test_it_returns_the_texts_that_went(self): - import db + from palmer import db self._seed() gone = db.cancel_reminders_named(self.PHONE, "call") assert sorted(gone) == ["call mom", "call the vet"] def test_the_unmatched_one_survives(self): - import db + from palmer import db self._seed() db.cancel_reminders_named(self.PHONE, "call") left = [r["text"] for r in db.get_pending_reminders(self.PHONE)] assert left == ["move the car"] def test_the_count_form_still_works(self): - import db + from palmer import db self._seed() assert db.cancel_reminders(self.PHONE) == 3 def test_the_dispatch_names_them(self): import inspect - import agent + from palmer import agent block = inspect.getsource(agent.get_reply).split('"cancel_reminders"')[1] \ .split("elif b.name")[0] assert "cancel_reminders_named" in block diff --git a/test_onboard.py b/tests/test_onboard.py similarity index 84% rename from test_onboard.py rename to tests/test_onboard.py index 5c183fc..fc56434 100644 --- a/test_onboard.py +++ b/tests/test_onboard.py @@ -9,8 +9,8 @@ import pytest -import db -import onboard +from palmer import db +from palmer import onboard @pytest.fixture @@ -27,7 +27,7 @@ class _Form(dict): class TestStart: def test_mints_a_token_and_parks_a_stub(self, store): - with patch("home.save") as save, patch("home.load", return_value=None): + with patch("palmer.home.save") as save, patch("palmer.home.load", return_value=None): url = onboard.start("+15550001111") assert url.startswith("https://palmer.test/h/") token, payload = save.call_args[0] @@ -41,12 +41,12 @@ def test_no_app_url_means_no_link(self, store, monkeypatch): def test_does_not_clobber_an_existing_page(self, store): """A user who already has a payload is not a new user; never overwrite a live page with a setup stub.""" - with patch("home.save") as save, patch("home.load", return_value={"built_at": 1}): + with patch("palmer.home.save") as save, patch("palmer.home.load", return_value={"built_at": 1}): onboard.start("+15550001111") save.assert_not_called() def test_never_raises(self, store): - with patch("home.home_token", side_effect=RuntimeError("db down")): + with patch("palmer.home.home_token", side_effect=RuntimeError("db down")): assert onboard.start("+15550001111") is None @@ -54,11 +54,11 @@ class TestApply: def _apply(self, phone, form, city_topics=("Austin local news", "National and international news")): payload = {"phone": phone, "setup_pending": True} - with patch("morning.default_topics", return_value=list(city_topics)), \ - patch("home.save") as save, \ + with patch("palmer.morning.default_topics", return_value=list(city_topics)), \ + patch("palmer.home.save") as save, \ patch.object(onboard, "_build_async") as build, \ - patch("agent._normalize_price_topic", side_effect=lambda t: t), \ - patch("userprofile._eager_build_home"): + patch("palmer.agent._normalize_price_topic", side_effect=lambda t: t), \ + patch("palmer.userprofile._eager_build_home"): ok = onboard.apply("tok", payload, form) return ok, save, build @@ -90,7 +90,7 @@ def test_timezone_is_derived_from_the_typed_city(self, store): """Without it every local_today() call degrades to UTC and the morning job has no local clock to aim at.""" form = _Form({"name": "Jeff", "city": "Austin, TX"}) - with patch("userprofile._derive_timezone", return_value="America/Chicago"): + with patch("palmer.userprofile._derive_timezone", return_value="America/Chicago"): self._apply("+15550002224", form) assert db.get_profile("+15550002224")["timezone"] == "America/Chicago" @@ -124,8 +124,8 @@ def test_closes_the_window_before_writing(self, store): class TestTopics: def _topics(self, city, follows, base=("National and international news",)): - with patch("morning.default_topics", return_value=list(base)), \ - patch("agent._normalize_price_topic", side_effect=lambda t: t): + with patch("palmer.morning.default_topics", return_value=list(base)), \ + patch("palmer.agent._normalize_price_topic", side_effect=lambda t: t): return onboard._topics_for(city, follows) def test_free_text_splits_on_commas_not_and(self): @@ -145,15 +145,15 @@ def test_capped(self): def test_each_entry_gets_the_texted_topic_normalization(self): """"nvidia stock" typed on the form resolves a ticker exactly as it would texted — same pass, so Markets renders either way.""" - with patch("morning.default_topics", return_value=[]), \ - patch("agent._normalize_price_topic", + with patch("palmer.morning.default_topics", return_value=[]), \ + patch("palmer.agent._normalize_price_topic", side_effect=lambda t: f"{t} (NVDA)" if "nvidia" in t.lower() else t): assert onboard._topics_for("Austin", "nvidia stock, AI") == [ "nvidia stock (NVDA)", "AI"] def test_normalizer_failure_keeps_the_raw_topic(self): - with patch("morning.default_topics", return_value=[]), \ - patch("agent._normalize_price_topic", side_effect=RuntimeError("yahoo down")): + with patch("palmer.morning.default_topics", return_value=[]), \ + patch("palmer.agent._normalize_price_topic", side_effect=RuntimeError("yahoo down")): assert onboard._topics_for("Austin", "Eagles") == ["Eagles"] @@ -195,13 +195,13 @@ class TestWhereTheLinkGoesOut: def test_message_one_appends_nothing(self): import inspect - import main + from palmer import main src = inspect.getsource(main._handle_sms_inner) assert "onboard" not in src and "setup" not in src def test_get_my_page_serves_the_setup_page_when_there_is_no_city(self): import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) branch = src[src.index('b.name == "get_my_page"'):] branch = branch[:branch.index("elif b.name")] @@ -210,7 +210,7 @@ def test_get_my_page_serves_the_setup_page_when_there_is_no_city(self): assert "Do NOT ask for their name or city" in branch def test_prompt_routes_setup_through_get_my_page(self): - from prompts import SYSTEM_PROMPT + from palmer.prompts import SYSTEM_PROMPT assert "call get_my_page — it returns their setup page" in SYSTEM_PROMPT assert "what should I call you, and what city are you in" not in SYSTEM_PROMPT assert "THE SETUP LINK" not in SYSTEM_PROMPT @@ -223,13 +223,13 @@ class TestCityLandingInChat: while Palmer said "local news". The topic is added where the city lands.""" def _land(self, store, phone, before): - import userprofile + from palmer import userprofile db.upsert_profile(phone, before) - with patch("morning.default_topics", + with patch("palmer.morning.default_topics", side_effect=lambda c: ["Austin local news", "National and international news"] if c else ["National and international news"]), \ - patch("userprofile._derive_timezone", return_value="America/Chicago"), \ - patch("userprofile._eager_build_home"): + patch("palmer.userprofile._derive_timezone", return_value="America/Chicago"), \ + patch("palmer.userprofile._eager_build_home"): userprofile._apply_profile_updates(phone, db.get_profile(phone), {"city": "Austin, TX"}) return db.get_profile(phone)["morning_topics"] @@ -245,9 +245,9 @@ def test_not_seeded_for_someone_who_never_turned_mornings_on(self, store): def test_eager_build_replaces_a_parked_setup_form(self, store): """Got the link, never tapped it, then said their city in chat: the stub must not block the build or their address stays a form.""" - import userprofile - with patch("home.home_token", return_value="tok"), \ - patch("home.load", return_value={"phone": "+1", "setup_pending": True}), \ - patch("home.rebuild") as rebuild: + from palmer import userprofile + with patch("palmer.home.home_token", return_value="tok"), \ + patch("palmer.home.load", return_value={"phone": "+1", "setup_pending": True}), \ + patch("palmer.home.rebuild") as rebuild: userprofile._eager_build_home("+15550003333") rebuild.assert_called_once() diff --git a/test_opening.py b/tests/test_opening.py similarity index 93% rename from test_opening.py rename to tests/test_opening.py index dc74b69..d02937b 100644 --- a/test_opening.py +++ b/tests/test_opening.py @@ -11,8 +11,8 @@ """ from unittest.mock import patch, MagicMock -import opening -import page +from palmer import opening +from palmer import page LA = {"city": "Culver City", "timezone": "America/Los_Angeles"} @@ -40,7 +40,7 @@ def _seed_caches(local_rows, screen_rows, tz="America/Los_Angeles", coords=(34.0 and TMDB do not. Seeding only two of the three leaves the third to fetch for real, which is how these tests once took 79 seconds. """ - from timeutil import local_today + from palmer.timeutil import local_today opening._clear_caches() today = local_today(tz) opening._candidate_cache[(*coords, opening._week_key(today))] = [] @@ -68,9 +68,9 @@ def _cache_hit_only(): boom = AssertionError("seeded cache missed — opening_snapshot computed a " "different key than _seed_caches wrote (check the " "profile timezone against the seed tz)") - with patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ + with patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ patch.object(opening, "_http_get_json", side_effect=boom), \ - patch("datafeeds._search_raw", side_effect=boom), \ + patch("palmer.datafeeds._search_raw", side_effect=boom), \ patch.object(opening, "client", MagicMock(**{"messages.create.side_effect": boom})): yield @@ -90,8 +90,8 @@ def _offline(curated=None, tavily=None, http=None): _http_get_json=http or (lambda url, timeout=10: {}), client=MagicMock(**{"messages.create.return_value": _resp(curated if curated is not None else CURATED)}), - ), patch("datafeeds._search_raw", return_value=tavily or []), \ - patch("weather._geocode", side_effect=lambda c: COORDS[c]) + ), patch("palmer.datafeeds._search_raw", return_value=tavily or []), \ + patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]) def _run(profile, **kw): @@ -106,8 +106,8 @@ class TestTheCostModel: def test_two_users_in_one_metro_share_a_single_fetch(self): opening._clear_caches() with patch.object(opening, "_http_get_json", return_value={}), \ - patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", return_value=[]) as tav, \ + patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[]) as tav, \ patch.object(opening, "client") as cl: cl.messages.create.return_value = _resp(CURATED) opening.opening_snapshot(LA) @@ -118,8 +118,8 @@ def test_two_users_in_one_metro_share_a_single_fetch(self): def test_a_different_metro_does_not_reuse_the_first(self): opening._clear_caches() with patch.object(opening, "_http_get_json", return_value={}), \ - patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", return_value=[]) as tav, \ + patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[]) as tav, \ patch.object(opening, "client") as cl: cl.messages.create.return_value = _resp(CURATED) opening.opening_snapshot(LA) @@ -134,8 +134,8 @@ def test_culver_city_and_woodland_hills_land_in_one_bucket(self): def test_a_user_with_no_city_costs_nothing(self): opening._clear_caches() - with patch("weather._geocode") as geo, \ - patch("datafeeds._search_raw") as tav, \ + with patch("palmer.weather._geocode") as geo, \ + patch("palmer.datafeeds._search_raw") as tav, \ patch.object(opening, "_http_get_json") as http, \ patch.object(opening, "client") as cl: assert opening.opening_snapshot({"timezone": "America/Chicago"}) == [] @@ -149,7 +149,7 @@ class TestDegradation: def test_a_geocode_failure_returns_nothing_rather_than_raising(self): opening._clear_caches() - with patch("weather._geocode", side_effect=RuntimeError("boom")): + with patch("palmer.weather._geocode", side_effect=RuntimeError("boom")): assert opening.opening_snapshot(LA) == [] def test_missing_keys_drop_their_rows_and_keep_the_rest(self): @@ -170,8 +170,8 @@ def test_a_curation_failure_yields_no_rows_rather_than_raw_junk(self): """If the taste gate is down, showing the unfiltered firehose is worse than showing nothing.""" opening._clear_caches() - with patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", return_value=[{"title": "x", "url": "https://a.com/1"}]), \ + with patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[{"title": "x", "url": "https://a.com/1"}]), \ patch.object(opening, "_http_get_json", return_value={}), \ patch.object(opening, "client") as cl: cl.messages.create.side_effect = RuntimeError("haiku down") @@ -179,8 +179,8 @@ def test_a_curation_failure_yields_no_rows_rather_than_raw_junk(self): def test_unparseable_curation_output_is_dropped(self): opening._clear_caches() - with patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", return_value=[{"title": "x", "url": "https://a.com/1"}]), \ + with patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[{"title": "x", "url": "https://a.com/1"}]), \ patch.object(opening, "_http_get_json", return_value={}), \ patch.object(opening, "client") as cl: cl.messages.create.return_value = _resp({"rows": [{"subtitle": "no title"}]}) @@ -294,8 +294,8 @@ class TestLongLeadEvents: def test_a_snapshot_makes_two_ticketmaster_calls(self): opening._clear_caches() with patch.object(opening, "TICKETMASTER_API_KEY", "k"), \ - patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", return_value=[]), \ + patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[]), \ patch.object(opening, "_http_get_json", return_value={}) as http, \ patch.object(opening, "client") as cl: cl.messages.create.return_value = _resp({"rows": []}) @@ -335,7 +335,7 @@ def test_a_past_dated_row_is_dropped_on_read(self): # machine's zone and a bare {"city": ...} profile reads the UTC day, # which is how this test spent its evenings fetching real concerts. from datetime import timedelta - from timeutil import local_today + from palmer.timeutil import local_today today = local_today("America/Los_Angeles") _seed_caches([ {"kind": "event", "title": "Already Happened", "subtitle": "", "when": "", @@ -350,7 +350,7 @@ def test_a_past_dated_row_is_dropped_on_read(self): assert "Already Happened" not in titles def test_todays_date_survives(self): - from timeutil import local_today + from palmer.timeutil import local_today today = local_today("America/Los_Angeles") _seed_caches([ {"kind": "event", "title": "Tonight", "subtitle": "", "when": "", @@ -366,8 +366,8 @@ def test_curate_stores_a_valid_date_from_the_model(self): from datetime import date, timedelta soon = (date.today() + timedelta(days=3)).isoformat() opening._clear_caches() - with patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", + with patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[{"title": "x", "url": "https://a.com/1"}]), \ patch.object(opening, "_http_get_json", return_value={}), \ patch.object(opening, "client") as cl: @@ -379,8 +379,8 @@ def test_curate_stores_a_valid_date_from_the_model(self): def test_curate_drops_a_malformed_date_rather_than_raising(self): opening._clear_caches() - with patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", + with patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[{"title": "x", "url": "https://a.com/1"}]), \ patch.object(opening, "_http_get_json", return_value={}), \ patch.object(opening, "client") as cl: @@ -477,8 +477,8 @@ def test_screens_cost_no_model_call(self): from datetime import date payload = {"results": [dict(r, release_date=date.today().isoformat()) for r in self.TMDB["results"]]} - with patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", return_value=[]), \ + with patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[]), \ patch.object(opening, "TMDB_API_KEY", "k"), \ patch.object(opening, "TICKETMASTER_API_KEY", ""), \ patch.object(opening, "_http_get_json", return_value=payload), \ @@ -495,8 +495,8 @@ def test_screens_are_capped_so_they_cannot_crowd_out_the_local_rows(self): from datetime import date payload = {"results": [dict(r, release_date=date.today().isoformat()) for r in self.TMDB["results"]]} - with patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", return_value=[]), \ + with patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[]), \ patch.object(opening, "TMDB_API_KEY", "k"), \ patch.object(opening, "TICKETMASTER_API_KEY", ""), \ patch.object(opening, "_http_get_json", return_value=payload), \ @@ -541,9 +541,9 @@ def test_a_cache_hit_costs_no_model_call(self): nothing was going to be searched for.""" opening._clear_caches() _seed_caches(self.POOL, self.SCREENS) - with patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ + with patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ patch.object(opening, "client") as cl, \ - patch("datafeeds._search_raw") as tav, \ + patch("palmer.datafeeds._search_raw") as tav, \ patch.object(opening, "_http_get_json") as http: rows = opening.opening_snapshot(dict(LA)) assert rows, "the cached rows must still come back" @@ -577,8 +577,8 @@ def test_two_users_with_different_tastes_still_share_one_fetch(self): """The cost model. Filtering is per user; fetching is per metro.""" opening._clear_caches() with patch.object(opening, "_http_get_json", return_value={}), \ - patch("weather._geocode", side_effect=lambda c: COORDS[c]), \ - patch("datafeeds._search_raw", + patch("palmer.weather._geocode", side_effect=lambda c: COORDS[c]), \ + patch("palmer.datafeeds._search_raw", return_value=[{"title": "gig", "url": "https://t.com/1"}]) as tav, \ patch.object(opening, "client") as cl: cl.messages.create.return_value = _resp( @@ -593,7 +593,7 @@ def test_two_users_with_different_tastes_still_share_one_fetch(self): class TestTheKindsDispatch: def _apply(self, profile, tool_input): - import agent + from palmer import agent updates = {} note = agent._apply_opening_kinds(profile, tool_input, updates) return updates.get("morning_prefs", {}), note @@ -622,21 +622,21 @@ def test_an_unknown_word_is_ignored_rather_than_stored(self): assert prefs["opening_kinds"] == ["local", "event"] def test_the_tool_exposes_both_directions(self): - from tools_def import TOOLS + from palmer.tools_def import TOOLS props = next(t for t in TOOLS if t["name"] == "update_morning_briefing")["input_schema"]["properties"] for k in ("opening_add", "opening_remove"): assert k in props assert set(props[k]["items"]["enum"]) == {"restaurants", "events", "movies"} def test_the_prompt_routes_these_away_from_topics(self): - import prompts + from palmer import prompts block = prompts.SYSTEM_PROMPT assert "opening_add" in block and "opening_remove" in block def test_a_kinds_change_expires_the_cached_rows(self): """Otherwise they keep seeing the concerts they just asked to stop.""" import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) block = src.split('update_morning_briefing"')[1].split("elif b.name")[0] assert "morning_prefs" in block and "opening" in block @@ -684,7 +684,7 @@ def test_the_paid_cache_is_weekly_and_the_free_ones_are_daily(self): """Only the Tavily search costs money. Ticketmaster is 5,000/day free and TMDB is free, so keying those weekly bought nothing and froze the section.""" - from timeutil import local_today + from palmer.timeutil import local_today _seed_caches(self.POOL6, self.SCREENS6) today = local_today("America/Los_Angeles") assert any(opening._week_key(today) in str(k) for k in opening._candidate_cache) @@ -732,7 +732,7 @@ def _rows(self, today): return near + far def test_a_far_row_survives_a_full_near_term_window(self): - from timeutil import local_today + from palmer.timeutil import local_today today = local_today("America/Los_Angeles") _seed_caches(self._rows(today), []) with _cache_hit_only(): @@ -740,7 +740,7 @@ def test_a_far_row_survives_a_full_near_term_window(self): assert "Kacey Musgraves" in [r["title"] for r in rows] def test_near_term_still_leads(self): - from timeutil import local_today + from palmer.timeutil import local_today today = local_today("America/Los_Angeles") _seed_caches(self._rows(today), []) with _cache_hit_only(): @@ -748,7 +748,7 @@ def test_near_term_still_leads(self): assert rows[0]["title"].startswith("Soon"), "advance notice must not lead" def test_no_far_candidate_means_no_slot_wasted(self): - from timeutil import local_today + from palmer.timeutil import local_today today = local_today("America/Los_Angeles") near = [r for r in self._rows(today) if r["title"].startswith("Soon")] _seed_caches(near, []) @@ -817,7 +817,7 @@ def test_untrusted_text_is_escaped(self): class TestTheMorningLineCanSeeIt: def test_the_digest_carries_opening_rows(self): - import morning + from palmer import morning d = morning._payload_digest({"opening": [ {"title": "Mamele's", "subtitle": "Peruvian counter", "when": "opened this week"}]}) assert "Mamele's" in d and "Opening near them" in d diff --git a/test_page.py b/tests/test_page.py similarity index 98% rename from test_page.py rename to tests/test_page.py index f7db41d..0ab1340 100644 --- a/test_page.py +++ b/tests/test_page.py @@ -8,7 +8,7 @@ import re from unittest.mock import patch -import page +from palmer import page BASE = {"city": "Kirkwood, MO", "weather": {"temp_now": 81.0, "description": "Clear"}, "fetched": {}, "tracking": {}} @@ -140,18 +140,18 @@ class TestThePreviewImageCanChange: def test_the_image_url_carries_a_content_stamp(self): import inspect - import main + from palmer import main src = inspect.getsource(main.home_page) assert "_card_fingerprint" in src and "?v=" in src def test_the_png_answers_a_revalidating_cache(self): import inspect - import main + from palmer import main src = inspect.getsource(main.home_png) assert "ETag" in src def test_the_stamp_moves_when_the_card_would_look_different(self): - import artifacts + from palmer import artifacts base = {"city": "Kirkwood, MO", "weather": {"temp_now": 70, "high": 80, "low": 60}, "prices": [], "headlines": [], "opening": []} warmer = dict(base, weather={"temp_now": 95, "high": 100, "low": 70}) @@ -170,7 +170,7 @@ class TestSectionLabelsAreOneWord: def _labels(self) -> list[str]: import inspect import re - import page as page_mod + from palmer import page as page_mod # Labels are written straight into the markup, so read them from source # rather than rendering every possible payload permutation. src = inspect.getsource(page_mod) @@ -192,7 +192,7 @@ def test_the_card_image_uses_the_same_words(self): """cards.py and page.py render from one payload and must not disagree about what a section is called.""" import inspect - import cards + from palmer import cards src = inspect.getsource(cards) assert '"NEWS"' in src and '"TODAY"' not in src @@ -390,5 +390,5 @@ def test_the_description_leads_with_the_weather_when_named(self): assert desc.startswith("81\u00b0") def test_the_icon_is_a_png(self): - import cards + from palmer import cards assert cards.render_icon()[:8] == b"\x89PNG\r\n\x1a\n" diff --git a/test_phantom_history.py b/tests/test_phantom_history.py similarity index 92% rename from test_phantom_history.py rename to tests/test_phantom_history.py index 4b1787d..34acd86 100644 --- a/test_phantom_history.py +++ b/tests/test_phantom_history.py @@ -16,7 +16,7 @@ """ from unittest.mock import patch -import db +from palmer import db PHONE = "+15550001111" @@ -56,7 +56,7 @@ class TestPriceAlertsReachHistory: def _run(self, tmp_path, monkeypatch, sent: bool): _fresh(tmp_path, monkeypatch) - import shopping + from palmer import shopping db.save_price_watch(PHONE, "Nike Pegasus 40", None) w = dict(db.get_user_price_watches(PHONE)[0]) w["baseline_price"] = 120.0 @@ -69,8 +69,8 @@ def _run(self, tmp_path, monkeypatch, sent: bool): patch.object(shopping, "check_price", return_value={ "price": 90.0, "url": "https://m.example/x", "merchant": "Nike"}), \ patch.object(shopping, "_draft_alert", return_value="pegasus dropped to $90"), \ - patch("userprofile._is_duplicate_subject", return_value=False), \ - patch("sms_util.send_sms", return_value=sent): + patch("palmer.userprofile._is_duplicate_subject", return_value=False), \ + patch("palmer.sms_util.send_sms", return_value=sent): shopping.run_price_watches() return [m for m in db.get_history(PHONE) if m["role"] == "assistant"] @@ -97,10 +97,12 @@ def test_no_proactive_sender_calls_ensure_sms(self): price check texted 'something went sideways on my end, try again' to someone who had asked for nothing.""" import pathlib + import palmer + pkg = pathlib.Path(palmer.__file__).parent proactive = ["watches.py", "followup.py", "morning.py", "scorewatch.py", - "shopping.py", "flightwatch.py", "send_reminders.py"] + "shopping.py", "flightwatch.py", "reminders.py"] for name in proactive: - src = pathlib.Path(name).read_text() + src = (pkg / name).read_text() calls = [ln for ln in src.splitlines() if "ensure_sms(" in ln and not ln.strip().startswith("#")] assert not calls, (name, calls) diff --git a/test_price_alert.py b/tests/test_price_alert.py similarity index 94% rename from test_price_alert.py rename to tests/test_price_alert.py index 0b6a51a..9dfaded 100644 --- a/test_price_alert.py +++ b/tests/test_price_alert.py @@ -6,7 +6,7 @@ """ from unittest.mock import patch, MagicMock -import price_alert +from palmer import price_alert def _resp(text: str) -> MagicMock: @@ -64,8 +64,8 @@ def _capture(**kw): seen.update(kw) m = MagicMock(); m.content = [MagicMock(text="shake went up to 63.50")] return m - with patch("price_alert.client.messages.create", side_effect=_capture), \ - patch("price_alert._build_system", return_value="sys"): + with patch("palmer.price_alert.client.messages.create", side_effect=_capture), \ + patch("palmer.price_alert._build_system", return_value="sys"): price_alert.draft_price_alert("Protein", {"price": 63.50, "merchant": "Z"}, WATCH, "rise") prompt = seen["messages"][0]["content"] @@ -147,14 +147,14 @@ def test_build_system_failure_does_not_lose_the_alert(self): class TestBothSourcesDelegate: def test_shopping_passes_no_link(self): - import shopping - with patch("price_alert.draft_price_alert", return_value="line") as d: + from palmer import shopping + with patch("palmer.price_alert.draft_price_alert", return_value="line") as d: shopping._draft_alert("Protein", CURRENT, WATCH, "drop") assert d.call_args.kwargs.get("link") is None def test_amazon_passes_link_and_label(self): - import amazon - with patch("price_alert.draft_price_alert", return_value="line") as d: + from palmer import amazon + with patch("palmer.price_alert.draft_price_alert", return_value="line") as d: amazon.draft_alert("Protein", CURRENT, WATCH, "drop") assert d.call_args.kwargs["link"] == CURRENT["url"] assert d.call_args.kwargs["source_label"] == "Amazon" diff --git a/test_price_daily_cap.py b/tests/test_price_daily_cap.py similarity index 90% rename from test_price_daily_cap.py rename to tests/test_price_daily_cap.py index 61f1468..e61b935 100644 --- a/test_price_daily_cap.py +++ b/tests/test_price_daily_cap.py @@ -4,7 +4,7 @@ from datetime import date, timedelta from unittest.mock import patch -from shopping import _daily_ok, _is_marketplace_thirdparty, PRICE_DAILY_ALERT_MAX, check_price +from palmer.shopping import _daily_ok, _is_marketplace_thirdparty, PRICE_DAILY_ALERT_MAX, check_price class TestDailyOk: @@ -62,8 +62,8 @@ def _capture(*args, **kwargs): seen.extend(args[1] if len(args) > 1 else kwargs.get("results", [])) return None - with patch("shopping._serpapi_search", return_value=results), \ - patch("shopping._pick_best_match", side_effect=_capture) as mock_pick: + with patch("palmer.shopping._serpapi_search", return_value=results), \ + patch("palmer.shopping._pick_best_match", side_effect=_capture) as mock_pick: check_price("AirPods Pro 2") mock_pick.assert_called_once() diff --git a/test_price_watches.py b/tests/test_price_watches.py similarity index 95% rename from test_price_watches.py rename to tests/test_price_watches.py index dbabeec..b8ce724 100644 --- a/test_price_watches.py +++ b/tests/test_price_watches.py @@ -7,7 +7,7 @@ from unittest.mock import patch -from shopping import ( +from palmer.shopping import ( _cooldown_ok, _should_alert, MOVE_MIN_ABS, _filter_and_sort, _is_marketplace_thirdparty, _brand_tokens, browse_shop, ) @@ -240,7 +240,7 @@ def test_knowledge_graph_wins_when_brand_matches(self): organic=[{"title": "Buzzfeed roundup", "link": "https://www.buzzfeed.com/best-tees"}], knowledge_graph={"title": "Madewell", "website": "https://www.madewell.com"}, ) - with patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi._http_get_json", return_value=payload): out = browse_shop("Madewell mens tee shirts") assert "madewell.com" in out @@ -249,7 +249,7 @@ def test_brand_organic_beats_top_ranked_listicle(self): {"title": "Best T-Shirts 2026", "link": "https://www.buzzfeed.com/tees"}, {"title": "Men's T-Shirts | Madewell", "link": "https://www.madewell.com/mens/tshirts"}, ]) - with patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi._http_get_json", return_value=payload): out = browse_shop("Madewell mens tee shirts") assert "madewell.com/mens/tshirts" in out assert "buzzfeed" not in out @@ -261,7 +261,7 @@ def test_aggregators_skipped(self): {"title": "Reddit thread", "link": "https://www.reddit.com/r/malefashionadvice/x"}, {"title": "Actual store", "link": "https://www.uniqlo.com/us/en/men/tops/t-shirts"}, ]) - with patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi._http_get_json", return_value=payload): out = browse_shop("mens tee shirts") assert "uniqlo.com" in out assert "pinterest" not in out @@ -272,7 +272,7 @@ def test_amazon_search_page_skipped_product_page_allowed(self): {"title": "Amazon search", "link": "https://www.amazon.com/s?k=wool+coat"}, {"title": "Wool coat", "link": "https://www.amazon.com/dp/B0XXXX"}, ]) - with patch("serpapi._http_get_json", return_value=search_page): + with patch("palmer.serpapi._http_get_json", return_value=search_page): out = browse_shop("wool coat") assert "/dp/B0XXXX" in out assert "/s?k=" not in out @@ -282,7 +282,7 @@ def test_falls_back_to_top_valid_organic_when_no_brand_match(self): {"title": "Random shop", "link": "https://www.someshop.com/x"}, {"title": "Another shop", "link": "https://www.othershop.com/y"}, ]) - with patch("serpapi._http_get_json", return_value=payload): + with patch("palmer.serpapi._http_get_json", return_value=payload): out = browse_shop("wool coat") assert "someshop.com" in out @@ -292,22 +292,22 @@ def test_empty_organic_returns_no_result_string(self): This used to assert the literal "No browse result found", which told the model nothing about what to do next — the shape that gets paraphrased into a refusal and then into another store.""" - import guards - with patch("serpapi._http_get_json", return_value=self._serp()): + from palmer import guards + with patch("palmer.serpapi._http_get_json", return_value=self._serp()): out = browse_shop("Madewell tees") assert "DO have this" in out assert "confirm the brand" in out assert not guards.redirects_elsewhere(out) def test_serpapi_failure_returns_no_result_string(self): - import guards - with patch("serpapi._http_get_json", return_value=None): + from palmer import guards + with patch("palmer.serpapi._http_get_json", return_value=None): out = browse_shop("Madewell tees") assert "DO have this" in out assert not guards.redirects_elsewhere(out) def test_missing_api_key_returns_unavailable(self): - with patch("serpapi.API_KEY", ""): + with patch("palmer.serpapi.API_KEY", ""): out = browse_shop("Madewell tees") # The string must not imply the capability does not exist — that is what # the model paraphrased into "I can't do that, try somewhere else". @@ -331,8 +331,8 @@ class TestPriceWatchSchedule: def _trigger(self): from unittest.mock import patch with patch("apscheduler.schedulers.background.BackgroundScheduler.start"): - import main - from shopping import run_price_watches + from palmer import main + from palmer.shopping import run_price_watches jobs = [j for j in main._scheduler.get_jobs() if j.func is run_price_watches] assert len(jobs) == 1, "expected exactly one run_price_watches job" return jobs[0].trigger diff --git a/test_profile_freshness.py b/tests/test_profile_freshness.py similarity index 97% rename from test_profile_freshness.py rename to tests/test_profile_freshness.py index a431932..9016924 100644 --- a/test_profile_freshness.py +++ b/tests/test_profile_freshness.py @@ -19,11 +19,11 @@ from datetime import date, timedelta from unittest.mock import patch -import agent -import artifacts -import home -import userprofile -from timeutil import local_today +from palmer import agent +from palmer import artifacts +from palmer import home +from palmer import userprofile +from palmer.timeutil import local_today def _aged(field, days, value="something"): @@ -158,13 +158,13 @@ def test_a_missing_timezone_asserts_no_day_at_all(self): assert artifacts._card_now({"timezone": "Not/AZone"}) is None def test_the_card_still_renders_without_a_day(self): - import cards + from palmer import cards png = cards.render_dashboard(city="Austin", weather=None, traffic=None, prices=None, headlines=None, show_date=False) assert png[:4] == b"\x89PNG" def test_the_page_omits_it_too(self): - import page + from palmer import page assert page._local_day(None) == "" assert page._local_day("Not/AZone") == "" assert page._local_day("America/Chicago") != "" @@ -186,6 +186,6 @@ def test_the_prompt_forbids_meta_facts(self): """follow_up held "confirm_morning_briefing_delivery_is_consistent_daily" for one user and "Maintain single-message format" for another — notes about the product, read back as facts about a person.""" - import prompts + from palmer import prompts assert "never about Palmer's own operation" in prompts.EXTRACT_PROMPT assert "return nothing for these fields" in prompts.EXTRACT_PROMPT diff --git a/test_profile_schema.py b/tests/test_profile_schema.py similarity index 92% rename from test_profile_schema.py rename to tests/test_profile_schema.py index fc930c1..bed8737 100644 --- a/test_profile_schema.py +++ b/tests/test_profile_schema.py @@ -6,9 +6,9 @@ """ from unittest.mock import patch, MagicMock -import db -import userprofile -import migrate_profile_prune as mig +from palmer import db +from palmer import userprofile +from scripts import migrate_profile_prune as mig class TestAllowList: @@ -133,11 +133,11 @@ class TestNameIsExtracted: asking for a name it had already been told twice.""" def test_the_schema_demands_identity_explicitly(self): - from prompts import EXTRACT_PROMPT + from palmer.prompts import EXTRACT_PROMPT assert "IDENTITY FIRST" in EXTRACT_PROMPT def test_it_names_the_phrasings_people_actually_use(self): - from prompts import EXTRACT_PROMPT + from palmer.prompts import EXTRACT_PROMPT low = EXTRACT_PROMPT.lower() for phrase in ("my name is", "i'm jeff", "call me"): assert phrase in low @@ -145,12 +145,12 @@ def test_it_names_the_phrasings_people_actually_use(self): def test_it_overrides_the_skip_when_already_present_instinct(self): """The failure was the model deciding the name was too obvious to bother returning, or assuming it must already be stored.""" - from prompts import EXTRACT_PROMPT + from palmer.prompts import EXTRACT_PROMPT low = EXTRACT_PROMPT.lower() assert "even when" in low and "already" in low def test_name_is_still_an_allowed_field(self): - from userprofile import PROFILE_FIELDS + from palmer.userprofile import PROFILE_FIELDS assert "name" in PROFILE_FIELDS @@ -160,17 +160,17 @@ class TestCityPrecisionIsExtracted: no rule distinguishing "where they live" from "a place they mentioned".""" def test_the_schema_has_a_location_precision_rule(self): - from prompts import EXTRACT_PROMPT + from palmer.prompts import EXTRACT_PROMPT assert "LOCATION PRECISION" in EXTRACT_PROMPT def test_it_excludes_passing_mentions(self): - from prompts import EXTRACT_PROMPT + from palmer.prompts import EXTRACT_PROMPT low = EXTRACT_PROMPT.lower() for phrase in ("in passing", "traffic into la", "leave the existing value alone"): assert phrase in low def test_consolidate_prompt_also_guards_city(self): - from prompts import CONSOLIDATE_PROMPT + from palmer.prompts import CONSOLIDATE_PROMPT low = CONSOLIDATE_PROMPT.lower() assert '"city"' in low and "leave it unchanged" in low @@ -182,7 +182,7 @@ def test_city_regression_prints_old_and_new(self, tmp_path, monkeypatch, capsys) db.init_db() db.upsert_profile("+1557", {"city": "Culver City, CA"}) profile = db.get_profile("+1557") - with patch("home.rebuild") as rebuild: + with patch("palmer.home.rebuild") as rebuild: userprofile._apply_profile_updates("+1557", profile, {"city": "Los Angeles"}) out = capsys.readouterr().out assert "Culver City, CA" in out and "Los Angeles" in out @@ -201,9 +201,9 @@ def test_first_city_triggers_a_build(self, tmp_path, monkeypatch): db.init_db() db.upsert_profile("+1558", {}) profile = db.get_profile("+1558") - with patch("home.home_token", return_value="tok"), \ - patch("home.load", return_value=None), \ - patch("home.rebuild") as rebuild: + with patch("palmer.home.home_token", return_value="tok"), \ + patch("palmer.home.load", return_value=None), \ + patch("palmer.home.rebuild") as rebuild: userprofile._apply_profile_updates("+1558", profile, {"city": "Chicago"}) rebuild.assert_called_once_with("+1558", refresh_news=True) @@ -213,7 +213,7 @@ def test_city_correction_does_not_rebuild(self, tmp_path, monkeypatch): db.init_db() db.upsert_profile("+1559", {"city": "Culver City, CA"}) profile = db.get_profile("+1559") - with patch("home.rebuild") as rebuild: + with patch("palmer.home.rebuild") as rebuild: userprofile._apply_profile_updates("+1559", profile, {"city": "Los Angeles"}) rebuild.assert_not_called() @@ -223,7 +223,7 @@ def test_no_app_url_skips_the_build(self, tmp_path, monkeypatch): db.init_db() db.upsert_profile("+1560", {}) profile = db.get_profile("+1560") - with patch("home.rebuild") as rebuild: + with patch("palmer.home.rebuild") as rebuild: userprofile._apply_profile_updates("+1560", profile, {"city": "Chicago"}) rebuild.assert_not_called() @@ -233,9 +233,9 @@ def test_a_page_already_built_is_not_rebuilt_again(self, tmp_path, monkeypatch): db.init_db() db.upsert_profile("+1561", {}) profile = db.get_profile("+1561") - with patch("home.home_token", return_value="tok"), \ - patch("home.load", return_value={"city": "Chicago"}), \ - patch("home.rebuild") as rebuild: + with patch("palmer.home.home_token", return_value="tok"), \ + patch("palmer.home.load", return_value={"city": "Chicago"}), \ + patch("palmer.home.rebuild") as rebuild: userprofile._apply_profile_updates("+1561", profile, {"city": "Chicago"}) rebuild.assert_not_called() @@ -287,12 +287,12 @@ class TestNoFieldIsAlsoAnAlias: """ def test_an_alias_key_is_never_a_real_field(self): - import userprofile as up + from palmer import userprofile as up clashes = {k: v for k, v in up._PROFILE_ALIASES.items() if k in up.PROFILE_FIELDS} assert not clashes, f"these fields are silently rewritten on write: {clashes}" def test_the_structured_follow_lists_survive_a_normalise(self): - import userprofile as up + from palmer import userprofile as up for field in ("followed_teams", "shows"): row = [{"league": "nfl", "abbrev": "PHI", "name": "Philadelphia Eagles"}] out = up._canonical_updates({field: row}) @@ -301,6 +301,6 @@ def test_the_structured_follow_lists_survive_a_normalise(self): def test_the_tool_written_lists_are_not_in_the_extractor_schema(self): """They hold structured dicts written by tool dispatch. In the schema, Haiku fills them with prose and downstream code gets strings.""" - import prompts + from palmer import prompts for field in ("followed_teams", "shows", "weather_locations"): assert f'"{field}"' not in prompts.EXTRACT_PROMPT diff --git a/test_reaction_interpret.py b/tests/test_reaction_interpret.py similarity index 98% rename from test_reaction_interpret.py rename to tests/test_reaction_interpret.py index b55308e..dfd4bd4 100644 --- a/test_reaction_interpret.py +++ b/tests/test_reaction_interpret.py @@ -11,10 +11,10 @@ import pytest -import tapback +from palmer import tapback with patch("apscheduler.schedulers.background.BackgroundScheduler.start"): - import main + from palmer import main def _haiku(text: str) -> MagicMock: @@ -227,7 +227,7 @@ def test_decays_as_positives_push_negatives_out(self): assert recovered < heavy def test_followup_gap_stretches(self): - import followup + from palmer import followup from datetime import datetime, timedelta base = {"morning_onboarded": True, "timezone": "America/Chicago", "ongoing_threads": ["job offer"]} @@ -314,7 +314,7 @@ def test_failure_is_swallowed(self): class TestNoticeSurfacedAndCleared: def test_notice_appears_in_prompt(self): - import agent + from palmer import agent with patch.object(agent, "get_profile", return_value={"pending_preference_notice": "crypto"}), \ patch.object(agent, "get_user_watches", return_value=[]), \ @@ -324,7 +324,7 @@ def test_notice_appears_in_prompt(self): assert "thumbs-downed" in out def test_notice_absent_when_unset(self): - import agent + from palmer import agent with patch.object(agent, "get_profile", return_value={"name": "Mike"}), \ patch.object(agent, "get_user_watches", return_value=[]), \ patch.object(agent, "get_user_price_watches", return_value=[]): @@ -333,7 +333,7 @@ def test_notice_absent_when_unset(self): def test_notice_is_cleared_after_being_shown(self): """Otherwise Palmer mentions the dropped topic every single turn.""" - import agent + from palmer import agent stored = {} with patch.object(agent, "_update_profile"), \ patch.object(agent, "_consolidate_history"), \ @@ -348,7 +348,7 @@ class TestWatchCapLookupIsPerUser: fresh DB connection per call — the cap lookup must not be inside the loop.""" def test_profile_read_once_per_user_not_per_watch(self): - import watches + from palmer import watches rows = [{"phone": "+1555000111" + str(i % 2), "id": i, "last_alerted": None, "cooldown_hours": 4, "daily_alert_date": None, "daily_alert_count": 0, "queries": "[]", "description": "x", "genre": None, "story_state": None} @@ -362,7 +362,7 @@ def test_profile_read_once_per_user_not_per_watch(self): assert gp.call_count == 2, f"expected 1 profile read per user, got {gp.call_count}" def test_profile_failure_falls_back_to_default_cap(self): - import watches + from palmer import watches rows = [{"phone": "+15550001111", "id": 1, "last_alerted": None, "cooldown_hours": 4, "daily_alert_date": None, "daily_alert_count": 0, "queries": "[]", "description": "x", "genre": None, "story_state": None}] diff --git a/test_reminder_recurrence.py b/tests/test_reminder_recurrence.py similarity index 92% rename from test_reminder_recurrence.py rename to tests/test_reminder_recurrence.py index 4e0417d..390c305 100644 --- a/test_reminder_recurrence.py +++ b/tests/test_reminder_recurrence.py @@ -18,8 +18,8 @@ from unittest.mock import patch from zoneinfo import ZoneInfo -import db -from timeutil import next_occurrence +from palmer import db +from palmer.timeutil import next_occurrence CHI = ZoneInfo("America/Chicago") PHONE = "+15551234567" @@ -167,13 +167,13 @@ class TestSendLoop: """End to end through send_due_reminders with Twilio and the drafter stubbed.""" def _run(self, profile=None): - import send_reminders + from palmer import reminders sent = [] - with patch("sms_util.send_sms", side_effect=lambda p, b, **k: sent.append((p, b)) or True), \ - patch("send_reminders._personalize_reminder", side_effect=lambda p, t, pr: t), \ - patch("send_reminders.get_profile", return_value=profile or {"timezone": "America/Chicago"}), \ - patch("send_reminders.save_message"): - send_reminders.send_due_reminders() + with patch("palmer.sms_util.send_sms", side_effect=lambda p, b, **k: sent.append((p, b)) or True), \ + patch("palmer.reminders._personalize_reminder", side_effect=lambda p, t, pr: t), \ + patch("palmer.reminders.get_profile", return_value=profile or {"timezone": "America/Chicago"}), \ + patch("palmer.reminders.save_message"): + reminders.send_due_reminders() return sent def test_recurring_reminder_sends_then_rearms_into_the_future(self, tmp_path, monkeypatch): @@ -215,12 +215,12 @@ def test_a_failed_send_still_rearms(self, tmp_path, monkeypatch): past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat() db.save_reminder(PHONE, "Take your meds", past, "daily") - import send_reminders - with patch("sms_util.send_sms", return_value=False), \ - patch("send_reminders._personalize_reminder", side_effect=lambda p, t, pr: t), \ - patch("send_reminders.get_profile", return_value={"timezone": "America/Chicago"}), \ - patch("send_reminders.save_message"): - send_reminders.send_due_reminders() + from palmer import reminders + with patch("palmer.sms_util.send_sms", return_value=False), \ + patch("palmer.reminders._personalize_reminder", side_effect=lambda p, t, pr: t), \ + patch("palmer.reminders.get_profile", return_value={"timezone": "America/Chicago"}), \ + patch("palmer.reminders.save_message"): + reminders.send_due_reminders() assert len(_pending(PHONE)) == 1 @@ -264,7 +264,7 @@ class TestTheWritePathRefusesARecurrenceItCannotKeep: def _set_reminder(self, recurrence): """Run one set_reminder tool call and return (result string, saved rows).""" from unittest.mock import MagicMock - import agent + from palmer import agent saved = [] block = MagicMock() @@ -307,7 +307,7 @@ def test_an_unsupported_recurrence_saves_nothing(self): def test_the_model_is_told_what_it_can_use_instead(self): """A refusal with no alternative is how the model ends up apologising.""" - from timeutil import RECURRENCES + from palmer.timeutil import RECURRENCES result, _ = self._set_reminder("every other tuesday") for supported in RECURRENCES: assert supported in result @@ -325,8 +325,8 @@ def test_it_normalizes_the_way_the_send_path_does(self): def test_every_enum_value_in_the_schema_is_one_the_send_path_keeps(self): """The schema and timeutil cannot be allowed to drift apart.""" - from timeutil import RECURRENCES - from tools_def import TOOLS + from palmer.timeutil import RECURRENCES + from palmer.tools_def import TOOLS schema = next(t for t in TOOLS if t["name"] == "set_reminder") enum = schema["input_schema"]["properties"]["recurrence"]["enum"] assert set(enum) == set(RECURRENCES) diff --git a/test_reminder_time.py b/tests/test_reminder_time.py similarity index 97% rename from test_reminder_time.py rename to tests/test_reminder_time.py index d078405..f8f2571 100644 --- a/test_reminder_time.py +++ b/tests/test_reminder_time.py @@ -10,13 +10,14 @@ These tests hold both halves of the fix: the prompt states the user's own day, and the server vets what comes back rather than trusting the string. """ +import importlib from datetime import datetime, timedelta, timezone, date from unittest.mock import patch -import agent -import db -import timeutil +from palmer import agent +from palmer import db +from palmer import timeutil PHONE = "+15550001111" @@ -108,7 +109,7 @@ class TestNormalizeDueAt: def _norm(self, raw, tz="America/Chicago", now=None): with patch.object(agent, "get_profile", return_value={"timezone": tz}), \ - patch("agent.datetime") as dt: + patch("palmer.agent.datetime") as dt: dt.now.return_value = now or datetime(2026, 8, 30, 12, 0, tzinfo=timezone.utc) dt.side_effect = datetime return agent._normalize_due_at(PHONE, raw) @@ -269,8 +270,8 @@ def test_the_convention_lives_in_timeutil(self): """It was in weather.py, so the only path where the MODEL computes a date — reminders — had no answer for "next friday" while the weather path had a considered one. The same user could get both in one thread.""" - from timeutil import resolve_day_delta - assert not hasattr(__import__("weather"), "_resolve_day_delta") + from palmer.timeutil import resolve_day_delta + assert not hasattr(importlib.import_module("palmer.weather"), "_resolve_day_delta") with patch.object(timeutil, "local_today", return_value=date(2026, 8, 26)): assert resolve_day_delta("friday", "friday") == 2 # this Friday assert resolve_day_delta("next friday", "next friday") == 9 @@ -287,6 +288,6 @@ def test_the_prompt_says_what_to_do_with_an_unsupported_repeat(self): assert "won't repeat" in block def test_the_due_at_field_points_at_the_block(self): - from tools_def import TOOLS + from palmer.tools_def import TOOLS schema = next(t for t in TOOLS if t["name"] == "set_reminder") assert "RIGHT NOW" in schema["input_schema"]["properties"]["due_at"]["description"] diff --git a/test_repetition.py b/tests/test_repetition.py similarity index 97% rename from test_repetition.py rename to tests/test_repetition.py index 8f7c3a4..a7fac79 100644 --- a/test_repetition.py +++ b/tests/test_repetition.py @@ -22,7 +22,7 @@ """ from unittest.mock import MagicMock, patch -import guards +from palmer import guards REPEAT_MORNINGS = [ @@ -64,7 +64,7 @@ def test_the_lexical_check_runs_before_any_model_call(self): """The point is that a verbatim repeat costs nothing to catch, so it can look back three days where the semantic check cannot afford to.""" import inspect - import userprofile + from palmer import userprofile src = inspect.getsource(userprofile._is_duplicate_subject) assert src.index("near_duplicate") < src.index("client.messages.create") assert userprofile.VERBATIM_WINDOW_HOURS > 24 @@ -100,7 +100,7 @@ def test_leading_with_something_else_clears_it(self): def test_the_morning_line_redrafts_on_a_repeat(self): import inspect - import morning + from palmer import morning src = inspect.getsource(morning.generate_morning_line) assert "repeats_opening" in src # Split across source lines in the literal, so match a fragment. @@ -176,11 +176,11 @@ def test_paraphrase_does_not_escape_it(self): def test_it_is_blocked_at_the_one_place_everything_passes_through(self): """morning.py had a guard for this and four other senders never ran it.""" import inspect - import sms_util + from palmer import sms_util assert "leaks_deliberation" in inspect.getsource(sms_util.send_sms) def test_blocking_returns_false_rather_than_sending_something_else(self): - import sms_util + from palmer import sms_util with patch.object(sms_util, "_twilio") as tw: sent = sms_util.send_sms("+1555", self.LEAKED[0]) assert sent is False @@ -197,7 +197,7 @@ class TestADeliberationLeakInAReplyIsRedraftedNotDropped: _finalize redrafts first, and the send_sms block stays behind it.""" def _finalize(self, first, *retries): - import agent + from palmer import agent calls = [] def _create(**kw): @@ -222,7 +222,7 @@ def test_a_clean_reply_still_costs_nothing(self): assert out == "sure, not sending those anymore" def test_a_failed_redraft_keeps_the_original_rather_than_going_silent(self): - import agent + from palmer import agent leak = "This one doesn't meet the threshold, not sending." with patch.object(agent.client.messages, "create", side_effect=RuntimeError("down")): out, _ = agent._finalize(leak, "sys", [], None) @@ -234,5 +234,5 @@ def test_the_correction_tells_it_what_to_do_instead(self): def test_the_send_sms_block_is_still_there(self): """The chokepoint stays — proactive senders never reach _finalize.""" import inspect - import sms_util + from palmer import sms_util assert "leaks_deliberation" in inspect.getsource(sms_util.send_sms) diff --git a/test_reply_resilience.py b/tests/test_reply_resilience.py similarity index 99% rename from test_reply_resilience.py rename to tests/test_reply_resilience.py index 99c2482..70379da 100644 --- a/test_reply_resilience.py +++ b/tests/test_reply_resilience.py @@ -9,7 +9,7 @@ """ from unittest.mock import MagicMock, patch -import agent +from palmer import agent def _blocks(*, text=None, tool=None): @@ -172,7 +172,7 @@ def test_keyboard_interrupt_is_not_swallowed(self): class TestWhatTheModelIsToldWhenAToolRaises: def test_it_never_claims_a_missing_capability(self): - import guards + from palmer import guards out = agent._tool_error("search_flights", RuntimeError("x")) assert not guards.redirects_elsewhere(out) assert "not a missing capability" in out diff --git a/test_rubrics.py b/tests/test_rubrics.py similarity index 87% rename from test_rubrics.py rename to tests/test_rubrics.py index 0f25b36..0e7fe8a 100644 --- a/test_rubrics.py +++ b/tests/test_rubrics.py @@ -8,7 +8,7 @@ """ from unittest.mock import patch, MagicMock -import rubrics +from palmer import rubrics def _haiku_reply(text: str) -> MagicMock: @@ -45,38 +45,38 @@ def setup_method(self): rubrics._reset_cache_for_tests() def test_exact_reply_maps(self): - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("sports_team") assert rubrics.classify_genre("Cardinals") == "sports_team" def test_normalizes_reply_with_punctuation(self): - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("Category: market_instrument.") assert rubrics.classify_genre("AAPL stock") == "market_instrument" def test_uppercase_reply_maps(self): - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("GEOPOLITICS") assert rubrics.classify_genre("Iran conflict") == "geopolitics" def test_unknown_reply_falls_back_to_other(self): - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("nonsense reply here") assert rubrics.classify_genre("something") == "other" def test_api_failure_falls_back_to_other(self): - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: mock_client.messages.create.side_effect = RuntimeError("boom") assert rubrics.classify_genre("anything") == "other" def test_empty_topic_returns_other_without_api_call(self): - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: assert rubrics.classify_genre("") == "other" assert rubrics.classify_genre(" ") == "other" mock_client.messages.create.assert_not_called() def test_memoization_short_circuits_second_call(self): - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("sports_team") rubrics.classify_genre("Cardinals") rubrics.classify_genre("Cardinals") @@ -85,7 +85,7 @@ def test_memoization_short_circuits_second_call(self): assert mock_client.messages.create.call_count == 1 def test_memoization_distinct_topics_call_separately(self): - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: mock_client.messages.create.side_effect = [ _haiku_reply("sports_team"), _haiku_reply("market_instrument"), @@ -97,10 +97,10 @@ def test_memoization_distinct_topics_call_separately(self): def test_failure_result_is_cached(self): """Once 'other' is cached for a topic, a later successful reply won't override it. This is deliberate — repeated Haiku hits on the same string are wasted budget.""" - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: mock_client.messages.create.side_effect = RuntimeError("boom") assert rubrics.classify_genre("weird") == "other" - with patch("rubrics.client") as mock_client: + with patch("palmer.rubrics.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("sports_team") assert rubrics.classify_genre("weird") == "other" mock_client.messages.create.assert_not_called() diff --git a/test_scheduler_config.py b/tests/test_scheduler_config.py similarity index 91% rename from test_scheduler_config.py rename to tests/test_scheduler_config.py index 39923dc..337613f 100644 --- a/test_scheduler_config.py +++ b/tests/test_scheduler_config.py @@ -12,6 +12,7 @@ The property under test throughout is phase-independence: fire times must not move when the process starts at a different moment. """ +import importlib from dotenv import load_dotenv load_dotenv() @@ -25,7 +26,7 @@ def _scheduler(): with patch("apscheduler.schedulers.background.BackgroundScheduler.start"): - import main + from palmer import main return main._scheduler @@ -47,11 +48,11 @@ def _fire_times(trigger, start, count): # (reminders 1m, morning 5m, watches 30m) stay on interval deliberately — # losing up to 30 minutes to a deploy is immaterial there. def _long_period_jobs(): - from followup import run_followups - from morning import send_missing_data_asks - from shopping import run_price_watches - from flightwatch import run_flight_watches - from wxaudit import run_forecast_audit + from palmer.followup import run_followups + from palmer.morning import send_missing_data_asks + from palmer.shopping import run_price_watches + from palmer.flightwatch import run_flight_watches + from palmer.wxaudit import run_forecast_audit return [run_followups, send_missing_data_asks, run_price_watches, run_flight_watches, run_forecast_audit] @@ -102,7 +103,7 @@ class TestFollowupGrid: SERVED = ("America/Chicago", "America/Los_Angeles") def _ticks_in_window(self, tz_name): - trigger = _trigger_for(__import__("followup").run_followups) + trigger = _trigger_for(importlib.import_module("palmer.followup").run_followups) start = datetime(2026, 8, 25, 0, 0, tzinfo=timezone.utc) fires = _fire_times(trigger, start, 24) # a full day at 2h spacing return [f for f in fires @@ -129,7 +130,7 @@ class TestNoUnpromptedSenderComesBack: def test_the_retired_modules_are_gone(self): import importlib - for name in ("alerts",): + for name in ("palmer.alerts",): try: importlib.import_module(name) except ModuleNotFoundError: @@ -150,8 +151,8 @@ class TestShortJobsStayOnInterval: def test_short_jobs_are_interval(self): from apscheduler.triggers.interval import IntervalTrigger - from send_reminders import send_due_reminders - from morning import send_morning_messages - from watches import run_watches + from palmer.reminders import send_due_reminders + from palmer.morning import send_morning_messages + from palmer.watches import run_watches for func in (send_due_reminders, send_morning_messages, run_watches): assert isinstance(_trigger_for(func), IntervalTrigger), func.__name__ diff --git a/test_shows.py b/tests/test_shows.py similarity index 92% rename from test_shows.py rename to tests/test_shows.py index 8077375..ed70385 100644 --- a/test_shows.py +++ b/tests/test_shows.py @@ -20,9 +20,9 @@ from datetime import date, timedelta from unittest.mock import patch -import morning -import opening -import shows +from palmer import morning +from palmer import opening +from palmer import shows TODAY = date(2026, 8, 29) @@ -142,7 +142,7 @@ class TestEpisodesLeadAndDisplace: "url": None, "source": "themoviedb.org", "date": None} def _snapshot(self, episodes): - from timeutil import local_today + from palmer.timeutil import local_today opening._clear_caches() today = local_today("America/Chicago") opening._candidate_cache[(38.5, -90.5, opening._week_key(today))] = [] @@ -152,8 +152,8 @@ def _snapshot(self, episodes): opening._screen_cache[today.isoformat()] = [ {"kind": "screen", "title": f"Film {i}", "subtitle": "", "when": "", "url": None, "source": ""} for i in range(4)] - with patch("weather._geocode", return_value=(38.5, -90.5, "Kirkwood")), \ - patch("shows.episode_rows", return_value=episodes): + with patch("palmer.weather._geocode", return_value=(38.5, -90.5, "Kirkwood")), \ + patch("palmer.shows.episode_rows", return_value=episodes): return opening.opening_snapshot({"city": "Kirkwood, MO", "timezone": "America/Chicago"}) @@ -180,21 +180,21 @@ def test_it_is_capped(self): def test_it_ignores_the_discovery_kinds_setting(self): """`opening_kinds` chooses which kinds of DISCOVERY you want. A show you named is not discovery — unfollowing is its control.""" - from timeutil import local_today + from palmer.timeutil import local_today opening._clear_caches() today = local_today("America/Chicago") opening._candidate_cache[(38.5, -90.5, opening._week_key(today))] = [] opening._local_cache[(38.5, -90.5, today.isoformat())] = [] opening._screen_cache[today.isoformat()] = [] - with patch("weather._geocode", return_value=(38.5, -90.5, "Kirkwood")), \ - patch("shows.episode_rows", return_value=[self.ROW]): + with patch("palmer.weather._geocode", return_value=(38.5, -90.5, "Kirkwood")), \ + patch("palmer.shows.episode_rows", return_value=[self.ROW]): rows = opening.opening_snapshot({ "city": "Kirkwood, MO", "timezone": "America/Chicago", "morning_prefs": {"opening_kinds": ["local"]}}) assert [r["kind"] for r in rows] == ["episode"] def test_a_broken_shows_module_never_costs_the_section(self): - from timeutil import local_today + from palmer.timeutil import local_today opening._clear_caches() today = local_today("America/Chicago") opening._candidate_cache[(38.5, -90.5, opening._week_key(today))] = [] @@ -202,8 +202,8 @@ def test_a_broken_shows_module_never_costs_the_section(self): {"kind": "event", "title": "Gig", "subtitle": "", "when": "", "url": None, "source": "", "date": None}] opening._screen_cache[today.isoformat()] = [] - with patch("weather._geocode", return_value=(38.5, -90.5, "Kirkwood")), \ - patch("shows.episode_rows", side_effect=RuntimeError("boom")): + with patch("palmer.weather._geocode", return_value=(38.5, -90.5, "Kirkwood")), \ + patch("palmer.shows.episode_rows", side_effect=RuntimeError("boom")): rows = opening.opening_snapshot({"city": "Kirkwood, MO", "timezone": "America/Chicago"}) assert [r["title"] for r in rows] == ["Gig"] @@ -236,26 +236,26 @@ def test_the_flag_rides_the_payload_not_a_profile_read(self): def test_home_puts_the_flag_on_the_payload(self): import inspect - import home + from palmer import home assert "episode_alerts" in inspect.getsource(home._refresh_identity) class TestFollowing: def test_the_field_is_on_the_schema(self): """A key outside PROFILE_FIELDS is silently dropped on write.""" - from userprofile import PROFILE_FIELDS + from palmer.userprofile import PROFILE_FIELDS assert "shows" in PROFILE_FIELDS def test_resolution_happens_on_the_write_path(self): import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) block = src.split('"follow_show"')[1].split("elif b.name")[0] assert "find_shows" in block def test_an_unresolvable_title_asks_rather_than_guessing(self): import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) block = src.split('"follow_show"')[1].split("elif b.name")[0] assert "confirm the title" in block @@ -263,7 +263,7 @@ def test_an_unresolvable_title_asks_rather_than_guessing(self): def test_following_is_capped(self): import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) block = src.split('"follow_show"')[1].split("elif b.name")[0] assert "FOLLOW_MAX" in block @@ -271,7 +271,7 @@ def test_following_is_capped(self): def test_following_expires_the_cached_section(self): """Otherwise the show they just added is missing for up to a day.""" import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) block = src.split('"follow_show"')[1].split("elif b.name")[0] assert "invalidate" in block @@ -305,7 +305,7 @@ def test_the_description_separates_them_for_a_person(self): def test_the_dispatch_refuses_to_pick(self): import inspect - import agent + from palmer import agent block = inspect.getsource(agent.get_reply).split('"follow_show"')[1] \ .split("elif b.name")[0] assert "ambiguous_shows" in block diff --git a/test_sms_chunking.py b/tests/test_sms_chunking.py similarity index 91% rename from test_sms_chunking.py rename to tests/test_sms_chunking.py index 1f32ff9..e647f2f 100644 --- a/test_sms_chunking.py +++ b/tests/test_sms_chunking.py @@ -3,8 +3,8 @@ from unittest.mock import patch, MagicMock -from smstext import _sms_clean -from sms_util import _split_for_sms, send_sms +from palmer.smstext import _sms_clean +from palmer.sms_util import _split_for_sms, send_sms class TestSmsCleanNoTruncation: @@ -51,7 +51,7 @@ def test_long_body_sent_as_multiple_messages(self, monkeypatch): long_body = ("Weather update here.\n\n" "Traffic is normal today.\n\n" "News: " + "x" * 900) - with patch("sms_util._twilio") as mock_twilio: + with patch("palmer.sms_util._twilio") as mock_twilio: mock_twilio.messages.create.return_value = MagicMock() result = send_sms("+15551234567", long_body) assert result is True @@ -63,7 +63,7 @@ def test_long_body_sent_as_multiple_messages(self, monkeypatch): def test_short_body_sent_as_one_message(self, monkeypatch): monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") - with patch("sms_util._twilio") as mock_twilio: + with patch("palmer.sms_util._twilio") as mock_twilio: mock_twilio.messages.create.return_value = MagicMock() result = send_sms("+15551234567", "quick note") assert result is True @@ -73,7 +73,7 @@ def test_short_body_sent_as_one_message(self, monkeypatch): def test_no_content_lost_across_chunks(self, monkeypatch): monkeypatch.setenv("TWILIO_PHONE_NUMBER", "+15550000000") long_body = "\n\n".join(f"Topic {i}: some detail about it." for i in range(20)) - with patch("sms_util._twilio") as mock_twilio: + with patch("palmer.sms_util._twilio") as mock_twilio: mock_twilio.messages.create.return_value = MagicMock() send_sms("+15551234567", long_body) bodies = [c.kwargs["body"] for c in mock_twilio.messages.create.call_args_list] diff --git a/test_sources.py b/tests/test_sources.py similarity index 98% rename from test_sources.py rename to tests/test_sources.py index 45ba25a..47193f2 100644 --- a/test_sources.py +++ b/tests/test_sources.py @@ -15,8 +15,8 @@ from pathlib import Path from unittest.mock import patch, MagicMock -import sources -import datafeeds +from palmer import sources +from palmer import datafeeds def _fresh(hours_ago: float = 1) -> str: @@ -182,7 +182,7 @@ class TestSourceListIntegrity: @classmethod def setup_class(cls): - with open(Path(__file__).parent / "trusted_sources.json") as f: + with open(Path(sources.__file__).parent / "trusted_sources.json") as f: cls.data = json.load(f) def test_every_allowlist_entry_has_domain_and_valid_tier(self): @@ -227,7 +227,7 @@ class TestCorroboratedIsShared: never fork into two implementations.""" def test_watches_reexports_the_same_function(self): - import watches + from palmer import watches assert watches.corroborated is sources.corroborated diff --git a/test_sports.py b/tests/test_sports.py similarity index 97% rename from test_sports.py rename to tests/test_sports.py index 56b9211..52719c7 100644 --- a/test_sports.py +++ b/tests/test_sports.py @@ -14,9 +14,9 @@ from datetime import date from unittest.mock import patch -import db +from palmer import db -import sports +from palmer import sports def _game(home=0, away=0, state="in", period=4, clock=200, gid="1", detail="Q4 3:20", league="nfl"): @@ -135,7 +135,7 @@ def test_an_unknown_league_makes_no_call(self): def test_the_poll_is_two_speed_and_gated_on_the_ask(self): """The two-speed live poll is back for the people who opted in; scorewatch.live_teams is what keeps everyone else out of it.""" - import scorewatch + from palmer import scorewatch assert scorewatch.live_teams({"followed_teams": [TEAM]}) == [] assert scorewatch.live_teams({"followed_teams": [dict(TEAM, live="key")]}) == [dict(TEAM, live="key")] @@ -172,7 +172,7 @@ def test_nonsense_matches_nothing(self): def test_the_dispatch_asks_rather_than_picking(self): import inspect - import agent + from palmer import agent block = inspect.getsource(agent.get_reply).split('"follow_team"')[1].split("elif b.name")[0] assert "Do NOT pick one yourself" in block assert "matches more than one team" in block @@ -190,13 +190,13 @@ def test_a_team_with_no_level_is_off(self): def test_the_dispatch_offers_when_no_level_was_given(self): import inspect - import agent + from palmer import agent block = inspect.getsource(agent.get_reply).split('"follow_team"')[1].split("elif b.name")[0] assert "No live texts are set" in block and "Offer them in one clause" in block assert "Do not promise live texts they have not chosen" in block def test_the_tool_descriptions_agree(self): - from tools_def import TOOLS + from palmer.tools_def import TOOLS follow = next(t for t in TOOLS if t["name"] == "follow_team") assert "OPT-IN" in follow["description"] assert follow["input_schema"]["properties"]["live"]["enum"] == ["off", "key", "all"] @@ -205,8 +205,8 @@ def test_the_tool_descriptions_agree(self): assert setter["input_schema"]["required"] == ["mode"] def test_the_offer_block_fires_once_for_a_named_team(self): - import agent - import userprofile + from palmer import agent + from palmer import userprofile base = {"intro_sent": True, "sports_teams": ["Cardinals fan"], "name": "Jeff", "city": "Kirkwood, MO"} with patch.object(agent, "get_profile", return_value=dict(base)), \ patch.object(agent, "get_history", return_value=[]): @@ -226,7 +226,7 @@ def test_a_follow_expires_the_page_section(self): """Otherwise the Scores card stays empty for up to ten minutes after they followed, which reads as it not having worked.""" import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) for tool in ('"follow_team"', '"unfollow_team"'): block = src.split(tool)[1].split("elif b.name")[0] @@ -243,7 +243,7 @@ class TestUnfollowSurvivesTheWrongKey: def test_both_keys_are_accepted(self): import inspect - import agent + from palmer import agent src = inspect.getsource(agent.get_reply) for tool in ('"unfollow_team"', '"unfollow_show"'): block = src.split(tool)[1].split("elif b.name")[0] @@ -324,7 +324,7 @@ def _run(game, prev=None, alert_count=0, delivered=True, live="key"): Yields a record of what was texted and what was written back, so tests can assert on behaviour instead of on the source of the function.""" - import scorewatch + from palmer import scorewatch rec = {"sms": [], "saved": []} if prev is not None: prev = {**prev, "alert_count": alert_count} @@ -338,7 +338,7 @@ def _run(game, prev=None, alert_count=0, delivered=True, live="key"): patch.object(scorewatch, "_draft", return_value="line"), \ patch.object(scorewatch, "record_game_alert", side_effect=lambda p, g, h, a, lv, st, sent: rec["saved"].append((h, a, sent))), \ - patch("sms_util.send_sms", side_effect=lambda p, t, **k: rec["sms"].append(t) or delivered): + patch("palmer.sms_util.send_sms", side_effect=lambda p, t, **k: rec["sms"].append(t) or delivered): scorewatch.run_score_alerts() yield rec @@ -421,7 +421,7 @@ def test_the_drafter_knows_who_scored(self): assert sports.scorer(_told(14, 10, "home"), _game(14, 17)) == "away" assert sports.scorer(None, _game(14, 17)) is None import inspect - import scorewatch + from palmer import scorewatch assert "just scored" in inspect.getsource(scorewatch._draft) def test_the_job_texts_every_score_only_for_all(self): @@ -497,8 +497,8 @@ def test_a_live_league_is_checked_often_enough_to_catch_a_lead_change(self): assert sports.LIVE_POLL_SECONDS <= 120 def test_the_job_never_raises(self): - import scorewatch - with patch("scorewatch.get_all_profiles", side_effect=RuntimeError("db down")): + from palmer import scorewatch + with patch("palmer.scorewatch.get_all_profiles", side_effect=RuntimeError("db down")): scorewatch.run_score_alerts() @@ -524,7 +524,7 @@ def test_state_is_per_user(self, tmp_path, monkeypatch): class TestSetScoreUpdatesDispatch: def _block(self): import inspect - import agent + from palmer import agent return inspect.getsource(agent.get_reply).split('"set_score_updates"')[1].split("elif b.name")[0] def test_it_changes_the_level_without_dropping_the_team(self): diff --git a/tests/test_subject_dedup.py b/tests/test_subject_dedup.py new file mode 100644 index 0000000..2f31ce2 --- /dev/null +++ b/tests/test_subject_dedup.py @@ -0,0 +1,158 @@ +"""Tests for cross-job subject-dedup: userprofile._is_duplicate_subject and its wiring +into watches.py and followup.py. Pure logic + mocked LLM/DB — no real network +or LLM calls. Run: pytest test_subject_dedup.py""" +from dotenv import load_dotenv +load_dotenv() + +from unittest.mock import patch, MagicMock + +from palmer import userprofile +from palmer import watches +from palmer import followup + + +def _haiku_response(text: str) -> MagicMock: + resp = MagicMock() + resp.content = [MagicMock(text=text)] + return resp + + +class TestIsDuplicateSubject: + def test_no_recent_messages_returns_false_without_llm_call(self): + with patch("palmer.db.get_recent_assistant_messages", return_value=[]), \ + patch("palmer.userprofile.client") as mock_client: + assert userprofile._is_duplicate_subject("+15551234567", "new message") is False + mock_client.messages.create.assert_not_called() + + def test_haiku_says_yes_returns_true(self): + with patch("palmer.db.get_recent_assistant_messages", return_value=["Hurts practice update"]), \ + patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.return_value = _haiku_response("YES") + assert userprofile._is_duplicate_subject("+15551234567", "Hurts camp footage") is True + + def test_haiku_says_no_returns_false(self): + with patch("palmer.db.get_recent_assistant_messages", return_value=["weather update"]), \ + patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.return_value = _haiku_response("NO") + assert userprofile._is_duplicate_subject("+15551234567", "bitcoin price") is False + + def test_llm_failure_fails_open(self): + with patch("palmer.db.get_recent_assistant_messages", return_value=["something"]), \ + patch("palmer.userprofile.client") as mock_client: + mock_client.messages.create.side_effect = Exception("API down") + assert userprofile._is_duplicate_subject("+15551234567", "new message") is False + + +def _watch(**overrides): + base = { + "id": 1, "phone": "+15551234567", "description": "Eagles Hurts injury", + "queries": ["Jalen Hurts injury"], "cooldown_hours": 4, + "last_alerted": None, "last_alert_summary": None, + "daily_alert_count": 0, "daily_alert_date": None, "recent_summaries": [], + } + base.update(overrides) + return base + + +_RAW_RESULTS = [ + {"title": "Hurts injury update", "url": "https://example1.com/a", + "content": "details", "published_date": "2026-08-13", "score": 1.0}, + {"title": "Hurts injury update 2", "url": "https://example2.com/b", + "content": "more details", "published_date": "2026-08-13", "score": 1.0}, +] + + +class TestWatchesSubjectDedup: + def test_skips_send_when_duplicate_subject(self): + with patch("palmer.watches.get_active_watches", return_value=[_watch()]), \ + patch("palmer.watches._search_raw", return_value=_RAW_RESULTS), \ + patch("palmer.watches._check_watch_hit", return_value=True), \ + patch("palmer.watches._best_result", return_value=_RAW_RESULTS[0]), \ + patch("palmer.watches._is_duplicate_subject", return_value=True) as dedup, \ + patch("palmer.watches.claim_watch_alert") as mock_claim, \ + patch("palmer.watches.update_watch_alerted") as mock_update, \ + patch("palmer.sms_util.send_sms") as mock_send: + watches.run_watches() + dedup.assert_called_once() + mock_claim.assert_not_called() + mock_send.assert_not_called() + mock_update.assert_not_called() + + def test_sends_normally_when_not_duplicate(self): + with patch("palmer.watches.get_active_watches", return_value=[_watch()]), \ + patch("palmer.watches._search_raw", return_value=_RAW_RESULTS), \ + patch("palmer.watches._check_watch_hit", return_value=True), \ + patch("palmer.watches._best_result", return_value=_RAW_RESULTS[0]), \ + patch("palmer.watches._is_duplicate_subject", return_value=False), \ + patch("palmer.watches.claim_watch_alert", return_value=True), \ + patch("palmer.watches._draft_alert", return_value="Hurts is out. https://example.com/a"), \ + patch("palmer.watches.update_watch_alerted") as mock_update, \ + patch("palmer.sms_util.send_sms") as mock_send: + watches.run_watches() + mock_send.assert_called_once() + mock_update.assert_called_once() + # What goes out is the drafted line, not the raw headline. + assert mock_send.call_args[0][1] == "Hurts is out. https://example.com/a" + + def test_alert_persists_the_fired_url_and_domain(self): + """The 'Watching' page section links a watch to the article + that fired it — update_watch_alerted must be called with that URL/domain, + not just the dedup title.""" + with patch("palmer.watches.get_active_watches", return_value=[_watch()]), \ + patch("palmer.watches._search_raw", return_value=_RAW_RESULTS), \ + patch("palmer.watches._check_watch_hit", return_value=True), \ + patch("palmer.watches._best_result", return_value=_RAW_RESULTS[0]), \ + patch("palmer.watches._is_duplicate_subject", return_value=False), \ + patch("palmer.watches.claim_watch_alert", return_value=True), \ + patch("palmer.watches._draft_alert", return_value="drafted line https://example.com/a"), \ + patch("palmer.watches.update_watch_alerted") as mock_update, \ + patch("palmer.sms_util.send_sms"): + watches.run_watches() + _, kwargs = mock_update.call_args + assert kwargs["url"] == "https://example1.com/a" + assert kwargs["domain"] == "example1.com" + + +class TestFollowupSubjectDedup: + def _profile(self): + return {"morning_onboarded": True, "timezone": "America/Chicago", + "ongoing_threads": ["a big life event"]} + + def test_skips_send_and_releases_claim_when_duplicate(self): + with patch("palmer.followup.get_all_profiles", + return_value=[("+15551234567", self._profile())]), \ + patch("palmer.followup._should_send_followup", return_value=True), \ + patch("palmer.followup.claim_daily_guard", return_value=True), \ + patch("palmer.followup.get_history", return_value=[]), \ + patch("palmer.followup._candidates", return_value=[followup.Subject("thread", "a big life event")]), \ + patch("palmer.followup._pick_subject", return_value=followup.Subject("thread", "a big life event")), \ + patch("palmer.followup._draft_followup", return_value="hey how'd that go?"), \ + patch("palmer.followup._is_duplicate_subject", return_value=True) as dedup, \ + patch("palmer.followup.upsert_profile") as mock_upsert, \ + patch("palmer.followup._local_today") as mock_today, \ + patch("palmer.sms_util.send_sms") as mock_send: + mock_today.return_value.isoformat.return_value = "2026-08-13" + followup.run_followups() + dedup.assert_called_once() + mock_send.assert_not_called() + assert any( + c.args[1] == {"followup_sent_date": None} for c in mock_upsert.call_args_list + ), "expected the daily claim to be released" + + def test_sends_normally_when_not_duplicate(self): + with patch("palmer.followup.get_all_profiles", + return_value=[("+15551234567", self._profile())]), \ + patch("palmer.followup._should_send_followup", return_value=True), \ + patch("palmer.followup.claim_daily_guard", return_value=True), \ + patch("palmer.followup.get_history", return_value=[]), \ + patch("palmer.followup._candidates", return_value=[followup.Subject("thread", "a big life event")]), \ + patch("palmer.followup._pick_subject", return_value=followup.Subject("thread", "a big life event")), \ + patch("palmer.followup._draft_followup", return_value="hey how'd that go?"), \ + patch("palmer.followup._is_duplicate_subject", return_value=False), \ + patch("palmer.followup.save_message") as mock_save, \ + patch("palmer.followup._local_today") as mock_today, \ + patch("palmer.sms_util.send_sms") as mock_send: + mock_today.return_value.isoformat.return_value = "2026-08-13" + followup.run_followups() + mock_send.assert_called_once() + mock_save.assert_called_once() diff --git a/test_tapback.py b/tests/test_tapback.py similarity index 99% rename from test_tapback.py rename to tests/test_tapback.py index d36f2c2..05edff5 100644 --- a/test_tapback.py +++ b/tests/test_tapback.py @@ -11,10 +11,10 @@ import pytest -import tapback +from palmer import tapback with patch("apscheduler.schedulers.background.BackgroundScheduler.start"): - import main + from palmer import main class TestAppleTapbacks: @@ -147,7 +147,7 @@ def test_renders_recent_reactions(self): assert "long all-hands take" in block def test_surfaced_by_build_system(self): - import agent + from palmer import agent profile = {"name": "Mike", "reactions": [ {"kind": "liked", "sentiment": "positive", "quoted": "the audacity of it", "emoji": ""}, ]} diff --git a/test_tickers.py b/tests/test_tickers.py similarity index 95% rename from test_tickers.py rename to tests/test_tickers.py index 8f43182..7958441 100644 --- a/test_tickers.py +++ b/tests/test_tickers.py @@ -13,8 +13,8 @@ import pytest -import tickers -from tickers import resolve_topic_asset as resolve +from palmer import tickers +from palmer.tickers import resolve_topic_asset as resolve class TestCompanyNames: @@ -124,7 +124,7 @@ class TestSearchResolution: versions did, and both encoded a stale snapshot of who was public.""" def _search(self, quotes, topic="Lululemon shares"): - with patch("netutil._http_get_json", return_value={"quotes": quotes}) as get: + with patch("palmer.netutil._http_get_json", return_value={"quotes": quotes}) as get: return tickers.search_symbol(topic), get def test_a_us_equity_is_returned(self): @@ -158,7 +158,7 @@ def test_no_qualifying_result_yields_nothing(self): assert self._search([])[0] is None def test_a_network_failure_yields_nothing(self): - with patch("netutil._http_get_json", return_value=None): + with patch("palmer.netutil._http_get_json", return_value=None): assert tickers.search_symbol("Lululemon shares") is None def test_a_stopword_symbol_is_still_rejected(self): @@ -166,8 +166,8 @@ def test_a_stopword_symbol_is_still_rejected(self): def test_no_model_is_consulted(self): """The whole point of the rewrite.""" - with patch("llm.client") as client, \ - patch("netutil._http_get_json", return_value={"quotes": []}): + with patch("palmer.llm.client") as client, \ + patch("palmer.netutil._http_get_json", return_value={"quotes": []}): tickers.resolve_company_ticker("Lululemon shares") client.messages.create.assert_not_called() @@ -188,7 +188,7 @@ def test_a_multiword_company_survives(self): assert tickers._search_query("Rocket Lab stock") == "Rocket Lab" def test_an_empty_query_short_circuits(self): - with patch("netutil._http_get_json") as get: + with patch("palmer.netutil._http_get_json") as get: assert tickers.search_symbol("stock price news") is None get.assert_not_called() @@ -247,7 +247,7 @@ def test_news_topics_do_not(self, topic): class TestReadPathIsFree: def test_resolution_never_calls_a_model(self): """This runs on every page view. A model call here would be a bill.""" - with patch("llm.client") as client: + with patch("palmer.llm.client") as client: for t in ["Nvidia stock", "AI news", "US stock market", "$TSLA", "SpaceX stock", "bitcoin", "Lululemon shares"]: resolve(t) diff --git a/test_timeutil.py b/tests/test_timeutil.py similarity index 91% rename from test_timeutil.py rename to tests/test_timeutil.py index 812a1cc..354df1f 100644 --- a/test_timeutil.py +++ b/tests/test_timeutil.py @@ -8,7 +8,7 @@ from datetime import date, datetime, timezone from unittest.mock import patch -import timeutil +from palmer import timeutil class TestLocalNow: @@ -35,7 +35,7 @@ def test_returns_date_type(self): def test_none_tz_uses_utc_date(self): # Freeze datetime.now so we can compare exactly - with patch("timeutil.datetime") as mock_dt: + with patch("palmer.timeutil.datetime") as mock_dt: mock_dt.now.side_effect = lambda tz=None: datetime(2026, 8, 18, 20, 0, tzinfo=tz or timezone.utc) assert timeutil.local_today(None) == date(2026, 8, 18) @@ -45,7 +45,7 @@ def test_west_coast_late_evening_stays_on_yesterday_local(self): date.today() sees Aug 18. local_today('America/Los_Angeles') must still see Aug 17.""" # Fake datetime.now so it returns Aug 18 05:00 UTC (=Aug 17 22:00 PDT) - with patch("timeutil.datetime") as mock_dt: + with patch("palmer.timeutil.datetime") as mock_dt: def _now(tz=None): base = datetime(2026, 8, 18, 5, 0, tzinfo=timezone.utc) return base.astimezone(tz) if tz else base @@ -59,10 +59,10 @@ class TestResolveDayDeltaHonorsTz: Friday' should get delta=1 (tomorrow), not delta=8 (next Friday).""" def test_friday_at_thursday_night_local_is_tomorrow(self): - from timeutil import resolve_day_delta + from palmer.timeutil import resolve_day_delta # Freeze both timeutil.local_today and weather._date.today so the module # under test sees a consistent 'now'. - with patch("timeutil.datetime") as mock_dt: + with patch("palmer.timeutil.datetime") as mock_dt: def _now(tz=None): # Aug 21 2026 is a Friday. So 05:00 UTC on Aug 21 = 22:00 PDT on Aug 20 (Thursday). base = datetime(2026, 8, 21, 5, 0, tzinfo=timezone.utc) @@ -75,6 +75,6 @@ def _now(tz=None): assert resolve_day_delta("friday", "friday", tz="America/Los_Angeles") == 1 def test_tomorrow_always_one_day_out(self): - from timeutil import resolve_day_delta + from palmer.timeutil import resolve_day_delta assert resolve_day_delta("tomorrow", "tomorrow", tz="America/Chicago") == 1 assert resolve_day_delta("tomorrow", "tomorrow", tz=None) == 1 diff --git a/test_trends.py b/tests/test_trends.py similarity index 96% rename from test_trends.py rename to tests/test_trends.py index bc2b897..68dfa68 100644 --- a/test_trends.py +++ b/tests/test_trends.py @@ -8,7 +8,7 @@ from datetime import date from unittest.mock import patch, MagicMock -import trends +from palmer import trends def _reply(text: str) -> MagicMock: @@ -126,22 +126,22 @@ def test_unparseable_reply_is_silent(self): class TestBriefingIntegration: def test_adjacent_failure_does_not_break_the_briefing(self): - import morning + from palmer import morning with patch.object(morning, "_weather_report", return_value="warm"), \ patch.object(morning, "_topic_digest", return_value="story"), \ patch.object(morning, "get_city_traffic", return_value="clear"), \ patch.object(morning, "get_travel_time", return_value="17 min"), \ - patch("trends.adjacent_story", side_effect=RuntimeError("boom")): + patch("palmer.trends.adjacent_story", side_effect=RuntimeError("boom")): out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": ["SpaceX news"]}) assert any("story" in s for s in out), "topics must survive a trends failure" def test_adjacent_section_is_labelled_for_the_drafter(self): - import morning + from palmer import morning with patch.object(morning, "_weather_report", return_value="warm"), \ patch.object(morning, "_topic_digest", return_value="story"), \ patch.object(morning, "get_city_traffic", return_value="clear"), \ patch.object(morning, "get_travel_time", return_value="17 min"), \ - patch("trends.adjacent_story", + patch("palmer.trends.adjacent_story", return_value={"query": "q", "why": "close to markets", "story": "Big thing happened"}): out = morning._gather_morning_data({"city": "Kirkwood", "morning_topics": ["SpaceX news"]}) adj = [s for s in out if s.startswith("ADJACENT")] diff --git a/test_user_covered.py b/tests/test_user_covered.py similarity index 80% rename from test_user_covered.py rename to tests/test_user_covered.py index e0fed49..810f8f8 100644 --- a/test_user_covered.py +++ b/tests/test_user_covered.py @@ -2,7 +2,7 @@ already brought up the same story themselves in their recent messages.""" from unittest.mock import patch, MagicMock -import userprofile +from palmer import userprofile def _haiku_reply(text: str) -> MagicMock: @@ -15,16 +15,16 @@ def _haiku_reply(text: str) -> MagicMock: class TestUserAlreadyCovered: def test_no_recent_user_messages_returns_false_without_haiku(self): - with patch("db.get_recent_user_messages", return_value=[]), \ - patch("userprofile.client") as mock_client: + with patch("palmer.db.get_recent_user_messages", return_value=[]), \ + patch("palmer.userprofile.client") as mock_client: assert userprofile._user_already_covered("+15550000000", "Iran launched strikes") is False mock_client.messages.create.assert_not_called() def test_yes_reply_suppresses(self): - with patch("db.get_recent_user_messages", return_value=[ + with patch("palmer.db.get_recent_user_messages", return_value=[ "did you see the Iran thing?", "wild what's happening over there", - ]), patch("userprofile.client") as mock_client: + ]), patch("palmer.userprofile.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("YES") assert userprofile._user_already_covered( "+15550000000", @@ -32,10 +32,10 @@ def test_yes_reply_suppresses(self): ) is True def test_no_reply_allows_send(self): - with patch("db.get_recent_user_messages", return_value=[ + with patch("palmer.db.get_recent_user_messages", return_value=[ "grocery list: milk, bread", "what's the weather tomorrow?", - ]), patch("userprofile.client") as mock_client: + ]), patch("palmer.userprofile.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("NO") assert userprofile._user_already_covered( "+15550000000", @@ -45,8 +45,8 @@ def test_no_reply_allows_send(self): def test_haiku_failure_fails_open(self): """Fail-open semantics — a broken Haiku call must NOT silently suppress real alerts. Better to send a possibly-duplicate than to drop a real one.""" - with patch("db.get_recent_user_messages", return_value=["did you see that"]), \ - patch("userprofile.client") as mock_client: + with patch("palmer.db.get_recent_user_messages", return_value=["did you see that"]), \ + patch("palmer.userprofile.client") as mock_client: mock_client.messages.create.side_effect = RuntimeError("boom") assert userprofile._user_already_covered("+15550000000", "some alert") is False @@ -57,9 +57,9 @@ def _create(**kwargs): captured.append(kwargs["messages"][0]["content"]) return _haiku_reply("NO") - with patch("db.get_recent_user_messages", + with patch("palmer.db.get_recent_user_messages", return_value=["did you see the Iran thing", "crazy"]), \ - patch("userprofile.client") as mock_client: + patch("palmer.userprofile.client") as mock_client: mock_client.messages.create.side_effect = _create userprofile._user_already_covered("+15550000000", "Iran launched missiles.") prompt = captured[0] diff --git a/test_watches_genre.py b/tests/test_watches_genre.py similarity index 84% rename from test_watches_genre.py rename to tests/test_watches_genre.py index 9e62c2c..2acfde9 100644 --- a/test_watches_genre.py +++ b/tests/test_watches_genre.py @@ -7,8 +7,8 @@ """ from unittest.mock import patch, MagicMock -import rubrics -import watches as watches_mod +from palmer import rubrics +from palmer import watches as watches_mod def _haiku_reply(text: str) -> MagicMock: @@ -24,16 +24,16 @@ def setup_method(self): rubrics._reset_cache_for_tests() def test_stored_genre_is_returned_verbatim(self): - with patch("watches.classify_genre") as mock_classify, \ - patch("watches.set_watch_genre") as mock_set: + with patch("palmer.watches.classify_genre") as mock_classify, \ + patch("palmer.watches.set_watch_genre") as mock_set: watch = {"id": 1, "description": "Cardinals", "genre": "sports_team"} assert watches_mod._watch_genre(watch) == "sports_team" mock_classify.assert_not_called() mock_set.assert_not_called() def test_missing_genre_triggers_classify_and_persist(self): - with patch("watches.classify_genre", return_value="sports_team") as mock_classify, \ - patch("watches.set_watch_genre") as mock_set: + with patch("palmer.watches.classify_genre", return_value="sports_team") as mock_classify, \ + patch("palmer.watches.set_watch_genre") as mock_set: watch = {"id": 42, "description": "Cardinals winning streak", "genre": None} assert watches_mod._watch_genre(watch) == "sports_team" mock_classify.assert_called_once_with("Cardinals winning streak") @@ -42,8 +42,8 @@ def test_missing_genre_triggers_classify_and_persist(self): assert watch["genre"] == "sports_team" def test_persist_failure_does_not_raise(self): - with patch("watches.classify_genre", return_value="market_instrument"), \ - patch("watches.set_watch_genre", side_effect=RuntimeError("db down")): + with patch("palmer.watches.classify_genre", return_value="market_instrument"), \ + patch("palmer.watches.set_watch_genre", side_effect=RuntimeError("db down")): watch = {"id": 7, "description": "Bitcoin", "genre": None} # Should still return the classified genre and not blow up assert watches_mod._watch_genre(watch) == "market_instrument" @@ -63,7 +63,7 @@ def _create(**kwargs): def test_prompt_contains_genre_rubric_text(self): create, captured = self._capture_prompt() - with patch("watches.client") as mock_client: + with patch("palmer.watches.client") as mock_client: mock_client.messages.create.side_effect = create hit = watches_mod._check_watch_hit( results="Cardinals moved into first place today.", @@ -82,7 +82,7 @@ def test_prompt_contains_genre_rubric_text(self): def test_prompt_uses_other_rubric_when_genre_unknown(self): create, captured = self._capture_prompt() - with patch("watches.client") as mock_client: + with patch("palmer.watches.client") as mock_client: mock_client.messages.create.side_effect = create watches_mod._check_watch_hit( results="Something happened.", @@ -98,7 +98,7 @@ def test_prompt_uses_other_rubric_when_genre_unknown(self): def test_engaged_footer_lowers_bar(self): create, captured = self._capture_prompt() - with patch("watches.client") as mock_client: + with patch("palmer.watches.client") as mock_client: mock_client.messages.create.side_effect = create watches_mod._check_watch_hit( results="Some update.", @@ -111,7 +111,7 @@ def test_engaged_footer_lowers_bar(self): assert "following this closely" in prompt.lower() def test_no_reply_is_no(self): - with patch("watches.client") as mock_client: + with patch("palmer.watches.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("NO — routine game") hit = watches_mod._check_watch_hit( results="Cardinals lost 5-4 to the Reds.", diff --git a/test_watches_story.py b/tests/test_watches_story.py similarity index 91% rename from test_watches_story.py rename to tests/test_watches_story.py index 7d60372..6c8c4fc 100644 --- a/test_watches_story.py +++ b/tests/test_watches_story.py @@ -6,8 +6,8 @@ """ from unittest.mock import patch, MagicMock -import watches as watches_mod -import watches +from palmer import watches as watches_mod +from palmer import watches def _haiku_reply(text: str) -> MagicMock: @@ -31,7 +31,7 @@ def _create(**kwargs): class TestCheckWatchHitStoryBlock: def test_no_story_state_omits_story_block(self): create, captured = _capture_prompt("YES") - with patch("watches.client") as mock_client: + with patch("palmer.watches.client") as mock_client: mock_client.messages.create.side_effect = create watches_mod._check_watch_hit( results="something happened", @@ -46,7 +46,7 @@ def test_no_story_state_omits_story_block(self): def test_story_state_included_in_prompt(self): create, captured = _capture_prompt("NO") - with patch("watches.client") as mock_client: + with patch("palmer.watches.client") as mock_client: mock_client.messages.create.side_effect = create watches_mod._check_watch_hit( results="Cardinals win again to extend streak to 7", @@ -63,7 +63,7 @@ def test_story_state_included_in_prompt(self): assert "advance" in prompt.lower() def test_yes_reply_still_fires(self): - with patch("watches.client") as mock_client: + with patch("palmer.watches.client") as mock_client: mock_client.messages.create.return_value = _haiku_reply("YES") assert watches_mod._check_watch_hit( results="candidate", description="d", recent_summaries=[], @@ -74,8 +74,8 @@ def test_yes_reply_still_fires(self): class TestUpdateStoryState: def test_persists_haiku_summary(self): - with patch("watches.client") as mock_client, \ - patch("watches.update_watch_story") as mock_update: + with patch("palmer.watches.client") as mock_client, \ + patch("palmer.watches.update_watch_story") as mock_update: mock_client.messages.create.return_value = _haiku_reply( "Cardinals extended their winning streak to seven with a 4-2 win over the Cubs." ) @@ -91,8 +91,8 @@ def test_persists_haiku_summary(self): assert "seven" in args[1].lower() or "streak" in args[1].lower() def test_no_previous_state_still_seeds(self): - with patch("watches.client") as mock_client, \ - patch("watches.update_watch_story") as mock_update: + with patch("palmer.watches.client") as mock_client, \ + patch("palmer.watches.update_watch_story") as mock_update: mock_client.messages.create.return_value = _haiku_reply( "Cardinals moved into first place with a win over the Cubs." ) @@ -107,8 +107,8 @@ def test_no_previous_state_still_seeds(self): assert "first alert" in prompt.lower() def test_haiku_failure_swallowed(self): - with patch("watches.client") as mock_client, \ - patch("watches.update_watch_story") as mock_update: + with patch("palmer.watches.client") as mock_client, \ + patch("palmer.watches.update_watch_story") as mock_update: mock_client.messages.create.side_effect = RuntimeError("boom") # Must not raise — the alert already went out; losing the state # update only costs us dedup benefit on the next tick. @@ -119,8 +119,8 @@ def test_haiku_failure_swallowed(self): mock_update.assert_not_called() def test_empty_haiku_reply_no_persist(self): - with patch("watches.client") as mock_client, \ - patch("watches.update_watch_story") as mock_update: + with patch("palmer.watches.client") as mock_client, \ + patch("palmer.watches.update_watch_story") as mock_update: mock_client.messages.create.return_value = _haiku_reply(" ") watches_mod._update_story_state( watch_id=1, previous_state=None, @@ -130,8 +130,8 @@ def test_empty_haiku_reply_no_persist(self): def test_summary_truncated_to_400_chars(self): long_text = "x" * 900 - with patch("watches.client") as mock_client, \ - patch("watches.update_watch_story") as mock_update: + with patch("palmer.watches.client") as mock_client, \ + patch("palmer.watches.update_watch_story") as mock_update: mock_client.messages.create.return_value = _haiku_reply(long_text) watches_mod._update_story_state( watch_id=1, previous_state=None, diff --git a/test_weather.py b/tests/test_weather.py similarity index 98% rename from test_weather.py rename to tests/test_weather.py index a0cb044..869cfdd 100644 --- a/test_weather.py +++ b/tests/test_weather.py @@ -4,7 +4,7 @@ import pytest -import weather +from palmer import weather class TestGeocode: diff --git a/test_weather_city.py b/tests/test_weather_city.py similarity index 94% rename from test_weather_city.py rename to tests/test_weather_city.py index 7c289bc..d502786 100644 --- a/test_weather_city.py +++ b/tests/test_weather_city.py @@ -28,8 +28,8 @@ """ from unittest.mock import patch -import agent -import morning +from palmer import agent +from palmer import morning def _dispatch_block() -> str: @@ -41,19 +41,19 @@ def _dispatch_block() -> str: class TestCityFromWeatherTopic: def test_a_weather_topic_yields_its_city(self): - with patch("morning._infer_city_from_topics", return_value="Culver City, CA") as m: + with patch("palmer.morning._infer_city_from_topics", return_value="Culver City, CA") as m: assert agent._city_from_weather_topic("Culver City CA weather") == "Culver City, CA" assert m.call_args[0][0] == ["Culver City CA weather"] def test_forecast_and_temperature_phrasings_also_count(self): - with patch("morning._infer_city_from_topics", return_value="Woodland Hills, CA"): + with patch("palmer.morning._infer_city_from_topics", return_value="Woodland Hills, CA"): for t in ("Woodland Hills CA weather forecast", "Denver temperature"): assert agent._city_from_weather_topic(t) == "Woodland Hills, CA" def test_a_non_weather_topic_never_pays_for_a_lookup(self): """This runs on the write path, but a model call per added topic is still worth avoiding — and a news topic must never move the city.""" - with patch("llm.client") as client: + with patch("palmer.llm.client") as client: for t in ("AI news", "US politics", "Bitcoin price", "SPCX stock price"): assert agent._city_from_weather_topic(t) is None client.messages.create.assert_not_called() @@ -63,7 +63,7 @@ def test_empty_input_is_safe(self): assert agent._city_from_weather_topic(None) is None def test_an_unresolvable_weather_topic_leaves_the_city_alone(self): - with patch("morning._infer_city_from_topics", return_value=None): + with patch("palmer.morning._infer_city_from_topics", return_value=None): assert agent._city_from_weather_topic("weather") is None diff --git a/test_weather_locations.py b/tests/test_weather_locations.py similarity index 94% rename from test_weather_locations.py rename to tests/test_weather_locations.py index a305d22..f2f4b92 100644 --- a/test_weather_locations.py +++ b/tests/test_weather_locations.py @@ -10,10 +10,10 @@ import inspect from unittest.mock import patch -import agent -import home -import page -import weather +from palmer import agent +from palmer import home +from palmer import page +from palmer import weather class TestResolveWeatherLocation: @@ -28,15 +28,15 @@ def test_an_unresolvable_place_returns_none_rather_than_guessing(self): class TestSchema: def test_the_field_is_allowed(self): - from userprofile import PROFILE_FIELDS + from palmer.userprofile import PROFILE_FIELDS assert "weather_locations" in PROFILE_FIELDS def test_it_is_not_aliased(self): - from userprofile import _PROFILE_ALIASES + from palmer.userprofile import _PROFILE_ALIASES assert "weather_locations" not in _PROFILE_ALIASES def test_structured_rows_survive_canonicalisation(self): - from userprofile import _canonical_updates + from palmer.userprofile import _canonical_updates out = _canonical_updates({"weather_locations": ["Holiday Shores, IL"]}) assert out == {"weather_locations": ["Holiday Shores, IL"]} @@ -108,7 +108,7 @@ def test_no_locations_is_not_an_error(self): assert home._fetch_weather_extra({"city": "Kirkwood, MO"}) == [] def test_one_snapshot_per_location(self): - with patch("weather.weather_snapshot", return_value={"temp_now": 80}) as snap: + with patch("palmer.weather.weather_snapshot", return_value={"temp_now": 80}) as snap: out = home._fetch_weather_extra(self.PROFILE) assert len(out) == 2 assert snap.call_count == 2 @@ -116,7 +116,7 @@ def test_one_snapshot_per_location(self): def test_a_failed_location_drops_only_that_row(self): """Same shape as _fetch_prices keeping other tickers when one 429s — one bad location must not blank the whole section.""" - with patch("weather.weather_snapshot", side_effect=[None, {"temp_now": 75}]): + with patch("palmer.weather.weather_snapshot", side_effect=[None, {"temp_now": 75}]): out = home._fetch_weather_extra(self.PROFILE) assert len(out) == 1 assert out[0]["temp_now"] == 75 @@ -203,7 +203,7 @@ def test_the_read_path_still_takes_the_top_hit(self): def test_the_dispatch_refuses_to_pick(self): import inspect - import agent + from palmer import agent block = inspect.getsource(agent.get_reply).split('"add_weather_location"')[1] \ .split("elif b.name")[0] assert "ambiguous_location" in block diff --git a/test_weather_source.py b/tests/test_weather_source.py similarity index 99% rename from test_weather_source.py rename to tests/test_weather_source.py index 20e4735..2e34570 100644 --- a/test_weather_source.py +++ b/tests/test_weather_source.py @@ -19,7 +19,7 @@ """ from unittest.mock import patch -import weather +from palmer import weather POINTS = {