7 Commits
Author SHA1 Message Date
Esa Kataja d97a665a9c Prepare 1.0rc2 release 2025-05-10 14:33:35 +03:00
Esa Kataja 39da976ef3 Add more comprehensive logging 2025-05-10 14:28:34 +03:00
Esa Kataja aecc0731bc Prepare 1.0rc1 release 2025-05-10 14:14:35 +03:00
Esa Kataja 751af50e8a Add logger 2025-05-10 14:13:07 +03:00
Esa Kataja 005ac8634f Fix db file being overwritten at startup 2025-05-10 14:10:12 +03:00
Esa Kataja 055dd3b43b Fix admin seed had no password 2025-05-10 11:01:20 +03:00
Esa Kataja 4a4eda09d1 Move country code seeds to sql from db.py 2025-05-10 11:00:37 +03:00
15 changed files with 233 additions and 58 deletions
+2
View File
@@ -13,5 +13,7 @@ wheels/
*.sqlite *.sqlite
*.duckdb *.duckdb
*.db *.db
*.log
*.gz
.vscode/* .vscode/*
+17
View File
@@ -0,0 +1,17 @@
# Changelog
All notable changes to the Eurovision-25 backend will be documented in this file.
## [1.0rc1] - 2025-05-10
### First Release Candidate
This is the first release candidate for Eurovision-25 backend, featuring all core functionality:
- Authentication and user management
- Song management and reviews
- Results calculation and display
- Admin functionality
- Static file serving for avatars and country flags
The application is now feature complete, but may still contain bugs that will be addressed before the final 1.0.0 release.
+19
View File
@@ -0,0 +1,19 @@
# Eurovision 25 Backend Changelog
## 1.0rc2 (2025-05-10)
### Changes
- Updated application version to 1.0rc2
- Added more comprehensive logging throughout the application
## 1.0rc1 (Previous Release)
### Features
- Initial release candidate
- Eurovision 25 Homereview API implementation
- Authentication and authorization system
- User management features
- Song management
- Review system
- Results calculation
+4
View File
@@ -220,6 +220,10 @@ Automated tests are not implemented at this time. Manual testing via the API doc
## TODOs ## TODOs
* [ ] Replace password hashing with passlib * [ ] Replace password hashing with passlib
* [ ] Switch to PostgreSQL from DuckDB
* [x] Implement more comprehensive logging
* [ ] Implement user disabling functionality
* [ ] Implement user password change functionality
## Contributing ## Contributing
+3
View File
@@ -10,3 +10,6 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
environment:
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=password
+2 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "Eurovision-25-backend" name = "Eurovision-25-backend"
version = "0.1.0" version = "1.0rc2"
description = "Backend for Eurovision 25" description = "Backend for Eurovision 25"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -8,6 +8,7 @@ dependencies = [
"aiofiles>=24.1.0", "aiofiles>=24.1.0",
"duckdb>=1.2.2", "duckdb>=1.2.2",
"fastapi>=0.115.12", "fastapi>=0.115.12",
"loguru>=0.7.3",
"pandas>=2.2.3", "pandas>=2.2.3",
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"python-dotenv>=1.1.0", "python-dotenv>=1.1.0",
+4 -1
View File
@@ -5,13 +5,14 @@ from fastapi.staticfiles import StaticFiles
# Import routers # Import routers
from routes import auth, admin, users, songs, reviews, results from routes import auth, admin, users, songs, reviews, results
from lib.logger import logger
from lib.db import init_db from lib.db import init_db
# Initialize FastAPI app # Initialize FastAPI app
app = FastAPI( app = FastAPI(
title="Eurovision 25 Homereview API", title="Eurovision 25 Homereview API",
description="Backend API for Eurovision 25 Homereview application", description="Backend API for Eurovision 25 Homereview application",
version="1.0.0", version="1.0rc2",
) )
# Add CORS middleware # Add CORS middleware
@@ -32,12 +33,14 @@ app.mount("/static/flags", StaticFiles(directory="static/flags"), name="flags")
# Startup event to create database tables # Startup event to create database tables
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
logger.info("Starting up...")
init_db() init_db()
# Health check endpoint # Health check endpoint
@app.get("/") @app.get("/")
async def root(): async def root():
logger.debug("Root endpoint accessed")
return {"status": "ok", "message": "Eurovision 25 Homereview API"} return {"status": "ok", "message": "Eurovision 25 Homereview API"}
+42 -1
View File
@@ -111,8 +111,49 @@ CREATE TABLE Review (
CREATE TABLE CountryCodes ( CREATE TABLE CountryCodes (
code VARCHAR PRIMARY KEY, code VARCHAR PRIMARY KEY,
name_en VARCHAR NOT NULL, name_en VARCHAR NOT NULL,
name_fi VARCHAR NOT NULL name_fi VARCHAR NOT NULL,
name_sv VARCHAR NOT NULL
); );
-- Insert country codes data
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ALB', 'Albania', 'Albania', 'Albanien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ARM', 'Armenia', 'Armenia', 'Armenien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('AUS', 'Australia', 'Australia', 'Australien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('AUT', 'Austria', 'Itävalta', 'Österrike');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('AZE', 'Azerbaijan', 'Azerbaidžan', 'Azerbajdzjan');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('BEL', 'Belgium', 'Belgia', 'Belgien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('HRV', 'Croatia', 'Kroatia', 'Kroatien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('CYP', 'Cyprus', 'Kypros', 'Cypern');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('CZE', 'Czechia', 'Tšekki', 'Tjeckien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('DNK', 'Denmark', 'Tanska', 'Danmark');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('EST', 'Estonia', 'Viro', 'Estland');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('FIN', 'Finland', 'Suomi', 'Finland');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('FRA', 'France', 'Ranska', 'Frankrike');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('GEO', 'Georgia', 'Georgia', 'Georgien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('DEU', 'Germany', 'Saksa', 'Tyskland');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('GRC', 'Greece', 'Kreikka', 'Grekland');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ISL', 'Iceland', 'Islanti', 'Island');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('IRL', 'Ireland', 'Irlanti', 'Irland');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ISR', 'Israel', 'Israel', 'Israel');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ITA', 'Italy', 'Italia', 'Italien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('LVA', 'Latvia', 'Latvia', 'Lettland');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('LTU', 'Lithuania', 'Liettua', 'Litauen');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('LUX', 'Luxembourg', 'Luxemburg', 'Luxemburg');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('MLT', 'Malta', 'Malta', 'Malta');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('MNE', 'Montenegro', 'Montenegro', 'Montenegro');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('NLD', 'Netherlands', 'Alankomaat', 'Nederländerna');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('NOR', 'Norway', 'Norja', 'Norge');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('POL', 'Poland', 'Puola', 'Polen');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('PRT', 'Portugal', 'Portugali', 'Portugal');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('SMR', 'San Marino', 'San Marino', 'San Marino');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('SRB', 'Serbia', 'Serbia', 'Serbien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('SVN', 'Slovenia', 'Slovenia', 'Slovenien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ESP', 'Spain', 'Espanja', 'Spanien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('SWE', 'Sweden', 'Ruotsi', 'Sverige');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('CHE', 'Switzerland', 'Sveitsi', 'Schweiz');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('UKR', 'Ukraine', 'Ukraina', 'Ukraina');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('GBR', 'United Kingdom', 'Englanti', 'Storbritannien');
-- ============================================================================= -- =============================================================================
-- Views (Optional - Not Created Here Based on Documentation Decision) -- Views (Optional - Not Created Here Based on Documentation Decision)
-- ============================================================================= -- =============================================================================
+4 -23
View File
@@ -12,7 +12,6 @@ load_dotenv()
DB_PATH = Path("./data/data.duckdb").absolute() DB_PATH = Path("./data/data.duckdb").absolute()
SQL_PATH = Path("./lib/database.sql").absolute() SQL_PATH = Path("./lib/database.sql").absolute()
SEED_JSON_PATH = Path("./lib/seed_data.json").absolute() SEED_JSON_PATH = Path("./lib/seed_data.json").absolute()
COUNTRYCODES_JSON_PATH = Path("./lib/countrycodes.json").absolute()
@contextmanager @contextmanager
@@ -53,23 +52,7 @@ def seed_db() -> None:
), ),
) )
# Seed CountryCodes # CountryCodes are now directly inserted in the SQL file
with open(COUNTRYCODES_JSON_PATH, "r") as f:
data = json.load(f)
if not data:
return
for item in data:
with get_connection() as conn:
conn.execute(
"INSERT INTO CountryCodes (code, name_en, name_fi) VALUES (?, ?, ?)",
(
item["code"],
item["name_en"],
item["name_fi"],
),
)
# Seed Teams # Seed Teams
with get_connection() as conn: with get_connection() as conn:
@@ -77,7 +60,7 @@ def seed_db() -> None:
# Seed Admin users # Seed Admin users
admin_username = os.getenv("ADMIN_USERNAME") admin_username = os.getenv("ADMIN_USERNAME")
hashed_admin_password = hash_password(os.getenv("ADMIN_PASSWORD")) hashed_admin_password = hash_password(os.getenv("ADMIN_PASSWORD", "password"))
with get_connection() as conn: with get_connection() as conn:
conn.execute( conn.execute(
'INSERT INTO "User" (username, hashed_password, team_id, is_active, is_admin) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO "User" (username, hashed_password, team_id, is_active, is_admin) VALUES (?, ?, ?, ?, ?)',
@@ -92,12 +75,10 @@ def seed_db() -> None:
def init_db(): def init_db():
if Path(DB_PATH).exists():
return
with open(SQL_PATH, "r") as f: with open(SQL_PATH, "r") as f:
sql = f.read() sql = f.read()
with get_connection() as conn: with get_connection() as conn:
conn.execute(sql) conn.execute(sql)
seed_db() seed_db()
if __name__ == "__main__":
init_db()
+16
View File
@@ -0,0 +1,16 @@
from loguru import logger
from pathlib import Path
import sys
LOG_PATH = Path("./data/logs/logs.log").absolute()
ERROR_PATH = Path("./data/logs/errors.log").absolute()
logger.remove()
logger.add(LOG_PATH, rotation="10 MB", compression="gz", level="INFO")
logger.add(sys.stdout, level="INFO")
logger.add(ERROR_PATH, rotation="10 MB", compression="gz", level="ERROR")
logger.add(sys.stderr, level="ERROR")
logger.add(sys.stdout, level="DEBUG")
logger = logger
+44 -6
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body, HTTPException, Request
from models.user import CreateUser from models.user import CreateUser
from models.team import TeamBase, Team from models.team import TeamBase, Team
from lib.db import get_connection from lib.db import get_connection
from lib.helpers import hash_password from lib.helpers import hash_password
from lib.logger import logger
router = APIRouter(prefix="/admin", tags=["Admin"]) router = APIRouter(prefix="/admin", tags=["Admin"])
@@ -19,22 +20,46 @@ router = APIRouter(prefix="/admin", tags=["Admin"])
@router.post("/teams") @router.post("/teams")
async def create_team(team: TeamBase): async def create_team(team: TeamBase, request: Request):
logger.info(
f"Admin action: Creating new team '{team.name}' from IP: {request.client.host}"
)
try:
with get_connection() as conn: with get_connection() as conn:
conn.execute("INSERT INTO Team (name) VALUES (?)", (team.name,)) conn.execute("INSERT INTO Team (name) VALUES (?)", (team.name,))
logger.info(f"Team '{team.name}' created successfully")
return {"message": "Team created successfully"} return {"message": "Team created successfully"}
except Exception as e:
logger.error(f"Failed to create team '{team.name}': {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create team")
@router.get("/teams", response_model=list[Team]) @router.get("/teams", response_model=list[Team])
async def list_teams(): async def list_teams(request: Request):
logger.debug(f"Admin action: Listing all teams from IP: {request.client.host}")
with get_connection() as conn: with get_connection() as conn:
teams = conn.execute("SELECT * FROM Team").fetchdf() teams = conn.execute("SELECT * FROM Team").fetchdf()
return teams.to_dict(orient="records") return teams.to_dict(orient="records")
@router.post("/users") @router.post("/users")
async def create_user(user: CreateUser): async def create_user(user: CreateUser, request: Request):
logger.info(
f"Admin action: Creating new user '{user.username}' from IP: {request.client.host}"
)
try:
with get_connection() as conn: with get_connection() as conn:
# Check if username already exists
existing = conn.execute(
"SELECT COUNT(*) as count FROM User WHERE username = ?",
(user.username,),
).fetchdf()
if existing["count"][0] > 0:
logger.warning(
f"Failed to create user: Username '{user.username}' already exists"
)
raise HTTPException(status_code=400, detail="Username already exists")
conn.execute( conn.execute(
"INSERT INTO User (username, hashed_password, team_id, is_admin) VALUES (?, ?, ?, ?)", "INSERT INTO User (username, hashed_password, team_id, is_admin) VALUES (?, ?, ?, ?)",
( (
@@ -44,11 +69,24 @@ async def create_user(user: CreateUser):
user.is_admin, user.is_admin,
), ),
) )
logger.info(
f"User '{user.username}' created successfully with team_id: {user.team_id}, admin status: {user.is_admin}"
)
return {"message": "User created successfully"} return {"message": "User created successfully"}
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Failed to create user '{user.username}': {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create user")
@router.post("/users/") @router.post("/users/disable")
async def disable_user(user_id: int = Body(..., embed=True)): async def disable_user(user_id: int = Body(..., embed=True), request: Request = None):
logger.info(
f"Admin action: Attempting to disable user with ID: {user_id} from IP: {request.client.host}"
)
logger.warning(f"Disable user functionality not implemented for user_id: {user_id}")
raise HTTPException(status_code=405, detail="Method not yet implemented") raise HTTPException(status_code=405, detail="Method not yet implemented")
+21 -6
View File
@@ -1,24 +1,34 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Request
from lib.db import get_connection from lib.db import get_connection
from models.user import User, UserLogin from models.user import User, UserLogin
from lib.helpers import verify_password from lib.helpers import verify_password
from lib.logger import logger
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/token", response_model=User) @router.post("/token", response_model=User)
async def login(user_login: UserLogin): async def login(user_login: UserLogin, request: Request):
logger.debug(
f"Login attempt for user: {user_login.username} from IP: {request.client.host}"
)
with get_connection() as conn: with get_connection() as conn:
user_df = conn.execute( user_df = conn.execute(
'SELECT * FROM "User" WHERE username = ?', (user_login.username,) 'SELECT * FROM "User" WHERE username = ?', (user_login.username,)
).fetchdf() ).fetchdf()
if user_df.empty: if user_df.empty:
logger.warning(
f"Failed login: Username {user_login.username} not found - IP: {request.client.host}"
)
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
user_data = user_df.to_dict(orient="records")[0] user_data = user_df.to_dict(orient="records")[0]
if not verify_password(user_login.password, user_data["hashed_password"]): if not verify_password(user_login.password, user_data["hashed_password"]):
logger.warning(
f"Failed login: Incorrect password for user {user_login.username} - IP: {request.client.host}"
)
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
# Update the login timestamp in the database # Update the login timestamp in the database
@@ -29,9 +39,14 @@ async def login(user_login: UserLogin):
) )
# Fetch the updated user data with the new last_login timestamp # Fetch the updated user data with the new last_login timestamp
updated_user = conn.execute( updated_user = (
'SELECT * FROM "User" WHERE id = ?', conn.execute('SELECT * FROM "User" WHERE id = ?', (user_data["id"],))
(user_data["id"],) .fetchdf()
).fetchdf().to_dict(orient="records")[0] .to_dict(orient="records")[0]
)
logger.info(
f"Successful login: User {user_login.username} (ID: {user_data['id']}) logged in from {request.client.host}"
)
return User(**updated_user) return User(**updated_user)
+16 -5
View File
@@ -1,14 +1,15 @@
from fastapi import APIRouter from fastapi import APIRouter, HTTPException, Request
from fastapi import HTTPException
from models.user import User, UserChangePassword from models.user import User, UserChangePassword
from lib.db import get_connection from lib.db import get_connection
from lib.logger import logger
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=list[User]) @router.get("/", response_model=list[User])
async def list_users(): async def list_users(request: Request):
logger.info(f"User list requested from {request.client.host}")
with get_connection() as conn: with get_connection() as conn:
users = conn.execute('SELECT * FROM "User"').fetchdf() users = conn.execute('SELECT * FROM "User"').fetchdf()
@@ -18,19 +19,29 @@ async def list_users():
@router.get("/{user_id}", response_model=User) @router.get("/{user_id}", response_model=User)
async def get_user(user_id: int): async def get_user(user_id: int, request: Request):
logger.debug(
f"User details requested for user_id: {user_id} from {request.client.host}"
)
with get_connection() as conn: with get_connection() as conn:
user_df = conn.execute( user_df = conn.execute(
'SELECT * FROM "User" WHERE id = ?', (user_id,) 'SELECT * FROM "User" WHERE id = ?', (user_id,)
).fetchdf() ).fetchdf()
if user_df.empty: if user_df.empty:
logger.warning(f"Failed user lookup: user_id {user_id} not found")
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
user_data = user_df.to_dict(orient="records")[0] user_data = user_df.to_dict(orient="records")[0]
logger.debug(f"User details retrieved: {user_data}")
return User(**user_data) return User(**user_data)
@router.patch("/{user_id}") @router.patch("/{user_id}")
async def change_password(user: UserChangePassword): async def change_password(user_id: int, user: UserChangePassword, request: Request):
logger.info(
f"Password change attempt for user_id: {user_id} from {request.client.host}"
)
# Implementation will go here when completed
logger.warning(f"Password change not implemented for user_id: {user_id}")
raise HTTPException(status_code=401, detail="Not implemented") raise HTTPException(status_code=401, detail="Not implemented")
Generated
+24
View File
@@ -137,6 +137,7 @@ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "duckdb" }, { name = "duckdb" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "loguru" },
{ name = "pandas" }, { name = "pandas" },
{ name = "passlib", extra = ["bcrypt"] }, { name = "passlib", extra = ["bcrypt"] },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@@ -154,6 +155,7 @@ requires-dist = [
{ name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiofiles", specifier = ">=24.1.0" },
{ name = "duckdb", specifier = ">=1.2.2" }, { name = "duckdb", specifier = ">=1.2.2" },
{ name = "fastapi", specifier = ">=0.115.12" }, { name = "fastapi", specifier = ">=0.115.12" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "pandas", specifier = ">=2.2.3" }, { name = "pandas", specifier = ">=2.2.3" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "python-dotenv", specifier = ">=1.1.0" }, { name = "python-dotenv", specifier = ">=1.1.0" },
@@ -218,6 +220,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
] ]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "3.0.0" version = "3.0.0"
@@ -639,3 +654,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
] ]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]