Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ ignore_missing_imports = false
[tool.ruff]
line-length = 100
target-version = "py311"
select = ["E", "F", "I", "N", "UP", "ANN", "S", "B", "A"]
ignore = ["ANN101", "ANN102"]

[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]

[tool.black]
line-length = 100
Expand Down
8 changes: 6 additions & 2 deletions src/atlas/api/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, status, Security

from fastapi import HTTPException, Security, status
from fastapi.security.api_key import APIKeyHeader
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from atlas.config.settings import settings

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
Expand All @@ -16,6 +18,7 @@
autoflush=False,
)


async def get_api_key(api_key_header: str = Security(api_key_header)) -> str:
"""Validate the provided API key."""
if api_key_header == settings.api_key:
Expand All @@ -25,6 +28,7 @@ async def get_api_key(api_key_header: str = Security(api_key_header)) -> str:
detail="Could not validate API key",
)


async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""Provide an asynchronous database session."""
async with AsyncSessionLocal() as session:
Expand Down
4 changes: 3 additions & 1 deletion src/atlas/api/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator

from atlas.api.routers import dq_router, sales_router

app = FastAPI(
Expand All @@ -13,7 +14,8 @@
app.include_router(dq_router.router)
app.include_router(sales_router.router)


@app.get("/health")
def health_check():
def health_check() -> dict[str, str]:
"""Liveness probe endpoint."""
return {"status": "ok"}
3 changes: 2 additions & 1 deletion src/atlas/api/routers/dq_router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Data Quality router exposing ingestion metrics."""

from fastapi import APIRouter, Depends

from atlas.api.dependencies import get_api_key
from atlas.api.schemas.dq import DQReport

Expand All @@ -12,4 +14,3 @@ async def get_dq_summary() -> DQReport:
# Placeholder: will read from a persisted DQ metadata table once the
# observability persistence layer is implemented.
return DQReport(tables={})

18 changes: 12 additions & 6 deletions src/atlas/api/routers/sales_router.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
"""Sales router querying the Gold layer for analytics metrics."""

from typing import List

import structlog
from fastapi import APIRouter, Depends
from sqlalchemy import exc, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text, exc
import structlog
from atlas.api.dependencies import get_db_session, get_api_key

from atlas.api.dependencies import get_api_key, get_db_session
from atlas.api.schemas.sales import CategorySalesMetric

logger = structlog.get_logger(__name__)

router = APIRouter(prefix="/sales", tags=["Sales"])


@router.get("/metrics", response_model=List[CategorySalesMetric], dependencies=[Depends(get_api_key)])
async def get_sales_metrics(db: AsyncSession = Depends(get_db_session)) -> List[CategorySalesMetric]:
@router.get(
"/metrics", response_model=List[CategorySalesMetric], dependencies=[Depends(get_api_key)]
)
async def get_sales_metrics(
db: AsyncSession = Depends(get_db_session),
) -> List[CategorySalesMetric]:
"""Retrieve total sales aggregated by product category."""
# ProgrammingError is expected if the Gold layer is not yet populated;
# return an empty result rather than a 500 during pipeline ramp-up.
Expand All @@ -39,4 +46,3 @@ async def get_sales_metrics(db: AsyncSession = Depends(get_db_session)) -> List[
except exc.ProgrammingError as e:
logger.warning("gold_layer_unavailable", error=str(e))
return []

5 changes: 4 additions & 1 deletion src/atlas/api/schemas/dq.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from typing import Dict, List, Any
from typing import Dict, List

from pydantic import BaseModel


class DQTableSummary(BaseModel):
success_count: int
failure_count: int
errors: List[str]


class DQReport(BaseModel):
tables: Dict[str, DQTableSummary]
1 change: 1 addition & 0 deletions src/atlas/api/schemas/sales.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pydantic import BaseModel


class CategorySalesMetric(BaseModel):
category: str
total_sales: float
11 changes: 5 additions & 6 deletions src/atlas/config/settings.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
"""Application settings, loaded from environment variables or .env file."""

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore"
)
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

# Database
postgres_url: str = "postgresql+asyncpg://atlas:atlas@localhost:5432/atlas"

# Kafka
kafka_broker: str = "localhost:9092"
kafka_topic_sales_events: str = "atlas.sales.events"
Expand All @@ -25,5 +23,6 @@ class Settings(BaseSettings):
env: str = "development"
log_level: str = "INFO"


# Global settings instance
settings = Settings()
5 changes: 3 additions & 2 deletions src/atlas/ingestion/loaders/postgres_loader.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Bulk loads Pydantic models into PostgreSQL using SQLAlchemy upserts."""

from typing import Any, Iterable, List, Optional, Protocol

import structlog
from pydantic import BaseModel
from sqlalchemy import Column, MetaData, Table
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy import Table, MetaData, Column

logger = structlog.get_logger(__name__)

Expand Down Expand Up @@ -77,4 +79,3 @@ def _execute_batch(
stmt = stmt.on_conflict_do_nothing(index_elements=[primary_key])

self.session.execute(stmt)

6 changes: 5 additions & 1 deletion src/atlas/ingestion/models/crm.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import date
from typing import Optional
from pydantic import BaseModel, Field

from pydantic import BaseModel


class CustomerSchema(BaseModel):
cst_id: int
Expand All @@ -11,6 +13,7 @@ class CustomerSchema(BaseModel):
cst_gndr: Optional[str] = None
cst_create_date: Optional[date] = None


class ProductSchema(BaseModel):
prd_id: int
prd_key: str
Expand All @@ -20,6 +23,7 @@ class ProductSchema(BaseModel):
prd_start_dt: Optional[date] = None
prd_end_dt: Optional[date] = None


class SaleSchema(BaseModel):
sls_ord_num: str
sls_prd_key: str
Expand Down
4 changes: 4 additions & 0 deletions src/atlas/ingestion/models/erp.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
from datetime import date
from typing import Optional

from pydantic import BaseModel, Field


class ErpCustomerSchema(BaseModel):
cid: str = Field(alias="CID")
bdate: Optional[date] = Field(default=None, alias="BDATE")
gen: Optional[str] = Field(default=None, alias="GEN")


class LocationSchema(BaseModel):
cid: str = Field(alias="CID")
cntry: Optional[str] = Field(default=None, alias="CNTRY")


class CategorySchema(BaseModel):
id: str = Field(alias="ID")
cat: Optional[str] = Field(default=None, alias="CAT")
Expand Down
7 changes: 5 additions & 2 deletions src/atlas/ingestion/sources/csv_reader.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Reads CSV files and yields validated Pydantic models."""

