Használj mssql-pythont FastAPI-val

A FastAPI egy modern Python webes keretrendszer API-k építéséhez. Az mssql-pythonnal kombinálva nagy teljesítményű REST API-kat építhetsz Microsoft SQL és Azure SQL Database támogatásával.

Prerequisites

  • Python 3.10 vagy újabb verzió.
  • A mssql-python, fastapi, uvicorn, pydantic, és PyJWT csomagok. Telepítsd az összeset ezzel: pip install fastapi uvicorn mssql-python pydantic pyjwt.
  • Egyszeri operációs rendszerspecifikus előfeltételek telepítése. A Windows felhasználók ezt a lépést kihagyhatják. A platform teljes részleteiért lásd: Install mssql-python.
    apk add libtool krb5-libs krb5-dev
    

SQL-adatbázis létrehozása

Létrehozni vagy csatlakozni SQL adatbázishoz az alábbi platformok egyikén:

A cikkben szereplő példák az AdventureWorksLT mintaadatbázist, konkrétan a SalesLT.Product táblát használják. Ha nincs telepítve az AdventureWorksLT, nézd meg az AdventureWorks mintaadatbázisokat.

Projekt beállítás

Virtuális környezet létrehozása

Hozz létre és aktiválj egy virtuális környezetet, hogy a projekt csomagjai elszigeteltek maradjanak a többi Python telepítéstől. Ez a lépés elkerüli azt a gyakori problémát is, hogy csomagokat telepítsenek egy értelmezőbe, miközben az alkalmazásodat vagy egy másiknál teszteket futtatnak.

py -m venv .venv
.\.venv\Scripts\Activate.ps1

A környezet aktiválása után a python, a pip és a pytest mind ugyanarra az értelmezőre mutat. A cikkben a többi parancsot az aktivált környezetből futtasd le.

Megjegyzés:

Windows on Arm rendszeren a környezetet a Python Arm64-es verziójával hozd létre, hogy a(z) mssql-python és annak függőségei előre elkészített wheel csomagokból települhessenek. Egynél több Python-verzióval rendelkező gépen a py -m venv a várttól eltérő verziót vagy architektúrát is kiválaszthat, ezért aktiválás után a python -c "import sys, sysconfig; print(sys.version, sysconfig.get_platform())" paranccsal ellenőrizd. Ha a(z) pip megpróbálja forrásból felépíteni a(z) cryptography csomagot (Rust- és OpenSSL-eszközlánccal kapcsolatos hiba miatt), először a(z) pip install --only-binary=:all: cryptography paranccsal telepíts egy wheel-alapú verziót, majd a többit.

Függőségek telepítése

Telepítsd a szükséges csomagokat pip-jel:

pip install fastapi uvicorn mssql-python pydantic pyjwt

A projekt szerkezete

Szervezze meg a projektjét külön modulokkal adatbázisokhoz, sémákhoz és CRUD műveletekhez:

my_api/
├── main.py
├── database.py
├── models.py
├── schemas.py
├── crud.py
├── test_api.py
└── routers/
    └── products.py

Adatbázis-kapcsolatkezelés

A FastAPI függőségi injekciót használ, hogy olyan erőforrásokat, mint például adatbázis-kapcsolatok biztosítson az útvonalkezelők számára. A minta ebben a szakaszban egy kontextuskezelőt hoz létre, amely megnyit egy kapcsolatot, kurzort ad, és automatikusan kezeli a commit/rollback/close műveleteket.

Készíts database.py

A get_connection_string() függvény konfigurációs értékekből építi az ODBC kapcsolati karakterlánc-et. A get_db() kontextuskezelő és a get_db_dependency() generátor ugyanazt a mintát követi: megnyit egy kapcsolatot, visszaad egy kurzort, siker esetén véglegesíti a tranzakciót, hiba esetén visszagörgeti azt, és a végén mindig lezárja. A FastAPI Depends() kérésenként egyszer hívja meg a(z) get_db_dependency() elemet, és kezeli annak életciklusát.

# database.py
import mssql_python
from contextlib import contextmanager
from typing import Generator

# Configuration
DATABASE_CONFIG = {
    "server": "<server>.database.windows.net",
    "database": "<database>",
}

