MIL-001 Baseline and project setup #23
@@ -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=
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 =
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
@file
|
||||
@brief Flight Club: finds cheap flights for destinations listed in a Google Sheet.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
@file
|
||||
@brief Lets `python -m flight_club` start the program.
|
||||
"""
|
||||
|
||||
from flight_club.main import main
|
||||
|
||||
main()
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
Reference in New Issue
Block a user