Repository: ryansurf/cli-surf

Issue:
help.txt 404 error
Recently, [help.txt](https://github.com/ryansurf/cli-surf/blob/main/help.txt) was updated. When checking the changes in my local, I received this error:

```curl http://127.0.0.1:8000/help
<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
```

It appears the path is wrong for some reason. I think `help.txt` used to live in the `src` directory, but now its in the projects root

Hints from issue comments:
@ryansurf: https://github.com/ryansurf/cli-surf/pull/62

Solution patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f978e55..f02a18c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,7 +25,7 @@ Please set up your development environment by referring to the `Setup` section i
 - The [PEP 8](https://realpython.com/python-pep8/) styling convention is used.
 - This is achieved using the `ruff` Linter and Formatter.
 - The Linter and Formatter are automatically executed before committing via pre-commit.
-  - If you want to run the Linter and Formatter at any time, execute `pre-commit run --all-files`.
+  - If you want to run the Linter and Formatter at any time, execute `pre-commit run --all-files`. Or, `make format` and `make run` can be ran.
 
 ### Testing
 > [!NOTE]
diff --git a/README.md b/README.md
index 70a13db..c8c95ac 100644
--- a/README.md
+++ b/README.md
@@ -162,7 +162,9 @@ Note that the Flask server must be running in order to send emails.
     <img src="images/website.gif" alt="cli-surf_website gif" style="width: 700px; height: auto;">
 </p>
 
-Although this application was made with the cli in mind, there is a frontend.
+Although this application was made with the cli in mind, there are two frontends (experimenting at the moment).
+
+**HTML/JS/CSS Frontend**
 
 `http://localhost:8000/home` **or** `<ip_of_host>:<port>/home` if the application is running on a different host or you have changed the default port.
 
@@ -170,6 +172,14 @@ You may need to change `IP_ADDRESS` in `.env` to match the ip of the host runnin
 
 Now, running `python3 server.py` will launch the website!
 
+**Streamlit Frontend**
+
+[Streamlit](https://streamlit.io/) is also an option that we are experimenting with. 
+
+To run streamlit: `streamlit run src/dev_streamlit.py`
+
+You will be able to find the frontend here: `http://localhost:8502`
+
 
 ## 📈 Contributing
 
diff --git a/docs/cheat_sheet.md b/docs/cheat_sheet.md
index 30f6897..4bda305 100644
--- a/docs/cheat_sheet.md
+++ b/docs/cheat_sheet.md
@@ -18,7 +18,7 @@ When developing, these commands may come in handy:
 |  `poetry add <package-name>` |  Add a new dependency to Poetry |
 | `poetry add --group dev <package-name>`  |  Add a new developer dependency to Poetry |
 | `poetry show`  |  List all available dependencies with descriptions |
-| `poetry run pre-commit run --all-files`  |  Run the Linter & Formatter |
+| `pre-commit run --all-files`  |  Run the Linter & Formatter |
 
 ## [Mkdocs Commands](https://www.mkdocs.org/user-guide/)
 
@@ -30,12 +30,16 @@ When developing, these commands may come in handy:
 
 | Argument    | Description|
 | -------- | ------- |
+| `make install`  |  Install dependencies and activates the virtual environment. |
 | `make run`  |  Runs `server.py` |
 | `make run_docker`  |  Runs `docker compose up -d` |
 | `make test`  |  Runs pytest |
 | `make test_docker`  |  Runs pytest on Docker |
 | `make output_coverage`  |  Outputs the coverage of the tests |
 | `make send_email`  |  Runs `send_email.py` |
+| `make lint`  |  Runs the ruff linter |
+| `make format`  |  Runs the ruff formatter |
+| `make clean`  |  Cleans up files generated during testing (`.coverage`, `pytest_cache`, etc.) |
 
 
 ## [Git](https://education.github.com/git-cheat-sheet-education.pdf)
@@ -51,4 +55,5 @@ When developing, these commands may come in handy:
 | `git checkout -b <branch>`  |  Creates a new branch, `branch`, and switches into it |
 | `git branch -d <branch>`  |  Delete a local branch |
 | `git push -u origin <branch-name>`  |  Pushes a local branch to the upstream remote repo |
-| `git log --branches --not --remotes`  |  View commits that have not yet been pushed |
\ No newline at end of file
+| `git log --branches --not --remotes`  |  View commits that have not yet been pushed |
+| `git fetch origin pull/ID/head:BRANCH_NAME`  |  Checking out a PR branch with `ID` and `BRANCH_NAME` |
\ No newline at end of file
diff --git a/docs/structure.md b/docs/structure.md
index 031e1e3..0147e46 100644
--- a/docs/structure.md
+++ b/docs/structure.md
@@ -28,6 +28,9 @@ More in-depth structure:
 .
 ├── compose.yaml
 ├── CONTRIBUTING.md
+├── dist
+│   ├── cli_surf-0.1.0-py3-none-any.whl
+│   └── cli_surf-0.1.0.tar.gz
 ├── Dockerfile
 ├── docs
 │   ├── cheat_sheet.md
@@ -55,6 +58,7 @@ More in-depth structure:
 │   ├── api.py
 │   ├── art.py
 │   ├── cli.py
+│   ├── dev_streamlit.py
 │   ├── gpt.py
 │   ├── helper.py
 │   ├── __init__.py
@@ -63,6 +67,7 @@ More in-depth structure:
 │   ├── server.py
 │   ├── settings.py
 │   ├── static
+│   ├── streamlit_helper.py
 │   └── templates
 └── tests
     ├── __init__.py
@@ -73,6 +78,6 @@ More in-depth structure:
     ├── test_helper.py
     └── test_server.py
 
-9 directories, 38 files
+10 directories, 42 files
 ```
 <!-- STRUCTURE END  -->
diff --git a/docs/styling.md b/docs/styling.md
index 39f5a9a..5e55032 100644
--- a/docs/styling.md
+++ b/docs/styling.md
@@ -7,4 +7,6 @@ This is achieved using the ruff Linter and Formatter.
 
 The Linter and Formatter are automatically executed before committing via pre-commit.
 
-If you want to run the Linter and Formatter at any time, execute `poetry run pre-commit run --all-files`.
+If you want to run the Linter and Formatter at any time, execute `pre-commit run --all-files`.
+
+You may also run `make lint` or `make format` to run the linter/formatter on its own.
diff --git a/pyproject.toml b/pyproject.toml
index 1ff487c..6500a82 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,10 +8,10 @@ packages = [{ include = "src" }]
 
 [tool.poetry.dependencies]
 # command: `poetry add <package-name>`
-python = "^3.9"
+python = ">3.9.7"
 flask = "3.0.3"
 flask-cors = "4.0.1"
-g4f = "0.3.1.9"
+g4f = "0.3.2.2"
 geopy = "2.4.1"
 openmeteo-requests = "1.2.0"
 pandas = "2.2.2"
@@ -23,6 +23,11 @@ requests-cache = "1.2.0"
 retry-requests = "2.0.0"
 email-validator = "2.1.1"
 openai = "^1.30.5"
+curl-cffi = "^0.7.1"
+streamlit = "^1.35.0"
+matplotlib = "^3.9.0"
+seaborn = "^0.13.2"
+numpy = "^1.26.4"
 
 [tool.poetry.group.dev.dependencies]
 # command: `poetry add --group dev <package-name>`
diff --git a/src/api.py b/src/api.py
index 641ef06..12b730f 100644
--- a/src/api.py
+++ b/src/api.py
@@ -16,8 +16,9 @@ from src import helper
 
 def get_coordinates(args):
     """
-    Takes a location(city or address) and returns the coordinates: [lat, long]
-    If no location is specified, default_location() finds the users coordinates
+    Takes a location (city or address) and returns the coordinates: [lat, long]
+    If no location is specified or the location is invalid, default_location()
+    finds the user's coordinates.
     """
     for arg in args:
         arg_str = str(arg)
@@ -27,13 +28,18 @@ def get_coordinates(args):
             location = geolocator.geocode(address)
             if location is not None:
                 return [location.latitude, location.longitude, location]
-            return "No data"
+            else:
+                print(
+                    f"Invalid location '{address}' provided. "
+                    "Using default location."
+                )
+                return default_location()
     return default_location()
 
 
 def default_location():
     """
-    If no location specified in cli, find users location
+    If no location specified in cli, find user's location
     Make a GET request to the API endpoint
     """
     response = requests.get("https://ipinfo.io/json", timeout=10)
diff --git a/src/cli.py b/src/cli.py
index 191fb91..3f2a12b 100644
--- a/src/cli.py
+++ b/src/cli.py
@@ -15,12 +15,15 @@ model = env.GPT_MODEL
 gpt_info = [api_key, model]
 
 
-def run(lat=0, long=0):
+def run(lat=0, long=0, args=None):
     """
     Main function
     """
     # Seperates the cli args into a list
-    args = helper.seperate_args(sys.argv)
+    if args is None:
+        args = helper.seperate_args(sys.argv)
+    else:
+        args = helper.seperate_args(args)
 
     #  return coordinates, lat, long, city
     location = api.seperate_args_and_get_location(args)
@@ -41,10 +44,12 @@ def run(lat=0, long=0):
 
     # Non-JSON output
     if arguments["json_output"] == 0:
-        helper.print_outputs(
+        # Response prints all the outputs & returns the GPT response
+        response = helper.print_outputs(
             city, ocean_data_dict, arguments, gpt_prompt, gpt_info
         )
-        return ocean_data_dict
+        # Returns ocean data, GPT response
+        return ocean_data_dict, response
     else:
         # print the output in json format!
         json_output = helper.json_output(ocean_data_dict)
diff --git a/src/dev_streamlit.py b/src/dev_streamlit.py
new file mode 100644
index 0000000..05ebb22
--- /dev/null
+++ b/src/dev_streamlit.py
@@ -0,0 +1,86 @@
+import sys
+import time
+from pathlib import Path
+
+import streamlit as st
+from streamlit_folium import st_folium
+
+sys.path.append(str(Path(__file__).parent.parent))
+
+from src import streamlit_helper as sl_help
+
+# NOTE: This file is for testing purposes. Do not use it in production.
+
+# Page Configuration ###
+title = "cli-surf"
+st.set_page_config(
+    page_title=title,
+    page_icon="🌊",
+    layout="wide",
+)
+st.title(title)
+
+# Page Content ###
+
+# sidebar
+st.sidebar.markdown(
+    """
+# MENU
+- [weather](#weather)
+- [csv upload](#csv-upload)
+- [graph](#graph)
+- [data sheet](#data-sheet)
+""",
+    unsafe_allow_html=True,
+)
+
+latest_iteration = st.empty()
+bar = st.progress(0)
+
+for i in range(100):
+    latest_iteration.text(f"Iteration {i + 1}")
+    bar.progress(i + 1)
+    time.sleep(0.01)
+
+st.caption("Enter a surf spot to see the map and forecast!")
+
+
+# Toggles in a horizontal line
+col1, col2 = st.columns(2)
+
+with col1:
+    gpt = st.toggle("Activate GPT")
+with col2:
+    map = st.toggle("Show Map", value=True)
+
+extra_args = sl_help.extra_args(gpt)
+
+# User input location
+location = st.text_input("Surf Spot", placeholder="Enter surf spot!")
+
+graph_type = st.radio(
+    "Choose graph type",
+    ["Height/Period :ocean:", "Direction :world_map:"],
+    index=None,
+)
+
+# Checks if location has been entered.
+# If True, gathers surf report and displays map
+if location:
+    get_report = sl_help.get_report(location, extra_args)
+    report_dict, gpt_response, lat, long = get_report
+
+    # Displays the map
+    # TODO: Configure map to only show nearby surf spots based on the lat/long
+    if map:
+        map_data = sl_help.map_data(lat, long)
+        st_folium(map_data, width=725)
+
+    # Writes the GPT response
+    if gpt_response is not None:
+        st.write(gpt_response)
+
+    # Displays the line graph
+    st.write("# Surf Conditions")
+    df = sl_help.graph_data(report_dict, graph_type)
+    st.line_chart(df.rename(columns={"date": "index"}).set_index("index"))
diff --git a/src/helper.py b/src/helper.py
index 2c18602..fa65303 100644
--- a/src/helper.py
+++ b/src/helper.py
@@ -232,9 +232,11 @@ def print_outputs(city, data_dict, arguments, gpt_prompt, gpt_info):
     # Prints the forecast(if activated in CLI args)
     print_forecast(arguments, forecast)
     # Checks if GPT in args, prints GPT response if True
+    gpt_response = None
     if arguments["gpt"] == 1:
         gpt_response = print_gpt(data_dict, gpt_prompt, gpt_info)
         print(gpt_response)
+    return gpt_response
 
 
 def set_location(location):
@@ -274,7 +276,7 @@ def surf_summary(surf_data):
     """
     location = surf_data["Location"]
     height = surf_data["Height"]
-    direction = surf_data["Direction"]
+    direction = surf_data["Swell Direction"]
     period = surf_data["Period"]
     unit = surf_data["Unit"]
     report = f"""
diff --git a/src/server.py b/src/server.py
index d14fb1b..9524d3e 100644
--- a/src/server.py
+++ b/src/server.py
@@ -18,65 +18,84 @@ from flask_cors import CORS
 
 from src.settings import ServerSettings
 
-# Load environment variables from .env file
-env = ServerSettings()
 
-app = Flask(__name__)
-CORS(app)
-
-
-@app.route("/help")
-def serve_help():
-    return send_from_directory(f"{str(Path(__file__).parent)}/", "help.txt")
-
-
-@app.route("/home")
-def serve_index():
-    return render_template("index.html", env_vars=env.model_dump())
-
-
-@app.route("/script.js")
-def serve_script():
-    return send_file("static/script.js")
-
-
-@app.route("/")
-def default_route():
-    query_parameters = urllib.parse.parse_qsl(
-        request.query_string.decode(), keep_blank_values=True
-    )
-    parsed_parameters = []
-
-    for key, value in query_parameters:
-        if value:
-            parsed_parameters.append(f"{key}={value}")
-        else:
-            parsed_parameters.append(key)
-
-    # Join the parsed parameters list into a single string
-    args = ",".join(parsed_parameters)
-
-    async def run_subprocess():
-        try:
-            result = subprocess.run(
-                ["python3", "src/cli.py", args],
-                capture_output=True,
-                text=True,
-                check=True,
-            )
-            return result.stdout
-        except subprocess.CalledProcessError as e:
-            # Print the error message from the subprocess
-            print("Error message from subprocess:", e.stderr)
-            # Raise the error again to propagate it
-            raise e
-
-    # Run subprocess asynchronously
-    loop = asyncio.new_event_loop()
-    asyncio.set_event_loop(loop)
-    result = loop.run_until_complete(run_subprocess())
-    return result
+def create_app(env):
+    """
+    Application factory function
+    """
+
+    # Load environment variables from .env file
+
+    app = Flask(__name__)
+    CORS(app)
+
+    @app.route("/help")
+    def serve_help():
+        """
+        Servers the help.txt file
+        """
+        return send_from_directory(
+            Path(__file__).resolve().parents[1], "help.txt"
+        )
+
+    @app.route("/home")
+    def serve_index():
+        """
+        Servers index.html
+        """
+        return render_template("index.html", env_vars=env.model_dump())
+
+    @app.route("/script.js")
+    def serve_script():
+        """
+        Servers javascript
+        """
+        return send_file("static/script.js")
+
+    @app.route("/")
+    def default_route():
+        """
+        Default route, serves surf report
+        """
+        query_parameters = urllib.parse.parse_qsl(
+            request.query_string.decode(), keep_blank_values=True
+        )
+        parsed_parameters = []
+
+        for key, value in query_parameters:
+            if value:
+                parsed_parameters.append(f"{key}={value}")
+            else:
+                parsed_parameters.append(key)
+
+        # Join the parsed parameters list into a single string
+        args = ",".join(parsed_parameters)
+
+        async def run_subprocess():
+            try:
+                result = subprocess.run(
+                    ["python3", "src/cli.py", args],
+                    capture_output=True,
+                    text=True,
+                    check=True,
+                )
+                return result.stdout
+            except subprocess.CalledProcessError as e:
+                # Print the error message from the subprocess
+                print("Error message from subprocess:", e.stderr)
+                # Raise the error again to propagate it
+                raise e
+
+        # Run subprocess asynchronously
+        loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(loop)
+        result = loop.run_until_complete(run_subprocess())
+        return result
+
+    return app
 
 
 if __name__ == "__main__":
+    env = ServerSettings()
+    app = create_app(env)
     app.run(host="0.0.0.0", port=env.PORT, debug=env.DEBUG)
diff --git a/src/streamlit_helper.py b/src/streamlit_helper.py
new file mode 100644
index 0000000..9703981
--- /dev/null
+++ b/src/streamlit_helper.py
@@ -0,0 +1,90 @@
+"""
+Helper functions for the streamlit frontend
+"""
+
+import sys
+from pathlib import Path
+
+import folium
+import pandas as pd
+
+sys.path.append(str(Path(__file__).parent.parent))
+
+from src import cli
+
+
+def extra_args(gpt):
+    """
+    By default, the location is the only argument when cli.run()
+    is ran. Extra args outputs and other arguments the user wants,
+    like using the GPT function
+    """
+    # Arguments
+    extra_args = ""
+
+    if gpt:
+        extra_args += ",gpt"
+
+    return extra_args
+
+
+def get_report(location, extra_args):
+    """
+    Executes cli.run(), retrns the report dictionary,
+    gpt response, lat and long
+    """
+    gpt_response = None
+    args = "location=" + location
+    if extra_args:
+        args += extra_args
+    surf_report = cli.run(args=["placeholder", args])
+    report_dict, gpt_response = surf_report[0], surf_report[1]
+    lat, long = report_dict["Lat"], report_dict["Long"]
+
+    return report_dict, gpt_response, lat, long
+
+
+def map_data(lat, long):
+    """
+    Gathers data for the map
+    Docs: https://folium.streamlit.app/
+    """
+    m = folium.Map(location=[lat, long], zoom_start=16)
+    folium.Marker(
+        [lat, long], popup="Surf Spot!", tooltip="Get out there!"
+    ).add_to(m)
+
+    return m
+
+
+def graph_data(report_dict, graph_type="Height/Period :ocean:"):
+    """
+    Gathers the forecasted dates, heights, period and stores them in a pandas
+    dataframe. Will be used to display the line chart
+    """
+    forecasted_dates = [
+        forecast["date"] for forecast in report_dict["Forecast"]
+    ]
+    forecasted_heights = [
+        forecast["height"] for forecast in report_dict["Forecast"]
+    ]
+    forecasted_periods = [
+        forecast["period"] for forecast in report_dict["Forecast"]
+    ]
+    forecasted_directions = [
+        forecast["direction"] for forecast in report_dict["Forecast"]
+    ]
+    # table
+    if graph_type == "Height/Period :ocean:" or graph_type is None:
+        df = pd.DataFrame({
+            "date": forecasted_dates,
+            "heights": forecasted_heights,
+            "periods": forecasted_periods,
+        })
+    else:
+        df = pd.DataFrame({
+            "date": forecasted_dates,
+            "directions": forecasted_directions,
+        })
+
+    return df


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 867d289..edea3a5 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -18,6 +18,6 @@ def test_cli_output():
     # Hardcode lat and long for location.
     # If not, when test are ran in Github Actions
     # We get an error(because server probably isn't near ocean)
-    data_dict = cli.run(36.95, -121.97)
+    data_dict = cli.run(36.95, -121.97)[0]
     time.sleep(5)
     assert len(data_dict) >= expected
diff --git a/tests/test_server.py b/tests/test_server.py
index b61c3aa..dd8eace 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -3,3 +3,29 @@ QA tests for server.py
 Make sure pytest is installed: pip install pytest
 Run pytest: pytest
 """
+
+from src import settings
+from src.server import create_app
+
+
+def test_routes():
+    """
+    Test that the routes are able to be retrieved
+    /home, /help, /
+    When a page is requested (GET)
+    THEN check if the response is valid (200)
+    """
+    env = settings.ServerSettings()
+    flask_app = create_app(env)
+    OK = 200
+
+    # Create a test client using the Flask application configured for testing
+    with flask_app.test_client() as test_client:
+        response_help = test_client.get("/help")
+        assert response_help.status_code == OK
+
+        response_home = test_client.get("/home")
+        assert response_home.status_code == OK
+
+        response_root = test_client.get("/")
+        assert response_root.status_code == OK