Compare commits
2 commits
2c52f9b561
...
6daedc3dee
| Author | SHA1 | Date | |
|---|---|---|---|
| 6daedc3dee | |||
| 73c105d5cb |
8 changed files with 220 additions and 14 deletions
|
|
@ -4,11 +4,12 @@
|
||||||
|
|
||||||
+ `sqlalchemy`: add `unbound_fk()`, `bound_fk()`
|
+ `sqlalchemy`: add `unbound_fk()`, `bound_fk()`
|
||||||
+ Add `sqlalchemy_async` module with `SQLAlchemy()`
|
+ 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()`
|
+ 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)
|
+ Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now)
|
||||||
+ Add `redact` module with `redact_url_password()`
|
+ Add `redact` module with `redact_url_password()`
|
||||||
+ Add more exceptions: `NotFoundError()`, `BabelTowerError()`
|
+ Add more exceptions: `NotFoundError()`, `BabelTowerError()`
|
||||||
|
+ Add `sass` module
|
||||||
|
|
||||||
## 0.4.0
|
## 0.4.0
|
||||||
|
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -1,12 +1,12 @@
|
||||||
# SIS Unified Object Underarmor
|
# 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:
|
It provides utilities such as:
|
||||||
* [SIQ](https://yusur.moe/protocols/siq.html)
|
* [SIQ](https://yusur.moe/protocols/siq.html)
|
||||||
* signing and generation of access tokens, on top of [ItsDangerous](https://github.com/pallets/itsdangerous)
|
* 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).
|
**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.
|
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
|
## License
|
||||||
|
|
||||||
Licensed under the [Apache License, Version 2.0](LICENSE), a non-copyleft free and open source 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.
|
> 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ dependencies = [
|
||||||
"itsdangerous",
|
"itsdangerous",
|
||||||
"toml",
|
"toml",
|
||||||
"pydantic",
|
"pydantic",
|
||||||
|
"setuptools>=78.0.0",
|
||||||
"uvloop; os_name=='posix'"
|
"uvloop; os_name=='posix'"
|
||||||
]
|
]
|
||||||
# - further devdependencies below - #
|
# - further devdependencies below - #
|
||||||
|
|
@ -44,6 +45,7 @@ flask_sqlalchemy = [
|
||||||
"Flask-SqlAlchemy",
|
"Flask-SqlAlchemy",
|
||||||
]
|
]
|
||||||
peewee = [
|
peewee = [
|
||||||
|
## HEADS UP! peewee has setup.py, may slow down installation
|
||||||
"peewee>=3.0.0"
|
"peewee>=3.0.0"
|
||||||
]
|
]
|
||||||
markdown = [
|
markdown = [
|
||||||
|
|
@ -51,10 +53,12 @@ markdown = [
|
||||||
]
|
]
|
||||||
quart = [
|
quart = [
|
||||||
"Quart",
|
"Quart",
|
||||||
"Quart-Schema"
|
"Quart-Schema",
|
||||||
|
"starlette>=0.47.2"
|
||||||
]
|
]
|
||||||
quart_sqlalchemy = [
|
sass = [
|
||||||
"Quart_SQLALchemy>=3.0.0, <4.0"
|
## HEADS UP!! libsass carries a C extension + uses setup.py
|
||||||
|
"libsass"
|
||||||
]
|
]
|
||||||
|
|
||||||
full = [
|
full = [
|
||||||
|
|
@ -63,9 +67,8 @@ full = [
|
||||||
"sakuragasaki46-suou[quart]",
|
"sakuragasaki46-suou[quart]",
|
||||||
"sakuragasaki46-suou[peewee]",
|
"sakuragasaki46-suou[peewee]",
|
||||||
"sakuragasaki46-suou[markdown]",
|
"sakuragasaki46-suou[markdown]",
|
||||||
"sakuragasaki46-suou[flask-sqlalchemy]"
|
"sakuragasaki46-suou[flask-sqlalchemy]",
|
||||||
# disabled: quart-sqlalchemy causes issues with anyone else. WON'T BE IMPLEMENTED
|
"sakuragasaki46-suou[sass]"
|
||||||
#"sakuragasaki46-suou[quart-sqlalchemy]"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
25
src/suou/asgi.py
Normal file
25
src/suou/asgi.py
Normal 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 ##
|
||||||
|
|
||||||
|
|
@ -304,6 +304,12 @@ def twocolon_list(s: str | None) -> list[str]:
|
||||||
return []
|
return []
|
||||||
return [x.strip() for x in s.split('::')]
|
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):
|
class StringCase(enum.Enum):
|
||||||
"""
|
"""
|
||||||
Enum values used by regex validators and storage converters.
|
Enum values used by regex validators and storage converters.
|
||||||
|
|
|
||||||
|
|
@ -16,21 +16,24 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from typing import Callable
|
from typing import Callable, TypeVar
|
||||||
import warnings
|
import warnings
|
||||||
from functools import wraps, lru_cache
|
from functools import wraps, lru_cache
|
||||||
|
|
||||||
|
_T = TypeVar('_T')
|
||||||
|
_U = TypeVar('_U')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from warnings import deprecated
|
from warnings import deprecated
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Python <=3.12 does not implement warnings.deprecated
|
# 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.
|
Backport of PEP 702 for Python <=3.12.
|
||||||
The stack_level stuff is not reimplemented on purpose because
|
The stack_level stuff is not reimplemented on purpose because
|
||||||
too obscure for the average programmer.
|
too obscure for the average programmer.
|
||||||
"""
|
"""
|
||||||
def decorator(func: Callable) -> Callable:
|
def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*a, **ka):
|
def wrapper(*a, **ka):
|
||||||
if category is not None:
|
if category is not None:
|
||||||
|
|
@ -89,7 +92,7 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False) -> Callable[[
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
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.
|
Wrap callable so that gets called only on not None values.
|
||||||
|
|
||||||
|
|
|
||||||
138
src/suou/sass.py
Normal file
138
src/suou/sass.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -16,6 +16,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from typing import Any, Iterable, TypeVar
|
||||||
|
|
||||||
|
_T = TypeVar('_T')
|
||||||
|
|
||||||
def matches(regex: str | int, /, length: int = 0, *, flags=0):
|
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.
|
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 (not length or len(s) < length) and bool(re.fullmatch(regex, s, flags=flags))
|
||||||
return validator
|
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', )
|
__all__ = ('matches', )
|
||||||
Loading…
Add table
Add a link
Reference in a new issue