Compare commits

..
15 Commits
17 changed files with 278 additions and 125 deletions
+2 -1
View File
@@ -9,7 +9,8 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
.env .env*
.ruff_cache
*.sqlite *.sqlite
*.duckdb *.duckdb
+1
View File
@@ -5,6 +5,7 @@ WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 ENV UV_COMPILE_BYTECODE=1
ADD pyproject.toml uv.lock . ADD pyproject.toml uv.lock .
RUN mkdir -p /app/data
RUN uv sync --frozen --no-dev RUN uv sync --frozen --no-dev
ADD /src . ADD /src .
+14 -2
View File
@@ -1,10 +1,22 @@
services: services:
pjl: pjl-backend:
# Build the image from the Dockerfile in the current directory # Build the image from the Dockerfile in the current directory
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: pjl:0.9 image: pjl-backend:1.0rc1
env_file:
- .env.prod
Environment:
DB_URL: /app/data/pjl.duckdb
DEFAULT_AVATAR_URL: https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Kingston
OMDB_API_KEY: ${OMDB_API_KEY}
STAGE: prod
volumes:
- ./data:/app/data
# Host the FastAPI application on port 8000 # Host the FastAPI application on port 8000
ports: ports:
+13 -3
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "pjl" name = "pjl-backend"
version = "0.9" version = "1.0rc1"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -11,8 +11,18 @@ dependencies = [
"loguru>=0.7.2", "loguru>=0.7.2",
"pydantic-settings>=2.6.1", "pydantic-settings>=2.6.1",
"pydantic>=2.9.2", "pydantic>=2.9.2",
"requests>=2.32.3",
"rich>=13.9.4", "rich>=13.9.4",
"uvicorn>=0.32.0", "uvicorn>=0.32.0",
"pandas>=2.2.3", "pandas>=2.2.3",
"requests>=2.32.3",
] ]
[dependency-groups]
dev = [
"ruff>=0.8.0",
]
[tool.ruff]
lint.extend-select = ["C4", "SIM", "TCH", "ERA", "FAST", "DOC"]
target-version = "py312"
+55 -18
View File
@@ -1,10 +1,14 @@
from datetime import datetime
import bcrypt
import requests
from lib.database.database import db_run from lib.database.database import db_run
from models import Message from models import Message
from models.user import UserCredentials from models.user import UserCredentials
from models.movie import Movie from models.movie import MovieIn, Movie
from models.card import Card from models.card import Card
from lib.settings import settings
import bcrypt from lib.logger import logger
def add_user(user: UserCredentials): def add_user(user: UserCredentials):
@@ -14,40 +18,71 @@ def add_user(user: UserCredentials):
sql = f"INSERT INTO users (username, password) VALUES ('{user.username}', '{hashed_password}')" sql = f"INSERT INTO users (username, password) VALUES ('{user.username}', '{hashed_password}')"
try: try:
db_run(sql) db_run(sql)
except: except Exception as e:
return Message(message="User already exists", message_type="error") return Message(message=f"User already exists. Error: {e}", message_type="error")
return Message(message="User added successfully", message_type="success") return Message(message="User added successfully", message_type="success")
def remove_user(user_id: str): def remove_user(user_id: str):
logger.debug(f"Removing user {user_id}")
sql = f"DELETE FROM users WHERE id = '{user_id}'" sql = f"DELETE FROM users WHERE id = '{user_id}'"
try: try:
db_run(sql) db_run(sql)
except: except Exception as e:
return Message(message="User not found", message_type="error") return Message(message=f"User not found. Error: {e}", message_type="error")
return Message(message="User removed successfully", message_type="success") return Message(message="User removed successfully", message_type="success")
def add_movie(movie: Movie): def add_movie(movie: MovieIn):
sql = "INSERT INTO movies (name, imdb_id, actors, release_date, plot, showtime) VALUES (?, ?, ?, ?, ?, ?)" logger.info(f"Adding movie {movie.imdb_id}")
logger.debug(f"Requesting movie data from OMDB: {movie.imdb_id}")
request_url = "https://omdbapi.com/"
request_params = {
"apikey": settings.OMDB_API_KEY,
"i": movie.imdb_id,
}
response = requests.get(request_url, params=request_params)
if response.status_code != 200:
logger.error(f"Failed to add movie {movie.imdb_id}")
return Message(message="Failed to add movie", message_type="error")
movie_data = response.json()
logger.debug(f"Movie data received from OMDB: {movie_data}")
if "Released" in movie_data:
movie_data["Released"] = datetime.strptime(movie_data["Released"], "%d %b %Y")
new_movie = Movie(
name=movie_data["Title"],
imdb_id=movie_data["imdbID"],
actors=[actor.strip() for actor in movie_data["Actors"].split(",")],
release_date=movie_data["Released"],
plot=movie.plot,
poster_url=movie_data["Poster"],
showtime=movie.showtime,
)
logger.debug(f"Movie data: {new_movie.model_dump()}")
sql = "INSERT INTO movies (name, imdb_id, actors, release_date, plot, showtime, poster_url) VALUES (?, ?, ?, ?, ?, ?, ?)"
try: try:
db_run( db_run(
sql, sql,
( (
movie.name, new_movie.name,
movie.imdb_id, new_movie.imdb_id,
movie.actors, new_movie.actors,
movie.release_date, new_movie.release_date,
movie.plot, new_movie.plot,
movie.showtime, new_movie.showtime,
new_movie.poster_url,
), ),
) )
except: except Exception as e:
logger.error(f"Failed to add movie {movie.imdb_id}. Error: {e}")
return Message(message="Failed to add movie", message_type="error") return Message(message="Failed to add movie", message_type="error")
logger.info(f"Movie {movie.imdb_id} added successfully")
return Message(message="Movie added successfully", message_type="success") return Message(message="Movie added successfully", message_type="success")
@@ -55,8 +90,10 @@ def set_movie_watched(movie_id: str):
sql = f"UPDATE movies SET is_watched = TRUE WHERE id = '{movie_id}'" sql = f"UPDATE movies SET is_watched = TRUE WHERE id = '{movie_id}'"
try: try:
db_run(sql) db_run(sql)
except: except Exception as e:
return Message(message="Failed to set movie as watched", message_type="error") return Message(
message=f"Failed to set movie as watched. Error: {e}", message_type="error"
)
return Message(message="Movie set as watched successfully", message_type="success") return Message(message="Movie set as watched successfully", message_type="success")
+7 -9
View File
@@ -1,15 +1,16 @@
from contextlib import contextmanager from contextlib import contextmanager
import duckdb import duckdb
import bcrypt import bcrypt
from rich import print
from lib import settings from lib import settings
from lib.logger import logger
@contextmanager @contextmanager
def get_db(): def get_db():
try: try:
conn = duckdb.connect(database=settings.db_url) logger.debug(f"Connecting to database {settings.db_url.absolute()}")
conn = duckdb.connect(database=settings.db_url.absolute())
yield conn yield conn
finally: finally:
conn.close() conn.close()
@@ -23,11 +24,11 @@ def db_run(sql, values: tuple = None):
def init_db(): def init_db():
if settings.db_url.exists(): if settings.db_url.exists():
pass
# TODO: Development feature. Remove this in production # TODO: Development feature. Remove this in production
from os import unlink # from os import unlink
unlink(settings.db_url) # unlink(settings.db_url)
# return
sql = "" sql = ""
with open("lib/database/database.sql") as f: with open("lib/database/database.sql") as f:
sql = f.read() sql = f.read()
@@ -38,8 +39,5 @@ def init_db():
hashed_password = bcrypt.hashpw("password".encode("utf-8"), bcrypt.gensalt()) hashed_password = bcrypt.hashpw("password".encode("utf-8"), bcrypt.gensalt())
sql = f"INSERT INTO users (username, password, is_admin) VALUES ('admin', '{hashed_password.decode('utf-8')}', true)" sql = f"INSERT INTO users (username, password, is_admin) VALUES ('admin', '{hashed_password.decode('utf-8')}', true), ('kessinen', '{hashed_password.decode('utf-8')}', false), ('jensku', '{hashed_password.decode('utf-8')}', false), ('emppu', '{hashed_password.decode('utf-8')}', false)"
db_run(sql)
sql = f"INSERT INTO users (username, password, is_admin) VALUES ('test', '{hashed_password.decode('utf-8')}', false)"
db_run(sql) db_run(sql)
+13 -7
View File
@@ -53,15 +53,21 @@ CREATE TABLE log (
); );
-- Add placeholder data -- Add placeholder data
INSERT INTO movies (name, imdb_id, actors, release_date, plot, poster_url, showtime) INSERT INTO movies (name, imdb_id, actors, release_date, plot, poster_url, showtime)
VALUES ('A Maple Valley Christmas', VALUES ('Christmas Sail',
'tt21841642', 'tt15758364',
['Peyton List', 'Andrew W. Walker', 'Frances Flanagan'], ['Katee Sackhoff', 'Patrick Sabongui', 'Terry O''Quinn'],
'2022-11-05', '2022-11-05',
'Erica on karjatilallinen, joka on viettänyt koko elämänsä työskennellen perhetilalla äitinsä ja sisarensa kanssa. 'Lizin etääntynyt isä pyytää apua ja Liz palaa kotikaupunkiinsa. Hän päättää luoda täydellisen joulun tyttärelleen ja saada ihmissuhteensa kuntoon.',
Kun Aaron saapuu ja häiritsee hänen suunnitelmiaan, hän alkaa kyseenalaistaa, mitä hän oikeastaan haluaa.', 'https://m.media-amazon.com/images/M/MV5BZTA0MjA2MmMtNWZhNy00N2UyLWI5YjItZTA4ZDVkZmNmMWVjXkEyXkFqcGc@._V1_SX300.jpg',
'https://m.media-amazon.com/images/M/MV5BZjliMTRlMGItOGQzZC00NTI2LWJkODctZWZmMDIzOGRmYTQ3XkEyXkFqcGc@._V1_.jpg', '2024-12-02'),
'2024-12-01'); ('The Holiday Stocking',
'tt22308008',
['Nadine Ellis', 'Tamala Jones', 'B.J. Britt'],
'2022-12-03','Enkeli palaa maan päälle vieraana ja saa mahdollisuuden auttaa sisariaan sovintoon - asia mitä hän ei onnistunut tekemään elinaikanaan.',
'https://m.media-amazon.com/images/M/MV5BZTI5OTc5OTYtOWVlMC00YmRhLWFhMmQtMGIwYjNmZTI1Y2Y5XkEyXkFqcGc@._V1_SX300.jpg',
'2024-12-03'),;
INSERT INTO cards (title, description, point_value) INSERT INTO cards (title, description, point_value)
VALUES VALUES
+12 -15
View File
@@ -1,25 +1,22 @@
from models.movie import Movie from models.movie import Movie
from lib.database.database import get_db from lib.database.database import get_db
from lib.logger import logger
@logger.catch
def get_all_movies() -> list[Movie]: def get_all_movies() -> list[Movie]:
logger.info("Getting all movies")
sql = "SELECT * FROM movies" sql = "SELECT * FROM movies"
with get_db() as conn: with get_db() as conn:
result = conn.execute(sql).fetchall() result = conn.execute(sql).df()
result_dict = result.to_dict(orient="records")
movies = [] movies = []
for row in result: for row in result_dict:
movie = Movie( row["id"] = str(row["id"])
id=str(row[0]), logger.debug(f"Found movie: {row}")
name=row[1], print("Type of name:", type(row["name"]))
imdb_id=row[2], movie = Movie(**row)
actors=row[3],
release_date=row[4],
plot=row[5],
showtime=row[6],
is_watched=row[7],
created_at=row[8],
updated_at=row[9],
)
movies.append(movie) movies.append(movie)
return movies return movies
@@ -28,7 +25,7 @@ def get_all_movies() -> list[Movie]:
def get_next_movie() -> Movie | None: def get_next_movie() -> Movie | None:
sql = "SELECT * FROM movies WHERE is_watched = FALSE AND showtime > CURRENT_TIMESTAMP ORDER BY showtime ASC" sql = "SELECT * FROM movies WHERE is_watched = FALSE AND showtime = CURRENT_DATE ORDER BY showtime ASC"
with get_db() as conn: with get_db() as conn:
result = conn.execute(sql).fetchdf() result = conn.execute(sql).fetchdf()
if result.empty: if result.empty:
+18 -13
View File
@@ -1,24 +1,29 @@
from datetime import datetime
from models.user import UserIn, UserOut from models.user import UserIn, UserOut
from lib.database.database import get_db from lib.database.database import get_db
from lib.logger import logger
def set_last_login(user_id: str) -> datetime:
logger.debug(f"Setting last login for user {user_id}")
cur_time = datetime.now()
sql = f"UPDATE users SET last_login = '{cur_time}' WHERE id = '{user_id}'"
with get_db() as conn:
conn.execute(sql)
return cur_time
def get_user_by_username(username) -> UserIn | None: def get_user_by_username(username) -> UserIn | None:
sql = f"SELECT * FROM users WHERE username = '{username}'" sql = f"SELECT * FROM users WHERE username = '{username}'"
with get_db() as conn: with get_db() as conn:
result = conn.execute(sql).fetchone() result = conn.execute(sql).df().to_dict(orient="records")[0]
if result is not None: if result is not None:
return UserIn( last_login = set_last_login(result["id"])
id=str(result[0]), result["id"] = str(result["id"])
username=result[1], result["last_login"] = last_login
password=result[2], return UserIn(**result)
email=result[3],
avatar_url=result[4],
is_active=result[5],
is_admin=result[6],
last_login=result[7],
created_at=result[8],
updated_at=result[9],
)
return None return None
+2 -4
View File
@@ -4,14 +4,12 @@ from pathlib import Path
class Settings(BaseSettings): class Settings(BaseSettings):
db_url: Path = Field(Path("pjl.duckdb"), env="DB_URL") db_url: Path = Field(Path("pjl_default.duckdb"), env="DB_URL")
default_avatar_url: str = Field( default_avatar_url: str = Field(
"https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Kingston", "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Kingston",
env="DEFAULT_AVATAR_URL", env="DEFAULT_AVATAR_URL",
) )
OMDB_API_KEY: str = Field(env="OMDB_API_KEY")
class Config:
env_file = ".env"
settings = Settings() settings = Settings()
-6
View File
@@ -1,11 +1,5 @@
# from .movie import Movie
# from .score import Score
# from .card import Card
from .general import Message from .general import Message
__all__ = [ __all__ = [
# "Movie",
# "Score",
# "Card",
"Message", "Message",
] ]
+24 -28
View File
@@ -1,44 +1,40 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from datetime import datetime from datetime import datetime, date
from typing import Optional from typing import Optional
class Movie(BaseModel): class MovieBase(BaseModel):
id: Optional[str] = Field( name: Optional[str] | None = Field(
default=None, examples=["123e4567-e89b-12d3-a456-426655440000"] None, description="Name of the movie", examples=["A Maple Valley Christmas"]
)
name: str = Field(
description="Name of the movie", examples=["A Maple Valley Christmas"]
) )
imdb_id: str = Field(description="IMDB ID of the movie", examples=["tt1234567"]) imdb_id: str = Field(description="IMDB ID of the movie", examples=["tt1234567"])
actors: list[str] = Field( showtime: date = Field(description="Showtime of the movie", examples=["2024-12-01"])
description="Actors of the movie",
examples=[["Peyton List", "Andrew W. Walker"]],
)
plot: str = Field( plot: str = Field(
description="Plot of the movie", examples=["Erica is a rancher..."] description="Plot of the movie", examples=["Erica is a rancher..."]
) )
release_date: datetime = Field(
description="Release date of the movie", examples=["2022-11-05"]
class MovieIn(MovieBase):
actors: Optional[list[str]] | None = Field(
None,
description="Actors of the movie",
examples=[["Peyton List", "Andrew W. Walker"]],
) )
poster_url: str = Field( release_date: datetime | None = Field(
description="Poster URL of the movie", examples=["https://..."] None, description="Release date of the movie", examples=["2022-11-05"]
) )
showtime: datetime = Field( poster_url: str | None = Field(
description="Showtime of the movie", examples=["2024-12-01"] None, description="Poster URL of the movie", examples=["https://..."]
) )
is_watched: bool = Field(
default=False,
description="Whether the movie is watched or not", class Movie(MovieIn):
examples=[True], id: Optional[str] = Field(
default=None, examples=["123e4567-e89b-12d3-a456-426655440000"]
) )
created_at: datetime = Field( created_at: datetime = Field(
default_factory=datetime.utcnow, default_factory=datetime.utcnow, description="Creation date of the movie"
description="Creation date of the movie",
examples=[datetime.utcnow()],
) )
updated_at: datetime = Field( modified_at: datetime = Field(
default_factory=datetime.utcnow, default_factory=datetime.utcnow, description="Modification date of the movie"
description="Modification date of the movie",
examples=[datetime.utcnow()],
) )
+50 -8
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter
from models import Message from models import Message
from models.user import UserCredentials from models.user import UserCredentials
from models.movie import Movie from models.movie import MovieBase
from models.card import Card from models.card import Card
from lib.database.admin import ( from lib.database.admin import (
add_user, add_user,
@@ -22,35 +22,77 @@ router = APIRouter(
@router.post("/user", response_model=Message) @router.post("/user", response_model=Message)
async def add_user_endpoint(user: UserCredentials): async def add_user_endpoint(user: UserCredentials):
"""Add a user to the database""" """Add a user to the database
Args:
user (UserCredentials): The user to add
Returns:
Message: A message indicating the result of the operation
"""
return add_user(user) return add_user(user)
@router.delete("/user", response_model=Message) @router.delete("/user", response_model=Message)
async def remove_user_endpoint(user_id: str): async def remove_user_endpoint(user_id: str):
"""Remove a user from the database""" """Remove a user from the database
Args:
user_id (str): The id of the user to remove
Returns:
Message: A message indicating the result of the operation
"""
return remove_user(user_id) return remove_user(user_id)
@router.post("/movie", response_model=Message) @router.post("/movie", response_model=Message)
async def add_movie_endpoint(movie: Movie): async def add_movie_endpoint(movie: MovieBase):
"""Add a movie to the database""" """Add a movie to the database
Args:
movie (MovieBase): The movie to add
Returns:
Message: A message indicating the result of the operation
"""
return add_movie(movie) return add_movie(movie)
@router.patch("/movie", response_model=Message) @router.patch("/movie", response_model=Message)
async def set_movie_watched_endpoint(movie_id: str): async def set_movie_watched_endpoint(movie_id: str):
"""Set movie as watched""" """Set movie as watched
Args:
movie_id (str): The id of the movie to set as watched
Returns:
Message: A message indicating the result of the operation
"""
return set_movie_watched(movie_id) return set_movie_watched(movie_id)
@router.post("/card", response_model=Message) @router.post("/card", response_model=Message)
async def add_card_endpoint(card: Card): async def add_card_endpoint(card: Card):
"""Add a card to the database""" """Add a card to the database
Args:
card (Card): The card to add
Returns:
Message: A message indicating the result of the operation
"""
return add_card(card) return add_card(card)
@router.delete("/card", response_model=Message) @router.delete("/card", response_model=Message)
async def remove_card_endpoint(card_id: str): async def remove_card_endpoint(card_id: str):
"""Remove a card from the database""" """Remove a card from the database
Args:
card_id (str): The id of the card to remove
Returns:
Message: A message indicating the result of the operation
"""
return remove_card(card_id) return remove_card(card_id)
+6 -2
View File
@@ -10,7 +10,11 @@ router = APIRouter(
) )
@router.get("/", response_model=list[Card]) @router.get("/", responses={200: {"model": list[Card]}})
async def get_cards() -> list[Card]: async def get_cards() -> list[Card]:
"""Get all cards""" """Get all cards
Returns:
list[Card]: List of cards
"""
return get_all_cards() return get_all_cards()
+5 -1
View File
@@ -18,7 +18,11 @@ async def get_movies():
@router.get("/next", response_model=Movie) @router.get("/next", response_model=Movie)
async def get_next_movie_endpoint(): async def get_next_movie_endpoint():
"""Get the next movie to watch""" """Get the next movie to watch
Returns:
Movie: The next movie to watch
"""
next_movie = get_next_movie() next_movie = get_next_movie()
if next_movie is None: if next_movie is None:
return Response( return Response(
+21 -6
View File
@@ -14,11 +14,14 @@ router = APIRouter(
@router.get( @router.get(
"/", "/",
response_model=list[UserOut], responses={200: {"model": list[UserOut]}, 404: {"model": Message}},
responses={200: {"model": UserOut}, 404: {"model": Message}},
) )
async def get_users() -> list[UserOut]: async def get_users() -> list[UserOut]:
"""Get all users""" """Get all users
Returns:
list[UserOut]: List of users
"""
return get_all_users() return get_all_users()
@@ -29,11 +32,20 @@ async def get_users() -> list[UserOut]:
400: {"model": Message}, 400: {"model": Message},
404: {"model": Message}, 404: {"model": Message},
}, },
summary="Login a user",
description="Login a user by providing their username and password",
) )
async def login(user: UserCredentials): async def login(user: UserCredentials):
"""Login a user""" """Login a user by providing their username and password
user_from_db: UserIn = get_user_by_username(user.username)
Returns:
UserOut: User information
Response: Error message
"""
# Get the user from the database
user_from_db: UserIn | None = get_user_by_username(user.username)
if user_from_db is None: if user_from_db is None:
# User not found
return Response( return Response(
status_code=404, status_code=404,
media_type="application/json", media_type="application/json",
@@ -41,11 +53,14 @@ async def login(user: UserCredentials):
message="User not found", message_type="error" message="User not found", message_type="error"
).model_dump_json(), ).model_dump_json(),
) )
password = user.password.encode("utf-8")
# Check the password
password = user.password.encode("utf-8")
if bcrypt.checkpw(password, user_from_db.password.encode("utf-8")): if bcrypt.checkpw(password, user_from_db.password.encode("utf-8")):
# Login successful
return UserOut(**user_from_db.model_dump()) return UserOut(**user_from_db.model_dump())
# Invalid password
return Response( return Response(
status_code=400, status_code=400,
media_type="application/json", media_type="application/json",
Generated
+35 -2
View File
@@ -289,8 +289,8 @@ wheels = [
] ]
[[package]] [[package]]
name = "pjl" name = "pjl-backend"
version = "0.9" version = "1.0rc1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "bcrypt" }, { name = "bcrypt" },
@@ -305,6 +305,11 @@ dependencies = [
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bcrypt", specifier = ">=4.2.0" }, { name = "bcrypt", specifier = ">=4.2.0" },
@@ -319,6 +324,9 @@ requires-dist = [
{ name = "uvicorn", specifier = ">=0.32.0" }, { name = "uvicorn", specifier = ">=0.32.0" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.8.0" }]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.9.2" version = "2.9.2"
@@ -448,6 +456,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
] ]
[[package]]
name = "ruff"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/d6/a2373f3ba7180ddb44420d2a9d1f1510e1a4d162b3d27282bedcb09c8da9/ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44", size = 3276537 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/77/e889ee3ce7fd8baa3ed1b77a03b9fb8ec1be68be1418261522fd6a5405e0/ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea", size = 10518283 },
{ url = "https://files.pythonhosted.org/packages/da/c8/0a47de01edf19fb22f5f9b7964f46a68d0bdff20144d134556ffd1ba9154/ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b", size = 10317691 },
{ url = "https://files.pythonhosted.org/packages/41/17/9885e4a0eeae07abd2a4ebabc3246f556719f24efa477ba2739146c4635a/ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a", size = 9940999 },
{ url = "https://files.pythonhosted.org/packages/3e/cd/46b6f7043597eb318b5f5482c8ae8f5491cccce771e85f59d23106f2d179/ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99", size = 10772437 },
{ url = "https://files.pythonhosted.org/packages/5d/87/afc95aeb8bc78b1d8a3461717a4419c05aa8aa943d4c9cbd441630f85584/ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c", size = 10299156 },
{ url = "https://files.pythonhosted.org/packages/65/fa/04c647bb809c4d65e8eae1ed1c654d9481b21dd942e743cd33511687b9f9/ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9", size = 11325819 },
{ url = "https://files.pythonhosted.org/packages/90/26/7dad6e7d833d391a8a1afe4ee70ca6f36c4a297d3cca83ef10e83e9aacf3/ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362", size = 12023927 },
{ url = "https://files.pythonhosted.org/packages/24/a0/be5296dda6428ba8a13bda8d09fbc0e14c810b485478733886e61597ae2b/ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df", size = 11589702 },
{ url = "https://files.pythonhosted.org/packages/26/3f/7602eb11d2886db545834182a9dbe500b8211fcbc9b4064bf9d358bbbbb4/ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3", size = 12782936 },
{ url = "https://files.pythonhosted.org/packages/4c/5d/083181bdec4ec92a431c1291d3fff65eef3ded630a4b55eb735000ef5f3b/ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c", size = 11138488 },
{ url = "https://files.pythonhosted.org/packages/b7/23/c12cdef58413cee2436d6a177aa06f7a366ebbca916cf10820706f632459/ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2", size = 10744474 },
{ url = "https://files.pythonhosted.org/packages/29/61/a12f3b81520083cd7c5caa24ba61bb99fd1060256482eff0ef04cc5ccd1b/ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70", size = 10369029 },
{ url = "https://files.pythonhosted.org/packages/08/2a/c013f4f3e4a54596c369cee74c24870ed1d534f31a35504908b1fc97017a/ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd", size = 10867481 },
{ url = "https://files.pythonhosted.org/packages/d5/f7/685b1e1d42a3e94ceb25eab23c70bdd8c0ab66a43121ef83fe6db5a58756/ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426", size = 11237117 },
{ url = "https://files.pythonhosted.org/packages/03/20/401132c0908e8837625e3b7e32df9962e7cd681a4df1e16a10e2a5b4ecda/ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468", size = 8783511 },
{ url = "https://files.pythonhosted.org/packages/1d/5c/4d800fca7854f62ad77f2c0d99b4b585f03e2d87a6ec1ecea85543a14a3c/ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f", size = 9559876 },
{ url = "https://files.pythonhosted.org/packages/5b/bc/cc8a6a5ca4960b226dc15dd8fb511dd11f2014ff89d325c0b9b9faa9871f/ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6", size = 8939733 },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"