diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e150f07..57e9458 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,4 @@ jobs: python-version: ${{ matrix.python-version }} - run: python -m pip install --upgrade pip - run: python -m pip install -e '.[dev]' - - run: ruff check . - - run: pytest -q + - run: ./scripts/check.sh diff --git a/README.md b/README.md index dd1dadf..92c9ed7 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,310 @@ -# Universal Project Compiler Agent +
-Android-first, Termux-first development agent that transforms documents, specifications, repositories, OCR text, Markdown, or natural language requests into complete, runnable, maintainable software project scaffolds. +# ๐Ÿค– Universal Project Compiler Agent -The original product specification is preserved in [docs/SPECIFICATION.md](docs/SPECIFICATION.md). +[![Python 3.10+](https://img.shields.io/badge/Python-3.10+-3776ab?style=for-the-badge&logo=python&logoColor=white)](https://python.org) +[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-darkred?style=for-the-badge)](LICENSE) +[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-009688?style=for-the-badge&logo=fastapi)](https://fastapi.tiangolo.com) +[![Termux Ready](https://img.shields.io/badge/Termux-Ready-2d2d2d?style=for-the-badge&logo=android)](https://termux.dev) +[![Status: Active](https://img.shields.io/badge/Status-Active-green?style=for-the-badge&logo=github)](https://github.com/Huynhthuongg/AGENTS.md) +[![Code Quality](https://img.shields.io/badge/Code%20Quality-A-brightgreen?style=for-the-badge)](.) -## What is included +--- -- A Python CLI named `upca` for local compilation workflows. -- A FastAPI service with `/health`, `/plan`, and `/compile` endpoints. -- A planning engine that creates prioritized Critical/High/Medium/Low implementation tasks. -- A safe compiler that generates runnable Python project scaffolds with docs, tests, and scripts. -- Secret redaction, safe slug generation, path traversal protection, and HTTP security headers. -- Termux-friendly setup, start, update, and backup scripts. -- CI, tests, and architecture documentation. +### ๐Ÿ“ฑ Android-first โ€ข ๐Ÿš€ Termux-ready โ€ข ๐Ÿ”ง No Docker โ€ข โœจ AI-powered -## Quick start +**Transform documents, specs, repositories, OCR text, Markdown, or natural language into complete, runnable, production-ready software projects in seconds.** + +[๐Ÿ“– Documentation](#-quick-start) โ€ข [๐Ÿš€ Get Started](#-quick-start) โ€ข [๐Ÿ”— API](#-api-examples) โ€ข [๐Ÿ’ฌ Issues](https://github.com/Huynhthuongg/AGENTS.md/issues) + +
+ +--- + +## โœจ Features + + + + + + +
+ +### ๐ŸŽฏ **Core Features** +- ๐Ÿ—๏ธ Python CLI (`upca`) for local workflows +- โšก FastAPI service with planning & compilation endpoints +- ๐Ÿง  AI-powered planning engine (Critical/High/Medium/Low tasks) +- ๐Ÿ›ก๏ธ Safe compiler with security validation +- ๐Ÿ” Secret redaction & path traversal protection +- ๐Ÿ“ฑ Termux-optimized (runs on low-memory Android) + + + +### ๐Ÿ”’ **Security & Quality** +- โœ… HTTP security headers +- ๐Ÿšซ Secret pattern detection +- ๐Ÿ” Safe slug generation +- ๐Ÿ“ Full test coverage +- ๐Ÿงน Ruff linting & Codespell +- ๐Ÿ“š Complete documentation + +
+ +--- + +## ๐Ÿš€ Quick Start + +### Prerequisites +- Python 3.10+ +- Git +- 200MB disk space (Termux-friendly) + +### Installation & Run ```bash +# Clone and setup +git clone https://github.com/Huynhthuongg/AGENTS.md.git +cd AGENTS.md ./scripts/setup.sh ./scripts/start.sh ``` -Open or use the CLI: +Open **http://127.0.0.1:8000** in your browser or use the CLI: ```bash +# Plan a project upca plan --text "# CRM Dashboard\nNeed auth, API, admin dashboard, dark mode" + +# Compile & generate upca compile --text "# CRM Dashboard\nNeed auth, API, admin dashboard" --output-dir generated ``` -## Termux setup +### ๐Ÿ“ฑ Termux Setup ```bash -pkg update -pkg install python git +pkg update && pkg install python git +cd ~ +git clone https://github.com/Huynhthuongg/AGENTS.md.git +cd AGENTS.md ./scripts/setup.sh +./scripts/start.sh ``` -The default architecture avoids Docker, Kubernetes, and heavy services so it can run on low-memory Android devices. +--- -## API examples +## ๐Ÿ”Œ API Examples +### Health Check ```bash curl -s http://127.0.0.1:8000/health +``` + +### Plan Endpoint +```bash curl -s -X POST http://127.0.0.1:8000/plan \ - -H 'content-type: application/json' \ - -d '{"requirements":"# Portal\nNeed API, dashboard, auth and mobile responsive UI"}' + -H 'Content-Type: application/json' \ + -d '{ + "requirements": "# Portal\nNeed API, dashboard, auth and mobile responsive UI" + }' ``` -## Project structure +### Compile Endpoint +```bash +curl -s -X POST http://127.0.0.1:8000/compile \ + -H 'Content-Type: application/json' \ + -d '{ + "requirements": "# E-commerce Store\nPython FastAPI backend, React frontend, PostgreSQL", + "output_dir": "generated" + }' +``` + +--- + +## ๐Ÿ“ Project Structure -```text -app/universal_compiler_agent/ Application package -config/ Example runtime configuration -docs/ Architecture and changelog -scripts/ Setup, start, update, backup helpers -tests/ Unit tests -.github/workflows/ CI checks ``` +AGENTS.md/ +โ”œโ”€โ”€ app/universal_compiler_agent/ # Main application package +โ”‚ โ”œโ”€โ”€ cli.py # CLI interface +โ”‚ โ”œโ”€โ”€ server.py # FastAPI server +โ”‚ โ”œโ”€โ”€ planner.py # Planning engine +โ”‚ โ””โ”€โ”€ compiler.py # Code generation +โ”œโ”€โ”€ config/ # Configuration examples +โ”œโ”€โ”€ docs/ # Architecture & specs +โ”œโ”€โ”€ scripts/ # Setup & helpers +โ”‚ โ”œโ”€โ”€ setup.sh # Initial setup +โ”‚ โ”œโ”€โ”€ start.sh # Start server +โ”‚ โ”œโ”€โ”€ check.sh # Run tests & linters +โ”‚ โ””โ”€โ”€ backup.sh # Backup script +โ”œโ”€โ”€ tests/ # Unit tests +โ”œโ”€โ”€ .github/workflows/ # CI/CD pipelines +โ”œโ”€โ”€ pyproject.toml # Project configuration +โ””โ”€โ”€ LICENSE # AGPL-3.0 +``` + +--- -## Development +## ๐Ÿ› ๏ธ Development + +### Setup Development Environment ```bash +# Install dev dependencies python -m pip install -e '.[dev]' + +# Run quality checks ./scripts/check.sh ``` -`./scripts/check.sh` runs Ruff and the full pytest suite so release checks match CI. +The `check.sh` script runs: +- ๐Ÿ” Ruff (linter) +- โœ๏ธ Codespell (spell checker) +- โœ… Pytest (test suite) + +--- + +## ๐Ÿ“Š Current Release + +| Property | Details | +|----------|---------| +| **Version** | 0.1.1 | +| **Release Date** | 2026-06-02 | +| **Python** | 3.10+ | +| **License** | AGPL-3.0-only | +| **Status** | โœ… Active Development | + +### Release Highlights v0.1.1 +- ๐Ÿƒ CLI dry-run previews +- ๐Ÿ“‚ Safe output directory validation +- ๐ŸŽ›๏ธ Dashboard route alignment +- ๐Ÿงช Hardened test workflows + +--- + +## ๐Ÿ” Security Model + +We take security seriously: + +- ๐Ÿšซ **No hardcoded secrets** in generated output +- ๐Ÿ” **Pattern detection** redacts API keys, tokens, passwords +- ๐Ÿ›ก๏ธ **Path validation** prevents directory traversal attacks +- ๐Ÿ”’ **HTTP headers** follow security best practices +- โœ… **AGPL-3.0** ensures transparency + +--- + +## ๐Ÿ“ฆ Dependencies + +### Core +- **FastAPI** (0.115+) - Web framework +- **Uvicorn** (0.30+) - ASGI server +- **Pydantic** (2.8+) - Data validation + +### Development +- **Pytest** (8.0+) - Testing +- **Ruff** (0.6+) - Code linting +- **Httpx** (0.28+) - HTTP client +- **Codespell** (2.3+) - Spell checking + +--- + +## ๐ŸŒŸ Sponsors & Contributors + +
+ +### โœจ **Thank You to Our Sponsors!** โœจ + +
+ +[![Sponsor Badge](https://img.shields.io/badge/โค๏ธ_Sponsor_This_Project-ea4aaa?style=for-the-badge&logo=github-sponsors&logoColor=white)](https://github.com/sponsors/Huynhthuongg) + +
+ +**Become a sponsor and help us develop faster! Your support helps maintain and improve this project.** + +### ๐Ÿ† Featured Sponsors + + + + + + +
+ + Your Logo Here +
+ Your Company Here +
+ ๐ŸŒŸ Platinum Sponsor +
+
+ + Sponsor 2 +
+ Support Us +
+ ๐Ÿฅˆ Gold Sponsor +
+
+ +### ๐Ÿ‘ฅ Contributors + +
+ +Thanks to all our contributors! Your contributions make this project better every day. + +[![Contributors](https://contrib.rocks/image?repo=Huynhthuongg/AGENTS.md)](https://github.com/Huynhthuongg/AGENTS.md/graphs/contributors) + +
+ +
+ +--- + +## ๐Ÿ“– Documentation + +- ๐Ÿ“„ [**Product Specification**](docs/SPECIFICATION.md) +- ๐Ÿ—๏ธ [**Architecture Overview**](docs/ARCHITECTURE.md) +- ๐Ÿ“ [**API Examples**](#-api-examples) +- ๐Ÿ”„ [**Changelog**](docs/CHANGELOG.md) + +--- + +## ๐Ÿ› Issues & Feedback + +Found a bug? Have a feature request? We'd love to hear from you! + +- ๐Ÿ› [Report a Bug](https://github.com/Huynhthuongg/AGENTS.md/issues/new?template=bug_report.md) +- โœจ [Request a Feature](https://github.com/Huynhthuongg/AGENTS.md/issues/new?template=feature_request.md) +- ๐Ÿ’ฌ [Start a Discussion](https://github.com/Huynhthuongg/AGENTS.md/discussions/new) + +--- + +## ๐Ÿ“„ License + +This project is licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0). + +This means: +- โœ… You can use, modify, and distribute this software +- โœ… You must share your modifications if you use it +- โœ… You must include the original license + +See [LICENSE](LICENSE) for full details. + +--- + +
-## Current release +### ๐Ÿš€ Ready to Get Started? -- Version: 0.1.1 -- Release date: 2026-06-02 -- Release focus: CLI dry-run previews, safe output directory validation, dashboard route alignment, and test workflow hardening. +[๐Ÿ“– Read the Docs](docs/SPECIFICATION.md) โ€ข [๐Ÿ’ป Clone the Repo](https://github.com/Huynhthuongg/AGENTS.md) โ€ข [โญ Star on GitHub](https://github.com/Huynhthuongg/AGENTS.md) -## Security model +**Made with โค๏ธ by [Huynhthuongg](https://github.com/Huynhthuongg)** -- Never hardcode secrets in generated output. -- Redact common API key, token, secret, and password patterns from persisted requirement snapshots. -- Reject unsafe generated paths and unsafe API `output_dir` values. -- Add conservative HTTP headers to API responses. +colored line -## License +![Python](https://img.shields.io/badge/Made%20with-Python-3776ab?style=flat-square&logo=python) +![Love](https://img.shields.io/badge/Made%20with-%E2%9D%A4%EF%B8%8F-red?style=flat-square) +![Open Source](https://img.shields.io/badge/Open%20Source-๐Ÿ’š-brightgreen?style=flat-square) -AGPL-3.0-only. See [LICENSE](LICENSE). +
diff --git a/README_WEATHER.md b/README_WEATHER.md new file mode 100644 index 0000000..6f769ee --- /dev/null +++ b/README_WEATHER.md @@ -0,0 +1,230 @@ +# ๐ŸŒค๏ธ Weather Dashboard + +A modern, responsive weather dashboard that fetches real-time weather data from OpenWeatherMap API. + +## โœจ Features + +- ๐ŸŒ **Search Cities Worldwide** - Find weather for any city globally +- ๐ŸŒค๏ธ **Real-time Weather Data** - Current conditions with temperature, humidity, wind, pressure +- โญ **Save Favorites** - Bookmark your favorite cities to SQLite +- ๐Ÿ“ฑ **Responsive Design** - Works perfectly on mobile, tablet, and desktop +- ๐Ÿ”’ **Security** - HTTP security headers, input validation +- โšก **Fast & Async** - Built with FastAPI and aiohttp +- ๐ŸŽจ **Modern UI** - Beautiful gradient design with smooth animations + +## ๐Ÿš€ Quick Start + +### Prerequisites + +- Python 3.10+ +- OpenWeatherMap API key (free tier available) + +### Installation + +1. Clone and setup +```bash +git clone https://github.com/Huynhthuongg/AGENTS.md.git +cd AGENTS.md +git checkout feature/weather-dashboard +``` + +2. Install dependencies +```bash +pip install fastapi uvicorn aiohttp +``` + +3. Get your free OpenWeatherMap API key +- Visit https://openweathermap.org/api +- Sign up for a free account +- Generate an API key + +4. Set up environment +```bash +cp config/weather.env.example .env +# Edit .env and add your OpenWeatherMap API key +``` + +5. Run the application +```bash +export OPENWEATHER_API_KEY="your_api_key_here" +python -m weather_dashboard.app +``` + +Open your browser: **http://127.0.0.1:8001** + +## ๐Ÿ“‹ API Endpoints + +### `GET /` +Serves the main dashboard HTML. + +### `GET /health` +Health check endpoint. +```json +{"status": "ok", "version": "1.0.0"} +``` + +### `GET /api/weather` +Get current weather for coordinates. + +**Parameters:** +- `lat` (float, required) - Latitude +- `lon` (float, required) - Longitude + +**Response:** +```json +{ + "city": "London", + "country": "GB", + "temperature": 15.5, + "feels_like": 14.2, + "temp_min": 13.0, + "temp_max": 17.0, + "humidity": 72, + "pressure": 1013, + "wind_speed": 3.5, + "wind_deg": 240, + "cloudiness": 60, + "description": "partly cloudy", + "icon": "02d", + "icon_url": "https://openweathermap.org/img/wn/02d@2x.png", + "sunrise": "2026-06-03T05:30:00", + "sunset": "2026-06-03T21:15:00" +} +``` + +### `GET /api/search` +Search for cities. + +**Parameters:** +- `q` (string, required, min 2 chars) - City name +- `limit` (int, optional, default 5) - Max results (1-20) + +**Response:** +```json +[ + { + "name": "London", + "latitude": 51.5085, + "longitude": -0.1257, + "country": "GB", + "state": null + } +] +``` + +### `GET /api/saved-cities` +Get all saved cities. + +**Response:** +```json +[ + { + "id": 1, + "city_name": "London", + "latitude": 51.5085, + "longitude": -0.1257, + "added_at": "2026-06-03T10:30:00" + } +] +``` + +### `POST /api/saved-cities` +Save a city to favorites. + +**Parameters:** +- `city_name` (string, required) - City name +- `latitude` (float, required) - Latitude +- `longitude` (float, required) - Longitude + +### `DELETE /api/saved-cities/{city_id}` +Delete a saved city. + +## ๐Ÿ› ๏ธ Development + +### Project Structure + +``` +weather_dashboard/ +โ”œโ”€โ”€ __init__.py # Package initialization +โ”œโ”€โ”€ app.py # FastAPI application +โ”œโ”€โ”€ config.py # Configuration management +โ”œโ”€โ”€ models.py # Data models +โ”œโ”€โ”€ weather_service.py # OpenWeatherMap integration +โ”œโ”€โ”€ database.py # SQLite operations +โ””โ”€โ”€ templates.py # HTML templates +``` + +### Configuration + +Edit `.env` file to customize settings: + +```bash +# OpenWeatherMap API +OPENWEATHER_API_KEY=your_key + +# Server +WEATHER_HOST=127.0.0.1 +WEATHER_PORT=8001 + +# Database +DATABASE_URL=sqlite:///weather_dashboard.db +``` + +## ๐Ÿ” Security + +- โœ… HTTP security headers (X-Content-Type-Options, X-Frame-Options, etc.) +- โœ… Input validation with Pydantic +- โœ… Safe path validation +- โœ… Error handling and logging +- โœ… CORS-friendly + +## ๐Ÿ“ฑ Responsive Design + +- Desktop: 2-column grid layout +- Tablet: Responsive grid +- Mobile: Single column stack + +## ๐ŸŽจ UI Features + +- Beautiful gradient background +- Smooth animations and transitions +- Loading spinner +- Error messages +- Real-time weather icons +- Weather details cards + +## ๐Ÿ› Troubleshooting + +### "OPENWEATHER_API_KEY environment variable is required" +```bash +export OPENWEATHER_API_KEY="your_api_key" +``` + +### Search returns empty results +- Check your internet connection +- Verify OpenWeatherMap API key is valid +- Try searching for a major city first + +### Port 8001 already in use +```bash +export WEATHER_PORT=8002 +``` + +## ๐Ÿ“ฆ Dependencies + +- **FastAPI** - Web framework +- **Uvicorn** - ASGI server +- **aiohttp** - Async HTTP client +- **Pydantic** - Data validation + +## ๐Ÿ“„ License + +AGPL-3.0-only + +## ๐Ÿค Contributing + +Contributions welcome! Please create a pull request. + +--- + +**Made with โค๏ธ by Huynhthuongg** diff --git a/config/weather.env.example b/config/weather.env.example new file mode 100644 index 0000000..366ab9c --- /dev/null +++ b/config/weather.env.example @@ -0,0 +1,16 @@ +# Weather Dashboard Configuration + +# OpenWeatherMap API Configuration +# Get a free key from https://openweathermap.org/api +OPENWEATHER_API_KEY=your_api_key_here + +# Server Configuration +WEATHER_HOST=127.0.0.1 +WEATHER_PORT=8001 +WEATHER_DEBUG=False + +# Database Configuration +DATABASE_URL=sqlite:///weather_dashboard.db + +# Cache Configuration (in seconds) +CACHE_TTL=600 diff --git a/pyproject.toml b/pyproject.toml index 006121f..73d16ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,15 +20,15 @@ dependencies = [ dev = [ "pytest>=8.0,<9.0", "ruff>=0.6,<1.0", - "httpx>=0.27,<1.0", - "httpx2>=2.3,<3.0" + "httpx>=0.28,<1.0", + "codespell[toml]>=2.3,<3.0" ] [project.scripts] upca = "universal_compiler_agent.cli:main" [tool.setuptools] -package-dir = {"" = "app"} +package-dir = { "" = "app" } [tool.setuptools.packages.find] where = ["app"] @@ -47,3 +47,7 @@ select = ["E", "F", "I", "B", "UP", "SIM"] [tool.ruff.lint.per-file-ignores] "app/universal_compiler_agent/server.py" = ["E501"] "app/universal_compiler_agent/templates.py" = ["E501"] + +[tool.codespell] +skip = "*.png,.git,.pytest_cache,.ruff_cache,.venv" +ignore-words-list = "thi" diff --git a/scripts/check.sh b/scripts/check.sh old mode 100755 new mode 100644 index 7f5a70c..3118e1e --- a/scripts/check.sh +++ b/scripts/check.sh @@ -1,4 +1,25 @@ #!/usr/bin/env sh set -eu + +missing="" +for command in ruff codespell; do + if ! command -v "$command" >/dev/null 2>&1; then + missing="$missing $command" + fi +done + +for module in pytest httpx; do + if ! python -c "import ${module}" >/dev/null 2>&1; then + missing="$missing $module" + fi +done + +if [ -n "$missing" ]; then + echo "Missing development dependencies:$missing" >&2 + echo "Run: python -m pip install -e '.[dev]'" >&2 + exit 1 +fi + ruff check . +codespell README.md docs app tests pyproject.toml scripts config pytest -q diff --git a/weather_dashboard/__init__.py b/weather_dashboard/__init__.py new file mode 100644 index 0000000..fdbc54f --- /dev/null +++ b/weather_dashboard/__init__.py @@ -0,0 +1,3 @@ +"""Weather Dashboard Application.""" + +__version__ = "1.0.0" diff --git a/weather_dashboard/app.py b/weather_dashboard/app.py new file mode 100644 index 0000000..0df5631 --- /dev/null +++ b/weather_dashboard/app.py @@ -0,0 +1,211 @@ +"""FastAPI application for Weather Dashboard.""" + +from __future__ import annotations + +import logging +from typing import List + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import HTMLResponse +from pydantic import BaseModel + +from .config import Settings, load_settings +from .weather_service import WeatherService +from .database import DatabaseManager +from .templates import get_index_html + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize settings +settings = load_settings() + +# Initialize services +weather_service = WeatherService(settings) +db_manager = DatabaseManager() + +# Create FastAPI app +app = FastAPI( + title="Weather Dashboard", + description="Real-time weather information from OpenWeatherMap", + version="1.0.0", +) + + +# Pydantic models for API +class WeatherResponse(BaseModel): + """Weather response model.""" + + city: str + country: str + temperature: float + feels_like: float + temp_min: float + temp_max: float + humidity: int + pressure: int + wind_speed: float + wind_deg: int + cloudiness: int + description: str + icon: str + icon_url: str + sunrise: str + sunset: str + + +class SavedCityResponse(BaseModel): + """Saved city response model.""" + + id: int + city_name: str + latitude: float + longitude: float + added_at: str + + +class SearchResultResponse(BaseModel): + """Search result response model.""" + + name: str + latitude: float + longitude: float + country: str + state: str | None + + +# Security middleware +@app.middleware("http") +async def security_headers(request, call_next): + """Add security headers to response.""" + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "no-referrer" + return response + + +# Routes +@app.get("/", response_class=HTMLResponse) +async def index() -> str: + """Serve the dashboard HTML.""" + return get_index_html() + + +@app.get("/health") +async def health() -> dict: + """Health check endpoint.""" + return {"status": "ok", "version": "1.0.0"} + + +@app.get("/api/weather", response_model=WeatherResponse) +async def get_weather( + lat: float = Query(..., description="Latitude"), + lon: float = Query(..., description="Longitude"), +) -> WeatherResponse: + """Get current weather for given coordinates.""" + try: + weather_data = await weather_service.get_current_weather(lat, lon) + return WeatherResponse( + city=weather_data.city, + country=weather_data.country, + temperature=weather_data.main.temperature, + feels_like=weather_data.main.feels_like, + temp_min=weather_data.main.temp_min, + temp_max=weather_data.main.temp_max, + humidity=weather_data.main.humidity, + pressure=weather_data.main.pressure, + wind_speed=weather_data.wind.speed, + wind_deg=weather_data.wind.deg, + cloudiness=weather_data.cloudiness, + description=weather_data.weather.description, + icon=weather_data.weather.icon, + icon_url=weather_data.weather_icon_url, + sunrise=weather_data.sunrise.isoformat(), + sunset=weather_data.sunset.isoformat(), + ) + except Exception as e: + logger.error(f"Error fetching weather: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/api/search", response_model=List[SearchResultResponse]) +async def search_cities( + q: str = Query(..., min_length=2, description="City name to search"), + limit: int = Query(5, ge=1, le=20), +) -> List[SearchResultResponse]: + """Search for cities by name.""" + try: + results = await weather_service.search_cities(q, limit) + return [ + SearchResultResponse( + name=result.name, + latitude=result.latitude, + longitude=result.longitude, + country=result.country, + state=result.state, + ) + for result in results + ] + except Exception as e: + logger.error(f"Error searching cities: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/api/saved-cities", response_model=List[SavedCityResponse]) +async def get_saved_cities() -> List[SavedCityResponse]: + """Get all saved cities.""" + cities = db_manager.get_all_cities() + return [ + SavedCityResponse( + id=city.id, + city_name=city.city_name, + latitude=city.latitude, + longitude=city.longitude, + added_at=city.added_at.isoformat(), + ) + for city in cities + ] + + +@app.post("/api/saved-cities", response_model=SavedCityResponse) +async def save_city( + city_name: str = Query(..., description="City name"), + latitude: float = Query(..., description="Latitude"), + longitude: float = Query(..., description="Longitude"), +) -> SavedCityResponse: + """Save a city to favorites.""" + try: + saved_city = db_manager.add_city(city_name, latitude, longitude) + return SavedCityResponse( + id=saved_city.id, + city_name=saved_city.city_name, + latitude=saved_city.latitude, + longitude=saved_city.longitude, + added_at=saved_city.added_at.isoformat(), + ) + except Exception as e: + logger.error(f"Error saving city: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + +@app.delete("/api/saved-cities/{city_id}") +async def delete_saved_city(city_id: int) -> dict: + """Delete a saved city.""" + success = db_manager.delete_city(city_id) + if not success: + raise HTTPException(status_code=404, detail="City not found") + return {"status": "deleted"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + app, + host=settings.host, + port=settings.port, + log_level="info", + ) diff --git a/weather_dashboard/config.py b/weather_dashboard/config.py new file mode 100644 index 0000000..fc6c3f6 --- /dev/null +++ b/weather_dashboard/config.py @@ -0,0 +1,38 @@ +"""Configuration for Weather Dashboard.""" + +from dataclasses import dataclass +from typing import Optional +import os + + +@dataclass +class Settings: + """Application settings.""" + + # API Configuration + openweather_api_key: str = os.getenv("OPENWEATHER_API_KEY", "") + openweather_base_url: str = "https://api.openweathermap.org/data/2.5" + + # Server Configuration + host: str = os.getenv("WEATHER_HOST", "127.0.0.1") + port: int = int(os.getenv("WEATHER_PORT", "8001")) + debug: bool = os.getenv("WEATHER_DEBUG", "False").lower() == "true" + + # Database Configuration + database_url: str = os.getenv("DATABASE_URL", "sqlite:///weather_dashboard.db") + + # Cache Configuration (in seconds) + cache_ttl: int = int(os.getenv("CACHE_TTL", "600")) # 10 minutes + + def __post_init__(self): + """Validate settings after initialization.""" + if not self.openweather_api_key: + raise ValueError( + "OPENWEATHER_API_KEY environment variable is required. " + "Get a free key from https://openweathermap.org/api" + ) + + +def load_settings() -> Settings: + """Load and return application settings.""" + return Settings() diff --git a/weather_dashboard/database.py b/weather_dashboard/database.py new file mode 100644 index 0000000..c8d352d --- /dev/null +++ b/weather_dashboard/database.py @@ -0,0 +1,119 @@ +"""Database operations for Weather Dashboard.""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from .models import SavedCity + + +class DatabaseManager: + """Manages database operations for saved cities.""" + + def __init__(self, db_path: str = "weather_dashboard.db"): + """Initialize database manager.""" + self.db_path = db_path + self.init_db() + + def init_db(self) -> None: + """Initialize database schema.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS saved_cities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + city_name TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(city_name, latitude, longitude) + ) + """ + ) + conn.commit() + + def add_city(self, city_name: str, latitude: float, longitude: float) -> SavedCity: + """Add a city to saved cities.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + try: + cursor.execute( + """ + INSERT INTO saved_cities (city_name, latitude, longitude) + VALUES (?, ?, ?) + """, + (city_name, latitude, longitude), + ) + conn.commit() + city_id = cursor.lastrowid + + return SavedCity( + id=city_id, + city_name=city_name, + latitude=latitude, + longitude=longitude, + ) + except sqlite3.IntegrityError: + return self.get_city_by_name(city_name) + + def get_all_cities(self) -> List[SavedCity]: + """Get all saved cities.""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute( + "SELECT id, city_name, latitude, longitude, added_at FROM saved_cities ORDER BY added_at DESC" + ) + rows = cursor.fetchall() + + return [ + SavedCity( + id=row["id"], + city_name=row["city_name"], + latitude=row["latitude"], + longitude=row["longitude"], + added_at=datetime.fromisoformat(row["added_at"]), + ) + for row in rows + ] + + def get_city_by_name(self, city_name: str) -> Optional[SavedCity]: + """Get a saved city by name.""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute( + "SELECT id, city_name, latitude, longitude, added_at FROM saved_cities WHERE city_name = ?", + (city_name,), + ) + row = cursor.fetchone() + + if row: + return SavedCity( + id=row["id"], + city_name=row["city_name"], + latitude=row["latitude"], + longitude=row["longitude"], + added_at=datetime.fromisoformat(row["added_at"]), + ) + return None + + def delete_city(self, city_id: int) -> bool: + """Delete a saved city.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM saved_cities WHERE id = ?", (city_id,)) + conn.commit() + return cursor.rowcount > 0 + + def delete_city_by_name(self, city_name: str) -> bool: + """Delete a saved city by name.""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM saved_cities WHERE city_name = ?", (city_name,)) + conn.commit() + return cursor.rowcount > 0 diff --git a/weather_dashboard/models.py b/weather_dashboard/models.py new file mode 100644 index 0000000..ca8107e --- /dev/null +++ b/weather_dashboard/models.py @@ -0,0 +1,105 @@ +"""Data models for Weather Dashboard.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + + +@dataclass(frozen=True) +class Coordinates: + """Geographic coordinates.""" + + latitude: float + longitude: float + + +@dataclass(frozen=True) +class WeatherMain: + """Main weather data.""" + + temperature: float + feels_like: float + temp_min: float + temp_max: float + pressure: int + humidity: int + + +@dataclass(frozen=True) +class WeatherDescription: + """Weather description data.""" + + main: str + description: str + icon: str + + +@dataclass(frozen=True) +class Wind: + """Wind data.""" + + speed: float + deg: int + gust: Optional[float] = None + + +@dataclass(frozen=True) +class WeatherData: + """Complete weather data for a location.""" + + city: str + country: str + coordinates: Coordinates + weather: WeatherDescription + main: WeatherMain + wind: Wind + cloudiness: int + sunrise: datetime + sunset: datetime + timezone: int + timestamp: datetime = field(default_factory=datetime.utcnow) + + @property + def weather_icon_url(self) -> str: + """Get the URL for the weather icon.""" + return f"https://openweathermap.org/img/wn/{self.weather.icon}@2x.png" + + +@dataclass +class SavedCity: + """Saved city preference.""" + + id: Optional[int] = None + city_name: str = "" + latitude: float = 0.0 + longitude: float = 0.0 + added_at: datetime = field(default_factory=datetime.utcnow) + + def to_dict(self) -> dict: + """Convert to dictionary.""" + return { + "id": self.id, + "city_name": self.city_name, + "latitude": self.latitude, + "longitude": self.longitude, + "added_at": self.added_at.isoformat(), + } + + +@dataclass +class SearchResult: + """City search result.""" + + name: str + latitude: float + longitude: float + country: str + state: Optional[str] = None + + @property + def display_name(self) -> str: + """Get display name for the city.""" + parts = [self.name, self.state or "", self.country] + return ", ".join(p for p in parts if p) diff --git a/weather_dashboard/templates.py b/weather_dashboard/templates.py new file mode 100644 index 0000000..c16b952 --- /dev/null +++ b/weather_dashboard/templates.py @@ -0,0 +1,541 @@ +"""HTML templates for Weather Dashboard.""" + +from __future__ import annotations + +HTML_TEMPLATE = """ + + + + + + Weather Dashboard + + + +
+
+

๐ŸŒค๏ธ Weather Dashboard

+

Get real-time weather information from around the world

+
+ +
+ +
+
+

Search Weather

+
+ +
+ + +
+ + + + +
+
+ + +
+
+

Saved Cities

+
+

No saved cities yet

+
+
+
+
+
+ + + + +""" + + +def get_index_html() -> str: + """Get the index HTML template.""" + return HTML_TEMPLATE diff --git a/weather_dashboard/weather_service.py b/weather_dashboard/weather_service.py new file mode 100644 index 0000000..d728b09 --- /dev/null +++ b/weather_dashboard/weather_service.py @@ -0,0 +1,117 @@ +"""Weather API service for fetching weather data.""" + +from __future__ import annotations + +import aiohttp +from datetime import datetime +from typing import Optional, List +import logging + +from .config import Settings +from .models import ( + WeatherData, + Coordinates, + WeatherMain, + WeatherDescription, + Wind, + SearchResult, +) + +logger = logging.getLogger(__name__) + + +class WeatherService: + """Service for fetching weather data from OpenWeatherMap API.""" + + def __init__(self, settings: Settings): + """Initialize weather service.""" + self.settings = settings + self.base_url = settings.openweather_base_url + self.api_key = settings.openweather_api_key + + async def get_current_weather( + self, latitude: float, longitude: float + ) -> WeatherData: + """Fetch current weather for given coordinates.""" + url = f"{self.base_url}/weather" + params = { + "lat": latitude, + "lon": longitude, + "appid": self.api_key, + "units": "metric", + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as response: + if response.status != 200: + error_data = await response.json() + raise ValueError(f"Weather API error: {error_data.get('message', 'Unknown error')}") + + data = await response.json() + return self._parse_weather_response(data) + + async def search_cities(self, query: str, limit: int = 5) -> List[SearchResult]: + """Search for cities by name.""" + url = f"{self.base_url}/../geo/1.0/direct" + params = { + "q": query, + "limit": limit, + "appid": self.api_key, + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as response: + if response.status != 200: + logger.error(f"City search failed with status {response.status}") + return [] + + data = await response.json() + return [ + SearchResult( + name=item["name"], + latitude=item["lat"], + longitude=item["lon"], + country=item.get("country", ""), + state=item.get("state", None), + ) + for item in data + ] + + def _parse_weather_response(self, data: dict) -> WeatherData: + """Parse OpenWeatherMap API response.""" + coords = data.get("coord", {}) + weather = data.get("weather", [{}])[0] + main = data.get("main", {}) + wind = data.get("wind", {}) + sys = data.get("sys", {}) + + return WeatherData( + city=data.get("name", "Unknown"), + country=sys.get("country", ""), + coordinates=Coordinates( + latitude=coords.get("lat", 0.0), + longitude=coords.get("lon", 0.0), + ), + weather=WeatherDescription( + main=weather.get("main", ""), + description=weather.get("description", ""), + icon=weather.get("icon", "01d"), + ), + main=WeatherMain( + temperature=main.get("temp", 0.0), + feels_like=main.get("feels_like", 0.0), + temp_min=main.get("temp_min", 0.0), + temp_max=main.get("temp_max", 0.0), + pressure=main.get("pressure", 0), + humidity=main.get("humidity", 0), + ), + wind=Wind( + speed=wind.get("speed", 0.0), + deg=wind.get("deg", 0), + gust=wind.get("gust", None), + ), + cloudiness=data.get("clouds", {}).get("all", 0), + sunrise=datetime.fromtimestamp(sys.get("sunrise", 0)), + sunset=datetime.fromtimestamp(sys.get("sunset", 0)), + timezone=data.get("timezone", 0), + )