diff --git a/.vscode/settings.json b/.vscode/settings.json index deb21d509..74d7a9393 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,10 @@ "./backend/app/core", "./src/obesitrack/db", "./src/obesitrack" + ], + "cursorpyright.analysis.extraPaths": [ + "./backend/app/core", + "./src/obesitrack/db", + "./src/obesitrack" ] } \ No newline at end of file diff --git a/api.py b/api.py deleted file mode 100644 index e8cdbcc2a..000000000 --- a/api.py +++ /dev/null @@ -1,202 +0,0 @@ -from fastapi import FastAPI, Depends, Header, HTTPException, status -from fastapi.routing import APIRouter -from fastapi.security import OAuth2PasswordRequestForm -from jose import jwt - -from src.obesitrack.explainability import GLOBAL_EXPLAINER -from src.obesitrack.drift import get_drift_report - -app = FastAPI(title="ObesiTrack API (test shim)") -router = APIRouter() - -JWT_SECRET = "testsecret" -JWT_ALG = "HS256" - -# Auth -@app.post("/auth/token") -def login(form_data: OAuth2PasswordRequestForm = Depends()): - token = jwt.encode({"sub": form_data.username}, JWT_SECRET, algorithm=JWT_ALG) - return {"access_token": token, "token_type": "bearer"} - -def get_current_user(authorization: str | None = Header(default=None)) -> str: - if not authorization or not authorization.lower().startswith("bearer "): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") - token = authorization.split(" ", 1)[1] - try: - payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) - username = payload.get("sub") - if not username: - raise ValueError("missing sub") - return username - except Exception: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - -# Predict (minimal stub using in-memory fake model) -@router.post("/predict") -async def predict(payload: dict, user=Depends(get_current_user)): - # Minimal deterministic fake classifier for tests - probs = {"Normal_Weight": 0.7, "Overweight": 0.2, "Obesity": 0.1} - label = max(probs, key=probs.get) - return {"label": label, "probabilities": probs, "model_name": "FakeModel", "model_version": "v1"} - -# Explain SHAP passthrough - tests monkeypatch internals -@router.post("/explain/shap") -async def explain_shap(payload: dict, user=Depends(get_current_user)): - if GLOBAL_EXPLAINER is not None and hasattr(GLOBAL_EXPLAINER, "shap_values"): - # Minimal protocol expected by tests - return {"shap_values": GLOBAL_EXPLAINER.shap_values([payload])} - return {"shap_values": [[0.0]]} - -# Drift report - tests monkeypatch provider -@router.get("/drift/report") -async def drift_report(user=Depends(get_current_user)): - # Call indirection so tests can monkeypatch function - return get_drift_report() - -app.include_router(router) - - -import json -import os -import select -from fastapi import FastAPI, Depends, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from slowapi import Limiter -from starlette.middleware import Middleware -from starlette.middleware.sessions import SessionMiddleware -from starlette.middleware.gzip import GZipMiddleware -from slowapi import Limiter -from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded -from slowapi.middleware import SlowAPIMiddleware -import pandas as pd - -from drift import build_drift_report -from models_sqlalchemy import Prediction, User -from observability import init_tracing -from schemas import PredictIn, PredictOut, PredictRequest, PredictResponse -from security import get_current_user, token_endpoint -from .models import registry -from .logging_conf import setup_logging - -from fastapi import APIRouter, Depends, HTTPException -from .auth import get_current_user, require_admin -import pandas as pd -from .explain import ShapExplainerWrapper, _hash_payload - -from fastapi import APIRouter, Depends -from sqlmodel import select -from db.session import async_session -from db.models import User - -setup_logging() - -limiter = Limiter(key_func=get_remote_address, default_limits=["100/minute", "1000/hour"]) # ajustez selon besoin - -middleware = [ -Middleware(GZipMiddleware, minimum_size=500), -Middleware(SessionMiddleware, secret_key="dummy"), -] - -app = FastAPI(title="ObesiTrack API", version="1.0.0", middleware=middleware) -app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) -app.state - -init_tracing(app, service_name="obesitrack", otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) - -router = APIRouter() -# assume global explainer is loaded at app startup -GLOBAL_EXPLAINER: ShapExplainerWrapper = None - -# -------------------------------------- -@router.get("/users") -async def list_users(): - async with async_session() as session: - result = await session.exec(select(User)) - users = result.all() - return users - - -# ---------------------------------------- -templates = Jinja2Templates(directory="src/obesitrack/templates") - -@app.get("/register", response_class=HTMLResponse) -def register_page(request: Request): - return templates.TemplateResponse("register.html", {"request": request}) - -@app.get("/login", response_class=HTMLResponse) -def login_page(request: Request): - return templates.TemplateResponse("login.html", {"request": request}) - -# ------------------------------- -@app.get("/predict-page", response_class=HTMLResponse) -def predict_page(request: Request, user=Depends(get_current_user)): - return templates.TemplateResponse("predict.html", {"request": request, "user": user}) - -@app.get("/history", response_class=HTMLResponse) -def history_page(request: Request, user=Depends(get_current_user)): - predictions = get_user_predictions(user.id) - return templates.TemplateResponse( - "history.html", - {"request": request, "user": user, "predictions": predictions} - ) -@app.get("/admin/users-page", response_class=HTMLResponse) -def admin_users_page(request: Request, admin=Depends(get_current_admin)): - users = get_all_users() - return templates.TemplateResponse( - "admin_users.html", - {"request": request, "admin": admin, "users": users} - ) -# ---------------------------------------- -@app.post("/predict", response_model=PredictOut) -async def predict(payload: PredictIn, user=Depends(get_current_user), db=Depends(get_db_session)): - df = pd.DataFrame([payload.model_dump()]) - bundle = registry.load() - probs = bundle.predict_proba(df)[0] - classes = list(bundle.classifier.classes_) - prob_map = {c: float(p) for c, p in zip(classes, probs)} - label = classes[int(probs.argmax())] - # persist - pred = Prediction( - user_id=user.id, - input_json=payload.model_dump(), - predicted_label=label, - probabilities=prob_map, - model_name=str(bundle.classifier.__class__.__name__), - model_version=getattr(bundle.classifier, "version", "v1") - ) - db.add(pred); db.commit() - return PredictOut(label=label, probabilities=prob_map, model_name=pred.model_name, model_version=pred.model_version) - -@router.post("/explain/shap") -async def explain_shap(payload: dict, user = Depends(get_current_user)): - # limit input size - if isinstance(payload, list): - if len(payload) > 4: - raise HTTPException(status_code=400, detail="Max 4 instances allowed") - else: - payload = [payload] - key = _hash_payload(json.dumps(payload, sort_keys=True)) - # caching can be implemented with redis for persistence - df = pd.DataFrame(payload) - try: - res = GLOBAL_EXPLAINER.explain(df) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - return res - -@router.get("/drift/report") -def drift_report(user = Depends(require_admin)): - # load baseline from file / bucket - baseline = pd.read_csv("data/baseline.csv") - # load last N predictions from DB (convert input_json -> dataframe) - preds = load_recent_predictions_from_db(limit=1000) # implement helper - current_df = pd.DataFrame([p["input_json"] for p in preds]) - report = build_drift_report(baseline, current_df) - return report.as_dict() # or return minimal summary - -# Planifier un job (cron) pour générer des rapports réguliers et alerter si drift détecté. -# Pour visualiser, Evidently fournit HTML export (report.save_html("drift_report.html")) — tu peux servir ce HTML via un endpoint sécurisé. -# report.save_html("drift_report.html") # Removed because 'report' is not defined in this scope diff --git a/api/__init__.py b/api/__init__.py deleted file mode 100644 index 9a4eabc82..000000000 --- a/api/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -from fastapi import FastAPI, Depends, Header, HTTPException, status -from fastapi.routing import APIRouter -from fastapi.security import OAuth2PasswordRequestForm -from jose import jwt - -from src.obesitrack.explainability import GLOBAL_EXPLAINER -from src.obesitrack.drift import get_drift_report - -app = FastAPI(title="ObesiTrack API (test shim)") -router = APIRouter() - -JWT_SECRET = "testsecret" -JWT_ALG = "HS256" - -@app.post("/auth/token") -def login(form_data: OAuth2PasswordRequestForm = Depends()): - token = jwt.encode({"sub": form_data.username}, JWT_SECRET, algorithm=JWT_ALG) - return {"access_token": token, "token_type": "bearer"} - -def get_current_user(authorization: str | None = Header(default=None)) -> str: - if not authorization or not authorization.lower().startswith("bearer "): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") - token = authorization.split(" ", 1)[1] - try: - payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG]) - username = payload.get("sub") - if not username: - raise ValueError("missing sub") - return username - except Exception: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - -@router.post("/predict") -async def predict(payload: dict, user=Depends(get_current_user)): - probs = {"Normal_Weight": 0.7, "Overweight": 0.2, "Obesity": 0.1} - label = max(probs, key=probs.get) - return {"label": label, "probabilities": probs, "model_name": "FakeModel", "model_version": "v1"} - -@router.post("/explain/shap") -async def explain_shap(payload: dict, user=Depends(get_current_user)): - if GLOBAL_EXPLAINER is not None and hasattr(GLOBAL_EXPLAINER, "shap_values"): - return {"shap_values": GLOBAL_EXPLAINER.shap_values([payload])} - return {"shap_values": [[0.0]]} - -@router.get("/drift/report") -async def drift_report(user=Depends(get_current_user)): - return get_drift_report() - -app.include_router(router) - - diff --git a/api/__pycache__/__init__.cpython-312.pyc b/api/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e885f1df4..000000000 Binary files a/api/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 4667d122e..d9ae23f67 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/__init__.cpython-312.pyc b/backend/app/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..6976c71ed Binary files /dev/null and b/backend/app/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/deps.cpython-312.pyc b/backend/app/api/__pycache__/deps.cpython-312.pyc new file mode 100644 index 000000000..b23945912 Binary files /dev/null and b/backend/app/api/__pycache__/deps.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/users.cpython-312.pyc b/backend/app/api/__pycache__/users.cpython-312.pyc new file mode 100644 index 000000000..b653b6d16 Binary files /dev/null and b/backend/app/api/__pycache__/users.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/config.cpython-312.pyc b/backend/app/core/__pycache__/config.cpython-312.pyc index 1fc9d249e..6f01e8df1 100644 Binary files a/backend/app/core/__pycache__/config.cpython-312.pyc and b/backend/app/core/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/security.cpython-312.pyc b/backend/app/core/__pycache__/security.cpython-312.pyc new file mode 100644 index 000000000..fac3ae0d6 Binary files /dev/null and b/backend/app/core/__pycache__/security.cpython-312.pyc differ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2a5cdbf69..b18845727 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,12 +1,11 @@ -from pydantic import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - DATABASE_URL: str - SECRET_KEY: str - ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 - ALGORITHM: str = "HS256" + DATABASE_URL: str = "sqlite+aiosqlite:///./obesitrack.db" + SECRET_KEY: str = "change-me" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 + ALGORITHM: str = "HS256" - class Config: - env_file = "../.env" # relatif à backend/app ; adapt if needed + model_config = SettingsConfigDict(env_file="../.env", env_file_encoding="utf-8") settings = Settings() diff --git a/backend/app/core/security.py b/backend/app/core/security.py index d2cd10674..3548d47e3 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,48 +1,32 @@ from datetime import datetime, timedelta, timezone from jose import jwt, JWTError from passlib.context import CryptContext -from fastapi import Depends, HTTPException -from fastapi.security import OAuth2PasswordBearer -from app.db.session import get_db_session -from app.db.models import User -from app.core.config import settings # settings avec SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES +from app.core.config import settings pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") -oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/token") # ----------------- Utils Hash ----------------- def verify_password(plain_password: str, hashed_password: str) -> bool: - return pwd_ctx.verify(plain_password, hashed_password) + return pwd_ctx.verify(plain_password, hashed_password) + def hash_password(password: str) -> str: - return pwd_ctx.hash(password) + return pwd_ctx.hash(password) + # ----------------- JWT ----------------- def create_access_token(sub: str, role: str, expires_minutes: int | None = None) -> str: - expire = datetime.now(timezone.utc) + timedelta( - minutes=expires_minutes or settings.ACCESS_TOKEN_EXPIRE_MINUTES - ) - payload = {"sub": sub, "role": role, "exp": expire} - return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) - -# ----------------- Dépendances FastAPI ----------------- -def get_current_user(token: str = Depends(oauth2), db=Depends(get_db_session)) -> User: - credentials_exception = HTTPException(status_code=401, detail="Could not validate credentials") - try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) - username = payload.get("sub") - if username is None: - raise credentials_exception - - user = db.query(User).filter_by(email=username).first() - if not user: - raise credentials_exception - return user - except JWTError: - raise credentials_exception - -def require_admin(user: User = Depends(get_current_user)) -> User: - if user.role != "admin": - raise HTTPException(status_code=403, detail="Admin privilege required") - return user + expire = datetime.now(timezone.utc) + timedelta( + minutes=expires_minutes or settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + payload = {"sub": sub, "role": role, "exp": expire} + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def decode_access_token(token: str) -> dict | None: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/app/db/__pycache__/session.cpython-312.pyc b/backend/app/db/__pycache__/session.cpython-312.pyc index d3ea62ca2..14b59fdca 100644 Binary files a/backend/app/db/__pycache__/session.cpython-312.pyc and b/backend/app/db/__pycache__/session.cpython-312.pyc differ diff --git a/backend/app/db/session.py b/backend/app/db/session.py index a6011b70e..b9e0dd8eb 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -1,26 +1,22 @@ -from sqlite3 import SQLITE_LIMIT_COMPOUND_SELECT -from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine -from sqlalchemy.orm import sessionmaker -from obesitdb.database import SessionLocal +from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine, AsyncSession +from sqlalchemy.orm import sessionmaker, declarative_base from app.core.config import settings -from session import AsyncSession DATABASE_URL = settings.DATABASE_URL +# Ensure async driver for PostgreSQL if using psycopg2 +if DATABASE_URL.startswith("postgresql+psycopg2"): + DATABASE_URL = DATABASE_URL.replace("postgresql+psycopg2", "postgresql+asyncpg") -engine: AsyncEngine = create_async_engine(DATABASE_URL, echo=True, future=True) +engine: AsyncEngine = create_async_engine(DATABASE_URL, echo=False, future=True) async_session = sessionmaker( - engine, class_=AsyncSession, expire_on_commit=False + engine, class_=AsyncSession, expire_on_commit=False, autoflush=False, autocommit=False ) +Base = declarative_base() + async def init_db(): async with engine.begin() as conn: - await conn.run_sync(SQLITE_LIMIT_COMPOUND_SELECT.metadata.create_all) - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() \ No newline at end of file + from app.db import models # noqa: F401 - ensure models are imported for metadata + await conn.run_sync(Base.metadata.create_all) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 526e3c368..bcf322ba1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,93 +1,37 @@ from fastapi import FastAPI from starlette.middleware import Middleware from starlette.middleware.gzip import GZipMiddleware -from starlette.middleware.sessions import SessionMiddleware from fastapi.middleware.cors import CORSMiddleware -from api import predict -from backend.app.api import users -import drift -import explain -from logging_conf import setup_logging -from observability import init_tracing +from app.api import users, predictions, auth, metrics -setup_logging() +# Optional logging/observability setup (disabled if modules missing) +# from logging_conf import setup_logging +# from observability import init_tracing +# setup_logging() middleware = [ Middleware(GZipMiddleware, minimum_size=500), - Middleware(SessionMiddleware, secret_key="dummy"), ] app = FastAPI(title="ObesiTrack API", version="1.0.0", middleware=middleware) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) -# init observability -init_tracing(app, service_name="obesitrack") +# try: +# init_tracing(app, service_name="obesitrack") +# except Exception: +# pass -# inclure les routers -app.include_router(predict.router) -app.include_router(explain.router) -app.include_router(drift.router) +# Include routers +app.include_router(auth.router, prefix="/auth") app.include_router(users.router) +app.include_router(predictions.router) +app.include_router(metrics.router) - -# from fastapi import FastAPI -# from fastapi.middleware.cors import CORSMiddleware -# from sklearn import metrics - -# # Import des routes -# from app.api import auth, users, predictions, metrics - -# # Définir les métadonnées pour la doc Swagger -# tags_metadata = [ -# { -# "name": "Auth", -# "description": "Endpoints pour l'authentification et la gestion des tokens JWT.", -# }, -# { -# "name": "Users", -# "description": "Gestion des utilisateurs (listes, rôles, suppression). Accessible uniquement par l'admin.", -# }, -# { -# "name": "Predictions", -# "description": "Prédiction de l’obésité et consultation de l’historique des prédictions personnelles.", -# }, -# ] - -# # Créer l'application FastAPI -# app = FastAPI( -# title="Obesity Prediction API", -# description="API REST pour gérer l'authentification, les utilisateurs et la prédiction de l'obésité.", -# version="1.0.0", -# openapi_tags=tags_metadata, -# ) - -# # Configurer CORS (frontend peut appeler l’API sans blocage) -# origins = [ -# "http://localhost:5500", # si tu ouvres le frontend avec live server -# "http://127.0.0.1:5500", -# "http://localhost:8000", -# "http://127.0.0.1:8000", -# ] -# app.add_middleware( -# CORSMiddleware, -# allow_origins=origins, # tu peux mettre ["*"] en dev -# allow_credentials=True, -# allow_methods=["*"], -# allow_headers=["*"], -# ) - -# # Inclure les routes -# app.include_router(auth.router, prefix="/api/auth", tags=["Auth"]) -# app.include_router(users.router, prefix="/api", tags=["Users"]) # users router already has /users -# app.include_router(predictions.router, prefix="/api", tags=["Predictions"]) # predictions router has /predictions -# app.include_router(metrics.router, prefix="/api", tags=["Metrics"]) # exposes /api/metrics - - -# # Endpoint de test (healthcheck) -# @app.get("/", tags=["Root"]) -# def read_root(): -# return {"message": "Bienvenue sur l'API de Prédiction de l'Obésité 🚀"} +# Healthcheck +@app.get("/", tags=["Root"]) +def read_root(): + return {"message": "Bienvenue sur l'API ObesiTrack 🚀"} diff --git a/tests/__pycache__/conftest.cpython-312-pytest-8.3.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-8.3.2.pyc index b161483fd..e12aae91e 100644 Binary files a/tests/__pycache__/conftest.cpython-312-pytest-8.3.2.pyc and b/tests/__pycache__/conftest.cpython-312-pytest-8.3.2.pyc differ diff --git a/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/INSTALLER b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/INSTALLER new file mode 100644 index 000000000..a1b589e38 --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/LICENSE.txt b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/LICENSE.txt new file mode 100644 index 000000000..7b190ca67 --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 2011 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/METADATA b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/METADATA new file mode 100644 index 000000000..ddf546484 --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/METADATA @@ -0,0 +1,60 @@ +Metadata-Version: 2.1 +Name: itsdangerous +Version: 2.2.0 +Summary: Safely pass data to untrusted environments and back. +Maintainer-email: Pallets +Requires-Python: >=3.8 +Description-Content-Type: text/markdown +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Typing :: Typed +Project-URL: Changes, https://itsdangerous.palletsprojects.com/changes/ +Project-URL: Chat, https://discord.gg/pallets +Project-URL: Documentation, https://itsdangerous.palletsprojects.com/ +Project-URL: Donate, https://palletsprojects.com/donate +Project-URL: Source, https://github.com/pallets/itsdangerous/ + +# ItsDangerous + +... so better sign this + +Various helpers to pass data to untrusted environments and to get it +back safe and sound. Data is cryptographically signed to ensure that a +token has not been tampered with. + +It's possible to customize how data is serialized. Data is compressed as +needed. A timestamp can be added and verified automatically while +loading a token. + + +## A Simple Example + +Here's how you could generate a token for transmitting a user's id and +name between web requests. + +```python +from itsdangerous import URLSafeSerializer +auth_s = URLSafeSerializer("secret key", "auth") +token = auth_s.dumps({"id": 5, "name": "itsdangerous"}) + +print(token) +# eyJpZCI6NSwibmFtZSI6Iml0c2Rhbmdlcm91cyJ9.6YP6T0BaO67XP--9UzTrmurXSmg + +data = auth_s.loads(token) +print(data["name"]) +# itsdangerous +``` + + +## Donate + +The Pallets organization develops and supports ItsDangerous and other +popular packages. In order to grow the community of contributors and +users, and allow the maintainers to devote more time to the projects, +[please donate today][]. + +[please donate today]: https://palletsprojects.com/donate + diff --git a/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/RECORD b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/RECORD new file mode 100644 index 000000000..b73b08a48 --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/RECORD @@ -0,0 +1,23 @@ +itsdangerous-2.2.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +itsdangerous-2.2.0.dist-info/LICENSE.txt,sha256=Y68JiRtr6K0aQlLtQ68PTvun_JSOIoNnvtfzxa4LCdc,1475 +itsdangerous-2.2.0.dist-info/METADATA,sha256=0rk0-1ZwihuU5DnwJVwPWoEI4yWOyCexih3JyZHblhE,1924 +itsdangerous-2.2.0.dist-info/RECORD,, +itsdangerous-2.2.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +itsdangerous-2.2.0.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81 +itsdangerous/__init__.py,sha256=4SK75sCe29xbRgQE1ZQtMHnKUuZYAf3bSpZOrff1IAY,1427 +itsdangerous/__pycache__/__init__.cpython-312.pyc,, +itsdangerous/__pycache__/_json.cpython-312.pyc,, +itsdangerous/__pycache__/encoding.cpython-312.pyc,, +itsdangerous/__pycache__/exc.cpython-312.pyc,, +itsdangerous/__pycache__/serializer.cpython-312.pyc,, +itsdangerous/__pycache__/signer.cpython-312.pyc,, +itsdangerous/__pycache__/timed.cpython-312.pyc,, +itsdangerous/__pycache__/url_safe.cpython-312.pyc,, +itsdangerous/_json.py,sha256=wPQGmge2yZ9328EHKF6gadGeyGYCJQKxtU-iLKE6UnA,473 +itsdangerous/encoding.py,sha256=wwTz5q_3zLcaAdunk6_vSoStwGqYWe307Zl_U87aRFM,1409 +itsdangerous/exc.py,sha256=Rr3exo0MRFEcPZltwecyK16VV1bE2K9_F1-d-ljcUn4,3201 +itsdangerous/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +itsdangerous/serializer.py,sha256=PmdwADLqkSyQLZ0jOKAgDsAW4k_H0TlA71Ei3z0C5aI,15601 +itsdangerous/signer.py,sha256=YO0CV7NBvHA6j549REHJFUjUojw2pHqwcUpQnU7yNYQ,9647 +itsdangerous/timed.py,sha256=6RvDMqNumGMxf0-HlpaZdN9PUQQmRvrQGplKhxuivUs,8083 +itsdangerous/url_safe.py,sha256=az4e5fXi_vs-YbWj8YZwn4wiVKfeD--GEKRT5Ueu4P4,2505 diff --git a/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/REQUESTED b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/REQUESTED new file mode 100644 index 000000000..e69de29bb diff --git a/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/WHEEL b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/WHEEL new file mode 100644 index 000000000..3b5e64b5e --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous-2.2.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: flit 3.9.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/venv/Lib/site-packages/itsdangerous/__init__.py b/venv/Lib/site-packages/itsdangerous/__init__.py new file mode 100644 index 000000000..ea55256eb --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous/__init__.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import typing as t + +from .encoding import base64_decode as base64_decode +from .encoding import base64_encode as base64_encode +from .encoding import want_bytes as want_bytes +from .exc import BadData as BadData +from .exc import BadHeader as BadHeader +from .exc import BadPayload as BadPayload +from .exc import BadSignature as BadSignature +from .exc import BadTimeSignature as BadTimeSignature +from .exc import SignatureExpired as SignatureExpired +from .serializer import Serializer as Serializer +from .signer import HMACAlgorithm as HMACAlgorithm +from .signer import NoneAlgorithm as NoneAlgorithm +from .signer import Signer as Signer +from .timed import TimedSerializer as TimedSerializer +from .timed import TimestampSigner as TimestampSigner +from .url_safe import URLSafeSerializer as URLSafeSerializer +from .url_safe import URLSafeTimedSerializer as URLSafeTimedSerializer + + +def __getattr__(name: str) -> t.Any: + if name == "__version__": + import importlib.metadata + import warnings + + warnings.warn( + "The '__version__' attribute is deprecated and will be removed in" + " ItsDangerous 2.3. Use feature detection or" + " 'importlib.metadata.version(\"itsdangerous\")' instead.", + DeprecationWarning, + stacklevel=2, + ) + return importlib.metadata.version("itsdangerous") + + raise AttributeError(name) diff --git a/venv/Lib/site-packages/itsdangerous/__pycache__/__init__.cpython-312.pyc b/venv/Lib/site-packages/itsdangerous/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..bd917e2d8 Binary files /dev/null and b/venv/Lib/site-packages/itsdangerous/__pycache__/__init__.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/itsdangerous/__pycache__/_json.cpython-312.pyc b/venv/Lib/site-packages/itsdangerous/__pycache__/_json.cpython-312.pyc new file mode 100644 index 000000000..6349ded2c Binary files /dev/null and b/venv/Lib/site-packages/itsdangerous/__pycache__/_json.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/itsdangerous/__pycache__/encoding.cpython-312.pyc b/venv/Lib/site-packages/itsdangerous/__pycache__/encoding.cpython-312.pyc new file mode 100644 index 000000000..b597beb7c Binary files /dev/null and b/venv/Lib/site-packages/itsdangerous/__pycache__/encoding.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/itsdangerous/__pycache__/exc.cpython-312.pyc b/venv/Lib/site-packages/itsdangerous/__pycache__/exc.cpython-312.pyc new file mode 100644 index 000000000..9b789add0 Binary files /dev/null and b/venv/Lib/site-packages/itsdangerous/__pycache__/exc.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/itsdangerous/__pycache__/serializer.cpython-312.pyc b/venv/Lib/site-packages/itsdangerous/__pycache__/serializer.cpython-312.pyc new file mode 100644 index 000000000..a7c959624 Binary files /dev/null and b/venv/Lib/site-packages/itsdangerous/__pycache__/serializer.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/itsdangerous/__pycache__/signer.cpython-312.pyc b/venv/Lib/site-packages/itsdangerous/__pycache__/signer.cpython-312.pyc new file mode 100644 index 000000000..89d538c7f Binary files /dev/null and b/venv/Lib/site-packages/itsdangerous/__pycache__/signer.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/itsdangerous/__pycache__/timed.cpython-312.pyc b/venv/Lib/site-packages/itsdangerous/__pycache__/timed.cpython-312.pyc new file mode 100644 index 000000000..7ea51c7c0 Binary files /dev/null and b/venv/Lib/site-packages/itsdangerous/__pycache__/timed.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/itsdangerous/__pycache__/url_safe.cpython-312.pyc b/venv/Lib/site-packages/itsdangerous/__pycache__/url_safe.cpython-312.pyc new file mode 100644 index 000000000..941de554b Binary files /dev/null and b/venv/Lib/site-packages/itsdangerous/__pycache__/url_safe.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/itsdangerous/_json.py b/venv/Lib/site-packages/itsdangerous/_json.py new file mode 100644 index 000000000..fc23feaaf --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous/_json.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import json as _json +import typing as t + + +class _CompactJSON: + """Wrapper around json module that strips whitespace.""" + + @staticmethod + def loads(payload: str | bytes) -> t.Any: + return _json.loads(payload) + + @staticmethod + def dumps(obj: t.Any, **kwargs: t.Any) -> str: + kwargs.setdefault("ensure_ascii", False) + kwargs.setdefault("separators", (",", ":")) + return _json.dumps(obj, **kwargs) diff --git a/venv/Lib/site-packages/itsdangerous/encoding.py b/venv/Lib/site-packages/itsdangerous/encoding.py new file mode 100644 index 000000000..f5ca80f90 --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous/encoding.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import base64 +import string +import struct +import typing as t + +from .exc import BadData + + +def want_bytes( + s: str | bytes, encoding: str = "utf-8", errors: str = "strict" +) -> bytes: + if isinstance(s, str): + s = s.encode(encoding, errors) + + return s + + +def base64_encode(string: str | bytes) -> bytes: + """Base64 encode a string of bytes or text. The resulting bytes are + safe to use in URLs. + """ + string = want_bytes(string) + return base64.urlsafe_b64encode(string).rstrip(b"=") + + +def base64_decode(string: str | bytes) -> bytes: + """Base64 decode a URL-safe string of bytes or text. The result is + bytes. + """ + string = want_bytes(string, encoding="ascii", errors="ignore") + string += b"=" * (-len(string) % 4) + + try: + return base64.urlsafe_b64decode(string) + except (TypeError, ValueError) as e: + raise BadData("Invalid base64-encoded data") from e + + +# The alphabet used by base64.urlsafe_* +_base64_alphabet = f"{string.ascii_letters}{string.digits}-_=".encode("ascii") + +_int64_struct = struct.Struct(">Q") +_int_to_bytes = _int64_struct.pack +_bytes_to_int = t.cast("t.Callable[[bytes], tuple[int]]", _int64_struct.unpack) + + +def int_to_bytes(num: int) -> bytes: + return _int_to_bytes(num).lstrip(b"\x00") + + +def bytes_to_int(bytestr: bytes) -> int: + return _bytes_to_int(bytestr.rjust(8, b"\x00"))[0] diff --git a/venv/Lib/site-packages/itsdangerous/exc.py b/venv/Lib/site-packages/itsdangerous/exc.py new file mode 100644 index 000000000..a75adcd52 --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous/exc.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import typing as t +from datetime import datetime + + +class BadData(Exception): + """Raised if bad data of any sort was encountered. This is the base + for all exceptions that ItsDangerous defines. + + .. versionadded:: 0.15 + """ + + def __init__(self, message: str): + super().__init__(message) + self.message = message + + def __str__(self) -> str: + return self.message + + +class BadSignature(BadData): + """Raised if a signature does not match.""" + + def __init__(self, message: str, payload: t.Any | None = None): + super().__init__(message) + + #: The payload that failed the signature test. In some + #: situations you might still want to inspect this, even if + #: you know it was tampered with. + #: + #: .. versionadded:: 0.14 + self.payload: t.Any | None = payload + + +class BadTimeSignature(BadSignature): + """Raised if a time-based signature is invalid. This is a subclass + of :class:`BadSignature`. + """ + + def __init__( + self, + message: str, + payload: t.Any | None = None, + date_signed: datetime | None = None, + ): + super().__init__(message, payload) + + #: If the signature expired this exposes the date of when the + #: signature was created. This can be helpful in order to + #: tell the user how long a link has been gone stale. + #: + #: .. versionchanged:: 2.0 + #: The datetime value is timezone-aware rather than naive. + #: + #: .. versionadded:: 0.14 + self.date_signed = date_signed + + +class SignatureExpired(BadTimeSignature): + """Raised if a signature timestamp is older than ``max_age``. This + is a subclass of :exc:`BadTimeSignature`. + """ + + +class BadHeader(BadSignature): + """Raised if a signed header is invalid in some form. This only + happens for serializers that have a header that goes with the + signature. + + .. versionadded:: 0.24 + """ + + def __init__( + self, + message: str, + payload: t.Any | None = None, + header: t.Any | None = None, + original_error: Exception | None = None, + ): + super().__init__(message, payload) + + #: If the header is actually available but just malformed it + #: might be stored here. + self.header: t.Any | None = header + + #: If available, the error that indicates why the payload was + #: not valid. This might be ``None``. + self.original_error: Exception | None = original_error + + +class BadPayload(BadData): + """Raised if a payload is invalid. This could happen if the payload + is loaded despite an invalid signature, or if there is a mismatch + between the serializer and deserializer. The original exception + that occurred during loading is stored on as :attr:`original_error`. + + .. versionadded:: 0.15 + """ + + def __init__(self, message: str, original_error: Exception | None = None): + super().__init__(message) + + #: If available, the error that indicates why the payload was + #: not valid. This might be ``None``. + self.original_error: Exception | None = original_error diff --git a/venv/Lib/site-packages/itsdangerous/py.typed b/venv/Lib/site-packages/itsdangerous/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/venv/Lib/site-packages/itsdangerous/serializer.py b/venv/Lib/site-packages/itsdangerous/serializer.py new file mode 100644 index 000000000..5ddf3871d --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous/serializer.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import collections.abc as cabc +import json +import typing as t + +from .encoding import want_bytes +from .exc import BadPayload +from .exc import BadSignature +from .signer import _make_keys_list +from .signer import Signer + +if t.TYPE_CHECKING: + import typing_extensions as te + + # This should be either be str or bytes. To avoid having to specify the + # bound type, it falls back to a union if structural matching fails. + _TSerialized = te.TypeVar( + "_TSerialized", bound=t.Union[str, bytes], default=t.Union[str, bytes] + ) +else: + # Still available at runtime on Python < 3.13, but without the default. + _TSerialized = t.TypeVar("_TSerialized", bound=t.Union[str, bytes]) + + +class _PDataSerializer(t.Protocol[_TSerialized]): + def loads(self, payload: _TSerialized, /) -> t.Any: ... + # A signature with additional arguments is not handled correctly by type + # checkers right now, so an overload is used below for serializers that + # don't match this strict protocol. + def dumps(self, obj: t.Any, /) -> _TSerialized: ... + + +# Use TypeIs once it's available in typing_extensions or 3.13. +def is_text_serializer( + serializer: _PDataSerializer[t.Any], +) -> te.TypeGuard[_PDataSerializer[str]]: + """Checks whether a serializer generates text or binary.""" + return isinstance(serializer.dumps({}), str) + + +class Serializer(t.Generic[_TSerialized]): + """A serializer wraps a :class:`~itsdangerous.signer.Signer` to + enable serializing and securely signing data other than bytes. It + can unsign to verify that the data hasn't been changed. + + The serializer provides :meth:`dumps` and :meth:`loads`, similar to + :mod:`json`, and by default uses :mod:`json` internally to serialize + the data to bytes. + + The secret key should be a random string of ``bytes`` and should not + be saved to code or version control. Different salts should be used + to distinguish signing in different contexts. See :doc:`/concepts` + for information about the security of the secret key and salt. + + :param secret_key: The secret key to sign and verify with. Can be a + list of keys, oldest to newest, to support key rotation. + :param salt: Extra key to combine with ``secret_key`` to distinguish + signatures in different contexts. + :param serializer: An object that provides ``dumps`` and ``loads`` + methods for serializing data to a string. Defaults to + :attr:`default_serializer`, which defaults to :mod:`json`. + :param serializer_kwargs: Keyword arguments to pass when calling + ``serializer.dumps``. + :param signer: A ``Signer`` class to instantiate when signing data. + Defaults to :attr:`default_signer`, which defaults to + :class:`~itsdangerous.signer.Signer`. + :param signer_kwargs: Keyword arguments to pass when instantiating + the ``Signer`` class. + :param fallback_signers: List of signer parameters to try when + unsigning with the default signer fails. Each item can be a dict + of ``signer_kwargs``, a ``Signer`` class, or a tuple of + ``(signer, signer_kwargs)``. Defaults to + :attr:`default_fallback_signers`. + + .. versionchanged:: 2.0 + Added support for key rotation by passing a list to + ``secret_key``. + + .. versionchanged:: 2.0 + Removed the default SHA-512 fallback signer from + ``default_fallback_signers``. + + .. versionchanged:: 1.1 + Added support for ``fallback_signers`` and configured a default + SHA-512 fallback. This fallback is for users who used the yanked + 1.0.0 release which defaulted to SHA-512. + + .. versionchanged:: 0.14 + The ``signer`` and ``signer_kwargs`` parameters were added to + the constructor. + """ + + #: The default serialization module to use to serialize data to a + #: string internally. The default is :mod:`json`, but can be changed + #: to any object that provides ``dumps`` and ``loads`` methods. + default_serializer: _PDataSerializer[t.Any] = json + + #: The default ``Signer`` class to instantiate when signing data. + #: The default is :class:`itsdangerous.signer.Signer`. + default_signer: type[Signer] = Signer + + #: The default fallback signers to try when unsigning fails. + default_fallback_signers: list[ + dict[str, t.Any] | tuple[type[Signer], dict[str, t.Any]] | type[Signer] + ] = [] + + # Serializer[str] if no data serializer is provided, or if it returns str. + @t.overload + def __init__( + self: Serializer[str], + secret_key: str | bytes | cabc.Iterable[str] | cabc.Iterable[bytes], + salt: str | bytes | None = b"itsdangerous", + serializer: None | _PDataSerializer[str] = None, + serializer_kwargs: dict[str, t.Any] | None = None, + signer: type[Signer] | None = None, + signer_kwargs: dict[str, t.Any] | None = None, + fallback_signers: list[ + dict[str, t.Any] | tuple[type[Signer], dict[str, t.Any]] | type[Signer] + ] + | None = None, + ): ... + + # Serializer[bytes] with a bytes data serializer positional argument. + @t.overload + def __init__( + self: Serializer[bytes], + secret_key: str | bytes | cabc.Iterable[str] | cabc.Iterable[bytes], + salt: str | bytes | None, + serializer: _PDataSerializer[bytes], + serializer_kwargs: dict[str, t.Any] | None = None, + signer: type[Signer] | None = None, + signer_kwargs: dict[str, t.Any] | None = None, + fallback_signers: list[ + dict[str, t.Any] | tuple[type[Signer], dict[str, t.Any]] | type[Signer] + ] + | None = None, + ): ... + + # Serializer[bytes] with a bytes data serializer keyword argument. + @t.overload + def __init__( + self: Serializer[bytes], + secret_key: str | bytes | cabc.Iterable[str] | cabc.Iterable[bytes], + salt: str | bytes | None = b"itsdangerous", + *, + serializer: _PDataSerializer[bytes], + serializer_kwargs: dict[str, t.Any] | None = None, + signer: type[Signer] | None = None, + signer_kwargs: dict[str, t.Any] | None = None, + fallback_signers: list[ + dict[str, t.Any] | tuple[type[Signer], dict[str, t.Any]] | type[Signer] + ] + | None = None, + ): ... + + # Fall back with a positional argument. If the strict signature of + # _PDataSerializer doesn't match, fall back to a union, requiring the user + # to specify the type. + @t.overload + def __init__( + self, + secret_key: str | bytes | cabc.Iterable[str] | cabc.Iterable[bytes], + salt: str | bytes | None, + serializer: t.Any, + serializer_kwargs: dict[str, t.Any] | None = None, + signer: type[Signer] | None = None, + signer_kwargs: dict[str, t.Any] | None = None, + fallback_signers: list[ + dict[str, t.Any] | tuple[type[Signer], dict[str, t.Any]] | type[Signer] + ] + | None = None, + ): ... + + # Fall back with a keyword argument. + @t.overload + def __init__( + self, + secret_key: str | bytes | cabc.Iterable[str] | cabc.Iterable[bytes], + salt: str | bytes | None = b"itsdangerous", + *, + serializer: t.Any, + serializer_kwargs: dict[str, t.Any] | None = None, + signer: type[Signer] | None = None, + signer_kwargs: dict[str, t.Any] | None = None, + fallback_signers: list[ + dict[str, t.Any] | tuple[type[Signer], dict[str, t.Any]] | type[Signer] + ] + | None = None, + ): ... + + def __init__( + self, + secret_key: str | bytes | cabc.Iterable[str] | cabc.Iterable[bytes], + salt: str | bytes | None = b"itsdangerous", + serializer: t.Any | None = None, + serializer_kwargs: dict[str, t.Any] | None = None, + signer: type[Signer] | None = None, + signer_kwargs: dict[str, t.Any] | None = None, + fallback_signers: list[ + dict[str, t.Any] | tuple[type[Signer], dict[str, t.Any]] | type[Signer] + ] + | None = None, + ): + #: The list of secret keys to try for verifying signatures, from + #: oldest to newest. The newest (last) key is used for signing. + #: + #: This allows a key rotation system to keep a list of allowed + #: keys and remove expired ones. + self.secret_keys: list[bytes] = _make_keys_list(secret_key) + + if salt is not None: + salt = want_bytes(salt) + # if salt is None then the signer's default is used + + self.salt = salt + + if serializer is None: + serializer = self.default_serializer + + self.serializer: _PDataSerializer[_TSerialized] = serializer + self.is_text_serializer: bool = is_text_serializer(serializer) + + if signer is None: + signer = self.default_signer + + self.signer: type[Signer] = signer + self.signer_kwargs: dict[str, t.Any] = signer_kwargs or {} + + if fallback_signers is None: + fallback_signers = list(self.default_fallback_signers) + + self.fallback_signers: list[ + dict[str, t.Any] | tuple[type[Signer], dict[str, t.Any]] | type[Signer] + ] = fallback_signers + self.serializer_kwargs: dict[str, t.Any] = serializer_kwargs or {} + + @property + def secret_key(self) -> bytes: + """The newest (last) entry in the :attr:`secret_keys` list. This + is for compatibility from before key rotation support was added. + """ + return self.secret_keys[-1] + + def load_payload( + self, payload: bytes, serializer: _PDataSerializer[t.Any] | None = None + ) -> t.Any: + """Loads the encoded object. This function raises + :class:`.BadPayload` if the payload is not valid. The + ``serializer`` parameter can be used to override the serializer + stored on the class. The encoded ``payload`` should always be + bytes. + """ + if serializer is None: + use_serializer = self.serializer + is_text = self.is_text_serializer + else: + use_serializer = serializer + is_text = is_text_serializer(serializer) + + try: + if is_text: + return use_serializer.loads(payload.decode("utf-8")) # type: ignore[arg-type] + + return use_serializer.loads(payload) # type: ignore[arg-type] + except Exception as e: + raise BadPayload( + "Could not load the payload because an exception" + " occurred on unserializing the data.", + original_error=e, + ) from e + + def dump_payload(self, obj: t.Any) -> bytes: + """Dumps the encoded object. The return value is always bytes. + If the internal serializer returns text, the value will be + encoded as UTF-8. + """ + return want_bytes(self.serializer.dumps(obj, **self.serializer_kwargs)) + + def make_signer(self, salt: str | bytes | None = None) -> Signer: + """Creates a new instance of the signer to be used. The default + implementation uses the :class:`.Signer` base class. + """ + if salt is None: + salt = self.salt + + return self.signer(self.secret_keys, salt=salt, **self.signer_kwargs) + + def iter_unsigners(self, salt: str | bytes | None = None) -> cabc.Iterator[Signer]: + """Iterates over all signers to be tried for unsigning. Starts + with the configured signer, then constructs each signer + specified in ``fallback_signers``. + """ + if salt is None: + salt = self.salt + + yield self.make_signer(salt) + + for fallback in self.fallback_signers: + if isinstance(fallback, dict): + kwargs = fallback + fallback = self.signer + elif isinstance(fallback, tuple): + fallback, kwargs = fallback + else: + kwargs = self.signer_kwargs + + for secret_key in self.secret_keys: + yield fallback(secret_key, salt=salt, **kwargs) + + def dumps(self, obj: t.Any, salt: str | bytes | None = None) -> _TSerialized: + """Returns a signed string serialized with the internal + serializer. The return value can be either a byte or unicode + string depending on the format of the internal serializer. + """ + payload = want_bytes(self.dump_payload(obj)) + rv = self.make_signer(salt).sign(payload) + + if self.is_text_serializer: + return rv.decode("utf-8") # type: ignore[return-value] + + return rv # type: ignore[return-value] + + def dump(self, obj: t.Any, f: t.IO[t.Any], salt: str | bytes | None = None) -> None: + """Like :meth:`dumps` but dumps into a file. The file handle has + to be compatible with what the internal serializer expects. + """ + f.write(self.dumps(obj, salt)) + + def loads( + self, s: str | bytes, salt: str | bytes | None = None, **kwargs: t.Any + ) -> t.Any: + """Reverse of :meth:`dumps`. Raises :exc:`.BadSignature` if the + signature validation fails. + """ + s = want_bytes(s) + last_exception = None + + for signer in self.iter_unsigners(salt): + try: + return self.load_payload(signer.unsign(s)) + except BadSignature as err: + last_exception = err + + raise t.cast(BadSignature, last_exception) + + def load(self, f: t.IO[t.Any], salt: str | bytes | None = None) -> t.Any: + """Like :meth:`loads` but loads from a file.""" + return self.loads(f.read(), salt) + + def loads_unsafe( + self, s: str | bytes, salt: str | bytes | None = None + ) -> tuple[bool, t.Any]: + """Like :meth:`loads` but without verifying the signature. This + is potentially very dangerous to use depending on how your + serializer works. The return value is ``(signature_valid, + payload)`` instead of just the payload. The first item will be a + boolean that indicates if the signature is valid. This function + never fails. + + Use it for debugging only and if you know that your serializer + module is not exploitable (for example, do not use it with a + pickle serializer). + + .. versionadded:: 0.15 + """ + return self._loads_unsafe_impl(s, salt) + + def _loads_unsafe_impl( + self, + s: str | bytes, + salt: str | bytes | None, + load_kwargs: dict[str, t.Any] | None = None, + load_payload_kwargs: dict[str, t.Any] | None = None, + ) -> tuple[bool, t.Any]: + """Low level helper function to implement :meth:`loads_unsafe` + in serializer subclasses. + """ + if load_kwargs is None: + load_kwargs = {} + + try: + return True, self.loads(s, salt=salt, **load_kwargs) + except BadSignature as e: + if e.payload is None: + return False, None + + if load_payload_kwargs is None: + load_payload_kwargs = {} + + try: + return ( + False, + self.load_payload(e.payload, **load_payload_kwargs), + ) + except BadPayload: + return False, None + + def load_unsafe( + self, f: t.IO[t.Any], salt: str | bytes | None = None + ) -> tuple[bool, t.Any]: + """Like :meth:`loads_unsafe` but loads from a file. + + .. versionadded:: 0.15 + """ + return self.loads_unsafe(f.read(), salt=salt) diff --git a/venv/Lib/site-packages/itsdangerous/signer.py b/venv/Lib/site-packages/itsdangerous/signer.py new file mode 100644 index 000000000..e324dc03d --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous/signer.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import collections.abc as cabc +import hashlib +import hmac +import typing as t + +from .encoding import _base64_alphabet +from .encoding import base64_decode +from .encoding import base64_encode +from .encoding import want_bytes +from .exc import BadSignature + + +class SigningAlgorithm: + """Subclasses must implement :meth:`get_signature` to provide + signature generation functionality. + """ + + def get_signature(self, key: bytes, value: bytes) -> bytes: + """Returns the signature for the given key and value.""" + raise NotImplementedError() + + def verify_signature(self, key: bytes, value: bytes, sig: bytes) -> bool: + """Verifies the given signature matches the expected + signature. + """ + return hmac.compare_digest(sig, self.get_signature(key, value)) + + +class NoneAlgorithm(SigningAlgorithm): + """Provides an algorithm that does not perform any signing and + returns an empty signature. + """ + + def get_signature(self, key: bytes, value: bytes) -> bytes: + return b"" + + +def _lazy_sha1(string: bytes = b"") -> t.Any: + """Don't access ``hashlib.sha1`` until runtime. FIPS builds may not include + SHA-1, in which case the import and use as a default would fail before the + developer can configure something else. + """ + return hashlib.sha1(string) + + +class HMACAlgorithm(SigningAlgorithm): + """Provides signature generation using HMACs.""" + + #: The digest method to use with the MAC algorithm. This defaults to + #: SHA1, but can be changed to any other function in the hashlib + #: module. + default_digest_method: t.Any = staticmethod(_lazy_sha1) + + def __init__(self, digest_method: t.Any = None): + if digest_method is None: + digest_method = self.default_digest_method + + self.digest_method: t.Any = digest_method + + def get_signature(self, key: bytes, value: bytes) -> bytes: + mac = hmac.new(key, msg=value, digestmod=self.digest_method) + return mac.digest() + + +def _make_keys_list( + secret_key: str | bytes | cabc.Iterable[str] | cabc.Iterable[bytes], +) -> list[bytes]: + if isinstance(secret_key, (str, bytes)): + return [want_bytes(secret_key)] + + return [want_bytes(s) for s in secret_key] # pyright: ignore + + +class Signer: + """A signer securely signs bytes, then unsigns them to verify that + the value hasn't been changed. + + The secret key should be a random string of ``bytes`` and should not + be saved to code or version control. Different salts should be used + to distinguish signing in different contexts. See :doc:`/concepts` + for information about the security of the secret key and salt. + + :param secret_key: The secret key to sign and verify with. Can be a + list of keys, oldest to newest, to support key rotation. + :param salt: Extra key to combine with ``secret_key`` to distinguish + signatures in different contexts. + :param sep: Separator between the signature and value. + :param key_derivation: How to derive the signing key from the secret + key and salt. Possible values are ``concat``, ``django-concat``, + or ``hmac``. Defaults to :attr:`default_key_derivation`, which + defaults to ``django-concat``. + :param digest_method: Hash function to use when generating the HMAC + signature. Defaults to :attr:`default_digest_method`, which + defaults to :func:`hashlib.sha1`. Note that the security of the + hash alone doesn't apply when used intermediately in HMAC. + :param algorithm: A :class:`SigningAlgorithm` instance to use + instead of building a default :class:`HMACAlgorithm` with the + ``digest_method``. + + .. versionchanged:: 2.0 + Added support for key rotation by passing a list to + ``secret_key``. + + .. versionchanged:: 0.18 + ``algorithm`` was added as an argument to the class constructor. + + .. versionchanged:: 0.14 + ``key_derivation`` and ``digest_method`` were added as arguments + to the class constructor. + """ + + #: The default digest method to use for the signer. The default is + #: :func:`hashlib.sha1`, but can be changed to any :mod:`hashlib` or + #: compatible object. Note that the security of the hash alone + #: doesn't apply when used intermediately in HMAC. + #: + #: .. versionadded:: 0.14 + default_digest_method: t.Any = staticmethod(_lazy_sha1) + + #: The default scheme to use to derive the signing key from the + #: secret key and salt. The default is ``django-concat``. Possible + #: values are ``concat``, ``django-concat``, and ``hmac``. + #: + #: .. versionadded:: 0.14 + default_key_derivation: str = "django-concat" + + def __init__( + self, + secret_key: str | bytes | cabc.Iterable[str] | cabc.Iterable[bytes], + salt: str | bytes | None = b"itsdangerous.Signer", + sep: str | bytes = b".", + key_derivation: str | None = None, + digest_method: t.Any | None = None, + algorithm: SigningAlgorithm | None = None, + ): + #: The list of secret keys to try for verifying signatures, from + #: oldest to newest. The newest (last) key is used for signing. + #: + #: This allows a key rotation system to keep a list of allowed + #: keys and remove expired ones. + self.secret_keys: list[bytes] = _make_keys_list(secret_key) + self.sep: bytes = want_bytes(sep) + + if self.sep in _base64_alphabet: + raise ValueError( + "The given separator cannot be used because it may be" + " contained in the signature itself. ASCII letters," + " digits, and '-_=' must not be used." + ) + + if salt is not None: + salt = want_bytes(salt) + else: + salt = b"itsdangerous.Signer" + + self.salt = salt + + if key_derivation is None: + key_derivation = self.default_key_derivation + + self.key_derivation: str = key_derivation + + if digest_method is None: + digest_method = self.default_digest_method + + self.digest_method: t.Any = digest_method + + if algorithm is None: + algorithm = HMACAlgorithm(self.digest_method) + + self.algorithm: SigningAlgorithm = algorithm + + @property + def secret_key(self) -> bytes: + """The newest (last) entry in the :attr:`secret_keys` list. This + is for compatibility from before key rotation support was added. + """ + return self.secret_keys[-1] + + def derive_key(self, secret_key: str | bytes | None = None) -> bytes: + """This method is called to derive the key. The default key + derivation choices can be overridden here. Key derivation is not + intended to be used as a security method to make a complex key + out of a short password. Instead you should use large random + secret keys. + + :param secret_key: A specific secret key to derive from. + Defaults to the last item in :attr:`secret_keys`. + + .. versionchanged:: 2.0 + Added the ``secret_key`` parameter. + """ + if secret_key is None: + secret_key = self.secret_keys[-1] + else: + secret_key = want_bytes(secret_key) + + if self.key_derivation == "concat": + return t.cast(bytes, self.digest_method(self.salt + secret_key).digest()) + elif self.key_derivation == "django-concat": + return t.cast( + bytes, self.digest_method(self.salt + b"signer" + secret_key).digest() + ) + elif self.key_derivation == "hmac": + mac = hmac.new(secret_key, digestmod=self.digest_method) + mac.update(self.salt) + return mac.digest() + elif self.key_derivation == "none": + return secret_key + else: + raise TypeError("Unknown key derivation method") + + def get_signature(self, value: str | bytes) -> bytes: + """Returns the signature for the given value.""" + value = want_bytes(value) + key = self.derive_key() + sig = self.algorithm.get_signature(key, value) + return base64_encode(sig) + + def sign(self, value: str | bytes) -> bytes: + """Signs the given string.""" + value = want_bytes(value) + return value + self.sep + self.get_signature(value) + + def verify_signature(self, value: str | bytes, sig: str | bytes) -> bool: + """Verifies the signature for the given value.""" + try: + sig = base64_decode(sig) + except Exception: + return False + + value = want_bytes(value) + + for secret_key in reversed(self.secret_keys): + key = self.derive_key(secret_key) + + if self.algorithm.verify_signature(key, value, sig): + return True + + return False + + def unsign(self, signed_value: str | bytes) -> bytes: + """Unsigns the given string.""" + signed_value = want_bytes(signed_value) + + if self.sep not in signed_value: + raise BadSignature(f"No {self.sep!r} found in value") + + value, sig = signed_value.rsplit(self.sep, 1) + + if self.verify_signature(value, sig): + return value + + raise BadSignature(f"Signature {sig!r} does not match", payload=value) + + def validate(self, signed_value: str | bytes) -> bool: + """Only validates the given signed value. Returns ``True`` if + the signature exists and is valid. + """ + try: + self.unsign(signed_value) + return True + except BadSignature: + return False diff --git a/venv/Lib/site-packages/itsdangerous/timed.py b/venv/Lib/site-packages/itsdangerous/timed.py new file mode 100644 index 000000000..73843755d --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous/timed.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import collections.abc as cabc +import time +import typing as t +from datetime import datetime +from datetime import timezone + +from .encoding import base64_decode +from .encoding import base64_encode +from .encoding import bytes_to_int +from .encoding import int_to_bytes +from .encoding import want_bytes +from .exc import BadSignature +from .exc import BadTimeSignature +from .exc import SignatureExpired +from .serializer import _TSerialized +from .serializer import Serializer +from .signer import Signer + + +class TimestampSigner(Signer): + """Works like the regular :class:`.Signer` but also records the time + of the signing and can be used to expire signatures. The + :meth:`unsign` method can raise :exc:`.SignatureExpired` if the + unsigning failed because the signature is expired. + """ + + def get_timestamp(self) -> int: + """Returns the current timestamp. The function must return an + integer. + """ + return int(time.time()) + + def timestamp_to_datetime(self, ts: int) -> datetime: + """Convert the timestamp from :meth:`get_timestamp` into an + aware :class`datetime.datetime` in UTC. + + .. versionchanged:: 2.0 + The timestamp is returned as a timezone-aware ``datetime`` + in UTC rather than a naive ``datetime`` assumed to be UTC. + """ + return datetime.fromtimestamp(ts, tz=timezone.utc) + + def sign(self, value: str | bytes) -> bytes: + """Signs the given string and also attaches time information.""" + value = want_bytes(value) + timestamp = base64_encode(int_to_bytes(self.get_timestamp())) + sep = want_bytes(self.sep) + value = value + sep + timestamp + return value + sep + self.get_signature(value) + + # Ignore overlapping signatures check, return_timestamp is the only + # parameter that affects the return type. + + @t.overload + def unsign( # type: ignore[overload-overlap] + self, + signed_value: str | bytes, + max_age: int | None = None, + return_timestamp: t.Literal[False] = False, + ) -> bytes: ... + + @t.overload + def unsign( + self, + signed_value: str | bytes, + max_age: int | None = None, + return_timestamp: t.Literal[True] = True, + ) -> tuple[bytes, datetime]: ... + + def unsign( + self, + signed_value: str | bytes, + max_age: int | None = None, + return_timestamp: bool = False, + ) -> tuple[bytes, datetime] | bytes: + """Works like the regular :meth:`.Signer.unsign` but can also + validate the time. See the base docstring of the class for + the general behavior. If ``return_timestamp`` is ``True`` the + timestamp of the signature will be returned as an aware + :class:`datetime.datetime` object in UTC. + + .. versionchanged:: 2.0 + The timestamp is returned as a timezone-aware ``datetime`` + in UTC rather than a naive ``datetime`` assumed to be UTC. + """ + try: + result = super().unsign(signed_value) + sig_error = None + except BadSignature as e: + sig_error = e + result = e.payload or b"" + + sep = want_bytes(self.sep) + + # If there is no timestamp in the result there is something + # seriously wrong. In case there was a signature error, we raise + # that one directly, otherwise we have a weird situation in + # which we shouldn't have come except someone uses a time-based + # serializer on non-timestamp data, so catch that. + if sep not in result: + if sig_error: + raise sig_error + + raise BadTimeSignature("timestamp missing", payload=result) + + value, ts_bytes = result.rsplit(sep, 1) + ts_int: int | None = None + ts_dt: datetime | None = None + + try: + ts_int = bytes_to_int(base64_decode(ts_bytes)) + except Exception: + pass + + # Signature is *not* okay. Raise a proper error now that we have + # split the value and the timestamp. + if sig_error is not None: + if ts_int is not None: + try: + ts_dt = self.timestamp_to_datetime(ts_int) + except (ValueError, OSError, OverflowError) as exc: + # Windows raises OSError + # 32-bit raises OverflowError + raise BadTimeSignature( + "Malformed timestamp", payload=value + ) from exc + + raise BadTimeSignature(str(sig_error), payload=value, date_signed=ts_dt) + + # Signature was okay but the timestamp is actually not there or + # malformed. Should not happen, but we handle it anyway. + if ts_int is None: + raise BadTimeSignature("Malformed timestamp", payload=value) + + # Check timestamp is not older than max_age + if max_age is not None: + age = self.get_timestamp() - ts_int + + if age > max_age: + raise SignatureExpired( + f"Signature age {age} > {max_age} seconds", + payload=value, + date_signed=self.timestamp_to_datetime(ts_int), + ) + + if age < 0: + raise SignatureExpired( + f"Signature age {age} < 0 seconds", + payload=value, + date_signed=self.timestamp_to_datetime(ts_int), + ) + + if return_timestamp: + return value, self.timestamp_to_datetime(ts_int) + + return value + + def validate(self, signed_value: str | bytes, max_age: int | None = None) -> bool: + """Only validates the given signed value. Returns ``True`` if + the signature exists and is valid.""" + try: + self.unsign(signed_value, max_age=max_age) + return True + except BadSignature: + return False + + +class TimedSerializer(Serializer[_TSerialized]): + """Uses :class:`TimestampSigner` instead of the default + :class:`.Signer`. + """ + + default_signer: type[TimestampSigner] = TimestampSigner + + def iter_unsigners( + self, salt: str | bytes | None = None + ) -> cabc.Iterator[TimestampSigner]: + return t.cast("cabc.Iterator[TimestampSigner]", super().iter_unsigners(salt)) + + # TODO: Signature is incompatible because parameters were added + # before salt. + + def loads( # type: ignore[override] + self, + s: str | bytes, + max_age: int | None = None, + return_timestamp: bool = False, + salt: str | bytes | None = None, + ) -> t.Any: + """Reverse of :meth:`dumps`, raises :exc:`.BadSignature` if the + signature validation fails. If a ``max_age`` is provided it will + ensure the signature is not older than that time in seconds. In + case the signature is outdated, :exc:`.SignatureExpired` is + raised. All arguments are forwarded to the signer's + :meth:`~TimestampSigner.unsign` method. + """ + s = want_bytes(s) + last_exception = None + + for signer in self.iter_unsigners(salt): + try: + base64d, timestamp = signer.unsign( + s, max_age=max_age, return_timestamp=True + ) + payload = self.load_payload(base64d) + + if return_timestamp: + return payload, timestamp + + return payload + except SignatureExpired: + # The signature was unsigned successfully but was + # expired. Do not try the next signer. + raise + except BadSignature as err: + last_exception = err + + raise t.cast(BadSignature, last_exception) + + def loads_unsafe( # type: ignore[override] + self, + s: str | bytes, + max_age: int | None = None, + salt: str | bytes | None = None, + ) -> tuple[bool, t.Any]: + return self._loads_unsafe_impl(s, salt, load_kwargs={"max_age": max_age}) diff --git a/venv/Lib/site-packages/itsdangerous/url_safe.py b/venv/Lib/site-packages/itsdangerous/url_safe.py new file mode 100644 index 000000000..56a079331 --- /dev/null +++ b/venv/Lib/site-packages/itsdangerous/url_safe.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import typing as t +import zlib + +from ._json import _CompactJSON +from .encoding import base64_decode +from .encoding import base64_encode +from .exc import BadPayload +from .serializer import _PDataSerializer +from .serializer import Serializer +from .timed import TimedSerializer + + +class URLSafeSerializerMixin(Serializer[str]): + """Mixed in with a regular serializer it will attempt to zlib + compress the string to make it shorter if necessary. It will also + base64 encode the string so that it can safely be placed in a URL. + """ + + default_serializer: _PDataSerializer[str] = _CompactJSON + + def load_payload( + self, + payload: bytes, + *args: t.Any, + serializer: t.Any | None = None, + **kwargs: t.Any, + ) -> t.Any: + decompress = False + + if payload.startswith(b"."): + payload = payload[1:] + decompress = True + + try: + json = base64_decode(payload) + except Exception as e: + raise BadPayload( + "Could not base64 decode the payload because of an exception", + original_error=e, + ) from e + + if decompress: + try: + json = zlib.decompress(json) + except Exception as e: + raise BadPayload( + "Could not zlib decompress the payload before decoding the payload", + original_error=e, + ) from e + + return super().load_payload(json, *args, **kwargs) + + def dump_payload(self, obj: t.Any) -> bytes: + json = super().dump_payload(obj) + is_compressed = False + compressed = zlib.compress(json) + + if len(compressed) < (len(json) - 1): + json = compressed + is_compressed = True + + base64d = base64_encode(json) + + if is_compressed: + base64d = b"." + base64d + + return base64d + + +class URLSafeSerializer(URLSafeSerializerMixin, Serializer[str]): + """Works like :class:`.Serializer` but dumps and loads into a URL + safe string consisting of the upper and lowercase character of the + alphabet as well as ``'_'``, ``'-'`` and ``'.'``. + """ + + +class URLSafeTimedSerializer(URLSafeSerializerMixin, TimedSerializer[str]): + """Works like :class:`.TimedSerializer` but dumps and loads into a + URL safe string consisting of the upper and lowercase character of + the alphabet as well as ``'_'``, ``'-'`` and ``'.'``. + """ diff --git a/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/INSTALLER b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/INSTALLER new file mode 100644 index 000000000..a1b589e38 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/METADATA b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/METADATA new file mode 100644 index 000000000..b92426cc7 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/METADATA @@ -0,0 +1,63 @@ +Metadata-Version: 2.4 +Name: pydantic-settings +Version: 2.10.1 +Summary: Settings management using Pydantic +Project-URL: Homepage, https://github.com/pydantic/pydantic-settings +Project-URL: Funding, https://github.com/sponsors/samuelcolvin +Project-URL: Source, https://github.com/pydantic/pydantic-settings +Project-URL: Changelog, https://github.com/pydantic/pydantic-settings/releases +Project-URL: Documentation, https://docs.pydantic.dev/dev-v2/concepts/pydantic_settings/ +Author-email: Samuel Colvin , Eric Jolibois , Hasan Ramezani +License-Expression: MIT +License-File: LICENSE +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Console +Classifier: Environment :: MacOS X +Classifier: Framework :: Pydantic +Classifier: Framework :: Pydantic :: 2 +Classifier: Intended Audience :: Developers +Classifier: Intended Audience :: Information Technology +Classifier: Intended Audience :: System Administrators +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: POSIX :: Linux +Classifier: Operating System :: Unix +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Topic :: Internet +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.9 +Requires-Dist: pydantic>=2.7.0 +Requires-Dist: python-dotenv>=0.21.0 +Requires-Dist: typing-inspection>=0.4.0 +Provides-Extra: aws-secrets-manager +Requires-Dist: boto3-stubs[secretsmanager]; extra == 'aws-secrets-manager' +Requires-Dist: boto3>=1.35.0; extra == 'aws-secrets-manager' +Provides-Extra: azure-key-vault +Requires-Dist: azure-identity>=1.16.0; extra == 'azure-key-vault' +Requires-Dist: azure-keyvault-secrets>=4.8.0; extra == 'azure-key-vault' +Provides-Extra: gcp-secret-manager +Requires-Dist: google-cloud-secret-manager>=2.23.1; extra == 'gcp-secret-manager' +Provides-Extra: toml +Requires-Dist: tomli>=2.0.1; extra == 'toml' +Provides-Extra: yaml +Requires-Dist: pyyaml>=6.0.1; extra == 'yaml' +Description-Content-Type: text/markdown + +# pydantic-settings + +[![CI](https://github.com/pydantic/pydantic-settings/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/pydantic/pydantic-settings/actions/workflows/ci.yml?query=branch%3Amain) +[![Coverage](https://codecov.io/gh/pydantic/pydantic-settings/branch/main/graph/badge.svg)](https://codecov.io/gh/pydantic/pydantic-settings) +[![pypi](https://img.shields.io/pypi/v/pydantic-settings.svg)](https://pypi.python.org/pypi/pydantic-settings) +[![license](https://img.shields.io/github/license/pydantic/pydantic-settings.svg)](https://github.com/pydantic/pydantic-settings/blob/main/LICENSE) +[![downloads](https://static.pepy.tech/badge/pydantic-settings/month)](https://pepy.tech/project/pydantic-settings) +[![versions](https://img.shields.io/pypi/pyversions/pydantic-settings.svg)](https://github.com/pydantic/pydantic-settings) + +Settings management using Pydantic. + +See [documentation](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for more details. diff --git a/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/RECORD b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/RECORD new file mode 100644 index 000000000..06c3a9064 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/RECORD @@ -0,0 +1,49 @@ +pydantic_settings-2.10.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +pydantic_settings-2.10.1.dist-info/METADATA,sha256=R4TCEAA6hk0_YqITOPKSe5PV1hCzk8t_hoPSJ0gIqoQ,3393 +pydantic_settings-2.10.1.dist-info/RECORD,, +pydantic_settings-2.10.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +pydantic_settings-2.10.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87 +pydantic_settings-2.10.1.dist-info/licenses/LICENSE,sha256=6zVadT4CA0bTPYO_l2kTW4n8YQVorFMaAcKVvO5_2Zg,1103 +pydantic_settings/__init__.py,sha256=IUkO5TkUu6eYgRJhA1piTw4jp6-CBhV7kam0rEh1Flo,1563 +pydantic_settings/__pycache__/__init__.cpython-312.pyc,, +pydantic_settings/__pycache__/exceptions.cpython-312.pyc,, +pydantic_settings/__pycache__/main.cpython-312.pyc,, +pydantic_settings/__pycache__/utils.cpython-312.pyc,, +pydantic_settings/__pycache__/version.cpython-312.pyc,, +pydantic_settings/exceptions.py,sha256=SHLrIBHeFltPMc8abiQxw-MGqEadlYI-VdLELiZtWPU,97 +pydantic_settings/main.py,sha256=YfYjplX3qeX4wx3n-t7fzG-65nnZS6domI6D7R5Vz2k,29176 +pydantic_settings/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +pydantic_settings/sources/__init__.py,sha256=Ti1bRZb0r7IxkO-wJWKy-qEpeBUFKYRpa3A1AQodOyk,2052 +pydantic_settings/sources/__pycache__/__init__.cpython-312.pyc,, +pydantic_settings/sources/__pycache__/base.cpython-312.pyc,, +pydantic_settings/sources/__pycache__/types.cpython-312.pyc,, +pydantic_settings/sources/__pycache__/utils.cpython-312.pyc,, +pydantic_settings/sources/base.py,sha256=8IwvDw2l_dDpYjc_QPh3omWpYkJgMkd9lV3VFp8BU-Q,20508 +pydantic_settings/sources/providers/__init__.py,sha256=jBTurqBXeJvMfTl2lvHr2iDVDOvHfO-8PVNJiKt7MBk,1205 +pydantic_settings/sources/providers/__pycache__/__init__.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/aws.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/azure.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/cli.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/dotenv.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/env.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/gcp.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/json.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/pyproject.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/secrets.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/toml.cpython-312.pyc,, +pydantic_settings/sources/providers/__pycache__/yaml.cpython-312.pyc,, +pydantic_settings/sources/providers/aws.py,sha256=RQB_n5mHMETVTEObTZW89xp_fyKjqIxNT4ePrd6l818,2416 +pydantic_settings/sources/providers/azure.py,sha256=jR18hCpALjDnEObWGegvHs4_Da5j7PoBIt2kBbHYMag,4108 +pydantic_settings/sources/providers/cli.py,sha256=zUpJlrjGNmjaoUSvqTfuM7u_UFNB5Nf5psRkhl5RoyM,51158 +pydantic_settings/sources/providers/dotenv.py,sha256=y_sDkf7D9jZEQJkKDeGWMnnVbR9JhkL-Zu8tSSuTRRc,5888 +pydantic_settings/sources/providers/env.py,sha256=E2q9YHjFrFUWAid2VpY3678PDSuIDQc_47iWcz_ojQ4,10717 +pydantic_settings/sources/providers/gcp.py,sha256=3bFh75aZp6mmn12VihQycND-5CLgnYWg6HBfNvIV26U,5644 +pydantic_settings/sources/providers/json.py,sha256=k0hWDu0fNLrI5z3zWTGtlKyR0xx-2pOPu-oWjwqmVXo,1436 +pydantic_settings/sources/providers/pyproject.py,sha256=zSQsV3-jtZhiLm3YlrlYoE2__tZBazp0KjQyKLNyLr0,2052 +pydantic_settings/sources/providers/secrets.py,sha256=JLMIj3VVwp86foGTP8fb6zWddmYpELBu95Ldzobnsw8,4303 +pydantic_settings/sources/providers/toml.py,sha256=5k9wMJbKrUqXNiCM5G1hYnCOEZNUJJBTAzFw6Pv2K6A,1827 +pydantic_settings/sources/providers/yaml.py,sha256=mhjmOkrwLT16AEGNDuYoex2PYHejusn7Y0J4KL6SVbw,2305 +pydantic_settings/sources/types.py,sha256=h0FA8TMUMCj2hPMcA6VqZddIffoLbXxaCCKpcDo5iXM,1554 +pydantic_settings/sources/utils.py,sha256=5LIf3WbkgABPGpBjl_SyLdMjdl3KYa-lHudZMm_zNEE,7288 +pydantic_settings/utils.py,sha256=SkOfKGo0omDB4REfg31XSO8yVmpzCQgeIcdg-qqcSrk,1382 +pydantic_settings/version.py,sha256=xCWGAR_AgQdjZg_-c3LrWksxw74Y-F1odSn9vNk1CkQ,19 diff --git a/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/REQUESTED b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/REQUESTED new file mode 100644 index 000000000..e69de29bb diff --git a/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/WHEEL b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/WHEEL new file mode 100644 index 000000000..12228d414 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.27.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/licenses/LICENSE b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/licenses/LICENSE new file mode 100644 index 000000000..d90598f2a --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings-2.10.1.dist-info/licenses/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Samuel Colvin and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/venv/Lib/site-packages/pydantic_settings/__init__.py b/venv/Lib/site-packages/pydantic_settings/__init__.py new file mode 100644 index 000000000..60990a8fe --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/__init__.py @@ -0,0 +1,63 @@ +from .exceptions import SettingsError +from .main import BaseSettings, CliApp, SettingsConfigDict +from .sources import ( + CLI_SUPPRESS, + AWSSecretsManagerSettingsSource, + AzureKeyVaultSettingsSource, + CliExplicitFlag, + CliImplicitFlag, + CliMutuallyExclusiveGroup, + CliPositionalArg, + CliSettingsSource, + CliSubCommand, + CliSuppress, + CliUnknownArgs, + DotEnvSettingsSource, + EnvSettingsSource, + ForceDecode, + GoogleSecretManagerSettingsSource, + InitSettingsSource, + JsonConfigSettingsSource, + NoDecode, + PydanticBaseSettingsSource, + PyprojectTomlConfigSettingsSource, + SecretsSettingsSource, + TomlConfigSettingsSource, + YamlConfigSettingsSource, + get_subcommand, +) +from .version import VERSION + +__all__ = ( + 'CLI_SUPPRESS', + 'AWSSecretsManagerSettingsSource', + 'AzureKeyVaultSettingsSource', + 'BaseSettings', + 'CliApp', + 'CliExplicitFlag', + 'CliImplicitFlag', + 'CliMutuallyExclusiveGroup', + 'CliPositionalArg', + 'CliSettingsSource', + 'CliSubCommand', + 'CliSuppress', + 'CliUnknownArgs', + 'DotEnvSettingsSource', + 'EnvSettingsSource', + 'ForceDecode', + 'GoogleSecretManagerSettingsSource', + 'InitSettingsSource', + 'JsonConfigSettingsSource', + 'NoDecode', + 'PydanticBaseSettingsSource', + 'PyprojectTomlConfigSettingsSource', + 'SecretsSettingsSource', + 'SettingsConfigDict', + 'SettingsError', + 'TomlConfigSettingsSource', + 'YamlConfigSettingsSource', + '__version__', + 'get_subcommand', +) + +__version__ = VERSION diff --git a/venv/Lib/site-packages/pydantic_settings/__pycache__/__init__.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..668b1aa13 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/__pycache__/__init__.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/__pycache__/exceptions.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 000000000..f7c2362b1 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/__pycache__/exceptions.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/__pycache__/main.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/__pycache__/main.cpython-312.pyc new file mode 100644 index 000000000..e1f69c7fc Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/__pycache__/main.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/__pycache__/utils.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/__pycache__/utils.cpython-312.pyc new file mode 100644 index 000000000..be68b9174 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/__pycache__/utils.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/__pycache__/version.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/__pycache__/version.cpython-312.pyc new file mode 100644 index 000000000..1eaaa5ed0 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/__pycache__/version.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/exceptions.py b/venv/Lib/site-packages/pydantic_settings/exceptions.py new file mode 100644 index 000000000..90806c626 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/exceptions.py @@ -0,0 +1,4 @@ +class SettingsError(ValueError): + """Base exception for settings-related errors.""" + + pass diff --git a/venv/Lib/site-packages/pydantic_settings/main.py b/venv/Lib/site-packages/pydantic_settings/main.py new file mode 100644 index 000000000..2f5f8d187 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/main.py @@ -0,0 +1,621 @@ +from __future__ import annotations as _annotations + +import asyncio +import inspect +import threading +from argparse import Namespace +from collections.abc import Mapping +from types import SimpleNamespace +from typing import Any, ClassVar, TypeVar + +from pydantic import ConfigDict +from pydantic._internal._config import config_keys +from pydantic._internal._signature import _field_name_for_signature +from pydantic._internal._utils import deep_update, is_model_class +from pydantic.dataclasses import is_pydantic_dataclass +from pydantic.main import BaseModel + +from .exceptions import SettingsError +from .sources import ( + ENV_FILE_SENTINEL, + CliSettingsSource, + DefaultSettingsSource, + DotEnvSettingsSource, + DotenvType, + EnvSettingsSource, + InitSettingsSource, + PathType, + PydanticBaseSettingsSource, + PydanticModel, + SecretsSettingsSource, + get_subcommand, +) + +T = TypeVar('T') + + +class SettingsConfigDict(ConfigDict, total=False): + case_sensitive: bool + nested_model_default_partial_update: bool | None + env_prefix: str + env_file: DotenvType | None + env_file_encoding: str | None + env_ignore_empty: bool + env_nested_delimiter: str | None + env_nested_max_split: int | None + env_parse_none_str: str | None + env_parse_enums: bool | None + cli_prog_name: str | None + cli_parse_args: bool | list[str] | tuple[str, ...] | None + cli_parse_none_str: str | None + cli_hide_none_type: bool + cli_avoid_json: bool + cli_enforce_required: bool + cli_use_class_docs_for_groups: bool + cli_exit_on_error: bool + cli_prefix: str + cli_flag_prefix_char: str + cli_implicit_flags: bool | None + cli_ignore_unknown_args: bool | None + cli_kebab_case: bool | None + cli_shortcuts: Mapping[str, str | list[str]] | None + secrets_dir: PathType | None + json_file: PathType | None + json_file_encoding: str | None + yaml_file: PathType | None + yaml_file_encoding: str | None + yaml_config_section: str | None + """ + Specifies the top-level key in a YAML file from which to load the settings. + If provided, the settings will be loaded from the nested section under this key. + This is useful when the YAML file contains multiple configuration sections + and you only want to load a specific subset into your settings model. + """ + + pyproject_toml_depth: int + """ + Number of levels **up** from the current working directory to attempt to find a pyproject.toml + file. + + This is only used when a pyproject.toml file is not found in the current working directory. + """ + + pyproject_toml_table_header: tuple[str, ...] + """ + Header of the TOML table within a pyproject.toml file to use when filling variables. + This is supplied as a `tuple[str, ...]` instead of a `str` to accommodate for headers + containing a `.`. + + For example, `toml_table_header = ("tool", "my.tool", "foo")` can be used to fill variable + values from a table with header `[tool."my.tool".foo]`. + + To use the root table, exclude this config setting or provide an empty tuple. + """ + + toml_file: PathType | None + enable_decoding: bool + + +# Extend `config_keys` by pydantic settings config keys to +# support setting config through class kwargs. +# Pydantic uses `config_keys` in `pydantic._internal._config.ConfigWrapper.for_model` +# to extract config keys from model kwargs, So, by adding pydantic settings keys to +# `config_keys`, they will be considered as valid config keys and will be collected +# by Pydantic. +config_keys |= set(SettingsConfigDict.__annotations__.keys()) + + +class BaseSettings(BaseModel): + """ + Base class for settings, allowing values to be overridden by environment variables. + + This is useful in production for secrets you do not wish to save in code, it plays nicely with docker(-compose), + Heroku and any 12 factor app design. + + All the below attributes can be set via `model_config`. + + Args: + _case_sensitive: Whether environment and CLI variable names should be read with case-sensitivity. + Defaults to `None`. + _nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields. + Defaults to `False`. + _env_prefix: Prefix for all environment variables. Defaults to `None`. + _env_file: The env file(s) to load settings values from. Defaults to `Path('')`, which + means that the value from `model_config['env_file']` should be used. You can also pass + `None` to indicate that environment variables should not be loaded from an env file. + _env_file_encoding: The env file encoding, e.g. `'latin-1'`. Defaults to `None`. + _env_ignore_empty: Ignore environment variables where the value is an empty string. Default to `False`. + _env_nested_delimiter: The nested env values delimiter. Defaults to `None`. + _env_nested_max_split: The nested env values maximum nesting. Defaults to `None`, which means no limit. + _env_parse_none_str: The env string value that should be parsed (e.g. "null", "void", "None", etc.) + into `None` type(None). Defaults to `None` type(None), which means no parsing should occur. + _env_parse_enums: Parse enum field names to values. Defaults to `None.`, which means no parsing should occur. + _cli_prog_name: The CLI program name to display in help text. Defaults to `None` if _cli_parse_args is `None`. + Otherwise, defaults to sys.argv[0]. + _cli_parse_args: The list of CLI arguments to parse. Defaults to None. + If set to `True`, defaults to sys.argv[1:]. + _cli_settings_source: Override the default CLI settings source with a user defined instance. Defaults to None. + _cli_parse_none_str: The CLI string value that should be parsed (e.g. "null", "void", "None", etc.) into + `None` type(None). Defaults to _env_parse_none_str value if set. Otherwise, defaults to "null" if + _cli_avoid_json is `False`, and "None" if _cli_avoid_json is `True`. + _cli_hide_none_type: Hide `None` values in CLI help text. Defaults to `False`. + _cli_avoid_json: Avoid complex JSON objects in CLI help text. Defaults to `False`. + _cli_enforce_required: Enforce required fields at the CLI. Defaults to `False`. + _cli_use_class_docs_for_groups: Use class docstrings in CLI group help text instead of field descriptions. + Defaults to `False`. + _cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs. + Defaults to `True`. + _cli_prefix: The root parser command line arguments prefix. Defaults to "". + _cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'. + _cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags. + (e.g. --flag, --no-flag). Defaults to `False`. + _cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`. + _cli_kebab_case: CLI args use kebab case. Defaults to `False`. + _cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`. + _secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`. + """ + + def __init__( + __pydantic_self__, + _case_sensitive: bool | None = None, + _nested_model_default_partial_update: bool | None = None, + _env_prefix: str | None = None, + _env_file: DotenvType | None = ENV_FILE_SENTINEL, + _env_file_encoding: str | None = None, + _env_ignore_empty: bool | None = None, + _env_nested_delimiter: str | None = None, + _env_nested_max_split: int | None = None, + _env_parse_none_str: str | None = None, + _env_parse_enums: bool | None = None, + _cli_prog_name: str | None = None, + _cli_parse_args: bool | list[str] | tuple[str, ...] | None = None, + _cli_settings_source: CliSettingsSource[Any] | None = None, + _cli_parse_none_str: str | None = None, + _cli_hide_none_type: bool | None = None, + _cli_avoid_json: bool | None = None, + _cli_enforce_required: bool | None = None, + _cli_use_class_docs_for_groups: bool | None = None, + _cli_exit_on_error: bool | None = None, + _cli_prefix: str | None = None, + _cli_flag_prefix_char: str | None = None, + _cli_implicit_flags: bool | None = None, + _cli_ignore_unknown_args: bool | None = None, + _cli_kebab_case: bool | None = None, + _cli_shortcuts: Mapping[str, str | list[str]] | None = None, + _secrets_dir: PathType | None = None, + **values: Any, + ) -> None: + super().__init__( + **__pydantic_self__._settings_build_values( + values, + _case_sensitive=_case_sensitive, + _nested_model_default_partial_update=_nested_model_default_partial_update, + _env_prefix=_env_prefix, + _env_file=_env_file, + _env_file_encoding=_env_file_encoding, + _env_ignore_empty=_env_ignore_empty, + _env_nested_delimiter=_env_nested_delimiter, + _env_nested_max_split=_env_nested_max_split, + _env_parse_none_str=_env_parse_none_str, + _env_parse_enums=_env_parse_enums, + _cli_prog_name=_cli_prog_name, + _cli_parse_args=_cli_parse_args, + _cli_settings_source=_cli_settings_source, + _cli_parse_none_str=_cli_parse_none_str, + _cli_hide_none_type=_cli_hide_none_type, + _cli_avoid_json=_cli_avoid_json, + _cli_enforce_required=_cli_enforce_required, + _cli_use_class_docs_for_groups=_cli_use_class_docs_for_groups, + _cli_exit_on_error=_cli_exit_on_error, + _cli_prefix=_cli_prefix, + _cli_flag_prefix_char=_cli_flag_prefix_char, + _cli_implicit_flags=_cli_implicit_flags, + _cli_ignore_unknown_args=_cli_ignore_unknown_args, + _cli_kebab_case=_cli_kebab_case, + _cli_shortcuts=_cli_shortcuts, + _secrets_dir=_secrets_dir, + ) + ) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """ + Define the sources and their order for loading the settings values. + + Args: + settings_cls: The Settings class. + init_settings: The `InitSettingsSource` instance. + env_settings: The `EnvSettingsSource` instance. + dotenv_settings: The `DotEnvSettingsSource` instance. + file_secret_settings: The `SecretsSettingsSource` instance. + + Returns: + A tuple containing the sources and their order for loading the settings values. + """ + return init_settings, env_settings, dotenv_settings, file_secret_settings + + def _settings_build_values( + self, + init_kwargs: dict[str, Any], + _case_sensitive: bool | None = None, + _nested_model_default_partial_update: bool | None = None, + _env_prefix: str | None = None, + _env_file: DotenvType | None = None, + _env_file_encoding: str | None = None, + _env_ignore_empty: bool | None = None, + _env_nested_delimiter: str | None = None, + _env_nested_max_split: int | None = None, + _env_parse_none_str: str | None = None, + _env_parse_enums: bool | None = None, + _cli_prog_name: str | None = None, + _cli_parse_args: bool | list[str] | tuple[str, ...] | None = None, + _cli_settings_source: CliSettingsSource[Any] | None = None, + _cli_parse_none_str: str | None = None, + _cli_hide_none_type: bool | None = None, + _cli_avoid_json: bool | None = None, + _cli_enforce_required: bool | None = None, + _cli_use_class_docs_for_groups: bool | None = None, + _cli_exit_on_error: bool | None = None, + _cli_prefix: str | None = None, + _cli_flag_prefix_char: str | None = None, + _cli_implicit_flags: bool | None = None, + _cli_ignore_unknown_args: bool | None = None, + _cli_kebab_case: bool | None = None, + _cli_shortcuts: Mapping[str, str | list[str]] | None = None, + _secrets_dir: PathType | None = None, + ) -> dict[str, Any]: + # Determine settings config values + case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive') + env_prefix = _env_prefix if _env_prefix is not None else self.model_config.get('env_prefix') + nested_model_default_partial_update = ( + _nested_model_default_partial_update + if _nested_model_default_partial_update is not None + else self.model_config.get('nested_model_default_partial_update') + ) + env_file = _env_file if _env_file != ENV_FILE_SENTINEL else self.model_config.get('env_file') + env_file_encoding = ( + _env_file_encoding if _env_file_encoding is not None else self.model_config.get('env_file_encoding') + ) + env_ignore_empty = ( + _env_ignore_empty if _env_ignore_empty is not None else self.model_config.get('env_ignore_empty') + ) + env_nested_delimiter = ( + _env_nested_delimiter + if _env_nested_delimiter is not None + else self.model_config.get('env_nested_delimiter') + ) + env_nested_max_split = ( + _env_nested_max_split + if _env_nested_max_split is not None + else self.model_config.get('env_nested_max_split') + ) + env_parse_none_str = ( + _env_parse_none_str if _env_parse_none_str is not None else self.model_config.get('env_parse_none_str') + ) + env_parse_enums = _env_parse_enums if _env_parse_enums is not None else self.model_config.get('env_parse_enums') + + cli_prog_name = _cli_prog_name if _cli_prog_name is not None else self.model_config.get('cli_prog_name') + cli_parse_args = _cli_parse_args if _cli_parse_args is not None else self.model_config.get('cli_parse_args') + cli_settings_source = ( + _cli_settings_source if _cli_settings_source is not None else self.model_config.get('cli_settings_source') + ) + cli_parse_none_str = ( + _cli_parse_none_str if _cli_parse_none_str is not None else self.model_config.get('cli_parse_none_str') + ) + cli_parse_none_str = cli_parse_none_str if not env_parse_none_str else env_parse_none_str + cli_hide_none_type = ( + _cli_hide_none_type if _cli_hide_none_type is not None else self.model_config.get('cli_hide_none_type') + ) + cli_avoid_json = _cli_avoid_json if _cli_avoid_json is not None else self.model_config.get('cli_avoid_json') + cli_enforce_required = ( + _cli_enforce_required + if _cli_enforce_required is not None + else self.model_config.get('cli_enforce_required') + ) + cli_use_class_docs_for_groups = ( + _cli_use_class_docs_for_groups + if _cli_use_class_docs_for_groups is not None + else self.model_config.get('cli_use_class_docs_for_groups') + ) + cli_exit_on_error = ( + _cli_exit_on_error if _cli_exit_on_error is not None else self.model_config.get('cli_exit_on_error') + ) + cli_prefix = _cli_prefix if _cli_prefix is not None else self.model_config.get('cli_prefix') + cli_flag_prefix_char = ( + _cli_flag_prefix_char + if _cli_flag_prefix_char is not None + else self.model_config.get('cli_flag_prefix_char') + ) + cli_implicit_flags = ( + _cli_implicit_flags if _cli_implicit_flags is not None else self.model_config.get('cli_implicit_flags') + ) + cli_ignore_unknown_args = ( + _cli_ignore_unknown_args + if _cli_ignore_unknown_args is not None + else self.model_config.get('cli_ignore_unknown_args') + ) + cli_kebab_case = _cli_kebab_case if _cli_kebab_case is not None else self.model_config.get('cli_kebab_case') + cli_shortcuts = _cli_shortcuts if _cli_shortcuts is not None else self.model_config.get('cli_shortcuts') + + secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') + + # Configure built-in sources + default_settings = DefaultSettingsSource( + self.__class__, nested_model_default_partial_update=nested_model_default_partial_update + ) + init_settings = InitSettingsSource( + self.__class__, + init_kwargs=init_kwargs, + nested_model_default_partial_update=nested_model_default_partial_update, + ) + env_settings = EnvSettingsSource( + self.__class__, + case_sensitive=case_sensitive, + env_prefix=env_prefix, + env_nested_delimiter=env_nested_delimiter, + env_nested_max_split=env_nested_max_split, + env_ignore_empty=env_ignore_empty, + env_parse_none_str=env_parse_none_str, + env_parse_enums=env_parse_enums, + ) + dotenv_settings = DotEnvSettingsSource( + self.__class__, + env_file=env_file, + env_file_encoding=env_file_encoding, + case_sensitive=case_sensitive, + env_prefix=env_prefix, + env_nested_delimiter=env_nested_delimiter, + env_nested_max_split=env_nested_max_split, + env_ignore_empty=env_ignore_empty, + env_parse_none_str=env_parse_none_str, + env_parse_enums=env_parse_enums, + ) + + file_secret_settings = SecretsSettingsSource( + self.__class__, secrets_dir=secrets_dir, case_sensitive=case_sensitive, env_prefix=env_prefix + ) + # Provide a hook to set built-in sources priority and add / remove sources + sources = self.settings_customise_sources( + self.__class__, + init_settings=init_settings, + env_settings=env_settings, + dotenv_settings=dotenv_settings, + file_secret_settings=file_secret_settings, + ) + (default_settings,) + if not any([source for source in sources if isinstance(source, CliSettingsSource)]): + if isinstance(cli_settings_source, CliSettingsSource): + sources = (cli_settings_source,) + sources + elif cli_parse_args is not None: + cli_settings = CliSettingsSource[Any]( + self.__class__, + cli_prog_name=cli_prog_name, + cli_parse_args=cli_parse_args, + cli_parse_none_str=cli_parse_none_str, + cli_hide_none_type=cli_hide_none_type, + cli_avoid_json=cli_avoid_json, + cli_enforce_required=cli_enforce_required, + cli_use_class_docs_for_groups=cli_use_class_docs_for_groups, + cli_exit_on_error=cli_exit_on_error, + cli_prefix=cli_prefix, + cli_flag_prefix_char=cli_flag_prefix_char, + cli_implicit_flags=cli_implicit_flags, + cli_ignore_unknown_args=cli_ignore_unknown_args, + cli_kebab_case=cli_kebab_case, + cli_shortcuts=cli_shortcuts, + case_sensitive=case_sensitive, + ) + sources = (cli_settings,) + sources + if sources: + state: dict[str, Any] = {} + states: dict[str, dict[str, Any]] = {} + for source in sources: + if isinstance(source, PydanticBaseSettingsSource): + source._set_current_state(state) + source._set_settings_sources_data(states) + + source_name = source.__name__ if hasattr(source, '__name__') else type(source).__name__ + source_state = source() + + states[source_name] = source_state + state = deep_update(source_state, state) + return state + else: + # no one should mean to do this, but I think returning an empty dict is marginally preferable + # to an informative error and much better than a confusing error + return {} + + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + extra='forbid', + arbitrary_types_allowed=True, + validate_default=True, + case_sensitive=False, + env_prefix='', + nested_model_default_partial_update=False, + env_file=None, + env_file_encoding=None, + env_ignore_empty=False, + env_nested_delimiter=None, + env_nested_max_split=None, + env_parse_none_str=None, + env_parse_enums=None, + cli_prog_name=None, + cli_parse_args=None, + cli_parse_none_str=None, + cli_hide_none_type=False, + cli_avoid_json=False, + cli_enforce_required=False, + cli_use_class_docs_for_groups=False, + cli_exit_on_error=True, + cli_prefix='', + cli_flag_prefix_char='-', + cli_implicit_flags=False, + cli_ignore_unknown_args=False, + cli_kebab_case=False, + cli_shortcuts=None, + json_file=None, + json_file_encoding=None, + yaml_file=None, + yaml_file_encoding=None, + yaml_config_section=None, + toml_file=None, + secrets_dir=None, + protected_namespaces=('model_validate', 'model_dump', 'settings_customise_sources'), + enable_decoding=True, + ) + + +class CliApp: + """ + A utility class for running Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as + CLI applications. + """ + + @staticmethod + def _run_cli_cmd(model: Any, cli_cmd_method_name: str, is_required: bool) -> Any: + command = getattr(type(model), cli_cmd_method_name, None) + if command is None: + if is_required: + raise SettingsError(f'Error: {type(model).__name__} class is missing {cli_cmd_method_name} entrypoint') + return model + + # If the method is asynchronous, we handle its execution based on the current event loop status. + if inspect.iscoroutinefunction(command): + # For asynchronous methods, we have two execution scenarios: + # 1. If no event loop is running in the current thread, run the coroutine directly with asyncio.run(). + # 2. If an event loop is already running in the current thread, run the coroutine in a separate thread to avoid conflicts. + try: + # Check if an event loop is currently running in this thread. + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + # We're in a context with an active event loop (e.g., Jupyter Notebook). + # Running asyncio.run() here would cause conflicts, so we use a separate thread. + exception_container = [] + + def run_coro() -> None: + try: + # Execute the coroutine in a new event loop in this separate thread. + asyncio.run(command(model)) + except Exception as e: + exception_container.append(e) + + thread = threading.Thread(target=run_coro) + thread.start() + thread.join() + if exception_container: + # Propagate exceptions from the separate thread. + raise exception_container[0] + else: + # No event loop is running; safe to run the coroutine directly. + asyncio.run(command(model)) + else: + # For synchronous methods, call them directly. + command(model) + + return model + + @staticmethod + def run( + model_cls: type[T], + cli_args: list[str] | Namespace | SimpleNamespace | dict[str, Any] | None = None, + cli_settings_source: CliSettingsSource[Any] | None = None, + cli_exit_on_error: bool | None = None, + cli_cmd_method_name: str = 'cli_cmd', + **model_init_data: Any, + ) -> T: + """ + Runs a Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as a CLI application. + Running a model as a CLI application requires the `cli_cmd` method to be defined in the model class. + + Args: + model_cls: The model class to run as a CLI application. + cli_args: The list of CLI arguments to parse. If `cli_settings_source` is specified, this may + also be a namespace or dictionary of pre-parsed CLI arguments. Defaults to `sys.argv[1:]`. + cli_settings_source: Override the default CLI settings source with a user defined instance. + Defaults to `None`. + cli_exit_on_error: Determines whether this function exits on error. If model is subclass of + `BaseSettings`, defaults to BaseSettings `cli_exit_on_error` value. Otherwise, defaults to + `True`. + cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd". + model_init_data: The model init data. + + Returns: + The ran instance of model. + + Raises: + SettingsError: If model_cls is not subclass of `BaseModel` or `pydantic.dataclasses.dataclass`. + SettingsError: If model_cls does not have a `cli_cmd` entrypoint defined. + """ + + if not (is_pydantic_dataclass(model_cls) or is_model_class(model_cls)): + raise SettingsError( + f'Error: {model_cls.__name__} is not subclass of BaseModel or pydantic.dataclasses.dataclass' + ) + + cli_settings = None + cli_parse_args = True if cli_args is None else cli_args + if cli_settings_source is not None: + if isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)): + cli_settings = cli_settings_source(parsed_args=cli_parse_args) + else: + cli_settings = cli_settings_source(args=cli_parse_args) + elif isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)): + raise SettingsError('Error: `cli_args` must be list[str] or None when `cli_settings_source` is not used') + + model_init_data['_cli_parse_args'] = cli_parse_args + model_init_data['_cli_exit_on_error'] = cli_exit_on_error + model_init_data['_cli_settings_source'] = cli_settings + if not issubclass(model_cls, BaseSettings): + + class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore + __doc__ = model_cls.__doc__ + model_config = SettingsConfigDict( + nested_model_default_partial_update=True, + case_sensitive=True, + cli_hide_none_type=True, + cli_avoid_json=True, + cli_enforce_required=True, + cli_implicit_flags=True, + cli_kebab_case=True, + ) + + model = CliAppBaseSettings(**model_init_data) + model_init_data = {} + for field_name, field_info in type(model).model_fields.items(): + model_init_data[_field_name_for_signature(field_name, field_info)] = getattr(model, field_name) + + return CliApp._run_cli_cmd(model_cls(**model_init_data), cli_cmd_method_name, is_required=False) + + @staticmethod + def run_subcommand( + model: PydanticModel, cli_exit_on_error: bool | None = None, cli_cmd_method_name: str = 'cli_cmd' + ) -> PydanticModel: + """ + Runs the model subcommand. Running a model subcommand requires the `cli_cmd` method to be defined in + the nested model subcommand class. + + Args: + model: The model to run the subcommand from. + cli_exit_on_error: Determines whether this function exits with error if no subcommand is found. + Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`. + cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd". + + Returns: + The ran subcommand model. + + Raises: + SystemExit: When no subcommand is found and cli_exit_on_error=`True` (the default). + SettingsError: When no subcommand is found and cli_exit_on_error=`False`. + """ + + subcommand = get_subcommand(model, is_required=True, cli_exit_on_error=cli_exit_on_error) + return CliApp._run_cli_cmd(subcommand, cli_cmd_method_name, is_required=True) diff --git a/venv/Lib/site-packages/pydantic_settings/py.typed b/venv/Lib/site-packages/pydantic_settings/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/venv/Lib/site-packages/pydantic_settings/sources/__init__.py b/venv/Lib/site-packages/pydantic_settings/sources/__init__.py new file mode 100644 index 000000000..a795c49d7 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/__init__.py @@ -0,0 +1,68 @@ +"""Package for handling configuration sources in pydantic-settings.""" + +from .base import ( + ConfigFileSourceMixin, + DefaultSettingsSource, + InitSettingsSource, + PydanticBaseEnvSettingsSource, + PydanticBaseSettingsSource, + get_subcommand, +) +from .providers.aws import AWSSecretsManagerSettingsSource +from .providers.azure import AzureKeyVaultSettingsSource +from .providers.cli import ( + CLI_SUPPRESS, + CliExplicitFlag, + CliImplicitFlag, + CliMutuallyExclusiveGroup, + CliPositionalArg, + CliSettingsSource, + CliSubCommand, + CliSuppress, + CliUnknownArgs, +) +from .providers.dotenv import DotEnvSettingsSource, read_env_file +from .providers.env import EnvSettingsSource +from .providers.gcp import GoogleSecretManagerSettingsSource +from .providers.json import JsonConfigSettingsSource +from .providers.pyproject import PyprojectTomlConfigSettingsSource +from .providers.secrets import SecretsSettingsSource +from .providers.toml import TomlConfigSettingsSource +from .providers.yaml import YamlConfigSettingsSource +from .types import DEFAULT_PATH, ENV_FILE_SENTINEL, DotenvType, ForceDecode, NoDecode, PathType, PydanticModel + +__all__ = [ + 'CLI_SUPPRESS', + 'ENV_FILE_SENTINEL', + 'DEFAULT_PATH', + 'AWSSecretsManagerSettingsSource', + 'AzureKeyVaultSettingsSource', + 'CliExplicitFlag', + 'CliImplicitFlag', + 'CliMutuallyExclusiveGroup', + 'CliPositionalArg', + 'CliSettingsSource', + 'CliSubCommand', + 'CliSuppress', + 'CliUnknownArgs', + 'DefaultSettingsSource', + 'DotEnvSettingsSource', + 'DotenvType', + 'EnvSettingsSource', + 'ForceDecode', + 'GoogleSecretManagerSettingsSource', + 'InitSettingsSource', + 'JsonConfigSettingsSource', + 'NoDecode', + 'PathType', + 'PydanticBaseEnvSettingsSource', + 'PydanticBaseSettingsSource', + 'ConfigFileSourceMixin', + 'PydanticModel', + 'PyprojectTomlConfigSettingsSource', + 'SecretsSettingsSource', + 'TomlConfigSettingsSource', + 'YamlConfigSettingsSource', + 'get_subcommand', + 'read_env_file', +] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/__init__.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..587f1098a Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/__init__.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/base.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/base.cpython-312.pyc new file mode 100644 index 000000000..c00472637 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/base.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/types.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/types.cpython-312.pyc new file mode 100644 index 000000000..13e4d5daf Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/types.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/utils.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/utils.cpython-312.pyc new file mode 100644 index 000000000..d039f9e23 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/__pycache__/utils.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/base.py b/venv/Lib/site-packages/pydantic_settings/sources/base.py new file mode 100644 index 000000000..b2c4d1667 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/base.py @@ -0,0 +1,521 @@ +"""Base classes and core functionality for pydantic-settings sources.""" + +from __future__ import annotations as _annotations + +import json +import os +from abc import ABC, abstractmethod +from dataclasses import asdict, is_dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional, cast + +from pydantic import AliasChoices, AliasPath, BaseModel, TypeAdapter +from pydantic._internal._typing_extra import ( # type: ignore[attr-defined] + get_origin, +) +from pydantic._internal._utils import is_model_class +from pydantic.fields import FieldInfo +from typing_extensions import get_args +from typing_inspection.introspection import is_union_origin + +from ..exceptions import SettingsError +from ..utils import _lenient_issubclass +from .types import EnvNoneType, ForceDecode, NoDecode, PathType, PydanticModel, _CliSubCommand +from .utils import ( + _annotation_is_complex, + _get_alias_names, + _get_model_fields, + _union_is_complex, +) + +if TYPE_CHECKING: + from pydantic_settings.main import BaseSettings + + +def get_subcommand( + model: PydanticModel, is_required: bool = True, cli_exit_on_error: bool | None = None +) -> Optional[PydanticModel]: + """ + Get the subcommand from a model. + + Args: + model: The model to get the subcommand from. + is_required: Determines whether a model must have subcommand set and raises error if not + found. Defaults to `True`. + cli_exit_on_error: Determines whether this function exits with error if no subcommand is found. + Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`. + + Returns: + The subcommand model if found, otherwise `None`. + + Raises: + SystemExit: When no subcommand is found and is_required=`True` and cli_exit_on_error=`True` + (the default). + SettingsError: When no subcommand is found and is_required=`True` and + cli_exit_on_error=`False`. + """ + + model_cls = type(model) + if cli_exit_on_error is None and is_model_class(model_cls): + model_default = model_cls.model_config.get('cli_exit_on_error') + if isinstance(model_default, bool): + cli_exit_on_error = model_default + if cli_exit_on_error is None: + cli_exit_on_error = True + + subcommands: list[str] = [] + for field_name, field_info in _get_model_fields(model_cls).items(): + if _CliSubCommand in field_info.metadata: + if getattr(model, field_name) is not None: + return getattr(model, field_name) + subcommands.append(field_name) + + if is_required: + error_message = ( + f'Error: CLI subcommand is required {{{", ".join(subcommands)}}}' + if subcommands + else 'Error: CLI subcommand is required but no subcommands were found.' + ) + raise SystemExit(error_message) if cli_exit_on_error else SettingsError(error_message) + + return None + + +class PydanticBaseSettingsSource(ABC): + """ + Abstract base class for settings sources, every settings source classes should inherit from it. + """ + + def __init__(self, settings_cls: type[BaseSettings]): + self.settings_cls = settings_cls + self.config = settings_cls.model_config + self._current_state: dict[str, Any] = {} + self._settings_sources_data: dict[str, dict[str, Any]] = {} + + def _set_current_state(self, state: dict[str, Any]) -> None: + """ + Record the state of settings from the previous settings sources. This should + be called right before __call__. + """ + self._current_state = state + + def _set_settings_sources_data(self, states: dict[str, dict[str, Any]]) -> None: + """ + Record the state of settings from all previous settings sources. This should + be called right before __call__. + """ + self._settings_sources_data = states + + @property + def current_state(self) -> dict[str, Any]: + """ + The current state of the settings, populated by the previous settings sources. + """ + return self._current_state + + @property + def settings_sources_data(self) -> dict[str, dict[str, Any]]: + """ + The state of all previous settings sources. + """ + return self._settings_sources_data + + @abstractmethod + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + """ + Gets the value, the key for model creation, and a flag to determine whether value is complex. + + This is an abstract method that should be overridden in every settings source classes. + + Args: + field: The field. + field_name: The field name. + + Returns: + A tuple that contains the value, key and a flag to determine whether value is complex. + """ + pass + + def field_is_complex(self, field: FieldInfo) -> bool: + """ + Checks whether a field is complex, in which case it will attempt to be parsed as JSON. + + Args: + field: The field. + + Returns: + Whether the field is complex. + """ + return _annotation_is_complex(field.annotation, field.metadata) + + def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: + """ + Prepares the value of a field. + + Args: + field_name: The field name. + field: The field. + value: The value of the field that has to be prepared. + value_is_complex: A flag to determine whether value is complex. + + Returns: + The prepared value. + """ + if value is not None and (self.field_is_complex(field) or value_is_complex): + return self.decode_complex_value(field_name, field, value) + return value + + def decode_complex_value(self, field_name: str, field: FieldInfo, value: Any) -> Any: + """ + Decode the value for a complex field + + Args: + field_name: The field name. + field: The field. + value: The value of the field that has to be prepared. + + Returns: + The decoded value for further preparation + """ + if field and ( + NoDecode in field.metadata + or (self.config.get('enable_decoding') is False and ForceDecode not in field.metadata) + ): + return value + + return json.loads(value) + + @abstractmethod + def __call__(self) -> dict[str, Any]: + pass + + +class ConfigFileSourceMixin(ABC): + def _read_files(self, files: PathType | None) -> dict[str, Any]: + if files is None: + return {} + if isinstance(files, (str, os.PathLike)): + files = [files] + vars: dict[str, Any] = {} + for file in files: + file_path = Path(file).expanduser() + if file_path.is_file(): + vars.update(self._read_file(file_path)) + return vars + + @abstractmethod + def _read_file(self, path: Path) -> dict[str, Any]: + pass + + +class DefaultSettingsSource(PydanticBaseSettingsSource): + """ + Source class for loading default object values. + + Args: + settings_cls: The Settings class. + nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields. + Defaults to `False`. + """ + + def __init__(self, settings_cls: type[BaseSettings], nested_model_default_partial_update: bool | None = None): + super().__init__(settings_cls) + self.defaults: dict[str, Any] = {} + self.nested_model_default_partial_update = ( + nested_model_default_partial_update + if nested_model_default_partial_update is not None + else self.config.get('nested_model_default_partial_update', False) + ) + if self.nested_model_default_partial_update: + for field_name, field_info in settings_cls.model_fields.items(): + alias_names, *_ = _get_alias_names(field_name, field_info) + preferred_alias = alias_names[0] + if is_dataclass(type(field_info.default)): + self.defaults[preferred_alias] = asdict(field_info.default) + elif is_model_class(type(field_info.default)): + self.defaults[preferred_alias] = field_info.default.model_dump() + + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + # Nothing to do here. Only implement the return statement to make mypy happy + return None, '', False + + def __call__(self) -> dict[str, Any]: + return self.defaults + + def __repr__(self) -> str: + return ( + f'{self.__class__.__name__}(nested_model_default_partial_update={self.nested_model_default_partial_update})' + ) + + +class InitSettingsSource(PydanticBaseSettingsSource): + """ + Source class for loading values provided during settings class initialization. + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + init_kwargs: dict[str, Any], + nested_model_default_partial_update: bool | None = None, + ): + self.init_kwargs = {} + init_kwarg_names = set(init_kwargs.keys()) + for field_name, field_info in settings_cls.model_fields.items(): + alias_names, *_ = _get_alias_names(field_name, field_info) + init_kwarg_name = init_kwarg_names & set(alias_names) + if init_kwarg_name: + preferred_alias = alias_names[0] + init_kwarg_names -= init_kwarg_name + self.init_kwargs[preferred_alias] = init_kwargs[init_kwarg_name.pop()] + self.init_kwargs.update({key: val for key, val in init_kwargs.items() if key in init_kwarg_names}) + + super().__init__(settings_cls) + self.nested_model_default_partial_update = ( + nested_model_default_partial_update + if nested_model_default_partial_update is not None + else self.config.get('nested_model_default_partial_update', False) + ) + + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + # Nothing to do here. Only implement the return statement to make mypy happy + return None, '', False + + def __call__(self) -> dict[str, Any]: + return ( + TypeAdapter(dict[str, Any]).dump_python(self.init_kwargs) + if self.nested_model_default_partial_update + else self.init_kwargs + ) + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(init_kwargs={self.init_kwargs!r})' + + +class PydanticBaseEnvSettingsSource(PydanticBaseSettingsSource): + def __init__( + self, + settings_cls: type[BaseSettings], + case_sensitive: bool | None = None, + env_prefix: str | None = None, + env_ignore_empty: bool | None = None, + env_parse_none_str: str | None = None, + env_parse_enums: bool | None = None, + ) -> None: + super().__init__(settings_cls) + self.case_sensitive = case_sensitive if case_sensitive is not None else self.config.get('case_sensitive', False) + self.env_prefix = env_prefix if env_prefix is not None else self.config.get('env_prefix', '') + self.env_ignore_empty = ( + env_ignore_empty if env_ignore_empty is not None else self.config.get('env_ignore_empty', False) + ) + self.env_parse_none_str = ( + env_parse_none_str if env_parse_none_str is not None else self.config.get('env_parse_none_str') + ) + self.env_parse_enums = env_parse_enums if env_parse_enums is not None else self.config.get('env_parse_enums') + + def _apply_case_sensitive(self, value: str) -> str: + return value.lower() if not self.case_sensitive else value + + def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[str, str, bool]]: + """ + Extracts field info. This info is used to get the value of field from environment variables. + + It returns a list of tuples, each tuple contains: + * field_key: The key of field that has to be used in model creation. + * env_name: The environment variable name of the field. + * value_is_complex: A flag to determine whether the value from environment variable + is complex and has to be parsed. + + Args: + field (FieldInfo): The field. + field_name (str): The field name. + + Returns: + list[tuple[str, str, bool]]: List of tuples, each tuple contains field_key, env_name, and value_is_complex. + """ + field_info: list[tuple[str, str, bool]] = [] + if isinstance(field.validation_alias, (AliasChoices, AliasPath)): + v_alias: str | list[str | int] | list[list[str | int]] | None = field.validation_alias.convert_to_aliases() + else: + v_alias = field.validation_alias + + if v_alias: + if isinstance(v_alias, list): # AliasChoices, AliasPath + for alias in v_alias: + if isinstance(alias, str): # AliasPath + field_info.append((alias, self._apply_case_sensitive(alias), True if len(alias) > 1 else False)) + elif isinstance(alias, list): # AliasChoices + first_arg = cast(str, alias[0]) # first item of an AliasChoices must be a str + field_info.append( + (first_arg, self._apply_case_sensitive(first_arg), True if len(alias) > 1 else False) + ) + else: # string validation alias + field_info.append((v_alias, self._apply_case_sensitive(v_alias), False)) + + if not v_alias or self.config.get('populate_by_name', False): + if is_union_origin(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata): + field_info.append((field_name, self._apply_case_sensitive(self.env_prefix + field_name), True)) + else: + field_info.append((field_name, self._apply_case_sensitive(self.env_prefix + field_name), False)) + + return field_info + + def _replace_field_names_case_insensitively(self, field: FieldInfo, field_values: dict[str, Any]) -> dict[str, Any]: + """ + Replace field names in values dict by looking in models fields insensitively. + + By having the following models: + + ```py + class SubSubSub(BaseModel): + VaL3: str + + class SubSub(BaseModel): + Val2: str + SUB_sub_SuB: SubSubSub + + class Sub(BaseModel): + VAL1: str + SUB_sub: SubSub + + class Settings(BaseSettings): + nested: Sub + + model_config = SettingsConfigDict(env_nested_delimiter='__') + ``` + + Then: + _replace_field_names_case_insensitively( + field, + {"val1": "v1", "sub_SUB": {"VAL2": "v2", "sub_SUB_sUb": {"vAl3": "v3"}}} + ) + Returns {'VAL1': 'v1', 'SUB_sub': {'Val2': 'v2', 'SUB_sub_SuB': {'VaL3': 'v3'}}} + """ + values: dict[str, Any] = {} + + for name, value in field_values.items(): + sub_model_field: FieldInfo | None = None + + annotation = field.annotation + + # If field is Optional, we need to find the actual type + if is_union_origin(get_origin(field.annotation)): + args = get_args(annotation) + if len(args) == 2 and type(None) in args: + for arg in args: + if arg is not None: + annotation = arg + break + + # This is here to make mypy happy + # Item "None" of "Optional[Type[Any]]" has no attribute "model_fields" + if not annotation or not hasattr(annotation, 'model_fields'): + values[name] = value + continue + else: + model_fields: dict[str, FieldInfo] = annotation.model_fields + + # Find field in sub model by looking in fields case insensitively + field_key: str | None = None + for sub_model_field_name, sub_model_field in model_fields.items(): + aliases, _ = _get_alias_names(sub_model_field_name, sub_model_field) + _search = (alias for alias in aliases if alias.lower() == name.lower()) + if field_key := next(_search, None): + break + + if not field_key: + values[name] = value + continue + + if ( + sub_model_field is not None + and _lenient_issubclass(sub_model_field.annotation, BaseModel) + and isinstance(value, dict) + ): + values[field_key] = self._replace_field_names_case_insensitively(sub_model_field, value) + else: + values[field_key] = value + + return values + + def _replace_env_none_type_values(self, field_value: dict[str, Any]) -> dict[str, Any]: + """ + Recursively parse values that are of "None" type(EnvNoneType) to `None` type(None). + """ + values: dict[str, Any] = {} + + for key, value in field_value.items(): + if not isinstance(value, EnvNoneType): + values[key] = value if not isinstance(value, dict) else self._replace_env_none_type_values(value) + else: + values[key] = None + + return values + + def _get_resolved_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + """ + Gets the value, the preferred alias key for model creation, and a flag to determine whether value + is complex. + + Note: + In V3, this method should either be made public, or, this method should be removed and the + abstract method get_field_value should be updated to include a "use_preferred_alias" flag. + + Args: + field: The field. + field_name: The field name. + + Returns: + A tuple that contains the value, preferred key and a flag to determine whether value is complex. + """ + field_value, field_key, value_is_complex = self.get_field_value(field, field_name) + if not (value_is_complex or (self.config.get('populate_by_name', False) and (field_key == field_name))): + field_infos = self._extract_field_info(field, field_name) + preferred_key, *_ = field_infos[0] + return field_value, preferred_key, value_is_complex + return field_value, field_key, value_is_complex + + def __call__(self) -> dict[str, Any]: + data: dict[str, Any] = {} + + for field_name, field in self.settings_cls.model_fields.items(): + try: + field_value, field_key, value_is_complex = self._get_resolved_field_value(field, field_name) + except Exception as e: + raise SettingsError( + f'error getting value for field "{field_name}" from source "{self.__class__.__name__}"' + ) from e + + try: + field_value = self.prepare_field_value(field_name, field, field_value, value_is_complex) + except ValueError as e: + raise SettingsError( + f'error parsing value for field "{field_name}" from source "{self.__class__.__name__}"' + ) from e + + if field_value is not None: + if self.env_parse_none_str is not None: + if isinstance(field_value, dict): + field_value = self._replace_env_none_type_values(field_value) + elif isinstance(field_value, EnvNoneType): + field_value = None + if ( + not self.case_sensitive + # and _lenient_issubclass(field.annotation, BaseModel) + and isinstance(field_value, dict) + ): + data[field_key] = self._replace_field_names_case_insensitively(field, field_value) + else: + data[field_key] = field_value + + return data + + +__all__ = [ + 'ConfigFileSourceMixin', + 'DefaultSettingsSource', + 'InitSettingsSource', + 'PydanticBaseEnvSettingsSource', + 'PydanticBaseSettingsSource', + 'SettingsError', +] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__init__.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/__init__.py new file mode 100644 index 000000000..31759f339 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/__init__.py @@ -0,0 +1,41 @@ +"""Package containing individual source implementations.""" + +from .aws import AWSSecretsManagerSettingsSource +from .azure import AzureKeyVaultSettingsSource +from .cli import ( + CliExplicitFlag, + CliImplicitFlag, + CliMutuallyExclusiveGroup, + CliPositionalArg, + CliSettingsSource, + CliSubCommand, + CliSuppress, +) +from .dotenv import DotEnvSettingsSource +from .env import EnvSettingsSource +from .gcp import GoogleSecretManagerSettingsSource +from .json import JsonConfigSettingsSource +from .pyproject import PyprojectTomlConfigSettingsSource +from .secrets import SecretsSettingsSource +from .toml import TomlConfigSettingsSource +from .yaml import YamlConfigSettingsSource + +__all__ = [ + 'AWSSecretsManagerSettingsSource', + 'AzureKeyVaultSettingsSource', + 'CliExplicitFlag', + 'CliImplicitFlag', + 'CliMutuallyExclusiveGroup', + 'CliPositionalArg', + 'CliSettingsSource', + 'CliSubCommand', + 'CliSuppress', + 'DotEnvSettingsSource', + 'EnvSettingsSource', + 'GoogleSecretManagerSettingsSource', + 'JsonConfigSettingsSource', + 'PyprojectTomlConfigSettingsSource', + 'SecretsSettingsSource', + 'TomlConfigSettingsSource', + 'YamlConfigSettingsSource', +] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/__init__.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..873570caf Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/__init__.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/aws.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/aws.cpython-312.pyc new file mode 100644 index 000000000..a5dab20f6 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/aws.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/azure.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/azure.cpython-312.pyc new file mode 100644 index 000000000..44313b02f Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/azure.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/cli.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/cli.cpython-312.pyc new file mode 100644 index 000000000..05d5e2862 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/cli.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/dotenv.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/dotenv.cpython-312.pyc new file mode 100644 index 000000000..b0ab0fa09 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/dotenv.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/env.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/env.cpython-312.pyc new file mode 100644 index 000000000..0f2f6daa7 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/env.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/gcp.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/gcp.cpython-312.pyc new file mode 100644 index 000000000..b9aa7fc5c Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/gcp.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/json.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/json.cpython-312.pyc new file mode 100644 index 000000000..30b069587 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/json.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/pyproject.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/pyproject.cpython-312.pyc new file mode 100644 index 000000000..a8813cafd Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/pyproject.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/secrets.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/secrets.cpython-312.pyc new file mode 100644 index 000000000..77b486bc4 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/secrets.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/toml.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/toml.cpython-312.pyc new file mode 100644 index 000000000..d6d7322bc Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/toml.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/yaml.cpython-312.pyc b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/yaml.cpython-312.pyc new file mode 100644 index 000000000..6f0b76469 Binary files /dev/null and b/venv/Lib/site-packages/pydantic_settings/sources/providers/__pycache__/yaml.cpython-312.pyc differ diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/aws.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/aws.py new file mode 100644 index 000000000..c1004ec55 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/aws.py @@ -0,0 +1,77 @@ +from __future__ import annotations as _annotations # important for BaseSettings import to work + +import json +from collections.abc import Mapping +from typing import TYPE_CHECKING, Optional + +from ..utils import parse_env_vars +from .env import EnvSettingsSource + +if TYPE_CHECKING: + from pydantic_settings.main import BaseSettings + + +boto3_client = None +SecretsManagerClient = None + + +def import_aws_secrets_manager() -> None: + global boto3_client + global SecretsManagerClient + + try: + from boto3 import client as boto3_client + from mypy_boto3_secretsmanager.client import SecretsManagerClient + except ImportError as e: # pragma: no cover + raise ImportError( + 'AWS Secrets Manager dependencies are not installed, run `pip install pydantic-settings[aws-secrets-manager]`' + ) from e + + +class AWSSecretsManagerSettingsSource(EnvSettingsSource): + _secret_id: str + _secretsmanager_client: SecretsManagerClient # type: ignore + + def __init__( + self, + settings_cls: type[BaseSettings], + secret_id: str, + region_name: str | None = None, + case_sensitive: bool | None = True, + env_prefix: str | None = None, + env_parse_none_str: str | None = None, + env_parse_enums: bool | None = None, + ) -> None: + import_aws_secrets_manager() + self._secretsmanager_client = boto3_client('secretsmanager', region_name=region_name) # type: ignore + self._secret_id = secret_id + super().__init__( + settings_cls, + case_sensitive=case_sensitive, + env_prefix=env_prefix, + env_nested_delimiter='--', + env_ignore_empty=False, + env_parse_none_str=env_parse_none_str, + env_parse_enums=env_parse_enums, + ) + + def _load_env_vars(self) -> Mapping[str, Optional[str]]: + response = self._secretsmanager_client.get_secret_value(SecretId=self._secret_id) # type: ignore + + return parse_env_vars( + json.loads(response['SecretString']), + self.case_sensitive, + self.env_ignore_empty, + self.env_parse_none_str, + ) + + def __repr__(self) -> str: + return ( + f'{self.__class__.__name__}(secret_id={self._secret_id!r}, ' + f'env_nested_delimiter={self.env_nested_delimiter!r})' + ) + + +__all__ = [ + 'AWSSecretsManagerSettingsSource', +] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/azure.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/azure.py new file mode 100644 index 000000000..04f0bee51 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/azure.py @@ -0,0 +1,118 @@ +"""Azure Key Vault settings source.""" + +from __future__ import annotations as _annotations + +from collections.abc import Iterator, Mapping +from typing import TYPE_CHECKING, Optional + +from pydantic.fields import FieldInfo + +from .env import EnvSettingsSource + +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.exceptions import ResourceNotFoundError + from azure.keyvault.secrets import SecretClient + + from pydantic_settings.main import BaseSettings +else: + TokenCredential = None + ResourceNotFoundError = None + SecretClient = None + + +def import_azure_key_vault() -> None: + global TokenCredential + global SecretClient + global ResourceNotFoundError + + try: + from azure.core.credentials import TokenCredential + from azure.core.exceptions import ResourceNotFoundError + from azure.keyvault.secrets import SecretClient + except ImportError as e: # pragma: no cover + raise ImportError( + 'Azure Key Vault dependencies are not installed, run `pip install pydantic-settings[azure-key-vault]`' + ) from e + + +class AzureKeyVaultMapping(Mapping[str, Optional[str]]): + _loaded_secrets: dict[str, str | None] + _secret_client: SecretClient + _secret_names: list[str] + + def __init__( + self, + secret_client: SecretClient, + case_sensitive: bool, + ) -> None: + self._loaded_secrets = {} + self._secret_client = secret_client + self._case_sensitive = case_sensitive + self._secret_map: dict[str, str] = self._load_remote() + + def _load_remote(self) -> dict[str, str]: + secret_names: Iterator[str] = ( + secret.name for secret in self._secret_client.list_properties_of_secrets() if secret.name and secret.enabled + ) + if self._case_sensitive: + return {name: name for name in secret_names} + return {name.lower(): name for name in secret_names} + + def __getitem__(self, key: str) -> str | None: + if not self._case_sensitive: + key = key.lower() + if key not in self._loaded_secrets and key in self._secret_map: + self._loaded_secrets[key] = self._secret_client.get_secret(self._secret_map[key]).value + return self._loaded_secrets[key] + + def __len__(self) -> int: + return len(self._secret_map) + + def __iter__(self) -> Iterator[str]: + return iter(self._secret_map.keys()) + + +class AzureKeyVaultSettingsSource(EnvSettingsSource): + _url: str + _credential: TokenCredential + + def __init__( + self, + settings_cls: type[BaseSettings], + url: str, + credential: TokenCredential, + dash_to_underscore: bool = False, + case_sensitive: bool | None = None, + env_prefix: str | None = None, + env_parse_none_str: str | None = None, + env_parse_enums: bool | None = None, + ) -> None: + import_azure_key_vault() + self._url = url + self._credential = credential + self._dash_to_underscore = dash_to_underscore + super().__init__( + settings_cls, + case_sensitive=case_sensitive, + env_prefix=env_prefix, + env_nested_delimiter='--', + env_ignore_empty=False, + env_parse_none_str=env_parse_none_str, + env_parse_enums=env_parse_enums, + ) + + def _load_env_vars(self) -> Mapping[str, Optional[str]]: + secret_client = SecretClient(vault_url=self._url, credential=self._credential) + return AzureKeyVaultMapping(secret_client, self.case_sensitive) + + def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[str, str, bool]]: + if self._dash_to_underscore: + return list((x[0], x[1].replace('_', '-'), x[2]) for x in super()._extract_field_info(field, field_name)) + return super()._extract_field_info(field, field_name) + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(url={self._url!r}, env_nested_delimiter={self.env_nested_delimiter!r})' + + +__all__ = ['AzureKeyVaultMapping', 'AzureKeyVaultSettingsSource'] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/cli.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/cli.py new file mode 100644 index 000000000..9e99ebcf1 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/cli.py @@ -0,0 +1,1086 @@ +"""Command-line interface settings source.""" + +from __future__ import annotations as _annotations + +import json +import re +import shlex +import sys +import typing +from argparse import ( + SUPPRESS, + ArgumentParser, + BooleanOptionalAction, + Namespace, + RawDescriptionHelpFormatter, + _SubParsersAction, +) +from collections import defaultdict +from collections.abc import Mapping, Sequence +from enum import Enum +from textwrap import dedent +from types import SimpleNamespace +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Callable, + Generic, + NoReturn, + Optional, + TypeVar, + Union, + cast, + overload, +) + +import typing_extensions +from pydantic import BaseModel, Field +from pydantic._internal._repr import Representation +from pydantic._internal._utils import is_model_class +from pydantic.dataclasses import is_pydantic_dataclass +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined +from typing_extensions import get_args, get_origin +from typing_inspection import typing_objects +from typing_inspection.introspection import is_union_origin + +from ...exceptions import SettingsError +from ...utils import _lenient_issubclass, _WithArgsTypes +from ..types import NoDecode, _CliExplicitFlag, _CliImplicitFlag, _CliPositionalArg, _CliSubCommand, _CliUnknownArgs +from ..utils import ( + _annotation_contains_types, + _annotation_enum_val_to_name, + _get_alias_names, + _get_model_fields, + _is_function, + _strip_annotated, + parse_env_vars, +) +from .env import EnvSettingsSource + +if TYPE_CHECKING: + from pydantic_settings.main import BaseSettings + + +class _CliInternalArgParser(ArgumentParser): + def __init__(self, cli_exit_on_error: bool = True, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._cli_exit_on_error = cli_exit_on_error + + def error(self, message: str) -> NoReturn: + if not self._cli_exit_on_error: + raise SettingsError(f'error parsing CLI: {message}') + super().error(message) + + +class CliMutuallyExclusiveGroup(BaseModel): + pass + + +T = TypeVar('T') +CliSubCommand = Annotated[Union[T, None], _CliSubCommand] +CliPositionalArg = Annotated[T, _CliPositionalArg] +_CliBoolFlag = TypeVar('_CliBoolFlag', bound=bool) +CliImplicitFlag = Annotated[_CliBoolFlag, _CliImplicitFlag] +CliExplicitFlag = Annotated[_CliBoolFlag, _CliExplicitFlag] +CLI_SUPPRESS = SUPPRESS +CliSuppress = Annotated[T, CLI_SUPPRESS] +CliUnknownArgs = Annotated[list[str], Field(default=[]), _CliUnknownArgs, NoDecode] + + +class CliSettingsSource(EnvSettingsSource, Generic[T]): + """ + Source class for loading settings values from CLI. + + Note: + A `CliSettingsSource` connects with a `root_parser` object by using the parser methods to add + `settings_cls` fields as command line arguments. The `CliSettingsSource` internal parser representation + is based upon the `argparse` parsing library, and therefore, requires the parser methods to support + the same attributes as their `argparse` library counterparts. + + Args: + cli_prog_name: The CLI program name to display in help text. Defaults to `None` if cli_parse_args is `None`. + Otherwise, defaults to sys.argv[0]. + cli_parse_args: The list of CLI arguments to parse. Defaults to None. + If set to `True`, defaults to sys.argv[1:]. + cli_parse_none_str: The CLI string value that should be parsed (e.g. "null", "void", "None", etc.) into `None` + type(None). Defaults to "null" if cli_avoid_json is `False`, and "None" if cli_avoid_json is `True`. + cli_hide_none_type: Hide `None` values in CLI help text. Defaults to `False`. + cli_avoid_json: Avoid complex JSON objects in CLI help text. Defaults to `False`. + cli_enforce_required: Enforce required fields at the CLI. Defaults to `False`. + cli_use_class_docs_for_groups: Use class docstrings in CLI group help text instead of field descriptions. + Defaults to `False`. + cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs. + Defaults to `True`. + cli_prefix: Prefix for command line arguments added under the root parser. Defaults to "". + cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'. + cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags. + (e.g. --flag, --no-flag). Defaults to `False`. + cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`. + cli_kebab_case: CLI args use kebab case. Defaults to `False`. + cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`. + case_sensitive: Whether CLI "--arg" names should be read with case-sensitivity. Defaults to `True`. + Note: Case-insensitive matching is only supported on the internal root parser and does not apply to CLI + subcommands. + root_parser: The root parser object. + parse_args_method: The root parser parse args method. Defaults to `argparse.ArgumentParser.parse_args`. + add_argument_method: The root parser add argument method. Defaults to `argparse.ArgumentParser.add_argument`. + add_argument_group_method: The root parser add argument group method. + Defaults to `argparse.ArgumentParser.add_argument_group`. + add_parser_method: The root parser add new parser (sub-command) method. + Defaults to `argparse._SubParsersAction.add_parser`. + add_subparsers_method: The root parser add subparsers (sub-commands) method. + Defaults to `argparse.ArgumentParser.add_subparsers`. + formatter_class: A class for customizing the root parser help text. Defaults to `argparse.RawDescriptionHelpFormatter`. + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + cli_prog_name: str | None = None, + cli_parse_args: bool | list[str] | tuple[str, ...] | None = None, + cli_parse_none_str: str | None = None, + cli_hide_none_type: bool | None = None, + cli_avoid_json: bool | None = None, + cli_enforce_required: bool | None = None, + cli_use_class_docs_for_groups: bool | None = None, + cli_exit_on_error: bool | None = None, + cli_prefix: str | None = None, + cli_flag_prefix_char: str | None = None, + cli_implicit_flags: bool | None = None, + cli_ignore_unknown_args: bool | None = None, + cli_kebab_case: bool | None = None, + cli_shortcuts: Mapping[str, str | list[str]] | None = None, + case_sensitive: bool | None = True, + root_parser: Any = None, + parse_args_method: Callable[..., Any] | None = None, + add_argument_method: Callable[..., Any] | None = ArgumentParser.add_argument, + add_argument_group_method: Callable[..., Any] | None = ArgumentParser.add_argument_group, + add_parser_method: Callable[..., Any] | None = _SubParsersAction.add_parser, + add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers, + formatter_class: Any = RawDescriptionHelpFormatter, + ) -> None: + self.cli_prog_name = ( + cli_prog_name if cli_prog_name is not None else settings_cls.model_config.get('cli_prog_name', sys.argv[0]) + ) + self.cli_parse_args = ( + cli_parse_args if cli_parse_args is not None else settings_cls.model_config.get('cli_parse_args', None) + ) + self.cli_hide_none_type = ( + cli_hide_none_type + if cli_hide_none_type is not None + else settings_cls.model_config.get('cli_hide_none_type', False) + ) + self.cli_avoid_json = ( + cli_avoid_json if cli_avoid_json is not None else settings_cls.model_config.get('cli_avoid_json', False) + ) + if not cli_parse_none_str: + cli_parse_none_str = 'None' if self.cli_avoid_json is True else 'null' + self.cli_parse_none_str = cli_parse_none_str + self.cli_enforce_required = ( + cli_enforce_required + if cli_enforce_required is not None + else settings_cls.model_config.get('cli_enforce_required', False) + ) + self.cli_use_class_docs_for_groups = ( + cli_use_class_docs_for_groups + if cli_use_class_docs_for_groups is not None + else settings_cls.model_config.get('cli_use_class_docs_for_groups', False) + ) + self.cli_exit_on_error = ( + cli_exit_on_error + if cli_exit_on_error is not None + else settings_cls.model_config.get('cli_exit_on_error', True) + ) + self.cli_prefix = cli_prefix if cli_prefix is not None else settings_cls.model_config.get('cli_prefix', '') + self.cli_flag_prefix_char = ( + cli_flag_prefix_char + if cli_flag_prefix_char is not None + else settings_cls.model_config.get('cli_flag_prefix_char', '-') + ) + self._cli_flag_prefix = self.cli_flag_prefix_char * 2 + if self.cli_prefix: + if cli_prefix.startswith('.') or cli_prefix.endswith('.') or not cli_prefix.replace('.', '').isidentifier(): # type: ignore + raise SettingsError(f'CLI settings source prefix is invalid: {cli_prefix}') + self.cli_prefix += '.' + self.cli_implicit_flags = ( + cli_implicit_flags + if cli_implicit_flags is not None + else settings_cls.model_config.get('cli_implicit_flags', False) + ) + self.cli_ignore_unknown_args = ( + cli_ignore_unknown_args + if cli_ignore_unknown_args is not None + else settings_cls.model_config.get('cli_ignore_unknown_args', False) + ) + self.cli_kebab_case = ( + cli_kebab_case if cli_kebab_case is not None else settings_cls.model_config.get('cli_kebab_case', False) + ) + self.cli_shortcuts = ( + cli_shortcuts if cli_shortcuts is not None else settings_cls.model_config.get('cli_shortcuts', None) + ) + + case_sensitive = case_sensitive if case_sensitive is not None else True + if not case_sensitive and root_parser is not None: + raise SettingsError('Case-insensitive matching is only supported on the internal root parser') + + super().__init__( + settings_cls, + env_nested_delimiter='.', + env_parse_none_str=self.cli_parse_none_str, + env_parse_enums=True, + env_prefix=self.cli_prefix, + case_sensitive=case_sensitive, + ) + + root_parser = ( + _CliInternalArgParser( + cli_exit_on_error=self.cli_exit_on_error, + prog=self.cli_prog_name, + description=None if settings_cls.__doc__ is None else dedent(settings_cls.__doc__), + formatter_class=formatter_class, + prefix_chars=self.cli_flag_prefix_char, + allow_abbrev=False, + ) + if root_parser is None + else root_parser + ) + self._connect_root_parser( + root_parser=root_parser, + parse_args_method=parse_args_method, + add_argument_method=add_argument_method, + add_argument_group_method=add_argument_group_method, + add_parser_method=add_parser_method, + add_subparsers_method=add_subparsers_method, + formatter_class=formatter_class, + ) + if self.cli_parse_args not in (None, False): + if self.cli_parse_args is True: + self.cli_parse_args = sys.argv[1:] + elif not isinstance(self.cli_parse_args, (list, tuple)): + raise SettingsError( + f'cli_parse_args must be a list or tuple of strings, received {type(self.cli_parse_args)}' + ) + self._load_env_vars(parsed_args=self._parse_args(self.root_parser, self.cli_parse_args)) + + @overload + def __call__(self) -> dict[str, Any]: ... + + @overload + def __call__(self, *, args: list[str] | tuple[str, ...] | bool) -> CliSettingsSource[T]: + """ + Parse and load the command line arguments list into the CLI settings source. + + Args: + args: + The command line arguments to parse and load. Defaults to `None`, which means do not parse + command line arguments. If set to `True`, defaults to sys.argv[1:]. If set to `False`, does + not parse command line arguments. + + Returns: + CliSettingsSource: The object instance itself. + """ + ... + + @overload + def __call__(self, *, parsed_args: Namespace | SimpleNamespace | dict[str, Any]) -> CliSettingsSource[T]: + """ + Loads parsed command line arguments into the CLI settings source. + + Note: + The parsed args must be in `argparse.Namespace`, `SimpleNamespace`, or vars dictionary + (e.g., vars(argparse.Namespace)) format. + + Args: + parsed_args: The parsed args to load. + + Returns: + CliSettingsSource: The object instance itself. + """ + ... + + def __call__( + self, + *, + args: list[str] | tuple[str, ...] | bool | None = None, + parsed_args: Namespace | SimpleNamespace | dict[str, list[str] | str] | None = None, + ) -> dict[str, Any] | CliSettingsSource[T]: + if args is not None and parsed_args is not None: + raise SettingsError('`args` and `parsed_args` are mutually exclusive') + elif args is not None: + if args is False: + return self._load_env_vars(parsed_args={}) + if args is True: + args = sys.argv[1:] + return self._load_env_vars(parsed_args=self._parse_args(self.root_parser, args)) + elif parsed_args is not None: + return self._load_env_vars(parsed_args=parsed_args) + else: + return super().__call__() + + @overload + def _load_env_vars(self) -> Mapping[str, str | None]: ... + + @overload + def _load_env_vars(self, *, parsed_args: Namespace | SimpleNamespace | dict[str, Any]) -> CliSettingsSource[T]: + """ + Loads the parsed command line arguments into the CLI environment settings variables. + + Note: + The parsed args must be in `argparse.Namespace`, `SimpleNamespace`, or vars dictionary + (e.g., vars(argparse.Namespace)) format. + + Args: + parsed_args: The parsed args to load. + + Returns: + CliSettingsSource: The object instance itself. + """ + ... + + def _load_env_vars( + self, *, parsed_args: Namespace | SimpleNamespace | dict[str, list[str] | str] | None = None + ) -> Mapping[str, str | None] | CliSettingsSource[T]: + if parsed_args is None: + return {} + + if isinstance(parsed_args, (Namespace, SimpleNamespace)): + parsed_args = vars(parsed_args) + + selected_subcommands: list[str] = [] + for field_name, val in parsed_args.items(): + if isinstance(val, list): + parsed_args[field_name] = self._merge_parsed_list(val, field_name) + elif field_name.endswith(':subcommand') and val is not None: + subcommand_name = field_name.split(':')[0] + val + subcommand_dest = self._cli_subcommands[field_name][subcommand_name] + selected_subcommands.append(subcommand_dest) + + for subcommands in self._cli_subcommands.values(): + for subcommand_dest in subcommands.values(): + if subcommand_dest not in selected_subcommands: + parsed_args[subcommand_dest] = self.cli_parse_none_str + + parsed_args = { + key: val + for key, val in parsed_args.items() + if not key.endswith(':subcommand') and val is not PydanticUndefined + } + if selected_subcommands: + last_selected_subcommand = max(selected_subcommands, key=len) + if not any(field_name for field_name in parsed_args.keys() if f'{last_selected_subcommand}.' in field_name): + parsed_args[last_selected_subcommand] = '{}' + + parsed_args.update(self._cli_unknown_args) + + self.env_vars = parse_env_vars( + cast(Mapping[str, str], parsed_args), + self.case_sensitive, + self.env_ignore_empty, + self.cli_parse_none_str, + ) + + return self + + def _get_merge_parsed_list_types( + self, parsed_list: list[str], field_name: str + ) -> tuple[Optional[type], Optional[type]]: + merge_type = self._cli_dict_args.get(field_name, list) + if ( + merge_type is list + or not is_union_origin(get_origin(merge_type)) + or not any( + type_ + for type_ in get_args(merge_type) + if type_ is not type(None) and get_origin(type_) not in (dict, Mapping) + ) + ): + inferred_type = merge_type + else: + inferred_type = list if parsed_list and (len(parsed_list) > 1 or parsed_list[0].startswith('[')) else str + + return merge_type, inferred_type + + def _merge_parsed_list(self, parsed_list: list[str], field_name: str) -> str: + try: + merged_list: list[str] = [] + is_last_consumed_a_value = False + merge_type, inferred_type = self._get_merge_parsed_list_types(parsed_list, field_name) + for val in parsed_list: + if not isinstance(val, str): + # If val is not a string, it's from an external parser and we can ignore parsing the rest of the + # list. + break + val = val.strip() + if val.startswith('[') and val.endswith(']'): + val = val[1:-1].strip() + while val: + val = val.strip() + if val.startswith(','): + val = self._consume_comma(val, merged_list, is_last_consumed_a_value) + is_last_consumed_a_value = False + else: + if val.startswith('{') or val.startswith('['): + val = self._consume_object_or_array(val, merged_list) + else: + try: + val = self._consume_string_or_number(val, merged_list, merge_type) + except ValueError as e: + if merge_type is inferred_type: + raise e + merge_type = inferred_type + val = self._consume_string_or_number(val, merged_list, merge_type) + is_last_consumed_a_value = True + if not is_last_consumed_a_value: + val = self._consume_comma(val, merged_list, is_last_consumed_a_value) + + if merge_type is str: + return merged_list[0] + elif merge_type is list: + return f'[{",".join(merged_list)}]' + else: + merged_dict: dict[str, str] = {} + for item in merged_list: + merged_dict.update(json.loads(item)) + return json.dumps(merged_dict) + except Exception as e: + raise SettingsError(f'Parsing error encountered for {field_name}: {e}') + + def _consume_comma(self, item: str, merged_list: list[str], is_last_consumed_a_value: bool) -> str: + if not is_last_consumed_a_value: + merged_list.append('""') + return item[1:] + + def _consume_object_or_array(self, item: str, merged_list: list[str]) -> str: + count = 1 + close_delim = '}' if item.startswith('{') else ']' + in_str = False + for consumed in range(1, len(item)): + if item[consumed] == '"' and item[consumed - 1] != '\\': + in_str = not in_str + elif in_str: + continue + elif item[consumed] in ('{', '['): + count += 1 + elif item[consumed] in ('}', ']'): + count -= 1 + if item[consumed] == close_delim and count == 0: + merged_list.append(item[: consumed + 1]) + return item[consumed + 1 :] + raise SettingsError(f'Missing end delimiter "{close_delim}"') + + def _consume_string_or_number(self, item: str, merged_list: list[str], merge_type: type[Any] | None) -> str: + consumed = 0 if merge_type is not str else len(item) + is_find_end_quote = False + while consumed < len(item): + if item[consumed] == '"' and (consumed == 0 or item[consumed - 1] != '\\'): + is_find_end_quote = not is_find_end_quote + if not is_find_end_quote and item[consumed] == ',': + break + consumed += 1 + if is_find_end_quote: + raise SettingsError('Mismatched quotes') + val_string = item[:consumed].strip() + if merge_type in (list, str): + try: + float(val_string) + except ValueError: + if val_string == self.cli_parse_none_str: + val_string = 'null' + if val_string not in ('true', 'false', 'null') and not val_string.startswith('"'): + val_string = f'"{val_string}"' + merged_list.append(val_string) + else: + key, val = (kv for kv in val_string.split('=', 1)) + if key.startswith('"') and not key.endswith('"') and not val.startswith('"') and val.endswith('"'): + raise ValueError(f'Dictionary key=val parameter is a quoted string: {val_string}') + key, val = key.strip('"'), val.strip('"') + merged_list.append(json.dumps({key: val})) + return item[consumed:] + + def _get_sub_models(self, model: type[BaseModel], field_name: str, field_info: FieldInfo) -> list[type[BaseModel]]: + field_types: tuple[Any, ...] = ( + (field_info.annotation,) if not get_args(field_info.annotation) else get_args(field_info.annotation) + ) + if self.cli_hide_none_type: + field_types = tuple([type_ for type_ in field_types if type_ is not type(None)]) + + sub_models: list[type[BaseModel]] = [] + for type_ in field_types: + if _annotation_contains_types(type_, (_CliSubCommand,), is_include_origin=False): + raise SettingsError(f'CliSubCommand is not outermost annotation for {model.__name__}.{field_name}') + elif _annotation_contains_types(type_, (_CliPositionalArg,), is_include_origin=False): + raise SettingsError(f'CliPositionalArg is not outermost annotation for {model.__name__}.{field_name}') + if is_model_class(_strip_annotated(type_)) or is_pydantic_dataclass(_strip_annotated(type_)): + sub_models.append(_strip_annotated(type_)) + return sub_models + + def _verify_cli_flag_annotations(self, model: type[BaseModel], field_name: str, field_info: FieldInfo) -> None: + if _CliImplicitFlag in field_info.metadata: + cli_flag_name = 'CliImplicitFlag' + elif _CliExplicitFlag in field_info.metadata: + cli_flag_name = 'CliExplicitFlag' + else: + return + + if field_info.annotation is not bool: + raise SettingsError(f'{cli_flag_name} argument {model.__name__}.{field_name} is not of type bool') + + def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]]: + positional_variadic_arg = [] + positional_args, subcommand_args, optional_args = [], [], [] + for field_name, field_info in _get_model_fields(model).items(): + if _CliSubCommand in field_info.metadata: + if not field_info.is_required(): + raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has a default value') + else: + alias_names, *_ = _get_alias_names(field_name, field_info) + if len(alias_names) > 1: + raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has multiple aliases') + field_types = [type_ for type_ in get_args(field_info.annotation) if type_ is not type(None)] + for field_type in field_types: + if not (is_model_class(field_type) or is_pydantic_dataclass(field_type)): + raise SettingsError( + f'subcommand argument {model.__name__}.{field_name} has type not derived from BaseModel' + ) + subcommand_args.append((field_name, field_info)) + elif _CliPositionalArg in field_info.metadata: + alias_names, *_ = _get_alias_names(field_name, field_info) + if len(alias_names) > 1: + raise SettingsError(f'positional argument {model.__name__}.{field_name} has multiple aliases') + is_append_action = _annotation_contains_types( + field_info.annotation, (list, set, dict, Sequence, Mapping), is_strip_annotated=True + ) + if not is_append_action: + positional_args.append((field_name, field_info)) + else: + positional_variadic_arg.append((field_name, field_info)) + else: + self._verify_cli_flag_annotations(model, field_name, field_info) + optional_args.append((field_name, field_info)) + + if positional_variadic_arg: + if len(positional_variadic_arg) > 1: + field_names = ', '.join([name for name, info in positional_variadic_arg]) + raise SettingsError(f'{model.__name__} has multiple variadic positional arguments: {field_names}') + elif subcommand_args: + field_names = ', '.join([name for name, info in positional_variadic_arg + subcommand_args]) + raise SettingsError( + f'{model.__name__} has variadic positional arguments and subcommand arguments: {field_names}' + ) + + return positional_args + positional_variadic_arg + subcommand_args + optional_args + + @property + def root_parser(self) -> T: + """The connected root parser instance.""" + return self._root_parser + + def _connect_parser_method( + self, parser_method: Callable[..., Any] | None, method_name: str, *args: Any, **kwargs: Any + ) -> Callable[..., Any]: + if ( + parser_method is not None + and self.case_sensitive is False + and method_name == 'parse_args_method' + and isinstance(self._root_parser, _CliInternalArgParser) + ): + + def parse_args_insensitive_method( + root_parser: _CliInternalArgParser, + args: list[str] | tuple[str, ...] | None = None, + namespace: Namespace | None = None, + ) -> Any: + insensitive_args = [] + for arg in shlex.split(shlex.join(args)) if args else []: + flag_prefix = rf'\{self.cli_flag_prefix_char}{{1,2}}' + matched = re.match(rf'^({flag_prefix}[^\s=]+)(.*)', arg) + if matched: + arg = matched.group(1).lower() + matched.group(2) + insensitive_args.append(arg) + return parser_method(root_parser, insensitive_args, namespace) + + return parse_args_insensitive_method + + elif parser_method is None: + + def none_parser_method(*args: Any, **kwargs: Any) -> Any: + raise SettingsError( + f'cannot connect CLI settings source root parser: {method_name} is set to `None` but is needed for connecting' + ) + + return none_parser_method + + else: + return parser_method + + def _connect_group_method(self, add_argument_group_method: Callable[..., Any] | None) -> Callable[..., Any]: + add_argument_group = self._connect_parser_method(add_argument_group_method, 'add_argument_group_method') + + def add_group_method(parser: Any, **kwargs: Any) -> Any: + if not kwargs.pop('_is_cli_mutually_exclusive_group'): + kwargs.pop('required') + return add_argument_group(parser, **kwargs) + else: + main_group_kwargs = {arg: kwargs.pop(arg) for arg in ['title', 'description'] if arg in kwargs} + main_group_kwargs['title'] += ' (mutually exclusive)' + group = add_argument_group(parser, **main_group_kwargs) + if not hasattr(group, 'add_mutually_exclusive_group'): + raise SettingsError( + 'cannot connect CLI settings source root parser: ' + 'group object is missing add_mutually_exclusive_group but is needed for connecting' + ) + return group.add_mutually_exclusive_group(**kwargs) + + return add_group_method + + def _connect_root_parser( + self, + root_parser: T, + parse_args_method: Callable[..., Any] | None, + add_argument_method: Callable[..., Any] | None = ArgumentParser.add_argument, + add_argument_group_method: Callable[..., Any] | None = ArgumentParser.add_argument_group, + add_parser_method: Callable[..., Any] | None = _SubParsersAction.add_parser, + add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers, + formatter_class: Any = RawDescriptionHelpFormatter, + ) -> None: + self._cli_unknown_args: dict[str, list[str]] = {} + + def _parse_known_args(*args: Any, **kwargs: Any) -> Namespace: + args, unknown_args = ArgumentParser.parse_known_args(*args, **kwargs) + for dest in self._cli_unknown_args: + self._cli_unknown_args[dest] = unknown_args + return cast(Namespace, args) + + self._root_parser = root_parser + if parse_args_method is None: + parse_args_method = _parse_known_args if self.cli_ignore_unknown_args else ArgumentParser.parse_args + self._parse_args = self._connect_parser_method(parse_args_method, 'parse_args_method') + self._add_argument = self._connect_parser_method(add_argument_method, 'add_argument_method') + self._add_group = self._connect_group_method(add_argument_group_method) + self._add_parser = self._connect_parser_method(add_parser_method, 'add_parser_method') + self._add_subparsers = self._connect_parser_method(add_subparsers_method, 'add_subparsers_method') + self._formatter_class = formatter_class + self._cli_dict_args: dict[str, type[Any] | None] = {} + self._cli_subcommands: defaultdict[str, dict[str, str]] = defaultdict(dict) + self._add_parser_args( + parser=self.root_parser, + model=self.settings_cls, + added_args=[], + arg_prefix=self.env_prefix, + subcommand_prefix=self.env_prefix, + group=None, + alias_prefixes=[], + model_default=PydanticUndefined, + ) + + def _add_parser_args( + self, + parser: Any, + model: type[BaseModel], + added_args: list[str], + arg_prefix: str, + subcommand_prefix: str, + group: Any, + alias_prefixes: list[str], + model_default: Any, + is_model_suppressed: bool = False, + ) -> ArgumentParser: + subparsers: Any = None + alias_path_args: dict[str, str] = {} + # Ignore model default if the default is a model and not a subclass of the current model. + model_default = ( + None + if ( + (is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default))) + and not issubclass(type(model_default), model) + ) + else model_default + ) + for field_name, field_info in self._sort_arg_fields(model): + sub_models: list[type[BaseModel]] = self._get_sub_models(model, field_name, field_info) + alias_names, is_alias_path_only = _get_alias_names( + field_name, field_info, alias_path_args=alias_path_args, case_sensitive=self.case_sensitive + ) + preferred_alias = alias_names[0] + if _CliSubCommand in field_info.metadata: + for model in sub_models: + subcommand_alias = self._check_kebab_name( + model.__name__ if len(sub_models) > 1 else preferred_alias + ) + subcommand_name = f'{arg_prefix}{subcommand_alias}' + subcommand_dest = f'{arg_prefix}{preferred_alias}' + self._cli_subcommands[f'{arg_prefix}:subcommand'][subcommand_name] = subcommand_dest + + subcommand_help = None if len(sub_models) > 1 else field_info.description + if self.cli_use_class_docs_for_groups: + subcommand_help = None if model.__doc__ is None else dedent(model.__doc__) + + subparsers = ( + self._add_subparsers( + parser, + title='subcommands', + dest=f'{arg_prefix}:subcommand', + description=field_info.description if len(sub_models) > 1 else None, + ) + if subparsers is None + else subparsers + ) + + if hasattr(subparsers, 'metavar'): + subparsers.metavar = ( + f'{subparsers.metavar[:-1]},{subcommand_alias}}}' + if subparsers.metavar + else f'{{{subcommand_alias}}}' + ) + + self._add_parser_args( + parser=self._add_parser( + subparsers, + subcommand_alias, + help=subcommand_help, + formatter_class=self._formatter_class, + description=None if model.__doc__ is None else dedent(model.__doc__), + ), + model=model, + added_args=[], + arg_prefix=f'{arg_prefix}{preferred_alias}.', + subcommand_prefix=f'{subcommand_prefix}{preferred_alias}.', + group=None, + alias_prefixes=[], + model_default=PydanticUndefined, + ) + else: + flag_prefix: str = self._cli_flag_prefix + is_append_action = _annotation_contains_types( + field_info.annotation, (list, set, dict, Sequence, Mapping), is_strip_annotated=True + ) + is_parser_submodel = sub_models and not is_append_action + kwargs: dict[str, Any] = {} + kwargs['default'] = CLI_SUPPRESS + kwargs['help'] = self._help_format(field_name, field_info, model_default, is_model_suppressed) + kwargs['metavar'] = self._metavar_format(field_info.annotation) + kwargs['required'] = ( + self.cli_enforce_required and field_info.is_required() and model_default is PydanticUndefined + ) + kwargs['dest'] = ( + # Strip prefix if validation alias is set and value is not complex. + # Related https://github.com/pydantic/pydantic-settings/pull/25 + f'{arg_prefix}{preferred_alias}'[self.env_prefix_len :] + if arg_prefix and field_info.validation_alias is not None and not is_parser_submodel + else f'{arg_prefix}{preferred_alias}' + ) + + arg_names = self._get_arg_names(arg_prefix, subcommand_prefix, alias_prefixes, alias_names, added_args) + if not arg_names or (kwargs['dest'] in added_args): + continue + + self._convert_append_action(kwargs, field_info, is_append_action) + + if _CliPositionalArg in field_info.metadata: + arg_names, flag_prefix = self._convert_positional_arg( + kwargs, field_info, preferred_alias, model_default + ) + + self._convert_bool_flag(kwargs, field_info, model_default) + + if is_parser_submodel: + self._add_parser_submodels( + parser, + model, + sub_models, + added_args, + arg_prefix, + subcommand_prefix, + flag_prefix, + arg_names, + kwargs, + field_name, + field_info, + alias_names, + model_default=model_default, + is_model_suppressed=is_model_suppressed, + ) + elif _CliUnknownArgs in field_info.metadata: + self._cli_unknown_args[kwargs['dest']] = [] + elif not is_alias_path_only: + if group is not None: + if isinstance(group, dict): + group = self._add_group(parser, **group) + added_args += list(arg_names) + self._add_argument( + group, *(f'{flag_prefix[: len(name)]}{name}' for name in arg_names), **kwargs + ) + else: + added_args += list(arg_names) + self._add_argument( + parser, *(f'{flag_prefix[: len(name)]}{name}' for name in arg_names), **kwargs + ) + + self._add_parser_alias_paths(parser, alias_path_args, added_args, arg_prefix, subcommand_prefix, group) + return parser + + def _check_kebab_name(self, name: str) -> str: + if self.cli_kebab_case: + return name.replace('_', '-') + return name + + def _convert_append_action(self, kwargs: dict[str, Any], field_info: FieldInfo, is_append_action: bool) -> None: + if is_append_action: + kwargs['action'] = 'append' + if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True): + self._cli_dict_args[kwargs['dest']] = field_info.annotation + + def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None: + if kwargs['metavar'] == 'bool': + if (self.cli_implicit_flags or _CliImplicitFlag in field_info.metadata) and ( + _CliExplicitFlag not in field_info.metadata + ): + del kwargs['metavar'] + kwargs['action'] = BooleanOptionalAction + + def _convert_positional_arg( + self, kwargs: dict[str, Any], field_info: FieldInfo, preferred_alias: str, model_default: Any + ) -> tuple[list[str], str]: + flag_prefix = '' + arg_names = [kwargs['dest']] + kwargs['default'] = PydanticUndefined + kwargs['metavar'] = self._check_kebab_name(preferred_alias.upper()) + + # Note: CLI positional args are always strictly required at the CLI. Therefore, use field_info.is_required in + # conjunction with model_default instead of the derived kwargs['required']. + is_required = field_info.is_required() and model_default is PydanticUndefined + if kwargs.get('action') == 'append': + del kwargs['action'] + kwargs['nargs'] = '+' if is_required else '*' + elif not is_required: + kwargs['nargs'] = '?' + + del kwargs['dest'] + del kwargs['required'] + return arg_names, flag_prefix + + def _get_arg_names( + self, + arg_prefix: str, + subcommand_prefix: str, + alias_prefixes: list[str], + alias_names: tuple[str, ...], + added_args: list[str], + ) -> list[str]: + arg_names: list[str] = [] + for prefix in [arg_prefix] + alias_prefixes: + for name in alias_names: + arg_name = self._check_kebab_name( + f'{prefix}{name}' + if subcommand_prefix == self.env_prefix + else f'{prefix.replace(subcommand_prefix, "", 1)}{name}' + ) + if arg_name not in added_args: + arg_names.append(arg_name) + + if self.cli_shortcuts: + for target, aliases in self.cli_shortcuts.items(): + if target in arg_names: + alias_list = [aliases] if isinstance(aliases, str) else aliases + arg_names.extend(alias for alias in alias_list if alias not in added_args) + + return arg_names + + def _add_parser_submodels( + self, + parser: Any, + model: type[BaseModel], + sub_models: list[type[BaseModel]], + added_args: list[str], + arg_prefix: str, + subcommand_prefix: str, + flag_prefix: str, + arg_names: list[str], + kwargs: dict[str, Any], + field_name: str, + field_info: FieldInfo, + alias_names: tuple[str, ...], + model_default: Any, + is_model_suppressed: bool, + ) -> None: + if issubclass(model, CliMutuallyExclusiveGroup): + # Argparse has deprecated "calling add_argument_group() or add_mutually_exclusive_group() on a + # mutually exclusive group" (https://docs.python.org/3/library/argparse.html#mutual-exclusion). + # Since nested models result in a group add, raise an exception for nested models in a mutually + # exclusive group. + raise SettingsError('cannot have nested models in a CliMutuallyExclusiveGroup') + + model_group: Any = None + model_group_kwargs: dict[str, Any] = {} + model_group_kwargs['title'] = f'{arg_names[0]} options' + model_group_kwargs['description'] = field_info.description + model_group_kwargs['required'] = kwargs['required'] + model_group_kwargs['_is_cli_mutually_exclusive_group'] = any( + issubclass(model, CliMutuallyExclusiveGroup) for model in sub_models + ) + if model_group_kwargs['_is_cli_mutually_exclusive_group'] and len(sub_models) > 1: + raise SettingsError('cannot use union with CliMutuallyExclusiveGroup') + if self.cli_use_class_docs_for_groups and len(sub_models) == 1: + model_group_kwargs['description'] = None if sub_models[0].__doc__ is None else dedent(sub_models[0].__doc__) + + if model_default is not PydanticUndefined: + if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)): + model_default = getattr(model_default, field_name) + else: + if field_info.default is not PydanticUndefined: + model_default = field_info.default + elif field_info.default_factory is not None: + model_default = field_info.default_factory + if model_default is None: + desc_header = f'default: {self.cli_parse_none_str} (undefined)' + if model_group_kwargs['description'] is not None: + model_group_kwargs['description'] = dedent(f'{desc_header}\n{model_group_kwargs["description"]}') + else: + model_group_kwargs['description'] = desc_header + + preferred_alias = alias_names[0] + is_model_suppressed = self._is_field_suppressed(field_info) or is_model_suppressed + if is_model_suppressed: + model_group_kwargs['description'] = CLI_SUPPRESS + if not self.cli_avoid_json: + added_args.append(arg_names[0]) + kwargs['nargs'] = '?' + kwargs['const'] = '{}' + kwargs['help'] = ( + CLI_SUPPRESS if is_model_suppressed else f'set {arg_names[0]} from JSON string (default: {{}})' + ) + model_group = self._add_group(parser, **model_group_kwargs) + self._add_argument(model_group, *(f'{flag_prefix}{name}' for name in arg_names), **kwargs) + for model in sub_models: + self._add_parser_args( + parser=parser, + model=model, + added_args=added_args, + arg_prefix=f'{arg_prefix}{preferred_alias}.', + subcommand_prefix=subcommand_prefix, + group=model_group if model_group else model_group_kwargs, + alias_prefixes=[f'{arg_prefix}{name}.' for name in alias_names[1:]], + model_default=model_default, + is_model_suppressed=is_model_suppressed, + ) + + def _add_parser_alias_paths( + self, + parser: Any, + alias_path_args: dict[str, str], + added_args: list[str], + arg_prefix: str, + subcommand_prefix: str, + group: Any, + ) -> None: + if alias_path_args: + context = parser + if group is not None: + context = self._add_group(parser, **group) if isinstance(group, dict) else group + is_nested_alias_path = arg_prefix.endswith('.') + arg_prefix = arg_prefix[:-1] if is_nested_alias_path else arg_prefix + for name, metavar in alias_path_args.items(): + name = '' if is_nested_alias_path else name + arg_name = ( + f'{arg_prefix}{name}' + if subcommand_prefix == self.env_prefix + else f'{arg_prefix.replace(subcommand_prefix, "", 1)}{name}' + ) + kwargs: dict[str, Any] = {} + kwargs['default'] = CLI_SUPPRESS + kwargs['help'] = 'pydantic alias path' + kwargs['dest'] = f'{arg_prefix}{name}' + if metavar == 'dict' or is_nested_alias_path: + kwargs['metavar'] = 'dict' + else: + kwargs['action'] = 'append' + kwargs['metavar'] = 'list' + if arg_name not in added_args: + added_args.append(arg_name) + self._add_argument(context, f'{self._cli_flag_prefix}{arg_name}', **kwargs) + + def _get_modified_args(self, obj: Any) -> tuple[str, ...]: + if not self.cli_hide_none_type: + return get_args(obj) + else: + return tuple([type_ for type_ in get_args(obj) if type_ is not type(None)]) + + def _metavar_format_choices(self, args: list[str], obj_qualname: str | None = None) -> str: + if 'JSON' in args: + args = args[: args.index('JSON') + 1] + [arg for arg in args[args.index('JSON') + 1 :] if arg != 'JSON'] + metavar = ','.join(args) + if obj_qualname: + return f'{obj_qualname}[{metavar}]' + else: + return metavar if len(args) == 1 else f'{{{metavar}}}' + + def _metavar_format_recurse(self, obj: Any) -> str: + """Pretty metavar representation of a type. Adapts logic from `pydantic._repr.display_as_type`.""" + obj = _strip_annotated(obj) + if _is_function(obj): + # If function is locally defined use __name__ instead of __qualname__ + return obj.__name__ if '' in obj.__qualname__ else obj.__qualname__ + elif obj is ...: + return '...' + elif isinstance(obj, Representation): + return repr(obj) + elif typing_objects.is_typealiastype(obj): + return str(obj) + + origin = get_origin(obj) + if origin is None and not isinstance(obj, (type, typing.ForwardRef, typing_extensions.ForwardRef)): + obj = obj.__class__ + + if is_union_origin(origin): + return self._metavar_format_choices(list(map(self._metavar_format_recurse, self._get_modified_args(obj)))) + elif typing_objects.is_literal(origin): + return self._metavar_format_choices(list(map(str, self._get_modified_args(obj)))) + elif _lenient_issubclass(obj, Enum): + return self._metavar_format_choices([val.name for val in obj]) + elif isinstance(obj, _WithArgsTypes): + return self._metavar_format_choices( + list(map(self._metavar_format_recurse, self._get_modified_args(obj))), + obj_qualname=obj.__qualname__ if hasattr(obj, '__qualname__') else str(obj), + ) + elif obj is type(None): + return self.cli_parse_none_str + elif is_model_class(obj): + return 'JSON' + elif isinstance(obj, type): + return obj.__qualname__ + else: + return repr(obj).replace('typing.', '').replace('typing_extensions.', '') + + def _metavar_format(self, obj: Any) -> str: + return self._metavar_format_recurse(obj).replace(', ', ',') + + def _help_format( + self, field_name: str, field_info: FieldInfo, model_default: Any, is_model_suppressed: bool + ) -> str: + _help = field_info.description if field_info.description else '' + if is_model_suppressed or self._is_field_suppressed(field_info): + return CLI_SUPPRESS + + if field_info.is_required() and model_default in (PydanticUndefined, None): + if _CliPositionalArg not in field_info.metadata: + ifdef = 'ifdef: ' if model_default is None else '' + _help += f' ({ifdef}required)' if _help else f'({ifdef}required)' + else: + default = f'(default: {self.cli_parse_none_str})' + if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)): + default = f'(default: {getattr(model_default, field_name)})' + elif model_default not in (PydanticUndefined, None) and _is_function(model_default): + default = f'(default factory: {self._metavar_format(model_default)})' + elif field_info.default not in (PydanticUndefined, None): + enum_name = _annotation_enum_val_to_name(field_info.annotation, field_info.default) + default = f'(default: {field_info.default if enum_name is None else enum_name})' + elif field_info.default_factory is not None: + default = f'(default factory: {self._metavar_format(field_info.default_factory)})' + _help += f' {default}' if _help else default + return _help.replace('%', '%%') if issubclass(type(self._root_parser), ArgumentParser) else _help + + def _is_field_suppressed(self, field_info: FieldInfo) -> bool: + _help = field_info.description if field_info.description else '' + return _help == CLI_SUPPRESS or CLI_SUPPRESS in field_info.metadata diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/dotenv.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/dotenv.py new file mode 100644 index 000000000..d953f5f0b --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/dotenv.py @@ -0,0 +1,168 @@ +"""Dotenv file settings source.""" + +from __future__ import annotations as _annotations + +import os +import warnings +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from dotenv import dotenv_values +from pydantic._internal._typing_extra import ( # type: ignore[attr-defined] + get_origin, +) +from typing_inspection.introspection import is_union_origin + +from ..types import ENV_FILE_SENTINEL, DotenvType +from ..utils import ( + _annotation_is_complex, + _union_is_complex, + parse_env_vars, +) +from .env import EnvSettingsSource + +if TYPE_CHECKING: + from pydantic_settings.main import BaseSettings + + +class DotEnvSettingsSource(EnvSettingsSource): + """ + Source class for loading settings values from env files. + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + env_file: DotenvType | None = ENV_FILE_SENTINEL, + env_file_encoding: str | None = None, + case_sensitive: bool | None = None, + env_prefix: str | None = None, + env_nested_delimiter: str | None = None, + env_nested_max_split: int | None = None, + env_ignore_empty: bool | None = None, + env_parse_none_str: str | None = None, + env_parse_enums: bool | None = None, + ) -> None: + self.env_file = env_file if env_file != ENV_FILE_SENTINEL else settings_cls.model_config.get('env_file') + self.env_file_encoding = ( + env_file_encoding if env_file_encoding is not None else settings_cls.model_config.get('env_file_encoding') + ) + super().__init__( + settings_cls, + case_sensitive, + env_prefix, + env_nested_delimiter, + env_nested_max_split, + env_ignore_empty, + env_parse_none_str, + env_parse_enums, + ) + + def _load_env_vars(self) -> Mapping[str, str | None]: + return self._read_env_files() + + @staticmethod + def _static_read_env_file( + file_path: Path, + *, + encoding: str | None = None, + case_sensitive: bool = False, + ignore_empty: bool = False, + parse_none_str: str | None = None, + ) -> Mapping[str, str | None]: + file_vars: dict[str, str | None] = dotenv_values(file_path, encoding=encoding or 'utf8') + return parse_env_vars(file_vars, case_sensitive, ignore_empty, parse_none_str) + + def _read_env_file( + self, + file_path: Path, + ) -> Mapping[str, str | None]: + return self._static_read_env_file( + file_path, + encoding=self.env_file_encoding, + case_sensitive=self.case_sensitive, + ignore_empty=self.env_ignore_empty, + parse_none_str=self.env_parse_none_str, + ) + + def _read_env_files(self) -> Mapping[str, str | None]: + env_files = self.env_file + if env_files is None: + return {} + + if isinstance(env_files, (str, os.PathLike)): + env_files = [env_files] + + dotenv_vars: dict[str, str | None] = {} + for env_file in env_files: + env_path = Path(env_file).expanduser() + if env_path.is_file(): + dotenv_vars.update(self._read_env_file(env_path)) + + return dotenv_vars + + def __call__(self) -> dict[str, Any]: + data: dict[str, Any] = super().__call__() + is_extra_allowed = self.config.get('extra') != 'forbid' + + # As `extra` config is allowed in dotenv settings source, We have to + # update data with extra env variables from dotenv file. + for env_name, env_value in self.env_vars.items(): + if not env_value or env_name in data: + continue + env_used = False + for field_name, field in self.settings_cls.model_fields.items(): + for _, field_env_name, _ in self._extract_field_info(field, field_name): + if env_name == field_env_name or ( + ( + _annotation_is_complex(field.annotation, field.metadata) + or ( + is_union_origin(get_origin(field.annotation)) + and _union_is_complex(field.annotation, field.metadata) + ) + ) + and env_name.startswith(field_env_name) + ): + env_used = True + break + if env_used: + break + if not env_used: + if is_extra_allowed and env_name.startswith(self.env_prefix): + # env_prefix should be respected and removed from the env_name + normalized_env_name = env_name[len(self.env_prefix) :] + data[normalized_env_name] = env_value + else: + data[env_name] = env_value + return data + + def __repr__(self) -> str: + return ( + f'{self.__class__.__name__}(env_file={self.env_file!r}, env_file_encoding={self.env_file_encoding!r}, ' + f'env_nested_delimiter={self.env_nested_delimiter!r}, env_prefix_len={self.env_prefix_len!r})' + ) + + +def read_env_file( + file_path: Path, + *, + encoding: str | None = None, + case_sensitive: bool = False, + ignore_empty: bool = False, + parse_none_str: str | None = None, +) -> Mapping[str, str | None]: + warnings.warn( + 'read_env_file will be removed in the next version, use DotEnvSettingsSource._static_read_env_file if you must', + DeprecationWarning, + ) + return DotEnvSettingsSource._static_read_env_file( + file_path, + encoding=encoding, + case_sensitive=case_sensitive, + ignore_empty=ignore_empty, + parse_none_str=parse_none_str, + ) + + +__all__ = ['DotEnvSettingsSource', 'read_env_file'] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/env.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/env.py new file mode 100644 index 000000000..5a350f1da --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/env.py @@ -0,0 +1,270 @@ +from __future__ import annotations as _annotations + +import os +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, +) + +from pydantic._internal._utils import deep_update, is_model_class +from pydantic.dataclasses import is_pydantic_dataclass +from pydantic.fields import FieldInfo +from typing_extensions import get_args, get_origin +from typing_inspection.introspection import is_union_origin + +from ...utils import _lenient_issubclass +from ..base import PydanticBaseEnvSettingsSource +from ..types import EnvNoneType +from ..utils import ( + _annotation_enum_name_to_val, + _get_model_fields, + _union_is_complex, + parse_env_vars, +) + +if TYPE_CHECKING: + from pydantic_settings.main import BaseSettings + + +class EnvSettingsSource(PydanticBaseEnvSettingsSource): + """ + Source class for loading settings values from environment variables. + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + case_sensitive: bool | None = None, + env_prefix: str | None = None, + env_nested_delimiter: str | None = None, + env_nested_max_split: int | None = None, + env_ignore_empty: bool | None = None, + env_parse_none_str: str | None = None, + env_parse_enums: bool | None = None, + ) -> None: + super().__init__( + settings_cls, case_sensitive, env_prefix, env_ignore_empty, env_parse_none_str, env_parse_enums + ) + self.env_nested_delimiter = ( + env_nested_delimiter if env_nested_delimiter is not None else self.config.get('env_nested_delimiter') + ) + self.env_nested_max_split = ( + env_nested_max_split if env_nested_max_split is not None else self.config.get('env_nested_max_split') + ) + self.maxsplit = (self.env_nested_max_split or 0) - 1 + self.env_prefix_len = len(self.env_prefix) + + self.env_vars = self._load_env_vars() + + def _load_env_vars(self) -> Mapping[str, str | None]: + return parse_env_vars(os.environ, self.case_sensitive, self.env_ignore_empty, self.env_parse_none_str) + + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + """ + Gets the value for field from environment variables and a flag to determine whether value is complex. + + Args: + field: The field. + field_name: The field name. + + Returns: + A tuple that contains the value (`None` if not found), key, and + a flag to determine whether value is complex. + """ + + env_val: str | None = None + for field_key, env_name, value_is_complex in self._extract_field_info(field, field_name): + env_val = self.env_vars.get(env_name) + if env_val is not None: + break + + return env_val, field_key, value_is_complex + + def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: + """ + Prepare value for the field. + + * Extract value for nested field. + * Deserialize value to python object for complex field. + + Args: + field: The field. + field_name: The field name. + + Returns: + A tuple contains prepared value for the field. + + Raises: + ValuesError: When There is an error in deserializing value for complex field. + """ + is_complex, allow_parse_failure = self._field_is_complex(field) + if self.env_parse_enums: + enum_val = _annotation_enum_name_to_val(field.annotation, value) + value = value if enum_val is None else enum_val + + if is_complex or value_is_complex: + if isinstance(value, EnvNoneType): + return value + elif value is None: + # field is complex but no value found so far, try explode_env_vars + env_val_built = self.explode_env_vars(field_name, field, self.env_vars) + if env_val_built: + return env_val_built + else: + # field is complex and there's a value, decode that as JSON, then add explode_env_vars + try: + value = self.decode_complex_value(field_name, field, value) + except ValueError as e: + if not allow_parse_failure: + raise e + + if isinstance(value, dict): + return deep_update(value, self.explode_env_vars(field_name, field, self.env_vars)) + else: + return value + elif value is not None: + # simplest case, field is not complex, we only need to add the value if it was found + return value + + def _field_is_complex(self, field: FieldInfo) -> tuple[bool, bool]: + """ + Find out if a field is complex, and if so whether JSON errors should be ignored + """ + if self.field_is_complex(field): + allow_parse_failure = False + elif is_union_origin(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata): + allow_parse_failure = True + else: + return False, False + + return True, allow_parse_failure + + # Default value of `case_sensitive` is `None`, because we don't want to break existing behavior. + # We have to change the method to a non-static method and use + # `self.case_sensitive` instead in V3. + def next_field( + self, field: FieldInfo | Any | None, key: str, case_sensitive: bool | None = None + ) -> FieldInfo | None: + """ + Find the field in a sub model by key(env name) + + By having the following models: + + ```py + class SubSubModel(BaseSettings): + dvals: Dict + + class SubModel(BaseSettings): + vals: list[str] + sub_sub_model: SubSubModel + + class Cfg(BaseSettings): + sub_model: SubModel + ``` + + Then: + next_field(sub_model, 'vals') Returns the `vals` field of `SubModel` class + next_field(sub_model, 'sub_sub_model') Returns `sub_sub_model` field of `SubModel` class + + Args: + field: The field. + key: The key (env name). + case_sensitive: Whether to search for key case sensitively. + + Returns: + Field if it finds the next field otherwise `None`. + """ + if not field: + return None + + annotation = field.annotation if isinstance(field, FieldInfo) else field + for type_ in get_args(annotation): + type_has_key = self.next_field(type_, key, case_sensitive) + if type_has_key: + return type_has_key + if is_model_class(annotation) or is_pydantic_dataclass(annotation): # type: ignore[arg-type] + fields = _get_model_fields(annotation) + # `case_sensitive is None` is here to be compatible with the old behavior. + # Has to be removed in V3. + for field_name, f in fields.items(): + for _, env_name, _ in self._extract_field_info(f, field_name): + if case_sensitive is None or case_sensitive: + if field_name == key or env_name == key: + return f + elif field_name.lower() == key.lower() or env_name.lower() == key.lower(): + return f + return None + + def explode_env_vars(self, field_name: str, field: FieldInfo, env_vars: Mapping[str, str | None]) -> dict[str, Any]: + """ + Process env_vars and extract the values of keys containing env_nested_delimiter into nested dictionaries. + + This is applied to a single field, hence filtering by env_var prefix. + + Args: + field_name: The field name. + field: The field. + env_vars: Environment variables. + + Returns: + A dictionary contains extracted values from nested env values. + """ + if not self.env_nested_delimiter: + return {} + + ann = field.annotation + is_dict = ann is dict or _lenient_issubclass(get_origin(ann), dict) + + prefixes = [ + f'{env_name}{self.env_nested_delimiter}' for _, env_name, _ in self._extract_field_info(field, field_name) + ] + result: dict[str, Any] = {} + for env_name, env_val in env_vars.items(): + try: + prefix = next(prefix for prefix in prefixes if env_name.startswith(prefix)) + except StopIteration: + continue + # we remove the prefix before splitting in case the prefix has characters in common with the delimiter + env_name_without_prefix = env_name[len(prefix) :] + *keys, last_key = env_name_without_prefix.split(self.env_nested_delimiter, self.maxsplit) + env_var = result + target_field: FieldInfo | None = field + for key in keys: + target_field = self.next_field(target_field, key, self.case_sensitive) + if isinstance(env_var, dict): + env_var = env_var.setdefault(key, {}) + + # get proper field with last_key + target_field = self.next_field(target_field, last_key, self.case_sensitive) + + # check if env_val maps to a complex field and if so, parse the env_val + if (target_field or is_dict) and env_val: + if target_field: + is_complex, allow_json_failure = self._field_is_complex(target_field) + if self.env_parse_enums: + enum_val = _annotation_enum_name_to_val(target_field.annotation, env_val) + env_val = env_val if enum_val is None else enum_val + else: + # nested field type is dict + is_complex, allow_json_failure = True, True + if is_complex: + try: + env_val = self.decode_complex_value(last_key, target_field, env_val) # type: ignore + except ValueError as e: + if not allow_json_failure: + raise e + if isinstance(env_var, dict): + if last_key not in env_var or not isinstance(env_val, EnvNoneType) or env_var[last_key] == {}: + env_var[last_key] = env_val + + return result + + def __repr__(self) -> str: + return ( + f'{self.__class__.__name__}(env_nested_delimiter={self.env_nested_delimiter!r}, ' + f'env_prefix_len={self.env_prefix_len!r})' + ) + + +__all__ = ['EnvSettingsSource'] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/gcp.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/gcp.py new file mode 100644 index 000000000..62f356a76 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/gcp.py @@ -0,0 +1,152 @@ +from __future__ import annotations as _annotations + +from collections.abc import Iterator, Mapping +from functools import cached_property +from typing import TYPE_CHECKING, Optional + +from .env import EnvSettingsSource + +if TYPE_CHECKING: + from google.auth import default as google_auth_default + from google.auth.credentials import Credentials + from google.cloud.secretmanager import SecretManagerServiceClient + + from pydantic_settings.main import BaseSettings +else: + Credentials = None + SecretManagerServiceClient = None + google_auth_default = None + + +def import_gcp_secret_manager() -> None: + global Credentials + global SecretManagerServiceClient + global google_auth_default + + try: + from google.auth import default as google_auth_default + from google.auth.credentials import Credentials + from google.cloud.secretmanager import SecretManagerServiceClient + except ImportError as e: # pragma: no cover + raise ImportError( + 'GCP Secret Manager dependencies are not installed, run `pip install pydantic-settings[gcp-secret-manager]`' + ) from e + + +class GoogleSecretManagerMapping(Mapping[str, Optional[str]]): + _loaded_secrets: dict[str, str | None] + _secret_client: SecretManagerServiceClient + + def __init__(self, secret_client: SecretManagerServiceClient, project_id: str, case_sensitive: bool) -> None: + self._loaded_secrets = {} + self._secret_client = secret_client + self._project_id = project_id + self._case_sensitive = case_sensitive + + @property + def _gcp_project_path(self) -> str: + return self._secret_client.common_project_path(self._project_id) + + @cached_property + def _secret_names(self) -> list[str]: + rv: list[str] = [] + + secrets = self._secret_client.list_secrets(parent=self._gcp_project_path) + for secret in secrets: + name = self._secret_client.parse_secret_path(secret.name).get('secret', '') + if not self._case_sensitive: + name = name.lower() + rv.append(name) + return rv + + def _secret_version_path(self, key: str, version: str = 'latest') -> str: + return self._secret_client.secret_version_path(self._project_id, key, version) + + def __getitem__(self, key: str) -> str | None: + if not self._case_sensitive: + key = key.lower() + if key not in self._loaded_secrets: + # If we know the key isn't available in secret manager, raise a key error + if key not in self._secret_names: + raise KeyError(key) + + try: + self._loaded_secrets[key] = self._secret_client.access_secret_version( + name=self._secret_version_path(key) + ).payload.data.decode('UTF-8') + except Exception: + raise KeyError(key) + + return self._loaded_secrets[key] + + def __len__(self) -> int: + return len(self._secret_names) + + def __iter__(self) -> Iterator[str]: + return iter(self._secret_names) + + +class GoogleSecretManagerSettingsSource(EnvSettingsSource): + _credentials: Credentials + _secret_client: SecretManagerServiceClient + _project_id: str + + def __init__( + self, + settings_cls: type[BaseSettings], + credentials: Credentials | None = None, + project_id: str | None = None, + env_prefix: str | None = None, + env_parse_none_str: str | None = None, + env_parse_enums: bool | None = None, + secret_client: SecretManagerServiceClient | None = None, + case_sensitive: bool | None = True, + ) -> None: + # Import Google Packages if they haven't already been imported + if SecretManagerServiceClient is None or Credentials is None or google_auth_default is None: + import_gcp_secret_manager() + + # If credentials or project_id are not passed, then + # try to get them from the default function + if not credentials or not project_id: + _creds, _project_id = google_auth_default() # type: ignore[no-untyped-call] + + # Set the credentials and/or project id if they weren't specified + if credentials is None: + credentials = _creds + + if project_id is None: + if isinstance(_project_id, str): + project_id = _project_id + else: + raise AttributeError( + 'project_id is required to be specified either as an argument or from the google.auth.default. See https://google-auth.readthedocs.io/en/master/reference/google.auth.html#google.auth.default' + ) + + self._credentials: Credentials = credentials + self._project_id: str = project_id + + if secret_client: + self._secret_client = secret_client + else: + self._secret_client = SecretManagerServiceClient(credentials=self._credentials) + + super().__init__( + settings_cls, + case_sensitive=case_sensitive, + env_prefix=env_prefix, + env_ignore_empty=False, + env_parse_none_str=env_parse_none_str, + env_parse_enums=env_parse_enums, + ) + + def _load_env_vars(self) -> Mapping[str, Optional[str]]: + return GoogleSecretManagerMapping( + self._secret_client, project_id=self._project_id, case_sensitive=self.case_sensitive + ) + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(project_id={self._project_id!r}, env_nested_delimiter={self.env_nested_delimiter!r})' + + +__all__ = ['GoogleSecretManagerSettingsSource', 'GoogleSecretManagerMapping'] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/json.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/json.py new file mode 100644 index 000000000..837601c39 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/json.py @@ -0,0 +1,47 @@ +"""JSON file settings source.""" + +from __future__ import annotations as _annotations + +import json +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, +) + +from ..base import ConfigFileSourceMixin, InitSettingsSource +from ..types import DEFAULT_PATH, PathType + +if TYPE_CHECKING: + from pydantic_settings.main import BaseSettings + + +class JsonConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): + """ + A source class that loads variables from a JSON file + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + json_file: PathType | None = DEFAULT_PATH, + json_file_encoding: str | None = None, + ): + self.json_file_path = json_file if json_file != DEFAULT_PATH else settings_cls.model_config.get('json_file') + self.json_file_encoding = ( + json_file_encoding + if json_file_encoding is not None + else settings_cls.model_config.get('json_file_encoding') + ) + self.json_data = self._read_files(self.json_file_path) + super().__init__(settings_cls, self.json_data) + + def _read_file(self, file_path: Path) -> dict[str, Any]: + with open(file_path, encoding=self.json_file_encoding) as json_file: + return json.load(json_file) + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(json_file={self.json_file_path})' + + +__all__ = ['JsonConfigSettingsSource'] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/pyproject.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/pyproject.py new file mode 100644 index 000000000..bb02cbbda --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/pyproject.py @@ -0,0 +1,62 @@ +"""Pyproject TOML file settings source.""" + +from __future__ import annotations as _annotations + +from pathlib import Path +from typing import ( + TYPE_CHECKING, +) + +from .toml import TomlConfigSettingsSource + +if TYPE_CHECKING: + from pydantic_settings.main import BaseSettings + + +class PyprojectTomlConfigSettingsSource(TomlConfigSettingsSource): + """ + A source class that loads variables from a `pyproject.toml` file. + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + toml_file: Path | None = None, + ) -> None: + self.toml_file_path = self._pick_pyproject_toml_file( + toml_file, settings_cls.model_config.get('pyproject_toml_depth', 0) + ) + self.toml_table_header: tuple[str, ...] = settings_cls.model_config.get( + 'pyproject_toml_table_header', ('tool', 'pydantic-settings') + ) + self.toml_data = self._read_files(self.toml_file_path) + for key in self.toml_table_header: + self.toml_data = self.toml_data.get(key, {}) + super(TomlConfigSettingsSource, self).__init__(settings_cls, self.toml_data) + + @staticmethod + def _pick_pyproject_toml_file(provided: Path | None, depth: int) -> Path: + """Pick a `pyproject.toml` file path to use. + + Args: + provided: Explicit path provided when instantiating this class. + depth: Number of directories up the tree to check of a pyproject.toml. + + """ + if provided: + return provided.resolve() + rv = Path.cwd() / 'pyproject.toml' + count = 0 + if not rv.is_file(): + child = rv.parent.parent / 'pyproject.toml' + while count < depth: + if child.is_file(): + return child + if str(child.parent) == rv.root: + break # end discovery after checking system root once + child = child.parent.parent / 'pyproject.toml' + count += 1 + return rv + + +__all__ = ['PyprojectTomlConfigSettingsSource'] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/secrets.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/secrets.py new file mode 100644 index 000000000..00a8f47ad --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/secrets.py @@ -0,0 +1,125 @@ +"""Secrets file settings source.""" + +from __future__ import annotations as _annotations + +import os +import warnings +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, +) + +from pydantic.fields import FieldInfo + +from pydantic_settings.utils import path_type_label + +from ...exceptions import SettingsError +from ..base import PydanticBaseEnvSettingsSource +from ..types import PathType + +if TYPE_CHECKING: + from pydantic_settings.main import BaseSettings + + +class SecretsSettingsSource(PydanticBaseEnvSettingsSource): + """ + Source class for loading settings values from secret files. + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + secrets_dir: PathType | None = None, + case_sensitive: bool | None = None, + env_prefix: str | None = None, + env_ignore_empty: bool | None = None, + env_parse_none_str: str | None = None, + env_parse_enums: bool | None = None, + ) -> None: + super().__init__( + settings_cls, case_sensitive, env_prefix, env_ignore_empty, env_parse_none_str, env_parse_enums + ) + self.secrets_dir = secrets_dir if secrets_dir is not None else self.config.get('secrets_dir') + + def __call__(self) -> dict[str, Any]: + """ + Build fields from "secrets" files. + """ + secrets: dict[str, str | None] = {} + + if self.secrets_dir is None: + return secrets + + secrets_dirs = [self.secrets_dir] if isinstance(self.secrets_dir, (str, os.PathLike)) else self.secrets_dir + secrets_paths = [Path(p).expanduser() for p in secrets_dirs] + self.secrets_paths = [] + + for path in secrets_paths: + if not path.exists(): + warnings.warn(f'directory "{path}" does not exist') + else: + self.secrets_paths.append(path) + + if not len(self.secrets_paths): + return secrets + + for path in self.secrets_paths: + if not path.is_dir(): + raise SettingsError(f'secrets_dir must reference a directory, not a {path_type_label(path)}') + + return super().__call__() + + @classmethod + def find_case_path(cls, dir_path: Path, file_name: str, case_sensitive: bool) -> Path | None: + """ + Find a file within path's directory matching filename, optionally ignoring case. + + Args: + dir_path: Directory path. + file_name: File name. + case_sensitive: Whether to search for file name case sensitively. + + Returns: + Whether file path or `None` if file does not exist in directory. + """ + for f in dir_path.iterdir(): + if f.name == file_name: + return f + elif not case_sensitive and f.name.lower() == file_name.lower(): + return f + return None + + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + """ + Gets the value for field from secret file and a flag to determine whether value is complex. + + Args: + field: The field. + field_name: The field name. + + Returns: + A tuple that contains the value (`None` if the file does not exist), key, and + a flag to determine whether value is complex. + """ + + for field_key, env_name, value_is_complex in self._extract_field_info(field, field_name): + # paths reversed to match the last-wins behaviour of `env_file` + for secrets_path in reversed(self.secrets_paths): + path = self.find_case_path(secrets_path, env_name, self.case_sensitive) + if not path: + # path does not exist, we currently don't return a warning for this + continue + + if path.is_file(): + return path.read_text().strip(), field_key, value_is_complex + else: + warnings.warn( + f'attempted to load secret file "{path}" but found a {path_type_label(path)} instead.', + stacklevel=4, + ) + + return None, field_key, value_is_complex + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(secrets_dir={self.secrets_dir!r})' diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/toml.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/toml.py new file mode 100644 index 000000000..eaff41da0 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/toml.py @@ -0,0 +1,66 @@ +"""TOML file settings source.""" + +from __future__ import annotations as _annotations + +import sys +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, +) + +from ..base import ConfigFileSourceMixin, InitSettingsSource +from ..types import DEFAULT_PATH, PathType + +if TYPE_CHECKING: + from pydantic_settings.main import BaseSettings + + if sys.version_info >= (3, 11): + import tomllib + else: + tomllib = None + import tomli +else: + tomllib = None + tomli = None + + +def import_toml() -> None: + global tomli + global tomllib + if sys.version_info < (3, 11): + if tomli is not None: + return + try: + import tomli + except ImportError as e: # pragma: no cover + raise ImportError('tomli is not installed, run `pip install pydantic-settings[toml]`') from e + else: + if tomllib is not None: + return + import tomllib + + +class TomlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): + """ + A source class that loads variables from a TOML file + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + toml_file: PathType | None = DEFAULT_PATH, + ): + self.toml_file_path = toml_file if toml_file != DEFAULT_PATH else settings_cls.model_config.get('toml_file') + self.toml_data = self._read_files(self.toml_file_path) + super().__init__(settings_cls, self.toml_data) + + def _read_file(self, file_path: Path) -> dict[str, Any]: + import_toml() + with open(file_path, mode='rb') as toml_file: + if sys.version_info < (3, 11): + return tomli.load(toml_file) + return tomllib.load(toml_file) + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(toml_file={self.toml_file_path})' diff --git a/venv/Lib/site-packages/pydantic_settings/sources/providers/yaml.py b/venv/Lib/site-packages/pydantic_settings/sources/providers/yaml.py new file mode 100644 index 000000000..82778b4f5 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/providers/yaml.py @@ -0,0 +1,75 @@ +"""YAML file settings source.""" + +from __future__ import annotations as _annotations + +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, +) + +from ..base import ConfigFileSourceMixin, InitSettingsSource +from ..types import DEFAULT_PATH, PathType + +if TYPE_CHECKING: + import yaml + + from pydantic_settings.main import BaseSettings +else: + yaml = None + + +def import_yaml() -> None: + global yaml + if yaml is not None: + return + try: + import yaml + except ImportError as e: + raise ImportError('PyYAML is not installed, run `pip install pydantic-settings[yaml]`') from e + + +class YamlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): + """ + A source class that loads variables from a yaml file + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + yaml_file: PathType | None = DEFAULT_PATH, + yaml_file_encoding: str | None = None, + yaml_config_section: str | None = None, + ): + self.yaml_file_path = yaml_file if yaml_file != DEFAULT_PATH else settings_cls.model_config.get('yaml_file') + self.yaml_file_encoding = ( + yaml_file_encoding + if yaml_file_encoding is not None + else settings_cls.model_config.get('yaml_file_encoding') + ) + self.yaml_config_section = ( + yaml_config_section + if yaml_config_section is not None + else settings_cls.model_config.get('yaml_config_section') + ) + self.yaml_data = self._read_files(self.yaml_file_path) + + if self.yaml_config_section: + try: + self.yaml_data = self.yaml_data[self.yaml_config_section] + except KeyError: + raise KeyError( + f'yaml_config_section key "{self.yaml_config_section}" not found in {self.yaml_file_path}' + ) + super().__init__(settings_cls, self.yaml_data) + + def _read_file(self, file_path: Path) -> dict[str, Any]: + import_yaml() + with open(file_path, encoding=self.yaml_file_encoding) as yaml_file: + return yaml.safe_load(yaml_file) or {} + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(yaml_file={self.yaml_file_path})' + + +__all__ = ['YamlConfigSettingsSource'] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/types.py b/venv/Lib/site-packages/pydantic_settings/sources/types.py new file mode 100644 index 000000000..2c00d0e2a --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/types.py @@ -0,0 +1,78 @@ +"""Type definitions for pydantic-settings sources.""" + +from __future__ import annotations as _annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar, Union + +if TYPE_CHECKING: + from pydantic._internal._dataclasses import PydanticDataclass + from pydantic.main import BaseModel + + PydanticModel = TypeVar('PydanticModel', bound=Union[PydanticDataclass, BaseModel]) +else: + PydanticModel = Any + + +class EnvNoneType(str): + pass + + +class NoDecode: + """Annotation to prevent decoding of a field value.""" + + pass + + +class ForceDecode: + """Annotation to force decoding of a field value.""" + + pass + + +DotenvType = Union[Path, str, Sequence[Union[Path, str]]] +PathType = Union[Path, str, Sequence[Union[Path, str]]] +DEFAULT_PATH: PathType = Path('') + +# This is used as default value for `_env_file` in the `BaseSettings` class and +# `env_file` in `DotEnvSettingsSource` so the default can be distinguished from `None`. +# See the docstring of `BaseSettings` for more details. +ENV_FILE_SENTINEL: DotenvType = Path('') + + +class _CliSubCommand: + pass + + +class _CliPositionalArg: + pass + + +class _CliImplicitFlag: + pass + + +class _CliExplicitFlag: + pass + + +class _CliUnknownArgs: + pass + + +__all__ = [ + 'DEFAULT_PATH', + 'ENV_FILE_SENTINEL', + 'DotenvType', + 'EnvNoneType', + 'ForceDecode', + 'NoDecode', + 'PathType', + 'PydanticModel', + '_CliExplicitFlag', + '_CliImplicitFlag', + '_CliPositionalArg', + '_CliSubCommand', + '_CliUnknownArgs', +] diff --git a/venv/Lib/site-packages/pydantic_settings/sources/utils.py b/venv/Lib/site-packages/pydantic_settings/sources/utils.py new file mode 100644 index 000000000..270a8c170 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/sources/utils.py @@ -0,0 +1,198 @@ +"""Utility functions for pydantic-settings sources.""" + +from __future__ import annotations as _annotations + +from collections import deque +from collections.abc import Mapping, Sequence +from dataclasses import is_dataclass +from enum import Enum +from typing import Any, Optional, cast + +from pydantic import BaseModel, Json, RootModel, Secret +from pydantic._internal._utils import is_model_class +from pydantic.dataclasses import is_pydantic_dataclass +from typing_extensions import get_args, get_origin +from typing_inspection import typing_objects + +from ..exceptions import SettingsError +from ..utils import _lenient_issubclass +from .types import EnvNoneType + + +def _get_env_var_key(key: str, case_sensitive: bool = False) -> str: + return key if case_sensitive else key.lower() + + +def _parse_env_none_str(value: str | None, parse_none_str: str | None = None) -> str | None | EnvNoneType: + return value if not (value == parse_none_str and parse_none_str is not None) else EnvNoneType(value) + + +def parse_env_vars( + env_vars: Mapping[str, str | None], + case_sensitive: bool = False, + ignore_empty: bool = False, + parse_none_str: str | None = None, +) -> Mapping[str, str | None]: + return { + _get_env_var_key(k, case_sensitive): _parse_env_none_str(v, parse_none_str) + for k, v in env_vars.items() + if not (ignore_empty and v == '') + } + + +def _annotation_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> bool: + # If the model is a root model, the root annotation should be used to + # evaluate the complexity. + if annotation is not None and _lenient_issubclass(annotation, RootModel) and annotation is not RootModel: + annotation = cast('type[RootModel[Any]]', annotation) + root_annotation = annotation.model_fields['root'].annotation + if root_annotation is not None: # pragma: no branch + annotation = root_annotation + + if any(isinstance(md, Json) for md in metadata): # type: ignore[misc] + return False + + origin = get_origin(annotation) + + # Check if annotation is of the form Annotated[type, metadata]. + if typing_objects.is_annotated(origin): + # Return result of recursive call on inner type. + inner, *meta = get_args(annotation) + return _annotation_is_complex(inner, meta) + + if origin is Secret: + return False + + return ( + _annotation_is_complex_inner(annotation) + or _annotation_is_complex_inner(origin) + or hasattr(origin, '__pydantic_core_schema__') + or hasattr(origin, '__get_pydantic_core_schema__') + ) + + +def _annotation_is_complex_inner(annotation: type[Any] | None) -> bool: + if _lenient_issubclass(annotation, (str, bytes)): + return False + + return _lenient_issubclass( + annotation, (BaseModel, Mapping, Sequence, tuple, set, frozenset, deque) + ) or is_dataclass(annotation) + + +def _union_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> bool: + """Check if a union type contains any complex types.""" + return any(_annotation_is_complex(arg, metadata) for arg in get_args(annotation)) + + +def _annotation_contains_types( + annotation: type[Any] | None, + types: tuple[Any, ...], + is_include_origin: bool = True, + is_strip_annotated: bool = False, +) -> bool: + """Check if a type annotation contains any of the specified types.""" + if is_strip_annotated: + annotation = _strip_annotated(annotation) + if is_include_origin is True and get_origin(annotation) in types: + return True + for type_ in get_args(annotation): + if _annotation_contains_types(type_, types, is_include_origin=True, is_strip_annotated=is_strip_annotated): + return True + return annotation in types + + +def _strip_annotated(annotation: Any) -> Any: + if typing_objects.is_annotated(get_origin(annotation)): + return annotation.__origin__ + else: + return annotation + + +def _annotation_enum_val_to_name(annotation: type[Any] | None, value: Any) -> Optional[str]: + for type_ in (annotation, get_origin(annotation), *get_args(annotation)): + if _lenient_issubclass(type_, Enum): + if value in tuple(val.value for val in type_): + return type_(value).name + return None + + +def _annotation_enum_name_to_val(annotation: type[Any] | None, name: Any) -> Any: + for type_ in (annotation, get_origin(annotation), *get_args(annotation)): + if _lenient_issubclass(type_, Enum): + if name in tuple(val.name for val in type_): + return type_[name] + return None + + +def _get_model_fields(model_cls: type[Any]) -> dict[str, Any]: + """Get fields from a pydantic model or dataclass.""" + + if is_pydantic_dataclass(model_cls) and hasattr(model_cls, '__pydantic_fields__'): + return model_cls.__pydantic_fields__ + if is_model_class(model_cls): + return model_cls.model_fields + raise SettingsError(f'Error: {model_cls.__name__} is not subclass of BaseModel or pydantic.dataclasses.dataclass') + + +def _get_alias_names( + field_name: str, field_info: Any, alias_path_args: dict[str, str] = {}, case_sensitive: bool = True +) -> tuple[tuple[str, ...], bool]: + """Get alias names for a field, handling alias paths and case sensitivity.""" + from pydantic import AliasChoices, AliasPath + + alias_names: list[str] = [] + is_alias_path_only: bool = True + if not any((field_info.alias, field_info.validation_alias)): + alias_names += [field_name] + is_alias_path_only = False + else: + new_alias_paths: list[AliasPath] = [] + for alias in (field_info.alias, field_info.validation_alias): + if alias is None: + continue + elif isinstance(alias, str): + alias_names.append(alias) + is_alias_path_only = False + elif isinstance(alias, AliasChoices): + for name in alias.choices: + if isinstance(name, str): + alias_names.append(name) + is_alias_path_only = False + else: + new_alias_paths.append(name) + else: + new_alias_paths.append(alias) + for alias_path in new_alias_paths: + name = cast(str, alias_path.path[0]) + name = name.lower() if not case_sensitive else name + alias_path_args[name] = 'dict' if len(alias_path.path) > 2 else 'list' + if not alias_names and is_alias_path_only: + alias_names.append(name) + if not case_sensitive: + alias_names = [alias_name.lower() for alias_name in alias_names] + return tuple(dict.fromkeys(alias_names)), is_alias_path_only + + +def _is_function(obj: Any) -> bool: + """Check if an object is a function.""" + from types import BuiltinFunctionType, FunctionType + + return isinstance(obj, (FunctionType, BuiltinFunctionType)) + + +__all__ = [ + '_annotation_contains_types', + '_annotation_enum_name_to_val', + '_annotation_enum_val_to_name', + '_annotation_is_complex', + '_annotation_is_complex_inner', + '_get_alias_names', + '_get_env_var_key', + '_get_model_fields', + '_is_function', + '_parse_env_none_str', + '_strip_annotated', + '_union_is_complex', + 'parse_env_vars', +] diff --git a/venv/Lib/site-packages/pydantic_settings/utils.py b/venv/Lib/site-packages/pydantic_settings/utils.py new file mode 100644 index 000000000..74c99be6c --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/utils.py @@ -0,0 +1,48 @@ +import sys +import types +from pathlib import Path +from typing import Any, _GenericAlias # type: ignore [attr-defined] + +from typing_extensions import get_origin + +_PATH_TYPE_LABELS = { + Path.is_dir: 'directory', + Path.is_file: 'file', + Path.is_mount: 'mount point', + Path.is_symlink: 'symlink', + Path.is_block_device: 'block device', + Path.is_char_device: 'char device', + Path.is_fifo: 'FIFO', + Path.is_socket: 'socket', +} + + +def path_type_label(p: Path) -> str: + """ + Find out what sort of thing a path is. + """ + assert p.exists(), 'path does not exist' + for method, name in _PATH_TYPE_LABELS.items(): + if method(p): + return name + + return 'unknown' # pragma: no cover + + +# TODO remove and replace usage by `isinstance(cls, type) and issubclass(cls, class_or_tuple)` +# once we drop support for Python 3.10. +def _lenient_issubclass(cls: Any, class_or_tuple: Any) -> bool: # pragma: no cover + try: + return isinstance(cls, type) and issubclass(cls, class_or_tuple) + except TypeError: + if get_origin(cls) is not None: + # Up until Python 3.10, isinstance(, type) is True + # (e.g. list[int]) + return False + raise + + +if sys.version_info < (3, 10): + _WithArgsTypes = tuple() +else: + _WithArgsTypes = (_GenericAlias, types.GenericAlias, types.UnionType) diff --git a/venv/Lib/site-packages/pydantic_settings/version.py b/venv/Lib/site-packages/pydantic_settings/version.py new file mode 100644 index 000000000..79585b421 --- /dev/null +++ b/venv/Lib/site-packages/pydantic_settings/version.py @@ -0,0 +1 @@ +VERSION = '2.10.1'