From 68416f0e8a1924dad422e8e065d3d0852cb10d67 Mon Sep 17 00:00:00 2001 From: Parissa Jamali Date: Fri, 9 Aug 2024 14:11:46 +0100 Subject: [PATCH] create app --- .env.example | 1 + .github/workflows/ci-cd.yml | 54 +++++++++++++++++++++ .gitignore | 3 ++ Dockerfile | 19 ++++++++ app/__init__.py | 0 app/crud.py | 46 ++++++++++++++++++ app/database.py | 37 +++++++++++++++ app/main.py | 73 ++++++++++++++++++++++++++++ app/models.py | 12 +++++ app/schemas.py | 18 +++++++ app/weather.py | 95 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 11 +++++ requirements.txt | 25 ++++++++++ test_main.py | 51 ++++++++++++++++++++ 14 files changed, 445 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci-cd.yml create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/crud.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/schemas.py create mode 100644 app/weather.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 test_main.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ada4c47 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +WEATHER_API_KEY=api_key_from_https://www.weatherapi.com/ diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..0bacca6 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,54 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Install Docker + run: | + sudo apt-get update + sudo apt-get install -y docker-ce docker-ce-cli containerd.io + + - name: Install Docker Compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/download/v2.20.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + + - name: Set environment variable + run: echo "WEATHER_API_KEY=${{ secrets.WEATHER_API_KEY }}" >> $GITHUB_ENV + + - name: Build and run containers + run: | + docker-compose up -d + env: + WEATHER_API_KEY: ${{ secrets.WEATHER_API_KEY }} + + - name: Run tests + run: | + pytest + + - name: Tear down + run: | + docker-compose down diff --git a/.gitignore b/.gitignore index 82f9275..29cb61d 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Ignore SQLite database file +weather.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cfdf68d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.10 + +# Set working directory +WORKDIR /app + +# Copy requirements file +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Command to run the FastAPI application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud.py b/app/crud.py new file mode 100644 index 0000000..0413711 --- /dev/null +++ b/app/crud.py @@ -0,0 +1,46 @@ +from sqlalchemy.orm import Session +from datetime import datetime + +from app import models, schemas + +def get_weather_by_city_and_date(db: Session, city: str, date: datetime): + """ + Retrieve a weather record for a specific city and date from the database. + + It returns the first matching record, or `None` if no match is found. + + Args: + db (Session): The SQLAlchemy session object used for database operations. + city (str): The name of the city for which to retrieve the weather data. + date (datetime): The specific date for which to retrieve the weather data. + + Returns: + models.Weather or None: The weather record as a SQLAlchemy model instance if found, + otherwise `None`. + """ + + return db.query(models.Weather).filter(models.Weather.city == city, models.Weather.date == date).first() + +def create_weather(db: Session, weather: schemas.WeatherCreate): + """ + Create a new weather record in the database. + + This function takes a SQLAlchemy database session and a `WeatherCreate` schema object, + then creates and stores a new weather record in the database. The function commits + the transaction and refreshes the instance to ensure it contains any updates made by the + database (e.g., generated primary keys). + + Args: + db (Session): The SQLAlchemy session object used for database operations. + weather (schemas.WeatherCreate): The Pydantic schema object containing the weather data to be saved. + + Returns: + models.Weather: The newly created weather record as a SQLAlchemy model instance. + """ + + db_weather = models.Weather(**weather.model_dump()) + db.add(db_weather) + db.commit() + db.refresh(db_weather) + + return db_weather diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..a5394a3 --- /dev/null +++ b/app/database.py @@ -0,0 +1,37 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = "sqlite:///./weather.db" + +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +def init_db(): + Base.metadata.create_all(bind=engine) + +def get_db(): + """ + Provides a database session for dependency injection in FastAPI. + + This generator function yields a database session (`SessionLocal`) + for use in API endpoint functions. The session is automatically + closed after the request is completed, ensuring proper resource management. + + Yields: + Session: A SQLAlchemy session object for interacting with the database. + + Example: + This function is typically used as a dependency in FastAPI routes: + + @app.get("/items/") + def read_items(db: Session = Depends(get_db)): + return db.query(Item).all() + """ + + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a0563ff --- /dev/null +++ b/app/main.py @@ -0,0 +1,73 @@ +from fastapi import FastAPI, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime +from pydantic import BaseModel, field_validator +import re +from app import weather, schemas, database + + +class WeatherRequest(BaseModel): + city: str + date: str + + @field_validator("date") + def validate_date_format(cls, value): + """ + Validate the format of a date string. + + This method checks if the provided date string matches the expected format + "YYYY-MM-DD". If the format is invalid, it raises a `ValueError`. + + Args: + cls: The class that the validator is being applied to (used for class methods). + value (str): The date string to be validated. + + Returns: + str: The original date string if it matches the required format. + + Raises: + ValueError: If the date string does not match the format "YYYY-MM-DD", + a `ValueError` is raised with a message indicating the format requirement. + + Example: + >>> validate_date_format("2024-08-08") + '2024-08-08' + + >>> validate_date_format("08-08-2024") + ValueError: Invalid date format. Use 'YYYY-MM-DD'. + """ + if not re.match(r"^\d{4}-\d{2}-\d{2}$", value): + raise ValueError("Invalid date format. Use 'YYYY-MM-DD'.") + return value + +# Initialize the database +database.init_db() + +app = FastAPI() + +@app.get("/weather/", response_model=schemas.WeatherResponse) +async def get_weather(city: str, date: str, db: Session = Depends(database.get_db)): + """ + Retrieve weather data for a specific city and date. + + This function fetches weather data from the database for the specified city and date. + If the data is not found, it raises an HTTP 404 exception. + + Args: + city (str): The name of the city for which to retrieve weather data. + date (str): The date for which to retrieve weather data, in the format "YYYY-MM-DD". + db (Session, optional): The database session dependency, automatically injected by FastAPI. + + Returns: + dict: A dictionary containing the weather data for the specified city and date. + + Raises: + HTTPException: If no weather data is found for the given city and date, a 404 error is raised. + """ + + date_obj = datetime.strptime(date, "%Y-%m-%d") + weather_data = await weather.get_weather(db, city, date_obj) + if not weather_data: + raise HTTPException(status_code=404, detail="Weather data not found") + + return weather_data diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..093546e --- /dev/null +++ b/app/models.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime +from app.database import Base + +class Weather(Base): + __tablename__ = "weather" + id = Column(Integer, primary_key=True, index=True) + city = Column(String, index=True) + date = Column(DateTime, index=True) + min_temp = Column(Float) + max_temp = Column(Float) + avg_temp = Column(Float) + humidity = Column(Float) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..1b65cc4 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime + +class WeatherBase(BaseModel): + city: str + date: datetime + min_temp: float + max_temp: float + avg_temp: float + humidity: float + +class WeatherCreate(WeatherBase): + pass + +class WeatherResponse(WeatherBase): + id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/app/weather.py b/app/weather.py new file mode 100644 index 0000000..7c66b3c --- /dev/null +++ b/app/weather.py @@ -0,0 +1,95 @@ +import httpx +from sqlalchemy.orm import Session +import os +from dotenv import load_dotenv +from app import crud, schemas +import logging + +# Initialize logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +load_dotenv() + +WEATHER_API_URL = "http://api.weatherapi.com/v1/history.json" +API_KEY = os.getenv("WEATHER_API_KEY") + +if not API_KEY: + logger.error("Weather API key is not set. Please set WEATHER_API_KEY in the environment variables.") + raise ValueError("Weather API key is missing.") + + +async def fetch_weather_data(city: str, date: str) -> dict: + """ + Fetch weather data from the external API. + + Args: + city (str): The name of the city to fetch weather data for. + date (str): The date to fetch weather data for, in 'YYYY-MM-DD' format. + + Returns: + dict: The weather data in JSON format. + + Raises: + httpx.HTTPStatusError: If the HTTP request to the weather API fails. + ValueError: If the response does not contain expected data. + """ + async with httpx.AsyncClient() as client: + try: + response = await client.get(WEATHER_API_URL, params={"key": API_KEY, "q": city, "dt": date}) + response.raise_for_status() + data = response.json() + + # Check if the response has the expected structure + if "forecast" not in data or "forecastday" not in data["forecast"]: + logger.error("Unexpected response structure: %s", data) + raise ValueError("Unexpected response structure from weather API.") + + return data + except httpx.HTTPStatusError as e: + logger.error("HTTP request failed: %s", e) + raise + except ValueError as e: + logger.error("Data validation error: %s", e) + raise + +async def get_weather(db: Session, city: str, date: str) -> schemas.WeatherResponse: + """ + Retrieve weather data from the database or fetch it from the API if not present. + + Args: + db (Session): The database session object. + city (str): The name of the city to get weather data for. + date (str): The date to get weather data for, in 'YYYY-MM-DD' format. + + Returns: + schemas.Weather: The weather data object. + + Raises: + Exception: If there is an error fetching or storing weather data. + """ + # Fetch weather from the database + weather = crud.get_weather_by_city_and_date(db, city, date) + if weather: + return weather + + # Fetch weather from the external API + data = await fetch_weather_data(city, date) + forecast = data["forecast"]["forecastday"][0]["day"] + + # Create a weather data object + weather_data = schemas.WeatherCreate( + city=city, + date=date, + min_temp=forecast.get("mintemp_c"), + max_temp=forecast.get("maxtemp_c"), + avg_temp=forecast.get("avgtemp_c"), + humidity=forecast.get("avghumidity") + ) + + # Store weather data in the database + try: + return crud.create_weather(db, weather_data) + except Exception as e: + logger.error("Error saving weather data to database: %s", e) + raise diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..772f9fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.9" + +services: + web: + build: . + ports: + - "8000:8000" + volumes: + - .:/app + environment: + - DATABASE_URL=sqlite:///./weather.db diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aa1fd6c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +annotated-types==0.7.0 +anyio==4.4.0 +certifi==2024.7.4 +click==8.1.7 +fastapi==0.112.0 +greenlet==3.0.3 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 +idna==3.7 +iniconfig==2.0.0 +packaging==24.1 +pluggy==1.5.0 +pydantic==2.8.2 +pydantic_core==2.20.1 +pytest==8.3.2 +pytest-asyncio==0.23.8 +pytest-httpx==0.30.0 +pytest-mock==3.14.0 +python-dotenv==1.0.1 +sniffio==1.3.1 +SQLAlchemy==2.0.32 +starlette==0.37.2 +typing_extensions==4.12.2 +uvicorn==0.30.5 diff --git a/test_main.py b/test_main.py new file mode 100644 index 0000000..032eae0 --- /dev/null +++ b/test_main.py @@ -0,0 +1,51 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + +def test_get_weather(): + """ + Test the `/weather/` endpoint for retrieving weather data for a specific city and date. + + This test sends a GET request to the `/weather/` endpoint with the query parameters + `city` set to "London" and `date` set to "2024-08-09". It asserts that the response + status code is 200, indicating a successful request. The test also checks that the + response JSON contains the expected keys: "city", "date", "min_temp", "max_temp", + and "avg_temp". + + Steps: + 1. Sends a GET request to the `/weather/` endpoint with the query parameters + `city` set to "London" and `date` set to "2024-08-09". + 2. Asserts that the response status code is 200, indicating a successful request. + 3. Parses the JSON response data. + 4. Prints the response data for debugging purposes. + 5. Asserts that the response JSON contains the expected keys: "city", "date", + "min_temp", "max_temp", and "avg_temp". + + Assertions: + - The response status code should be 200 (OK). + - The response JSON should contain the keys "city", "date", "min_temp", + "max_temp", and "avg_temp". + + Example: + Running this test might produce the following output in the console: + + data { + "city": "London", + "date": "2024-08-09", + "min_temp": 15.2, + "max_temp": 25.4, + "avg_temp": 20.3 + } + """ + + response = client.get("/weather/?city=London&date=2024-08-09") + assert response.status_code == 200 + data = response.json() + print('data', data) + assert "city" in data + assert "date" in data + assert "min_temp" in data + assert "max_temp" in data + assert "avg_temp" in data