mirror of
https://github.com/khoaliber/LetterFeed.git
synced 2026-03-02 13:18:27 +00:00
feat: authentication
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""add auth to settings
|
||||
|
||||
Revision ID: ce35472309a4
|
||||
Revises: fb190ac6937f
|
||||
Create Date: 2025-07-17 22:45:31.442679
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ce35472309a4'
|
||||
down_revision: Union[str, Sequence[str], None] = 'fb190ac6937f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('settings', sa.Column('auth_username', sa.String(), nullable=True))
|
||||
op.add_column('settings', sa.Column('auth_password_hash', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('settings', 'auth_password_hash')
|
||||
op.drop_column('settings', 'auth_username')
|
||||
# ### end Alembic commands ###
|
||||
108
backend/app/core/auth.py
Normal file
108
backend/app/core/auth.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings as env_settings
|
||||
from app.core.database import get_db
|
||||
from app.core.hashing import get_password_hash
|
||||
from app.models.settings import Settings as SettingsModel
|
||||
from app.schemas.auth import TokenData
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_env_password_hash():
|
||||
"""Get and cache the password hash from environment variables."""
|
||||
if env_settings.auth_password:
|
||||
return get_password_hash(env_settings.auth_password)
|
||||
return None
|
||||
|
||||
|
||||
def _get_auth_credentials(db: Session) -> dict:
|
||||
"""Get auth credentials, prioritizing environment variables."""
|
||||
# Env vars take precedence
|
||||
if env_settings.auth_username and env_settings.auth_password:
|
||||
return {
|
||||
"username": env_settings.auth_username,
|
||||
"password_hash": _get_env_password_hash(),
|
||||
}
|
||||
|
||||
# Then check DB
|
||||
db_settings = db.query(SettingsModel).first()
|
||||
if db_settings and db_settings.auth_username and db_settings.auth_password_hash:
|
||||
return {
|
||||
"username": db_settings.auth_username,
|
||||
"password_hash": db_settings.auth_password_hash,
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||
"""Create a new access token."""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, env_settings.secret_key, algorithm=env_settings.algorithm
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def protected_route(
|
||||
token: str | None = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Dependency to protect routes with JWTs."""
|
||||
auth_creds = _get_auth_credentials(db)
|
||||
|
||||
# If no auth credentials are set up, access is allowed.
|
||||
if not auth_creds.get("username") or not auth_creds.get("password_hash"):
|
||||
return
|
||||
|
||||
if token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, env_settings.secret_key, algorithms=[env_settings.algorithm]
|
||||
)
|
||||
username: str | None = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
# Check if the username from the token matches the configured username
|
||||
correct_username = secrets.compare_digest(
|
||||
token_data.username, auth_creds["username"]
|
||||
)
|
||||
if not correct_username:
|
||||
raise credentials_exception
|
||||
|
||||
return token_data.username
|
||||
|
||||
|
||||
def is_auth_enabled(db: Session = Depends(get_db)):
|
||||
"""Dependency to check if auth is enabled."""
|
||||
auth_creds = _get_auth_credentials(db)
|
||||
return bool(auth_creds.get("username"))
|
||||
@@ -27,6 +27,13 @@ class Settings(BaseSettings):
|
||||
mark_as_read: bool = False
|
||||
email_check_interval: int = 15
|
||||
auto_add_new_senders: bool = False
|
||||
auth_username: str | None = None
|
||||
auth_password: str | None = None
|
||||
secret_key: str = Field(
|
||||
..., validation_alias=AliasChoices("SECRET_KEY", "LETTERFEED_SECRET_KEY")
|
||||
)
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
13
backend/app/core/hashing.py
Normal file
13
backend/app/core/hashing.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a hashed one."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
@@ -86,16 +86,25 @@ def update_newsletter(
|
||||
db_newsletter.move_to_folder = newsletter_update.move_to_folder
|
||||
db_newsletter.extract_content = newsletter_update.extract_content
|
||||
|
||||
# Simple approach: delete existing senders and add new ones
|
||||
# More efficient sender update
|
||||
existing_emails = {sender.email for sender in db_newsletter.senders}
|
||||
new_emails = set(newsletter_update.sender_emails)
|
||||
|
||||
# Remove senders that are no longer in the list
|
||||
for sender in db_newsletter.senders:
|
||||
db.delete(sender)
|
||||
db.commit()
|
||||
if sender.email not in new_emails:
|
||||
db.delete(sender)
|
||||
|
||||
for email in newsletter_update.sender_emails:
|
||||
db_sender = Sender(id=generate(), email=email, newsletter_id=db_newsletter.id)
|
||||
db.add(db_sender)
|
||||
# Add new senders
|
||||
for email in new_emails:
|
||||
if email not in existing_emails:
|
||||
db_sender = Sender(
|
||||
id=generate(), email=email, newsletter_id=db_newsletter.id
|
||||
)
|
||||
db.add(db_sender)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_newsletter)
|
||||
|
||||
logger.info(f"Successfully updated newsletter with id={db_newsletter.id}")
|
||||
return get_newsletter(db, newsletter_id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings as env_settings
|
||||
from app.core.hashing import get_password_hash
|
||||
from app.core.logging import get_logger
|
||||
from app.models.settings import Settings as SettingsModel
|
||||
from app.schemas.settings import Settings as SettingsSchema
|
||||
@@ -9,11 +10,10 @@ from app.schemas.settings import SettingsCreate
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_settings(db: Session, with_password: bool = False) -> SettingsSchema:
|
||||
"""Retrieve application settings, prioritizing environment variables over database."""
|
||||
logger.debug("Querying for settings")
|
||||
def create_initial_settings(db: Session):
|
||||
"""Create initial settings in the database if they don't exist."""
|
||||
logger.debug("Checking for initial settings.")
|
||||
db_settings = db.query(SettingsModel).first()
|
||||
|
||||
if not db_settings:
|
||||
logger.info(
|
||||
"No settings found in the database, creating new default settings from environment variables."
|
||||
@@ -25,12 +25,29 @@ def get_settings(db: Session, with_password: bool = False) -> SettingsSchema:
|
||||
k: v for k, v in env_settings.model_dump().items() if k in model_fields
|
||||
}
|
||||
|
||||
if env_settings.auth_password:
|
||||
env_data_for_db["auth_password_hash"] = get_password_hash(
|
||||
env_settings.auth_password
|
||||
)
|
||||
if "auth_password" in env_data_for_db:
|
||||
del env_data_for_db["auth_password"]
|
||||
|
||||
db_settings = SettingsModel(**env_data_for_db)
|
||||
db.add(db_settings)
|
||||
db.commit()
|
||||
db.refresh(db_settings)
|
||||
logger.info("Default settings created from environment variables.")
|
||||
|
||||
|
||||
def get_settings(db: Session, with_password: bool = False) -> SettingsSchema:
|
||||
"""Retrieve application settings, prioritizing environment variables over database."""
|
||||
logger.debug("Querying for settings")
|
||||
db_settings = db.query(SettingsModel).first()
|
||||
|
||||
if not db_settings:
|
||||
# This should not happen if create_initial_settings is called at startup.
|
||||
raise RuntimeError("Settings not initialized.")
|
||||
|
||||
# Build dictionary from DB model attributes, handling possible None values
|
||||
db_data = {
|
||||
"id": db_settings.id,
|
||||
@@ -41,6 +58,7 @@ def get_settings(db: Session, with_password: bool = False) -> SettingsSchema:
|
||||
"mark_as_read": db_settings.mark_as_read,
|
||||
"email_check_interval": db_settings.email_check_interval,
|
||||
"auto_add_new_senders": db_settings.auto_add_new_senders,
|
||||
"auth_username": db_settings.auth_username,
|
||||
}
|
||||
|
||||
# Get all environment settings that were explicitly set.
|
||||
@@ -80,14 +98,22 @@ def create_or_update_settings(db: Session, settings: SettingsCreate):
|
||||
db_settings = SettingsModel()
|
||||
db.add(db_settings)
|
||||
|
||||
update_data = settings.model_dump()
|
||||
update_data = settings.model_dump(exclude_unset=True)
|
||||
|
||||
# Do not update fields that are set by environment variables
|
||||
locked_fields = list(env_settings.model_dump(exclude_unset=True).keys())
|
||||
logger.debug(f"Fields locked by environment variables: {locked_fields}")
|
||||
|
||||
for key, value in update_data.items():
|
||||
if key not in locked_fields:
|
||||
if key in locked_fields:
|
||||
continue
|
||||
|
||||
if key == "auth_password":
|
||||
if value:
|
||||
db_settings.auth_password_hash = get_password_hash(value)
|
||||
else:
|
||||
db_settings.auth_password_hash = None
|
||||
elif hasattr(db_settings, key):
|
||||
setattr(db_settings, key, value)
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.database import Base, engine
|
||||
from app.core.auth import protected_route
|
||||
from app.core.database import Base, SessionLocal, engine
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.core.scheduler import scheduler, start_scheduler_with_interval
|
||||
from app.routers import feeds, health, imap, newsletters
|
||||
from app.crud.settings import create_initial_settings
|
||||
from app.routers import auth, feeds, health, imap, newsletters
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -19,6 +21,10 @@ async def lifespan(app: FastAPI):
|
||||
logger.info(f"DATABASE_URL used: {settings.database_url}")
|
||||
logger.info("Starting up Letterfeed backend...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
with SessionLocal() as db:
|
||||
create_initial_settings(db)
|
||||
|
||||
start_scheduler_with_interval()
|
||||
yield
|
||||
if scheduler.running:
|
||||
@@ -43,6 +49,7 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(imap.router)
|
||||
app.include_router(newsletters.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(imap.router, dependencies=[Depends(protected_route)])
|
||||
app.include_router(newsletters.router, dependencies=[Depends(protected_route)])
|
||||
app.include_router(feeds.router)
|
||||
|
||||
@@ -17,3 +17,5 @@ class Settings(Base):
|
||||
mark_as_read = Column(Boolean, default=False)
|
||||
email_check_interval = Column(Integer, default=15) # Interval in minutes
|
||||
auto_add_new_senders = Column(Boolean, default=False)
|
||||
auth_username = Column(String, nullable=True)
|
||||
auth_password_hash = Column(String, nullable=True)
|
||||
|
||||
55
backend/app/routers/auth.py
Normal file
55
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import (
|
||||
_get_auth_credentials,
|
||||
create_access_token,
|
||||
is_auth_enabled,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.hashing import verify_password
|
||||
from app.schemas.auth import Token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/auth/status")
|
||||
def auth_status(auth_enabled: bool = Depends(is_auth_enabled)):
|
||||
"""Check if authentication is enabled."""
|
||||
return {"auth_enabled": auth_enabled}
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=Token)
|
||||
def login_for_access_token(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Verify username and password and return an access token."""
|
||||
auth_creds = _get_auth_credentials(db)
|
||||
if not auth_creds.get("username") or not auth_creds.get("password_hash"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication is not configured on the server",
|
||||
)
|
||||
|
||||
correct_username = secrets.compare_digest(
|
||||
form_data.username, auth_creds["username"]
|
||||
)
|
||||
correct_password = verify_password(form_data.password, auth_creds["password_hash"])
|
||||
|
||||
if not (correct_username and correct_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||
access_token = create_access_token(
|
||||
data={"sub": form_data.username}, expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
14
backend/app/schemas/auth.py
Normal file
14
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema for the access token."""
|
||||
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Schema for the data encoded in the JWT."""
|
||||
|
||||
username: str | None = None
|
||||
@@ -13,12 +13,14 @@ class SettingsBase(BaseModel):
|
||||
mark_as_read: bool = False
|
||||
email_check_interval: int = 15
|
||||
auto_add_new_senders: bool = False
|
||||
auth_username: str | None = None
|
||||
|
||||
|
||||
class SettingsCreate(SettingsBase):
|
||||
"""Schema for creating or updating settings, including the IMAP password."""
|
||||
|
||||
imap_password: str
|
||||
auth_password: str | None = None
|
||||
|
||||
|
||||
class Settings(SettingsBase):
|
||||
@@ -26,6 +28,7 @@ class Settings(SettingsBase):
|
||||
|
||||
id: int
|
||||
imap_password: str | None = Field(None, exclude=True)
|
||||
auth_password: str | None = Field(None, exclude=True)
|
||||
locked_fields: List[str] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
|
||||
os.environ["LETTERFEED_DATABASE_URL"] = "sqlite:///./test.db"
|
||||
os.environ["LETTERFEED_SECRET_KEY"] = "testsecret"
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
170
backend/app/tests/test_auth.py
Normal file
170
backend/app/tests/test_auth.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.settings import create_or_update_settings
|
||||
from app.schemas.settings import SettingsCreate
|
||||
|
||||
|
||||
def test_auth_status_disabled(client: TestClient):
|
||||
"""Test auth status when auth is disabled."""
|
||||
response = client.get("/auth/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"auth_enabled": False}
|
||||
|
||||
|
||||
def test_auth_status_enabled(client: TestClient, db_session: Session):
|
||||
"""Test auth status when auth is enabled."""
|
||||
settings_data = SettingsCreate(
|
||||
imap_server="test.com",
|
||||
imap_username="test",
|
||||
imap_password="password",
|
||||
auth_username="admin",
|
||||
auth_password="password",
|
||||
)
|
||||
create_or_update_settings(db_session, settings_data)
|
||||
|
||||
response = client.get("/auth/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"auth_enabled": True}
|
||||
|
||||
|
||||
def test_login_endpoint(client: TestClient, db_session: Session):
|
||||
"""Test the /auth/login endpoint directly."""
|
||||
# Setup auth credentials in the database
|
||||
settings_data = SettingsCreate(
|
||||
imap_server="test.com",
|
||||
imap_username="test",
|
||||
imap_password="password",
|
||||
auth_username="admin",
|
||||
auth_password="password",
|
||||
)
|
||||
create_or_update_settings(db_session, settings_data)
|
||||
|
||||
# Test with correct credentials
|
||||
login_data = {"username": "admin", "password": "password"}
|
||||
response = client.post("/auth/login", data=login_data)
|
||||
assert response.status_code == 200
|
||||
json_response = response.json()
|
||||
assert "access_token" in json_response
|
||||
assert json_response["token_type"] == "bearer"
|
||||
|
||||
# Test with incorrect password
|
||||
login_data["password"] = "wrongpassword"
|
||||
response = client.post("/auth/login", data=login_data)
|
||||
assert response.status_code == 401
|
||||
|
||||
# Test with incorrect username
|
||||
login_data["username"] = "wronguser"
|
||||
login_data["password"] = "password"
|
||||
response = client.post("/auth/login", data=login_data)
|
||||
assert response.status_code == 401
|
||||
|
||||
# Test with no credentials
|
||||
response = client.post("/auth/login")
|
||||
assert response.status_code == 422 # FastAPI validation error for missing form data
|
||||
|
||||
|
||||
def test_protected_route_no_auth(client: TestClient, db_session: Session):
|
||||
"""Test accessing a protected route without auth enabled."""
|
||||
# Health is not protected, newsletters is.
|
||||
response = client.get("/newsletters")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_protected_route_with_auth_fail(client: TestClient, db_session: Session):
|
||||
"""Test accessing a protected route with auth enabled but wrong credentials."""
|
||||
settings_data = SettingsCreate(
|
||||
imap_server="test.com",
|
||||
imap_username="test",
|
||||
imap_password="password",
|
||||
auth_username="admin",
|
||||
auth_password="password",
|
||||
)
|
||||
create_or_update_settings(db_session, settings_data)
|
||||
|
||||
response = client.get("/newsletters")
|
||||
assert response.status_code == 401
|
||||
|
||||
response = client.get(
|
||||
"/newsletters", headers={"Authorization": "Bearer wrongtoken"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_protected_route_with_auth_success(client: TestClient, db_session: Session):
|
||||
"""Test accessing a protected route with auth enabled and correct credentials."""
|
||||
settings_data = SettingsCreate(
|
||||
imap_server="test.com",
|
||||
imap_username="test",
|
||||
imap_password="password",
|
||||
auth_username="admin",
|
||||
auth_password="password",
|
||||
)
|
||||
create_or_update_settings(db_session, settings_data)
|
||||
|
||||
# First, log in to get a token
|
||||
login_data = {"username": "admin", "password": "password"}
|
||||
response = client.post("/auth/login", data=login_data)
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Then, use the token to access the protected route
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = client.get("/newsletters", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_unprotected_route_with_auth(client: TestClient, db_session: Session):
|
||||
"""Test that feed endpoint is not protected."""
|
||||
settings_data = SettingsCreate(
|
||||
imap_server="test.com",
|
||||
imap_username="test",
|
||||
imap_password="password",
|
||||
auth_username="admin",
|
||||
auth_password="password",
|
||||
)
|
||||
create_or_update_settings(db_session, settings_data)
|
||||
|
||||
# Log in to get a token
|
||||
login_data = {"username": "admin", "password": "password"}
|
||||
login_response = client.post("/auth/login", data=login_data)
|
||||
token = login_response.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Create a newsletter to get a feed from
|
||||
newsletter_data = {"name": "Test Newsletter", "sender_emails": ["test@test.com"]}
|
||||
create_response = client.post("/newsletters", json=newsletter_data, headers=headers)
|
||||
newsletter_id = create_response.json()["id"]
|
||||
|
||||
response = client.get(f"/feeds/{newsletter_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_auth_with_env_vars(client: TestClient):
|
||||
"""Test authentication using environment variables."""
|
||||
with patch("app.core.auth.env_settings") as mock_env_settings:
|
||||
mock_env_settings.auth_username = "env_admin"
|
||||
mock_env_settings.auth_password = "env_password"
|
||||
mock_env_settings.secret_key = "test-secret"
|
||||
mock_env_settings.algorithm = "HS256"
|
||||
mock_env_settings.access_token_expire_minutes = 30
|
||||
|
||||
# Log in to get a token
|
||||
login_data = {"username": "env_admin", "password": "env_password"}
|
||||
login_response = client.post("/auth/login", data=login_data)
|
||||
assert login_response.status_code == 200
|
||||
token = login_response.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = client.get("/newsletters", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get(
|
||||
"/newsletters", headers={"Authorization": "Bearer wrongtoken"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
response = client.get("/auth/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"auth_enabled": True}
|
||||
@@ -167,8 +167,12 @@ def test_process_emails_auto_add_sender(mock_imap, db_session: Session):
|
||||
|
||||
@patch("app.services.email_processor.imaplib.IMAP4_SSL")
|
||||
def test_process_emails_no_settings(mock_imap, db_session: Session):
|
||||
"""Test processing emails with no settings in the database."""
|
||||
# No settings in the DB
|
||||
"""Test processing emails with no settings configured."""
|
||||
# This test ensures that email processing is skipped if settings are not configured.
|
||||
# In the new flow, initial settings are created at startup, so we call it here.
|
||||
from app.crud.settings import create_initial_settings
|
||||
|
||||
create_initial_settings(db_session)
|
||||
process_emails(db_session)
|
||||
mock_imap.assert_not_called()
|
||||
|
||||
|
||||
@@ -20,10 +20,19 @@ def test_create_or_update_settings(db_session: Session):
|
||||
search_folder="INBOX",
|
||||
move_to_folder="Archive",
|
||||
mark_as_read=True,
|
||||
auth_username="user",
|
||||
auth_password="password",
|
||||
)
|
||||
settings = create_or_update_settings(db_session, settings_data)
|
||||
assert settings.imap_server == "imap.test.com"
|
||||
assert settings.mark_as_read
|
||||
assert settings.auth_username == "user"
|
||||
|
||||
# check password hash
|
||||
from app.models.settings import Settings as SettingsModel
|
||||
|
||||
db_settings = db_session.query(SettingsModel).first()
|
||||
assert db_settings.auth_password_hash is not None
|
||||
|
||||
updated_settings_data = SettingsCreate(
|
||||
imap_server="imap.updated.com",
|
||||
@@ -32,11 +41,13 @@ def test_create_or_update_settings(db_session: Session):
|
||||
search_folder="Inbox",
|
||||
move_to_folder=None,
|
||||
mark_as_read=False,
|
||||
auth_username="new_user",
|
||||
)
|
||||
updated_settings = create_or_update_settings(db_session, updated_settings_data)
|
||||
assert updated_settings.imap_server == "imap.updated.com"
|
||||
assert not updated_settings.mark_as_read
|
||||
assert updated_settings.move_to_folder is None
|
||||
assert updated_settings.auth_username == "new_user"
|
||||
|
||||
|
||||
def test_get_settings(db_session: Session):
|
||||
@@ -62,6 +73,8 @@ def test_get_settings_with_env_override(db_session: Session):
|
||||
imap_username="db_user",
|
||||
imap_password="db_pass",
|
||||
email_check_interval=15,
|
||||
auth_username="db_user",
|
||||
auth_password="db_password",
|
||||
)
|
||||
create_or_update_settings(db_session, db_settings_data)
|
||||
|
||||
@@ -72,8 +85,11 @@ def test_get_settings_with_env_override(db_session: Session):
|
||||
"imap_username": "env_user",
|
||||
"imap_password": "env_pass",
|
||||
"email_check_interval": 30,
|
||||
"auth_username": "env_auth_user",
|
||||
"auth_password": "env_auth_password",
|
||||
}
|
||||
mock_env_settings.imap_password = "env_pass"
|
||||
mock_env_settings.auth_password = "env_auth_password"
|
||||
|
||||
# 3. Call get_settings and assert the override
|
||||
settings = get_settings(db_session, with_password=True)
|
||||
@@ -81,8 +97,10 @@ def test_get_settings_with_env_override(db_session: Session):
|
||||
assert settings.imap_username == "env_user"
|
||||
assert settings.imap_password == "env_pass"
|
||||
assert settings.email_check_interval == 30
|
||||
assert settings.auth_username == "env_auth_user"
|
||||
assert "imap_server" in settings.locked_fields
|
||||
assert "imap_username" in settings.locked_fields
|
||||
assert "auth_username" in settings.locked_fields
|
||||
|
||||
# 4. Call create_or_update_settings and assert that locked fields are not updated
|
||||
update_data = SettingsCreate(
|
||||
@@ -90,11 +108,14 @@ def test_get_settings_with_env_override(db_session: Session):
|
||||
imap_username="new_user",
|
||||
imap_password="new_pass",
|
||||
email_check_interval=45,
|
||||
auth_username="new_auth_user",
|
||||
auth_password="new_auth_password",
|
||||
)
|
||||
updated_settings = create_or_update_settings(db_session, update_data)
|
||||
assert updated_settings.imap_server == "env.imap.com" # Should not change
|
||||
assert updated_settings.imap_username == "env_user" # Should not change
|
||||
assert updated_settings.email_check_interval == 30 # Should not change
|
||||
assert updated_settings.auth_username == "env_auth_user" # Should not change
|
||||
|
||||
|
||||
def test_create_newsletter(db_session: Session):
|
||||
|
||||
@@ -2,6 +2,10 @@ import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.settings import create_or_update_settings
|
||||
from app.schemas.settings import SettingsCreate
|
||||
|
||||
|
||||
def test_health_check(client: TestClient):
|
||||
@@ -11,12 +15,8 @@ def test_health_check(client: TestClient):
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@patch("app.core.imap.imaplib.IMAP4_SSL")
|
||||
def test_update_imap_settings(mock_imap, client: TestClient):
|
||||
def test_update_imap_settings(client: TestClient):
|
||||
"""Test updating IMAP settings."""
|
||||
mock_imap.return_value.login.return_value = (None, None)
|
||||
mock_imap.return_value.logout.return_value = (None, None)
|
||||
|
||||
settings_data = {
|
||||
"imap_server": "imap.example.com",
|
||||
"imap_username": "test@example.com",
|
||||
@@ -34,12 +34,8 @@ def test_update_imap_settings(mock_imap, client: TestClient):
|
||||
assert response.json()["mark_as_read"]
|
||||
|
||||
|
||||
@patch("app.core.imap.imaplib.IMAP4_SSL")
|
||||
def test_get_imap_settings(mock_imap, client: TestClient):
|
||||
def test_get_imap_settings(client: TestClient):
|
||||
"""Test getting IMAP settings."""
|
||||
mock_imap.return_value.login.return_value = (None, None)
|
||||
mock_imap.return_value.logout.return_value = (None, None)
|
||||
|
||||
settings_data = {
|
||||
"imap_server": "imap.example.com",
|
||||
"imap_username": "test@example.com",
|
||||
@@ -57,20 +53,20 @@ def test_get_imap_settings(mock_imap, client: TestClient):
|
||||
|
||||
|
||||
@patch("app.core.imap.imaplib.IMAP4_SSL")
|
||||
def test_test_imap_connection(mock_imap, client: TestClient):
|
||||
def test_test_imap_connection(mock_imap, client: TestClient, db_session: Session):
|
||||
"""Test the IMAP connection."""
|
||||
mock_imap.return_value.login.return_value = (None, None)
|
||||
mock_imap.return_value.logout.return_value = (None, None)
|
||||
|
||||
settings_data = {
|
||||
"imap_server": "imap.example.com",
|
||||
"imap_username": "test@example.com",
|
||||
"imap_password": "password",
|
||||
"search_folder": "INBOX",
|
||||
"move_to_folder": "Processed",
|
||||
"mark_as_read": True,
|
||||
}
|
||||
client.post("/imap/settings", json=settings_data)
|
||||
settings_data = SettingsCreate(
|
||||
imap_server="imap.example.com",
|
||||
imap_username="test@example.com",
|
||||
imap_password="password",
|
||||
search_folder="INBOX",
|
||||
move_to_folder="Processed",
|
||||
mark_as_read=True,
|
||||
)
|
||||
create_or_update_settings(db_session, settings_data)
|
||||
|
||||
response = client.post("/imap/test")
|
||||
assert response.status_code == 200
|
||||
@@ -78,7 +74,7 @@ def test_test_imap_connection(mock_imap, client: TestClient):
|
||||
|
||||
|
||||
@patch("app.core.imap.imaplib.IMAP4_SSL")
|
||||
def test_get_imap_folders(mock_imap, client: TestClient):
|
||||
def test_get_imap_folders(mock_imap, client: TestClient, db_session: Session):
|
||||
"""Test getting IMAP folders."""
|
||||
mock_imap.return_value.login.return_value = (None, None)
|
||||
mock_imap.return_value.logout.return_value = (None, None)
|
||||
@@ -87,15 +83,15 @@ def test_get_imap_folders(mock_imap, client: TestClient):
|
||||
[b'(NOCONNECT NOSELECT) "/" "INBOX"', b'(NOCONNECT NOSELECT) "/" "Processed"'],
|
||||
)
|
||||
|
||||
settings_data = {
|
||||
"imap_server": "imap.example.com",
|
||||
"imap_username": "test@example.com",
|
||||
"imap_password": "password",
|
||||
"search_folder": "INBOX",
|
||||
"move_to_folder": "Processed",
|
||||
"mark_as_read": True,
|
||||
}
|
||||
client.post("/imap/settings", json=settings_data)
|
||||
settings_data = SettingsCreate(
|
||||
imap_server="imap.example.com",
|
||||
imap_username="test@example.com",
|
||||
imap_password="password",
|
||||
search_folder="INBOX",
|
||||
move_to_folder="Processed",
|
||||
mark_as_read=True,
|
||||
)
|
||||
create_or_update_settings(db_session, settings_data)
|
||||
|
||||
response = client.get("/imap/folders")
|
||||
assert response.status_code == 200
|
||||
@@ -141,7 +137,7 @@ def test_get_single_newsletter(client: TestClient):
|
||||
"""Test getting a single newsletter."""
|
||||
unique_email = f"newsletter_{uuid.uuid4()}@example.com"
|
||||
newsletter_data = {"name": "Third Newsletter", "sender_emails": [unique_email]}
|
||||
create_response = client.post("/newsletters/", json=newsletter_data)
|
||||
create_response = client.post("/newsletters", json=newsletter_data)
|
||||
newsletter_id = create_response.json()["id"]
|
||||
|
||||
response = client.get(f"/newsletters/{newsletter_id}")
|
||||
@@ -151,7 +147,7 @@ def test_get_single_newsletter(client: TestClient):
|
||||
|
||||
def test_get_nonexistent_newsletter(client: TestClient):
|
||||
"""Test getting a nonexistent newsletter."""
|
||||
response = client.get("/newsletters/999")
|
||||
response = client.get("/newsletters/nonexistent")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Newsletter not found"}
|
||||
|
||||
@@ -160,7 +156,7 @@ def test_get_newsletter_feed(client: TestClient):
|
||||
"""Test generating a newsletter feed."""
|
||||
unique_email = f"feed_test_{uuid.uuid4()}@example.com"
|
||||
newsletter_data = {"name": "Feed Test Newsletter", "sender_emails": [unique_email]}
|
||||
create_response = client.post("/newsletters/", json=newsletter_data)
|
||||
create_response = client.post("/newsletters", json=newsletter_data)
|
||||
newsletter_id = create_response.json()["id"]
|
||||
|
||||
# Add some entries to the newsletter
|
||||
@@ -195,6 +191,6 @@ def test_get_newsletter_feed(client: TestClient):
|
||||
|
||||
def test_get_newsletter_feed_nonexistent_newsletter(client: TestClient):
|
||||
"""Test generating a feed for a nonexistent newsletter."""
|
||||
response = client.get("/feeds/999")
|
||||
response = client.get("/feeds/nonexistent")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Newsletter not found"}
|
||||
|
||||
@@ -7,11 +7,15 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"alembic>=1.16.4",
|
||||
"apscheduler>=3.11.0",
|
||||
"bcrypt>=4.3.0",
|
||||
"fastapi>=0.116.0",
|
||||
"feedgen>=1.0.0",
|
||||
"nanoid>=2.0.0",
|
||||
"passlib>=1.7.4",
|
||||
"pydantic-settings>=2.10.1",
|
||||
"python-dotenv>=1.1.1",
|
||||
"python-jose[cryptography]>=3.5.0",
|
||||
"python-multipart>=0.0.20",
|
||||
"sqlalchemy>=2.0.41",
|
||||
"trafilatura>=1.10.0",
|
||||
"uvicorn>=0.35.0",
|
||||
|
||||
194
backend/uv.lock
generated
194
backend/uv.lock
generated
@@ -59,6 +59,56 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.7.14"
|
||||
@@ -68,6 +118,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "1.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.4.0"
|
||||
@@ -134,6 +206,41 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "45.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dateparser"
|
||||
version = "1.2.2"
|
||||
@@ -158,6 +265,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.116.1"
|
||||
@@ -314,11 +433,15 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "apscheduler" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "feedgen" },
|
||||
{ name = "nanoid" },
|
||||
{ name = "passlib" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "trafilatura" },
|
||||
{ name = "uvicorn" },
|
||||
@@ -336,11 +459,15 @@ test = [
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.16.4" },
|
||||
{ name = "apscheduler", specifier = ">=3.11.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.3.0" },
|
||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||
{ name = "feedgen", specifier = ">=1.0.0" },
|
||||
{ name = "nanoid", specifier = ">=2.0.0" },
|
||||
{ name = "passlib", specifier = ">=1.7.4" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.10.1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.1.1" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.41" },
|
||||
{ name = "trafilatura", specifier = ">=1.10.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.35.0" },
|
||||
@@ -463,6 +590,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "passlib"
|
||||
version = "1.7.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.8"
|
||||
@@ -497,6 +633,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.7"
|
||||
@@ -600,6 +754,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-jose"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ecdsa" },
|
||||
{ name = "pyasn1" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cryptography = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2025.2"
|
||||
@@ -649,6 +831,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545, upload-time = "2024-11-06T20:11:15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.12.3"
|
||||
|
||||
Reference in New Issue
Block a user