Standard library only
The script uses urllib, json, argparse,
os, and basic error handling.
This project turns the weather logic from my larger Flask seed app into a small standalone terminal tool. The app takes a ZIP code or latitude/longitude, asks Weather.gov for the forecast, parses the JSON response, and prints the next few forecast periods in the terminal.
I like this project because it makes APIs feel real. Instead of opening a weather site, my own Python script talks to Weather.gov, follows the API-provided forecast URL, and prints a clean result I can use from the command line.
I built a one-file Python weather app called weather.py. It uses only the Python
standard library, so there are no pip packages, no framework, and no API key required.
The script uses urllib, json, argparse,
os, and basic error handling.
The app calls the Weather.gov API, follows the returned forecast URL, and reads the forecast periods from the JSON response.
The result is printed directly in the terminal, which makes it simple to use over SSH, in a VM, on a Raspberry Pi, or inside a Codespace.
This is a small project, but it teaches the core API workflow: send a request, get JSON back, extract useful fields, and turn the response into something readable.
Use Python to request data from a real public web service.
Read nested dictionaries and lists from the API response.
Catch HTTP errors, network errors, bad input, and missing data.
Turn raw API data into a useful command-line app.
My larger seed tracking app already uses a similar weather pipeline. This tiny project pulls out the core idea and makes it easier to study.
| Seed app idea | Tiny app version | Why it matters |
|---|---|---|
| Weather User-Agent | WEATHER_USER_AGENT environment variable. |
Identifies the app when making Weather.gov requests. |
| fetch_json() | Reusable helper for API requests. | Keeps headers, timeouts, and JSON parsing in one place. |
| ZIP lookup | Convert a ZIP code into latitude and longitude. | Lets the user run the app with a simple ZIP code. |
| Weather.gov /points | Follow the forecast URL returned by Weather.gov. | Avoids hardcoding the forecast office and gridpoint manually. |
| Forecast periods | Print the next three periods. | Keeps the output small and readable. |
The app follows the same basic API workflow every time.
The user provides either a ZIP code or latitude and longitude.
If a ZIP code is used, the app converts it to coordinates.
The app calls the Weather.gov /points endpoint.
The app reads forecast periods and prints the next few results.
This project can be kept very small.
tiny-terminal-weather/
├── weather.py
└── README.md
The script can be run directly with Python.
python3 weather.py --zip 11201
It can also use coordinates directly.
python3 weather.py --coords 40.7128 -74.0060
By default, it prints three forecast periods. The count can be changed.
python3 weather.py --zip 11201 --count 5
The exact weather changes, but the output format stays simple.
Weather forecast for New York, NY
=================================
Today
-----
Temp: 72°F
Wind: 8 mph NW
Short: Mostly Sunny
Details: Mostly sunny, with a high near 72.
Tonight
-------
Temp: 58°F
Wind: 6 mph N
Short: Partly Cloudy
Details: Partly cloudy, with a low around 58.
Tuesday
-------
Temp: 74°F
Wind: 7 mph E
Short: Chance Rain Showers
Details: A chance of rain showers after 2pm.
This is the complete turnkey version. Save it as weather.py.
#!/usr/bin/env python3
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
DEFAULT_USER_AGENT = os.environ.get(
"WEATHER_USER_AGENT",
"tiny-weather/1.0 (student project; contact: example@example.com)"
)
def fetch_json(url):
request_headers = {
"Accept": "application/geo+json, application/json",
"User-Agent": DEFAULT_USER_AGENT,
}
req = urllib.request.Request(url, headers=request_headers)
with urllib.request.urlopen(req, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))
def fetch_zip_location(zip_code):
zip_code = re.sub(r"[^0-9]", "", zip_code or "")
if len(zip_code) != 5:
raise ValueError("Use a valid 5 digit US ZIP code.")
data = fetch_json(f"https://api.zippopotam.us/us/{zip_code}")
place = data.get("places", [{}])[0]
return {
"zip_code": zip_code,
"latitude": place["latitude"],
"longitude": place["longitude"],
"label": f"{place.get('place name', zip_code)}, {place.get('state abbreviation', '')}".strip(", "),
}
def fetch_forecast_url(latitude, longitude):
points_url = f"https://api.weather.gov/points/{latitude},{longitude}"
data = fetch_json(points_url)
properties = data.get("properties", {})
forecast_url = properties.get("forecast")
if not forecast_url:
raise ValueError("Weather.gov did not return a forecast URL for this location.")
location = properties.get("relativeLocation", {}).get("properties", {})
city = location.get("city", "")
state = location.get("state", "")
return forecast_url, f"{city}, {state}".strip(", ")
def fetch_forecast_periods(latitude, longitude):
forecast_url, location_label = fetch_forecast_url(latitude, longitude)
forecast = fetch_json(forecast_url)
periods = forecast.get("properties", {}).get("periods", [])
if not periods:
raise ValueError("Weather.gov did not return forecast periods.")
return location_label, periods
def print_forecast(location_label, periods, count):
print()
print(f"Weather forecast for {location_label}")
print("=" * (21 + len(location_label)))
for period in periods[:count]:
name = period.get("name", "Forecast")
temperature = period.get("temperature", "")
unit = period.get("temperatureUnit", "")
wind_speed = period.get("windSpeed", "")
wind_direction = period.get("windDirection", "")
short_forecast = period.get("shortForecast", "")
detailed_forecast = period.get("detailedForecast", "")
print()
print(name)
print("-" * len(name))
print(f"Temp: {temperature}°{unit}")
print(f"Wind: {wind_speed} {wind_direction}".strip())
print(f"Short: {short_forecast}")
if detailed_forecast:
print(f"Details: {detailed_forecast}")
print()
def main():
parser = argparse.ArgumentParser(
description="Tiny terminal weather app using Weather.gov."
)
location = parser.add_mutually_exclusive_group(required=True)
location.add_argument(
"--zip",
dest="zip_code",
help="US ZIP code, example: 11201"
)
location.add_argument(
"--coords",
nargs=2,
metavar=("LATITUDE", "LONGITUDE"),
help="Latitude and longitude, example: 40.7128 -74.0060"
)
parser.add_argument(
"-n",
"--count",
type=int,
default=3,
help="Number of forecast periods to print. Default: 3"
)
args = parser.parse_args()
try:
if args.zip_code:
location_info = fetch_zip_location(args.zip_code)
latitude = location_info["latitude"]
longitude = location_info["longitude"]
fallback_label = location_info["label"]
else:
latitude, longitude = args.coords
fallback_label = f"{latitude}, {longitude}"
location_label, periods = fetch_forecast_periods(latitude, longitude)
if not location_label:
location_label = fallback_label
print_forecast(location_label, periods, max(args.count, 1))
except urllib.error.HTTPError as error:
print(f"HTTP error: {error.code} {error.reason}", file=sys.stderr)
sys.exit(1)
except urllib.error.URLError as error:
print(f"Network error: {error.reason}", file=sys.stderr)
sys.exit(1)
except (KeyError, ValueError, TimeoutError) as error:
print(f"Weather lookup failed: {error}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Because the file starts with a shebang, it can also run like a small Linux command.
chmod +x weather.py
./weather.py --zip 11201
Weather.gov expects a User-Agent that identifies the application. The script uses a default, but I can override it with an environment variable.
export WEATHER_USER_AGENT="tiny-weather/1.0 (your-email@example.com)"
python3 weather.py --zip 11201
The app is easier to understand when each function has one job.
| Function | Job | Why it exists |
|---|---|---|
fetch_json() |
Sends the HTTP request and parses JSON. | Keeps request headers, timeout, and JSON loading in one helper. |
fetch_zip_location() |
Turns a ZIP code into coordinates. | Lets the user run the app with a simple ZIP code. |
fetch_forecast_url() |
Calls Weather.gov /points. |
Finds the correct forecast endpoint for the location. |
fetch_forecast_periods() |
Downloads the actual forecast periods. | Separates location lookup from forecast retrieval. |
print_forecast() |
Formats the output for the terminal. | Turns raw JSON fields into readable text. |
main() |
Handles arguments and errors. | Keeps the script usable as a normal command-line tool. |
This specific app does not need a secret API key, but the project still teaches an important security habit: API logic often belongs in scripts or backend code, not exposed frontend code.
The request is made from Python, where headers, environment variables, error handling, and caching can be controlled.
If an API uses a secret key, putting that key in static HTML or browser JavaScript would expose it to anyone who views the page source.
The first version was intentionally simple: run the script with a ZIP code or coordinates, fetch the forecast, print the next few forecast periods, and exit. That version proved the API workflow worked before I added a more advanced interactive menu, saved settings, and repeatable terminal-app behavior.
--short mode that prints one-line summaries.--hourly option using the hourly forecast URL.This project is small enough to understand quickly, but it still teaches real API and Linux habits.
The first API response may not be the final data. It may contain the next endpoint the program should request.
A small script can start as a terminal tool, then later become part of a web app, dashboard, cron job, or automation workflow.
A command-line app with arguments, errors, and clear output is easier to reuse than code that only works once inside an editor.
This is the kind of small project that makes APIs click. The app asks Weather.gov for structured data, follows the forecast link, extracts the next forecast periods, and prints something useful in the terminal. It is simple, but it connects Python, APIs, JSON, HTTP headers, Linux commands, and real-world automation.