def get_connection_string() -> str:
    """Build connection string from config."""
    return (
        f"Server={DATABASE_CONFIG['server']};"
        f"Database={DATABASE_CONFIG['database']};"
        "Authentication=ActiveDirectoryDefault;"
        "Encrypt=yes"
    )

Megjegyzés:

ActiveDirectoryDefault használ DefaultAzureCredential, amely több hitelesítésszolgáltatót próbál egymás után. Az első kapcsolat lassú lehet, mert az SDK végigjárja a láncot, amíg meg nem talál egy működő szolgáltatót. A termelésben, ha tudod, melyik hitelesítéstípust használja a környezeted, közvetlenül megadd (például ActiveDirectoryMSI menedzselt identitásnál), hogy elkerüld a láncos sétát. További információ: Microsoft Entra-hitelesítés.

@contextmanager
def get_db() -> Generator:
    """Database connection context manager for FastAPI dependency injection."""
    conn = mssql_python.connect(get_connection_string())
    cursor = conn.cursor()
    try:
        yield cursor
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        cursor.close()
        conn.close()

def get_db_dependency():
    """FastAPI dependency for database cursor."""
    conn = mssql_python.connect(get_connection_string())
    cursor = conn.cursor()
    try:
        yield cursor
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        cursor.close()
        conn.close()

Pydantikus modellek

A pydantikus modellek határozzák meg a kérelmek és válaszadatok alakját és validációs szabályait. A FastAPI ezeket a modelleket használja a bejövő JSON elemzésére, a mezőkorlátok validálására és az OpenAPI dokumentáció automatikus generálására.

Készíts schemas.py

Szegezzük a sémákat Base, Create, Update, és válaszvariánsokra. A Base séma megosztott mezőket tart, Create örököl belőle beszúrási műveletekhez, és Update minden mezőt opcionálissá tesz részleges frissítésekhez.

# schemas.py
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from typing import Optional
from datetime import datetime

