Compare commits

..

2 commits

Author SHA1 Message Date
6daedc3dee add sass module, update README 2025-07-31 22:53:44 +02:00
73c105d5cb typing whitespace 2025-07-30 23:00:46 +02:00
8 changed files with 220 additions and 14 deletions

View file

@ -4,11 +4,12 @@
+ `sqlalchemy`: add `unbound_fk()`, `bound_fk()`
+ Add `sqlalchemy_async` module with `SQLAlchemy()`
+ Add `timed_cache()`, `TimedDict()`, `none_pass()`, `twocolon_list()`
+ Add `timed_cache()`, `TimedDict()`, `none_pass()`, `twocolon_list()`, `quote_css_string()`, `must_be()`
+ Add module `calendar` with `want_*` date type conversion utilities and `age_and_days()`
+ Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now)
+ Add `redact` module with `redact_url_password()`
+ Add more exceptions: `NotFoundError()`, `BabelTowerError()`
+ Add `sass` module
## 0.4.0

View file

@ -1,12 +1,12 @@
# SIS Unified Object Underarmor
Good morning, my brother! Welcome the SUOU (SIS Unified Object Underarmor), an utility library for developing API's, database schemas and stuff in Python.
Good morning, my brother! Welcome **SUOU** (**S**IS **U**nified **O**bject **U**nderarmor), the Python library which makes API development faster for developing API's, database schemas and stuff in Python.
It provides utilities such as:
* [SIQ](https://yusur.moe/protocols/siq.html)
* signing and generation of access tokens, on top of [ItsDangerous](https://github.com/pallets/itsdangerous)
* helpers for use in Flask and SQLAlchemy
* ...
* helpers for use in Flask, SQLAlchemy, and other popular frameworks
* i forgor 💀
**It is not an ORM** nor a replacement of it; it works along existing ORMs (currently only SQLAlchemy is supported lol).
@ -26,6 +26,22 @@ $ pip install sakuragasaki46-suou[sqlalchemy]
Please note that you probably already have those dependencies, if you just use the library.
## Features
...
## Support
Just a heads up: SUOU was made to support Sakuragasaki46 (me)'s own selfish, egoistic needs. Not to provide a service to the public.
As a consequence, 'add this add that' stuff is best-effort.
Expect breaking changes, disruptive renames in bugfix releases, sudden deprecations, years of unmantainment, or sudden removal of SUOU from GH or pip.
Don't want to depend on my codebase for moral reasons (albeit unrelated)? It's fine. I did not ask you.
**DO NOT ASK TO MAKE SUOU SAFE FOR CHILDREN**. Enjoy having your fingers cut.
## License
Licensed under the [Apache License, Version 2.0](LICENSE), a non-copyleft free and open source license.
@ -36,3 +52,5 @@ I (sakuragasaki46) may NOT be held accountable for Your use of my code.
> It's pointless to file a lawsuit because you feel damaged, and it's only going to turn against you. What a waste of money you could have spent on a vacation or charity, or invested in stocks.
Happy hacking.

View file

@ -12,6 +12,7 @@ dependencies = [
"itsdangerous",
"toml",
"pydantic",
"setuptools>=78.0.0",
"uvloop; os_name=='posix'"
]
# - further devdependencies below - #
@ -44,6 +45,7 @@ flask_sqlalchemy = [
"Flask-SqlAlchemy",
]
peewee = [
## HEADS UP! peewee has setup.py, may slow down installation
"peewee>=3.0.0"
]
markdown = [
@ -51,10 +53,12 @@ markdown = [
]
quart = [
"Quart",
"Quart-Schema"
"Quart-Schema",
"starlette>=0.47.2"
]
quart_sqlalchemy = [
"Quart_SQLALchemy>=3.0.0, <4.0"
sass = [
## HEADS UP!! libsass carries a C extension + uses setup.py
"libsass"
]
full = [
@ -63,9 +67,8 @@ full = [
"sakuragasaki46-suou[quart]",
"sakuragasaki46-suou[peewee]",
"sakuragasaki46-suou[markdown]",
"sakuragasaki46-suou[flask-sqlalchemy]"
# disabled: quart-sqlalchemy causes issues with anyone else. WON'T BE IMPLEMENTED
#"sakuragasaki46-suou[quart-sqlalchemy]"
"sakuragasaki46-suou[flask-sqlalchemy]",
"sakuragasaki46-suou[sass]"
]

25
src/suou/asgi.py Normal file
View file

@ -0,0 +1,25 @@
"""
"""
from typing import Any, Awaitable, Callable, MutableMapping, ParamSpec, Protocol
## TYPES ##
# all the following is copied from Starlette
# available in starlette.types as of starlette==0.47.2
P = ParamSpec("P")
ASGIScope = MutableMapping[str, Any]
ASGIMessage = MutableMapping[str, Any]
ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
ASGISend = Callable[[ASGIMessage], Awaitable[None]]
ASGIApp = Callable[[ASGIScope, ASGIReceive, ASGISend], Awaitable[None]]
class _MiddlewareFactory(Protocol[P]):
def __call__(self, app: ASGIApp, /, *args: P.args, **kwargs: P.kwargs) -> ASGIApp: ... # pragma: no cover
## end TYPES ##

View file

@ -304,6 +304,12 @@ def twocolon_list(s: str | None) -> list[str]:
return []
return [x.strip() for x in s.split('::')]
def quote_css_string(s):
"""Quotes a string as CSS string literal.
Source: libsass==0.23.0"""
return "'" + ''.join(('\\%06x' % ord(c)) for c in s) + "'"
class StringCase(enum.Enum):
"""
Enum values used by regex validators and storage converters.

View file

@ -16,21 +16,24 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
import math
import time
from typing import Callable
from typing import Callable, TypeVar
import warnings
from functools import wraps, lru_cache
_T = TypeVar('_T')
_U = TypeVar('_U')
try:
from warnings import deprecated
except ImportError:
# Python <=3.12 does not implement warnings.deprecated
def deprecated(message: str, /, *, category=DeprecationWarning, stacklevel: int = 1):
def deprecated(message: str, /, *, category=DeprecationWarning, stacklevel: int = 1) -> Callable[[Callable[_T, _U]], Callable[_T, _U]]:
"""
Backport of PEP 702 for Python <=3.12.
The stack_level stuff is not reimplemented on purpose because
too obscure for the average programmer.
"""
def decorator(func: Callable) -> Callable:
def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
@wraps(func)
def wrapper(*a, **ka):
if category is not None:
@ -89,7 +92,7 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[
return wrapper
return decorator
def none_pass(func: Callable, *args, **kwargs):
def none_pass(func: Callable, *args, **kwargs) -> Callable:
"""
Wrap callable so that gets called only on not None values.

138
src/suou/sass.py Normal file
View file

@ -0,0 +1,138 @@
"""
"""
import datetime
import logging
import os
from typing import Callable, Mapping
from sass import CompileError
from sassutils.builder import Manifest
from importlib.metadata import version as _get_version
from .codecs import quote_css_string
from .validators import must_be
from .asgi import _MiddlewareFactory, ASGIApp, ASGIReceive, ASGIScope, ASGISend
from . import __version__ as _suou_version
from pkg_resources import resource_filename
logger = logging.getLogger(__name__)
## NOTE Python/PSF recommends use of importlib.metadata for version checks.
_libsass_version = _get_version('libsass')
class SassAsyncMiddleware(_MiddlewareFactory):
"""
ASGI middleware for development purpose.
Every time a CSS file has requested it finds a matched
Sass/SCSS source file andm then compiled it into CSS.
Eventual syntax errors are displayed in three ways:
- heading CSS comment (i.e. `/* Error: invalid pro*/`)
- **red text** in `body::before` (in most cases very evident, since every other
style fails to render!)
- server-side logging (level is *error*, remember to enable logging!)
app = ASGI application to wrap
manifests = a Mapping of build settings, see sass_manifests= option
in `setup.py`
Shamelessly adapted from libsass==0.23.0 with modifications
XXX experimental and untested!
"""
def __init__(
self, app: ASGIApp, manifests: Mapping, package_dir = {},
error_status = '200 OK'
):
self.app = must_be(app, Callable, 'app must be a ASGI-compliant callable')
self.manifests = Manifest.normalize_manifests(manifests)
self.package_dir = dict(must_be(package_dir, Mapping, 'package_dir must be a mapping'))
## ???
self.error_status = error_status
for package_name in self.manifests:
if package_name in self.package_dir:
continue
self.package_dir[package_name] = resource_filename(package_name, '')
self.paths: list[tuple[str, str, Manifest]] = []
for pkgname, manifest in self.manifests.items():
## WSGI path — is it valid for ASGI as well??
asgi_path = f'/{manifest.wsgi_path.strip('/')}/'
pkg_dir = self.package_dir[pkgname]
self.paths.append((asgi_path, package_dir, manifest))
async def __call__(self, /, scope: ASGIScope, receive: ASGIReceive, send: ASGISend):
path: str = scope.get('path')
if path.endswith('.css'):
for prefix, package_dir, manifest in self.paths:
if not path.startswith(prefix):
continue
css_filename = path[len(prefix):]
sass_filename = manifest.unresolve_filename(package_dir, css_filename)
try:
## TODO consider async??
result = manifest.build_one(
package_dir,
sass_filename,
source_map=True
)
except OSError:
break
except CompileError as e:
logger.error(str(e))
await send({
'type': 'http.response.start',
'status': self.error_status,
'headers': [
'Content-Type: text/css; charset=utf-8'
]
})
await send({
'type': 'http.response.body',
'body': '\n'.join([
'/*',
str(e),
'***',
f'libsass {_libsass_version} + suou {_suou_version} {datetime.datetime.now().isoformat()}',
'*/',
'',
'body::before {',
f' content: {quote_css_string(str(e))};',
' color: maroon;',
' background-color: white;',
' white-space: pre-wrap;',
' display: block;',
' font-family: monospace;',
' user-select: text;'
'}'
]).encode('utf-8')
})
async def _read_file(path):
with open(path, 'rb') as f:
while True:
chunk = f.read(4096)
if chunk:
yield chunk
else:
break
await send({
'type': 'http.response.start',
'status': 200,
'headers': [
'Content-Type: text/css; charset=utf-8'
]
})
async for chunk in _read_file(os.path.join(package_dir, result)):
await send({
'type': 'http.response.body',
'body': chunk
})
await self.app(scope, receive, send)

View file

@ -16,6 +16,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
import re
from typing import Any, Iterable, TypeVar
_T = TypeVar('_T')
def matches(regex: str | int, /, length: int = 0, *, flags=0):
"""
Return a function which returns true if X is shorter than length and matches the given regex.
@ -27,5 +31,13 @@ def matches(regex: str | int, /, length: int = 0, *, flags=0):
return (not length or len(s) < length) and bool(re.fullmatch(regex, s, flags=flags))
return validator
def must_be(obj: _T | Any, typ: type[_T] | Iterable[type], message: str, *, exc = TypeError) -> _T:
"""
Raise TypeError if the requested object is not of the desired type(s), with a nice message.
"""
if not isinstance(obj, typ):
raise TypeError(f'{message}, not {obj.__class__.__name__!r}')
return obj
__all__ = ('matches', )