From 5b56a1d1c811e427a7d5dc2b39e5c484345d63b7 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Tue, 28 Apr 2026 00:36:11 -0400 Subject: [PATCH 1/6] Update to add ty support, fixed ty issues on main code. --- src/api/endpoints.py | 19 +++++++------ src/config.py | 25 +++++++++++------ src/core/cshcalendar.py | 42 ++++++++++++++++------------ src/core/slack.py | 32 +++++++++++---------- src/core/wikithoughts.py | 54 ++++++++++++++++++++++++++---------- src/main.py | 14 ++++------ tests/conftest.py | 3 +- tests/src/core/test_slack.py | 2 +- tests/src/test_config.py | 2 +- ty.toml | 2 ++ 10 files changed, 122 insertions(+), 73 deletions(-) create mode 100644 ty.toml diff --git a/src/api/endpoints.py b/src/api/endpoints.py index b5985a6..5166301 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -1,13 +1,12 @@ -from logging import getLogger, Logger - import json -import httpx +from logging import Logger, getLogger -from fastapi import APIRouter, Request, Form +import httpx +from fastapi import APIRouter, Form, Request from fastapi.responses import JSONResponse -from core import slack, wikithoughts, cshcalendar from config import WATCHED_CHANNELS +from core import cshcalendar, slack, wikithoughts logger: Logger = getLogger(__name__) router: APIRouter = APIRouter() @@ -28,9 +27,13 @@ async def get_calendar() -> JSONResponse: events: list[dict[str, str]] = [] try: - get_future_events_ical: list[ - cshcalendar.CalendarInfo - ] = await cshcalendar.get_future_events() + get_future_events_ical: ( + list[cshcalendar.CalendarInfo] | None + ) = await cshcalendar.get_future_events() + + if get_future_events_ical is None: + raise Exception("Gathering future events resulted in None") + events = cshcalendar.format_events(get_future_events_ical) except Exception as e: logger.error(f"Error fetching calendar events: {e}") diff --git a/src/config.py b/src/config.py index 3e3eb81..ea26991 100644 --- a/src/config.py +++ b/src/config.py @@ -1,6 +1,8 @@ -import os import json import logging +import os +from typing import overload + from dotenv import load_dotenv load_dotenv() @@ -8,6 +10,14 @@ logger: logging.Logger = logging.getLogger(__name__) +@overload +def _get_env_variable(name: str, default: None = None) -> str | None: ... + + +@overload +def _get_env_variable(name: str, default: str) -> str: ... + + def _get_env_variable(name: str, default: str | None = None) -> str | None: """ Retrieves an environment variable, with an optional default value. @@ -21,7 +31,7 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None: """ try: - value: str = os.getenv(name, default) + value: str | None = os.getenv(name, default) if value in (None, ""): logger.warning( @@ -39,10 +49,9 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None: SLACK_API_TOKEN: str | None = _get_env_variable("SLACK_API_TOKEN", None) SLACK_JUMPSTART_MESSAGE: str = "Would you like to post this message to Jumpstart?" -WATCHED_CHANNELS: tuple[str] = tuple( - _get_env_variable("WATCHED_CHANNELS", "").split(",") -) -SLACK_DM_TEMPLATE: dict | None = None +RAW_CHANNELS: str = _get_env_variable("WATCHED_CHANNELS", "") +WATCHED_CHANNELS: tuple[str, ...] = tuple(RAW_CHANNELS.split(",")) +SLACK_DM_TEMPLATE: list | None = None CALENDAR_URL: str | None = _get_env_variable("CALENDAR_URL", None) CALENDAR_OUTLOOK_DAYS: int = int(_get_env_variable("CALENDAR_OUTLOOK_DAYS", "7")) @@ -51,8 +60,8 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None: CALENDAR_CACHE_REFRESH: int = int(_get_env_variable("CALENDAR_CACHE_REFRESH", "10")) WIKI_API: str | None = _get_env_variable("WIKI_API", None) -WIKIBOT_USER: str | None = _get_env_variable("WIKIBOT_USER", None) -WIKIBOT_PASSWORD: str | None = _get_env_variable("WIKIBOT_PASSWORD", None) +WIKIBOT_USER: str = _get_env_variable("WIKIBOT_USER", "") +WIKIBOT_PASSWORD: str = _get_env_variable("WIKIBOT_PASSWORD", "") WIKI_CATEGORY: str = _get_env_variable("WIKI_CATEGORY", "JobAdvice") with open(os.path.join(BASE_DIR, "static", "slack", "dm_request_template.json")) as f: diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py index 01b7c34..7edc5ac 100644 --- a/src/core/cshcalendar.py +++ b/src/core/cshcalendar.py @@ -1,12 +1,13 @@ -from logging import getLogger, Logger -from datetime import datetime, date, timedelta, time +import asyncio +import re +from datetime import date, datetime, time, timedelta +from logging import Logger, getLogger from zoneinfo import ZoneInfo -from icalendar.cal import Event, Calendar +import arrow import httpx import recurring_ical_events -import arrow -import re +from icalendar.cal import Calendar, Event from config import ( CALENDAR_CACHE_REFRESH, @@ -15,7 +16,6 @@ CALENDAR_TIMEZONE, CALENDAR_URL, ) -import asyncio calendar_cache: list[CalendarInfo] = [] # The current cache of the calendar cal_last_update: date | None = ( @@ -45,7 +45,7 @@ WARNING: PERCENTAGE SIGNS WILL TRIGGER A REGEX OPERATION WARNING: FOLLOW INSERTION ORDER """ -HUMANIZER_CHECKS: dict[int, str] = { +HUMANIZER_CHECKS: dict[int | float, str] = { MINUTE: "In 1 Minute", (HOUR - MINUTE): f"In %{MINUTE}% Minutes", (HOUR * 1.5): "In 1 Hour", @@ -67,7 +67,7 @@ class CalendarInfo: def __init__(self, name: str, date_time: date, location: str | None = None): self.name: str = name - self.date: arrow.arrow = arrow.get(date_time) # Arrow has way cooler stuff + self.date: arrow.Arrow = arrow.get(date_time) # Arrow has way cooler stuff self.location: str | None = location def __eq__(self, other): @@ -93,7 +93,7 @@ def ceil_division(num: int, den: int) -> int: return (num + den - 1) // den -def time_humanizer(current_time: datetime, event_time: datetime) -> str: +def time_humanizer(current_time: datetime, event_time: arrow.Arrow) -> str: """ Custom humanizer for text to be displayed @@ -118,7 +118,7 @@ def repl(match: re.Match[str]) -> str: num = int(match.group(1)) return str(round(time_before_event / num)) - time_before_event: int = (event_time - current_time).total_seconds() + time_before_event: int | float = (event_time - current_time).total_seconds() if time_before_event > WEEK: return "Over a Week Away" @@ -136,6 +136,7 @@ def repl(match: re.Match[str]) -> str: return TIME_PATTERN.sub(repl, unformatted_string) + def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: """ Formats a parsed list of CalendarInfos, and returns the HTML required for front end @@ -150,7 +151,7 @@ def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: current_date: date = datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) if not events: - return {"data": [{"header": ":(", "content": "No Events on the Calendar"}]} + return [{"header": ":(", "content": "No Events on the Calendar"}] formatted_list: list[dict[str, str]] = [] @@ -181,6 +182,9 @@ async def rebuild_calendar() -> None: global calendar_cache, cal_last_update, cal_constructed_event + if CALENDAR_URL is None: + raise Exception("Calendar URL is None, cant request.") + current_time: datetime = datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) try: cal_constructed_event.clear() @@ -188,11 +192,11 @@ async def rebuild_calendar() -> None: response: httpx.Response = await cshcal_client.get(CALENDAR_URL, timeout=10) response.raise_for_status() - cal: Calendar = Calendar.from_ical(response.content) + cal: Calendar | None = Calendar.from_ical(response.content) - fetched_daily_events: list[Event] = recurring_ical_events.of(cal).between( - current_time, current_time + timedelta(days=CALENDAR_OUTLOOK_DAYS) - ) + fetched_daily_events: list[Event] | None = recurring_ical_events.of( + cal + ).between(current_time, current_time + timedelta(days=CALENDAR_OUTLOOK_DAYS)) for event in fetched_daily_events: dt = event.get("DTSTART").dt @@ -228,7 +232,7 @@ async def rebuild_calendar() -> None: cal_constructed_event.set() -async def get_future_events() -> list[CalendarInfo]: +async def get_future_events() -> list[CalendarInfo] | None: """ Returns the first events up to event maximum within the the calendar outlook day amount custom object has name, date and the location @@ -244,6 +248,9 @@ async def get_future_events() -> list[CalendarInfo]: header_none_match, \ cal_constructed_event + if CALENDAR_URL is None: + raise Exception("Calendar URL is None, cant request.") + if not cal_constructed_event.is_set(): await cal_constructed_event.wait() return calendar_cache @@ -261,10 +268,11 @@ async def get_future_events() -> list[CalendarInfo]: logger.info("Checking to rebuild CSH Calendar...") try: - headers: dict[str, str | None] = {} + headers: dict[str, str] = {} if header_none_match: headers["If-None-Match"] = header_none_match + if header_last_modified: headers["If-Modified-Since"] = header_last_modified diff --git a/src/core/slack.py b/src/core/slack.py index 1b410b3..8dfa91f 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -1,21 +1,20 @@ -import re import copy import json +import re +from datetime import datetime +from logging import Logger, getLogger +from zoneinfo import ZoneInfo -from logging import getLogger, Logger - -from slack_sdk.web.async_client import AsyncWebClient -from slack_sdk.web.slack_response import SlackResponse from slack_sdk.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse from config import ( + CALENDAR_TIMEZONE, SLACK_API_TOKEN, - SLACK_JUMPSTART_MESSAGE, SLACK_DM_TEMPLATE, - CALENDAR_TIMEZONE, + SLACK_JUMPSTART_MESSAGE, ) -from datetime import datetime -from zoneinfo import ZoneInfo logger: Logger = getLogger(__name__) @@ -32,11 +31,10 @@ "user": "Jumpstart", "timestamp": datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) .strftime("%I:%M %p") - .lstrip("0") + .lstrip("0"), } - def clean_text(raw: str) -> str: """ Strip Slack mrkdwn, HTML entities, and formatting characters. @@ -67,7 +65,7 @@ async def gather_emojis() -> dict: if client is None: raise ValueError("Slack client is not initialized") - emoji_request: dict = await client.emoji_list() + emoji_request: AsyncSlackResponse = await client.emoji_list() assert emoji_request.get("ok", False) emojis = emoji_request.get("emoji", {}) @@ -88,7 +86,10 @@ async def get_username(user_id: str) -> str: str: The username, or an empty string if not applicable """ - response = await client.users_info(user=user_id) + if client is None: + raise ValueError("Slack client is not initialized") + + response: AsyncSlackResponse = await client.users_info(user=user_id) user = response.get("user", None) if user is None: @@ -115,7 +116,10 @@ async def request_upload_via_dm(user_id: str, announcement_text: str) -> None: if client is None: raise ValueError("Slack client is not initialized") - message: dict = copy.deepcopy(SLACK_DM_TEMPLATE) + message: list | None = copy.deepcopy(SLACK_DM_TEMPLATE) + + if message is None: + raise Exception("Unable to deepcopy dm template.") message[0]["text"]["text"] += announcement_text message[1]["elements"][0]["value"] = json.dumps( diff --git a/src/core/wikithoughts.py b/src/core/wikithoughts.py index 1a3e715..77ce73e 100644 --- a/src/core/wikithoughts.py +++ b/src/core/wikithoughts.py @@ -1,15 +1,14 @@ -import re -import httpx -import random import asyncio import logging - -from typing import Pattern -from itertools import islice +import random +import re from datetime import datetime, timedelta +from itertools import islice +from typing import Pattern -from config import WIKIBOT_PASSWORD, WIKIBOT_USER, WIKI_CATEGORY, WIKI_API +import httpx +from config import WIKI_API, WIKI_CATEGORY, WIKIBOT_PASSWORD, WIKIBOT_USER CYCLE_DEBOUNCE_TIME: int = 12 # How long it takes to resfresh wiki titles BATCH_SIZE: int = 50 # max titles per request @@ -17,15 +16,15 @@ 3 # The amount of times it will attempt to re-authenticare ) -HEADERS: dict[str, str] = {"User-Agent": "JumpstartFetcher/1.0"} -AUTH: tuple[str] = (WIKIBOT_USER, WIKIBOT_PASSWORD) +_HEADERS: dict[str, str] = {"User-Agent": "JumpstartFetcher/1.0"} +_AUTH: tuple[str, str] = (WIKIBOT_USER, WIKIBOT_PASSWORD) logger: logging.Logger = logging.getLogger(__name__) client: httpx.AsyncClient | None = None try: - client = httpx.AsyncClient(headers=HEADERS, auth=AUTH) + client = httpx.AsyncClient(headers=_HEADERS, auth=_AUTH) except Exception as e: logger.warning(f"Failed to initialize HTTP client for wiki: {e}") @@ -68,7 +67,7 @@ def clean_wikitext(text: str) -> str: str: The cleaned up text string """ - reg_operations: tuple[Pattern[str]] = ( + reg_operations: tuple[Pattern[str], ...] = ( RE_FILE, RE_IMAGE, RE_LINK, @@ -218,12 +217,12 @@ def needs_category_refresh(update_time: datetime) -> bool: ) -def process_category_page(r_json: dict[str, str]) -> tuple[list[str], bool | str]: +def process_category_page(r_json: dict) -> tuple[list[str], bool | str]: """ Processes a wikithoughts response into a list of title pages Args: - r_json (dict[str,str]): The JSON from the wiki to be processed + r_json: The JSON from the wiki to be processed Returns: tuple[list[str], bool | str]: The list of titles from the request, along with a possible continutation if needed @@ -256,7 +255,19 @@ async def fetch_category_pages(response: httpx.Response) -> list[str]: list[str]: The list of titles to be fetched. """ - params: dict[str, str] = { + if not client: + logger.warning( + "HTTP client for wiki is not initialized, unable to fetch category pages!" + ) + return [] + + if not WIKI_API: + logger.warning( + "There is no WIKI_API set to make requests, unable to fetch category pages!" + ) + return [] + + params: dict[str, str | bool] = { "action": "query", "list": "categorymembers", "cmtitle": f"Category:{WIKI_CATEGORY}", @@ -269,7 +280,7 @@ async def fetch_category_pages(response: httpx.Response) -> list[str]: failed_authentication_attempts: int = 0 while True: - r_json: dict[str, str] = response.json() + r_json: dict = response.json() if "error" in r_json and r_json["error"].get("code") in ( "readapidenied", @@ -325,7 +336,14 @@ async def refresh_category_pages() -> list[str]: ) return [] + if not WIKI_API: + logger.warning( + "There is no WIKI_API set to make requests, unable to refresh category pages!" + ) + return [] + global page_title_cache, last_updated_time, queued_pages, shown_pages + time_now: datetime = datetime.now() if not needs_category_refresh(time_now): @@ -378,6 +396,12 @@ async def refresh_page_dictionary() -> None: ) return + if not WIKI_API: + logger.warning( + "There is no WIKI_API set to make requests, unable to refresh page dictionary!" + ) + return + global page_dict_cache, page_title_cache if not page_title_cache: diff --git a/src/main.py b/src/main.py index 0fd0da7..a131c1b 100644 --- a/src/main.py +++ b/src/main.py @@ -5,21 +5,19 @@ V1 Authors: Beckett Jenen """ -import os import asyncio - -from logging import getLogger, Logger +import os +from contextlib import asynccontextmanager +from logging import Logger, getLogger from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.responses import RedirectResponse, HTMLResponse -from contextlib import asynccontextmanager - -from config import BASE_DIR from api import endpoints -from core import wikithoughts, cshcalendar +from config import BASE_DIR +from core import cshcalendar, wikithoughts logger: Logger = getLogger(__name__) diff --git a/tests/conftest.py b/tests/conftest.py index 96dcc6a..cd8997c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ -import coverage import os +import coverage + # Get the absolute path to the src directory src_path = os.path.join(os.path.dirname((os.path.abspath(__file__))), "..", "src") diff --git a/tests/src/core/test_slack.py b/tests/src/core/test_slack.py index ea952a4..015cfda 100644 --- a/tests/src/core/test_slack.py +++ b/tests/src/core/test_slack.py @@ -1,6 +1,6 @@ -import sys import asyncio import importlib +import sys def import_slack_module(monkeypatch) -> object: diff --git a/tests/src/test_config.py b/tests/src/test_config.py index 767b36f..b1aa955 100644 --- a/tests/src/test_config.py +++ b/tests/src/test_config.py @@ -1,5 +1,5 @@ -import sys import importlib +import sys def import_config_module() -> object: diff --git a/ty.toml b/ty.toml new file mode 100644 index 0000000..868b754 --- /dev/null +++ b/ty.toml @@ -0,0 +1,2 @@ +[environment] +extra-paths = ["./src"] From 796942c0eef29befeb1623f4da7a4de5fa7f9ccc Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Thu, 30 Apr 2026 21:14:08 -0400 Subject: [PATCH 2/6] Add logging levels --- .env.template | 2 ++ docker-compose.yml | 3 ++- mkdocs.yml | 2 +- src/api/endpoints.py | 6 +++--- src/config.py | 18 ++++++++++++++++-- src/core/cshcalendar.py | 25 +++++++++++-------------- src/core/slack.py | 5 +++-- src/core/wikithoughts.py | 25 +++++++++++++++---------- src/main.py | 3 ++- 9 files changed, 55 insertions(+), 34 deletions(-) diff --git a/.env.template b/.env.template index 853032e..edcbb45 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,5 @@ +LOGGING_LEVEL=DEBUG + CALENDAR_URL= CALENDAR_OUTLOOK_DAYS= CALENDAR_EVENT_MAXIMUM= diff --git a/docker-compose.yml b/docker-compose.yml index 6468237..0d6f13f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,4 @@ --- - services: jumpstart: build: . @@ -7,6 +6,8 @@ services: ports: - "8000:8000" environment: + - LOGGING_LEVEL=${LOGGING_LEVEL} + - CALENDAR_URL=${CALENDAR_URL} - CALENDAR_OUTLOOK_DAYS=${CALENDAR_OUTLOOK_DAYS} - CALENDAR_EVENT_MAXIMUM=${CALENDAR_EVENT_MAXIMUM} diff --git a/mkdocs.yml b/mkdocs.yml index 1b7673e..a806442 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,7 +38,7 @@ use_directory_urls: false nav: - Home: index.md - Getting Started: getting-started/getting-started.md - - Backend: + - Backend: - Calendar: core/csh_calendar.md - Slack: core/slack.md - Wikithoughts: core/wikithoughts.md diff --git a/src/api/endpoints.py b/src/api/endpoints.py index 5166301..634f1b8 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -34,7 +34,7 @@ async def get_calendar() -> JSONResponse: if get_future_events_ical is None: raise Exception("Gathering future events resulted in None") - events = cshcalendar.format_events(get_future_events_ical) + events.extend(cshcalendar.format_events(get_future_events_ical)) except Exception as e: logger.error(f"Error fetching calendar events: {e}") return JSONResponse({"status": "error", "message": str(e)}, status_code=500) @@ -126,8 +126,8 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse: "User approved the announcement, Adding it to the announcement list!" ) - message_object: dict[str, dict] = json.loads( - form_json.get("actions", [{}])[0].get("value", '{text:""}') + message_object: str | None = json.loads( + form_json.get("actions", [{}])[0].get("value", {}) ).get("text", None) user_id = form_json.get("user", {}).get("id") diff --git a/src/config.py b/src/config.py index ea26991..92cc15e 100644 --- a/src/config.py +++ b/src/config.py @@ -47,10 +47,23 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None: BASE_DIR: str = os.path.dirname(os.path.abspath(__file__)) +_raw_logging_level: str = _get_env_variable("LOGGING_LEVEL", "DEBUG") +LOGGING_LEVEL: int = logging.INFO + +match _raw_logging_level: + case "DEBUG": + LOGGING_LEVEL = logging.DEBUG + case "WARN": + LOGGING_LEVEL = logging.WARN + SLACK_API_TOKEN: str | None = _get_env_variable("SLACK_API_TOKEN", None) SLACK_JUMPSTART_MESSAGE: str = "Would you like to post this message to Jumpstart?" RAW_CHANNELS: str = _get_env_variable("WATCHED_CHANNELS", "") WATCHED_CHANNELS: tuple[str, ...] = tuple(RAW_CHANNELS.split(",")) + +SLACK_DM_TEMPLATE_FILEPATH: str = os.path.join( + BASE_DIR, "static", "slack", "dm_request_template.json" +) SLACK_DM_TEMPLATE: list | None = None CALENDAR_URL: str | None = _get_env_variable("CALENDAR_URL", None) @@ -64,5 +77,6 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None: WIKIBOT_PASSWORD: str = _get_env_variable("WIKIBOT_PASSWORD", "") WIKI_CATEGORY: str = _get_env_variable("WIKI_CATEGORY", "JobAdvice") -with open(os.path.join(BASE_DIR, "static", "slack", "dm_request_template.json")) as f: - SLACK_DM_TEMPLATE = json.load(f) +if os.path.exists(SLACK_DM_TEMPLATE_FILEPATH): + with open(SLACK_DM_TEMPLATE_FILEPATH, mode="r") as f: + SLACK_DM_TEMPLATE = json.load(f) diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py index 7edc5ac..fe1134b 100644 --- a/src/core/cshcalendar.py +++ b/src/core/cshcalendar.py @@ -15,6 +15,7 @@ CALENDAR_OUTLOOK_DAYS, CALENDAR_TIMEZONE, CALENDAR_URL, + LOGGING_LEVEL, ) calendar_cache: list[CalendarInfo] = [] # The current cache of the calendar @@ -31,6 +32,8 @@ cal_constructed_event.clear() logger: Logger = getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) + logger.info("Starting up the calendar service!") cshcal_client = httpx.AsyncClient() @@ -157,19 +160,16 @@ def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: for event in events: content_dict: dict[str, str] = {} + content_dict["content"] = str(event.name) - event_cur_happening: bool = event.date < current_date - if event_cur_happening: - formatted: str = ( + if event.date < current_date: + content_dict["header"] = ( f"Happening in {event.location}!" if event.location else "Happening Now!" ) - content_dict["header"] = formatted - content_dict["content"] = str(event.name) else: content_dict["header"] = time_humanizer(current_date, event.date) - content_dict["content"] = str(event.name) formatted_list.append(content_dict) return formatted_list @@ -194,19 +194,17 @@ async def rebuild_calendar() -> None: cal: Calendar | None = Calendar.from_ical(response.content) - fetched_daily_events: list[Event] | None = recurring_ical_events.of( - cal - ).between(current_time, current_time + timedelta(days=CALENDAR_OUTLOOK_DAYS)) + fetched_daily_events: list[Event] = recurring_ical_events.of(cal).between( + current_time, current_time + timedelta(days=CALENDAR_OUTLOOK_DAYS) + ) for event in fetched_daily_events: dt = event.get("DTSTART").dt if isinstance(dt, date) and not isinstance(dt, datetime): dt = datetime.combine(dt, time.min, tzinfo=ZoneInfo(CALENDAR_TIMEZONE)) - elif dt.tzinfo is None: dt = dt.replace(tzinfo=ZoneInfo(CALENDAR_TIMEZONE)) - else: dt = dt.astimezone(ZoneInfo(CALENDAR_TIMEZONE)) @@ -218,11 +216,10 @@ async def rebuild_calendar() -> None: found_events.add(new_event) - cal = None - fetched_daily_events = None + fetched_daily_events.clear() except Exception as e: logger.warning("Failed to rebuild calendar cache! Error:") - logger.warning(e) + logger.error(e) cal_constructed_event.set() cal_last_update = current_time diff --git a/src/core/slack.py b/src/core/slack.py index 8dfa91f..f75f931 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -11,13 +11,14 @@ from config import ( CALENDAR_TIMEZONE, + LOGGING_LEVEL, SLACK_API_TOKEN, SLACK_DM_TEMPLATE, SLACK_JUMPSTART_MESSAGE, ) logger: Logger = getLogger(__name__) - +logger.setLevel(LOGGING_LEVEL) client: AsyncWebClient | None = None @@ -173,7 +174,7 @@ def get_announcement() -> dict[str, str] | None: return current_announcement -def add_announcement(announcement_text: str, username: str) -> None: +def add_announcement(announcement_text: str | None, username: str) -> None: """ Adds an announcement to the queue. diff --git a/src/core/wikithoughts.py b/src/core/wikithoughts.py index 77ce73e..526c7ba 100644 --- a/src/core/wikithoughts.py +++ b/src/core/wikithoughts.py @@ -8,7 +8,13 @@ import httpx -from config import WIKI_API, WIKI_CATEGORY, WIKIBOT_PASSWORD, WIKIBOT_USER +from config import ( + LOGGING_LEVEL, + WIKI_API, + WIKI_CATEGORY, + WIKIBOT_PASSWORD, + WIKIBOT_USER, +) CYCLE_DEBOUNCE_TIME: int = 12 # How long it takes to resfresh wiki titles BATCH_SIZE: int = 50 # max titles per request @@ -20,6 +26,7 @@ _AUTH: tuple[str, str] = (WIKIBOT_USER, WIKIBOT_PASSWORD) logger: logging.Logger = logging.getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) client: httpx.AsyncClient | None = None @@ -132,6 +139,8 @@ async def auth_bot() -> None: ) return + global bot_authenticated + token_req: httpx.Response = await client.get( WIKI_API, params={"action": "query", "meta": "tokens", "type": "login", "format": "json"}, @@ -153,8 +162,6 @@ async def auth_bot() -> None: returned_json: dict = login_req.json()["login"] if returned_json and returned_json["result"] == "Success": - global bot_authenticated - bot_authenticated = True logger.info("Bot was authenticated successfully!") else: @@ -237,11 +244,10 @@ def process_category_page(r_json: dict) -> tuple[list[str], bool | str]: # Loop to keep everything going if "continue" in r_json: return (titles, r_json["continue"]["cmcontinue"]) - - return (titles, False) else: logger.warning(f"Failure in obtaining info, JSON:\n{r_json}") - return (titles, False) + + return (titles, False) async def fetch_category_pages(response: httpx.Response) -> list[str]: @@ -307,7 +313,7 @@ async def fetch_category_pages(response: httpx.Response) -> list[str]: continue added, repeat_req = process_category_page(r_json) - titles_found += added + titles_found.extend(added) if repeat_req not in (None, False, ""): params["cmcontinue"] = repeat_req @@ -349,7 +355,6 @@ async def refresh_category_pages() -> list[str]: if not needs_category_refresh(time_now): return page_title_cache - titles: list[str] = [] params: dict[str, str] = { "action": "query", "list": "categorymembers", @@ -379,7 +384,7 @@ async def refresh_category_pages() -> list[str]: queued_pages = titles.copy() random.shuffle(queued_pages) - shown_pages = [] + shown_pages.clear() await refresh_page_dictionary() return page_title_cache @@ -459,7 +464,7 @@ def reset_queues() -> None: queued_pages = shown_pages random.shuffle(queued_pages) - shown_pages = [] + shown_pages.clear() async def get_next_display() -> dict[str, str]: diff --git a/src/main.py b/src/main.py index a131c1b..a74cc65 100644 --- a/src/main.py +++ b/src/main.py @@ -16,10 +16,11 @@ from fastapi.templating import Jinja2Templates from api import endpoints -from config import BASE_DIR +from config import BASE_DIR, LOGGING_LEVEL from core import cshcalendar, wikithoughts logger: Logger = getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) @asynccontextmanager From cc10c7ee89b43dde75f94d53e51cdc55092c40a1 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Sat, 2 May 2026 17:51:19 -0400 Subject: [PATCH 3/6] Simplify some --- src/api/endpoints.py | 24 ++++++++++-------------- src/core/cshcalendar.py | 3 +-- src/core/wikithoughts.py | 2 +- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/api/endpoints.py b/src/api/endpoints.py index 634f1b8..2b6d037 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -121,6 +121,8 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse: if form_json.get("type") != "block_actions": return JSONResponse({}, status_code=200) + message: str = DENY_MESSAGE + if slack.convert_user_response_to_bool(form_json): logger.info( "User approved the announcement, Adding it to the announcement list!" @@ -136,20 +138,14 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse: username = username[:40] slack.add_announcement(message_object, username) - - if response_url: - async with httpx.AsyncClient() as client: - await client.post( - response_url, - json={"text": ACCEPT_MESSAGE, "replace_original": True}, - ) - else: - if response_url: - async with httpx.AsyncClient() as client: - await client.post( - response_url, - json={"text": DENY_MESSAGE, "replace_original": True}, - ) + message: str = ACCEPT_MESSAGE + + if response_url: + async with httpx.AsyncClient() as client: + await client.post( + response_url, + json={"text": message, "replace_original": True}, + ) except Exception as e: logger.error(f"Error in message_actions: {e}") diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py index fe1134b..bb5ec47 100644 --- a/src/core/cshcalendar.py +++ b/src/core/cshcalendar.py @@ -159,8 +159,7 @@ def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: formatted_list: list[dict[str, str]] = [] for event in events: - content_dict: dict[str, str] = {} - content_dict["content"] = str(event.name) + content_dict: dict[str, str] = {"content": str(event.name)} if event.date < current_date: content_dict["header"] = ( diff --git a/src/core/wikithoughts.py b/src/core/wikithoughts.py index 526c7ba..4bfe234 100644 --- a/src/core/wikithoughts.py +++ b/src/core/wikithoughts.py @@ -415,7 +415,7 @@ async def refresh_page_dictionary() -> None: results: dict[str, str] = {} tasks: list = [] for batch in batch_iterable(page_title_cache, BATCH_SIZE): - params = { + params: dict[str, str | bool] = { "action": "query", "prop": "revisions", "rvprop": "content", From cbba0cff55dcd69e41803a3f2fe09f9c71251212 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Sat, 2 May 2026 18:38:40 -0400 Subject: [PATCH 4/6] add more logging levels --- src/config.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config.py b/src/config.py index 92cc15e..a00a714 100644 --- a/src/config.py +++ b/src/config.py @@ -55,6 +55,12 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None: LOGGING_LEVEL = logging.DEBUG case "WARN": LOGGING_LEVEL = logging.WARN + case "ERROR": + LOGGING_LEVEL = logging.ERROR + case "FATAL": + LOGGING_LEVEL = logging.FATAL + case "CRITICAL": + LOGGING_LEVEL = logging.CRITICAL SLACK_API_TOKEN: str | None = _get_env_variable("SLACK_API_TOKEN", None) SLACK_JUMPSTART_MESSAGE: str = "Would you like to post this message to Jumpstart?" From efbe534dc2c6f8db75aa10caf38ea318b5fac80d Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Wed, 23 Sep 2026 10:37:37 -0400 Subject: [PATCH 5/6] cleanup --- .gitignore | 5 ++ src/api/endpoints.py | 18 +++-- src/config.py | 11 +-- src/core/announcement_queue.py | 118 ++++++++++++++------------------- src/core/cshcalendar.py | 20 +++--- src/core/slack.py | 30 +++++---- src/main.py | 1 - src/modules/taskmanager.py | 4 +- tests/src/core/test_slack.py | 2 - 9 files changed, 107 insertions(+), 102 deletions(-) diff --git a/.gitignore b/.gitignore index 4fae22f..bd7e040 100644 --- a/.gitignore +++ b/.gitignore @@ -204,6 +204,11 @@ cython_debug/ .cursorignore .cursorindexingignore + +# shtl +.shtl/ +shtl.toml + # Marimo marimo/_static/ marimo/_lsp/ diff --git a/src/api/endpoints.py b/src/api/endpoints.py index e719220..64f4371 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -1,14 +1,13 @@ import json from logging import Logger, getLogger -import httpx -from fastapi import APIRouter, Form, Request +from fastapi import APIRouter, Request from fastapi.responses import JSONResponse -from config import WATCHED_CHANNELS, LOGGING_LEVEL +from config import LOGGING_LEVEL from core import cshcalendar, slack, wikithoughts -import urllib +import urllib.parse logger: Logger = getLogger(__name__) logger.setLevel(LOGGING_LEVEL) @@ -73,7 +72,10 @@ async def slack_events(request: Request) -> JSONResponse: logger.warning(f"Received a Fake Slack Event!: {raw_body}") return JSONResponse({"error": "Invalid signature"}, status_code=403) - body: dict = json.loads(raw_body) + try: + body: dict = json.loads(raw_body) + except json.JSONDecodeError: + return JSONResponse({"error": "Invalid JSON body"}, status_code=400) # Challenge from Bot Authentication if request.headers.get("content-type") == "application/json": @@ -102,7 +104,11 @@ async def message_actions(request: Request) -> JSONResponse: logger.warning(f"Received a Fake Slack Message Action! {raw_body}") return JSONResponse({"error": "Invalid signature"}, status_code=403) - form_data = urllib.parse.parse_qs(raw_body.decode("utf-8")) + try: + form_data = urllib.parse.parse_qs(raw_body.decode("utf-8")) + except UnicodeDecodeError: + return JSONResponse({"error": "Invalid request body"}, status_code=400) + payload = form_data.get("payload", [None])[0] if payload is None: diff --git a/src/config.py b/src/config.py index dc8514d..ed4f90e 100644 --- a/src/config.py +++ b/src/config.py @@ -4,7 +4,6 @@ from typing import overload from dotenv import load_dotenv -from typing import Any load_dotenv() @@ -70,11 +69,15 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None: ) SLACK_DM_TEMPLATE_FILEPATH: str = os.path.join( - BASE_DIR, "static", "slack", "dm_request_template.json" + BASE_DIR, "static", "slack", "dm_request_template.json" ) SLACK_MEETINGS_KEYWORD: str = _get_env_variable("SLACK_MEETINGS_KEYWORD", "meeting") -SLACK_NONTECHNICAL_SEMINAR_KEYWORD: str = _get_env_variable("SLACK_NONTECHNICAL_SEMINAR_KEYWORD", "non-technical") -SLACK_TECHNICAL_SEMINAR_KEYWORD: str = _get_env_variable("SLACK_TECHNICAL_SEMINAR_KEYWORD", "technical") +SLACK_NONTECHNICAL_SEMINAR_KEYWORD: str = _get_env_variable( + "SLACK_NONTECHNICAL_SEMINAR_KEYWORD", "non-technical" +) +SLACK_TECHNICAL_SEMINAR_KEYWORD: str = _get_env_variable( + "SLACK_TECHNICAL_SEMINAR_KEYWORD", "technical" +) SLACK_ALLOW_ANNOUNCEMENTS: bool = ( _get_env_variable("SLACK_ALLOW_ANNOUNCEMENTS", "false") == "true" diff --git a/src/core/announcement_queue.py b/src/core/announcement_queue.py index 6b7ba00..decb026 100644 --- a/src/core/announcement_queue.py +++ b/src/core/announcement_queue.py @@ -17,11 +17,10 @@ SLACK_MEETINGS_GROUP_ID, SLACK_TEST_GROUP_ID, SLACK_ALLOW_ANNOUNCEMENTS, - SLACK_TECHNICAL_SEMINAR_KEYWORD, SLACK_NONTECHNICAL_SEMINAR_KEYWORD, SLACK_MEETINGS_KEYWORD, - LOGGING_LEVEL + LOGGING_LEVEL, ) logger: Logger = getLogger(__name__) @@ -38,6 +37,7 @@ logger.info(SLACK_MEETINGS_KEYWORD) logger.info(SLACK_TECHNICAL_SEMINAR_KEYWORD) + async def create_announcement_worker( event_uid: str, event_recurrence_id: str, text: str, event_time: datetime ) -> None: @@ -50,6 +50,7 @@ async def create_announcement_worker( text (str): The message to be sent event_time (datetime): The time for the event. """ + key: str = f"{event_uid}:{event_recurrence_id}" # we should use redis instead logger.info(key) @@ -96,104 +97,85 @@ def queue_announcement( queued_announcement_id_cache[key] = task -def clear_running_workers() -> None: - """ - Loops through and removes each running worker event. Used for clearing events that have been deleted +def clear_stale_workers(valid_keys: set[str]) -> None: """ + Cancels and removes any queued workers whose key is not in valid_keys. - for event in queued_announcement_id_cache.values(): - event.cancel() + Args: + valid_keys (set[str]): The set of event keys that are still valid this rebuild + """ - queued_announcement_id_cache.clear() + for key in list(queued_announcement_id_cache.keys()): + if key not in valid_keys: + task = queued_announcement_id_cache.pop(key, None) + if task is not None: + task.cancel() -def check_for_announcement(event: dict[str, Any], time: datetime) -> None: +def check_for_announcement(event: dict[str, Any], time: datetime) -> str | None: """ Checks to see if a worker needs to be created for an event Args: event (dict[str, str]): The information for the event time (datetime): The time for the event + + Returns: + str | None: The queued announcement's key if one was queued, otherwise None """ if not SLACK_ALLOW_ANNOUNCEMENTS: - return + return None description: str = event.get("DESCRIPTION", "") if not description: - return + return None title: str = event.get("SUMMARY", "") if not title: - return + return None uid: str = str(event.get("UID", "")) if not uid: - return + return None recurrence_id = event.get("RECURRENCE-ID", None) if not recurrence_id: - return + return None rec_id: str = recurrence_id.dt.isoformat() loc = event.get("LOCATION", None) description = description.lower().strip() - if SLACK_NONTECHNICAL_SEMINAR_KEYWORD.lower() in description: - if loc: - queue_announcement( - uid, - rec_id, - f" The Non-Technical Seminar {title} will be happening in the {loc} in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - else: - queue_announcement( - uid, - rec_id, - f" The Non-Technical Seminar {title} will be happening in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - elif SLACK_TECHNICAL_SEMINAR_KEYWORD.lower() in description: - if loc: - queue_announcement( - uid, - rec_id, - f" The Technical Seminar {title} will be happening in the {loc} in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - else: - queue_announcement( - uid, - rec_id, - f" The Technical Seminar {title} will be happening in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - elif SLACK_MEETINGS_KEYWORD.lower() in description: - if loc: - queue_announcement( - uid, - rec_id, - f" The {title} directorship will be happening in the {loc} in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - else: - queue_announcement( - uid, - rec_id, - f" The {title} directorship will be happening in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - elif "gick" in description: - if loc: - queue_announcement( - uid, rec_id, f" gick go to the {loc}!", time - ) - else: - queue_announcement( - uid, rec_id, f" hi gick!", time - ) + message: str | None = None + + match description: + case x if SLACK_NONTECHNICAL_SEMINAR_KEYWORD.lower() in x: + message = f" The Non-Technical Seminar {title} will be happening {f'in the {loc} ' if loc else ''}in {MINUTES_BEFORE_EVENT_PING} minutes!" + + case x if SLACK_TECHNICAL_SEMINAR_KEYWORD.lower() in x: + message = f" The Technical Seminar {title} will be happening {f'in the {loc} ' if loc else ''}in {MINUTES_BEFORE_EVENT_PING} minutes!" + + case x if SLACK_MEETINGS_KEYWORD.lower() in x: + message = f" The {title} directorship will be happening {f'in the {loc} ' if loc else ''}in {MINUTES_BEFORE_EVENT_PING} minutes!" + + case x if "gick" in x: + message = f" {f'gick go to the {loc}' if loc else 'hi gick'}!" + + case _: + return None + + if message is not None: + queue_announcement( + uid, + rec_id, + message, + time, + ) + + return f"{uid}:{rec_id}" + # if TECHNICAL_SEMINAR_KEYWORD.lower() in description: # taskmanager.create_background_task(create_announcement_worker( diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py index 0356dae..5410d81 100644 --- a/src/core/cshcalendar.py +++ b/src/core/cshcalendar.py @@ -10,8 +10,6 @@ import httpx import recurring_ical_events import arrow -import re -import asyncio from modules import taskmanager @@ -94,6 +92,7 @@ def __hash__(self): def ceil_division(num: int, den: int) -> int: """ Returns a ceiling division of the two numbers + Args: num (int): the numerator den (int): the denominator @@ -111,7 +110,8 @@ def time_humanizer(current_time: datetime, event_time: arrow.Arrow) -> str: Args: current_time (datetime): The current time to be judged off of - event_time (datetime): The events time to be factored + event_time (arrow.Arrow): The events time to be factored + Returns: str: The humanized time as a string """ @@ -154,7 +154,7 @@ def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: Formats a parsed list of CalendarInfos, and returns the HTML required for front end Args: - events: The list of CalendarInfos to be formatted + events: (list[CalendarInfo]) The list of CalendarInfos to be formatted Returns: list[dict[str, str]]: Returns a dictionary with the "data" key mapping to a list of dictionarys of each event. @@ -204,7 +204,7 @@ async def rebuild_calendar() -> None: current_time, current_time + timedelta(days=CALENDAR_OUTLOOK_DAYS) ) - announcement_queue.clear_running_workers() + matched_announcement_keys: set[str] = set() for event in fetched_daily_events: dt = event.get("DTSTART").dt @@ -226,9 +226,13 @@ async def rebuild_calendar() -> None: event.get("LOCATION"), ) - announcement_queue.check_for_announcement(event, dt) + announcement_key = announcement_queue.check_for_announcement(event, dt) + if announcement_key is not None: + matched_announcement_keys.add(announcement_key) found_events.add(new_event) + announcement_queue.clear_stale_workers(matched_announcement_keys) + cal_last_update = current_time calendar_cache = sorted(found_events, key=lambda x: x.date)[ :CALENDAR_EVENT_MAXIMUM @@ -257,8 +261,8 @@ async def get_future_events() -> list[CalendarInfo] | None: header_none_match, \ cal_constructed_event - if CALENDAR_URL is None: - raise Exception("Calendar URL is None, cant request.") + if not CALENDAR_URL: + raise Exception("Calendar URL is not configured, cant request.") if not cal_constructed_event.is_set(): await cal_constructed_event.wait() diff --git a/src/core/slack.py b/src/core/slack.py index 2deae8d..fc903a2 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -2,8 +2,7 @@ import json import re -from slack_sdk.web.async_client import AsyncWebClient -from slack_sdk.web.slack_response import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient, AsyncSlackResponse from slack_sdk.errors import SlackApiError from slack_sdk.signature import SignatureVerifier @@ -32,7 +31,7 @@ logger.setLevel(LOGGING_LEVEL) client: AsyncWebClient | None = None -event_id_cache: dict[str, str] = {} +event_id_cache: dict[str, int] = {} EVENT_CACHE_DEBOUNCE = ( 60 # Hold event in for one minute? I think its fine genuiflowkirkenuinelowskinly @@ -110,19 +109,20 @@ async def reset_event_from_cache(event_id: str) -> None: Arguments: event_id (str): The id of the slack event """ + global event_id_cache await asyncio.sleep(EVENT_CACHE_DEBOUNCE) - event_id_cache[event_id] = None + event_id_cache.pop(event_id, None) return -def get_event_retry_amount(event_id: str) -> int: +def get_event_retry_amount(event_id: str | None) -> int: """ Returns the amount of times a event has been retried Arguments: - event_id (str): The id of the slack event + event_id (str | None): The id of the slack event Returns: int: The amount of times the event has been retried @@ -130,6 +130,9 @@ def get_event_retry_amount(event_id: str) -> int: global event_id_cache + if event_id is None: + return 0 + if event_id in event_id_cache: event_id_cache[event_id] += 1 return event_id_cache[event_id] @@ -227,7 +230,9 @@ async def request_upload_via_dm(user_id: str, announcement_text: str) -> None: logger.error(f"Error messaging user {user_id}: {e}") -async def process_slack_events(body: dict) -> dict[str, str]: +async def process_slack_events( + body: dict, +) -> dict[str, str] | tuple[dict[str, str], int]: """ Processes a slack event, logging and returning the result from the event @@ -246,7 +251,7 @@ async def process_slack_events(body: dict) -> dict[str, str]: logger.info( f"SLACK EVENT: Retried event for {body.get('event_id', None)} {event_amounts} time(s)!" ) - return ({"status": "success"}, 200) + return {"status": "success"} event: dict = body.get("event", {}) @@ -284,7 +289,7 @@ async def process_slack_message_actions(payload: str): logger.info( f"SLACK MESSAGE ACTION: Retried event for {form_json.get('trigger_id', None)} {event_amounts} time(s)!" ) - return {"status": "ignored"} + return ({"status": "ignored"}, 200) if form_json.get("type") != "block_actions": return ({}, 200) @@ -294,8 +299,8 @@ async def process_slack_message_actions(payload: str): "User approved the announcement, Adding it to the announcement list!" ) - message_object: dict[str, dict] = json.loads( - form_json.get("actions", [{}])[0].get("value", '{text:""}') + message_object: str | None = json.loads( + form_json.get("actions", [{}])[0].get("value", '{"text": ""}') ).get("text", None) user_id = form_json.get("user", {}).get("id") @@ -334,8 +339,8 @@ async def send_announcement_message(msg_text: str) -> None: Args: msg_text (str): The text for the message - """ + if not client: logger.warning("Client has not been initalized") return @@ -385,6 +390,7 @@ def add_announcement(announcement_text: str | None, username: str) -> None: announcement_text (str): The text of the announcement to be added. user_id (str): The user_id of the person """ + global current_announcement if announcement_text is None or announcement_text.strip() == "": diff --git a/src/main.py b/src/main.py index 78eabc3..b2319ca 100644 --- a/src/main.py +++ b/src/main.py @@ -5,7 +5,6 @@ V1 Authors: Beckett Jenen """ -import asyncio import os from contextlib import asynccontextmanager from logging import Logger, getLogger diff --git a/src/modules/taskmanager.py b/src/modules/taskmanager.py index da29b07..88d8b32 100644 --- a/src/modules/taskmanager.py +++ b/src/modules/taskmanager.py @@ -20,6 +20,7 @@ def handle_task_exception(task: asyncio.Task) -> None: Arguments: Task (asyncio.Task): The task to be """ + try: task.result() except asyncio.CancelledError: @@ -48,10 +49,11 @@ def create_background_task(coroutine: Coroutine) -> asyncio.Task: return task -async def calendar_worker(): +async def calendar_worker() -> None: """ Loop to force rebuild the calendar every 20 minutes to check for event updates """ + while True: await asyncio.sleep(TWENTY_MINUTES) await cshcalendar.rebuild_calendar() diff --git a/tests/src/core/test_slack.py b/tests/src/core/test_slack.py index 56572bf..120b988 100644 --- a/tests/src/core/test_slack.py +++ b/tests/src/core/test_slack.py @@ -153,8 +153,6 @@ async def chat_postMessage(self, *, channel, text, blocks): recorded["text"] = text recorded["blocks"] = blocks - asyncio.run(slack.request_upload_via_dm("U123", "Announcement!")) - monkeypatch.setattr(slack, "client", FakeClient()) asyncio.run(slack.request_upload_via_dm("U123", "Announcement!")) From 01b573ea75f8d09c627c7d26574ec6fb6eb203c5 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Wed, 23 Sep 2026 16:50:58 -0400 Subject: [PATCH 6/6] reduce dependency keeping and remove package manager at runtime (134mb -> 86mb) --- Dockerfile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0a3dab6..6f75e3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,11 +5,13 @@ ENV UV_NO_CACHE=1 WORKDIR /app COPY pyproject.toml uv.lock ./ -RUN uv sync --group docs --frozen +RUN uv sync --frozen FROM base AS docbuilder ENV UV_NO_CACHE=1 +RUN uv sync --group docs --frozen + WORKDIR /appdocs COPY mkdocs.yml . @@ -20,8 +22,8 @@ WORKDIR /app RUN uv run zensical build --config-file /appdocs/mkdocs.yml -FROM ghcr.io/astral-sh/uv:python3.14-alpine -ENV UV_NO_CACHE=1 +FROM python:3.14-alpine +ENV PATH="/app/.venv/bin:$PATH" COPY --from=base /app/.venv /app/.venv COPY src /app @@ -34,4 +36,4 @@ RUN addgroup -g 2000 jumpgroup && \ USER jumpstart -CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-config", "/app/logging_config.yaml", "--proxy-headers", "--forwarded-allow-ips", "*"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-config", "/app/logging_config.yaml", "--proxy-headers", "--forwarded-allow-ips", "*"]