# Product schemas
class ProductBase(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    product_number: str = Field(..., min_length=1, max_length=25)
    price: float = Field(..., gt=0)
    color: Optional[str] = Field(None, max_length=50)
    size: Optional[str] = Field(None, max_length=50)
    category_id: Optional[int] = None

class ProductCreate(ProductBase):
    pass

class ProductUpdate(BaseModel):
    name: Optional[str] = Field(None, min_length=1, max_length=100)
    product_number: Optional[str] = Field(None, min_length=1, max_length=25)
    price: Optional[float] = Field(None, gt=0)
    color: Optional[str] = Field(None, max_length=50)
    size: Optional[str] = Field(None, max_length=50)
    category_id: Optional[int] = None

class Product(ProductBase):
    id: int

    model_config = ConfigDict(from_attributes=True)

# Pagination
class PaginatedResponse(BaseModel):
    items: list
    total: int
    page: int
    page_size: int
    pages: int

CRUD műveletek

Incapsuláld az adatbázis-lekérdezéseket egy dedikált osztályba, hogy az útvonalkezelők vékonyak legyenek. Minden statikus metódus egy kurzort fogad (amelyet a FastAPI ad át), és paraméterezett lekérdezéseket használva egy műveletet hajt végre (%(name)s helyőrzők egy értékszótárral), az SQL-injektálás megelőzése érdekében. Ez a szétválasztás megkönnyíti az üzleti logika tesztelését és újrahasznosítását.

Készíts crud.py

# crud.py
from typing import Optional, List
from schemas import ProductCreate, ProductUpdate, Product

class ProductCRUD:
    """CRUD operations for products."""
    
    @staticmethod
    def get(cursor, product_id: int) -> Optional[dict]:
        cursor.execute("""
            SELECT ProductID, Name, ProductNumber, ListPrice, Color, Size
            FROM SalesLT.Product
            WHERE ProductID = %(id)s
        """, {"id": product_id})
        
        row = cursor.fetchone()
        if row:
            return {
                "id": row.ProductID,
                "name": row.Name,
                "product_number": row.ProductNumber,
                "price": float(row.ListPrice),
                "color": row.Color,
                "size": row.Size
            }
        return None
    
    @staticmethod
    def get_all(cursor, skip: int = 0, limit: int = 100) -> List[dict]:
        cursor.execute("""
            SELECT ProductID, Name, ProductNumber, ListPrice, Color, Size
            FROM SalesLT.Product
            ORDER BY ProductID
            OFFSET %(skip)s ROWS
            FETCH NEXT %(limit)s ROWS ONLY
        """, {"skip": skip, "limit": limit})
        
        return [{
            "id": row.ProductID,
            "name": row.Name,
            "product_number": row.ProductNumber,
            "price": float(row.ListPrice),
            "color": row.Color,
            "size": row.Size
        } for row in cursor.fetchall()]
    
    @staticmethod
    def count(cursor) -> int:
        cursor.execute("SELECT COUNT(*) FROM SalesLT.Product")
        return cursor.fetchval()
    
    @staticmethod
    def create(cursor, product: ProductCreate) -> dict:
        cursor.execute("""
            INSERT INTO SalesLT.Product (Name, ProductNumber, ListPrice, Color, Size, ProductCategoryID, StandardCost, SellStartDate)
            OUTPUT INSERTED.ProductID, INSERTED.Name, INSERTED.ProductNumber,
                   INSERTED.ListPrice, INSERTED.Color, INSERTED.Size
            VALUES (%(name)s, %(product_number)s, %(price)s, %(color)s, %(size)s, %(category_id)s, 0, GETDATE())
        """, {
            "name": product.name,
            "product_number": product.product_number,
            "price": product.price,
            "color": product.color,
            "size": product.size,
            "category_id": product.category_id
        })
        
        row = cursor.fetchone()
        return {
            "id": row.ProductID,
            "name": row.Name,
            "product_number": row.ProductNumber,
            "price": float(row.ListPrice),
            "color": row.Color,
            "size": row.Size
        }
    
    @staticmethod
    def update(cursor, product_id: int, product: ProductUpdate) -> Optional[dict]:
        # Build dynamic update
        updates = []
        params = {"id": product_id}
        
        if product.name is not None:
            updates.append("Name = %(name)s")
            params["name"] = product.name
        if product.product_number is not None:
            updates.append("ProductNumber = %(product_number)s")
            params["product_number"] = product.product_number
        if product.price is not None:
            updates.append("ListPrice = %(price)s")
            params["price"] = product.price
        if product.category_id is not None:
            updates.append("ProductCategoryID = %(category_id)s")
            params["category_id"] = product.category_id
        
        if not updates:
            return ProductCRUD.get(cursor, product_id)
        
        cursor.execute(f"""
            UPDATE SalesLT.Product SET {', '.join(updates)}
            OUTPUT INSERTED.ProductID, INSERTED.Name, INSERTED.ProductNumber,
                   INSERTED.ListPrice, INSERTED.Color, INSERTED.Size
            WHERE ProductID = %(id)s
        """, params)
        
        row = cursor.fetchone()
        if row:
            return {
                "id": row.ProductID,
                "name": row.Name,
                "product_number": row.ProductNumber,
                "price": float(row.ListPrice),
                "color": row.Color,
                "size": row.Size
            }
        return None
    
    @staticmethod
    def delete(cursor, product_id: int) -> bool:
        cursor.execute("""
            DELETE FROM SalesLT.Product WHERE ProductID = %(id)s
        """, {"id": product_id})
        return cursor.rowcount > 0
    
    @staticmethod
    def search(cursor, query: str, skip: int = 0, limit: int = 100) -> List[dict]:
        cursor.execute("""
            SELECT ProductID, Name, ProductNumber, ListPrice, Color, Size
            FROM SalesLT.Product
            WHERE Name LIKE %(query)s OR ProductNumber LIKE %(query)s
            ORDER BY ProductID
            OFFSET %(skip)s ROWS
            FETCH NEXT %(limit)s ROWS ONLY
        """, {"query": f"%{query}%", "skip": skip, "limit": limit})
        
        return [{
            "id": row.ProductID,
            "name": row.Name,
            "product_number": row.ProductNumber,
            "price": float(row.ListPrice),
            "color": row.Color,
            "size": row.Size
        } for row in cursor.fetchall()]

FastAPI alkalmazás

Készíts main.py

A fő modul mindent összeköt. Minden útvonal deklarálja cursor = Depends(get_db_dependency), ami azt mondja a FastAPI-nak, hogy hívja meg a generátort, adja át a megadott kurzort a kezelőnek, majd tisztítsa meg azt. A FastAPI a kérés testeit is érvényesíti a Pydantic sémáidhoz képest, mielőtt a kezelő elindulna.

# main.py
from fastapi import FastAPI, HTTPException, Depends, Query
from typing import List
from database import get_db_dependency
from schemas import Product, ProductCreate, ProductUpdate, PaginatedResponse
from crud import ProductCRUD

app = FastAPI(
    title="Product API",
    description="REST API for products using mssql-python",
    version="1.0.0"
)

@app.get("/")
def root():
    return {"message": "Product API", "docs": "/docs"}

@app.get("/products", response_model=PaginatedResponse)
def list_products(
    page: int = Query(1, ge=1),
    page_size: int = Query(10, ge=1, le=100),
    cursor = Depends(get_db_dependency)
):
    """List all products with pagination."""
    skip = (page - 1) * page_size
    items = ProductCRUD.get_all(cursor, skip=skip, limit=page_size)
    total = ProductCRUD.count(cursor)
    
    return {
        "items": items,
        "total": total,
        "page": page,
        "page_size": page_size,
        "pages": (total + page_size - 1) // page_size
    }

@app.get("/products/{product_id}", response_model=Product)
def get_product(product_id: int, cursor = Depends(get_db_dependency)):
    """Get a specific product by ID."""
    product = ProductCRUD.get(cursor, product_id)
    if not product:
        raise HTTPException(status_code=404, detail="Product not found")
    return product

@app.post("/products", response_model=Product, status_code=201)
def create_product(product: ProductCreate, cursor = Depends(get_db_dependency)):
    """Create a new product."""
    return ProductCRUD.create(cursor, product)

@app.put("/products/{product_id}", response_model=Product)
def update_product(
    product_id: int,
    product: ProductUpdate,
    cursor = Depends(get_db_dependency)
):
    """Update an existing product."""
    updated = ProductCRUD.update(cursor, product_id, product)
    if not updated:
        raise HTTPException(status_code=404, detail="Product not found")
    return updated

@app.delete("/products/{product_id}", status_code=204)
def delete_product(product_id: int, cursor = Depends(get_db_dependency)):
    """Delete a product."""
    if not ProductCRUD.delete(cursor, product_id):
        raise HTTPException(status_code=404, detail="Product not found")

@app.get("/products/search/", response_model=List[Product])
def search_products(
    q: str = Query(..., min_length=1),
    page: int = Query(1, ge=1),
    page_size: int = Query(10, ge=1, le=100),
    cursor = Depends(get_db_dependency)
):
    """Search products by name or product number."""
    skip = (page - 1) * page_size
    return ProductCRUD.search(cursor, q, skip=skip, limit=page_size)

# Health check endpoint
@app.get("/health")
def health_check(cursor = Depends(get_db_dependency)):
    """Check database connectivity."""
    try:
        cursor.execute("SELECT 1")
        return {"status": "healthy", "database": "connected"}
    except Exception as e:
        raise HTTPException(status_code=503, detail=f"Database unhealthy: {str(e)}")

Az alkalmazás futtatása

uvicorn main:app --reload --host 0.0.0.0 --port 8000

Hibakezelés

A FastAPI lehetővé teszi globális exception-kezelők regisztrálását speciális kivételtípusok számára. Amikor elkapod mssql_python.DatabaseError a és mssql_python.IntegrityError, a FastAPI strukturált JSON hibákat ad vissza megfelelő HTTP státuszkódokkal a generikus 500-as válaszok helyett.

Globális kivételkezelő

Ezeket a kezelőket közvetlenül a app = FastAPI(...) sor után adjuk hozzá a main.py elemhez. A FastAPI a megfelelő kezelőt futtatja, amikor egy útvonal ilyen típusú kivételt vált ki, így nem kell minden útvonalban külön try/except blokk.

# main.py
from fastapi import Request
from fastapi.responses import JSONResponse
import mssql_python

@app.exception_handler(mssql_python.DatabaseError)
async def database_exception_handler(request: Request, exc: mssql_python.DatabaseError):
    """Handle database errors globally."""
    return JSONResponse(
        status_code=500,
        content={"detail": "Database error occurred", "type": "database_error"}
    )

@app.exception_handler(mssql_python.IntegrityError)
async def integrity_exception_handler(request: Request, exc: mssql_python.IntegrityError):
    """Handle integrity constraint violations."""
    error_msg = str(exc)
    
    if "UNIQUE" in error_msg:
        return JSONResponse(
            status_code=409,
            content={"detail": "Resource already exists", "type": "duplicate_error"}
        )
    elif "FOREIGN KEY" in error_msg:
        return JSONResponse(
            status_code=400,
            content={"detail": "Referenced resource not found", "type": "reference_error"}
        )
    
    return JSONResponse(
        status_code=400,
        content={"detail": "Data integrity error", "type": "integrity_error"}
    )

Megjegyzés:

Egy olyan termék törlése, amelyre más sorok még hivatkoznak, a külső kulcs megszorítása miatt mssql_python.IntegrityError hibát vált ki, és a kezelőfüggvény a sor eltávolítása helyett 400-as választ ad vissza. Az AdventureWorksLT mintában a SalesLT.Product-ban található termékek többségére SalesLT.SalesOrderDetail hivatkozik, ezért esetükben a DELETE szándékosan nem működik. A sikeres törlés teszteléséhez hozz létre egy terméket azzal POST /products , és töröld azt, vagy először távolítsd el a hivatkozási sorokat.

Kapcsolatmegosztás

Kapcsolati pooling nélkül minden kérés megnyitja és zár egy TCP kapcsolatot a Microsoft SQL-hez, ami késleltetést növel. A kapcsolat-összevonás a tétlen kapcsolatok egy készletét készen tartja az újrafelhasználásra. Hívd meg egyszer a mssql_python.pooling() elemet indításkor. Ha a pooling engedélyezve, conn.close() az In get_db_dependency() visszaadja a kapcsolatot a poolhoz, nem pedig zárná be.

Fejlesztett adatbázis modul

Engedélyezd a készletezést az indításkor a(z) mssql_python.pooling() meghívásával, majd konfiguráld megfelelő maximálisméret- és időkorlát-beállításokkal:

# database.py with connection pooling
import mssql_python
from contextlib import contextmanager
import os

# Configure pool
mssql_python.pooling(max_size=20, idle_timeout=300)

DATABASE_URL = os.getenv(
    "DATABASE_URL",
    "Server=<server>.database.windows.net;Database=<database>;"
    "Authentication=ActiveDirectoryDefault;Encrypt=yes"
)

def get_db_dependency():
    """FastAPI dependency with connection pooling."""
    conn = mssql_python.connect(DATABASE_URL)
    cursor = conn.cursor()
    try:
        yield cursor
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        cursor.close()
        conn.close()  # Returns to pool

Hitelesítési köztes réteg

Az adatbázis-hozzáférést a hitelesítéssel kombinálhatod a FastAPI függőségek láncolásával is. A következő példa validálja a JWT bearer tokent, megkeresi az egyező személy rekordot az AdventureWorksLT mintaadatbázisban, és elérhetővé teszi az eredményt védett útvonalaknál.

# auth.py
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt

security = HTTPBearer()

def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security),
    cursor = Depends(get_db_dependency)
):
    """Validate JWT and return the matching AdventureWorksLT person."""
    try:
        token = credentials.credentials
        # Replace with a strong secret loaded from environment variables
        payload = jwt.decode(token, "your-secret-key", algorithms=["HS256"])
        person_id = int(payload.get("sub"))
        
        if not person_id:
            raise HTTPException(status_code=401, detail="Invalid token")
        
        cursor.execute("""
            SELECT BusinessEntityID, FirstName, LastName
            FROM Person.Person
            WHERE BusinessEntityID = %(id)s
        """, {"id": person_id})
        
        person = cursor.fetchone()
        if not person:
            raise HTTPException(status_code=401, detail="User not found")
        
        return {
            "id": person.BusinessEntityID,
            "first_name": person.FirstName,
            "last_name": person.LastName
        }
        
    except (TypeError, ValueError):
        raise HTTPException(status_code=401, detail="Invalid token subject")
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")

