14 Commits
Author SHA1 Message Date
Brian Bjarke Jensen 0e247a69b0 formatting fixes
Build and Push Docker Image / build-and-push (push) Failing after 23s
Python Code Quality / python-code-quality (push) Failing after 9s
Python Test / python-test (push) Successful in 15s
2025-11-06 22:06:41 +01:00
Brian Bjarke Jensen 41636cce77 updated tests 2025-11-06 22:06:34 +01:00
Brian Bjarke Jensen b36f5eb183 added ignore for local test db 2025-11-06 21:40:12 +01:00
Brian Bjarke Jensen 68aedc6f43 aligned docker files 2025-11-06 21:34:04 +01:00
Brian Bjarke Jensen de13beb48b added convenience docs 2025-11-06 21:33:51 +01:00
Brian Bjarke Jensen 5baea47185 updated available endpoints 2025-11-06 21:33:35 +01:00
Brian Bjarke Jensen a22de7cef9 added data models 2025-11-06 21:33:13 +01:00
Brian Bjarke Jensen 28305eb36f added repositories with dependency injection 2025-11-06 21:32:56 +01:00
Brian Bjarke Jensen c0266acb18 added fastapi endpoint routers 2025-11-06 21:32:02 +01:00
Brian Bjarke Jensen 37625f2e76 added login and home page 2025-11-06 21:31:11 +01:00
Brian Bjarke Jensen 6b0982b369 updated author info 2025-11-06 21:30:45 +01:00
Brian Bjarke Jensen 607a202a19 renamed test file 2025-11-06 20:05:01 +01:00
Brian Bjarke Jensen b1bb2ca4bb added CI to build docker image 2025-11-06 20:04:24 +01:00
Brian Bjarke Jensen 29fd7b4656 added support for postgres and redis 2025-11-06 20:03:32 +01:00
38 changed files with 1911 additions and 119 deletions
+79
View File
@@ -0,0 +1,79 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."10.0.0.2:3000"]
http = true
insecure = true
- name: Log in to Gitea Container Registry
run: |
# Configure Docker to allow insecure registry for this session
mkdir -p ~/.docker
echo '{
"auths": {},
"insecure-registries": ["10.0.0.2:3000"]
}' > ~/.docker/config.json
# Login using direct docker command
echo "${{ secrets.CI_RUNNER_TOKEN }}" | docker login 10.0.0.2:3000 --username "${{ gitea.actor }}" --password-stdin
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/baby-monitor
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=baby-monitor
org.opencontainers.image.description=Baby Monitor App with all optional dependencies included
org.opencontainers.image.authors=Brian Bjarke Jensen
org.opencontainers.image.vendor=Brian Bjarke Jensen
org.opencontainers.image.licenses=MIT
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Update deployment info
run: |
echo "Docker image built and pushed successfully at $(date)"
echo "Image tags:"
echo "${{ steps.meta.outputs.tags }}"
echo "Image labels:"
echo "${{ steps.meta.outputs.labels }}"
+3
View File
@@ -62,6 +62,9 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
# SQLite database files
*.db
# Flask stuff:
instance/
.webassets-cache
+13 -5
View File
@@ -7,7 +7,8 @@ ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
UV_PROJECT_ENVIRONMENT=/app/.venv \
ENVIRONMENT=production
ENVIRONMENT=production \
DATA_DIR=/data
# Set working directory
WORKDIR /app
@@ -18,14 +19,18 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Copy dependency files
COPY pyproject.toml README.md ./
# Install Python dependencies
RUN uv sync --no-dev
# Copy application code
COPY src/ ./src/
# Install Python dependencies including all optional dependencies
# This makes the image self-contained and ready for any configuration
RUN uv sync --no-dev --all-extras
# Add virtual environment to PATH
ENV PATH="/app/.venv/bin:$PATH"
# Copy application code
COPY src/ ./src/
# Create data directory for SQLite database
RUN mkdir -p /data && chmod 777 /data
# Add healthcheck
HEALTHCHECK \
@@ -39,6 +44,9 @@ HEALTHCHECK \
# Expose port
EXPOSE 8000
# Declare volume for persistent data
VOLUME /data
# Set entrypoint to restrict usage to uvicorn with this specific app
ENTRYPOINT ["uvicorn", "src.baby_monitor.main:app"]
+19 -2
View File
@@ -1,5 +1,3 @@
version: "3.8"
services:
app:
build:
@@ -10,7 +8,26 @@ services:
volumes:
# Mount source code for hot-reloading during development
- ./src:/app/src:ro
# Persist database to local directory
- ./data:/data
environment:
- ENVIRONMENT=development
# Optional: Override admin credentials for development
# - ADMIN_USERNAME=admin
# - ADMIN_PASSWORD=devpassword
# Optional: Use Redis for token storage (uncomment redis service below)
# - REDIS_URI=redis://redis:6379/0
command: ["--host", "0.0.0.0", "--port", "8000", "--reload"]
restart: unless-stopped
# Uncomment to enable Redis token storage
# redis:
# image: redis:7-alpine
# ports:
# - "6379:6379"
# volumes:
# - redis-data:/data
# restart: unless-stopped
# Uncomment if using Redis
# volumes:
# redis-data:
+186
View File
@@ -0,0 +1,186 @@
# Baby Monitor - Self-Contained Docker Image
This Docker image is a self-contained service ready for deployment with all optional dependencies pre-installed.
## Quick Start
```bash
docker run -p 8000:8000 baby-monitor
```
Access the application at `http://localhost:8000`
## Features
This image includes all optional dependencies:
-**Redis** support for distributed token storage
-**PostgreSQL** support for scalable database backend
-**SQLite** built-in for single-instance deployments
## Configuration
Configure the service using environment variables:
### Basic Configuration
| Variable | Default | Description |
| ---------------- | ------------ | --------------------------------------- |
| `ENVIRONMENT` | `production` | Set to `development` to enable API docs |
| `DATA_DIR` | `/data` | Directory for SQLite database |
| `ADMIN_USERNAME` | `admin` | Admin username |
| `ADMIN_PASSWORD` | _(required)_ | Admin password (required in production) |
### Redis Configuration (Optional)
| Variable | Description |
| ----------- | --------------------------------------------------- |
| `REDIS_URI` | Redis connection URI (e.g., `redis://redis:6379/0`) |
When `REDIS_URI` is set, the application uses Redis for token storage instead of in-memory storage.
### PostgreSQL Configuration (Optional)
_Coming soon_
## Examples
### Basic Deployment
```bash
docker run -d \
-p 8000:8000 \
-e ADMIN_PASSWORD=your_secure_password \
-v ./data:/data \
baby-monitor
```
### With Redis
```bash
# Start Redis
docker run -d --name redis redis:7-alpine
# Start Baby Monitor with Redis
docker run -d \
-p 8000:8000 \
-e ADMIN_PASSWORD=your_secure_password \
-e REDIS_URI=redis://redis:6379/0 \
--link redis \
baby-monitor
```
### Using Docker Compose
```yaml
version: "3.8"
services:
app:
image: baby-monitor
ports:
- "8000:8000"
environment:
- ADMIN_PASSWORD=your_secure_password
- REDIS_URI=redis://redis:6379/0
volumes:
- ./data:/data
depends_on:
- redis
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
redis-data:
```
## Health Checks
The image provides several endpoints for monitoring:
- `GET /health` - Basic health check (returns `{"status": "healthy"}`)
- `GET /ready` - Readiness check for orchestration
- `GET /info` - Service information including installed features
Example:
```bash
curl http://localhost:8000/info
```
Response:
```json
{
"service": "baby-monitor",
"version": "0.1.0",
"features": {
"redis": true,
"postgresql": true
},
"config": {
"environment": "production",
"data_dir": "/data",
"redis_configured": true
}
}
```
## Volumes
- `/data` - Persistent storage for SQLite database
Mount this directory to preserve data across container restarts:
```bash
docker run -v /path/on/host:/data baby-monitor
```
## Ports
- `8000` - HTTP API server
## Security Notes
1. **Always set `ADMIN_PASSWORD`** in production
2. Use **strong passwords** (minimum 16 characters)
3. Run behind a **reverse proxy** with HTTPS
4. Use **Redis** for multi-instance deployments
5. **Back up** the `/data` directory regularly
## Troubleshooting
### Check installed features
```bash
curl http://localhost:8000/info
```
### View logs
```bash
docker logs <container-name>
```
### Verify Redis connection
Look for startup message:
```bash
✓ Connected to Redis at redis://...
```
### Test authentication
```bash
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"your_password"}'
```
## Support
For issues and feature requests, please visit the project repository.
+89
View File
@@ -0,0 +1,89 @@
# Token Storage Configuration
The application automatically detects and uses the appropriate token storage backend based on environment variables.
## In-Memory Storage (Default)
By default, tokens are stored in memory. This is suitable for:
- Development
- Single-instance deployments
- Testing
**No configuration needed** - this is the default behavior.
## Redis Storage (Production Recommended)
For production deployments with multiple instances, use Redis for shared token storage.
### Setup
1. **Install Redis dependency:**
```bash
uv sync --extra redis
# or
pip install redis>=5.0.0
```
2. **Set environment variable:**
```bash
export REDIS_URI=redis://localhost:6379/0
```
3. **Start the application:**
The app will automatically detect Redis and use it for token storage.
### Docker Compose with Redis
Uncomment the Redis service in `docker-compose.yml`:
```yaml
services:
app:
environment:
- REDIS_URI=redis://redis:6379/0
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
redis-data:
```
Then run:
```bash
docker compose up
```
### Verification
On startup, the application will print:
- `✓ Connected to Redis at redis://...` - Using Redis
- ` Using in-memory token storage` - Using in-memory
### Error Handling
**Important**: If `REDIS_URI` is set, the application **requires** a successful Redis connection. It will **not** fall back to in-memory storage.
If Redis connection fails, the application will exit with a clear error message:
```
RuntimeError: Failed to connect to Redis at redis://localhost:6379.
Ensure Redis is running and accessible.
```
This prevents silent failures in production environments where Redis is expected.
### Benefits of Redis
-**Shared storage** across multiple app instances
-**Automatic expiration** with TTL
-**Persistent** (with proper Redis configuration)
-**Scalable** for high-traffic applications
-**Drop-in replacement** - no code changes needed
+14
View File
@@ -6,9 +6,18 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.121.0",
"sqlalchemy>=2.0.0",
"uvicorn>=0.38.0",
]
[project.optional-dependencies]
redis = [
"redis>=5.0.0",
]
postgres = [
"psycopg2-binary>=2.9.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
@@ -25,6 +34,7 @@ dev = [
"pytest-cov>=7.0.0",
"pyupgrade>=3.21.0",
"ruff>=0.14.3",
"types-psycopg2>=2.9.21.20251012",
]
[[tool.uv.index]]
@@ -55,3 +65,7 @@ markers = [
"unit: marks tests as unit tests",
"integration: marks tests as integration tests",
]
[[tool.mypy.overrides]]
module = "redis.*"
ignore_missing_imports = true
+39 -24
View File
@@ -1,7 +1,18 @@
"""Baby Monitor Main Application"""
import os
from fastapi import FastAPI
from pathlib import Path
from typing import Annotated
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI, Depends
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from baby_monitor.routers.auth import router as auth_router
from baby_monitor.routers.auth import verify_token
from baby_monitor.routers.health import router as health_router
from baby_monitor.repositories.dependencies.get_database import init_db
# Disable docs in production
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
@@ -9,37 +20,41 @@ docs_url = "/docs" if ENVIRONMENT == "development" else None
redoc_url = "/redoc" if ENVIRONMENT == "development" else None
openapi_url = "/openapi.json" if ENVIRONMENT == "development" else None
@asynccontextmanager
async def lifespan(app_instance: FastAPI) -> AsyncIterator[None]:
"""Lifespan event handler for startup and shutdown."""
# Startup: Initialize database tables
init_db()
yield
# Shutdown: Add cleanup code here if needed
# Instantiate app
app = FastAPI(
docs_url=docs_url,
redoc_url=redoc_url,
openapi_url=openapi_url,
lifespan=lifespan,
)
@app.get("/")
def read_root() -> dict:
"""Root endpoint returning a welcome message."""
return {"message": "Hello World"}
# Mount static files
static_path = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
# Include routers
app.include_router(auth_router)
app.include_router(health_router)
@app.get("/health")
def health_check() -> dict[str, str]:
"""
Health check endpoint for container orchestration.
Returns 200 if the service is alive and able to handle requests.
"""
return {"status": "healthy"}
@app.get("/", include_in_schema=False)
def serve_home() -> FileResponse:
"""Serve the home page (handles auth check client-side)."""
return FileResponse(static_path / "index.html")
@app.get("/ready")
def readiness_check() -> dict[str, str]:
"""
Readiness check endpoint for container orchestration.
Returns 200 if the service is ready to accept traffic.
This can include checks for database connectivity, external services, etc.
"""
# Add dependency checks here if needed (database, cache, etc.)
# For now, simple readiness check
return {"status": "ready"}
@app.get("/api/")
def read_root(token: Annotated[str, Depends(verify_token)]) -> dict:
"""API root endpoint (requires authentication)."""
return {"message": "Hello World", "authenticated": True}
+3
View File
@@ -0,0 +1,3 @@
from .auth import LoginRequest, LoginResponse
__all__ = ["LoginRequest", "LoginResponse"]
+19
View File
@@ -0,0 +1,19 @@
"""Authentication models for request and response validation."""
from pydantic import BaseModel
class LoginRequest(BaseModel):
"""Request model for user login."""
username: str
password: str
class LoginResponse(BaseModel):
"""Response model for successful login."""
message: str
username: str
access_token: str
token_type: str = "bearer"
+5
View File
@@ -0,0 +1,5 @@
"""Database models package."""
from baby_monitor.models.db.user import User
__all__ = ["User"]
+20
View File
@@ -0,0 +1,20 @@
"""Database models."""
from sqlalchemy import Column, Integer, String, DateTime
from datetime import datetime
from baby_monitor.repositories.dependencies.get_database import Base
class User(Base):
"""User database model."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self) -> str:
return f"<User(id={self.id}, username='{self.username}')>"
+13
View File
@@ -0,0 +1,13 @@
"""Repository implementations package."""
from baby_monitor.repositories.dependencies import (
get_credentials_repository,
get_token_repository,
get_user_repository,
)
__all__ = [
"get_credentials_repository",
"get_token_repository",
"get_user_repository",
]
@@ -0,0 +1,37 @@
"""Environment-based credentials repository."""
import os
from baby_monitor.repositories.interfaces import CredentialsRepositoryInterface
class EnvCredentialsRepository(CredentialsRepositoryInterface):
"""
Credentials repository that reads from environment variables.
Environment variables:
- ADMIN_USERNAME: Admin username (default: "admin")
- ADMIN_PASSWORD: Admin password (required in production)
"""
def __init__(self) -> None:
self.admin_username = os.getenv("ADMIN_USERNAME", "admin")
self.admin_password = os.getenv("ADMIN_PASSWORD")
# Warn if using default password in production
env = os.getenv("ENVIRONMENT", "production")
if env == "production" and not self.admin_password:
raise ValueError(
"ADMIN_PASSWORD environment variable must be set in production"
)
# Use default for development
if not self.admin_password:
self.admin_password = "password"
def verify_admin_credentials(self, username: str, password: str) -> bool:
"""Verify admin username and password."""
return username == self.admin_username and password == self.admin_password
def get_admin_username(self) -> str:
"""Get the admin username."""
return self.admin_username
@@ -0,0 +1,11 @@
"""Dependency injection for repositories."""
from .get_credentials_repository import get_credentials_repository
from .get_token_repository import get_token_repository
from .get_user_repository import get_user_repository
__all__ = [
"get_user_repository",
"get_token_repository",
"get_credentials_repository",
]
@@ -0,0 +1,44 @@
"""Definition of get_credentials_repository dependency."""
import logging
from baby_monitor.repositories.interfaces import (
CredentialsRepositoryInterface,
)
from baby_monitor.repositories.credentials.env_credentials import (
EnvCredentialsRepository,
)
# Singleton instance
_credentials_repository: CredentialsRepositoryInterface | None = None
def _initialize_credentials_repository() -> CredentialsRepositoryInterface:
"""
Initialize credentials repository based on environment.
Currently uses environment variables.
Can be extended to support:
- Database-backed credentials
- External secret managers (AWS Secrets Manager, HashiCorp Vault)
- LDAP/Active Directory
"""
# Setup logger
logger = logging.getLogger(__name__)
logger.info("Using environment variable based credentials repository")
return EnvCredentialsRepository()
def get_credentials_repository() -> CredentialsRepositoryInterface:
"""
Get credentials repository instance.
"""
global _credentials_repository
if _credentials_repository is None:
_credentials_repository = _initialize_credentials_repository()
return _credentials_repository
@@ -0,0 +1,46 @@
"""Database configuration and session management."""
import os
from pathlib import Path
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session
# Database directory
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
# SQLite database URL
DATABASE_URL = f"sqlite:///{DATA_DIR}/baby_monitor.db"
# Create engine with check_same_thread=False for SQLite
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False},
echo=False, # Set to True for SQL query logging
)
# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for declarative models
Base = declarative_base()
def init_db() -> None:
"""Initialize the database by creating all tables."""
# Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine)
def get_database() -> Generator[Session, None, None]:
"""
Dependency that provides a database session.
Yields a database session and ensures it's closed after use.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
@@ -0,0 +1,77 @@
"""Definition of get_token_repository dependency."""
import os
import logging
from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface,
)
from baby_monitor.repositories.token.memory_token import (
InMemoryTokenRepository,
)
# Singleton instance
_token_repository: TokenRepositoryInterface | None = None
def _initialize_token_repository() -> TokenRepositoryInterface:
"""
Initialize token repository based on environment.
Uses Redis if REDIS_URI is set, otherwise uses in-memory storage.
Raises exception if REDIS_URI is set but connection fails.
"""
# Setup logger
logger = logging.getLogger(__name__)
# Check for REDIS_URI environment variable
redis_uri = os.getenv("REDIS_URI")
# Use in-memory token repository if REDIS_URI is not set
if not redis_uri:
logger.info("Using in-memory token storage")
return InMemoryTokenRepository()
# Attempt to use RedisTokenRepository
try:
import redis
except ImportError as e:
raise RuntimeError(
"REDIS_URI is set but redis package is not installed. "
"Install with: uv sync --extra redis"
) from e
try:
from baby_monitor.repositories.token.redis_token import (
RedisTokenRepository,
)
redis_client = redis.from_url(redis_uri)
# Test connection
redis_client.ping()
logger.info(f"Connected to Redis at {redis_uri}")
return RedisTokenRepository(redis_client)
except redis.ConnectionError as e:
raise RuntimeError(
f"Failed to connect to Redis at {redis_uri}. "
f"Ensure Redis is running and accessible. Error: {e}"
) from e
except Exception as e:
raise RuntimeError(f"Failed to initialize Redis token repository: {e}") from e
def get_token_repository() -> TokenRepositoryInterface:
"""
Get token repository instance.
Automatically uses Redis if REDIS_URI environment variable is set,
otherwise falls back to in-memory storage.
"""
global _token_repository
if _token_repository is None:
_token_repository = _initialize_token_repository()
return _token_repository
@@ -0,0 +1,33 @@
"""Definition of get_user_repository dependency."""
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from baby_monitor.repositories.dependencies.get_database import (
get_database,
)
from baby_monitor.repositories.interfaces import (
UserRepositoryInterface,
)
from baby_monitor.repositories.user.sqlite_user import (
SQLiteUserRepository,
)
# Singleton instance
_user_repository: UserRepositoryInterface | None = None
def get_user_repository(
db: Annotated[Session, Depends(get_database)],
) -> UserRepositoryInterface:
"""
Get user repository instance.
"""
global _user_repository
if _user_repository is None:
_user_repository = SQLiteUserRepository(db)
return _user_repository
@@ -0,0 +1,17 @@
"""Repository interfaces package."""
from .credentials_repository_interface import (
CredentialsRepositoryInterface,
)
from .token_repository_interface import (
TokenRepositoryInterface,
)
from .user_repository_interface import (
UserRepositoryInterface,
)
__all__ = [
"UserRepositoryInterface",
"TokenRepositoryInterface",
"CredentialsRepositoryInterface",
]
@@ -0,0 +1,15 @@
"""Definition of CredentialsRepositoryInterface."""
from abc import ABC, abstractmethod
class CredentialsRepositoryInterface(ABC):
"""Interface for credentials/secrets management."""
@abstractmethod
def verify_admin_credentials(self, username: str, password: str) -> bool:
"""Verify admin username and password."""
@abstractmethod
def get_admin_username(self) -> str:
"""Get the admin username."""
@@ -0,0 +1,23 @@
"""Definition of the TokenRepositoryInterface."""
from abc import ABC, abstractmethod
class TokenRepositoryInterface(ABC):
"""Interface for token storage operations."""
@abstractmethod
def store(self, token: str, user_id: int, ttl: int = 3600) -> None:
"""Store a token with optional TTL (time to live in seconds)."""
@abstractmethod
def verify(self, token: str) -> int | None:
"""Verify token and return user_id if valid, None otherwise."""
@abstractmethod
def invalidate(self, token: str) -> None:
"""Invalidate/delete a token."""
@abstractmethod
def cleanup_expired(self) -> None:
"""Remove expired tokens (for implementations without auto-expiry)."""
@@ -0,0 +1,19 @@
"""Definition of UserRepositoryInterface."""
from abc import ABC, abstractmethod
class UserRepositoryInterface(ABC):
"""Interface for user data operations."""
@abstractmethod
def get_by_username(self, username: str) -> dict | None:
"""Get user by username."""
@abstractmethod
def create(self, username: str, hashed_password: str) -> dict:
"""Create a new user."""
@abstractmethod
def get_by_id(self, user_id: int) -> dict | None:
"""Get user by ID."""
@@ -0,0 +1,50 @@
"""In-memory token repository (can be replaced with Redis)."""
from datetime import datetime, timedelta, UTC
from baby_monitor.repositories.interfaces import TokenRepositoryInterface
class InMemoryTokenRepository(TokenRepositoryInterface):
"""
In-memory token storage.
This is a simple implementation that stores tokens in memory.
Replace with RedisTokenRepository for production use.
"""
def __init__(self) -> None:
# token -> (user_id, expiry_time)
self._tokens: dict[str, tuple[int, datetime]] = {}
def store(self, token: str, user_id: int, ttl: int = 3600) -> None:
"""Store a token with TTL (time to live in seconds)."""
expiry = datetime.now(UTC) + timedelta(seconds=ttl)
self._tokens[token] = (user_id, expiry)
def verify(self, token: str) -> int | None:
"""Verify token and return user_id if valid, None otherwise."""
if token not in self._tokens:
return None
user_id, expiry = self._tokens[token]
# Check if token has expired
if datetime.now(UTC) > expiry:
del self._tokens[token]
return None
return user_id
def invalidate(self, token: str) -> None:
"""Invalidate/delete a token."""
self._tokens.pop(token, None)
def cleanup_expired(self) -> None:
"""Remove expired tokens."""
now = datetime.utcnow()
expired_tokens = [
token for token, (_, expiry) in self._tokens.items() if now > expiry
]
for token in expired_tokens:
del self._tokens[token]
@@ -0,0 +1,45 @@
"""Redis token repository (future implementation)."""
from typing import Any
from baby_monitor.repositories.interfaces import TokenRepositoryInterface
class RedisTokenRepository(TokenRepositoryInterface):
"""
Redis-based token storage.
Requires redis package: pip install redis
Set REDIS_URI environment variable (e.g., redis://localhost:6379/0)
"""
def __init__(self, redis_client: Any) -> None:
"""
Initialize with Redis client.
Args:
redis_client: Redis client instance from redis.from_url()
"""
self.redis = redis_client
def store(self, token: str, user_id: int, ttl: int = 3600) -> None:
"""Store a token with TTL (time to live in seconds)."""
key = f"token:{token}"
self.redis.setex(key, ttl, str(user_id))
def verify(self, token: str) -> int | None:
"""Verify token and return user_id if valid, None otherwise."""
key = f"token:{token}"
user_id_str = self.redis.get(key)
if user_id_str:
return int(user_id_str)
return None
def invalidate(self, token: str) -> None:
"""Invalidate/delete a token."""
key = f"token:{token}"
self.redis.delete(key)
def cleanup_expired(self) -> None:
"""Redis automatically handles expiration, so no cleanup needed."""
pass # Redis handles TTL automatically
@@ -0,0 +1,64 @@
"""PostgreSQL user repository (future implementation)."""
from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces import UserRepositoryInterface
from baby_monitor.models.db.user import User
class PostgreSQLUserRepository(UserRepositoryInterface):
"""
PostgreSQL-based user repository.
This implementation uses the same SQLAlchemy models as SQLite,
just with a different database engine.
To use this implementation:
1. Add psycopg2-binary dependency
2. Update DATABASE_URL in database.py:
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://user:password@localhost:5432/baby_monitor"
)
3. Replace SQLiteUserRepository with this in dependencies
"""
def __init__(self, db: Session):
self.db = db
def get_by_username(self, username: str) -> dict | None:
"""Get user by username."""
user = self.db.query(User).filter(User.username == username).first()
if user:
return {
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"created_at": user.created_at,
}
return None
def create(self, username: str, hashed_password: str) -> dict:
"""Create a new user."""
user = User(username=username, hashed_password=hashed_password)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return {
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"created_at": user.created_at,
}
def get_by_id(self, user_id: int) -> dict | None:
"""Get user by ID."""
user = self.db.query(User).filter(User.id == user_id).first()
if user:
return {
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"created_at": user.created_at,
}
return None
@@ -0,0 +1,50 @@
"""SQLite implementation of user repository."""
from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces import UserRepositoryInterface
from baby_monitor.models.db.user import User
class SQLiteUserRepository(UserRepositoryInterface):
"""SQLite-based user repository."""
def __init__(self, db: Session):
self.db = db
def get_by_username(self, username: str) -> dict | None:
"""Get user by username."""
user = self.db.query(User).filter(User.username == username).first()
if user:
return {
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"created_at": user.created_at,
}
return None
def create(self, username: str, hashed_password: str) -> dict:
"""Create a new user."""
user = User(username=username, hashed_password=hashed_password)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return {
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"created_at": user.created_at,
}
def get_by_id(self, user_id: int) -> dict | None:
"""Get user by ID."""
user = self.db.query(User).filter(User.id == user_id).first()
if user:
return {
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"created_at": user.created_at,
}
return None
+1
View File
@@ -0,0 +1 @@
"""Routers package for API endpoints."""
+78
View File
@@ -0,0 +1,78 @@
"""Authentication router for login and user management."""
import secrets
from fastapi import APIRouter, HTTPException, Depends, Header
from typing import Annotated
from baby_monitor.models.auth import LoginRequest, LoginResponse
from baby_monitor.repositories import (
get_token_repository,
get_credentials_repository,
)
from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface,
CredentialsRepositoryInterface,
)
router = APIRouter(prefix="/api", tags=["authentication"])
def verify_token(
authorization: Annotated[str | None, Header()] = None,
token_repo: TokenRepositoryInterface = Depends(get_token_repository),
) -> int:
"""Verify the bearer token and return user_id."""
if not authorization:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
scheme, token = authorization.split()
if scheme.lower() != "bearer":
raise HTTPException(status_code=401, detail="Invalid authentication scheme")
except ValueError:
raise HTTPException(status_code=401, detail="Invalid authorization header")
user_id = token_repo.verify(token)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return user_id
@router.post("/login", response_model=LoginResponse)
def login(
credentials: LoginRequest,
token_repo: TokenRepositoryInterface = Depends(get_token_repository),
creds_repo: CredentialsRepositoryInterface = Depends(get_credentials_repository),
) -> LoginResponse:
"""
API endpoint for user authentication.
Returns user info and access token on successful login.
Raises 401 on invalid credentials.
"""
# Verify credentials using repository
if creds_repo.verify_admin_credentials(credentials.username, credentials.password):
# Generate a secure random token
access_token = secrets.token_urlsafe(32)
# Store token with user_id (hardcoded 1 for now)
token_repo.store(access_token, user_id=1, ttl=3600)
return LoginResponse(
message="Login successful",
username=credentials.username,
access_token=access_token,
)
else:
raise HTTPException(status_code=401, detail="Invalid username or password")
@router.post("/logout")
def logout(
user_id: Annotated[int, Depends(verify_token)],
token_repo: TokenRepositoryInterface = Depends(get_token_repository),
) -> dict[str, str]:
"""Logout and invalidate the current token."""
# Note: We'd need to pass the token itself, not user_id
# This is simplified - in production, extract token from verify_token
return {"message": "Logged out successfully"}
+72
View File
@@ -0,0 +1,72 @@
"""Health check router for monitoring endpoints."""
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health_check() -> dict[str, str]:
"""
Health check endpoint for container orchestration.
Returns 200 if the service is alive and able to handle requests.
"""
return {"status": "healthy"}
@router.get("/ready")
def readiness_check() -> dict[str, str]:
"""
Readiness check endpoint for container orchestration.
Returns 200 if the service is ready to accept traffic.
This can include checks for database connectivity, external services, etc.
"""
# Add dependency checks here if needed (database, cache, etc.)
# For now, simple readiness check
return {"status": "ready"}
@router.get("/info")
def service_info() -> dict:
"""
Get service information including available features.
Useful for verifying which optional dependencies are installed.
"""
import os
# Check which optional dependencies are available
features = {
"redis": False,
"postgresql": False,
}
try:
import redis # noqa: F401
features["redis"] = True
except ImportError:
pass
try:
import psycopg2 # noqa: F401
features["postgresql"] = True
except ImportError:
pass
# Check current configuration
config = {
"environment": os.getenv("ENVIRONMENT", "production"),
"data_dir": os.getenv("DATA_DIR", "/data"),
"redis_configured": bool(os.getenv("REDIS_URI")),
}
return {
"service": "baby-monitor",
"version": "0.1.0",
"features": features,
"config": config,
}
+123
View File
@@ -0,0 +1,123 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Home</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
background-color: #f0f0f0;
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333;
margin-bottom: 1rem;
}
.user-info {
background: #e3f2fd;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
button {
padding: 0.75rem 1.5rem;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background-color: #c82333;
}
.loading {
text-align: center;
padding: 2rem;
}
</style>
</head>
<body>
<div class="container">
<div id="content" class="loading">
<p>Loading...</p>
</div>
</div>
<script>
const token = localStorage.getItem("access_token");
const username = localStorage.getItem("username");
// Check if user is logged in
if (!token) {
window.location.href = "/static/login.html";
} else {
// Verify token by making an authenticated request
fetch("/api/", {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => {
if (response.ok) {
return response.json();
} else {
// Token invalid, redirect to login
localStorage.removeItem("access_token");
localStorage.removeItem("username");
window.location.href = "/static/login.html";
throw new Error("Authentication failed");
}
})
.then((data) => {
// Show authenticated content
document.getElementById("content").innerHTML = `
<h1>Welcome to Baby Monitor!</h1>
<div class="user-info">
<p><strong>Logged in as:</strong> ${username}</p>
</div>
<p>${data.message}</p>
<button id="logoutBtn">Logout</button>
`;
// Add logout handler
document
.getElementById("logoutBtn")
.addEventListener("click", logout);
})
.catch((error) => {
console.error("Error:", error);
});
}
async function logout() {
const token = localStorage.getItem("access_token");
try {
await fetch("/api/logout", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
} catch (error) {
console.error("Logout error:", error);
} finally {
// Clear local storage and redirect
localStorage.removeItem("access_token");
localStorage.removeItem("username");
window.location.href = "/static/login.html";
}
}
</script>
</body>
</html>
+137
View File
@@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Login</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f0f0f0;
}
.login-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 300px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 1.5rem;
}
input {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 0.75rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background-color: #0056b3;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.message {
text-align: center;
margin-bottom: 1rem;
padding: 0.5rem;
border-radius: 4px;
}
.error {
background-color: #fee;
color: #c00;
}
.success {
background-color: #efe;
color: #0a0;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="login-container">
<h1>Baby Monitor</h1>
<div id="message" class="message hidden"></div>
<form id="loginForm">
<input type="text" id="username" placeholder="Username" required />
<input type="password" id="password" placeholder="Password" required />
<button type="submit" id="loginButton">Login</button>
</form>
</div>
<script>
const form = document.getElementById("loginForm");
const messageDiv = document.getElementById("message");
const loginButton = document.getElementById("loginButton");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
// Disable button during request
loginButton.disabled = true;
loginButton.textContent = "Logging in...";
try {
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok) {
showMessage(data.message, "success");
// Store token in localStorage
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("username", data.username);
// Redirect to home page
setTimeout(() => {
window.location.href = "/";
}, 1000);
} else {
showMessage(data.detail || "Login failed", "error");
}
} catch (error) {
showMessage("Network error. Please try again.", "error");
} finally {
loginButton.disabled = false;
loginButton.textContent = "Login";
}
});
function showMessage(text, type) {
messageDiv.textContent = text;
messageDiv.className = `message ${type}`;
messageDiv.classList.remove("hidden");
}
</script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
"""Root conftest for all tests."""
import os
import tempfile
import pytest
def pytest_configure(config: pytest.Config) -> None:
"""Configure pytest environment before any imports happen."""
# Set DATA_DIR before any modules are imported
if "DATA_DIR" not in os.environ:
tmpdir = tempfile.mkdtemp(prefix="baby_monitor_test_")
os.environ["DATA_DIR"] = tmpdir
os.environ["ENVIRONMENT"] = "development"
+2 -16
View File
@@ -1,28 +1,14 @@
"""Integration tests configuration."""
import os
from typing import Iterator
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="session", autouse=True)
def set_test_environment() -> Iterator[None]:
"""Set environment to development for all integration tests."""
original_env = os.environ.get("ENVIRONMENT")
os.environ["ENVIRONMENT"] = "development"
yield
# Cleanup: restore original environment
if original_env is None:
os.environ.pop("ENVIRONMENT", None)
else:
os.environ["ENVIRONMENT"] = original_env
@pytest.fixture(scope="session")
def app_test_client() -> Iterator[TestClient]:
"""Provides a TestClient for the FastAPI app."""
# Import here after environment is set
# Import after environment is set by root conftest
from baby_monitor.main import app
with TestClient(app) as client:
+171
View File
@@ -0,0 +1,171 @@
"""Integration tests for FastAPI application."""
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
def test_home_page_serves_html(app_test_client: TestClient) -> None:
"""Test root endpoint returns HTML page."""
response = app_test_client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
@pytest.mark.integration
def test_api_root_requires_auth(app_test_client: TestClient) -> None:
"""Test API root endpoint requires authentication."""
response = app_test_client.get("/api/")
assert response.status_code == 401
assert response.json() == {"detail": "Not authenticated"}
@pytest.mark.integration
def test_api_root_with_auth(app_test_client: TestClient) -> None:
"""Test authenticated API root endpoint returns expected message."""
# First login to get token
login_response = app_test_client.post(
"/api/login", json={"username": "admin", "password": "password"}
)
assert login_response.status_code == 200
token = login_response.json()["access_token"]
# Then access API root with token
response = app_test_client.get(
"/api/", headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
assert response.json() == {"message": "Hello World", "authenticated": True}
@pytest.mark.integration
def test_health_check(app_test_client: TestClient) -> None:
"""Test health check endpoint returns healthy status."""
response = app_test_client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
@pytest.mark.integration
def test_readiness_check(app_test_client: TestClient) -> None:
"""Test readiness check endpoint returns ready status."""
response = app_test_client.get("/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready"}
@pytest.mark.integration
def test_openapi_docs_accessible(app_test_client: TestClient) -> None:
"""Test OpenAPI documentation is accessible."""
response = app_test_client.get("/docs")
assert response.status_code == 200
@pytest.mark.integration
def test_openapi_json_accessible(app_test_client: TestClient) -> None:
"""Test OpenAPI JSON schema is accessible."""
response = app_test_client.get("/openapi.json")
assert response.status_code == 200
assert "openapi" in response.json()
assert "info" in response.json()
@pytest.mark.integration
def test_nonexistent_endpoint_returns_404(app_test_client: TestClient) -> None:
"""Test requesting a non-existent endpoint returns 404."""
response = app_test_client.get("/nonexistent")
assert response.status_code == 404
@pytest.mark.integration
def test_root_endpoint_content_type(app_test_client: TestClient) -> None:
"""Test root endpoint returns HTML content type."""
response = app_test_client.get("/")
assert "text/html" in response.headers["content-type"]
@pytest.mark.integration
def test_api_root_endpoint_content_type(app_test_client: TestClient) -> None:
"""Test API root endpoint returns JSON content type."""
# Login first to get token
login_response = app_test_client.post(
"/api/login", json={"username": "admin", "password": "password"}
)
token = login_response.json()["access_token"]
response = app_test_client.get(
"/api/", headers={"Authorization": f"Bearer {token}"}
)
assert response.headers["content-type"] == "application/json"
@pytest.mark.integration
def test_health_endpoint_content_type(app_test_client: TestClient) -> None:
"""Test health endpoint returns JSON content type."""
response = app_test_client.get("/health")
assert response.headers["content-type"] == "application/json"
@pytest.mark.integration
def test_ready_endpoint_content_type(app_test_client: TestClient) -> None:
"""Test readiness endpoint returns JSON content type."""
response = app_test_client.get("/ready")
assert response.headers["content-type"] == "application/json"
@pytest.mark.integration
def test_login_success(app_test_client: TestClient) -> None:
"""Test successful login returns access token."""
response = app_test_client.post(
"/api/login", json={"username": "admin", "password": "password"}
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.integration
def test_login_invalid_credentials(app_test_client: TestClient) -> None:
"""Test login with invalid credentials returns 401."""
response = app_test_client.post(
"/api/login", json={"username": "admin", "password": "wrongpassword"}
)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid username or password"}
@pytest.mark.integration
def test_login_missing_fields(app_test_client: TestClient) -> None:
"""Test login with missing fields returns validation error."""
response = app_test_client.post("/api/login", json={"username": "admin"})
assert response.status_code == 422
@pytest.mark.integration
def test_logout(app_test_client: TestClient) -> None:
"""Test logout endpoint."""
# Login first
login_response = app_test_client.post(
"/api/login", json={"username": "admin", "password": "password"}
)
token = login_response.json()["access_token"]
# Logout
response = app_test_client.post(
"/api/logout", headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
assert response.json() == {"message": "Logged out successfully"}
@pytest.mark.integration
def test_info_endpoint(app_test_client: TestClient) -> None:
"""Test info endpoint returns installed features."""
response = app_test_client.get("/info")
assert response.status_code == 200
data = response.json()
assert "features" in data
assert isinstance(data["features"], dict)
assert "redis" in data["features"]
assert "postgresql" in data["features"]
-72
View File
@@ -1,72 +0,0 @@
"""Integration tests for FastAPI application."""
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
def test_read_root(app_test_client: TestClient) -> None:
"""Test root endpoint returns expected message."""
response = app_test_client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}
@pytest.mark.integration
def test_health_check(app_test_client: TestClient) -> None:
"""Test health check endpoint returns healthy status."""
response = app_test_client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
@pytest.mark.integration
def test_readiness_check(app_test_client: TestClient) -> None:
"""Test readiness check endpoint returns ready status."""
response = app_test_client.get("/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready"}
@pytest.mark.integration
def test_openapi_docs_accessible(app_test_client: TestClient) -> None:
"""Test OpenAPI documentation is accessible."""
response = app_test_client.get("/docs")
assert response.status_code == 200
@pytest.mark.integration
def test_openapi_json_accessible(app_test_client: TestClient) -> None:
"""Test OpenAPI JSON schema is accessible."""
response = app_test_client.get("/openapi.json")
assert response.status_code == 200
assert "openapi" in response.json()
assert "info" in response.json()
@pytest.mark.integration
def test_nonexistent_endpoint_returns_404(app_test_client: TestClient) -> None:
"""Test requesting a non-existent endpoint returns 404."""
response = app_test_client.get("/nonexistent")
assert response.status_code == 404
@pytest.mark.integration
def test_root_endpoint_content_type(app_test_client: TestClient) -> None:
"""Test root endpoint returns JSON content type."""
response = app_test_client.get("/")
assert response.headers["content-type"] == "application/json"
@pytest.mark.integration
def test_health_endpoint_content_type(app_test_client: TestClient) -> None:
"""Test health endpoint returns JSON content type."""
response = app_test_client.get("/health")
assert response.headers["content-type"] == "application/json"
@pytest.mark.integration
def test_ready_endpoint_content_type(app_test_client: TestClient) -> None:
"""Test readiness endpoint returns JSON content type."""
response = app_test_client.get("/ready")
assert response.headers["content-type"] == "application/json"
+138
View File
@@ -0,0 +1,138 @@
"""Unit tests for database module."""
from pathlib import Path
from sqlalchemy.orm import Session
from baby_monitor.repositories.dependencies.get_database import get_database, init_db
def test_get_db_yields_session() -> None:
"""Test that get_db yields a valid database session."""
init_db()
db_generator = get_database()
db = next(db_generator)
assert isinstance(db, Session)
assert db.is_active
# Close the session
try:
next(db_generator)
except StopIteration:
pass
def test_get_db_closes_session() -> None:
"""Test that get_db properly closes the session."""
from unittest.mock import patch
init_db()
db_generator = get_database()
db = next(db_generator)
assert db.is_active
# Patch the close method to verify it gets called
with patch.object(db, 'close', wraps=db.close) as mock_close:
# Complete the generator to trigger finally block
try:
next(db_generator)
except StopIteration:
pass
# Verify that close() was called
mock_close.assert_called_once()
def test_get_db_closes_on_exception() -> None:
"""Test that get_db closes session when used in context manager."""
from unittest.mock import patch
init_db()
# Simulate how FastAPI actually uses the dependency
db = None
db_generator = get_database()
try:
db = next(db_generator)
assert db.is_active
# Patch close to verify it gets called even with an exception
with patch.object(db, 'close', wraps=db.close) as mock_close:
try:
# Simulate an exception occurring during request processing
raise ValueError("Simulated error during request")
except ValueError:
pass
finally:
# FastAPI ensures the generator completes
try:
next(db_generator)
except StopIteration:
pass
# Verify close() was called despite the exception
mock_close.assert_called_once()
except Exception:
# Ensure we have a reference to db for the final assertion
pass
# Verify db was created
assert db is not None
# def test_init_db_creates_directory(tmp_path: Path) -> None:
# """Test that init_db creates the data directory."""
# import os
# # Use a fresh temp directory for this test
# test_dir = tmp_path / "test_data"
# # Reinitialize database module with new path
# from baby_monitor.repositories.dependencies.get_database import (
# DATA_DIR,
# DATABASE_URL,
# create_engine,
# sessionmaker,
# )
# DATABASE_URL = f"sqlite:///{test_dir}/baby_monitor.db"
# engine = create_engine(
# DATABASE_URL,
# connect_args={"check_same_thread": False},
# echo=False,
# )
# SessionLocal = sessionmaker(
# autocommit=False,
# autoflush=False,
# bind=engine
# )
# init_db()
# assert isinstance(SessionLocal, sessionmaker)
# assert test_dir.exists()
# assert test_dir.is_dir()
def test_init_db_creates_tables(tmp_path: Path) -> None:
"""Test that init_db creates database tables."""
init_db()
# Check that database file exists
import os
data_dir = Path(os.environ["DATA_DIR"])
db_path = data_dir / "baby_monitor.db"
assert db_path.exists()
# Verify tables were created by checking metadata
from baby_monitor.repositories.dependencies.get_database import engine
from sqlalchemy import inspect
inspector = inspect(engine)
tables = inspector.get_table_names()
# Should have at least some tables (depends on models)
# For now, just verify the function runs without error
assert isinstance(tables, list)
Generated
+142
View File
@@ -40,9 +40,18 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "sqlalchemy" },
{ name = "uvicorn" },
]
[package.optional-dependencies]
postgres = [
{ name = "psycopg2-binary" },
]
redis = [
{ name = "redis" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
@@ -52,13 +61,18 @@ dev = [
{ name = "pytest-cov" },
{ name = "pyupgrade" },
{ name = "ruff" },
{ name = "types-psycopg2" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.121.0" },
{ name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.9.0" },
{ name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "uvicorn", specifier = ">=0.38.0" },
]
provides-extras = ["redis", "postgres"]
[package.metadata.requires-dev]
dev = [
@@ -69,6 +83,7 @@ dev = [
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pyupgrade", specifier = ">=3.21.0" },
{ name = "ruff", specifier = ">=0.14.3" },
{ name = "types-psycopg2", specifier = ">=2.9.21.20251012" },
]
[[package]]
@@ -217,6 +232,45 @@ wheels = [
{ url = "http://10.0.0.2:5001/index/filelock/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
source = { registry = "http://10.0.0.2:5001/index/" }
sdist = { url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d" }
wheels = [
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681" },
{ url = "http://10.0.0.2:5001/index/greenlet/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -383,6 +437,47 @@ wheels = [
{ url = "http://10.0.0.2:5001/index/pre-commit/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
source = { registry = "http://10.0.0.2:5001/index/" }
sdist = { url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c" }
wheels = [
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d" },
{ url = "http://10.0.0.2:5001/index/psycopg2-binary/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316" },
]
[[package]]
name = "pydantic"
version = "2.12.3"
@@ -562,6 +657,15 @@ wheels = [
{ url = "http://10.0.0.2:5001/index/pyyaml/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" },
]
[[package]]
name = "redis"
version = "7.0.1"
source = { registry = "http://10.0.0.2:5001/index/" }
sdist = { url = "http://10.0.0.2:5001/index/redis/redis-7.0.1.tar.gz", hash = "sha256:c949df947dca995dc68fdf5a7863950bf6df24f8d6022394585acc98e81624f1" }
wheels = [
{ url = "http://10.0.0.2:5001/index/redis/redis-7.0.1-py3-none-any.whl", hash = "sha256:4977af3c7d67f8f0eb8b6fec0dafc9605db9343142f634041fb0235f67c0588a" },
]
[[package]]
name = "ruff"
version = "0.14.3"
@@ -597,6 +701,35 @@ wheels = [
{ url = "http://10.0.0.2:5001/index/sniffio/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.44"
source = { registry = "http://10.0.0.2:5001/index/" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22" }
wheels = [
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e" },
{ url = "http://10.0.0.2:5001/index/sqlalchemy/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05" },
]
[[package]]
name = "starlette"
version = "0.49.3"
@@ -619,6 +752,15 @@ wheels = [
{ url = "http://10.0.0.2:5001/index/tokenize-rt/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44" },
]
[[package]]
name = "types-psycopg2"
version = "2.9.21.20251012"
source = { registry = "http://10.0.0.2:5001/index/" }
sdist = { url = "http://10.0.0.2:5001/index/types-psycopg2/types_psycopg2-2.9.21.20251012.tar.gz", hash = "sha256:4cdafd38927da0cfde49804f39ab85afd9c6e9c492800e42f1f0c1a1b0312935" }
wheels = [
{ url = "http://10.0.0.2:5001/index/types-psycopg2/types_psycopg2-2.9.21.20251012-py3-none-any.whl", hash = "sha256:712bad5c423fe979e357edbf40a07ca40ef775d74043de72bd4544ca328cc57e" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"