Blog Python

App del clima con Flet y OpenWeatherMap: componentes y API REST

Construimos una app del clima en Python usando Flet para la UI, la API de OpenWeatherMap para los datos, y python-dotenv para manejar credenciales de forma segura. Arquitectura en capas: config, servicios, componentes y utilidades.

01

¿Qué vamos a construir?

Una app del clima que consulta la API de OpenWeatherMap en tiempo real. Desde la interfaz podés buscar cualquier ciudad y ver:

  1. Temperatura actual en grados Celsius
  2. Sensación térmica y descripción del estado del cielo
  3. Humedad relativa y velocidad del viento
  4. Ícono visual según el código meteorológico

La app arranca con Medellín por defecto y se puede usar en el navegador gracias al modo WEB_BROWSER de Flet.

El código está organizado en capas bien separadas para que cada parte sea fácil de mantener o reemplazar:

  • config/ — ajustes globales y variables de entorno
  • services/ — toda la lógica de llamadas a la API
  • components/ — piezas reutilizables de la interfaz
  • utils/ — funciones auxiliares pequeñas
02

API key y variables de entorno

Antes de escribir una línea de código necesitás una API key gratuita de OpenWeatherMap. Entrá a openweathermap.org, creá una cuenta y generá una clave desde la sección API keys de tu perfil.

Con la clave en mano, creá un archivo .env en la raíz del proyecto:

WEATHER_API_KEY=tu_clave_aqui

Ese archivo lo lee config/settings.py con python-dotenv:

import os
from dotenv import load_dotenv

load_dotenv()

WEATHER_API_KEY = os.getenv("WEATHER_API_KEY", "")
BASE_URL        = "https://api.openweathermap.org/data/2.5"
GEOCODING_URL   = "https://api.openweathermap.org/geo/1.0"
APP_TITLE       = "Clima App"
DEFAULT_CITY    = "Medellín"
Nunca subás el .env al repositorio. Agregalo a .gitignore antes de hacer el primer commit: echo ".env" >> .gitignore. La API key es tuya y tiene límites de uso.
03

Instalar dependencias

El proyecto usa solo tres paquetes. El archivo requirements.txt en la raíz los declara:

flet>=0.21.0
requests>=2.31.0
python-dotenv>=1.0.0

Para qué sirve cada uno:

  • flet — framework de UI en Python basado en Flutter. Permite construir apps de escritorio, móvil y web desde un mismo código.
  • requests — librería estándar para hacer peticiones HTTP. La usamos para consultar los endpoints de OpenWeatherMap.
  • python-dotenv — carga las variables del archivo .env al entorno del proceso sin tener que exportarlas manualmente.

Instalá todo con un solo comando:

pip install -r requirements.txt
04

Estructura del proyecto

Así queda el árbol de archivos completo:

weather_app/
├── main.py
├── requirements.txt
├── .env                      ← no commitear
├── config/
│   └── settings.py
├── services/
│   └── weather_service.py
├── components/
│   ├── weather_card.py
│   └── search_bar.py
└── utils/
    └── helpers.py

Cada capa tiene una responsabilidad clara: services/ habla con la API, components/ construye la UI, utils/ agrupa funciones pequeñas sin estado, y config/ centraliza todas las constantes. El archivo main.py solo ensambla las piezas.

05

El servicio de clima: weather_service.py

La clase WeatherService encapsula todas las llamadas a la API. Tiene tres métodos:

import requests
from config.settings import WEATHER_API_KEY, BASE_URL, GEOCODING_URL