# Protected endpoint
@app.get("/me")
def get_me(current_user: dict = Depends(get_current_user)):
    return current_user

Testing

A FastAPI olyan TestClient beépített httpx megoldást kínál, amely kéréseket küld az alkalmazásodnak anélkül, hogy valódi HTTP szervert indítana. Írj teszteket az útvonalak, státuszkódok és válaszformák ellenőrzésére pytest .

Az ebben a szakaszban szereplő tesztek futtatása előtt telepítse a tesztfüggőségeket:

pip install pytest httpx

Megjegyzés:

Ha a Starlette legfrissebb verzióját használod, vagy új környezetet állítasz be, inkább a httpx2 használd a httpx helyett. A Starlette újabb verziói a TestClient helyett a httpx2 használják, és elavulási figyelmeztetést adnak ki, ha csak a httpx van telepítve. Telepítse a(z) pip install pytest httpx2 használatával.

Tesztbeállítás

Hozz létre egy tesztfájlt, amely az útvonal viselkedését és válaszsémáinak ellenőrzését használja TestClient :

# test_api.py
from fastapi.testclient import TestClient
from main import app
import uuid
import pytest

client = TestClient(app)

def test_list_products():
    response = client.get("/products")
    assert response.status_code == 200
    data = response.json()
    assert "items" in data
    assert "total" in data