import csv
from typing import Iterator, Type, TypeVar

import structlog
from pydantic import BaseModel, ValidationError

Expand All @@ -23,6 +25,7 @@ def read_csv_to_models(file_path: str, model_class: Type[T]) -> Iterator[T]:
try:
yield model_class(**clean_row)
except ValidationError as e:
logger.error("csv_validation_failed", file_path=file_path, row_number=i, error=str(e))
logger.error(
"csv_validation_failed", file_path=file_path, row_number=i, error=str(e)
)
raise

7 changes: 4 additions & 3 deletions src/atlas/streaming/consumer.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Kafka consumer wrapping confluent-kafka's polling loop."""
from confluent_kafka import Consumer, KafkaError, KafkaException
import structlog

from typing import Callable

import structlog
from confluent_kafka import Consumer, KafkaError, KafkaException

logger = structlog.get_logger(__name__)


Expand Down Expand Up @@ -65,4 +67,3 @@ def consume_loop(self, processor: Callable[[str], bool]) -> None:

def stop(self) -> None:
self.running = False

17 changes: 10 additions & 7 deletions src/atlas/streaming/main.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
import structlog
from sqlalchemy import create_engine
from sqlalchemy.orm import Session

from atlas.config.settings import settings
from atlas.ingestion.loaders.postgres_loader import PostgresLoader
from atlas.streaming.consumer import KafkaEventConsumer
from atlas.streaming.processor import process_message
from atlas.ingestion.loaders.postgres_loader import PostgresLoader

logger = structlog.get_logger(__name__)

def main():

def main() -> None:
sync_url = settings.postgres_url.replace("postgresql+asyncpg", "postgresql+psycopg2")
engine = create_engine(sync_url)

consumer = KafkaEventConsumer(
broker=settings.kafka_broker,
group_id=settings.kafka_consumer_group,
topic=settings.kafka_topic_sales_events
topic=settings.kafka_topic_sales_events,
)

with Session(engine) as session:
loader = PostgresLoader(session)

# wrapper to inject the loader into the processor
def process_wrapper(msg_value: str) -> bool:
return process_message(msg_value, loader)

logger.info("starting_streaming_pipeline")
consumer.consume_loop(process_wrapper)


if __name__ == "__main__":
main()
8 changes: 6 additions & 2 deletions src/atlas/streaming/processor.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import json

import structlog
from pydantic import ValidationError

from atlas.ingestion.loaders.postgres_loader import PostgresLoader
from atlas.ingestion.models.crm import SaleSchema

logger = structlog.get_logger(__name__)

def process_message(msg_value: str, loader) -> bool:

def process_message(msg_value: str, loader: PostgresLoader) -> bool:
"""Parse JSON, validate schema, and load into PostgreSQL."""
try:
data = json.loads(msg_value)
Expand All @@ -25,7 +29,7 @@ def process_message(msg_value: str, loader) -> bool:
schema="bronze",
table_name="crm_sales_details",
primary_key="sls_ord_num",
batch_size=1
batch_size=1,
)
return True
except Exception as e:
Expand Down
13 changes: 4 additions & 9 deletions src/atlas/validation/reporter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Dict, Any, List
from typing import Any, Dict


class DataQualityReporter:
# Collects and generates a data quality report.
Expand All @@ -7,11 +8,7 @@ def __init__(self) -> None:

def _ensure_table(self, table_name: str) -> None:
if table_name not in self.tables:
self.tables[table_name] = {
"success_count": 0,
"failure_count": 0,
"errors": []
}
self.tables[table_name] = {"success_count": 0, "failure_count": 0, "errors": []}

def record_success(self, table_name: str, count: int = 1) -> None:
self._ensure_table(table_name)
Expand All @@ -23,6 +20,4 @@ def record_failure(self, table_name: str, error_msg: str, count: int = 1) -> Non
self.tables[table_name]["errors"].append(error_msg)

def generate_report(self) -> Dict[str, Any]:
return {
"tables": self.tables
}
return {"tables": self.tables}
3 changes: 2 additions & 1 deletion src/atlas/validation/rules.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from datetime import date
from typing import Any

def is_date_in_past(d: date) -> bool:
def is_date_in_past(d: date | None) -> bool:
if d is None:
return True
return d <= date.today()


def is_positive(val: Any) -> bool:
if val is None:
return True
Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import pytest
from fastapi.testclient import TestClient
from atlas.config.settings import settings

from atlas.api.main import app
from atlas.config.settings import settings


@pytest.fixture
Expand All @@ -12,4 +13,3 @@ def client() -> TestClient:
@pytest.fixture
def auth_headers() -> dict[str, str]:
return {"X-API-Key": settings.api_key}

Loading
Loading