MIL-001 Baseline and project setup #23

Merged
Tirsvad merged 3 commits from mil-001-baseline-and-setup into main 2026-10-24 17:42:55 +02:00
24 changed files with 1804 additions and 0 deletions
Showing only changes of commit 2f3bdd41e2 - Show all commits
+17
View File
@@ -0,0 +1,17 @@
# Copy to .env and fill in. .env is ignored by git; never commit it.
# Sheety: the endpoint of the "prices" sheet and the Bearer token of the project
SHEETY_ENDPOINT=https://api.sheety.co/<your-id>/<your-project>/prices
SHEETY_TOKEN=
# Flight provider: "serpapi" or "mock" (the course's mock flight service)
FLIGHT_PROVIDER=mock
SERPAPI_API_KEY=
MOCK_FLIGHTS_APP_ID=
MOCK_FLIGHTS_APP_KEY=
# Optional: override the default URL of the mock flight service
# MOCK_FLIGHTS_URL=
# WhatsApp: your own number with country code, for example +441234567890
# Needs the optional extra: python -m pip install -e ".[whatsapp]"
WHATSAPP_NUMBER=
+9
View File
@@ -0,0 +1,9 @@
# Fails a pull request that adds comments or docstrings which are not English.
name: English comments
on:
pull_request:
jobs:
english-comments:
uses: TirSystem/github-action/.gitea/workflows/english-comments.yml@v2
+38
View File
@@ -0,0 +1,38 @@
# Publish the project's wiki pages to the git host's wiki.
#
# On a push to main that changes docs/wiki/, this workflow calls the reusable
# workflow of TirSystem/github-action, which copies the Markdown pages of
# docs/wiki/ to the wiki of this repository (framework/process/wiki.md says
# what the folder holds: English and the PO language as a minimum).
#
# INSTALLING THIS FILE IS THE OPT-IN. There is no marker file. A project that
# does not copy it publishes nothing.
#
# Copy it to .gitea/workflows/publish-wiki.yml. Then:
# 1. Create the secret TOKEN_FOR_GITEA_WIKI in the organization of the
# repository: a Gitea token with scope write:repository for a user that
# can write this repository (see guides/credentials.md of github-action).
# 2. Enable the wiki in the repository settings.
# 3. Keep @v1, or pin the exact release tag together with tools-ref.
# A runner must be registered for the repository (or its organization).
#
# prune: "true" deletes wiki pages that are not in docs/wiki/, so the wiki
# always equals the folder. Leave it "false" to keep hand-written pages.
name: Publish wiki
on:
push:
branches: [ main ]
paths:
- "docs/wiki/**"
workflow_dispatch:
jobs:
wiki:
uses: TirSystem/github-action/.gitea/workflows/publish-wiki-pages.yml@v1
with:
wiki_dir: docs/wiki
prune: "true"
secrets:
TOKEN_FOR_GITEA_WIKI: ${{ secrets.TOKEN_FOR_GITEA_WIKI }}
+15
View File
@@ -0,0 +1,15 @@
# Lint, format check and type check (ruff, mypy) through the shared TirSystem workflow.
name: Quality
on:
pull_request:
push:
branches: [ main ]
jobs:
quality:
permissions:
contents: read
uses: TirSystem/github-action/.gitea/workflows/quality-checks.yml@v1
with:
languages: python,shell
+19
View File
@@ -0,0 +1,19 @@
# Run the unit tests. The tests use no network and no .env file.
name: Tests
on:
pull_request:
push:
branches: [ main ]
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: python -m pip install --upgrade pip
- run: python -m pip install -e ".[dev]"
- run: python -m pytest
+374
View File
@@ -0,0 +1,374 @@
# Doxyfile 1.15.0
#---------------------------------------------------------------------------
# Project related configuration options
#---------------------------------------------------------------------------
DOXYFILE_ENCODING = UTF-8
PROJECT_NAME = "Personal Flight Club"
PROJECT_NUMBER =
PROJECT_BRIEF =
PROJECT_LOGO =
PROJECT_ICON =
OUTPUT_DIRECTORY = docs/doxygen
CREATE_SUBDIRS = NO
CREATE_SUBDIRS_LEVEL = 8
ALLOW_UNICODE_NAMES = NO
OUTPUT_LANGUAGE = English
BRIEF_MEMBER_DESC = YES
REPEAT_BRIEF = YES
ABBREVIATE_BRIEF = "The $name class" \
"The $name widget" \
"The $name file" \
is \
provides \
specifies \
contains \
represents \
a \
an \
the
ALWAYS_DETAILED_SEC = NO
INLINE_INHERITED_MEMB = NO
FULL_PATH_NAMES = YES
STRIP_FROM_PATH =
STRIP_FROM_INC_PATH =
SHORT_NAMES = NO
JAVADOC_AUTOBRIEF = NO
JAVADOC_BANNER = NO
QT_AUTOBRIEF = NO
MULTILINE_CPP_IS_BRIEF = NO
PYTHON_DOCSTRING = YES
INHERIT_DOCS = YES
SEPARATE_MEMBER_PAGES = NO
TAB_SIZE = 4
ALIASES =
OPTIMIZE_OUTPUT_FOR_C = NO
OPTIMIZE_OUTPUT_JAVA = YES
OPTIMIZE_FOR_FORTRAN = NO
OPTIMIZE_OUTPUT_VHDL = NO
OPTIMIZE_OUTPUT_SLICE = NO
EXTENSION_MAPPING =
MARKDOWN_SUPPORT = YES
MARKDOWN_STRICT = YES
TOC_INCLUDE_HEADINGS = 6
MARKDOWN_ID_STYLE = DOXYGEN
AUTOLINK_SUPPORT = YES
AUTOLINK_IGNORE_WORDS =
BUILTIN_STL_SUPPORT = NO
CPP_CLI_SUPPORT = NO
SIP_SUPPORT = NO
IDL_PROPERTY_SUPPORT = YES
DISTRIBUTE_GROUP_DOC = NO
GROUP_NESTED_COMPOUNDS = NO
SUBGROUPING = YES
INLINE_GROUPED_CLASSES = NO
INLINE_SIMPLE_STRUCTS = NO
TYPEDEF_HIDES_STRUCT = NO
LOOKUP_CACHE_SIZE = 0
NUM_PROC_THREADS = 1
TIMESTAMP = NO
#---------------------------------------------------------------------------
# Build related configuration options
#---------------------------------------------------------------------------
EXTRACT_ALL = YES
EXTRACT_PRIVATE = NO
EXTRACT_PRIV_VIRTUAL = NO
EXTRACT_PACKAGE = NO
EXTRACT_STATIC = NO
EXTRACT_LOCAL_CLASSES = YES
EXTRACT_LOCAL_METHODS = NO
EXTRACT_ANON_NSPACES = NO
RESOLVE_UNNAMED_PARAMS = YES
HIDE_UNDOC_MEMBERS = NO
HIDE_UNDOC_CLASSES = NO
HIDE_UNDOC_NAMESPACES = YES
HIDE_FRIEND_COMPOUNDS = NO
HIDE_IN_BODY_DOCS = NO
INTERNAL_DOCS = NO
CASE_SENSE_NAMES = SYSTEM
HIDE_SCOPE_NAMES = NO
HIDE_COMPOUND_REFERENCE= NO
SHOW_HEADERFILE = YES
SHOW_INCLUDE_FILES = YES
SHOW_GROUPED_MEMB_INC = NO
FORCE_LOCAL_INCLUDES = NO
INLINE_INFO = YES
SORT_MEMBER_DOCS = YES
SORT_BRIEF_DOCS = NO
SORT_MEMBERS_CTORS_1ST = NO
SORT_GROUP_NAMES = NO
SORT_BY_SCOPE_NAME = NO
STRICT_PROTO_MATCHING = NO
GENERATE_TODOLIST = YES
GENERATE_TESTLIST = YES
GENERATE_BUGLIST = YES
GENERATE_DEPRECATEDLIST= YES
ENABLED_SECTIONS =
MAX_INITIALIZER_LINES = 30
SHOW_USED_FILES = YES
SHOW_FILES = YES
SHOW_NAMESPACES = YES
FILE_VERSION_FILTER =
LAYOUT_FILE =
CITE_BIB_FILES =
EXTERNAL_TOOL_PATH =
#---------------------------------------------------------------------------
# Configuration options related to warning and progress messages
#---------------------------------------------------------------------------
QUIET = YES
WARNINGS = YES
WARN_IF_UNDOCUMENTED = YES
WARN_IF_DOC_ERROR = YES
WARN_IF_INCOMPLETE_DOC = YES
WARN_NO_PARAMDOC = NO
WARN_IF_UNDOC_ENUM_VAL = NO
WARN_LAYOUT_FILE = YES
WARN_AS_ERROR = NO
WARN_FORMAT = "$file:$line: $text"
WARN_LINE_FORMAT = "at line $line of file $file"
WARN_LOGFILE =
#---------------------------------------------------------------------------
# Configuration options related to the input files
#---------------------------------------------------------------------------
INPUT = src
INPUT_ENCODING = UTF-8
INPUT_FILE_ENCODING =
FILE_PATTERNS = *.py
RECURSIVE = YES
EXCLUDE =
EXCLUDE_SYMLINKS = NO
EXCLUDE_PATTERNS =
EXCLUDE_SYMBOLS =
EXAMPLE_PATH =
EXAMPLE_PATTERNS = *
EXAMPLE_RECURSIVE = NO
IMAGE_PATH =
INPUT_FILTER =
FILTER_PATTERNS =
FILTER_SOURCE_FILES = NO
FILTER_SOURCE_PATTERNS =
USE_MDFILE_AS_MAINPAGE =
IMPLICIT_DIR_DOCS = YES
FORTRAN_COMMENT_AFTER = 72
#---------------------------------------------------------------------------
# Configuration options related to source browsing
#---------------------------------------------------------------------------
SOURCE_BROWSER = NO
INLINE_SOURCES = NO
STRIP_CODE_COMMENTS = YES
REFERENCED_BY_RELATION = NO
REFERENCES_RELATION = NO
REFERENCES_LINK_SOURCE = YES
SOURCE_TOOLTIPS = YES
USE_HTAGS = NO
VERBATIM_HEADERS = YES
CLANG_ASSISTED_PARSING = NO
CLANG_ADD_INC_PATHS = YES
CLANG_OPTIONS =
CLANG_DATABASE_PATH =
#---------------------------------------------------------------------------
# Configuration options related to the alphabetical class index
#---------------------------------------------------------------------------
ALPHABETICAL_INDEX = YES
IGNORE_PREFIX =
#---------------------------------------------------------------------------
# Configuration options related to the HTML output
#---------------------------------------------------------------------------
GENERATE_HTML = YES
HTML_OUTPUT = html
HTML_FILE_EXTENSION = .html
HTML_HEADER =
HTML_FOOTER =
HTML_STYLESHEET =
HTML_EXTRA_STYLESHEET =
HTML_EXTRA_FILES =
HTML_COLORSTYLE = AUTO_LIGHT
HTML_COLORSTYLE_HUE = 220
HTML_COLORSTYLE_SAT = 100
HTML_COLORSTYLE_GAMMA = 80
HTML_DYNAMIC_MENUS = YES
HTML_DYNAMIC_SECTIONS = NO
HTML_CODE_FOLDING = YES
HTML_COPY_CLIPBOARD = YES
HTML_PROJECT_COOKIE =
HTML_INDEX_NUM_ENTRIES = 100
GENERATE_DOCSET = NO
DOCSET_FEEDNAME = "Doxygen generated docs"
DOCSET_FEEDURL =
DOCSET_BUNDLE_ID = org.doxygen.Project
DOCSET_PUBLISHER_ID = org.doxygen.Publisher
DOCSET_PUBLISHER_NAME = Publisher
GENERATE_HTMLHELP = NO
CHM_FILE =
HHC_LOCATION =
GENERATE_CHI = NO
CHM_INDEX_ENCODING =
BINARY_TOC = NO
TOC_EXPAND = NO
SITEMAP_URL =
GENERATE_QHP = NO
QCH_FILE =
QHP_NAMESPACE = org.doxygen.Project
QHP_VIRTUAL_FOLDER = doc
QHP_CUST_FILTER_NAME =
QHP_CUST_FILTER_ATTRS =
QHP_SECT_FILTER_ATTRS =
QHG_LOCATION =
GENERATE_ECLIPSEHELP = NO
ECLIPSE_DOC_ID = org.doxygen.Project
DISABLE_INDEX = NO
GENERATE_TREEVIEW = YES
PAGE_OUTLINE_PANEL = YES
FULL_SIDEBAR = NO
ENUM_VALUES_PER_LINE = 4
SHOW_ENUM_VALUES = NO
TREEVIEW_WIDTH = 250
EXT_LINKS_IN_WINDOW = NO
OBFUSCATE_EMAILS = YES
HTML_FORMULA_FORMAT = png
FORMULA_FONTSIZE = 10
FORMULA_MACROFILE =
USE_MATHJAX = NO
MATHJAX_VERSION = MathJax_2
MATHJAX_FORMAT = HTML-CSS
MATHJAX_RELPATH =
MATHJAX_EXTENSIONS =
MATHJAX_CODEFILE =
SEARCHENGINE = YES
SERVER_BASED_SEARCH = NO
EXTERNAL_SEARCH = NO
SEARCHENGINE_URL =
SEARCHDATA_FILE = searchdata.xml
EXTERNAL_SEARCH_ID =
EXTRA_SEARCH_MAPPINGS =
#---------------------------------------------------------------------------
# Configuration options related to the LaTeX output
#---------------------------------------------------------------------------
GENERATE_LATEX = NO
LATEX_OUTPUT = latex
LATEX_CMD_NAME =
MAKEINDEX_CMD_NAME = makeindex
LATEX_MAKEINDEX_CMD = makeindex
COMPACT_LATEX = NO
PAPER_TYPE = a4
EXTRA_PACKAGES =
LATEX_HEADER =
LATEX_FOOTER =
LATEX_EXTRA_STYLESHEET =
LATEX_EXTRA_FILES =
PDF_HYPERLINKS = YES
USE_PDFLATEX = YES
LATEX_BATCHMODE = NO
LATEX_HIDE_INDICES = NO
LATEX_BIB_STYLE = plainnat
LATEX_EMOJI_DIRECTORY =
#---------------------------------------------------------------------------
# Configuration options related to the RTF output
#---------------------------------------------------------------------------
GENERATE_RTF = NO
RTF_OUTPUT = rtf
COMPACT_RTF = NO
RTF_HYPERLINKS = NO
RTF_STYLESHEET_FILE =
RTF_EXTENSIONS_FILE =
RTF_EXTRA_FILES =
#---------------------------------------------------------------------------
# Configuration options related to the man page output
#---------------------------------------------------------------------------
GENERATE_MAN = NO
MAN_OUTPUT = man
MAN_EXTENSION = .3
MAN_SUBDIR =
MAN_LINKS = NO
#---------------------------------------------------------------------------
# Configuration options related to the XML output
#---------------------------------------------------------------------------
GENERATE_XML = NO
XML_OUTPUT = xml
XML_PROGRAMLISTING = YES
XML_NS_MEMB_FILE_SCOPE = NO
#---------------------------------------------------------------------------
# Configuration options related to the DOCBOOK output
#---------------------------------------------------------------------------
GENERATE_DOCBOOK = NO
DOCBOOK_OUTPUT = docbook
#---------------------------------------------------------------------------
# Configuration options for the AutoGen Definitions output
#---------------------------------------------------------------------------
GENERATE_AUTOGEN_DEF = NO
#---------------------------------------------------------------------------
# Configuration options related to Sqlite3 output
#---------------------------------------------------------------------------
GENERATE_SQLITE3 = NO
SQLITE3_OUTPUT = sqlite3
SQLITE3_RECREATE_DB = YES
#---------------------------------------------------------------------------
# Configuration options related to the Perl module output
#---------------------------------------------------------------------------
GENERATE_PERLMOD = NO
PERLMOD_LATEX = NO
PERLMOD_PRETTY = YES
PERLMOD_MAKEVAR_PREFIX =
#---------------------------------------------------------------------------
# Configuration options related to the preprocessor
#---------------------------------------------------------------------------
ENABLE_PREPROCESSING = YES
MACRO_EXPANSION = NO
EXPAND_ONLY_PREDEF = NO
SEARCH_INCLUDES = YES
INCLUDE_PATH =
INCLUDE_FILE_PATTERNS =
PREDEFINED =
EXPAND_AS_DEFINED =
SKIP_FUNCTION_MACROS = YES
#---------------------------------------------------------------------------
# Configuration options related to external references
#---------------------------------------------------------------------------
TAGFILES =
GENERATE_TAGFILE =
ALLEXTERNALS = NO
EXTERNAL_GROUPS = YES
EXTERNAL_PAGES = YES
#---------------------------------------------------------------------------
# Configuration options related to diagram generator tools
#---------------------------------------------------------------------------
HIDE_UNDOC_RELATIONS = YES
HAVE_DOT = NO
DOT_NUM_THREADS = 0
DOT_COMMON_ATTR = "fontname=Helvetica,fontsize=10"
DOT_EDGE_ATTR = "labelfontname=Helvetica,labelfontsize=10"
DOT_NODE_ATTR = "shape=box,height=0.2,width=0.4"
DOT_FONTPATH =
CLASS_GRAPH = YES
COLLABORATION_GRAPH = YES
GROUP_GRAPHS = YES
UML_LOOK = NO
UML_LIMIT_NUM_FIELDS = 10
UML_MAX_EDGE_LABELS = 10
DOT_UML_DETAILS = NO
DOT_WRAP_THRESHOLD = 17
TEMPLATE_RELATIONS = NO
INCLUDE_GRAPH = YES
INCLUDED_BY_GRAPH = YES
CALL_GRAPH = NO
CALLER_GRAPH = NO
GRAPHICAL_HIERARCHY = YES
DIRECTORY_GRAPH = YES
DIR_GRAPH_MAX_DEPTH = 1
DOT_IMAGE_FORMAT = png
INTERACTIVE_SVG = NO
DOT_PATH =
DOTFILE_DIRS =
DIA_PATH =
DIAFILE_DIRS =
PLANTUML_JAR_PATH =
PLANTUML_CFG_FILE =
PLANTUML_INCLUDE_PATH =
PLANTUMLFILE_DIRS =
DOT_GRAPH_MAX_NODES = 50
MAX_DOT_GRAPH_DEPTH = 0
DOT_MULTI_TARGETS = NO
GENERATE_LEGEND = YES
DOT_CLEANUP = YES
MSCGEN_TOOL =
MSCFILE_DIRS =
+47
View File
@@ -0,0 +1,47 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "flight-club"
version = "0.1.0"
description = "Day 40 flight club: find cheap direct and indirect flights from a Google Sheet and notify the club by email and WhatsApp."
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = [
"requests",
"requests-cache",
"python-dotenv",
]
[project.optional-dependencies]
whatsapp = ["pywhatkit"]
dev = ["pytest", "pytest-cov", "ruff", "mypy", "types-requests"]
[project.scripts]
flight-club = "flight_club.main:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.ruff]
line-length = 100
target-version = "py313"
[tool.mypy]
strict = true
python_version = "3.13"
# The shared CI quality job runs mypy without the project dependencies installed.
[[tool.mypy.overrides]]
module = ["requests", "requests_cache", "dotenv", "pywhatkit", "pytest"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_decorators = false
+6
View File
@@ -0,0 +1,6 @@
"""
@file
@brief Flight Club: finds cheap flights for destinations listed in a Google Sheet.
"""
__version__ = "0.1.0"
+8
View File
@@ -0,0 +1,8 @@
"""
@file
@brief Lets `python -m flight_club` start the program.
"""
from flight_club.main import main
main()
+18
View File
@@ -0,0 +1,18 @@
"""
@file
@brief Local HTTP cache for flight searches.
"""
import requests_cache
from flight_club.constants import CACHE_EXPIRE_SECONDS, CACHE_NAME, SHEETY_HOST
## @brief Installs the HTTP cache; Sheety traffic is never cached.
# @return None
def install_flight_cache() -> None:
requests_cache.install_cache(
CACHE_NAME,
expire_after=CACHE_EXPIRE_SECONDS,
urls_expire_after={f"{SHEETY_HOST}*": requests_cache.DO_NOT_CACHE},
)
+79
View File
@@ -0,0 +1,79 @@
"""
@file
@brief Constants shared across Flight Club. Holds no logic.
"""
## @brief IATA code of the airport every search departs from.
ORIGIN_AIRPORT = "LHR"
## @brief Currency of every price requested and shown.
CURRENCY = "GBP"
## @brief SerpApi search endpoint.
SERPAPI_URL = "https://serpapi.com/search"
## @brief SerpApi engine used for flight searches.
SERPAPI_ENGINE = "google_flights"
## @brief Default URL of the course's mock flight service (overridable via the MOCK_FLIGHTS_URL environment variable).
DEFAULT_MOCK_FLIGHTS_URL = "https://app.100daysofpython.dev/v1/flights/search"
## @brief Host of the Sheety API.
SHEETY_HOST = "api.sheety.co"
## @brief Name of the local HTTP cache for flight searches.
CACHE_NAME = "flight_cache"
## @brief Seconds a cached flight search stays valid.
CACHE_EXPIRE_SECONDS = 3600
## @brief Length of the search window in days (about six months).
SEARCH_WINDOW_DAYS = 182
## @brief Text shown when no price is available.
NOT_AVAILABLE = "N/A"
## @brief strftime format of dates sent to and read from providers.
DATE_FORMAT = "%Y-%m-%d"
## @brief Name of the SerpApi flight provider.
PROVIDER_SERPAPI = "serpapi"
## @brief Name of the mock flight provider.
PROVIDER_MOCK = "mock"
## @brief Environment variable: Sheety sheet endpoint.
ENV_SHEETY_ENDPOINT = "SHEETY_ENDPOINT"
## @brief Environment variable: Sheety Bearer token.
ENV_SHEETY_TOKEN = "SHEETY_TOKEN"
## @brief Environment variable: selected flight provider.
ENV_FLIGHT_PROVIDER = "FLIGHT_PROVIDER"
## @brief Environment variable: SerpApi API key.
ENV_SERPAPI_API_KEY = "SERPAPI_API_KEY"
## @brief Environment variable: mock flight service app id.
ENV_MOCK_APP_ID = "MOCK_FLIGHTS_APP_ID"
## @brief Environment variable: mock flight service app key.
ENV_MOCK_APP_KEY = "MOCK_FLIGHTS_APP_KEY"
## @brief Environment variable: optional URL of the mock flight service.
ENV_MOCK_FLIGHTS_URL = "MOCK_FLIGHTS_URL"
## @brief Environment variable: WhatsApp recipient number with country code.
ENV_WHATSAPP_NUMBER = "WHATSAPP_NUMBER"
## @brief Seconds pywhatkit waits for WhatsApp Web to load before it sends the message.
WHATSAPP_WAIT_SECONDS = 15
## @brief Seconds pywhatkit waits after sending before it closes the browser tab.
WHATSAPP_CLOSE_SECONDS = 3
## @brief Seconds to wait for an HTTP response before giving up.
REQUEST_TIMEOUT_SECONDS = 30
## @brief The longest piece of the flight service's error text that is printed.
ERROR_TEXT_MAX_LENGTH = 200
+72
View File
@@ -0,0 +1,72 @@
"""
@file
@brief Reads and updates the destination sheet through the Sheety API.
"""
import os
from typing import Any
import requests
from flight_club.constants import (
ENV_SHEETY_ENDPOINT,
ENV_SHEETY_TOKEN,
REQUEST_TIMEOUT_SECONDS,
)
## @brief Raised when the sheet cannot be configured, read or updated.
class DataManagerError(RuntimeError):
pass
## @brief Talks to the Sheety sheet that holds the destinations.
class DataManager:
## @brief Reads the endpoint and token from the environment.
# @param session Optional HTTP session; a new one is made per call if omitted.
def __init__(self, session: requests.Session | None = None) -> None:
self._endpoint = self._require_env(ENV_SHEETY_ENDPOINT)
self._headers = {"Authorization": f"Bearer {self._require_env(ENV_SHEETY_TOKEN)}"}
self._session = session
@staticmethod
def _require_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise DataManagerError(f"Environment variable {name} is not set.")
return value
def _get_session(self) -> requests.Session:
# Created at call time so requests_cache.install_cache patching applies.
return self._session if self._session is not None else requests.Session()
@staticmethod
def _check(response: requests.Response, action: str) -> None:
if not 200 <= response.status_code < 300:
raise DataManagerError(f"Sheety {action} failed with HTTP {response.status_code}.")
## @brief Fetch every destination row from the Google Sheet through the Sheety API.
# @return Rows such as {'city': 'Paris', 'iataCode': 'CDG', 'lowestPrice': 54, 'id': 2}.
def get_destination_data(self) -> list[dict[str, Any]]:
response = self._get_session().get(
self._endpoint, headers=self._headers, timeout=REQUEST_TIMEOUT_SECONDS
)
self._check(response, "read")
body = response.json()
prices = body.get("prices") if isinstance(body, dict) else None
if not isinstance(prices, list):
raise DataManagerError("Sheety response has no 'prices' list.")
return prices
## @brief Stores a new lowest price for one row.
# @param row_id Sheety row id.
# @param new_price The new lowest price.
# @return None
def update_lowest_price(self, row_id: int, new_price: float) -> None:
response = self._get_session().put(
f"{self._endpoint}/{row_id}",
json={"price": {"lowestPrice": new_price}},
headers=self._headers,
timeout=REQUEST_TIMEOUT_SECONDS,
)
self._check(response, "update")
+82
View File
@@ -0,0 +1,82 @@
"""
@file
@brief Picks the cheapest flight out of a SerpApi Google Flights response.
"""
from dataclasses import dataclass
from typing import Any
from flight_club.constants import NOT_AVAILABLE
## @brief The cheapest flight found for one destination.
@dataclass
class FlightData:
price: float | int | str
origin_airport: str
destination_airport: str
out_date: str
return_date: str
def _unavailable() -> FlightData:
return FlightData(
price=NOT_AVAILABLE,
origin_airport=NOT_AVAILABLE,
destination_airport=NOT_AVAILABLE,
out_date=NOT_AVAILABLE,
return_date=NOT_AVAILABLE,
)
def _parse_price(entry: dict[str, Any]) -> float | None:
price = entry.get("price")
if isinstance(price, bool):
return None
try:
return float(price) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _build_flight(entry: dict[str, Any], price: float, return_date: str) -> FlightData | None:
try:
legs = entry["flights"]
return FlightData(
price=price,
origin_airport=legs[0]["departure_airport"]["id"],
destination_airport=legs[-1]["arrival_airport"]["id"],
out_date=legs[0]["departure_airport"]["time"].split(" ")[0],
return_date=return_date,
)
except (KeyError, IndexError, TypeError, AttributeError):
return None
## @brief Finds the lowest priced flight across best and other flights.
# @param data SerpApi response, or None when the search failed.
# @param return_date Return date to store on the result.
# @return The cheapest flight, or a FlightData filled with "N/A" when none is usable.
def find_cheapest_flight(data: dict[str, Any] | None, return_date: str) -> FlightData:
if not data:
print("No flight data available.")
return _unavailable()
entries = [*data.get("best_flights", []), *data.get("other_flights", [])]
cheapest: FlightData | None = None
for entry in entries:
if not isinstance(entry, dict):
continue
price = _parse_price(entry)
if price is None:
continue
if cheapest is not None and price >= float(cheapest.price):
continue
flight = _build_flight(entry, price, return_date)
if flight is not None:
cheapest = flight
if cheapest is None:
print("No valid flight found in the response.")
return _unavailable()
return cheapest
+152
View File
@@ -0,0 +1,152 @@
"""
@file
@brief Searches flight prices through SerpApi or the course's mock flight service.
"""
import os
from datetime import datetime
from typing import Any
import requests
from flight_club.constants import (
CURRENCY,
DATE_FORMAT,
DEFAULT_MOCK_FLIGHTS_URL,
ENV_FLIGHT_PROVIDER,
ENV_MOCK_APP_ID,
ENV_MOCK_APP_KEY,
ENV_MOCK_FLIGHTS_URL,
ENV_SERPAPI_API_KEY,
ERROR_TEXT_MAX_LENGTH,
PROVIDER_MOCK,
PROVIDER_SERPAPI,
REQUEST_TIMEOUT_SECONDS,
SERPAPI_ENGINE,
SERPAPI_URL,
)
## @brief Raised when the flight provider is configured wrongly.
class FlightSearchError(RuntimeError):
pass
## @brief Searches round trip flights with the provider chosen by FLIGHT_PROVIDER.
#
# The mock provider returns JSON shaped like a SerpApi Google Flights response, with
# the keys best_flights and other_flights, so both providers feed the same parser.
# The mock provider is called with the x-app-id and x-app-key headers and also with
# the app key as the api_key query parameter, which the service requires.
class FlightSearch:
## @brief Reads the provider and its credentials from the environment.
# @param session Optional HTTP session; a new one is made per call if omitted.
def __init__(self, session: requests.Session | None = None) -> None:
self._session = session
self._provider = os.environ.get(ENV_FLIGHT_PROVIDER) or PROVIDER_SERPAPI
self._api_key: str | None = None
self._url = SERPAPI_URL
self._headers: dict[str, str] = {}
if self._provider == PROVIDER_SERPAPI:
self._api_key = self._require_env(ENV_SERPAPI_API_KEY)
elif self._provider == PROVIDER_MOCK:
app_key = self._require_env(ENV_MOCK_APP_KEY)
self._headers = {
"x-app-id": self._require_env(ENV_MOCK_APP_ID),
"x-app-key": app_key,
}
# The real mock service also demands the app key as the api_key query parameter.
self._api_key = app_key
self._url = os.environ.get(ENV_MOCK_FLIGHTS_URL) or DEFAULT_MOCK_FLIGHTS_URL
else:
raise FlightSearchError(
f"Unknown {ENV_FLIGHT_PROVIDER} '{self._provider}'; "
f"use '{PROVIDER_SERPAPI}' or '{PROVIDER_MOCK}'."
)
@staticmethod
def _require_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise FlightSearchError(f"Environment variable {name} is not set.")
return value
## @brief Reads the service's own error text from a failed response.
# @return The "error" string of a JSON body cut to the limit, or None if there is none.
@staticmethod
def _service_error_text(response: requests.Response) -> str | None:
try:
body = response.json()
except ValueError:
return None
if isinstance(body, dict) and isinstance(body.get("error"), str):
return str(body["error"])[:ERROR_TEXT_MAX_LENGTH]
return None
def _get_session(self) -> requests.Session:
# Created at call time so requests_cache.install_cache patching applies.
return self._session if self._session is not None else requests.Session()
def _build_params(
self,
origin_city_code: str,
destination_city_code: str,
from_time: datetime,
to_time: datetime,
) -> dict[str, str]:
params = {
"engine": SERPAPI_ENGINE,
"departure_id": origin_city_code,
"arrival_id": destination_city_code,
"outbound_date": from_time.strftime(DATE_FORMAT),
"return_date": to_time.strftime(DATE_FORMAT),
"type": "1",
"adults": "1",
"currency": CURRENCY,
}
if self._api_key is not None:
params["api_key"] = self._api_key
return params
## @brief Searches the cheapest round trips between two airports.
# @param origin_city_code IATA code to depart from.
# @param destination_city_code IATA code to arrive at.
# @param from_time Outbound date.
# @param to_time Return date.
# @return The decoded response, or None after printing why the search failed.
def check_flights(
self,
origin_city_code: str,
destination_city_code: str,
from_time: datetime,
to_time: datetime,
) -> dict[str, Any] | None:
params = self._build_params(origin_city_code, destination_city_code, from_time, to_time)
try:
response = self._get_session().get(
self._url,
params=params,
headers=self._headers or None,
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as error:
# The exception type is printed, not its text, which may echo the URL and key.
print(f"Flight search request failed: {type(error).__name__}.")
return None
if not 200 <= response.status_code < 300:
detail = self._service_error_text(response)
suffix = f": {detail}" if detail else ""
print(f"Flight search failed with HTTP {response.status_code}{suffix}.")
return None
try:
body = response.json()
except ValueError:
print("Flight search returned invalid JSON.")
return None
if not isinstance(body, dict):
print("Flight search returned an unexpected response shape.")
return None
if "error" in body:
print(f"Flight search returned an error: {body['error']}")
return None
return body
+84
View File
@@ -0,0 +1,84 @@
"""
@file
@brief Entry point: checks every destination in the sheet and alerts on lower prices.
Run it from the project root with `python -m flight_club` or the `flight-club` command,
after filling the variables described in the README into a local `.env` file.
"""
import sys
from datetime import datetime, timedelta
from dotenv import load_dotenv
from flight_club.cache import install_flight_cache
from flight_club.constants import (
CURRENCY,
DATE_FORMAT,
NOT_AVAILABLE,
ORIGIN_AIRPORT,
SEARCH_WINDOW_DAYS,
)
from flight_club.data_manager import DataManager, DataManagerError
from flight_club.flight_data import FlightData, find_cheapest_flight
from flight_club.flight_search import FlightSearch, FlightSearchError
from flight_club.notification_manager import NotificationManager
def _is_checkable(row: dict[str, object]) -> bool:
price = row.get("lowestPrice")
return (
bool(row.get("iataCode")) and isinstance(price, int | float) and not isinstance(price, bool)
)
## @brief Checks every sheet row and reports each flight cheaper than the stored price.
# @param data_manager Source of the destination rows and target of price updates.
# @param flight_search Provider of flight offers.
# @param notification_manager Sender of the alerts.
# @param today Start of the run; the current time when omitted.
# @return The cheaper flights that were found.
def run(
data_manager: DataManager,
flight_search: FlightSearch,
notification_manager: NotificationManager,
today: datetime | None = None,
) -> list[FlightData]:
today = today if today is not None else datetime.now()
tomorrow = today + timedelta(days=1)
six_month_from_today = today + timedelta(days=SEARCH_WINDOW_DAYS)
return_date = six_month_from_today.strftime(DATE_FORMAT)
found: list[FlightData] = []
sheet_data = data_manager.get_destination_data()
for row in sheet_data:
if not _is_checkable(row):
print(f"Skipping row without an IATA code or a numeric lowest price: {row.get('city')}")
continue
flights = flight_search.check_flights(
ORIGIN_AIRPORT, row["iataCode"], tomorrow, six_month_from_today
)
cheapest = find_cheapest_flight(flights, return_date)
print(f"{row['city']}: {CURRENCY} {cheapest.price}")
if cheapest.price != NOT_AVAILABLE and cheapest.price < row["lowestPrice"]:
print(f"Lower price flight found to {row['city']}!")
data_manager.update_lowest_price(row["id"], cheapest.price) # type: ignore[arg-type]
notification_manager.send_whatsapp(notification_manager.build_message(cheapest))
found.append(cheapest)
return found
## @brief Wires the real collaborators and runs one check; exits with 1 on a setup error.
# @return None
def main() -> None:
load_dotenv()
install_flight_cache()
try:
run(DataManager(), FlightSearch(), NotificationManager())
except (DataManagerError, FlightSearchError) as error:
print(f"Error: {error}")
sys.exit(1)
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
"""
@file
@brief Builds low price alerts and sends them through WhatsApp.
"""
import os
from collections.abc import Callable
from flight_club.constants import (
CURRENCY,
ENV_WHATSAPP_NUMBER,
WHATSAPP_CLOSE_SECONDS,
WHATSAPP_WAIT_SECONDS,
)
from flight_club.flight_data import FlightData
_INSTALL_HINT = 'Install the optional extra with: pip install -e ".[whatsapp]"'
_NUMBER_HINT = f"Set {ENV_WHATSAPP_NUMBER} (with country code) to receive WhatsApp messages."
## @brief Formats flight alerts and delivers them to the owner's WhatsApp.
class NotificationManager:
## @brief Creates the manager with an optional replacement for pywhatkit.
# @param sender Callable taking (number, message); used instead of pywhatkit when given.
def __init__(self, sender: Callable[[str, str], None] | None = None) -> None:
self._sender = sender
## @brief Builds the alert text for one cheap flight.
# @param flight The cheapest flight found for a destination.
# @return A one line message with price, airports and both dates.
def build_message(self, flight: FlightData) -> str:
return (
f"Low price alert! Only {CURRENCY} {flight.price} to fly "
f"from {flight.origin_airport} to {flight.destination_airport}, "
f"from {flight.out_date} to {flight.return_date}."
)
## @brief Sends a message to the number in the environment; never raises.
# @param message The text to send.
# @return True when sent, False when it was only printed.
def send_whatsapp(self, message: str) -> bool:
number = os.environ.get(ENV_WHATSAPP_NUMBER)
if not number:
return self._print_instead(_NUMBER_HINT, message)
if self._sender is not None:
return self._send_with(self._sender, number, message)
try:
import pywhatkit # type: ignore[import-not-found,unused-ignore]
except ImportError:
return self._print_instead(_INSTALL_HINT, message)
return self._send_with(self._pywhatkit_sender(pywhatkit), number, message)
@staticmethod
def _pywhatkit_sender(module: object) -> Callable[[str, str], None]:
def send(number: str, message: str) -> None:
module.sendwhatmsg_instantly( # type: ignore[attr-defined]
number, message, WHATSAPP_WAIT_SECONDS, True, WHATSAPP_CLOSE_SECONDS
)
return send
@staticmethod
def _send_with(sender: Callable[[str, str], None], number: str, message: str) -> bool:
try:
sender(number, message)
except Exception as error: # noqa: BLE001
# Only the type is printed: the text of the error might echo the phone number.
print(f"WhatsApp message failed: {type(error).__name__}.")
print(message)
return False
return True
@staticmethod
def _print_instead(hint: str, message: str) -> bool:
print(hint)
print(message)
return False
View File
+16
View File
@@ -0,0 +1,16 @@
from unittest.mock import MagicMock, patch
import requests_cache
from flight_club.cache import install_flight_cache
from flight_club.constants import CACHE_EXPIRE_SECONDS, CACHE_NAME, SHEETY_HOST
def test_install_flight_cache_never_caches_sheety() -> None:
with patch("flight_club.cache.requests_cache.install_cache", new=MagicMock()) as install:
install_flight_cache()
install.assert_called_once_with(
CACHE_NAME,
expire_after=CACHE_EXPIRE_SECONDS,
urls_expire_after={f"{SHEETY_HOST}*": requests_cache.DO_NOT_CACHE},
)
+32
View File
@@ -0,0 +1,32 @@
from datetime import date
from flight_club import constants
def test_search_window_is_positive() -> None:
assert constants.SEARCH_WINDOW_DAYS > 0
def test_provider_names_are_distinct() -> None:
assert constants.PROVIDER_SERPAPI != constants.PROVIDER_MOCK
def test_origin_is_three_letter_code() -> None:
assert len(constants.ORIGIN_AIRPORT) == 3
assert constants.ORIGIN_AIRPORT.isalpha()
assert constants.ORIGIN_AIRPORT.isupper()
def test_urls_use_https() -> None:
assert constants.SERPAPI_URL.startswith("https://")
assert constants.DEFAULT_MOCK_FLIGHTS_URL.startswith("https://")
def test_date_format_round_trips() -> None:
day = date(2026, 1, 31)
assert day.strftime(constants.DATE_FORMAT) == "2026-01-31"
def test_env_variable_names_are_distinct() -> None:
names = [value for key, value in vars(constants).items() if key.startswith("ENV_")]
assert len(names) == len(set(names))
+84
View File
@@ -0,0 +1,84 @@
from typing import Any
from unittest.mock import MagicMock
import pytest
from flight_club.constants import REQUEST_TIMEOUT_SECONDS
from flight_club.data_manager import DataManager, DataManagerError
ENDPOINT = "https://api.sheety.co/abc/flights/prices"
TOKEN = "super-secret-token"
@pytest.fixture(autouse=True)
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SHEETY_ENDPOINT", ENDPOINT)
monkeypatch.setenv("SHEETY_TOKEN", TOKEN)
def _session(status: int = 200, body: Any = None) -> MagicMock:
response = MagicMock()
response.status_code = status
response.json.return_value = body
session = MagicMock()
session.get.return_value = response
session.put.return_value = response
return session
def test_get_destination_data_returns_prices() -> None:
rows = [{"city": "Paris", "iataCode": "CDG", "lowestPrice": 54, "id": 2}]
session = _session(body={"prices": rows})
assert DataManager(session).get_destination_data() == rows
session.get.assert_called_once_with(
ENDPOINT,
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
@pytest.mark.parametrize("body", [{}, {"prices": "x"}, [1]])
def test_get_destination_data_raises_without_prices_list(body: Any) -> None:
with pytest.raises(DataManagerError, match="prices"):
DataManager(_session(body=body)).get_destination_data()
@pytest.mark.parametrize("status", [500, 401])
def test_get_destination_data_raises_on_http_error(status: int) -> None:
with pytest.raises(DataManagerError, match=str(status)):
DataManager(_session(status=status)).get_destination_data()
def test_update_lowest_price_puts_payload() -> None:
session = _session()
DataManager(session).update_lowest_price(7, 49.5)
session.put.assert_called_once_with(
f"{ENDPOINT}/7",
json={"price": {"lowestPrice": 49.5}},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
@pytest.mark.parametrize("status", [500, 401])
def test_update_lowest_price_raises_on_http_error(status: int) -> None:
with pytest.raises(DataManagerError, match=str(status)):
DataManager(_session(status=status)).update_lowest_price(1, 10)
def test_errors_never_contain_token() -> None:
with pytest.raises(DataManagerError) as read_error:
DataManager(_session(status=401)).get_destination_data()
with pytest.raises(DataManagerError) as write_error:
DataManager(_session(status=500)).update_lowest_price(1, 10)
assert TOKEN not in str(read_error.value)
assert TOKEN not in str(write_error.value)
@pytest.mark.parametrize("name", ["SHEETY_ENDPOINT", "SHEETY_TOKEN"])
def test_missing_env_variable_names_the_variable(
monkeypatch: pytest.MonkeyPatch, name: str
) -> None:
monkeypatch.delenv(name)
with pytest.raises(DataManagerError, match=name):
DataManager(_session())
+135
View File
@@ -0,0 +1,135 @@
from typing import Any
import pytest
from flight_club.constants import NOT_AVAILABLE
from flight_club.flight_data import FlightData, find_cheapest_flight
RETURN_DATE = "2026-06-22"
SAMPLE_RESPONSE: dict[str, Any] = {
"best_flights": [
{
"flights": [
{
"departure_airport": {"id": "LHR", "time": "2026-06-15 10:30"},
"arrival_airport": {"id": "CDG", "time": "2026-06-15 12:50"},
}
],
"price": 120,
}
],
"other_flights": [
{
"flights": [
{
"departure_airport": {"id": "LHR", "time": "2026-06-16 07:00"},
"arrival_airport": {"id": "CDG", "time": "2026-06-16 09:20"},
}
],
"price": 95,
},
{
"flights": [
{
"departure_airport": {"id": "LHR", "time": "2026-06-17 18:00"},
"arrival_airport": {"id": "CDG", "time": "2026-06-17 20:20"},
}
],
"price": 210,
},
],
}
def _entry(price: Any, origin: str = "LHR", destination: str = "CDG") -> dict[str, Any]:
return {
"flights": [
{
"departure_airport": {"id": origin, "time": "2026-07-01 08:00"},
"arrival_airport": {"id": destination, "time": "2026-07-01 10:00"},
}
],
"price": price,
}
def _unavailable() -> FlightData:
return FlightData(NOT_AVAILABLE, NOT_AVAILABLE, NOT_AVAILABLE, NOT_AVAILABLE, NOT_AVAILABLE)
def test_find_cheapest_flight_picks_lowest_across_both_lists() -> None:
result = find_cheapest_flight(SAMPLE_RESPONSE, RETURN_DATE)
assert result == FlightData(95, "LHR", "CDG", "2026-06-16", RETURN_DATE)
def test_find_cheapest_flight_uses_only_other_flights_when_best_missing() -> None:
data = {"other_flights": [_entry(80), _entry(60, destination="ORY")]}
result = find_cheapest_flight(data, RETURN_DATE)
assert result.price == 60
assert result.destination_airport == "ORY"
def test_find_cheapest_flight_skips_missing_price() -> None:
data = {"best_flights": [{"flights": _entry(1)["flights"]}, _entry(150)]}
assert find_cheapest_flight(data, RETURN_DATE).price == 150
@pytest.mark.parametrize("price", ["abc", None, [], True])
def test_find_cheapest_flight_skips_non_numeric_price(price: Any) -> None:
data = {"best_flights": [_entry(price), _entry(300)]}
assert find_cheapest_flight(data, RETURN_DATE).price == 300
def test_find_cheapest_flight_returns_unavailable_for_empty_dict() -> None:
assert find_cheapest_flight({}, RETURN_DATE) == _unavailable()
def test_find_cheapest_flight_returns_unavailable_for_none(
capsys: pytest.CaptureFixture[str],
) -> None:
assert find_cheapest_flight(None, RETURN_DATE) == _unavailable()
assert capsys.readouterr().out.strip() != ""
def test_find_cheapest_flight_skips_malformed_entries() -> None:
data = {
"best_flights": [
{"price": 10},
{"flights": [], "price": 10},
{"flights": [{"departure_airport": {}}], "price": 10},
"not a dict",
_entry(200),
]
}
assert find_cheapest_flight(data, RETURN_DATE).price == 200
def test_find_cheapest_flight_returns_unavailable_when_all_malformed() -> None:
data = {"best_flights": [{"price": 10}, {"flights": [], "price": 5}]}
assert find_cheapest_flight(data, RETURN_DATE) == _unavailable()
def test_find_cheapest_flight_takes_destination_from_last_leg() -> None:
entry = {
"flights": [
{
"departure_airport": {"id": "LHR", "time": "2026-08-01 06:00"},
"arrival_airport": {"id": "AMS", "time": "2026-08-01 08:00"},
},
{
"departure_airport": {"id": "AMS", "time": "2026-08-01 09:30"},
"arrival_airport": {"id": "BCN", "time": "2026-08-01 11:45"},
},
],
"price": 140,
}
result = find_cheapest_flight({"best_flights": [entry]}, RETURN_DATE)
assert result.origin_airport == "LHR"
assert result.destination_airport == "BCN"
def test_find_cheapest_flight_trims_time_from_out_date() -> None:
result = find_cheapest_flight({"best_flights": [_entry(50)]}, RETURN_DATE)
assert result.out_date == "2026-07-01"
assert result.return_date == RETURN_DATE
+219
View File
@@ -0,0 +1,219 @@
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock
import pytest
import requests
from flight_club.constants import (
CURRENCY,
DEFAULT_MOCK_FLIGHTS_URL,
REQUEST_TIMEOUT_SECONDS,
SERPAPI_URL,
)
from flight_club.flight_search import FlightSearch, FlightSearchError
API_KEY = "serp-secret-key"
APP_ID = "mock-app-id-value"
APP_KEY = "mock-app-key-secret"
FROM_TIME = datetime(2026, 6, 15)
TO_TIME = datetime(2026, 6, 22)
BODY: dict[str, Any] = {"best_flights": [{"flights": [], "price": 99}]}
@pytest.fixture(autouse=True)
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (
"FLIGHT_PROVIDER",
"SERPAPI_API_KEY",
"MOCK_FLIGHTS_APP_ID",
"MOCK_FLIGHTS_APP_KEY",
"MOCK_FLIGHTS_URL",
):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("SERPAPI_API_KEY", API_KEY)
def _use_mock(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FLIGHT_PROVIDER", "mock")
monkeypatch.setenv("MOCK_FLIGHTS_APP_ID", APP_ID)
monkeypatch.setenv("MOCK_FLIGHTS_APP_KEY", APP_KEY)
def _session(status: int = 200, body: Any = BODY, invalid_json: bool = False) -> MagicMock:
response = MagicMock()
response.status_code = status
if invalid_json:
response.json.side_effect = ValueError("bad json")
else:
response.json.return_value = body
session = MagicMock()
session.get.return_value = response
return session
def _search(session: MagicMock) -> dict[str, Any] | None:
return FlightSearch(session).check_flights("LHR", "CDG", FROM_TIME, TO_TIME)
def test_check_flights_sends_serpapi_params() -> None:
session = _session()
assert _search(session) == BODY
args, kwargs = session.get.call_args
assert args == (SERPAPI_URL,)
assert kwargs["params"] == {
"engine": "google_flights",
"departure_id": "LHR",
"arrival_id": "CDG",
"outbound_date": "2026-06-15",
"return_date": "2026-06-22",
"type": "1",
"adults": "1",
"currency": CURRENCY,
"api_key": API_KEY,
}
assert kwargs["timeout"] == REQUEST_TIMEOUT_SECONDS
def test_check_flights_uses_mock_headers_and_default_url(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_mock(monkeypatch)
session = _session()
assert _search(session) == BODY
args, kwargs = session.get.call_args
assert args == (DEFAULT_MOCK_FLIGHTS_URL,)
assert kwargs["headers"] == {"x-app-id": APP_ID, "x-app-key": APP_KEY}
assert kwargs["params"]["api_key"] == APP_KEY
assert kwargs["params"]["departure_id"] == "LHR"
def test_default_mock_url_targets_flights_search_when_env_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert DEFAULT_MOCK_FLIGHTS_URL.endswith("/v1/flights/search")
_use_mock(monkeypatch)
session = _session()
_search(session)
assert session.get.call_args.args[0].endswith("/v1/flights/search")
def test_check_flights_uses_mock_url_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
_use_mock(monkeypatch)
monkeypatch.setenv("MOCK_FLIGHTS_URL", "https://example.test/flights")
session = _session()
_search(session)
assert session.get.call_args.args == ("https://example.test/flights",)
def test_init_raises_for_unknown_provider(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FLIGHT_PROVIDER", "carrier-pigeon")
with pytest.raises(FlightSearchError, match="FLIGHT_PROVIDER"):
FlightSearch()
def test_init_raises_without_serpapi_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SERPAPI_API_KEY")
with pytest.raises(FlightSearchError, match="SERPAPI_API_KEY"):
FlightSearch()
@pytest.mark.parametrize("missing", ["MOCK_FLIGHTS_APP_ID", "MOCK_FLIGHTS_APP_KEY"])
def test_init_raises_without_mock_credentials(
monkeypatch: pytest.MonkeyPatch, missing: str
) -> None:
_use_mock(monkeypatch)
monkeypatch.delenv(missing)
with pytest.raises(FlightSearchError, match=missing):
FlightSearch()
def test_check_flights_returns_none_on_http_error(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(status=500)) is None
assert "500" in capsys.readouterr().out
def test_check_flights_prints_service_error_text_on_http_error(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_use_mock(monkeypatch)
text = "Invalid input: expected string, received undefined"
assert _search(_session(status=400, body={"error": text})) is None
captured = capsys.readouterr()
assert f"Flight search failed with HTTP 400: {text}." in captured.out
for secret in (API_KEY, APP_ID, APP_KEY):
assert secret not in captured.out + captured.err
def test_check_flights_truncates_long_service_error_text(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(status=400, body={"error": "x" * 500})) is None
out = capsys.readouterr().out
assert "x" * 200 in out
assert "x" * 201 not in out
@pytest.mark.parametrize("body", [["error"], {"error": 5}, {"other": "x"}])
def test_check_flights_falls_back_when_error_body_is_not_text(
capsys: pytest.CaptureFixture[str], body: Any
) -> None:
assert _search(_session(status=500, body=body)) is None
assert capsys.readouterr().out == "Flight search failed with HTTP 500.\n"
def test_check_flights_falls_back_when_error_body_is_not_json(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(status=502, invalid_json=True)) is None
assert capsys.readouterr().out == "Flight search failed with HTTP 502.\n"
def test_check_flights_returns_none_on_invalid_json(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(invalid_json=True)) is None
assert "invalid JSON" in capsys.readouterr().out
def test_check_flights_returns_none_on_error_body(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(body={"error": "Invalid API key"})) is None
assert "Invalid API key" in capsys.readouterr().out
def test_check_flights_returns_none_on_connection_error(
capsys: pytest.CaptureFixture[str],
) -> None:
session = MagicMock()
session.get.side_effect = requests.ConnectionError(f"failed for key {API_KEY}")
assert _search(session) is None
assert "ConnectionError" in capsys.readouterr().out
def test_check_flights_never_prints_serpapi_key(
capsys: pytest.CaptureFixture[str],
) -> None:
session = MagicMock()
session.get.side_effect = requests.ConnectionError(f"failed for key {API_KEY}")
_search(session)
_search(_session(status=500))
_search(_session(body={"error": "bad"}))
captured = capsys.readouterr()
assert API_KEY not in captured.out + captured.err
def test_check_flights_never_prints_mock_secrets(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_use_mock(monkeypatch)
session = MagicMock()
session.get.side_effect = requests.ConnectionError(f"{APP_ID} {APP_KEY}")
_search(session)
_search(_session(status=500))
captured = capsys.readouterr()
assert APP_ID not in captured.out + captured.err
assert APP_KEY not in captured.out + captured.err
+130
View File
@@ -0,0 +1,130 @@
from datetime import datetime, timedelta
from typing import Any
import pytest
from flight_club import main as main_module
from flight_club.data_manager import DataManagerError
from flight_club.flight_data import FlightData
from flight_club.main import run
TODAY = datetime(2026, 10, 24, 12, 0)
class FakeDataManager:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self.rows = rows
self.updates: list[tuple[int, float]] = []
def get_destination_data(self) -> list[dict[str, Any]]:
return self.rows
def update_lowest_price(self, row_id: int, new_price: float) -> None:
self.updates.append((row_id, new_price))
class FakeFlightSearch:
def __init__(self, price: float | None) -> None:
self.price = price
self.calls: list[tuple[str, str, datetime, datetime]] = []
def check_flights(
self, origin: str, destination: str, from_time: datetime, to_time: datetime
) -> dict[str, Any] | None:
self.calls.append((origin, destination, from_time, to_time))
if self.price is None:
return None
leg = {
"departure_airport": {"id": origin, "time": "2026-10-25 08:00"},
"arrival_airport": {"id": destination, "time": "2026-10-25 10:00"},
}
return {"best_flights": [{"price": self.price, "flights": [leg]}]}
class FakeNotificationManager:
def __init__(self) -> None:
self.sent: list[str] = []
def build_message(self, flight: FlightData) -> str:
return f"alert {flight.destination_airport} {flight.price}"
def send_whatsapp(self, message: str) -> bool:
self.sent.append(message)
return True
def _run(
rows: list[dict[str, Any]], price: float | None
) -> tuple[list[FlightData], FakeDataManager, FakeFlightSearch, FakeNotificationManager]:
data, search, notifier = (
FakeDataManager(rows),
FakeFlightSearch(price),
FakeNotificationManager(),
)
found = run(data, search, notifier, today=TODAY) # type: ignore[arg-type]
return found, data, search, notifier
def _row(lowest: Any = 100) -> dict[str, Any]:
return {"city": "Paris", "iataCode": "CDG", "lowestPrice": lowest, "id": 2}
def test_run_updates_and_notifies_when_price_is_lower() -> None:
found, data, _, notifier = _run([_row(100)], 54)
assert [f.price for f in found] == [54]
assert data.updates == [(2, 54)]
assert notifier.sent == ["alert CDG 54.0"]
assert found[0].return_date == (TODAY + timedelta(days=182)).strftime("%Y-%m-%d")
@pytest.mark.parametrize("price", [100, 150])
def test_run_does_nothing_when_price_is_not_lower(price: float) -> None:
found, data, _, notifier = _run([_row(100)], price)
assert found == []
assert data.updates == []
assert notifier.sent == []
def test_run_does_nothing_when_price_not_available() -> None:
found, data, _, notifier = _run([_row(100)], None)
assert found == []
assert data.updates == []
assert notifier.sent == []
@pytest.mark.parametrize("lowest", [None, "", "cheap"])
def test_run_skips_row_without_numeric_lowest_price(lowest: Any) -> None:
_, data, search, notifier = _run([_row(lowest)], 1)
assert search.calls == []
assert data.updates == []
assert notifier.sent == []
def test_run_skips_row_without_iata_code() -> None:
row = {"city": "Nowhere", "iataCode": "", "lowestPrice": 100, "id": 3}
_, _, search, _ = _run([row], 1)
assert search.calls == []
def test_run_checks_every_row_with_origin_and_dates() -> None:
rows = [{**_row(), "iataCode": code, "id": i} for i, code in enumerate(["CDG", "BER", "TYO"])]
_, _, search, _ = _run(rows, None)
assert [c[:2] for c in search.calls] == [("LHR", "CDG"), ("LHR", "BER"), ("LHR", "TYO")]
for call in search.calls:
assert call[2] == TODAY + timedelta(days=1)
assert call[3] == TODAY + timedelta(days=182)
def test_main_exits_with_one_on_data_manager_error(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
def fail() -> None:
raise DataManagerError("Environment variable SHEETY_ENDPOINT is not set.")
monkeypatch.setattr(main_module, "load_dotenv", lambda: True)
monkeypatch.setattr(main_module, "install_flight_cache", lambda: None)
monkeypatch.setattr(main_module, "DataManager", fail)
with pytest.raises(SystemExit) as exit_info:
main_module.main()
assert exit_info.value.code == 1
assert "SHEETY_ENDPOINT" in capsys.readouterr().out
+91
View File
@@ -0,0 +1,91 @@
import builtins
import sys
import types
from typing import Any
import pytest
from flight_club.constants import ENV_WHATSAPP_NUMBER
from flight_club.flight_data import FlightData
from flight_club.notification_manager import NotificationManager
NUMBER = "+441234567890"
def _flight() -> FlightData:
return FlightData(54, "LHR", "CDG", "2026-10-25", "2027-04-20")
def test_build_message_contains_price_airports_and_dates() -> None:
message = NotificationManager().build_message(_flight())
assert message == (
"Low price alert! Only GBP 54 to fly from LHR to CDG, from 2026-10-25 to 2027-04-20."
)
def test_send_whatsapp_returns_false_when_number_missing(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.delenv(ENV_WHATSAPP_NUMBER, raising=False)
assert NotificationManager().send_whatsapp("hello") is False
output = capsys.readouterr().out
assert "hello" in output
assert ENV_WHATSAPP_NUMBER in output
def test_send_whatsapp_uses_injected_sender(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(ENV_WHATSAPP_NUMBER, NUMBER)
calls: list[tuple[str, str]] = []
manager = NotificationManager(sender=lambda number, message: calls.append((number, message)))
assert manager.send_whatsapp("hello") is True
assert calls == [(NUMBER, "hello")]
def test_send_whatsapp_returns_false_when_pywhatkit_missing(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv(ENV_WHATSAPP_NUMBER, NUMBER)
monkeypatch.delitem(sys.modules, "pywhatkit", raising=False)
real_import = builtins.__import__
def fake_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == "pywhatkit":
raise ImportError(name)
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
assert NotificationManager().send_whatsapp("hello") is False
output = capsys.readouterr().out
assert "whatsapp" in output
assert "hello" in output
assert NUMBER not in output
def test_send_whatsapp_never_prints_number_when_pywhatkit_fails(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv(ENV_WHATSAPP_NUMBER, NUMBER)
fake = types.ModuleType("pywhatkit")
def fail(*args: Any) -> None:
raise RuntimeError(f"cannot reach {args[0]}")
fake.sendwhatmsg_instantly = fail # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "pywhatkit", fake)
assert NotificationManager().send_whatsapp("hello") is False
output = capsys.readouterr().out
assert "RuntimeError" in output
assert "hello" in output
assert NUMBER not in output
def test_send_whatsapp_calls_pywhatkit_with_documented_arguments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(ENV_WHATSAPP_NUMBER, NUMBER)
received: list[tuple[Any, ...]] = []
fake = types.ModuleType("pywhatkit")
fake.sendwhatmsg_instantly = lambda *args: received.append(args) # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "pywhatkit", fake)
assert NotificationManager().send_whatsapp("hello") is True
assert received == [(NUMBER, "hello", 15, True, 3)]