def test_create_product():
    suffix = uuid.uuid4().hex[:8]
    name = f"Test Product {suffix}"
    product_data = {
        "name": name,
        "product_number": f"TEST-{suffix}",
        "price": 19.99,
        "color": "Red",
        "size": "M",
        "category_id": 1
    }
    response = client.post("/products", json=product_data)
    assert response.status_code == 201
    data = response.json()
    assert data["name"] == name
    assert data["price"] == 19.99

def test_get_product_not_found():
    response = client.get("/products/99999")
    assert response.status_code == 404

def test_health_check():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json()["status"] == "healthy"

Futtassa a teszteket a(z) pytest paranccsal a projekt gyökérkönyvtárából, ugyanabból a könyvtárból, ahol a(z) main.py található:

pytest

Ezek a tesztek az élő adatbázisodon futnak, nem pedig mock objektumokon, így a test_create_product egy valódi sort szúr be a SalesLT.Product-be. Az AdventureWorksLT-ben mindkettőnek NameProductNumber egyedi korlátai vannak, így a teszt minden futáskor egyedi értéket generál mindegyikhez. Ha ehelyett hardcode-olod ezeket az értékeket, a teszt a második futtatáskor ütközés miatt sikertelen lesz, hacsak előtte nem törlöd a sort.

