diff --git a/Makefile b/Makefile index f495dd5..f5636d2 100644 --- a/Makefile +++ b/Makefile @@ -28,13 +28,13 @@ nopyc: clean: nopyc rm -rf _build .venv -test: env virtualenv +test: install @( \ source .venv/bin/activate; \ python -m coverage run -m unittest discover -v -b && python -m coverage xml -o _build/coverage/coverage.xml; \ ) -run-devserver: env virtualenv +run-devserver: install @( \ source .venv/bin/activate; \ FLASK_SKIP_DOTENV=1 FLASK_ENV=development FLASK_APP=devserver.py flask run; \ diff --git a/README.md b/README.md index 0621cd9..ad826ad 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@
-[![Build](https://github.com/alexdlaird/air-quality-bot/actions/workflows/build.yml/badge.svg)](https://github.com/alexdlaird/air-quality-bot/actions/workflows/build.yml) -[![Codecov](https://codecov.io/gh/alexdlaird/air-quality-bot/branch/main/graph/badge.svg)](https://codecov.io/gh/alexdlaird/air-quality-bot) ![Python Versions](https://img.shields.io/badge/python-%203.8%20|%203.9%20|%203.10%20|%203.11%20-blue) -![GitHub License](https://img.shields.io/github/license/alexdlaird/air-quality-bot) -[![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Text+your+zip+code+to+the+%23AirQualityBot+at+%28415%29+212-4229%2C+and+optionally+add+%E2%80%9Cmap%E2%80%9D%2C+and+it%E2%80%99ll+respond+with+the+current+%23airquality+for+your+region.%0D%0A%0D%0AStay+safe+everyone.%0D%0A%0D%0A&url=https://github.com/alexdlaird/air-quality-bot&via=alexdlaird+@twilio&hashtags=Twilio,TwilioFunctions,AirNow,AQI) +[![Coverage](https://img.shields.io/codecov/c/github/alexdlaird/air-quality-bot)](https://codecov.io/gh/alexdlaird/air-quality-bot) +[![Build](https://img.shields.io/github/actions/workflow/status/alexdlaird/air-quality-bot/build.yml)](https://github.com/alexdlaird/air-quality-bot/actions/workflows/build.yml) +[![GitHub License](https://img.shields.io/github/license/alexdlaird/air-quality-bot)](https://github.com/alexdlaird/air-quality-bot/blob/main/LICENSE) The Air Quality Bot is generally available by texting a zip code (and optionally the word "map") to (415) 212-4229. The bot will respond with the latest air @@ -154,6 +153,6 @@ can easily be redeployed at any time by rerunning the deploy script: If you would like to get involved, be sure to review the [Contribution Guide](https://github.com/alexdlaird/air-quality-bot/blob/main/CONTRIBUTING.rst). -Want to contribute financially? If you've found `pyngrok` useful, [sponsorship](https://github.com/sponsors/alexdlaird) would +Want to contribute financially? If you've found Air Quality Bot useful, [sponsorship](https://github.com/sponsors/alexdlaird) would also be greatly appreciated! diff --git a/devserver.py b/devserver.py index 265f0a6..9d4e13f 100644 --- a/devserver.py +++ b/devserver.py @@ -93,7 +93,7 @@ # Open a ngrok tunnel to the dev server public_url = ngrok.connect(port) - print(" * ngrok tunnel \"{}\" -> \"http://127.0.0.1:{}/\"".format(public_url, port)) + print(f" * ngrok tunnel \"{public_url}\" -> \"http://127.0.0.1:{port}/\"") TWILIO_ACCOUNT_SID = os.environ.get("AIR_QUALITY_DEV_TWILIO_ACCOUNT_SID", None) TWILIO_AUTH_TOKEN = os.environ.get("AIR_QUALITY_DEV_TWILIO_AUTH_TOKEN", None) @@ -101,7 +101,7 @@ # Update any base URLs or webhooks to use the public ngrok URL if TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN and TWILIO_SMS_NUMBER: - callback_url = "{}/inbound".format(public_url) + callback_url = f"{public_url}/inbound" client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) phone_number_sid = client.incoming_phone_numbers.list(phone_number=TWILIO_SMS_NUMBER)[0].sid diff --git a/lambdas/aqi_GET/lambda_function.py b/lambdas/aqi_GET/lambda_function.py index b2e1ecf..3765192 100644 --- a/lambdas/aqi_GET/lambda_function.py +++ b/lambdas/aqi_GET/lambda_function.py @@ -24,8 +24,9 @@ AIRNOW_API_KEYS = json.loads(os.environ.get("AIRNOW_API_KEYS")) AIRNOW_API_URL = os.environ.get("AIRNOW_API_URL", - "http://www.airnowapi.org/aq/observation/zipCode/current/?format=application/json&zipCode={}&distance=75&API_KEY={}") -AIRNOW_URL = os.environ.get("AIRNOW_URL", "https://airnow.gov/index.cfm?action=airnow.local_city&zipcode={}&submit=Go") + "http://www.airnowapi.org/aq/observation/zipCode/current/?format=application/json&zipCode={zip_code}&distance=75&API_KEY={api_key}") +AIRNOW_URL = os.environ.get("AIRNOW_URL", + "https://airnow.gov/index.cfm?action=airnow.local_city&zipcode={zip_code}&submit=Go") AIRNOW_MAP_URL_PREFIX = os.environ.get("AIRNOW_MAP_URL_PREFIX", "https://files.airnowtech.org/airnow/today/") _AIRNOW_API_TIMEOUT = 2 @@ -42,10 +43,10 @@ @conditional_decorator(datadog_lambda_wrapper, not os.environ.get("FLASK_APP", None)) def lambda_handler(event, context): - logger.info("Event: {}".format(event)) + logger.info(f"Event: {event}") query_string = event["params"]["querystring"] - logger.info("Query String: {}".format(query_string)) + logger.info(f"Query String: {query_string}") metricutils.increment("aqi_GET.request") @@ -114,10 +115,10 @@ def lambda_handler(event, context): def _get_zip_code_data(zip_code, utc_dt): db_zip_read = table.get_item( Key={ - "PartitionKey": "ZipCode:{}".format(zip_code) + "PartitionKey": f"ZipCode:{zip_code}" } ) - logger.info("DynamoDB ZipCode read response: {}".format(db_zip_read)) + logger.info(f"DynamoDB ZipCode read response: {db_zip_read}") data = None if "Item" not in db_zip_read or (utc_dt - parser.parse(db_zip_read["Item"]["LastUpdated"])).total_seconds() > 3600: @@ -145,12 +146,14 @@ def _get_zip_code_data(zip_code, utc_dt): def _airnow_api_request(zip_code, utc_dt, data, retries=0): airnow_api_key = random.choice(AIRNOW_API_KEYS) - logger.info("AirNow API URL: {}".format(AIRNOW_API_URL.format(zip_code, airnow_api_key))) + url = AIRNOW_API_URL.format(zip_code=zip_code, api_key=airnow_api_key) + logger.info(f"AirNow API URL: {url}") try: - response = requests.get(AIRNOW_API_URL.format(zip_code, airnow_api_key), timeout=_AIRNOW_API_TIMEOUT) + response = requests.get(AIRNOW_API_URL.format(zip_code=zip_code, api_key=airnow_api_key), + timeout=_AIRNOW_API_TIMEOUT) - logger.info("AirNow API response: {}".format(response.text)) + logger.info(f"AirNow API response: {response.text}") response_json = response.json() @@ -169,14 +172,14 @@ def _airnow_api_request(zip_code, utc_dt, data, retries=0): data[parameter["ParameterName"]] = parameter if "PM2.5" in data or "PM10" in data or "O3" in data: - data["PartitionKey"] = "ZipCode:{}".format(zip_code) + data["PartitionKey"] = f"ZipCode:{zip_code}" data["LastUpdated"] = utc_dt.isoformat() data["TTL"] = int((utc_dt + timedelta(hours=24) - datetime.fromtimestamp(0)).total_seconds()) db_zip_write = table.put_item( Item=data ) - logger.info("DynamoDB ZipCode write response: {}".format(db_zip_write)) + logger.info(f"DynamoDB ZipCode write response: {db_zip_write}") else: logger.info("AirNow data is unavailable for this zip code, not caching") except requests.exceptions.RequestException as e: @@ -206,11 +209,12 @@ def _airnow_api_request(zip_code, utc_dt, data, retries=0): def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt): db_reporting_area_read = table.get_item( Key={ - "PartitionKey": "ReportingArea:{}|{}".format(zip_code_data[parameter_name]["ReportingArea"], - zip_code_data[parameter_name]["StateCode"]) + "PartitionKey": "ReportingArea:{reporting_area}|{status_code}".format( + reporting_area=zip_code_data[parameter_name]["ReportingArea"], + status_code=zip_code_data[parameter_name]["StateCode"]) } ) - logger.info("DynamoDB ReportingArea read response: {}".format(db_reporting_area_read)) + logger.info(f"DynamoDB ReportingArea read response: {db_reporting_area_read}") data = None if "Item" not in db_reporting_area_read or ( @@ -226,8 +230,9 @@ def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt): db_reporting_area_update = table.update_item( Key={ - "PartitionKey": "ReportingArea:{}|{}".format(zip_code_data[parameter_name]["ReportingArea"], - zip_code_data[parameter_name]["StateCode"]) + "PartitionKey": "ReportingArea:{reporting_area}|{status_code}".format( + reporting_area=zip_code_data[parameter_name]["ReportingArea"], + status_code=zip_code_data[parameter_name]["StateCode"]) }, UpdateExpression="set LastUpdated = :dt, CachedAQI = :aqi", ExpressionAttributeValues={ @@ -236,13 +241,13 @@ def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt): }, ReturnValues="UPDATED_NEW" ) - logger.info("DynamoDB ReportingArea update response: {}".format(db_reporting_area_update)) + logger.info(f"DynamoDB ReportingArea update response: {db_reporting_area_update}") elif "Item" not in db_reporting_area_read: logger.info("No ReportingArea value found, querying AirNow for data") try: metricutils.increment("aqi_GET.airnow-request") - response = requests.get(AIRNOW_URL.format(zip_code_data["PartitionKey"][len("ZipCode") + 1:]), + response = requests.get(AIRNOW_URL.format(zip_code=zip_code_data["PartitionKey"][len("ZipCode") + 1:]), timeout=_AIRNOW_TIMEOUT) if AIRNOW_MAP_URL_PREFIX in response.text: @@ -252,15 +257,16 @@ def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt): data = { "MapUrl": map_url, "CachedAQI": zip_code_data.copy(), - "PartitionKey": "ReportingArea:{}|{}".format(zip_code_data[parameter_name]["ReportingArea"], - zip_code_data[parameter_name]["StateCode"]), + "PartitionKey": "ReportingArea:{reporting_area}|{status_code}".format( + reporting_area=zip_code_data[parameter_name]["ReportingArea"], + status_code=zip_code_data[parameter_name]["StateCode"]), "LastUpdated": utc_dt.isoformat() } db_reporting_area_write = table.put_item( Item=data ) - logger.info("DynamoDB ReportingArea write response: {}".format(db_reporting_area_write)) + logger.info(f"DynamoDB ReportingArea write response: {db_reporting_area_write}") except requests.exceptions.ConnectionError as e: # We don't retry these as they're expensive and infrequent, and # once we have the URL for the ReportingArea map, it doesn't expire @@ -273,6 +279,6 @@ def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt): data = db_reporting_area_read["Item"] - logger.info("Response data: {}".format(data)) + logger.info(f"Response data: {data}") return data diff --git a/lambdas/inbound_POST/lambda_function.py b/lambdas/inbound_POST/lambda_function.py index 8411bde..e673ff4 100644 --- a/lambdas/inbound_POST/lambda_function.py +++ b/lambdas/inbound_POST/lambda_function.py @@ -31,10 +31,10 @@ @conditional_decorator(datadog_lambda_wrapper, not os.environ.get("FLASK_APP", None)) def lambda_handler(event, context): - logger.info("Event: {}".format(event)) + logger.info(f"Event: {event}") query_string = event["params"]["querystring"] - logger.info("Query String: {}".format(query_string)) + logger.info(f"Query String: {query_string}") metricutils.increment("inbound_POST.request") @@ -42,7 +42,7 @@ def lambda_handler(event, context): phone_number = data["From"][0] body = data["Body"][0] - logger.info("Received \"{}\" from {}".format(body, phone_number)) + logger.info(f"Received \"{body}\" from {phone_number}") zip_code = body.lower().strip() include_map = "map" in zip_code @@ -61,8 +61,8 @@ def lambda_handler(event, context): zip_code = zip_code.split("map")[0].strip() try: - response = requests.get( - "{}/aqi?zipCode={}".format(AIR_QUALITY_API_URL, zip_code, timeout=_AIR_QUALITY_API_TIMEOUT)).json() + response = requests.get(f"{AIR_QUALITY_API_URL}/aqi?zipCode={zip_code}", + timeout=_AIR_QUALITY_API_TIMEOUT).json() except requests.exceptions.RequestException as e: metricutils.increment("inbound_POST.error.aqi-request-failed") logger.error(e) @@ -71,7 +71,7 @@ def lambda_handler(event, context): "errorMessage": "Oops, an unknown error occurred. AirNow may be overloaded at the moment." } - logger.info("Response from `/aqi`: {}".format(response)) + logger.info(f"Response from `/aqi`: {response}") if "errorMessage" in response: return _get_response(response["errorMessage"]) @@ -95,19 +95,17 @@ def lambda_handler(event, context): response[parameter_name]["HourObserved"] time = str(int(12 if time == "00" else time)) + suffix + " " + response[parameter_name]["LocalTimeZone"] - msg = "{} AQI of {} {} for {} at {}. {}\nSource: AirNow".format( - response[parameter_name]["Category"]["Name"], - int(response[parameter_name]["AQI"]), - parameter_name, - response[parameter_name]["ReportingArea"], time, - _AQI_MESSAGES[ - response[parameter_name]["Category"][ - "Name"]]) + msg = "{category_name} AQI of {aqi} {param_name} for {reporting_area} at {time}. {category_name}\nSource: AirNow".format( + category_name=response[parameter_name]["Category"]["Name"], + aqi=int(response[parameter_name]["AQI"]), + param_name=parameter_name, + reporting_area=response[parameter_name]["ReportingArea"], + time=time) media = None if include_map: # if "MapUrl" in response[parameter_name]: - media = "https://gispub.epa.gov/airnow/images/current-pm-ozone.jpg" #response[parameter_name]["MapUrl"] + media = "https://gispub.epa.gov/airnow/images/current-pm-ozone.jpg" # response[parameter_name]["MapUrl"] # else: # metricutils.increment("inbound_POST.warn.map-request-failed") # logger.info("Map requested but not included, no MapUrl provided from AirNow") @@ -118,10 +116,9 @@ def lambda_handler(event, context): def _get_response(msg, media=None): media_block = "" if media is not None: - media_block = "