Compare commits

..

2 commits

6 changed files with 136 additions and 13 deletions

View file

@ -6,6 +6,7 @@
* New module `mat` adds a shallow reimplementation of `Matrix()` in order to implement matrix multiplication
* Removed obsolete `configparse` implementation that has been around since 0.3 and shelved since 0.4.
* `color`: added support for conversion from RGB to sRGB, XYZ, OKLab and OKLCH.
* Added `user-loader` for Quart-Auth and SQLAlchemy
## 0.11.2

View file

@ -39,7 +39,7 @@ Documentation = "https://suou.readthedocs.io"
# the below are all dev dependencies (and probably already installed)
sqlalchemy = [
"SQLAlchemy[asyncio]>=2.0.0",
"flask-sqlalchemy"
"flask-sqlalchemy" # glue code
]
flask = [
"Flask>=2.0.0",
@ -61,6 +61,10 @@ quart = [
"Quart-Schema",
"starlette>=0.47.2"
]
quart_auth = [
"Quart-Auth",
"suou[sqlalchemy]" # glue code
]
sass = [
## HEADS UP!! libsass carries a C extension + uses setup.py
"libsass"
@ -70,6 +74,7 @@ full = [
"suou[sqlalchemy]",
"suou[flask]",
"suou[quart]",
"suou[quart_auth]",
"suou[peewee]",
"suou[markdown]",
"suou[sass]"

View file

@ -38,7 +38,7 @@ from .http import WantsContentType
from .color import OKLabColor, chalk, WebColor, RGBColor, SRGBColor, XYZColor, OKLabColor
from .mat import Matrix
__version__ = "0.12.0a5"
__version__ = "0.12.0a7"
__all__ = (
'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue',

View file

@ -23,6 +23,7 @@ from collections import namedtuple
from functools import lru_cache
import math
from suou.functools import deprecated
from suou.mat import Matrix
@ -102,6 +103,8 @@ class RGBColor(namedtuple('_WebColor', 'red green blue')):
Useful for theming.
XXX CURRENTLY THE OKLCH CONVERSION DOES NOT WORK
*Changed in 0.12.0*: name is now RGBColor, with WebColor being an alias.
Added conversions to and from OKLCH, OKLab, sRGB, and XYZ.
"""
@ -168,7 +171,7 @@ class RGBColor(namedtuple('_WebColor', 'red green blue')):
])
def to_xyz(self):
return XYZColor(*(self.RGB_TO_XYZ @ Matrix.as_column(self)).get_column())
return XYZColor(*(self.RGB_TO_XYZ @ Matrix.as_column([x / 255 for x in self])).get_column())
def to_oklch(self):
return self.to_xyz().to_oklch()
@ -192,7 +195,9 @@ class SRGBColor(namedtuple('_SRGBColor', 'red green blue')):
blue: float
def __str__(self):
return f"srgb({self.red}, {self.green}, {self.blue})"
r, g, b = round(self.red, 4), round(self.green, 4), round(self.blue, 4)
return f"srgb({r}, {g}, {b})"
def to_rgb(self):
return RGBColor(*(
@ -232,8 +237,8 @@ class XYZColor(namedtuple('_XYZColor', 'x y z')):
])
def to_rgb(self):
return RGBColor(*(self.XYZ_TO_RGB @ Matrix.as_column(self)).get_column())
return RGBColor(*[int(x * 255) for x in (self.XYZ_TO_RGB @ Matrix.as_column(self)).get_column()])
def to_oklab(self):
lms = (self.XYZ_TO_LMS @ Matrix.as_column(self)).get_column()
lmsg = [math.cbrt(i) for i in lms]
@ -243,6 +248,11 @@ class XYZColor(namedtuple('_XYZColor', 'x y z')):
def to_oklch(self):
return self.to_oklab().to_oklch()
def __str__(self):
x, y, z = round(self.x, 4), round(self.y, 4), round(self.z, 4)
return f'xyz({x} {y} {z})'
class OKLabColor(namedtuple('_OKLabColor', 'l a b')):
"""
@ -276,6 +286,11 @@ class OKLabColor(namedtuple('_OKLabColor', 'l a b')):
0 if abs(self.a) < .0002 and abs(self.b) < .0002 else (((math.atan2(self.b, self.a) * 180) / math.pi % 360) + 360) % 360
)
def __str__(self):
l, c, h = round(self.l, 4), round(self.a, 4), round(self.b, 4)
return f'oklab({l} {c} {h})'
def to_rgb(self):
return self.to_xyz().to_rgb()
@ -289,19 +304,18 @@ class OKLCHColor(namedtuple('_OKLCHColor', 'l c h')):
"""
def __str__(self):
l, c, h = round(self.l, 4), round(self.c, 4), round(self.h, 4)
return f'oklch({l}, {c}, {h})'
l, c, h = round(self.l, 4), round(self.c, 4), round(self.h, 2)
return f'oklch({l} {c} {h})'
def to_oklab(self):
return OKLabColor(
self.l,
self.c * math.cos(self.h * math.pi / 180),
self.h * math.cos(self.h * math.pi / 180)
self.c * math.sin(self.h * math.pi / 180)
)
def to_rgb(self):
return self.to_oklab().to_rgb()
__all__ = ('chalk', 'WebColor', "RGBColor", 'SRGBColor', 'XYZColor', 'OKLabColor')
__all__ = ('chalk', 'WebColor', "RGBColor", 'SRGBColor', 'XYZColor', 'OKLabColor', 'OKLCHColor')

97
src/suou/quart_auth.py Normal file
View file

@ -0,0 +1,97 @@
"""
Utilities for Quart-Auth
---
Copyright (c) 2025 Sakuragasaki46.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
See LICENSE for the specific language governing permissions and
limitations under the License.
This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
from __future__ import annotations
from typing import Callable, TypeVar
from quart_auth import AuthUser, Action
from sqlalchemy import select
from sqlalchemy.orm import DeclarativeBase
from .sqlalchemy.asyncio import AsyncSession, SQLAlchemy
_T = TypeVar('_T')
def user_loader(database: SQLAlchemy, user_class: type[DeclarativeBase], *,
attr_loader: Callable[[type[AuthUser], str], _T] = lambda x, y: x.id == int(y)
):
"""
Returns a properly subclassed AuthUser loader for use in Quart-Auth.
Actual User object is at .user; other attributes are proxied.
Requires to be awaited before request before usage.
Uses SQLAlchemy's AsyncSession.
Parameters:
* database The database instance.
* user_class The user class.
* attr_loader A lambda taking user_class and auth_id, default (user_class, auth_id : user_class.id == int(auth_id))
*New in 0.12.0*
"""
class UserLoader(AuthUser):
_auth_id: str | None
_auth_obj: user_class | None
id: _T
def __init__(self, auth_id: str | None, action: Action = Action.PASS):
self._auth_id = auth_id
self._auth_obj = None
self._auth_sess: AsyncSession | None = None
self.action = action
@property
def auth_id(self) -> str | None:
return self._auth_id
@property
async def is_authenticated(self) -> bool:
await self._load()
return self._auth_id is not None
async def _load(self):
if self._auth_obj is None and self._auth_id is not None:
async with database as session:
self._auth_obj = (await session.execute(select(user_class).where(attr_loader(user_class, self._auth_id)))).scalar()
if self._auth_obj is None:
raise RuntimeError('failed to fetch user')
def __getattr__(self, key):
if self._auth_obj is None:
raise RuntimeError('user is not loaded')
return getattr(self._auth_obj, key)
def __bool__(self):
return self._auth_obj is not None
@property
def session(self):
return self._auth_sess
async def _unload(self):
# user is not expected to mutate
if self._auth_sess:
await self._auth_sess.rollback()
@property
def user(self):
return self._auth_obj
return UserLoader
# Optional dependency: do not import into __init__.py
__all__ = ('user_loader',)

View file

@ -2,7 +2,8 @@
import unittest
from suou import chalk
from suou import RGBColor, chalk
from suou.color import OKLCHColor
class TestColor(unittest.TestCase):
def setUp(self) -> None:
@ -24,4 +25,9 @@ class TestColor(unittest.TestCase):
strg = "The quick brown fox jumps over the lazy dog"
self.assertEqual(f'\x1b[1m{strg}\x1b[22m', chalk.bold(strg))
self.assertEqual(f'\x1b[2m{strg}\x1b[22m', chalk.faint(strg))
self.assertEqual(f'\x1b[1m\x1b[33m{strg}\x1b[39m\x1b[22m', chalk.bold.yellow(strg))
self.assertEqual(f'\x1b[1m\x1b[33m{strg}\x1b[39m\x1b[22m', chalk.bold.yellow(strg))
def test_oklch_to_rgb(self):
self.assertEqual(OKLCHColor(0.628, 0.2577, 29.23).to_rgb(), RGBColor(255, 0, 0))
self.assertEqual(OKLCHColor(0.7653, 0.1306, 194.77).to_rgb(), RGBColor(0, 204, 204))
self.assertEqual(OKLCHColor(0.5931, 0., 0.).to_rgb(), RGBColor(126, 126, 126))