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 b5985a6..2b6d037 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,10 +27,14 @@ async def get_calendar() -> JSONResponse: events: list[dict[str, str]] = [] try: - get_future_events_ical: list[ - cshcalendar.CalendarInfo - ] = await cshcalendar.get_future_events() - events = cshcalendar.format_events(get_future_events_ical) + 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.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) @@ -118,13 +121,15 @@ 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!" ) - 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") @@ -133,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/config.py b/src/config.py index 3e3eb81..a00a714 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( @@ -37,12 +47,30 @@ 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 + 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?" -WATCHED_CHANNELS: tuple[str] = tuple( - _get_env_variable("WATCHED_CHANNELS", "").split(",") +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: dict | None = None +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,9 +79,10 @@ 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: - 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 01b7c34..bb5ec47 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, @@ -14,8 +15,8 @@ CALENDAR_OUTLOOK_DAYS, CALENDAR_TIMEZONE, CALENDAR_URL, + LOGGING_LEVEL, ) -import asyncio calendar_cache: list[CalendarInfo] = [] # The current cache of the calendar cal_last_update: date | None = ( @@ -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() @@ -45,7 +48,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 +70,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 +96,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 +121,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 +139,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,25 +154,21 @@ 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]] = [] for event in events: - content_dict: dict[str, str] = {} + content_dict: dict[str, str] = {"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 @@ -181,6 +181,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,7 +191,7 @@ 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) @@ -199,10 +202,8 @@ async def rebuild_calendar() -> None: 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)) @@ -214,11 +215,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 @@ -228,7 +228,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 +244,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 +264,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..f75f931 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -1,24 +1,24 @@ -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, + LOGGING_LEVEL, 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__) - +logger.setLevel(LOGGING_LEVEL) client: AsyncWebClient | None = None @@ -32,11 +32,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 +66,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 +87,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 +117,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( @@ -169,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 1a3e715..4bfe234 100644 --- a/src/core/wikithoughts.py +++ b/src/core/wikithoughts.py @@ -1,15 +1,20 @@ -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 ( + 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 @@ -17,15 +22,16 @@ 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__) +logger.setLevel(LOGGING_LEVEL) 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 +74,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, @@ -133,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"}, @@ -154,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: @@ -218,12 +224,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 @@ -238,11 +244,10 @@ def process_category_page(r_json: dict[str, str]) -> 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]: @@ -256,7 +261,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 +286,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", @@ -296,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 @@ -325,13 +342,19 @@ 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): return page_title_cache - titles: list[str] = [] params: dict[str, str] = { "action": "query", "list": "categorymembers", @@ -361,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 @@ -378,6 +401,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: @@ -386,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", @@ -435,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 0fd0da7..a74cc65 100644 --- a/src/main.py +++ b/src/main.py @@ -5,23 +5,22 @@ 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, LOGGING_LEVEL +from core import cshcalendar, wikithoughts logger: Logger = getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) @asynccontextmanager 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"]