diff --git a/README.md b/README.md index 4c0e00c..42a3385 100644 --- a/README.md +++ b/README.md @@ -35,29 +35,30 @@ To exit the app, click the "Exit" button. Additionally, the code contains several functions, methods, and classes: -### `self_return_decorator()` -A decorator function that allows for chaining of methods. This function takes a method as an argument and returns a new function that calls the original method and returns the instance of the class. - ### `App()` A class that inherits from the Tk class and defines the main window of the app. The __init__ method sets up the menubar, window, and widgets. The about, resize_app, exit_app, and OWMCITY methods define the behavior of the corresponding buttons in the app. -### `.about()` +#### `.about()` A method that displays a message box with information about the app. -### `.resize_app()` +#### `.resize_app()` A method that uses tkinter to detect the minimum size of the app, get the center of the screen, and place the app there. -### `.exit_app()` -A method that exits the app. +#### `.reset_app()` +This method clears all the labels or can take an input of 9 strings in a list and set each label to the corresponding string. + +#### `.OWMCITY()` +A method that sends a request to the OpenWeatherMap API to get the weather for a given city. Once the data is recieved, the method determines wether the call worked and if not, updates the city label correspondingly. If the API call does indeed succeed, it will update the labels by calling `.update_labels()` with a list of 9 strings corresponding to the data recieved. Note that because the application allows changing of units from Celsius to Fahreinheight (Metric/Imperial), it will run some calculations to convert the data to the proper units. -### `.OWMCITY()` -A method that sends a request to the OpenWeatherMap API to get the weather for a given city and displays the temperature in Celsius in the label. If the city is not found, the label displays an error message. +#### `.update_labels()` +This method takes an input of 9 strings which default to all empty strings that allow the application to modify the weather info labels with one's desired text. -# Final Notes +#### `.update_settings()` +This method runs when a user uses one or the other settings dropdowns and it determines if a setting was changed and, if so, it updates the settigns file accordingly and updates any labels to the proper units or the color theme to the chosen one. ## Contributing -Contributions are welcome! If you find a bug or have an idea for a new feature, please open an [issue](https://github.com/Futura-Py/weather/issues/new) or submit a [pull request](https://github.com/Futura-Py/weather/compare). +Contributions are welcome! If you find a bug, typo, or have an idea for a new feature, please open an [issue](https://github.com/Futura-Py/weather/issues/new) or submit a [pull request](https://github.com/Futura-Py/weather/compare). ## Pre-Commit Actions Before making any commits, run the following command: diff --git a/extra-hooks/hook-sv_ttk.py b/extra-hooks/hook-sv_ttk.py new file mode 100644 index 0000000..bd5b675 --- /dev/null +++ b/extra-hooks/hook-sv_ttk.py @@ -0,0 +1,4 @@ +# extra-hooks/hook-sv_ttk.py +from PyInstaller.utils.hooks import collect_data_files + +datas: list[tuple[str, str]] = collect_data_files("sv_ttk") diff --git a/main.py b/main.py index 889e371..d1a68dc 100644 --- a/main.py +++ b/main.py @@ -1,21 +1,76 @@ from __future__ import annotations +from pathlib import Path from platform import system -from tkinter import Menu, Tk, messagebox -from tkinter.ttk import Button, Entry, Frame, Label +from tkinter import Event, Menu, Tk, messagebox +from tkinter.ttk import Button, Combobox, Entry, Frame, Label +from platformdirs import user_data_dir from pyowm import OWM +from pyowm.commons.exceptions import APIRequestError, InvalidSSLCertificateError +from pyowm.commons.exceptions import NotFoundError as OWMNotFoundError +from pyowm.commons.exceptions import TimeoutError from requests import Response from requests import get as requests_get +from sv_ttk import set_theme + +# Create constant +SYSTEM = system() + +# Get file info +data_dir = Path(user_data_dir("Weather", "Futura-Py", ensure_exists=True)) +data_file = Path(str(data_dir / "data.txt")) + +# Ensure file directory exists +if not data_dir.exists(): + # Create the directory if it doesn't exist + data_dir.mkdir(parents=True) + data_file.touch("dark\nmetric") + +# Ensure file exists and contains the correct data +if data_file.exists(): + # Ensure the file contains everything it needs + # NOTE: File clearing has caused issues in the past so its better to + # take the safe route and be very sure the file is cleared hence three + # clearing methods instead of one + with open(data_file, "r+") as f: + data = f.read().splitlines() + if len(data) != 2: + # Clear the file and write the default values + f.truncate(0) + f.flush() + f.seek(0) + f.write("dark\nmetric") + f.close() + else: + # Ensure the file contains the correct values + if data[0] not in ["light", "dark"] or data[1] not in [ + "metric", + "imperial", + ]: + # Clear the file and write the default values + f.truncate(0) + f.flush() + f.seek(0) + f.write("dark\nmetric") + f.close() + else: + # File is good + f.close() +else: + # Write the file if it doesn't exist + data_file.write_text("dark\nmetric") class App(Tk): def __init__(self): super().__init__() self.withdraw() + # Set up Menubar if system() == "Darwin": self.menubar = Menu(self) + # Apple menus have special names and special commands self.app_menu = Menu(self.menubar, tearoff=0, name="apple") self.menubar.add_cascade(label="App", menu=self.app_menu) @@ -23,83 +78,126 @@ def __init__(self): self.menubar = Menu(self) self.app_menu = Menu(self.menubar, tearoff=0) self.menubar.add_cascade(label="App", menu=self.app_menu) - self.menubar.add_command(label="About Weather", command=self.about) + self.app_menu.add_command(label="About Weather", command=self.about) self.config(menu=self.menubar) + # Get file info + with open(data_file, "r") as file: + data = file.read().splitlines() + self.color_mode = data[0] + self.units = data[1] + + # Set up style + set_theme(self.color_mode) + # Set up window self.title("Weather") self.resizable(False, False) self.configure(bg="white") # Set up widgets - self.main_frame = Frame(self, padding=10) - self.main_frame.pack() + self.main_frame = Frame(self) + self.main_frame.grid() + + heading = Label(self.main_frame, text="Weather", font="Helvetica 25 bold") + heading.grid(row=0, column=0, padx=10, pady=10) + + settings_frame = Frame(self.main_frame) + settings_frame.grid(row=0, column=1, padx=(0, 10), pady=(10, 0), sticky="w") + + self.unit_combobox = Combobox( + settings_frame, + values=["Metric", "Imperial"], + state="readonly", + font=("Helvetica 13"), + width=10, + ) + self.unit_combobox.grid(row=0, column=0, padx=(0, 10), sticky="e") + self.unit_combobox.current(0 if self.units == "metric" else 1) + self.unit_combobox.bind("<>", self.update_settings) - heading = Label(self.main_frame, text="Weather", font="Helvetica 13") - heading.grid(row=0, column=0, columnspan=2, padx=10, pady=10) + self.color_mode_combobox = Combobox( + settings_frame, + values=["Light", "Dark"], + state="readonly", + font=("Helvetica 13"), + width=10, + ) + self.color_mode_combobox.grid(row=0, column=1, sticky="e") + self.color_mode_combobox.current(0 if self.color_mode == "light" else 1) + self.color_mode_combobox.bind("<>", self.update_settings) - self.cityname = Label(self.main_frame, text="", font=("Helvetica 13")) - self.cityname.grid(row=1, column=0, columnspan=2) + self.cityname = Label( + self.main_frame, text="City: None", font=("Helvetica 15 bold") + ) + self.cityname.grid(row=2, column=0, columnspan=2) self.searchbar = Entry(self.main_frame, width=42) - self.searchbar.grid(row=2, column=0, columnspan=2, padx=10, pady=10) + self.searchbar.grid(row=3, column=0, columnspan=2, padx=10, pady=10) + self.bind("", self.OWMCITY) + + self.info_frame = Frame(self.main_frame, relief="sunken") + self.info_frame.grid(row=4, column=0, columnspan=2, padx=10, pady=10) + + self.label_weather = Label(self.info_frame, text="", font=("Helvetica 13")) + self.label_weather.grid(row=0, column=0, columnspan=2) - self.label_status = Label( - self.main_frame, text="", font=("Helvetica 13")) - self.label_status.grid(row=3, column=0, columnspan=2) + self.label_temp = Label(self.info_frame, text="", font=("Helvetica 13")) + self.label_temp.grid(row=1, column=0, columnspan=2) - self.label_temp = Label( - self.main_frame, text="", font=("Helvetica 13")) - self.label_temp.grid(row=4, column=0, columnspan=2) + self.label_temp_max = Label(self.info_frame, text="", font=("Helvetica 13")) + self.label_temp_max.grid(row=2, column=0, columnspan=2) - self.label_temp_max = Label( - self.main_frame, text="", font=("Helvetica 13")) - self.label_temp_max.grid(row=5, column=0, columnspan=2) + self.label_temp_min = Label(self.info_frame, text="", font=("Helvetica 13")) + self.label_temp_min.grid(row=3, column=0, columnspan=2) - self.label_temp_min = Label( - self.main_frame, text="", font=("Helvetica 13")) - self.label_temp_min.grid(row=6, column=0, columnspan=2) + self.label_feels_like = Label(self.info_frame, text="", font=("Helvetica 13")) + self.label_feels_like.grid(row=4, column=0, columnspan=2) - self.label_feels_like = Label( - self.main_frame, text="", font=("Helvetica 13")) - self.label_feels_like.grid(row=7, column=0, columnspan=2) + self.label_humidity = Label(self.info_frame, text="", font=("Helvetica 13")) + self.label_humidity.grid(row=5, column=0, columnspan=2) - self.label_humidity = Label( - self.main_frame, text="", font=("Helvetica 13")) - self.label_humidity.grid(row=8, column=0, columnspan=2) + self.label_pressure = Label(self.info_frame, text="", font=("Helvetica 13")) + self.label_pressure.grid(row=6, column=0, columnspan=2) - self.label_pressure = Label( - self.main_frame, text="", font=("Helvetica 13")) - self.label_pressure.grid(row=9, column=0, columnspan=2) + self.label_visibility = Label(self.info_frame, text="", font=("Helvetica 13")) + self.label_visibility.grid(row=7, column=0, columnspan=2) - self.label_visibility = Label( - self.main_frame, text="", font=("Helvetica 13")) - self.label_visibility.grid(row=10, column=0, columnspan=2) + self.label_windspeed = Label(self.info_frame, text="", font=("Helvetica 13")) + self.label_windspeed.grid(row=8, column=0, columnspan=2) - self.label_windspeed = Label( - self.main_frame, text="", font=("Helvetica 13")) - self.label_windspeed.grid(row=11, column=0, columnspan=2) - Button(self.main_frame, text="Search for City", command=self.OWMCITY).grid( - row=12, column=0, padx=10, pady=10 + # Set up buttons + buttons_frame = Frame(self.main_frame) + buttons_frame.grid(row=5, column=0, columnspan=2, padx=10, pady=10, sticky="n") + self.start_button = Button( + buttons_frame, + text="Search for City", + command=self.OWMCITY, ) - Button(self.main_frame, text="Exit", command=self.exit_app).grid( - row=12, column=1, padx=10, pady=10 + self.start_button.grid(row=0, column=0, padx=10, pady=10, sticky="w") + Button(buttons_frame, text="Exit", command=self.destroy).grid( + row=0, column=1, padx=10, pady=10, sticky="e" ) + # Set variables + self.searching: bool = False + + # Resize and deiconify self.resize_app() self.deiconify() - def about(self) -> App: + def about(self) -> None: """Display a messagebox with information about the app.""" + messagebox.showinfo( "About Weather", "Weather is a simple weather app that uses the OpenWeatherMap API to get the weather for a given city.", parent=self, ) - return self - def resize_app(self) -> App: + def resize_app(self) -> None: """Use tkinter to detect the minimum size of the app, get the center of the screen, and place the app there.""" + # Update widgets so minimum size is accurate self.update_idletasks() @@ -109,79 +207,206 @@ def resize_app(self) -> App: # Get center of screen based on minimum size x_coords = int(self.winfo_screenwidth() / 2 - minimum_width / 2) - y_coords = int(self.winfo_screenheight() / 2 - minimum_height / 2) - 20 - # `-20` should deal with Dock on macOS and looks good on other OS's + y_coords = int(self.wm_maxsize()[1] / 2 - minimum_height / 2) # Place app and make the minimum size the actual minimum size (non-infringable) - self.geometry( - f"{minimum_width}x{minimum_height}+{x_coords}+{y_coords}") + self.geometry(f"{minimum_width}x{minimum_height}+{x_coords}+{y_coords}") self.wm_minsize(minimum_width, minimum_height) - return self - def exit_app(self) -> None: - """Exit the app.""" - self.destroy() + def reset_app(self, data: list[str] = ["" for _ in range(9)]) -> None: + """Reset the app to its initial state.""" - def OWMCITY(self) -> App: + # Update labels + self.update_labels(data) + + # Reset variables + self.searching = False + + # Reset widgets + self.searchbar.configure(state="normal") + self.start_button.configure(state="normal") + + # Resize app + self.resize_app() + + def OWMCITY(self, _: Event | None = None) -> None: """Get the weather for a given city using the OpenWeatherMap API and display it in a label.""" + + # Check if already searching + if self.searching: + return + self.searching = True + + # Start the Progress Bar, disable buttons and clear labels, and disable searchbar + self.start_button.configure(state="disabled") + self.searchbar.configure(state="disabled") + self.update_labels() + # Get API key api_key: str = "c439e1209216cc7e7c73a3a8d1d12bfd" owm = OWM(api_key) mgr = owm.weather_manager() # Get city name city: str = self.searchbar.get() - observation = mgr.weather_at_place(city) + + # Check if city is empty + if not city: + self.cityname.configure(text="City: Needs Name") + self.reset_app() + return + + # Check if city exists + try: + observation = mgr.weather_at_place(city) + except ( + OWMNotFoundError + or APIRequestError + or TimeoutError + or InvalidSSLCertificateError + ): + self.cityname.configure(text="City: Not Found") + self.reset_app() + return + + # Get weather data weather = observation.weather + # Send request to OpenWeatherMap API response: Response = requests_get( f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}" ) + + # Check if request was successful if response.status_code != 200: - self.label_status.configure(text="City not found") + self.cityname.configure(text="City: Connection Error") + self.update_labels() + self.start_button.configure(state="normal") + self.searchbar.configure(state="normal") + self.searching = False + self.resize_app() # In case the name gets too long or it renders differently on other systems return + # Get response data, simplify and create variables for usage + # TODO: Give all data in dual columns (there's unused data!) + # | Weather: Clear | Temp: 20°C | data = response.json() main = data["main"] temperature = weather.temperature("celsius") - wind = weather.wind(unit="meters_sec") - # Get temperature in Celsius - # temperature_kelvin: float = main["temp"] - # temperature_celsius = temperature_kelvin - 273.15 - temp = temperature.get("temp", None) - temp_max = temperature.get("temp_max", None) - temp_min = temperature.get("temp_min", None) - feels_like = temperature.get("feels_like", None) - status: str = weather.status - detailed_status: str = weather.detailed_status - # Other data needed from the API - humidity = main["humidity"] - pressure = main["pressure"] - visibility = weather.visibility(unit="kilometers") - windspeed: float = wind["speed"] - # Put in label - self.cityname.configure( - text=data["name"] + ", " + data["sys"]["country"]) - self.label_status.configure( - text="Weather: " + status + ":- " + detailed_status) - self.label_temp.configure(text="Temperature: " + f"{temp:.2f}°C") - self.label_temp_max.configure( - text="Maximum Temperature: " + f"{temp_max:.2f}°C" - ) - self.label_temp_min.configure( - text="Minimum Temperature: " + f"{temp_min:.2f}°C" - ) - self.label_feels_like.configure( - text="Feels like " + f"{feels_like:.2f}°C") - self.label_humidity.configure(text="Humidity: " + f"{humidity:.2f}%") - self.label_pressure.configure( - text="Pressure: " + f"{pressure:.2f}" + " hPa") - self.label_visibility.configure( - text="Visibility: " + f"{visibility:.2f}" + " km" - ) - self.label_windspeed.configure( - text="Wind Speed: " + f"{windspeed:.2f}" + " meters per second" + + # Update labels + info: list[str] = ( + [ + f"Weather: {weather.status} ~ {weather.detailed_status}", + f"Current Temperature: {temperature.get('temp', None):.2f}°C", + f"Maximum Temperature: {temperature.get('temp_max', None):.2f}°C", + f"Minimum Temperature: {temperature.get('temp_min', None):.2f}°C", + f"Feels like {temperature.get('feels_like', None):.2f}°C", + f"Humidity: {main['humidity']:.2f}%", + f"Pressure: {main['pressure']:.2f} hPa", + f"Visibility: {weather.visibility(unit='kilometers'):.2f} km", + f"Wind Speed: { weather.wind(unit='meters_sec')['speed']:.2f} meters per second", + ] + if self.units == "metric" + else [ + f"Weather: {weather.status} ~ {weather.detailed_status}", + f"Current Temperature: {(temperature.get('temp', None)*(9/5))+32:.2f}°F", + f"Maximum Temperature: {(temperature.get('temp_max', None)*(9/5))+32:.2f}°F", + f"Minimum Temperature: {(temperature.get('temp_min', None)*(9/5))+32:.2f}°F", + f"Feels like {(temperature.get('feels_like', None)*(9/5))+32:.2f}°F", + f"Humidity: {main['humidity']:.2f}%", + f"Pressure: {main['pressure']*.0145038:.2f} psi", + f"Visibility: {weather.visibility(unit='miles'):.2f} miles", + f"Wind Speed: { weather.wind(unit='miles_hour')['speed']:.2f} miles per hour", + ] ) - return self + + # Set the city name + self.cityname.configure(text=f"City: {data['name']}, {data['sys']['country']}") + + # Reset app + self.reset_app(info) + self.searchbar.delete(0, "end") + + def update_labels(self, data: list[str] = ["" for _ in range(9)]) -> None: + """Update the labels with the given data.""" + + # Update labels + self.label_weather.configure(text=data[0]) + self.label_temp.configure(text=data[1]) + self.label_temp_max.configure(text=data[2]) + self.label_temp_min.configure(text=data[3]) + self.label_feels_like.configure(text=data[4]) + self.label_humidity.configure(text=data[5]) + self.label_pressure.configure(text=data[6]) + self.label_visibility.configure(text=data[7]) + self.label_windspeed.configure(text=data[8]) + + def update_settings(self, _: Event | None = None) -> None: + """Updates the settings such as units and color mode""" + + # Color Mode Settings + if self.color_mode == "light" and self.color_mode_combobox.get() == "Dark": + set_theme("dark") + self.color_mode = "dark" + self.write_file() + + if self.color_mode == "dark" and self.color_mode_combobox.get() == "Light": + set_theme("light") + self.color_mode = "light" + self.write_file() + + # Unit Settings + if self.units == "metric" and self.unit_combobox.get() == "Imperial": + self.units = "imperial" + if not self.label_weather.cget( + "text" + ): # Check that there is data to convert + return + self.update_labels( + [ + self.label_weather.cget("text"), + f"Current Temperature: {(float(self.label_temp.cget('text').split('°')[0].split(': ')[1]))*(9/5)+32:.2f}°F", # noqa: E501 + f"Maximum Temperature: {(float(self.label_temp_max.cget('text').split('°')[0].split(': ')[1]))*(9/5)+32:.2f}°F", # noqa: E501 + f"Minimum Temperature: {(float(self.label_temp_min.cget('text').split('°')[0].split(': ')[1])*(9/5))+32:.2f}°F", # noqa: E501 + f"Feels like {float(self.label_feels_like.cget('text').split('°')[0].split(' ')[2])*(9/5)+32:.2f}°F", + self.label_humidity.cget("text"), + f"Pressure: {float(self.label_pressure.cget('text').split(' ')[1])*.0145038:.2f} psi", + f"Visibility: {float(self.label_visibility.cget('text').split(' ')[1])*0.621371:.2f} miles", + f"Wind Speed: {float(self.label_windspeed.cget('text').split(' ')[2])*0.621371:.2f} miles per hour", + ] + ) + self.write_file() + + if self.units == "imperial" and self.unit_combobox.get() == "Metric": + self.units = "metric" + if not self.label_weather.cget( + "text" + ): # Check that there is data to convert + return + self.update_labels( + [ + self.label_weather.cget("text"), + f"Current Temperature: {(5/9)*((float(self.label_temp.cget('text').split('°')[0].split(': ')[1])-32)):.2f}°C", # noqa: E501 + f"Maximum Temperature: {(5/9)*((float(self.label_temp_max.cget('text').split('°')[0].split(': ')[1])-32)):.2f}°C", # noqa: E501 + f"Minimum Temperature: {(5/9)*((float(self.label_temp_min.cget('text').split('°')[0].split(': ')[1])-32)):.2f}°C", # noqa: E501 + f"Feels like {(5/9)*((float(self.label_feels_like.cget('text').split('°')[0].split(' ')[2])-32)):.2f}°C", + self.label_humidity.cget("text"), + f"Pressure: {float(self.label_pressure.cget('text').split(' ')[1])*68.9476:.2f} hPa", + f"Visibility: {float(self.label_visibility.cget('text').split(' ')[1])*1.60934:.2f} km", + f"Wind Speed: {float(self.label_windspeed.cget('text').split(' ')[2])*1.60934:.2f} meters per second", + ] + ) + self.write_file() + + def write_file(self) -> None: + """Write the settings to the file.""" + + with open(data_file, "w") as file: + # Clear the file + file.truncate(0) + file.flush() + file.seek(0) + file.write(f"{self.color_mode}\n{self.units}") if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 3208719..fd3aa9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ requests isort black ruff -pyowm \ No newline at end of file +pyowm +sv_ttk \ No newline at end of file diff --git a/weather.spec b/weather.spec index 985e7e0..5d0dee4 100644 --- a/weather.spec +++ b/weather.spec @@ -9,7 +9,7 @@ a = Analysis( binaries=[], datas=[], hiddenimports=[], - hookspath=[], + hookspath=["extra-hooks"], hooksconfig={}, runtime_hooks=[], excludes=[],