diff --git a/CHANGELOG.md b/CHANGELOG.md index aecb99f7..1a336651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ If there is no "Upgrading" header for that version, no post-upgrade actions need ## Upcoming +### New Features +- Added environment variable alternatives for many configuration items +([#210](https://github.com/jdholtz/auto-southwest-check-in/pull/210)) + ### Bug Fixes - Fix failed logins not reporting the correct error ([#189](https://github.com/jdholtz/auto-southwest-check-in/issues/189)) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index ae0524ee..8aeba8da 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -5,6 +5,9 @@ file can be found at [config.example.json](config.example.json) Auto-Southwest Check-In supports both global configuration and account/reservation-specific configuration. See [Accounts and Reservations](#accounts-and-reservations) for more information. +**Note**: Many configuration items may also be configured via environment variables (except for account and +reservation specific configurations). + ## Table of Contents - [Fare Check](#fare-check) - [Notifications](#notifications) @@ -19,7 +22,9 @@ Auto-Southwest Check-In supports both global configuration and account/reservati ## Fare Check Default: true \ -Type: Boolean +Type: Boolean \ +Environment Variable: `AUTO_SOUTHWEST_CHECK_IN_CHECK_FARES` +> Using the environment variable will override the applicable setting in `config.json`. In addition to automatically checking in, check for price drops on an interval (see [Retrieval Interval](#retrieval-interval)). If a lower fare is found, the user will be notified. @@ -34,7 +39,10 @@ In addition to automatically checking in, check for price drops on an interval ## Notifications ### Notification URLs Default: [] \ -Type: String or List +Type: String or List \ +Environment Variable: `AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_URL` +> When using the environment variable, you may only specify a single URL. +> If you are also using `config.json`, it will append the URL as long as it's not a duplicate. Users can be notified on successful and failed check-ins. This is done through the [Apprise library][0]. To start, first gather the service URL you want to send notifications to (information on how to create @@ -57,7 +65,9 @@ If you have more than one service you want to send notifications to, you can put ### Notification Level Default: 1 \ -Type: Integer +Type: Integer \ +Environment Variable: `AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_LEVEL` +> Using the environment variable will override the applicable setting in `config.json`. You can also select the level of notifications you want to receive. ```json @@ -76,7 +86,9 @@ $ python3 southwest.py --test-notifications ## Browser Path Default: The path to your Chrome or Chromium browser (if installed) \ -Type: String +Type: String \ +Environment Variable: `AUTO_SOUTHWEST_CHECK_IN_BROWSER_PATH` +> Using the environment variable will override the applicable setting in `config.json`. If you use another Chromium-based browser besides Google Chrome or Chromium (such as Brave), you need to specify the path to the browser executable. @@ -90,7 +102,9 @@ the browser executable. ## Retrieval Interval Default: 24 hours \ -Type: Integer +Type: Integer \ +Environment Variable: `AUTO_SOUTHWEST_CHECK_IN_RETRIEVAL_INTERVAL` +> Using the environment variable will override the applicable setting in `config.json`. You can choose how often the script checks for lower fares on scheduled flights (in hours). Additionally, this interval will also determine how often the script checks for new flights if login credentials are provided. To @@ -108,7 +122,11 @@ account and reservation. ### Accounts Default: [] \ -Type: List +Type: List \ +Environment Variables: + - `AUTO_SOUTHWEST_CHECK_IN_USERNAME` + - `AUTO_SOUTHWEST_CHECK_IN_PASSWORD` +> When using the environment variables, you may only specify a single set of credentials. You can add more accounts to the script, allowing you to run multiple accounts at the same time and/or not provide a username and password as arguments. @@ -123,7 +141,12 @@ provide a username and password as arguments. ### Reservations Default: [] \ -Type: List +Type: List \ +Environment Variables: + - `AUTO_SOUTHWEST_CHECK_IN_CONFIRMATION_NUMBER` + - `AUTO_SOUTHWEST_CHECK_IN_FIRST_NAME` + - `AUTO_SOUTHWEST_CHECK_IN_LAST_NAME` +> When using the environment variables, you may only specify a single reservation. You can also add more reservations to the script, allowing you check in to multiple reservations in the same instance and/or not provide reservation information as arguments. diff --git a/README.md b/README.md index 8091fc13..f1639e66 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ Alternatively, you can log in to your account, which will automatically check yo ```shell python3 southwest.py USERNAME PASSWORD ``` -**Note**: If any arguments contain special characters, make sure to escape them so they are passed into -the script correctly. +**Note**: If any arguments contain special characters, make sure to escape them or use +environment variables so they are passed into the script correctly. For the full usage of the script, run: ```shell @@ -97,7 +97,7 @@ You can optionally attach a configuration file to the container by adding the **Note**: The recommended restart policy for the container is `on-failure` or `no` -#### Docker Compose Example +#### Docker Compose Example Using Config ```yaml services: auto-southwest: @@ -108,6 +108,18 @@ services: - /full-path/to/config.json:/app/config.json ``` +#### Docker Compose Example Using Environment Variables +```yaml +services: + auto-southwest: + image: jdholtz/auto-southwest-check-in + container_name: auto-southwest + restart: on-failure + environment: + - AUTO_SOUTHWEST_CHECK_IN_USERNAME=MyUsername + - AUTO_SOUTHWEST_CHECK_IN_PASSWORD=TopsyKretts +``` + Additional information on the Docker container can be found in the [public repository][5]. ## Configuration diff --git a/lib/config.py b/lib/config.py index 988a3f9f..fb454719 100644 --- a/lib/config.py +++ b/lib/config.py @@ -1,10 +1,11 @@ import json +import os import sys from pathlib import Path from typing import Any, Dict, List from .log import get_logger -from .utils import NotificationLevel +from .utils import NotificationLevel, is_truthy # Type alias for JSON JSON = Dict[str, Any] @@ -108,6 +109,7 @@ def initialize(self) -> JSON: try: config = self._read_config() + config = self._read_env_vars(config) self._parse_config(config) except (ConfigError, json.decoder.JSONDecodeError) as err: print("Error in configuration file:") @@ -144,6 +146,75 @@ def _read_config(self) -> JSON: return config + def _read_env_vars(self, config: JSON) -> JSON: + logger.debug("Reading configuration from environment variables") + # Check Fares + check_fares = os.getenv("AUTO_SOUTHWEST_CHECK_IN_CHECK_FARES") + if check_fares: + try: + config["check_fares"] = is_truthy(check_fares) + except ValueError as err: + raise ConfigError("Error parsing 'AUTO_SOUTHWEST_CHECK_IN_CHECK_FARES'") from err + + # Notification URL + notification_url = os.getenv("AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_URL") + if notification_url: + config.setdefault("notification_urls", []) + if isinstance(config["notification_urls"], str): + config["notification_urls"] = [config["notification_urls"]] + if not isinstance(config["notification_urls"], list): + raise ConfigError("'notification_urls' must be a string or a list") + if notification_url not in config["notification_urls"]: + config["notification_urls"].append(notification_url) + + # Notification Level + notification_level = os.getenv("AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_LEVEL") + if notification_level: + try: + config["notification_level"] = int(notification_level) + except ValueError as err: + raise ConfigError( + "'AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_LEVEL' must be an integer" + ) from err + + # Browser Path + browser_path = os.getenv("AUTO_SOUTHWEST_CHECK_IN_BROWSER_PATH") + if browser_path: + config["browser_path"] = browser_path + + # Retrieval Interval + retrieval_interval = os.getenv("AUTO_SOUTHWEST_CHECK_IN_RETRIEVAL_INTERVAL") + if retrieval_interval: + try: + config["retrieval_interval"] = int(retrieval_interval) + except ValueError as err: + raise ConfigError( + "'AUTO_SOUTHWEST_CHECK_IN_RETRIEVAL_INTERVAL' must be an integer" + ) from err + + # Account credentials + username = os.getenv("AUTO_SOUTHWEST_CHECK_IN_USERNAME") + password = os.getenv("AUTO_SOUTHWEST_CHECK_IN_PASSWORD") + if username and password: + new_credentials = {"username": username, "password": password} + config.setdefault("accounts", []) + config["accounts"].append(new_credentials) + + # Reservation information + confirmation_number = os.getenv("AUTO_SOUTHWEST_CHECK_IN_CONFIRMATION_NUMBER") + first_name = os.getenv("AUTO_SOUTHWEST_CHECK_IN_FIRST_NAME") + last_name = os.getenv("AUTO_SOUTHWEST_CHECK_IN_LAST_NAME") + if confirmation_number and first_name and last_name: + new_reservation = { + "confirmationNumber": confirmation_number, + "firstName": first_name, + "lastName": last_name, + } + config.setdefault("reservations", []) + config["reservations"].append(new_reservation) + + return config + def _parse_config(self, config: JSON) -> None: super()._parse_config(config) diff --git a/lib/utils.py b/lib/utils.py index 48df8c33..bbbb0356 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -2,7 +2,7 @@ import logging import time from enum import IntEnum -from typing import Any, Dict +from typing import Any, Dict, Union import requests @@ -73,3 +73,26 @@ class FlightChangeError(Exception): class NotificationLevel(IntEnum): INFO = 1 ERROR = 2 + + +def is_truthy(arg: Union[bool, int, str]) -> bool: + """ + Convert "truthy" strings into Booleans. + + Examples: + >>> is_truthy('yes') + True + + Args: + arg: Truthy value (True values are y, yes, t, true, on and 1; false values are n, no, + f, false, off and 0. Raises ValueError if val is anything else. + """ + if isinstance(arg, bool): + return arg + + val = str(arg).lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return True + if val in ("n", "no", "f", "false", "off", "0"): + return False + raise ValueError(f"Invalid truthy value: `{arg}`") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 10a1e83b..b6d54c6f 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -180,6 +180,202 @@ def test_read_config_raises_exception_when_config_not_dict(self, mocker: MockerF with pytest.raises(ConfigError): test_config._read_config() + def test_read_env_vars_check_fares_successful(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_CHECK_FARES": "true"}) + test_config = GlobalConfig() + config_content = test_config._read_env_vars({}) + + assert config_content == {"check_fares": True} + + def test_read_env_vars_check_fares_override_json_config(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_CHECK_FARES": "true"}) + test_config = GlobalConfig() + base_config = {"check_fares": False} + config_content = test_config._read_env_vars(base_config) + + assert config_content == {"check_fares": True} + + def test_read_env_vars_check_fares_invalid(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_CHECK_FARES": "invalid"}) + test_config = GlobalConfig() + with pytest.raises(ConfigError): + test_config._read_env_vars({}) + + def test_read_env_vars_notification_url_successful(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_URL": "test_url"}) + test_config = GlobalConfig() + config_content = test_config._read_env_vars({}) + + assert config_content == {"notification_urls": ["test_url"]} + + def test_read_env_vars_notification_url_duplicate(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_URL": "test_url"}) + test_config = GlobalConfig() + base_config = {"notification_urls": ["test_url"]} + config_content = test_config._read_env_vars(base_config) + + assert config_content == {"notification_urls": ["test_url"]} + + def test_read_env_vars_notification_url_additional(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_URL": "test_url2"}) + test_config = GlobalConfig() + base_config = {"notification_urls": ["test_url1"]} + config_content = test_config._read_env_vars(base_config) + + assert config_content == {"notification_urls": ["test_url1", "test_url2"]} + + def test_read_env_vars_notification_url_base_string(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_URL": "test_url2"}) + test_config = GlobalConfig() + base_config = {"notification_urls": "test_url"} + config_content = test_config._read_env_vars(base_config) + + assert config_content == {"notification_urls": ["test_url", "test_url2"]} + + def test_read_env_vars_notification_url_base_invalid(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_URL": "test_url2"}) + test_config = GlobalConfig() + base_config = {"notification_urls": 0} + with pytest.raises(ConfigError): + test_config._read_env_vars(base_config) + + def test_read_env_vars_notification_level_successful(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_LEVEL": "1"}) + test_config = GlobalConfig() + config_content = test_config._read_env_vars({}) + + assert config_content == {"notification_level": 1} + + def test_read_env_vars_notification_level_invalid(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_NOTIFICATION_LEVEL": "invalid"}) + test_config = GlobalConfig() + with pytest.raises(ConfigError): + test_config._read_env_vars({}) + + def test_read_env_vars_browser_path_successful(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_BROWSER_PATH": "test_path"}) + test_config = GlobalConfig() + config_content = test_config._read_env_vars({}) + + assert config_content == {"browser_path": "test_path"} + + def test_read_env_vars_browser_path_override_json_config(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_BROWSER_PATH": "test_path"}) + test_config = GlobalConfig() + base_config = {"browser_path": "test_path2"} + config_content = test_config._read_env_vars(base_config) + + assert config_content == {"browser_path": "test_path"} + + def test_read_env_vars_retrieval_interval_successful(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_RETRIEVAL_INTERVAL": "10"}) + test_config = GlobalConfig() + config_content = test_config._read_env_vars({}) + + assert config_content == {"retrieval_interval": 10} + + def test_read_env_vars_retrieval_interval_override_json_config( + self, mocker: MockerFixture + ) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_RETRIEVAL_INTERVAL": "10"}) + test_config = GlobalConfig() + base_config = {"retrieval_interval": 20} + config_content = test_config._read_env_vars(base_config) + + assert config_content == {"retrieval_interval": 10} + + def test_read_env_vars_retrieval_interval_invalid(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_RETRIEVAL_INTERVAL": "invalid"}) + test_config = GlobalConfig() + with pytest.raises(ConfigError): + test_config._read_env_vars({}) + + def test_read_env_vars_account_successful(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_USERNAME": "test_user"}) + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_PASSWORD": "test_pass"}) + test_config = GlobalConfig() + config_content = test_config._read_env_vars({}) + + assert config_content == {"accounts": [{"username": "test_user", "password": "test_pass"}]} + + def test_read_env_vars_additional_account_successful(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_USERNAME": "test_user2"}) + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_PASSWORD": "test_pass2"}) + test_config = GlobalConfig() + base_config = {"accounts": [{"username": "test_user1", "password": "test_pass1"}]} + config_content = test_config._read_env_vars(base_config) + + assert config_content == { + "accounts": [ + {"username": "test_user1", "password": "test_pass1"}, + {"username": "test_user2", "password": "test_pass2"}, + ] + } + + def test_read_env_vars_missing_credentials(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_USERNAME": "test_user"}) + test_config = GlobalConfig() + config_content = test_config._read_env_vars({}) + + assert config_content == {} + + def test_read_env_vars_reservation_successful(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_CONFIRMATION_NUMBER": "test_num"}) + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_FIRST_NAME": "test_first"}) + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_LAST_NAME": "test_last"}) + test_config = GlobalConfig() + config_content = test_config._read_env_vars({}) + + assert config_content == { + "reservations": [ + { + "confirmationNumber": "test_num", + "firstName": "test_first", + "lastName": "test_last", + } + ] + } + + def test_read_env_vars_additional_reservation_successful(self, mocker: MockerFixture) -> None: + mocker.patch.dict( + "os.environ", {"AUTO_SOUTHWEST_CHECK_IN_CONFIRMATION_NUMBER": "test_num2"} + ) + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_FIRST_NAME": "test_first2"}) + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_LAST_NAME": "test_last2"}) + test_config = GlobalConfig() + base_config = { + "reservations": [ + { + "confirmationNumber": "test_num1", + "firstName": "test_first1", + "lastName": "test_last1", + } + ] + } + config_content = test_config._read_env_vars(base_config) + + assert config_content == { + "reservations": [ + { + "confirmationNumber": "test_num1", + "firstName": "test_first1", + "lastName": "test_last1", + }, + { + "confirmationNumber": "test_num2", + "firstName": "test_first2", + "lastName": "test_last2", + }, + ] + } + + def test_read_env_vars_missing_reservation(self, mocker: MockerFixture) -> None: + mocker.patch.dict("os.environ", {"AUTO_SOUTHWEST_CHECK_IN_CONFIRMATION_NUMBER": "test_num"}) + test_config = GlobalConfig() + config_content = test_config._read_env_vars({}) + + assert config_content == {} + @pytest.mark.parametrize( "config_content", [ diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index ece01a5d..3b692136 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from pytest_mock import MockerFixture from requests_mock.mocker import Mocker as RequestMocker @@ -52,3 +54,43 @@ def test_make_request_handles_malformed_URLs(requests_mock: RequestMocker) -> No mock_post = requests_mock.get(utils.BASE_URL + "test/test2", status_code=200, text="{}") utils.make_request("GET", "/test//test2", {}, {}) assert mock_post.last_request.url == utils.BASE_URL + "test/test2" + + +@pytest.mark.parametrize( + "value, expected", + [ + (True, True), + (False, False), + ("true", True), + ("false", False), + ("True", True), + ("False", False), + ("TRUE", True), + ("FALSE", False), + ("t", True), + ("f", False), + ("T", True), + ("F", False), + ("yes", True), + ("no", False), + ("Yes", True), + ("No", False), + ("YES", True), + ("NO", False), + ("y", True), + ("n", False), + ("Y", True), + ("N", False), + ("1", True), + ("0", False), + ], +) +def test_is_truthy(value: Any, expected: bool) -> None: + assert utils.is_truthy(value) == expected + + +def test_is_truthy_raises_exception_on_invalid_type() -> None: + with pytest.raises(ValueError) as excinfo: + utils.is_truthy("test") + + assert "Invalid truthy value" in str(excinfo.value)