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)
+---
+
+## 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
+
-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
[](#contributors)
@@ -333,6 +397,5 @@ Questions? Comments?
-
## License
[](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 = {}