Üzembehelyezési konfiguráció

Használd a Pydantic BaseSettings elemét a konfiguráció környezeti változókból és .env fájlokból történő betöltéséhez. Ez a megközelítés távol tartja a titkokat a forráskódtól, és megkönnyíti a környezetek közötti váltást. Telepítsd a beállításcsomagot a(z) pip install pydantic-settings segítségével.

Környezeti változók

Hozz létre egy beállítási modult, amely környezeti változókból tölt be konfigurációt, lehetővé téve titkok és telepítés-specifikus értékek kezelését a kódodon kívül:

# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    database_server: str = "<server>.database.windows.net"
    database_name: str = "<database>"
    pool_size: int = 10

    model_config = SettingsConfigDict(env_file=".env")

settings = Settings()

def get_connection_string() -> str:
    return (
        f"Server={settings.database_server};"
        f"Database={settings.database_name};"
        "Authentication=ActiveDirectoryDefault;"
        "Encrypt=yes"
    )

Ezután frissítse a(z) database.py elemet, hogy a(z) config helyről importálja a(z) get_connection_string elemet ahelyett, hogy a saját másolatát definiálná. A duplikált funkció eltávolításával biztosítod, hogy az alkalmazás egyetlen forrásból olvassa fel a kapcsolati beállításokat.

# database.py
from config import get_connection_string