class WeatherService:

    def get_coordinates(self, city_name):
        url    = f"{GEOCODING_URL}/direct"
        params = {"q": city_name, "limit": 1, "appid": WEATHER_API_KEY}
        try:
            response = requests.get(url, params=params)
            data     = response.json()
            if data:
                return data[0]["lat"], data[0]["lon"]
            return None, None
        except Exception as e:
            print(f"Error obteniendo coordenadas: {e}")
            return None, None

    def get_weather(self, city_name):
        lat, lon = self.get_coordinates(city_name)
        if lat is None:
            return None
        url    = f"{BASE_URL}/weather"
        params = {
            "lat": lat, "lon": lon,
            "units": "metric", "lang": "es",
            "appid": WEATHER_API_KEY
        }
        try:
            response = requests.get(url, params=params)
            return response.json()
        except Exception as e:
            print(f"Error obteniendo clima: {e}")
            return None

    def get_forecast(self, city_name, days=5):
        lat, lon = self.get_coordinates(city_name)
        if lat is None:
            return None
        url    = f"{BASE_URL}/forecast"
        params = {
            "lat": lat, "lon": lon,
            "cnt": days * 8,
            "units": "metric", "lang": "es",
            "appid": WEATHER_API_KEY
        }
        try:
            response = requests.get(url, params=params)
            return response.json()
        except Exception as e:
            print(f"Error obteniendo pronóstico: {e}")
            return None

El flujo de get_weather es intencional: primero llama a la Geocoding API para convertir el nombre de la ciudad en coordenadas lat/lon, y luego usa esas coordenadas para pedir el clima. Esto es más preciso que pasar el nombre directamente, especialmente para ciudades colombianas o municipios pequeños donde la búsqueda por nombre puede devolver resultados de otro país.

06

Componentes reutilizables

Flet tiene el concepto de UserControl: una clase que empaqueta un fragmento de UI con su propio estado y lógica. Así se puede reutilizar en cualquier parte de la app sin duplicar código.

SearchBar — barra de búsqueda con campo de texto y botón:

import flet as ft

class SearchBar(ft.UserControl):

    def __init__(self, on_search):
        super().__init__()
        self.on_search = on_search

    def build(self):
        self.text_field = ft.TextField(
            hint_text="Buscar ciudad...",
            prefix_icon=ft.icons.SEARCH,
            on_submit=self.search_city,
            expand=True,
        )
        return ft.Row([
            self.text_field,
            ft.IconButton(
                icon=ft.icons.SEARCH,
                on_click=self.search_city,
            ),
        ])

    def search_city(self, e):
        city = self.text_field.value.strip()
        if city:
            self.on_search(city)

El patrón on_search es un callback: el componente no sabe qué va a pasar cuando el usuario busca, solo lo notifica. Quien instancia SearchBar decide qué hacer con el nombre de la ciudad.

WeatherCard — tarjeta que muestra los datos del clima:

import flet as ft
from utils.helpers import get_weather_icon, format_temperature, capitalize_first

class WeatherCard(ft.UserControl):

    def build(self):
        self.city_title   = ft.Text("", size=24, weight=ft.FontWeight.BOLD)
        self.weather_icon = ft.Text("", size=64)
        self.temperature  = ft.Text("", size=48, weight=ft.FontWeight.BOLD)
        self.description  = ft.Text("", size=16)
        self.feels_like   = ft.Text("")
        self.humidity     = ft.Text("")
        self.wind_speed   = ft.Text("")

        return ft.Card(
            content=ft.Container(
                padding=20,
                content=ft.Column([
                    self.city_title,
                    self.weather_icon,
                    self.temperature,
                    self.description,
                    ft.Row([
                        self.feels_like,
                        self.humidity,
                        self.wind_speed,
                    ], alignment=ft.MainAxisAlignment.SPACE_AROUND),
                ], horizontal_alignment=ft.CrossAxisAlignment.CENTER),
            )
        )

    def update_weather(self, weather_data):
        name    = weather_data["name"]
        country = weather_data["sys"]["country"]
        temp    = weather_data["main"]["temp"]
        feels   = weather_data["main"]["feels_like"]
        hum     = weather_data["main"]["humidity"]
        wind_ms = weather_data["wind"]["speed"]
        icon_code   = weather_data["weather"][0]["icon"]
        description = weather_data["weather"][0]["description"]

        self.city_title.value   = f"{name}, {country}"
        self.weather_icon.value = get_weather_icon(icon_code)
        self.temperature.value  = format_temperature(temp)
        self.description.value  = capitalize_first(description)
        self.feels_like.value   = f"Sensación {format_temperature(feels)}"
        self.humidity.value     = f"Humedad {hum}%"
        self.wind_speed.value   = f"Viento {int(wind_ms * 3.6)} km/h"
        self.update()
