From 3da80e8503985bacb121ce77f27dd7cdb0341623 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 7 Apr 2026 18:19:00 -0700 Subject: [PATCH 1/5] mocked out api testing --- src/api.py | 1 - tests/test_api.py | 233 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 198 insertions(+), 36 deletions(-) diff --git a/src/api.py b/src/api.py index 23fd98d..fc63f38 100644 --- a/src/api.py +++ b/src/api.py @@ -168,7 +168,6 @@ def get_uv_history( # Attempt to fetch the UV index data from the API try: responses = openmeteo.weather_api(url, params=params) - except ValueError: return "No data" diff --git a/tests/test_api.py b/tests/test_api.py index 400f3bf..04f839b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,12 +6,17 @@ import logging from http import HTTPStatus -from unittest.mock import Mock, patch +from unittest.mock import Mock, patch, MagicMock +import numpy as np import pytest from openmeteo_requests.Client import OpenMeteoRequestsError +from src import api + from src.api import ( + _forecast_cache, + _ocean_history_cache, default_location, forecast, gather_data, @@ -57,43 +62,138 @@ def test_default_location_mocked( # Assert: Verify 'requests.get' is called with correct arguments mock_requests.assert_called_once_with("https://ipinfo.io/json", timeout=10) +""" +return [ + location.latitude, + location.longitude, + location.raw["name"], + ] +""" + +@patch("src.api.Nominatim") +def test_get_coordinates(mock_nominatim): + # setup the fake location objects geocode returns + mock_location = MagicMock() + mock_location.latitude = 32.41 + mock_location.longitude = -84.92 + mock_location.raw = {"name": "santa_cruz"} + + # Nominatim() returns an instance, .geocode() returns mock_location + mock_nominatim.return_value.geocode.return_value = mock_location + + get_coordinates.cache_clear() + result = get_coordinates(("loc=santa_cruz",)) + + assert result == [32.41, -84.92, "santa_cruz"] + assert isinstance(result[0], (int, float)) + assert isinstance(result[1], (int, float)) + assert isinstance(result[2], str) + + +@patch("src.api._create_openmeteo_client") +def test_get_uv(mock_create_client): + mock_variable = MagicMock() + mock_variable.Value.return_value = 5.0 # real number so round() works + + mock_current = MagicMock() + mock_current.Variables.return_value = mock_variable # .Variables(0) → mock_variable + + mock_response = MagicMock() + mock_response.Current.return_value = mock_current + + mock_client = MagicMock() + mock_client.weather_api.return_value = [mock_response] + mock_create_client.return_value = mock_client + + result = get_uv(31.41, -84.92, 2, "imperial") + + assert result == 5.0 + assert isinstance(result, float) + -def test_get_coordinates(): - coordinates = get_coordinates(tuple(["loc=santa_cruz"])) - lat = coordinates[0] - long = coordinates[1] - assert isinstance(lat, (int, float)) - assert isinstance(long, (int, float)) -def test_get_uv(): - uv = get_uv(37, 122, 2) - assert isinstance(uv, (int, float)) +@patch("src.api._create_openmeteo_client") +def test_ocean_information(mock_create_client): + # fake each variable (height, direction, period) + mock_var_0 = MagicMock() + mock_var_0.Value.return_value = 3.5 + mock_var_1 = MagicMock() + mock_var_1.Value.return_value = 180.0 -def test_ocean_information(): - ocean = ocean_information(37, 122, 2) - assert isinstance(ocean[0], (int, float)) - assert isinstance(ocean[1], (int, float)) - assert isinstance(ocean[2], (int, float)) + mock_var_2 = MagicMock() + mock_var_2.Value.return_value = 12.0 + # Variables(0), Variables(1), Variables(2) return different mocks + mock_current = MagicMock() + mock_current.Variables.side_effect = [mock_var_0, mock_var_1, mock_var_2] -def test_forecast(): + mock_response = MagicMock() + mock_response.Current.return_value = mock_current + + mock_client = MagicMock() + mock_client.weather_api.return_value = [mock_response] + mock_create_client.return_value = mock_client + + result = ocean_information(31.41, -84.92, 2, "imperial") + + assert result == [3.5, 180.0, 12.0] + + +@patch("src.api._create_openmeteo_client") +def test_forecast(mock_create_client): """ Test forecast() at an arbitrary location(palm beach), ensures it returns 7 days of heights/directions/periods """ - len_of_forecast_list = 7 + fake_array = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]) + + mock_marine_var = MagicMock() + mock_marine_var.ValuesAsNumpy.return_value = fake_array + mock_marine_daily = MagicMock() + mock_marine_daily.Variables.return_value = mock_marine_var + mock_marine_daily.Time.return_value = 1700000000 + mock_marine_daily.TimeEnd.return_value = 1700604800 + mock_marine_daily.Interval.return_value = 86400 + mock_marine_response = MagicMock() + mock_marine_response.Daily.return_value = mock_marine_daily + + mock_general_var = MagicMock() + mock_general_var.ValuesAsNumpy.return_value = fake_array + mock_general_daily = MagicMock() + mock_general_daily.Variables.return_value = mock_general_var + mock_general_response = MagicMock() + mock_general_response.Daily.return_value = mock_general_daily + + mock_client = MagicMock() + mock_client.weather_api.side_effect = [ + [mock_marine_response], + [mock_general_response], + ] + mock_create_client.return_value = mock_client + + _forecast_cache.clear() fc = forecast(26.705, -80.036, 1, 7) - heights = fc["wave_height_max"] - directions = fc["wave_direction_dominant"] - periods = fc["wave_period_max"] - assert len(heights) == len_of_forecast_list - assert len(directions) == len_of_forecast_list - assert len(periods) == len_of_forecast_list - -def test_gather_data(): + assert len(fc["wave_height_max"]) == 7 + assert len(fc["wave_direction_dominant"]) == 7 + assert len(fc["wave_period_max"]) == 7 + + +@patch("src.api.ocean_information", return_value=[3.5, 180.0, 12.0]) +@patch("src.api.get_uv", return_value=5.0) +@patch("src.api.get_hourly_forecast", return_value={"cloud_cover": 50.0, "visibility": 10.0}) +@patch("src.api.current_wind_temp", return_value=[70.0, 10.0, 180.0]) +@patch("src.api.get_rain", return_value=(0.1, 30.0)) +@patch("src.api.forecast", return_value={}) +@patch("src.api.ocean_information_history", return_value=["3.5", "180.0", "12.0"]) +@patch("src.api.get_uv_history", return_value="5.0") +@patch("src.helper.forecast_to_json", return_value={}) +def test_gather_data( + mock_ftj, mock_uv_hist, mock_ocean_hist, mock_fc, + mock_rain, mock_wind, mock_hourly, mock_uv, mock_ocean, +): """ Gather data needs the arguments dictionary as input, so we will get this by calling arguments_dictionary() @@ -108,10 +208,18 @@ def test_gather_data(): assert ocean_data_dict["Long"] == long -def test_seperate_args_and_get_location(): +@patch("src.api.Nominatim") +def test_seperate_args_and_get_location(mock_nominatim): """ - Test with an abritrary location + Test with an arbitrary location """ + mock_location = MagicMock() + mock_location.latitude = 36.97 + mock_location.longitude = -121.98 + mock_location.raw = {"name": "Pleasure Point"} + mock_nominatim.return_value.geocode.return_value = mock_location + + get_coordinates.cache_clear() location = ["location=pleasure_point_california"] location_data = seperate_args_and_get_location(location) lat = location_data["lat"] @@ -122,72 +230,121 @@ def test_seperate_args_and_get_location(): assert "Pleasure Point" in str(city) -def test_get_uv_history_basic_functionality(): +@patch("src.api._create_openmeteo_client") +def test_get_uv_history_basic_functionality(mock_create_client): """ Test the basic functionality of the get_uv_history function. This test verifies that the function returns a string when provided with valid latitude and longitude coordinates. """ - uv = get_uv_history(31.9505, 115.8605, 2) # Perth coordinates + fake_uv = np.array([3.5] * 24) + mock_hourly = MagicMock() + mock_hourly.Variables.return_value.ValuesAsNumpy.return_value = fake_uv + mock_response = MagicMock() + mock_response.Hourly.return_value = mock_hourly + mock_client = MagicMock() + mock_client.weather_api.return_value = [mock_response] + mock_create_client.return_value = mock_client + + uv_history_cache.clear() + uv = get_uv_history(31.9505, 115.8605, 2) assert isinstance(uv, str) -def test_get_uv_history_invalid_coordinates(): +@patch("src.api._create_openmeteo_client") +def test_get_uv_history_invalid_coordinates(mock_create_client): """ Test get_uv_history with invalid coordinates. This test checks that the function raises an OpenMeteoRequestsError when provided with latitude and longitude values that are out of range. """ + mock_client = MagicMock() + mock_client.weather_api.side_effect = OpenMeteoRequestsError("out of range") + mock_create_client.return_value = mock_client + + uv_history_cache.clear() with pytest.raises(OpenMeteoRequestsError): get_uv_history(1000, -2000, 2) +@patch("src.api._create_openmeteo_client") @patch("src.api.testing", new=0) # Set testing variable to 0 -def test_get_uv_history_api_response(): +def test_get_uv_history_api_response(mock_create_client): """ Test how get_uv_history handles API response. This test verifies that the function returns the expected values when called with valid coordinates while patching the API call request. """ + mock_create_client.return_value = MagicMock() + + uv_history_cache.clear() result = get_uv_history(31.9505, 115.8605, 1) expected_result = "0.6" assert result == expected_result -def test_ocean_information_history_basic_functionality(): +@patch("src.api._create_openmeteo_client") +def test_ocean_information_history_basic_functionality(mock_create_client): """ Test the basic functionality of the ocean_information_history function. This test checks that the function returns actual values for the wave data points when provided with valid coordinates. """ + fake_array = np.array([1.5] * 24) + mock_hourly = MagicMock() + mock_hourly.Variables.return_value.ValuesAsNumpy.return_value = fake_array + mock_response = MagicMock() + mock_response.Hourly.return_value = mock_hourly + mock_client = MagicMock() + mock_client.weather_api.return_value = [mock_response] + mock_create_client.return_value = mock_client + + _ocean_history_cache.clear() waves = ocean_information_history(31.9505, 115.8605, 2) assert waves[0] is not None assert waves[1] is not None assert waves[2] is not None -def test_ocean_information_history_invalid_coordinates(): +@patch("src.api._create_openmeteo_client") +def test_ocean_information_history_invalid_coordinates(mock_create_client): """ Test ocean_information_history with invalid coordinates. This test ensures that the function raises an OpenMeteoRequestsError when provided with latitude and longitude values that are out of range. """ + mock_client = MagicMock() + mock_client.weather_api.side_effect = OpenMeteoRequestsError("out of range") + mock_create_client.return_value = mock_client + + _ocean_history_cache.clear() with pytest.raises(OpenMeteoRequestsError): ocean_information_history(1000, -2000, 2) -def test_ocean_information_history_response_format(): +@patch("src.api._create_openmeteo_client") +def test_ocean_information_history_response_format(mock_create_client): """ Test the response format of ocean_information_history. This test verifies that the function returns a list with a specific number of elements when called with valid coordinates. """ + fake_array = np.array([2.0] * 24) + mock_hourly = MagicMock() + mock_hourly.Variables.return_value.ValuesAsNumpy.return_value = fake_array + mock_response = MagicMock() + mock_response.Hourly.return_value = mock_hourly + mock_client = MagicMock() + mock_client.weather_api.return_value = [mock_response] + mock_create_client.return_value = mock_client + + _ocean_history_cache.clear() waves = ocean_information_history(31.9505, 115.8605, 2) expected_wave_count = 3 assert isinstance(waves, list) @@ -195,14 +352,18 @@ def test_ocean_information_history_response_format(): assert len(waves) == expected_wave_count +@patch("src.api._create_openmeteo_client") @patch("src.api.testing", new=0) # Set testing variable to 0 -def test_ocean_information_history(): +def test_ocean_information_history(mock_create_client): """ Test how ocean_information_history handles API response. This test verifies that the function returns the expected values when called with valid coordinates while patching the API call request. """ + mock_create_client.return_value = MagicMock() + + _ocean_history_cache.clear() result = ocean_information_history(31.9505, 115.8605, 1) expected_result = ["0.6", "0.6", "0.6"] assert result == expected_result @@ -218,6 +379,7 @@ def test_get_coordinates_no_args_falls_back_to_default(mocker): mocker.patch( "src.api.default_location", return_value=[0.0, 0.0, "Default City"] ) + get_coordinates.cache_clear() result = get_coordinates(()) assert result == [0.0, 0.0, "Default City"] @@ -232,6 +394,7 @@ def test_get_coordinates_invalid_location_falls_back_to_default( "src.api.default_location", return_value=[0.0, 0.0, "Default City"] ) + get_coordinates.cache_clear() with caplog.at_level(logging.WARNING, logger="src.api"): result = get_coordinates(tuple(["location=nowhere_xyz_invalid"])) From 4251e66c1ebe8f37a9daa329181627b38221e693 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 7 Apr 2026 18:28:59 -0700 Subject: [PATCH 2/5] linter fix --- src/api.py | 12 ++++----- tests/test_api.py | 67 +++++++++++++++++++++++++++++------------------ 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/api.py b/src/api.py index fc63f38..ba2c853 100644 --- a/src/api.py +++ b/src/api.py @@ -28,11 +28,11 @@ _ocean_cache = TTLCache(maxsize=300, ttl=_TTL) _uv_cache = TTLCache(maxsize=300, ttl=_TTL) uv_history_cache = TTLCache(maxsize=300, ttl=_TTL) -_ocean_history_cache = TTLCache(maxsize=300, ttl=_TTL) +ocean_history_cache = TTLCache(maxsize=300, ttl=_TTL) _wind_temp_cache = TTLCache(maxsize=300, ttl=_TTL) _rain_cache = TTLCache(maxsize=300, ttl=_TTL) -_forecast_cache = TTLCache(maxsize=300, ttl=_TTL) -_hourly_forecast_cache = TTLCache(maxsize=300, ttl=_TTL) +forecast_cache = TTLCache(maxsize=300, ttl=_TTL) +_hourlyforecast_cache = TTLCache(maxsize=300, ttl=_TTL) _ocean_lock = Lock() @@ -225,7 +225,7 @@ def ocean_information( return [current_wave_height, current_wave_direction, current_wave_period] -@cached(_ocean_history_cache, lock=_ocean_lock) +@cached(ocean_history_cache, lock=_ocean_lock) def ocean_information_history( lat: float, long: float, decimal: int, unit: str = "imperial" ) -> list | str: @@ -366,7 +366,7 @@ def get_rain(lat: float, long: float) -> tuple[float, float]: ) -@cached(_forecast_cache, lock=_ocean_lock) +@cached(forecast_cache, lock=_ocean_lock) def forecast(lat: float, long: float, decimal: int, days: int = 0) -> dict: """ Number of forecast days. Max is 7 @@ -465,7 +465,7 @@ def forecast(lat: float, long: float, decimal: int, days: int = 0) -> dict: return forecast_data -@cached(_hourly_forecast_cache, lock=_ocean_lock) +@cached(_hourlyforecast_cache, lock=_ocean_lock) def get_hourly_forecast( lat: float, long: float, days: int = 1, unit: str = "fahrenheit" ) -> dict: diff --git a/tests/test_api.py b/tests/test_api.py index 04f839b..27ce305 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,23 +6,21 @@ import logging from http import HTTPStatus -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import MagicMock, Mock, patch import numpy as np import pytest from openmeteo_requests.Client import OpenMeteoRequestsError -from src import api - from src.api import ( - _forecast_cache, - _ocean_history_cache, default_location, forecast, + forecast_cache, gather_data, get_coordinates, get_uv, get_uv_history, + ocean_history_cache, ocean_information, ocean_information_history, seperate_args_and_get_location, @@ -62,6 +60,7 @@ def test_default_location_mocked( # Assert: Verify 'requests.get' is called with correct arguments mock_requests.assert_called_once_with("https://ipinfo.io/json", timeout=10) + """ return [ location.latitude, @@ -70,6 +69,7 @@ def test_default_location_mocked( ] """ + @patch("src.api.Nominatim") def test_get_coordinates(mock_nominatim): # setup the fake location objects geocode returns @@ -92,11 +92,12 @@ def test_get_coordinates(mock_nominatim): @patch("src.api._create_openmeteo_client") def test_get_uv(mock_create_client): + UV_INDEX = 5.0 mock_variable = MagicMock() - mock_variable.Value.return_value = 5.0 # real number so round() works + mock_variable.Value.return_value = UV_INDEX # real number so round() works mock_current = MagicMock() - mock_current.Variables.return_value = mock_variable # .Variables(0) → mock_variable + mock_current.Variables.return_value = mock_variable mock_response = MagicMock() mock_response.Current.return_value = mock_current @@ -107,12 +108,10 @@ def test_get_uv(mock_create_client): result = get_uv(31.41, -84.92, 2, "imperial") - assert result == 5.0 + assert result == UV_INDEX assert isinstance(result, float) - - @patch("src.api._create_openmeteo_client") def test_ocean_information(mock_create_client): # fake each variable (height, direction, period) @@ -173,26 +172,40 @@ def test_forecast(mock_create_client): ] mock_create_client.return_value = mock_client - _forecast_cache.clear() + forecast_cache.clear() fc = forecast(26.705, -80.036, 1, 7) - assert len(fc["wave_height_max"]) == 7 - assert len(fc["wave_direction_dominant"]) == 7 - assert len(fc["wave_period_max"]) == 7 + FORECAST_LENGTH = 7 + + assert len(fc["wave_height_max"]) == FORECAST_LENGTH + assert len(fc["wave_direction_dominant"]) == FORECAST_LENGTH + assert len(fc["wave_period_max"]) == FORECAST_LENGTH @patch("src.api.ocean_information", return_value=[3.5, 180.0, 12.0]) @patch("src.api.get_uv", return_value=5.0) -@patch("src.api.get_hourly_forecast", return_value={"cloud_cover": 50.0, "visibility": 10.0}) +@patch( + "src.api.get_hourly_forecast", + return_value={"cloud_cover": 50.0, "visibility": 10.0}, +) @patch("src.api.current_wind_temp", return_value=[70.0, 10.0, 180.0]) @patch("src.api.get_rain", return_value=(0.1, 30.0)) @patch("src.api.forecast", return_value={}) -@patch("src.api.ocean_information_history", return_value=["3.5", "180.0", "12.0"]) +@patch( + "src.api.ocean_information_history", return_value=["3.5", "180.0", "12.0"] +) @patch("src.api.get_uv_history", return_value="5.0") @patch("src.helper.forecast_to_json", return_value={}) -def test_gather_data( - mock_ftj, mock_uv_hist, mock_ocean_hist, mock_fc, - mock_rain, mock_wind, mock_hourly, mock_uv, mock_ocean, +def test_gather_data( # noqa: PLR0913, PLR0917 + mock_ftj, + mock_uv_hist, + mock_ocean_hist, + mock_fc, + mock_rain, + mock_wind, + mock_hourly, + mock_uv, + mock_ocean, ): """ Gather data needs the arguments dictionary as input, @@ -261,7 +274,9 @@ def test_get_uv_history_invalid_coordinates(mock_create_client): when provided with latitude and longitude values that are out of range. """ mock_client = MagicMock() - mock_client.weather_api.side_effect = OpenMeteoRequestsError("out of range") + mock_client.weather_api.side_effect = OpenMeteoRequestsError( + "out of range" + ) # noqa: E501 mock_create_client.return_value = mock_client uv_history_cache.clear() @@ -303,7 +318,7 @@ def test_ocean_information_history_basic_functionality(mock_create_client): mock_client.weather_api.return_value = [mock_response] mock_create_client.return_value = mock_client - _ocean_history_cache.clear() + ocean_history_cache.clear() waves = ocean_information_history(31.9505, 115.8605, 2) assert waves[0] is not None assert waves[1] is not None @@ -319,10 +334,12 @@ def test_ocean_information_history_invalid_coordinates(mock_create_client): when provided with latitude and longitude values that are out of range. """ mock_client = MagicMock() - mock_client.weather_api.side_effect = OpenMeteoRequestsError("out of range") + mock_client.weather_api.side_effect = OpenMeteoRequestsError( + "out of range" + ) # noqa: E501 mock_create_client.return_value = mock_client - _ocean_history_cache.clear() + ocean_history_cache.clear() with pytest.raises(OpenMeteoRequestsError): ocean_information_history(1000, -2000, 2) @@ -344,7 +361,7 @@ def test_ocean_information_history_response_format(mock_create_client): mock_client.weather_api.return_value = [mock_response] mock_create_client.return_value = mock_client - _ocean_history_cache.clear() + ocean_history_cache.clear() waves = ocean_information_history(31.9505, 115.8605, 2) expected_wave_count = 3 assert isinstance(waves, list) @@ -363,7 +380,7 @@ def test_ocean_information_history(mock_create_client): """ mock_create_client.return_value = MagicMock() - _ocean_history_cache.clear() + ocean_history_cache.clear() result = ocean_information_history(31.9505, 115.8605, 1) expected_result = ["0.6", "0.6", "0.6"] assert result == expected_result From cd83ce507c891bffb10e14ef3e1b384ac49b8c1a Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 7 Apr 2026 19:05:35 -0700 Subject: [PATCH 3/5] readme cleanup --- README.md | 325 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 194 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 2817b22..9a9496b 100644 --- a/README.md +++ b/README.md @@ -7,20 +7,38 @@

+**cli-surf** is a real-time ocean data and surf forecasting tool for the command line. +- Live wave height, swell direction, period, UV index, wind, and more +- Forecasts up to 7 days out +- Use as a CLI tool (`surf`) or query via HTTP API / browser +- Optional GPT-powered surf reports +- Supports metric and imperial units, custom colors, and JSON output -Surfs up! - -cli-surf is a real time ocean data and forecasting service used in the command line. - -Inspired by [wttr.in](https://github.com/chubin/wttr.in) - -[Documentation](https://ryansurf.github.io/cli-surf/) | [Discord](https://discord.gg/He2UpxRuJP) +Inspired by [wttr.in](https://github.com/chubin/wttr.in) · [Documentation](https://ryansurf.github.io/cli-surf/) · [Discord](https://discord.gg/He2UpxRuJP)

cli-surf gif

+--- + +## Table of Contents + +- [Usage](#-usage) +- [Setup](#️-setup) + - [Poetry](#how-to-start-locally-with-poetry) + - [Docker](#how-to-start-with-docker) + - [Environment Variables](#variables) + - [Email Server](#email-server) + - [MongoDB](#mongodb) +- [GPT Surf Report](#-gpt-surf-report) +- [Tech Stack](#-tech-stack) +- [Contributing](#-contributing) +- [Contributors](#-contributors) + +--- + ## 💻 Usage There are two ways to use cli-surf: install it as a CLI tool via pipx, or run the server and access it via API/browser. @@ -49,7 +67,7 @@ Location: San Diego .-``'. .` .` _.-' '._ - + UV index: 6.4 Wave Height: 3.9 Wave Direction: 238.0 @@ -57,55 +75,79 @@ Wave Period: 9.8 ``` -**API Arguments** -| Argument | Description| -| -------- | ------- | -| location / loc | Specify the location of your forecast. Ex: `location=new_york_city` **or** `location=nyc`. | -| forecast / fc | Number of forecast days. Max = 7, default = 0 | -| hide_wave / hw | Hide the default wave art | -| show_large_wave / slw | Show the large wave art | -| show_air_temp / sat | Show the air temp | -| show_wind_speed / sws | Show the wind speed | -| show_wind_direction / swd | Show the wind direction | -| show_rain_sum / srs | Show the rain sum | -| show_precipitation_prob / spp | Show the max precipitation chance | -| hide_uv / huv | Hide uv index | -| show_past_uv / spuv | Show past uv index | -| hide_past_uv | Hide past uv index | -| show_height_history / shh | Show past wave height index | -| hide_height_history | Hide past wave height index | -| show_direction_history / sdh | Show past wave direction index | -| hide_direction_history | Hide past wave direction index | -| show_period_history / sph | Show past wave period index | -| hide_period_history | Hide past wave period index | -| hide_height / hh | Hide surf height | -| hide_direction / hdir | Hide Swell direction | -| hide_period / hp | Hide swell period | -| hide_location / hl | Hide location | -| hide_date / hdate | Hide date in forecast | -| metric / m | Numbers in Metric units. Defaults to Imperial | -| decimal / dec | Specify decimal points in output | -| color / c | Choose color of wave art. Ex: `color=light_blue` | -| json / j | Output the data in JSON format. Must be the only argument | -| gpt / g | Activates the GPT surf report. Change the `GPT_PROMPT` variable in `.env` to customize responses. Default = off | -| show_cloud_cover / scc | Show the hourly cloud cover | -| show_visibility / sv | Show the hourly visibility | - **API Examples** -* Arguments are separated by commas. -* `curl localhost:8000` -* `curl localhost:8000?location=new_york,hide_height,hide_wave,show_large_wave` -* `curl localhost:8000?fc=3,hdate,loc=trestles` -* `curl localhost:8000?show_past_uv,show_height_history,show_direction_history,show_period_history` +> Arguments are separated by commas. + +```bash +curl localhost:8000 +curl localhost:8000?location=new_york,hide_height,hide_wave,show_large_wave +curl localhost:8000?fc=3,hdate,loc=trestles,c=light_blue +curl localhost:8000?show_past_uv,show_height_history,show_direction_history,show_period_history +curl localhost:8000?loc=malibu,gpt,color=yellow +curl localhost:8000?loc=nazare,json +``` -**For detailed information you can access the [help](https://github.com/ryansurf/cli-surf/blob/main/help.txt) page** +For the full argument reference, see below or run: +```bash +curl localhost:8000/help +``` -* `curl localhost:8000/help` +**API Arguments** +*Display* + +| Argument | Shorthand | Description | +|---|---|---| +| `location` | `loc` | Location for the forecast. Ex: `loc=new_york_city` or `loc=nyc` | +| `hide_wave` | `hw` | Hide the default wave art | +| `show_large_wave` | `slw` | Show the large wave art | +| `color` | `c` | Color of wave art. Ex: `color=light_blue` | +| `hide_location` | `hl` | Hide location name | +| `hide_date` | `hdate` | Hide date in forecast | +| `metric` | `m` | Use metric units (default: imperial) | +| `decimal` | `dec` | Specify decimal places in output | +| `json` | `j` | Output data as JSON. Must be the only argument | + +*Surf Conditions* + +| Argument | Shorthand | Description | +|---|---|---| +| `hide_height` | `hh` | Hide wave height | +| `hide_direction` | `hdir` | Hide swell direction | +| `hide_period` | `hp` | Hide swell period | +| `hide_uv` | `huv` | Hide UV index | +| `show_air_temp` | `sat` | Show air temperature | +| `show_wind_speed` | `sws` | Show wind speed | +| `show_wind_direction` | `swd` | Show wind direction | +| `show_rain_sum` | `srs` | Show rain sum | +| `show_precipitation_prob` | `spp` | Show max precipitation chance | +| `show_cloud_cover` | `scc` | Show hourly cloud cover | +| `show_visibility` | `sv` | Show hourly visibility | + +*Historical Data* + +| Argument | Shorthand | Description | +|---|---|---| +| `show_past_uv` | `spuv` | Show past UV index | +| `hide_past_uv` | — | Hide past UV index | +| `show_height_history` | `shh` | Show past wave height | +| `hide_height_history` | — | Hide past wave height | +| `show_direction_history` | `sdh` | Show past wave direction | +| `hide_direction_history` | — | Hide past wave direction | +| `show_period_history` | `sph` | Show past wave period | +| `hide_period_history` | — | Hide past wave period | + +*GPT* + +| Argument | Shorthand | Description | +|---|---|---| +| `gpt` | `g` | Activate GPT surf report. Customize via `GPT_PROMPT` in `.env`. Default: off | + +--- ## 🛠️ Setup + ### How to Start Locally with `Poetry` -To use cli-surf, clone the project locally and install the necessary dependencies via `poetry`. 1. Install [Poetry](https://python-poetry.org/docs/#installation). @@ -115,166 +157,186 @@ To use cli-surf, clone the project locally and install the necessary dependencie cd cli-surf ``` -3. Install dependencies and Activate the virtual environment. +3. Install dependencies and activate the virtual environment. ```bash make install ``` -4. Run the project. For example, if the entry point is `server.py`, use the following command. +4. Start the server. ```bash poetry run python src/server.py - # Alternatively, you can run the project using `Makefile` + # Or via Makefile make run ``` ### How to Start with `Docker` -If you do not have Poetry installed or do not want to pollute your local environment, you can also start the project using Docker Compose. -1. Install [Docker](https://docs.docker.com/engine/install/). -2. Install [Docker Compose](https://docs.docker.com/compose/install/). +1. Install [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/). -3. Clone the repository. +2. Clone the repository. ```bash git clone https://github.com/ryansurf/cli-surf.git cd cli-surf ``` -4. Docker compose up. +3. Start the container. ```bash docker compose up -d - # Alternatively, you can run the project using `Makefile` + # Or via Makefile make run_docker ``` - ### Variables -When running locally with Poetry, create a `.env` file from the `.env.example` file. +When running locally with Poetry, create a `.env` file from the example: ```bash cp .env.example .env ``` -Note that when starting with Docker, the `.env` file will be automatically created from `.env.example` during the image build. +When starting with Docker, the `.env` file is created automatically from `.env.example` during the image build. + +**General** + +| Variable | Description | +|---|---| +| `PORT` | Port to run the application on. Default: `8000` | +| `IP_ADDRESS` | IP address the server runs on. Default: `localhost` | + +**Email** *(optional — see [Email Server](#email-server))* + +| Variable | Description | +|---|---| +| `SMTP_SERVER` | Email server. Default: `smtp.gmail.com` | +| `SMTP_PORT` | Email server port. Default: `587` | +| `EMAIL` | Address to send the report from | +| `EMAIL_PW` | Sending email's password | +| `EMAIL_RECEIVER` | Address to receive the report | +| `COMMAND` | Command shown in the email. Default: `localhost:8000` | +| `SUBJECT` | Email subject line. Default: `Surf Report` | +**GPT** *(optional — see [GPT Surf Report](#-gpt-surf-report))* -| Variable | Description| -| -------- | ------- | -| `PORT` | The port you want to open to run the application. Default = `8000` | -| `IP_ADDRESS` | The ip your server is running on. Default = `localhost` | -| `SMTP_SERVER` | The email server you are using. Default = smtp.gmail.com | -| `SMTP_PORT` | The email server port you are using. Default = `587` | -| `EMAIL` | The email you will send the report from. | -| `EMAIL_PW` | The sending email's password | -| `EMAIL_RECEIVER` | The email that will receive the report (your personal email) | -| `COMMAND` | The command that will be ran and shown in the email. Default = `localhost:8000` | -| `SUBJECT` | The email's subject. Default = Surf Report | -| `GPT_PROMPT` | Given the surf data (height, swell direction, etc.), you can tell the GPT what kind of report you would like. For example: `With this data, recommend what size board I should ride and nearby surf spots that may be better with the given conditions.` | -| `API_KEY` | Your OpenAI API key. Optional, the default GPT does not need an API key (and has slighly worse performance). Create one [here](https://platform.openai.com/api-keys) | -| `GPT_MODEL` | The OpenAI GPT model. Default = `gpt-3.5-turbo` (if possible, using `gpt-4o` is recommended.) Explore other models [here](https://platform.openai.com/docs/overview)| -| `DB_URI` | MongoDB URI | +| Variable | Description | +|---|---| +| `GPT_PROMPT` | Prompt sent to the model along with surf data. Ex: `With this data, recommend what size board I should ride.` | +| `API_KEY` | OpenAI API key. Create one [here](https://platform.openai.com/api-keys) | +| `GPT_MODEL` | OpenAI model to use. Default: `gpt-3.5-turbo` (recommended: `gpt-4o`). See all models [here](https://platform.openai.com/docs/overview) | +**Database** *(optional — see [MongoDB](#mongodb))* + +| Variable | Description | +|---|---| +| `DB_URI` | MongoDB connection URI | ### Email Server -Optional, sends a surf report to a specified email. +Optional — sends a surf report to a specified email on a schedule. + +You'll need an email account with SMTP access. Gmail works; follow Method #1 [here](https://www.cubebackup.com/blog/how-to-use-google-smtp-service-to-send-emails-for-free/), then update the email variables in `.env`. -You will need to setup an email account that is able to utilize SMTP services. Gmail can be used, following Method #1 outlined [here](https://www.cubebackup.com/blog/how-to-use-google-smtp-service-to-send-emails-for-free/). After doing this, change the variables in `.env` +> Note: The FastAPI server must be running to send emails. -The Email Server can be executed using one of the following methods. ```bash -# Send Email locally using Poetry +# Send email locally (Poetry) make send_email -# Send Email in a Docker container +# Send email via Docker make send_email_docker ``` -Note that the Flask server must be running in order to send emails. ### MongoDB -Optional, stores all request output into a MongoDB database. +Optional — stores all request output in a MongoDB database. -See [get started](https://www.mongodb.com/docs/get-started/?language=python) to learn how to set this up! +See the [MongoDB get started guide](https://www.mongodb.com/docs/get-started/?language=python) for setup, then set `DB_URI` in your `.env`. ### Frontend +
+Note: The frontend is no longer maintained +

cli-surf_website gif

-Although this application was made with the cli in mind, there is a frontend. +Although this application was made with the CLI in mind, a frontend exists. **Streamlit Frontend** -[Streamlit](https://streamlit.io/) is used! - -To run streamlit: `streamlit run src/dev_streamlit.py` - -You will be able to find the frontend here: `http://localhost:8502` - -**HTML/JS/CSS Frontend** - -> [!NOTE] -> Streamlit is now the main focus for the frontend. This legacy frontend is no longer being actively developed. - -`http://localhost:8000/home` **or** `:/home` if the application is running on a different host or you have changed the default port. +```bash +streamlit run src/dev_streamlit.py +# Available at http://localhost:8502 +``` -You may need to change `IP_ADDRESS` in `.env` to match the ip of the host running the machine. +**HTML/JS/CSS Frontend** *(legacy, no longer actively developed)* -Now, running `python3 server.py` will launch the website! +Available at `http://localhost:8000/home` or `:/home`. +You may need to set `IP_ADDRESS` in `.env` to match the host's IP. -### 🧠 GPT Surf Report +
-**cli-surf** can generate personalized surf reports using OpenAI's GPT models. This section is for those that choose to not rely on gpt4free as the repo faces pending legal action. +--- -**Enabling GPT Reports** +## 🧠 GPT Surf Report -1. **Obtain an OpenAI API Key**: +cli-surf can generate personalized surf reports using OpenAI's GPT models. - - Sign up at [OpenAI](https://beta.openai.com/signup/). - - Navigate to the API section and create a new API key. - - Make sure to add a payment method. +**Setup** -2. **Update `.env` File**: +1. Get an OpenAI API key at [platform.openai.com](https://platform.openai.com/api-keys). Make sure a payment method is added. +2. Update `.env`: ```bash - GPT_PROMPT=With this data, recommend what size board I should ride and nearby surf spots that may be better with the given conditions. API_KEY=your_openai_api_key_here - GPT_MODEL=gpt-3.5-turbo # Or use gpt-4 for better results + GPT_MODEL=gpt-3.5-turbo # gpt-4o recommended for better results + GPT_PROMPT=With this data, recommend what size board I should ride and nearby surf spots that may be better with the given conditions. ``` -3. **Use the GPT Argument:**: - - Example Usage: - ```bash - curl localhost:8000?location=Malibu,gpt - ``` +3. Use the `gpt` argument: + ```bash + curl localhost:8000?location=Malibu,gpt + ``` + +**Customizing the prompt** + +Change `GPT_PROMPT` in `.env` to get different types of reports: +```bash +GPT_PROMPT="Analyze the surf conditions and suggest the best time of day to surf." +GPT_PROMPT="What are some good places to eat around this surf spot?" +``` + +**Notes** +- A payment method is required — OpenAI will reject requests from free accounts. +- GPT responses consume tokens based on prompt and response size. +- Response time may be slower than standard output, especially during OpenAI outages. +- `gpt-4o` gives better results than `gpt-3.5-turbo` but costs more. -**Customizing the GPT Prompt** -You can tailor the response by changing the GPT_PROMPT in your .env file to get different types of reports. +--- - - Common Examples: - ```bash - GPT_PROMPT="Analyze the surf conditions and suggest the best time of day to surf." - ``` - ```bash - GPT_PROMPT="What are some good places to eat around this surf spot" - ``` +## 🔧 Tech Stack -**Notes on Usage** - - Common Issue: Without a payment method, this feature will not work as OpenAI will deny API requests from these accounts. - - API Costs: Using the GPT feature will consume tokens from your OpenAI account based on the size of your custom prompt and the responses. - - Response Time: Generating GPT responses may take longer than standard outputs, especially if there are outages. - - Model Selection: Using gpt-4 provides better results but may be slower and more expensive than gpt-3.5-turbo. +| Layer | Technology | +|---|---| +| Language | Python 3.10+ | +| Web framework | FastAPI + Uvicorn | +| CLI | Click | +| Weather data | [Open-Meteo API](https://open-meteo.com/) | +| Optional AI | OpenAI GPT API | +| Optional database | MongoDB (pymongo) | +| Optional frontend | Streamlit | +| Packaging | Poetry, pipx | +| Containerization | Docker / Docker Compose | +--- ## 📈 Contributing Thank you for considering contributing to cli-surf! -See [CONTRIBUTING.md](https://github.com/ryansurf/cli-surf/blob/main/CONTRIBUTING.md) to get an idea of how contributions work. +See [CONTRIBUTING.md](https://github.com/ryansurf/cli-surf/blob/main/CONTRIBUTING.md) to get started. Questions? Comments? @@ -282,6 +344,8 @@ Questions? Comments? * [Discussions](https://github.com/ryansurf/cli-surf/discussions) * [GitHub](https://github.com/ryansurf) +--- + ## ✨ Contributors [![All Contributors](https://img.shields.io/github/all-contributors/ryansurf/cli-surf?color=ee8449&style=flat-square)](#contributors) @@ -333,6 +397,5 @@ Questions? Comments? - ## License [![License](https://img.shields.io/:license-mit-blue.svg?style=flat-square)](https://badges.mit-license.org) From b582ab253e2a21ec77ae8064aa7f5b7860c50c7a Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 7 Apr 2026 19:10:02 -0700 Subject: [PATCH 4/5] remove test that makes real api call --- tests/test_helper.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/test_helper.py b/tests/test_helper.py index 58024d8..d7a27d0 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -51,22 +51,6 @@ def test_default_input(): # assert isinstance(json_output["Location"], str) -def test_print_gpt(): - """ - Tests the simple_gpt() - """ - surf_data = { - "Location": "test", - "Height": "test", - "Swell Direction": "test", - "Period": "test", - "Unit": "test", - } - gpt_prompt = "Please output 'gpt works'" - gpt_info = [None, ""] - gpt_response = helper.print_gpt(surf_data, gpt_prompt, gpt_info) - assert "gpt works" in gpt_response - def test_set_output_values_show_past_uv(): args = ["show_past_uv"] From 930594c7c4c7f3eb92d25c145d7b1d1d0dd52ee1 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 7 Apr 2026 19:10:20 -0700 Subject: [PATCH 5/5] lint --- tests/test_helper.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_helper.py b/tests/test_helper.py index d7a27d0..5464cd8 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -51,7 +51,6 @@ def test_default_input(): # assert isinstance(json_output["Location"], str) - def test_set_output_values_show_past_uv(): args = ["show_past_uv"] arguments_dictionary = {}