From 6daedc3dee29bdaec1cd35ee37e27a65dea41c24 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 31 Jul 2025 22:53:44 +0200 Subject: [PATCH] add sass module, update README --- CHANGELOG.md | 3 +- README.md | 24 ++++++- pyproject.toml | 15 +++-- src/suou/asgi.py | 25 ++++++++ src/suou/codecs.py | 6 ++ src/suou/sass.py | 138 +++++++++++++++++++++++++++++++++++++++++ src/suou/validators.py | 12 ++++ 7 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 src/suou/asgi.py create mode 100644 src/suou/sass.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dafe7d..dfdeaef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 948b4af..56da550 100644 --- a/README.md +++ b/README.md @@ -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. + diff --git a/pyproject.toml b/pyproject.toml index d5ae206..2194046 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]" ] diff --git a/src/suou/asgi.py b/src/suou/asgi.py new file mode 100644 index 0000000..3f3fd70 --- /dev/null +++ b/src/suou/asgi.py @@ -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 ## + diff --git a/src/suou/codecs.py b/src/suou/codecs.py index 22e52f5..e5f94af 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -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. diff --git a/src/suou/sass.py b/src/suou/sass.py new file mode 100644 index 0000000..c986d63 --- /dev/null +++ b/src/suou/sass.py @@ -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) + + diff --git a/src/suou/validators.py b/src/suou/validators.py index 037d2b6..609b99e 100644 --- a/src/suou/validators.py +++ b/src/suou/validators.py @@ -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', ) \ No newline at end of file