La conversión de viento es m/s × 3.6 para pasar a km/h. La API devuelve metros por segundo por defecto incluso con units=metric.

helpers.py — funciones auxiliares sin estado:

def get_weather_icon(code):
    icons = {
        "01d": "☀️",  "01n": "🌙",
        "02d": "⛅",  "02n": "⛅",
        "03d": "☁️",  "03n": "☁️",
        "04d": "☁️",  "04n": "☁️",
        "09d": "🌧️", "09n": "🌧️",
        "10d": "🌦️", "10n": "🌦️",
        "11d": "⛈️", "11n": "⛈️",
        "13d": "❄️",  "13n": "❄️",
        "50d": "🌫️", "50n": "🌫️",
    }
    return icons.get(code, "🌡️")

def format_temperature(temp):
    return f"{int(temp)}°C"

def capitalize_first(text):
    return text[0].upper() + text[1:] if text else text
07

El punto de entrada: main.py

La clase WeatherApp ensambla todo y controla el flujo de la interfaz:

import flet as ft
from config.settings import APP_TITLE, DEFAULT_CITY
from services.weather_service import WeatherService
from components.search_bar import SearchBar
from components.weather_card import WeatherCard

class WeatherApp:

    def __init__(self, page: ft.Page):
        self.page    = page
        self.service = WeatherService()
        self.setup_page()
        self.build_ui()
        self.search_weather(DEFAULT_CITY)

    def setup_page(self):
        self.page.title           = APP_TITLE
        self.page.theme_mode      = ft.ThemeMode.DARK
        self.page.padding         = 20
        self.page.window_width    = 420
        self.page.window_height   = 680

    def build_ui(self):
        self.title        = ft.Text(APP_TITLE, size=28, weight=ft.FontWeight.BOLD)
        self.search_bar   = SearchBar(on_search=self.search_weather)
        self.progress     = ft.ProgressRing(visible=False)
        self.error_text   = ft.Text("", color=ft.colors.RED_400, visible=False)
        self.weather_card = WeatherCard()

        self.page.add(
            self.title,
            self.search_bar,
            self.progress,
            self.error_text,
            self.weather_card,
        )

    def search_weather(self, city):
        self.show_loading()
        self.hide_error()
        weather_data = self.service.get_weather(city)
        self.hide_loading()
        if weather_data and weather_data.get("cod") == 200:
            self.weather_card.update_weather(weather_data)
        else:
            self.show_error(f"No se encontró información para '{city}'.")

    def show_loading(self):
        self.progress.visible = True
        self.progress.update()

    def hide_loading(self):
        self.progress.visible = False
        self.progress.update()

    def show_error(self, message):
        self.error_text.value   = message
        self.error_text.visible = True
        self.error_text.update()

    def hide_error(self):
        self.error_text.visible = False
        self.error_text.update()


def main(page: ft.Page):
    WeatherApp(page)


ft.app(target=main, view=ft.WEB_BROWSER, port=8550)

El flujo cuando el usuario busca una ciudad es lineal y fácil de seguir: search_weather llama a show_loading, luego pide los datos al servicio, luego llama a hide_loading, y según el resultado actualiza la tarjeta o muestra un mensaje de error.

El port=8550 evita conflictos con otros servicios que corren en puertos comunes como 8000 o 8080. Podés cambiarlo si ese puerto está ocupado en tu máquina.
08

Correr la app

Con el entorno virtual activo y las dependencias instaladas, ejecutá:

python main.py

Flet abre automáticamente el navegador en http://localhost:8550. Si no se abre solo, entrá a esa URL manualmente.

El flujo completo desde el navegador:

  1. La app carga y busca Medellín por defecto — deberías ver temperatura y datos de la ciudad
  2. Escribí el nombre de otra ciudad en la barra de búsqueda
  3. Presioná Enter o hacé clic en el botón de lupa
  4. El indicador de carga aparece brevemente mientras se consulta la API
  5. La tarjeta se actualiza con los datos de la nueva ciudad
  6. Si escribís un nombre que no existe, aparece el mensaje de error en rojo
jamesmosq / weather_app
Código completo de este tutorial — app del clima con Flet, OpenWeatherMap, componentes reutilizables y arquitectura por capas.
Ver en GitHub
Volver al blog