diff --git a/api/main.py b/api/main.py index 6bdd569..c695c70 100644 --- a/api/main.py +++ b/api/main.py @@ -3,7 +3,6 @@ from os import getenv from pathlib import Path from time import time -from typing import Union from fastapi import FastAPI, Request, Response from fastapi.exceptions import HTTPException @@ -32,7 +31,7 @@ def dicthash(data: dict) -> str: """Take a dictionary and convert it to a SHA-1 hash.""" - return sha1(dumps(data).encode()).hexdigest() + return sha1(dumps(data).encode()).hexdigest() # noqa: S324 def make_file_path(dh: str, request_url: URL) -> str: @@ -44,7 +43,7 @@ def make_file_path(dh: str, request_url: URL) -> str: @app.get("/duck", response_model=DuckResponse, status_code=201) async def get_duck(request: Request, response: Response, seed: int | None = None) -> DuckResponse: """Create a new random duck, with an optional seed.""" - dh = sha1(str(time()).encode()).hexdigest() + dh = sha1(str(time()).encode()).hexdigest() # noqa: S324 file = CACHE / f"{dh}.png" DuckBuilder(seed).generate().image.save(file) @@ -64,9 +63,9 @@ async def post_duck(request: Request, response: Response, duck: DuckRequest = No try: DuckBuilder().generate(options=duck.dict()).image.save(file) except ValueError as e: - raise HTTPException(400, e.args[0]) + raise HTTPException(400, e.args[0]) from e except KeyError as e: - raise HTTPException(400, f"Invalid configuration option provided: '{e.args[0]}'") + raise HTTPException(400, f"Invalid configuration option provided: '{e.args[0]}'") from e file_path = make_file_path(dh, request.url) response.headers["Location"] = file_path @@ -76,7 +75,7 @@ async def post_duck(request: Request, response: Response, duck: DuckRequest = No @app.get("/manduck", response_model=DuckResponse, status_code=201) async def get_man_duck(request: Request, response: Response, seed: int | None = None) -> DuckResponse: """Create a new man_duck, with an optional seed.""" - dh = sha1(str(time()).encode()).hexdigest() + dh = sha1(str(time()).encode()).hexdigest() # noqa: S324 ducky = DuckBuilder(seed).generate() ducky = ManDuckBuilder(seed).generate(ducky=ducky) @@ -97,9 +96,9 @@ async def post_man_duck(request: Request, response: Response, manduck: ManDuckRe try: ducky = ManDuckBuilder().generate(options=manduck.dict()) except ValueError as e: - raise HTTPException(400, e.args[0]) + raise HTTPException(400, e.args[0]) from e except KeyError as e: - raise HTTPException(400, f"Invalid configuration option provided: '{e.args[0]}'") + raise HTTPException(400, f"Invalid configuration option provided: '{e.args[0]}'") from e ducky.image.save(CACHE / f"{dh}.png") file_path = make_file_path(dh, request.url) @@ -107,8 +106,8 @@ async def post_man_duck(request: Request, response: Response, manduck: ManDuckRe return DuckResponse(file=file_path) -@app.get("/details/{type}", response_model=Union[ManDuckDetails, DuckyDetails]) -async def get_details(type: str) -> ManDuckDetails | DuckyDetails: +@app.get("/details/{type}", response_model=ManDuckDetails | DuckyDetails) +async def get_details(type: str) -> ManDuckDetails | DuckyDetails: # noqa: A002 """Get details about accessories which can be used to build ducks/man-ducks.""" details = { "ducky": DuckyDetails( diff --git a/quackstack/colors.py b/quackstack/colors.py index 7a79bf1..6ab4895 100644 --- a/quackstack/colors.py +++ b/quackstack/colors.py @@ -1,12 +1,26 @@ -from collections import namedtuple from colorsys import hls_to_rgb, rgb_to_hls from random import Random +from typing import NamedTuple -DuckyColors = namedtuple("DuckyColors", "eye eye_wing wing body beak") -DressColors = namedtuple("DressColors", "shirt pants") +class DuckyColors(NamedTuple): + """RGB tuples of colours for each part of the Ducky.""" -def make_color(random: Random, hue: float, dark_variant: bool) -> tuple[float, float, float]: + eye: tuple[int, int, int] + eye_wing: tuple[int, int, int] + wing: tuple[int, int, int] + body: tuple[int, int, int] + beak: tuple[int, int, int] + + +class DressColors(NamedTuple): + """RGB tuples of colours for each part of the Ducky's dress.""" + + shirt: tuple[int, int, int] + pants: tuple[int, int, int] + + +def make_color(random: Random, hue: float, *, dark_variant: bool) -> tuple[float, float, float]: """Make a nice hls color to use in a duck.""" saturation = 1 lightness = random.uniform(.7, .85) @@ -27,7 +41,7 @@ def make_duck_colors(random: Random) -> DuckyColors: """Create a matching DuckyColors object.""" hue = random.random() dark_variant = random.choice([True, False]) - eye, wing, body, beak = (make_color(random, hue, dark_variant) for _ in range(4)) + eye, wing, body, beak = (make_color(random, hue, dark_variant=dark_variant) for _ in range(4)) # Lower the eye light eye_main = (eye[0], max(.1, eye[1] - .7), eye[2]) @@ -46,16 +60,16 @@ def make_man_duck_colors(ducky: tuple) -> DressColors: hls_ = tuple(rgb_to_hls(ducky[0] / 255, ducky[1] / 255, ducky[2] / 255)) # Find the first triadic hls color - first_varient = [((hls_[0] * 360 + 120) % 360) / 360, hls_[1], hls_[2]] + first_variant = [((hls_[0] * 360 + 120) % 360) / 360, hls_[1], hls_[2]] # Find the second triadic hls color - second_varient = [((hls_[0] * 360 + 240) % 360) / 360, hls_[1], hls_[2]] + second_variant = [((hls_[0] * 360 + 240) % 360) / 360, hls_[1], hls_[2]] first = tuple( - round(x * 255) for x in hls_to_rgb(first_varient[0], first_varient[1], first_varient[2]) + round(x * 255) for x in hls_to_rgb(first_variant[0], first_variant[1], first_variant[2]) ) second = tuple( - round(x * 255) for x in hls_to_rgb(second_varient[0], second_varient[1], second_varient[2]) + round(x * 255) for x in hls_to_rgb(second_variant[0], second_variant[1], second_variant[2]) ) return DressColors(first, second) diff --git a/quackstack/ducky.py b/quackstack/ducky.py index 66e0ee3..7cb34fd 100644 --- a/quackstack/ducky.py +++ b/quackstack/ducky.py @@ -1,9 +1,9 @@ -import os -from collections import namedtuple from pathlib import Path from random import Random +from typing import NamedTuple from PIL import Image, ImageChops +from frozendict import frozendict from quackstack import __file__ as qs_file @@ -12,24 +12,32 @@ ASSETS_PATH = Path(qs_file).parent / Path("assets", "ducky") DUCK_SIZE = (499, 600) -ProceduralDucky = namedtuple("ProceduralDucky", "image colors hat equipment outfit") + +class ProceduralDucky(NamedTuple): + """Represents a Ducky and all its defined features/colours.""" + + image: Image.Image + colors: DuckyColors + hat: str + equipment: str + outfit: str class DuckBuilder: """A class used to build new ducks.""" - templates = { + templates = frozendict({ int(filename.name[0]): filename for filename in (ASSETS_PATH / "templates").iterdir() - } - hats = { + }) + hats = frozendict({ filename.stem: filename for filename in (ASSETS_PATH / "accessories/hats").iterdir() - } - equipments = { + }) + equipments = frozendict({ filename.stem: filename for filename in (ASSETS_PATH / "accessories/equipment").iterdir() - } - outfits = { + }) + outfits = frozendict({ filename.stem: filename for filename in (ASSETS_PATH / "accessories/outfits").iterdir() - } + }) def __init__(self, seed: int | None = None) -> None: self.random = Random(seed) @@ -85,8 +93,8 @@ def apply_layer(self, layer_path: str, recolor: tuple[int, int, int] | None = No """Add the given layer on top of the ducky. Can be recolored with the recolor argument.""" try: layer = Image.open(layer_path) - except FileNotFoundError: - raise ValueError(f"Invalid option provided: {os.path.basename(layer_path)} not found.") + except FileNotFoundError as e: + raise ValueError(f"Invalid option provided: {Path.name(layer_path)} not found.") from e if recolor: if isinstance(recolor, dict): diff --git a/quackstack/manducky.py b/quackstack/manducky.py index a615e7f..4583c83 100644 --- a/quackstack/manducky.py +++ b/quackstack/manducky.py @@ -1,16 +1,22 @@ -import os -from collections import namedtuple from pathlib import Path from random import Random +from typing import NamedTuple from PIL import Image, ImageChops +from frozendict import frozendict from quackstack import __file__ as qs_file from .colors import DressColors, DuckyColors, make_man_duck_colors from .ducky import ProceduralDucky -ManDucky = namedtuple("ManDucky", "image") + +class ManDucky(NamedTuple): + """Holds a reference to the ManDucky's source image.""" + + image: Image.Image + + Color = tuple[int, int, int] ASSETS_PATH = Path(qs_file).parent / Path("assets", "manduck") @@ -21,25 +27,25 @@ class ManDuckBuilder: """Temporary class used to generate a ducky human.""" VARIATIONS = (1, 2) - HATS = { + HATS = frozendict({ filename.stem: filename for filename in (ASSETS_PATH / "accessories/hats").iterdir() - } - OUTFITS = { + }) + OUTFITS = frozendict({ "variation_1": { filename.stem: filename for filename in (ASSETS_PATH / "accessories/outfits/variation_1").iterdir() }, "variation_2": { filename.stem: filename for filename in (ASSETS_PATH / "accessories/outfits/variation_2").iterdir() }, - } - EQUIPMENTS = { + }) + EQUIPMENTS = frozendict({ "variation_1": { filename.stem: filename for filename in (ASSETS_PATH / "accessories/equipment/variation_1").iterdir() }, "variation_2": { filename.stem: filename for filename in (ASSETS_PATH / "accessories/equipment/variation_2").iterdir() }, - } + }) def __init__(self, seed: int | None = None) -> None: self.random = Random(seed) @@ -127,8 +133,8 @@ def apply_layer(self, layer_path: str, recolor: Color | None = None) -> None: """Add the given layer on top of the ducky. Can be recolored with the recolor argument.""" try: layer = Image.open(layer_path) - except FileNotFoundError: - raise ValueError(f"Invalid option provided: {os.path.basename(layer_path)} not found.") + except FileNotFoundError as e: + raise ValueError(f"Invalid option provided: {Path.name(layer_path)} not found.") from e if recolor: if isinstance(recolor, dict):