Compare commits

...

47 commits

Author SHA1 Message Date
b1d0c62b44 0.12.0a5 add OKLab and oklch 2025-12-25 11:01:10 +01:00
ef645bd4da 0.12.0a4 add XYZ color 2025-12-20 07:50:43 +01:00
e6ee355f2e 0.12.0a3 add rgb <-> srgb 2025-12-18 16:57:36 +01:00
75adb9fbff 0.12.0a2 remove deprecated configparse from 0.3.0 and AuthSrc derivatives 2025-12-12 11:34:37 +01:00
d123b9c196 0.12.0a1 add Matrix() 2025-12-12 11:03:10 +01:00
eca16d781f 0.11.2 add tests for not_*_than() 2025-12-05 17:45:15 +01:00
3af9d6c9fb 0.11.1 make yesno() accept boolean types 2025-12-01 10:23:59 +01:00
0460062867 0.11.0 wrap SQLAlchemy() sessions by default, add Lawyer(), SpitText(), cb32lencode(), more Snowflake.from_*(), docstring changes 2025-11-28 10:21:26 +01:00
855299c6d5 0.10.2 fix types on cb32decode() 2025-11-27 19:50:33 +01:00
7e6a46c654 0.10.1 fix missing imports 2025-11-23 21:52:01 +01:00
5c9a6f2c7e 0.10.0 add peewee.SnowflakeField() 2025-11-23 19:13:14 +01:00
def2634f21 0.9.0 add yesno() + make Waiter usable + document validators 2025-11-16 10:34:49 +01:00
f1f9a95189 CD/CI gitignore fix pt.2 2025-11-10 17:30:48 +01:00
8e3da63216 CD/CI gitignore fix 2025-11-10 17:29:35 +01:00
305f193f93 0.8.2 fix chalk behavior 2025-11-10 17:18:13 +01:00
9471fc338f 0.8.1 missing type guard in *bound_fk() 2025-11-05 18:08:49 +01:00
4a31fbc14f 0.8.0 improve (experimental) Waiter + add sqlalchemy.username_column() 2025-11-05 10:47:08 +01:00
0ca2fde687 0.7.7 forgot what the actual fix was 2025-11-01 22:43:07 +01:00
96a65c38e3 0.7.6 fix @glue() stray usage 2025-11-01 10:05:22 +01:00
556019e0bd 0.7.5 update sqlalchemy module to require flask_sqlalchemy 2025-11-01 09:29:20 +01:00
c27630c3d6 0.7.4 add test and docs to .iding 2025-10-29 09:28:59 +01:00
10e6c202f0 0.7.3 fix imports (?) in .sqlalchemy, add experimental .glue, docs for .sqlalchemy 2025-10-18 14:48:32 +02:00
7e80c84de6 0.7.2 add version= to @future(), support Py3.14, mark .waiter as future 2025-10-11 18:39:06 +02:00
be4404c520 0.7.1 2025-10-11 11:09:37 +02:00
ef8ce327cd prepare for release 2025-10-11 11:07:50 +02:00
72b759504b improve auto doc 2025-10-11 11:00:50 +02:00
2719f71b06 update readthedocs.yaml 2025-10-11 10:40:58 +02:00
6c00217095 update requirements for Sphinx 2025-10-11 10:35:40 +02:00
fca91bdc54 improve decorator typing 2025-10-11 10:22:49 +02:00
21021875c8 fix again 2025-10-10 20:29:05 +02:00
1b03f3b2e9 again fix 2025-10-10 20:24:38 +02:00
18f6a78524 fix import 2025-10-10 20:22:09 +02:00
484e46b2f9 change to rtd theme 2025-10-10 20:20:23 +02:00
47ac53ea9b try to fix doc building 2025-10-10 20:17:09 +02:00
d48767c603 readd requirements.txt 2025-10-10 20:12:12 +02:00
84af601a6f remove myst_parser 2025-10-10 20:08:47 +02:00
3151948dd0 add requirements.txt (provisional) to make build succeed 2025-10-10 20:05:17 +02:00
efb5ab1a5b add docs 2025-10-10 19:52:48 +02:00
646ac2e1bf 0.7.0 "The Lucky Update" 2025-09-30 20:34:38 +02:00
9c0e889750 version advance 2025-09-30 20:09:21 +02:00
25697ee958 add not_lesser_than(), WebColor(), annotations and documentation changes 2025-09-23 12:52:11 +02:00
17cab8e257 update CHANGELOG 2025-09-19 19:02:46 +02:00
e7726328d3 add @future 2025-09-19 17:22:00 +02:00
f07d691004 add parse_time(), validators.not_greater_than() 2025-09-19 16:52:23 +02:00
18950c3445 add @terminal_required 2025-09-19 16:01:10 +02:00
83ab616e13 add chalk 2025-09-19 15:39:44 +02:00
a2fdc9166f 0.7.x: @lucky, @rng_overload and more exceptions 2025-09-19 13:34:51 +02:00
90 changed files with 2590 additions and 449 deletions

8
.gitignore vendored
View file

@ -25,3 +25,11 @@ dist/
.vscode .vscode
/run.sh /run.sh
ROADMAP.md ROADMAP.md
aliases/*/src
docs/_build
docs/_static
docs/templates
.coverage
# changes during CD/CI
aliases/*/pyproject.toml

17
.readthedocs.yaml Normal file
View file

@ -0,0 +1,17 @@
version: 2
build:
os: ubuntu-24.04
tools:
python: "3.13"
sphinx:
configuration: docs/conf.py
python:
install:
- method: pip
path: .
extra_requirements:
- docs
- full

View file

@ -1,8 +1,109 @@
# Changelog # Changelog
## 0.12.0 "The Color Update"
* All `AuthSrc()` derivatives, deprecated and never used, have been removed.
* 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.
## 0.11.2
+ increase test coverage of `validators`
## 0.11.1
+ make `yesno()` accept boolean types
## 0.11.0
+ **Breaking**: sessions returned by `SQLAlchemy()` are now wrapped by default. Restore original behavior by passing `wrap=False` to the constructor or to `begin()`
+ Slate unused `require_auth()` and derivatives for removal in 0.14.0
+ Add `cb32lencode()`
+ `Snowflake()`: add `.from_cb32()`, `.from_base64()`, `.from_oct()`, `.from_hex()` classmethods
+ Add `SpitText()`
+ Add `Lawyer()` with seven methods
+ Style changes to docstrings
## 0.10.2 and 0.7.11
+ fix incorrect types on `cb32decode()`
## 0.10.1 and 0.7.10
+ `peewee`: fix missing imports
## 0.10.0
+ `peewee`: add `SnowflakeField` class
## 0.9.0
+ Fix to make experimental `Waiter` usable
+ Suspend `glue()` release indefinitely
+ Add `yesno()`
+ Document validators
## 0.8.2 and 0.7.9
+ `.color`: fix `chalk` not behaving as expected
## 0.8.1 and 0.7.8
+ Fix missing type guard in `unbound_fk()` and `bound_fk()`
## 0.8.0
+ Add `username_column()` to `.sqlalchemy`
+ Improve (experimental) `Waiter`
## 0.7.7
+ Fix imports in `.sqlalchemy`
## 0.7.5
+ Delay release of `FakeModule` to 0.9.0
+ Update dependencies: `.sqlalchemy` now requires `flask_sqlalchemy` regardless of use of Flask
## 0.7.4
+ Delay release of `@glue()`
+ Add docs and some tests to `.iding`
+ Fix bug in `SiqGen()` that may prevent generation in short amounts of time
## 0.7.3
+ Fixed some broken imports in `.sqlalchemy`
+ Stage `@glue()` for release in ~~0.8.0~~ 0.9.0
+ Add docs to `.sqlalchemy`
## 0.7.2
+ `@future()` now can take a `version=` argument
+ `Waiter()` got marked `@future` indefinitely
+ Stage `username_column()` for release in 0.8.0
+ Explicit support for Python 3.14 (aka python pi)
## 0.7.1
+ Add documentation ([Read The Docs](https://suou.readthedocs.io/))
+ Improved decorator typing
## 0.7.0 "The Lucky Update"
+ Add RNG/random selection overloads such as `luck()`, `rng_overload()`
+ Add 7 new throwable exceptions
+ Add color utilities: `chalk` object and `WebColor()`
+ Add `.terminal` module, to ease TUI development
+ `calendar`: add `parse_time()`
+ Add validators `not_greater_than()`, `not_less_than()`
+ Add `@future()` decorator: it signals features not yet intended to be public, for instance, backported as a part of a bug fix.
## 0.6.1 ## 0.6.1
- First release on PyPI under the name `suou`. - First release on PyPI under the name `suou`.
- **BREAKING**: if you installed `sakuragasaki46-suou<=0.6.0` you need to uninstall and reinstall or things may break.
- Fix `sqlalchemy.asyncio.SQLAlchemy()` to use context vars; `expire_on_commit=` is now configurable at instantiation. Fix some missing re-exports. - Fix `sqlalchemy.asyncio.SQLAlchemy()` to use context vars; `expire_on_commit=` is now configurable at instantiation. Fix some missing re-exports.
## 0.6.0 ## 0.6.0

View file

@ -1,11 +1,11 @@
# SIS Unified Object Underarmor # SIS Unified Object Underarmor
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. Good morning, my brother! Welcome **SUOU** (**S**IS **U**nified **O**bject **U**nderarmor), the Python library which speeds up and makes it pleasing to develop API, database schemas and stuff in Python.
It provides utilities such as: It provides utilities such as:
* [SIQ](https://yusur.moe/protocols/siq.html) * SIQ ([specification](https://yusur.moe/protocols/siq.html) - [copy](https://suou.readthedocs.io/en/latest/iding.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) *not tested and not working*
* helpers for use in Flask, SQLAlchemy, and other popular frameworks * helpers for use in Flask, [SQLAlchemy](https://suou.readthedocs.io/en/latest/sqlalchemy.html), and other popular frameworks
* i forgor 💀 * 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).
@ -28,11 +28,11 @@ Please note that you probably already have those dependencies, if you just use t
## Features ## Features
... Read the [documentation](https://suou.readthedocs.io/).
## Support ## 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. Just a heads up: SUOU was made to support Sakuragasaki46 (me)'s own selfish, egoistic needs. Not certainly to provide a service to the public.
As a consequence, 'add this add that' stuff is best-effort. As a consequence, 'add this add that' stuff is best-effort.

View file

@ -1,8 +0,0 @@
[project]
name = "sakuragasaki46_suou"
authors = [ { name = "Sakuragasaki46" } ]
version = "0.6.1"
requires-python = ">=3.10"
dependencies = [ "suou==0.6.1" ]
readme = "README.md"

20
docs/Makefile Normal file
View file

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

43
docs/api.rst Normal file
View file

@ -0,0 +1,43 @@
API
===
.. autosummary::
:toctree: generated
:recursive:
suou.sqlalchemy
suou.asgi
suou.bits
suou.calendar
suou.classtools
suou.codecs
suou.collections
suou.color
suou.configparse
suou.dei
suou.dorks
suou.exceptions
suou.flask_restx
suou.flask_sqlalchemy
suou.flask
suou.functools
suou.http
suou.i18n
suou.iding
suou.itertools
suou.legal
suou.lex
suou.luck
suou.markdown
suou.migrate
suou.peewee
suou.quart
suou.redact
suou.sass
suou.signing
suou.snowflake
suou.strtools
suou.terminal
suou.validators
suou.waiter

19
docs/color.rst Normal file
View file

@ -0,0 +1,19 @@
Color
=====
.. currentmodule:: suou.color
...
Web colors
----------
.. autoclass:: RGBColor
.. autoclass:: WebColor
.. autoclass:: XYZColor

53
docs/conf.py Normal file
View file

@ -0,0 +1,53 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import sys
from pathlib import Path
sys.path.insert(0, str(Path("..", "src").resolve()))
project = 'suou'
copyright = '2025 Sakuragasaki46'
author = 'Sakuragasaki46'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"sphinx.ext.autodoc",
'sphinx.ext.autosummary',
'sphinx_rtd_theme'
]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
autodoc_mock_imports = [
"toml",
"starlette",
"itsdangerous",
#"pydantic",
"quart_schema"
]
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_rtd_theme'
html_theme_path = ["_themes", ]
html_static_path = ['_static']
def polish_module_docstring(app, what, name, obj, options, lines):
if what == "module" and 'members' in options:
try:
del lines[lines.index('---'):]
except Exception:
pass
def setup(app):
app.connect("autodoc-process-docstring", polish_module_docstring)

View file

@ -0,0 +1,6 @@
suou.asgi
=========
.. automodule:: suou.asgi

View file

@ -0,0 +1,17 @@
suou.bits
=========
.. automodule:: suou.bits
.. rubric:: Functions
.. autosummary::
count_ones
join_bits
mask_shift
mod_ceil
mod_floor
split_bits

View file

@ -0,0 +1,16 @@
suou.calendar
=============
.. automodule:: suou.calendar
.. rubric:: Functions
.. autosummary::
age_and_days
parse_time
want_datetime
want_isodate
want_timestamp

View file

@ -0,0 +1,16 @@
suou.classtools
===============
.. automodule:: suou.classtools
.. rubric:: Classes
.. autosummary::
Incomplete
MissingType
ValueProperty
ValueSource
Wanted

View file

@ -0,0 +1,37 @@
suou.codecs
===========
.. automodule:: suou.codecs
.. rubric:: Functions
.. autosummary::
b2048decode
b2048encode
b32ldecode
b32lencode
b64decode
b64encode
cb32decode
cb32encode
cb32lencode
jsonencode
quote_css_string
rb64decode
rb64encode
ssv_list
twocolon_list
want_bytes
want_str
want_urlsafe
want_urlsafe_bytes
z85encode
.. rubric:: Classes
.. autosummary::
StringCase

View file

@ -0,0 +1,12 @@
suou.collections
================
.. automodule:: suou.collections
.. rubric:: Classes
.. autosummary::
TimedDict

View file

@ -0,0 +1,15 @@
suou.color
==========
.. automodule:: suou.color
.. rubric:: Classes
.. autosummary::
Chalk
RGBColor
SRGBColor
WebColor

View file

@ -0,0 +1,18 @@
suou.configparse
================
.. automodule:: suou.configparse
.. rubric:: Classes
.. autosummary::
ArgConfigSource
ConfigOptions
ConfigParserConfigSource
ConfigSource
ConfigValue
DictConfigSource
EnvConfigSource

View file

@ -0,0 +1,24 @@
suou.dei
========
.. automodule:: suou.dei
.. rubric:: Module Attributes
.. autosummary::
BRICKS
.. rubric:: Functions
.. autosummary::
dei_args
.. rubric:: Classes
.. autosummary::
Pronoun

View file

@ -0,0 +1,6 @@
suou.dorks
==========
.. automodule:: suou.dorks

View file

@ -0,0 +1,24 @@
suou.exceptions
===============
.. automodule:: suou.exceptions
.. rubric:: Exceptions
.. autosummary::
BabelTowerError
BadLuckError
BrokenStringsError
Fahrenheit451Error
FuckAroundFindOutError
InconsistencyError
LexError
MissingConfigError
MissingConfigWarning
NotFoundError
PoliticalError
PoliticalWarning
TerminalRequiredError

View file

@ -0,0 +1,16 @@
suou.flask
==========
.. automodule:: suou.flask
.. rubric:: Functions
.. autosummary::
add_context_from_config
add_i18n
get_flask_conf
harden
negotiate

View file

@ -0,0 +1,18 @@
suou.flask\_restx
=================
.. automodule:: suou.flask_restx
.. rubric:: Functions
.. autosummary::
output_json
.. rubric:: Classes
.. autosummary::
Api

View file

@ -0,0 +1,6 @@
suou.flask\_sqlalchemy
======================
.. automodule:: suou.flask_sqlalchemy

View file

@ -0,0 +1,18 @@
suou.functools
==============
.. automodule:: suou.functools
.. rubric:: Functions
.. autosummary::
alru_cache
deprecated_alias
flat_args
future
none_pass
not_implemented
timed_cache

View file

@ -0,0 +1,12 @@
suou.http
=========
.. automodule:: suou.http
.. rubric:: Classes
.. autosummary::
WantsContentType

View file

@ -0,0 +1,16 @@
suou.i18n
=========
.. automodule:: suou.i18n
.. rubric:: Classes
.. autosummary::
I18n
I18nLang
IdentityLang
JsonI18n
TomlI18n

View file

@ -0,0 +1,22 @@
suou.iding
==========
.. automodule:: suou.iding
.. rubric:: Functions
.. autosummary::
make_domain_hash
.. rubric:: Classes
.. autosummary::
Siq
SiqCache
SiqFormatType
SiqGen
SiqType

View file

@ -0,0 +1,23 @@
suou.itertools
==============
.. automodule:: suou.itertools
.. rubric:: Functions
.. autosummary::
addattr
additem
kwargs_prefix
ltuple
makelist
rtuple
.. rubric:: Classes
.. autosummary::
hashed_list

View file

@ -0,0 +1,12 @@
suou.legal
==========
.. automodule:: suou.legal
.. rubric:: Classes
.. autosummary::
Lawyer

View file

@ -0,0 +1,6 @@
suou.lex
========
.. currentmodule:: suou
.. autofunction:: lex

View file

@ -0,0 +1,19 @@
suou.luck
=========
.. automodule:: suou.luck
.. rubric:: Functions
.. autosummary::
lucky
rng_overload
.. rubric:: Classes
.. autosummary::
RngCallable

View file

@ -0,0 +1,16 @@
suou.markdown
=============
.. automodule:: suou.markdown
.. rubric:: Classes
.. autosummary::
MentionPattern
PingExtension
SpoilerExtension
StrikethroughExtension
StrikethroughPostprocessor

View file

@ -0,0 +1,14 @@
suou.migrate
============
.. automodule:: suou.migrate
.. rubric:: Classes
.. autosummary::
SiqMigrator
SnowflakeSiqMigrator
UlidSiqMigrator

View file

@ -0,0 +1,23 @@
suou.peewee
===========
.. automodule:: suou.peewee
.. rubric:: Functions
.. autosummary::
connect_reconnect
.. rubric:: Classes
.. autosummary::
ConnectToDatabase
PeeweeConnectionState
ReconnectMysqlDatabase
RegexCharField
SiqField
SnowflakeField

View file

@ -0,0 +1,14 @@
suou.quart
==========
.. automodule:: suou.quart
.. rubric:: Functions
.. autosummary::
add_i18n
add_rest
negotiate

View file

@ -0,0 +1,12 @@
suou.redact
===========
.. automodule:: suou.redact
.. rubric:: Functions
.. autosummary::
redact_url_password

6
docs/generated/suou.rst Normal file
View file

@ -0,0 +1,6 @@
suou
====
.. automodule:: suou

View file

@ -0,0 +1,12 @@
suou.sass
=========
.. automodule:: suou.sass
.. rubric:: Classes
.. autosummary::
SassAsyncMiddleware

View file

@ -0,0 +1,13 @@
suou.signing
============
.. automodule:: suou.signing
.. rubric:: Classes
.. autosummary::
HasSigner
UserSigner

View file

@ -0,0 +1,13 @@
suou.snowflake
==============
.. automodule:: suou.snowflake
.. rubric:: Classes
.. autosummary::
Snowflake
SnowflakeGen

View file

@ -0,0 +1,20 @@
suou.sqlalchemy.asyncio
=======================
.. automodule:: suou.sqlalchemy.asyncio
.. rubric:: Functions
.. autosummary::
async_query
.. rubric:: Classes
.. autosummary::
AsyncSelectPagination
SQLAlchemy
SessionWrapper

View file

@ -0,0 +1,33 @@
suou.sqlalchemy.orm
===================
.. automodule:: suou.sqlalchemy.orm
.. rubric:: Functions
.. autosummary::
a_relationship
age_pair
author_pair
bool_column
bound_fk
declarative_base
entity_base
id_column
match_column
match_constraint
parent_children
secret_column
snowflake_column
unbound_fk
username_column
want_column
.. rubric:: Classes
.. autosummary::
BitSelector

View file

@ -0,0 +1,34 @@
suou.sqlalchemy
===============
.. automodule:: suou.sqlalchemy
.. rubric:: Module Attributes
.. autosummary::
IdType
.. rubric:: Functions
.. autosummary::
create_session
require_auth_base
token_signer
.. rubric:: Classes
.. autosummary::
AuthSrc
.. rubric:: Modules
.. autosummary::
:toctree:
:recursive:
asyncio
orm

View file

@ -0,0 +1,13 @@
suou.strtools
=============
.. automodule:: suou.strtools
.. rubric:: Classes
.. autosummary::
PrefixIdentifier
SpitText

View file

@ -0,0 +1,12 @@
suou.terminal
=============
.. automodule:: suou.terminal
.. rubric:: Functions
.. autosummary::
terminal_required

View file

@ -0,0 +1,16 @@
suou.validators
===============
.. automodule:: suou.validators
.. rubric:: Functions
.. autosummary::
matches
must_be
not_greater_than
not_less_than
yesno

View file

@ -0,0 +1,14 @@
suou.waiter
===========
.. automodule:: suou.waiter
.. rubric:: Functions
.. autosummary::
Waiter
ko
ok

197
docs/iding.rst Normal file
View file

@ -0,0 +1,197 @@
IDing
=====
.. currentmodule:: suou.iding
...
SIQ
---
The main point of the SUOU library is to provide an implementation for the methods of SIS, a protocol for information exchange in phase of definition,
and of which SUOU is the reference implementation.
The key element is the ID format called SIQ, a 112-bit identifier format.
Here follow an extract from the `specification`_:
.. _specification: <https://yusur.moe/protocols/siq.html>
Why SIQ?
********
.. highlights::
I needed unique, compact, decentralized, reproducible and sortable identifiers for my applications.
Something I could reliably use as database key, as long as being fit for my purposes, in the context of a larger project, a federated protocol.
Why not ...
***********
.. highlights::
* **Serial numbers**? They are relative. If they needed to be absolute, they would have to be issued by a single central authority for everyone else. Unacceptable for a decentralized protocol.
* **Username-domain identifiers**? Despite them being in use in other decentralized protocols (such as ActivityPub and Matrix), they are immutable and bound to a single domain. It means, the system sees different domains or usernames as different users. Users can't change their username after registration, therefore forcing them to carry an unpleasant or cringe handle for the rest of their life.
* **UUID**'s? UUIDs are unreliable. Most services use UUIDv4's, which are just opaque sequences of random bytes, and definitely not optimal as database keys. Other versions exist (such as the timestamp-based [UUIDv7](https://uuidv7.org)), however they still miss something needed for cross-domain uniqueness. In any case, UUIDs need to waste some bits to specify their "protocol".
* **Snowflake**s? Snowflakes would be a good choice, and are the inspiration for SIQ themselves. However, 64 bits are not enough for our use case, and Snowflake is *already making the necessary sacrifices* to ensure everything fits into 64 bits (i.e. the epoch got significantly moved forward).
* **Content hashes**? They are based on content, therefore they require content to be immutable and undeletable. Also: collisions.
* **PLC**'s (i.e. the ones in use at BlueSky)? [The implementation is cryptic](https://github.com/did-method-plc/did-method-plc). Moreover, it requires a central authority, and BlueSky is, as of now, holding the role of the sole authority. The resulting identifier as well is apparently random, therefore unorderable.
* **ULID**'s? They are just UUIDv4's with a timestamp. Sortable? Yes. Predictable? No, random bits rely on the assumption of being generated on a single host — i.e. centralization. Think of them as yet another attempt to UUIDv7's.
Anatomy of a SIQ
****************
SIQ's are **112 bit** binary strings. Why 112? Why not 128? Idk, felt like it. Maybe to save space. Maybe because I could fit it into UUID some day — UUID already reserves some bits for the protocol.
Those 112 bits split up into:
* 56 bits of **timestamp**;
* 8 bits of process ("**shard**") information;
* 32 bits of **domain** hash;
* 16 bits of **serial** and **qualifier**.
Here is a graph of a typical SIQ layout:
```
0: tttttttt tttttttt tttttttt tttttttt tttttttt
40: uuuuuuuu uuuuuuuu ssssssss dddddddd dddddddd
80: dddddddd dddddddd nnnnnnnn nnqqqqqq
where:
t : timestamp -- seconds
u : timestamp -- fraction seconds
s : shard
d : domain hash
n : progressive
q : qualifier (variable width, in fact)
```
Timestamp
*********
SIQ uses 56 bits for storing timestamp:
- **40 bits** for **seconds**;
- **16 bits** for **fraction seconds**.
There is no need to explain [why I need no less than 40 bits for seconds](https://en.wikipedia.org/wiki/Year_2038_problem).
Most standards — including Snowflake and ULID — store timestamp in *milliseconds*. It means the system needs to make a division by 1000 to retrieve second value.
But 1000 is almost 1024, right? So the last ten bits can safely be ignored and we easily obtain a UNIX timestamp by doing a right shi-&nbsp; wait.
It's more comfortable to assume that 1024 is nearly 1000. *Melius abundare quam deficere*. And injective mapping is there.
But rounding? Truncation? Here comes the purpose of the 6 additional trailing bits: precision control. Bits from dividing milliseconds o'clock are different from those from rounding microseconds.
Yes, most systems can't go beyond milliseconds for accuracy — standard Java is like that. But detecting platform accuracy is beyond my scope.
There are other factors to ensure uniqueness: *domain* and *shard* bits.
Domain, shard
*************
The temporal uniqueness is ensured by timestamp. However, in a distributed, federated system there is the chance for the same ID to get generated twice by two different subjects.
Therefore, *spacial* uniqueness must be enforced in some way.
Since SIQ's are going to be used the most in web applications, a way to differentiate *spacially* different applications is via the **domain name**.
I decided to reserve **32 bits** for the domain hash.
The algorithm of choice is **SHA-256** for its well-known diffusion and collision resistance. However, 256 bits are too much to fit into a SIQ! So, the last 4 bytes are taken.
*...*
Development and testing environments may safely set all the domain bits to 0.
Qualifiers
**********
The last 16 bits are special, in a way that makes those identifiers unique, and you can tell what is what just by looking at them.
Inspired by programming language implementations, such as OCaml and early JavaScript, a distinguishing bit affix differentiates among types of heterogeneous entities:
* terminal entities (leaves) end in ``1``. This includes content blobs, array elements, and relationships;
* non-leaves end in ``0``.
The full assigment scheme (managed by me) looks like this:
-------------------------------------------------------
Suffix Usage
=======================================================
``x00000`` user account
``x10000`` application (e.g. API, client, bot, form)
``x01000`` event, task
``x11000`` product, subscription
``x00100`` user group, membership, role
``x10100`` collection, feed
``x01100`` invite
``x11100`` *unassigned*
``x00010`` tag, category
``x10010`` *unassigned*
``x01010`` channel (guild, live chat, forum, wiki~)
``x11010`` *unassigned*
``xx0110`` thread, page
``xx1110`` message, post, revision
``xxx001`` 3+ fk relationship
``xxx101`` many-to-many, hash array element
``xxx011`` array element (one to many)
``xxx111`` content
--------------------------------------------------------
The leftover bits are used as progressive serials, incremented as generation continues, and usually reset when timestamp is incremented.
Like with snowflakes and ULID's, if you happen to run out with serials, you need to wait till timestamp changes. Usually around 15 microseconds.
Storage
*******
It is advised to store in databases as *16 byte binary strings*.
- In MySQL/MariaDB, it's ``VARBINARY(16)``.
The two extra bytes are to ease alignment, and possible expansion of timestamp range — even though it would not be an issue until some years after 10,000 CE.
It is possible to fit them into UUID's (specifically, UUIDv8's — custom ones), taking advantage from databases and libraries implementing a UUID type — e.g. PostgreSQL.
Unfortunately, nobody wants to deal with storing arbitrarily long integers — lots of issues pop up by going beyond 64. 128 bit integers are not natively supported in most places. Let alone 112 bit ones.
(end of extract)
Implementation
**************
.. autoclass:: Siq
.. autoclass:: SiqGen
.. automethod:: SiqGen.__init__
.. automethod:: SiqGen.generate
Snowflake
---------
SUOU also implements \[the Discord flavor of\] Snowflake ID's.
This flavor of Snowflake requires an epoch date, and consists of:
* 42 bits of timestamp, with millisecond precision;
* 10 bits for, respectively, worker ID (5 bits) and shard ID (5 bits);
* 12 bits incremented progressively.
.. autoclass:: suou.snowflake.Snowflake
.. autoclass:: suou.snowflake.SnowflakeGen
Other ID formats
----------------
Other ID formats (such as UUID's, ULID's) are implemented by other libraries.
In particular, Python itself has support for UUID in the Standard Library.

20
docs/index.rst Normal file
View file

@ -0,0 +1,20 @@
.. suou documentation master file, created by
sphinx-quickstart on Fri Oct 10 19:24:23 2025.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
SUOU
==================
SUOU (acronym for **SIS Unified Object Underarmour**) is a casual Python library providing utilities to
ease programmer's QoL and write shorter and cleaner code that works.
.. toctree::
:maxdepth: 2
sqlalchemy
iding
validators
color
api

35
docs/make.bat Normal file
View file

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

48
docs/sqlalchemy.rst Normal file
View file

@ -0,0 +1,48 @@
sqlalchemy helpers
==================
.. currentmodule:: suou.sqlalchemy
SUOU provides several helpers to make sqlalchemy learning curve less steep.
In fact, there are pre-made column presets for a specific purpose.
Columns
-------
.. autofunction:: id_column
.. warning::
``id_column()`` expects SIQ's!
.. autofunction:: snowflake_column
.. autofunction:: match_column
.. autofunction:: secret_column
.. autofunction:: bool_column
.. autofunction:: username_column
.. autofunction:: unbound_fk
.. autofunction:: bound_fk
Column pairs
------------
.. autofunction:: age_pair
.. autofunction:: author_pair
.. autofunction:: parent_children
Misc
----
.. autoclass:: BitSelector
.. autofunction:: match_constraint
.. autofunction:: a_relationship
.. autofunction:: declarative_base
.. autofunction:: want_column

15
docs/validators.rst Normal file
View file

@ -0,0 +1,15 @@
validators
==================
.. currentmodule:: suou.validators
Validators for use in frameworks such as Pydantic or Marshmallow.
.. autofunction:: matches
.. autofunction:: not_greater_than
.. autofunction:: not_less_than
.. autofunction:: yesno

View file

@ -27,23 +27,27 @@ classifiers = [
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13" "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14"
] ]
[project.urls] [project.urls]
Repository = "https://nekode.yusur.moe/yusur/suou" Repository = "https://nekode.yusur.moe/yusur/suou"
Documentation = "https://suou.readthedocs.io"
[project.optional-dependencies] [project.optional-dependencies]
# the below are all dev dependencies (and probably already installed) # the below are all dev dependencies (and probably already installed)
sqlalchemy = [ sqlalchemy = [
"SQLAlchemy[asyncio]>=2.0.0" "SQLAlchemy[asyncio]>=2.0.0",
"flask-sqlalchemy"
] ]
flask = [ flask = [
"Flask>=2.0.0", "Flask>=2.0.0",
"Flask-RestX" "Flask-RestX"
] ]
flask_sqlalchemy = [ flask_sqlalchemy = [
"Flask-SqlAlchemy", "suou[sqlalchemy]",
"suou[flask]"
] ]
peewee = [ peewee = [
## HEADS UP! peewee has setup.py, may slow down installation ## HEADS UP! peewee has setup.py, may slow down installation
@ -68,10 +72,15 @@ full = [
"suou[quart]", "suou[quart]",
"suou[peewee]", "suou[peewee]",
"suou[markdown]", "suou[markdown]",
"suou[flask-sqlalchemy]",
"suou[sass]" "suou[sass]"
] ]
docs = [
"sphinx>=2.1",
"myst_parser",
"sphinx_rtd_theme"
]
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = { attr = "suou.__version__" } version = { attr = "suou.__version__" }

14
requirements.txt Normal file
View file

@ -0,0 +1,14 @@
# This file is only used for Sphinx.
# End users should use pyproject.toml instead
itsdangerous==2.2.0
libsass==0.23.0
peewee==3.18.1
pydantic==2.12.0
quart_schema==0.22.0
setuptools==80.9.0
starlette==0.48.0
SQLAlchemy==2.0.40
toml==0.10.2
sphinx_rtd_theme==3.0.2

View file

@ -24,7 +24,7 @@ from .calendar import want_datetime, want_isodate, want_timestamp, age_and_days
from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource
from .collections import TimedDict from .collections import TimedDict
from .dei import dei_args from .dei import dei_args
from .functools import deprecated, not_implemented, timed_cache, none_pass, alru_cache from .functools import deprecated, not_implemented, timed_cache, none_pass, alru_cache, future
from .classtools import Wanted, Incomplete from .classtools import Wanted, Incomplete
from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr
from .i18n import I18n, JsonI18n, TomlI18n from .i18n import I18n, JsonI18n, TomlI18n
@ -32,25 +32,31 @@ from .signing import UserSigner
from .snowflake import Snowflake, SnowflakeGen from .snowflake import Snowflake, SnowflakeGen
from .lex import symbol_table, lex, ilex from .lex import symbol_table, lex, ilex
from .strtools import PrefixIdentifier from .strtools import PrefixIdentifier
from .validators import matches from .validators import matches, not_less_than, not_greater_than, yesno
from .redact import redact_url_password from .redact import redact_url_password
from .http import WantsContentType from .http import WantsContentType
from .color import OKLabColor, chalk, WebColor, RGBColor, SRGBColor, XYZColor, OKLabColor
from .mat import Matrix
__version__ = "0.6.1" __version__ = "0.12.0a5"
__all__ = ( __all__ = (
'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue', 'ConfigOptions', 'ConfigParserConfigSource', 'ConfigSource', 'ConfigValue',
'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n',
'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Matrix', 'MissingConfigError', 'MissingConfigWarning', 'OKLabColor',
'PrefixIdentifier', 'RGBColor', 'SRGBColor',
'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen',
'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType', 'StringCase', 'TimedDict', 'TomlI18n', 'UserSigner', 'Wanted', 'WantsContentType',
'WebColor', 'XYZColor',
'addattr', 'additem', 'age_and_days', 'alru_cache', 'b2048decode', 'b2048encode', 'addattr', 'additem', 'age_and_days', 'alru_cache', 'b2048decode', 'b2048encode',
'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode',
'cb32decode', 'count_ones', 'dei_args', 'deprecated', 'ilex', 'join_bits', 'cb32decode', 'chalk', 'count_ones', 'dei_args', 'deprecated',
'future', 'ilex', 'join_bits',
'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift',
'matches', 'mod_ceil', 'mod_floor', 'none_pass', 'not_implemented', 'matches', 'mod_ceil', 'mod_floor', 'must_be', 'none_pass', 'not_implemented',
'not_less_than', 'not_greater_than',
'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', 'redact_url_password', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table',
'timed_cache', 'twocolon_list', 'want_bytes', 'want_datetime', 'want_isodate', 'timed_cache', 'twocolon_list', 'want_bytes', 'want_datetime', 'want_isodate',
'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes', 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes', 'yesno',
'z85encode', 'z85decode' 'z85encode', 'z85decode'
) )

View file

@ -1,5 +1,17 @@
""" """
ASGI stuff
---
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 typing import Any, Awaitable, Callable, MutableMapping, ParamSpec, Protocol from typing import Any, Awaitable, Callable, MutableMapping, ParamSpec, Protocol

View file

@ -17,7 +17,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
import datetime import datetime
from suou.functools import not_implemented from suou.luck import lucky
from suou.validators import not_greater_than
def want_isodate(d: datetime.datetime | str | float | int, *, tz = None) -> str: def want_isodate(d: datetime.datetime | str | float | int, *, tz = None) -> str:
@ -63,4 +64,28 @@ def age_and_days(date: datetime.datetime, now: datetime.datetime | None = None)
d = (now - datetime.date(date.year + y, date.month, date.day)).days d = (now - datetime.date(date.year + y, date.month, date.day)).days
return y, d return y, d
__all__ = ('want_datetime', 'want_timestamp', 'want_isodate', 'age_and_days') @lucky([not_greater_than(259200)])
def parse_time(timestr: str, /) -> int:
"""
Parse a number-suffix (es. 3s, 15m) or colon (1:30) time expression.
Returns seconds as an integer.
"""
if timestr.isdigit():
return int(timestr)
elif ':' in timestr:
timeparts = timestr.split(':')
if not timeparts[0].isdigit() and not all(x.isdigit() and len(x) == 2 for x in timeparts[1:]):
raise ValueError('invalid time format')
return sum(int(x) * 60 ** (len(timeparts) - 1 - i) for i, x in enumerate(timeparts))
elif timestr.endswith('s') and timestr[:-1].isdigit():
return int(timestr[:-1])
elif timestr.endswith('m') and timestr[:-1].isdigit():
return int(timestr[:-1]) * 60
elif timestr.endswith('h') and timestr[:-1].isdigit():
return int(float(timestr[:-1]) * 3600)
else:
raise ValueError('invalid time format')
__all__ = ('want_datetime', 'want_timestamp', 'want_isodate', 'age_and_days', 'parse_time')

View file

@ -179,7 +179,13 @@ def cb32encode(val: bytes) -> str:
''' '''
return want_str(base64.b32encode(val)).translate(B32_TO_CROCKFORD) return want_str(base64.b32encode(val)).translate(B32_TO_CROCKFORD)
def cb32decode(val: bytes | str) -> str: def cb32lencode(val: bytes) -> str:
'''
Encode bytes in Crockford Base32, lowercased.
'''
return want_str(base64.b32encode(val)).translate(B32_TO_CROCKFORD).lower()
def cb32decode(val: bytes | str) -> bytes:
''' '''
Decode bytes from Crockford Base32. Decode bytes from Crockford Base32.
''' '''

View file

@ -28,7 +28,7 @@ class TimedDict(dict[_KT, _VT]):
""" """
Dictionary where keys expire after the defined time to live, expressed in seconds. Dictionary where keys expire after the defined time to live, expressed in seconds.
NEW 0.5.0 *New in 0.5.0*
""" """
_expires: dict[_KT, int] _expires: dict[_KT, int]
_ttl: int _ttl: int

307
src/suou/color.py Normal file
View file

@ -0,0 +1,307 @@
"""
Colors for coding artists
*New in 0.7.0*
---
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 collections import namedtuple
from functools import lru_cache
import math
from suou.mat import Matrix
class Chalk:
"""
ANSI escape codes for terminal colors, similar to JavaScript's `chalk` library.
Best used with Python 3.12+ that allows arbitrary nesting of f-strings.
Yes, I am aware colorama exists.
UNTESTED
*New in 0.7.0*
"""
CSI = '\x1b['
RED = CSI + "31m"
GREEN = CSI + "32m"
YELLOW = CSI + "33m"
BLUE = CSI + "34m"
CYAN = CSI + "36m"
PURPLE = CSI + "35m"
GREY = CSI + "90m"
END_COLOR = CSI + "39m"
BOLD = CSI + "1m"
END_BOLD = CSI + "22m"
FAINT = CSI + "2m"
def __init__(self, flags = (), ends = ()):
self._flags = tuple(flags)
self._ends = tuple(ends)
@lru_cache()
def _wrap(self, beg, end):
return Chalk(self._flags + (beg,), self._ends + (end,))
def __call__(self, s: str) -> str:
return ''.join(self._flags) + s + ''.join(reversed(self._ends))
@property
def red(self):
return self._wrap(self.RED, self.END_COLOR)
@property
def green(self):
return self._wrap(self.GREEN, self.END_COLOR)
@property
def blue(self):
return self._wrap(self.BLUE, self.END_COLOR)
@property
def yellow(self):
return self._wrap(self.YELLOW, self.END_COLOR)
@property
def cyan(self):
return self._wrap(self.CYAN, self.END_COLOR)
@property
def purple(self):
return self._wrap(self.PURPLE, self.END_COLOR)
@property
def grey(self):
return self._wrap(self.GREY, self.END_COLOR)
gray = grey
marine = blue
magenta = purple
@property
def bold(self):
return self._wrap(self.BOLD, self.END_BOLD)
@property
def faint(self):
return self._wrap(self.FAINT, self.END_BOLD)
## TODO make it lazy / an instance variable?
chalk = Chalk()
## Utilities for web colors
class RGBColor(namedtuple('_WebColor', 'red green blue')):
"""
Representation of a color in the RGB TrueColor space.
Useful for theming.
*Changed in 0.12.0*: name is now RGBColor, with WebColor being an alias.
Added conversions to and from OKLCH, OKLab, sRGB, and XYZ.
"""
def lighten(self, *, factor = .75):
"""
Return a whitened shade of the color.
Factor stands between 0 and 1: 0 = total white, 1 = no change. Default is .75
"""
return WebColor(
255 - int((255 - self.red) * factor),
255 - int((255 - self.green) * factor),
255 - int((255 - self.blue) * factor),
)
def darken(self, *, factor = .75):
"""
Return a darkened shade of the color.
Factor stands between 0 and 1: 0 = total black, 1 = no change. Default is .75
"""
return WebColor(
int(self.red * factor),
int(self.green * factor),
int(self.blue * factor)
)
def greyen(self, *, factor = .75):
"""
Return a desaturated shade of the color.
Factor stands between 0 and 1: 0 = gray, 1 = no change. Default is .75
"""
return self.darken(factor=factor) + self.lighten(factor=factor)
def blend_with(self, other: RGBColor):
"""
Mix two colors, returning the average.
"""
return RGBColor (
(self.red + other.red) // 2,
(self.green + other.green) // 2,
(self.blue + other.blue) // 2
)
def to_srgb(self):
"""
Convert to sRGB space.
*New in 0.12.0*
"""
return SRGBColor(*(
(i / 12.92 if abs(i) <= 0.04045 else
(-1 if i < 0 else 1) * (((abs(i) + 0.55)) / 1.055) ** 2.4) for i in self
))
def to_oklab(self):
return self.to_xyz().to_oklab()
__add__ = blend_with
def __str__(self):
return f"rgb({self.red}, {self.green}, {self.blue})"
RGB_TO_XYZ = Matrix([
[0.41239079926595934, 0.357584339383878, 0.1804807884018343],
[0.21263900587151027, 0.715168678767756, 0.07219231536073371],
[0.01933081871559182, 0.11919477979462598, 0.9505321522496607]
])
def to_xyz(self):
return XYZColor(*(self.RGB_TO_XYZ @ Matrix.as_column(self)).get_column())
def to_oklch(self):
return self.to_xyz().to_oklch()
def to_oklab(self):
return self.to_xyz().to_oklab()
WebColor = RGBColor
## The following have been adapted from
## https://gist.github.com/dkaraush/65d19d61396f5f3cd8ba7d1b4b3c9432
class SRGBColor(namedtuple('_SRGBColor', 'red green blue')):
"""
Represent a color in the sRGB space.
*New in 0.12.0*
"""
red: float
green: float
blue: float
def __str__(self):
return f"srgb({self.red}, {self.green}, {self.blue})"
def to_rgb(self):
return RGBColor(*(
((-1 if i < 0 else 1) * (1.055 * (abs(i) ** (1/2.4)) - 0.055)
if abs(i) > 0.0031308 else 12.92 * i) for i in self))
def to_xyz(self):
return self.to_rgb().to_xyz()
def to_oklab(self):
return self.to_rgb().to_oklab()
class XYZColor(namedtuple('_XYZColor', 'x y z')):
"""
Represent a color in the XYZ color space.
*New in 0.12.0*
"""
XYZ_TO_RGB = Matrix([
[ 3.2409699419045226, -1.537383177570094, -0.4986107602930034],
[-0.9692436362808796, 1.8759675015077202, 0.04155505740717559],
[ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786]
])
XYZ_TO_LMS = Matrix([
[0.8190224379967030, 0.3619062600528904, -0.1288737815209879],
[0.0329836539323885, 0.9292868615863434, 0.0361446663506424],
[0.0481771893596242, 0.2642395317527308, 0.6335478284694309]
])
LMSG_TO_OKLAB = Matrix([
[0.2104542683093140, 0.7936177747023054, -0.0040720430116193],
[1.9779985324311684, -2.4285922420485799, 0.4505937096174110],
[0.0259040424655478, 0.7827717124575296, -0.8086757549230774]
])
def to_rgb(self):
return RGBColor(*(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]
oklab = (self.LMSG_TO_OKLAB @ Matrix.as_column(self)).get_column()
return OKLabColor(*oklab)
def to_oklch(self):
return self.to_oklab().to_oklch()
class OKLabColor(namedtuple('_OKLabColor', 'l a b')):
"""
Represent a color in the OKLab color space.
*New in 0.12.0*
"""
OKLAB_TO_LMSG = Matrix([
[1., 0.3963377773761749, 0.2158037573099136],
[1., -0.1055613458156586, -0.0638541728258133],
[1., -0.0894841775298119, -1.2914855480194092]
])
LMS_TO_XYZ = Matrix([
[ 1.2268798758459243, -0.5578149944602171, 0.2813910456659647],
[-0.0405757452148008, 1.1122868032803170, -0.0717110580655164],
[-0.0763729366746601, -0.4214933324022432, 1.5869240198367816]
])
def to_xyz(self):
lmsg = (self.OKLAB_TO_LMSG @ Matrix.as_column(self)).get_column()
lms = [i ** 3 for i in lmsg]
xyz = (self.LMS_TO_XYZ @ Matrix.as_column(lms)).get_column()
return XYZColor(*xyz)
def to_oklch(self):
return OKLCHColor(
self.l,
math.sqrt(self.a ** 2 + self.b ** 2),
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 to_rgb(self):
return self.to_xyz().to_rgb()
class OKLCHColor(namedtuple('_OKLCHColor', 'l c h')):
"""
Represent a color in the OKLCH color space.
*Warning*: conversion to RGB is not bound checked yet!
*New in 0.12.0*
"""
def __str__(self):
l, c, h = round(self.l, 4), round(self.c, 4), round(self.h, 4)
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)
)
def to_rgb(self):
return self.to_oklab().to_rgb()
__all__ = ('chalk', 'WebColor', "RGBColor", 'SRGBColor', 'XYZColor', 'OKLabColor')

View file

@ -109,9 +109,10 @@ class DictConfigSource(ConfigSource):
class ArgConfigSource(ValueSource): class ArgConfigSource(ValueSource):
""" """
It assumes arguments have already been parsed Config source that assumes arguments have already been parsed.
NEW 0.6""" *New in 0.6.0*
"""
_ns: Namespace _ns: Namespace
def __init__(self, ns: Namespace): def __init__(self, ns: Namespace):
super().__init__() super().__init__()

View file

@ -19,7 +19,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from __future__ import annotations from __future__ import annotations
from functools import wraps from functools import wraps
from typing import Callable from typing import Callable, TypeVar
_T = TypeVar('_T')
_U = TypeVar('_U')
BRICKS = '@abcdefghijklmnopqrstuvwxyz+?-\'/' BRICKS = '@abcdefghijklmnopqrstuvwxyz+?-\'/'
@ -122,7 +125,7 @@ def dei_args(**renames):
Dear conservatives, this does not influence the ability to call the wrapped function Dear conservatives, this does not influence the ability to call the wrapped function
with the original parameter names. with the original parameter names.
""" """
def decorator(func: Callable): def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
for alias_name, actual_name in renames.items(): for alias_name, actual_name in renames.items():

View file

@ -1,5 +1,5 @@
""" """
Exceptions and throwables for various purposes Exceptions and throwables for all purposes!
--- ---
@ -14,6 +14,17 @@ This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
""" """
class PoliticalError(Exception):
"""
Base class for anything that is refused to be executed for political reasons.
"""
class PoliticalWarning(PoliticalError, Warning):
"""
Base class for politically suspicious behaviors.
"""
class MissingConfigError(LookupError): class MissingConfigError(LookupError):
""" """
Config variable not found. Config variable not found.
@ -53,6 +64,40 @@ class BabelTowerError(NotFoundError):
The user requested a language that cannot be understood. The user requested a language that cannot be understood.
""" """
class BadLuckError(Exception):
"""
Stuff did not go as expected.
Raised by @lucky decorator.
"""
class TerminalRequiredError(OSError):
"""
Raised by terminal_required() decorator when a function is called from a non-interactive environment.
"""
class BrokenStringsError(OSError):
"""
Issues related to audio happened, i.e. appropriate executables/libraries/drivers are not installed.
"""
class Fahrenheit451Error(PoliticalError):
"""
Base class for thought crimes related to arts (e.g. writing, visual arts, music)
"""
# Werkzeug
code = 451
class FuckAroundFindOutError(PoliticalError):
"""
Raised when there is no actual grounds to raise an exception, but you did something in the past to deserve this outcome.
Ideal for permanent service bans or similar.
"""
__all__ = ( __all__ = (
'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError', 'NotFoundError' 'MissingConfigError', 'MissingConfigWarning', 'LexError', 'InconsistencyError', 'NotFoundError',
'TerminalRequiredError', 'PoliticalError', 'PoliticalWarning', 'Fahrenheit451Error', 'FuckAroundFindOutError',
'BrokenStringsError', 'BadLuckError'
) )

View file

@ -1,6 +1,8 @@
""" """
Utilities for Flask-SQLAlchemy binding. Utilities for Flask-SQLAlchemy binding.
This module has been emptied in 0.12.0 following deprecation removals.
--- ---
Copyright (c) 2025 Sakuragasaki46. Copyright (c) 2025 Sakuragasaki46.
@ -14,70 +16,6 @@ This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
""" """
from functools import partial
from typing import Any, Callable, Never
from flask import abort, request
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase, Session
from .functools import deprecated
from .codecs import want_bytes
from .sqlalchemy import AuthSrc, require_auth_base
@deprecated('inherits from deprecated and unused class')
class FlaskAuthSrc(AuthSrc):
'''
'''
db: SQLAlchemy
def __init__(self, db: SQLAlchemy):
super().__init__()
self.db = db
def get_session(self) -> Session:
return self.db.session
def get_token(self):
if request.authorization:
return request.authorization.token
def get_signature(self) -> bytes:
sig = request.headers.get('authorization-signature', None)
return want_bytes(sig) if sig else None
def invalid_exc(self, msg: str = 'validation failed') -> Never:
abort(400, msg)
def required_exc(self):
abort(401, 'Login required')
@deprecated('not intuitive to use')
def require_auth(cls: type[DeclarativeBase], db: SQLAlchemy) -> Callable[Any, Callable]:
"""
Make an auth_required() decorator for Flask views.
This looks for a token in the Authorization header, validates it, loads the
appropriate object, and injects it as the user= parameter.
NOTE: the actual decorator to be used on routes is **auth_required()**,
NOT require_auth() which is the **constructor** for it.
cls is a SQLAlchemy table.
db is a flask_sqlalchemy.SQLAlchemy() binding.
Usage:
auth_required = require_auth(User, db)
@route('/admin')
@auth_required(validators=[lambda x: x.is_administrator])
def super_secret_stuff(user):
pass
NOTE: require_auth() DOES NOT work with flask_restx.
"""
def auth_required(**kwargs):
return require_auth_base(cls=cls, src=FlaskAuthSrc(db), **kwargs)
auth_required.__doc__ = require_auth_base.__doc__
return auth_required
# Optional dependency: do not import into __init__.py # Optional dependency: do not import into __init__.py
__all__ = () __all__ = ()

View file

@ -19,7 +19,7 @@ import math
from threading import RLock from threading import RLock
import time import time
from types import CoroutineType, NoneType from types import CoroutineType, NoneType
from typing import Callable, Iterable, Mapping, TypeVar from typing import Any, Callable, Iterable, Mapping, Never, TypeVar
import warnings import warnings
from functools import update_wrapper, wraps, lru_cache from functools import update_wrapper, wraps, lru_cache
@ -28,43 +28,49 @@ from suou.itertools import hashed_list
_T = TypeVar('_T') _T = TypeVar('_T')
_U = TypeVar('_U') _U = TypeVar('_U')
def _suou_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 used by warnings.warn() btw
"""
def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
@wraps(func)
def wrapper(*a, **ka):
if category is not None:
warnings.warn(message, category, stacklevel=stacklevel)
return func(*a, **ka)
func.__deprecated__ = True
wrapper.__deprecated__ = True
return wrapper
return decorator
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) -> Callable[[Callable[_T, _U]], Callable[_T, _U]]: deprecated = _suou_deprecated
"""
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[_T, _U]) -> Callable[_T, _U]:
@wraps(func)
def wrapper(*a, **ka):
if category is not None:
warnings.warn(message, category, stacklevel=stacklevel)
return func(*a, **ka)
func.__deprecated__ = True
wrapper.__deprecated__ = True
return wrapper
return decorator
## this syntactic sugar for deprecated() is ... deprecated, which is ironic. ## this syntactic sugar for deprecated() is ... deprecated, which is ironic.
## Needed move because VSCode seems to not sense deprecated_alias()es as deprecated. ## Needed move because VSCode seems to not sense deprecated_alias()es as deprecated.
@deprecated('use deprecated(message)(func) instead') @deprecated('use deprecated(message)(func) instead')
def deprecated_alias(func: Callable, /, message='use .{name}() instead', *, category=DeprecationWarning) -> Callable: def deprecated_alias(func: Callable[_T, _U], /, message='use .{name}() instead', *, category=DeprecationWarning) -> Callable[_T, _U]:
""" """
Syntactic sugar helper for renaming functions. Syntactic sugar helper for renaming functions.
DEPRECATED use deprecated(message)(func) instead DEPRECATED use deprecated(message)(func) instead
""" """
return deprecated(message.format(name=func.__name__), category=category)(func) @deprecated(message.format(name=func.__name__), category=category)
@wraps(func)
def deprecated_wrapper(*a, **k) -> _U:
return func(*a, **k)
return deprecated_wrapper
def not_implemented(msg: Callable | str | None = None): def not_implemented(msg: Callable | str | None = None):
""" """
A more elegant way to say a method is not implemented, but may get in the future. A more elegant way to say a method is not implemented, but may get in the future.
""" """
def decorator(func: Callable) -> Callable: def decorator(func: Callable[_T, Any]) -> Callable[_T, Never]:
da_msg = msg if isinstance(msg, str) else 'method {name}() is not implemented'.format(name=func.__name__) da_msg = msg if isinstance(msg, str) else 'method {name}() is not implemented'.format(name=func.__name__)
@wraps(func) @wraps(func)
def wrapper(*a, **k): def wrapper(*a, **k):
@ -74,6 +80,26 @@ def not_implemented(msg: Callable | str | None = None):
return decorator(msg) return decorator(msg)
return decorator return decorator
def future(message: str | None = None, *, version: str = None):
"""
Describes experimental or future API's introduced as bug fixes (including as backports)
but not yet intended for general use (mostly to keep semver consistent).
version= is the intended version release.
*New in 0.7.0*
"""
def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
@wraps(func)
def wrapper(*a, **k) -> _U:
warnings.warn(message or (
f'{func.__name__}() is intended for release on {version} and not ready for use right now'
if version else
f'{func.__name__}() is intended for a future release and not ready for use right now'
), FutureWarning)
return func(*a, **k)
return wrapper
return decorator
def flat_args(args: Iterable, kwds: Mapping, typed, def flat_args(args: Iterable, kwds: Mapping, typed,
kwd_mark = (object(),), kwd_mark = (object(),),
@ -109,7 +135,7 @@ def _make_alru_cache(_CacheInfo):
PSA there is no C speed up. Unlike PSL. Sorry. PSA there is no C speed up. Unlike PSL. Sorry.
NEW 0.5.0 *New in 0.5.0*
""" """
# Users should only access the lru_cache through its public API: # Users should only access the lru_cache through its public API:
@ -266,9 +292,9 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False, *, async_: bo
Supports coroutines with async_=True. Supports coroutines with async_=True.
NEW 0.5.0 *New in 0.5.0*
""" """
def decorator(func): def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
start_time = None start_time = None
if async_: if async_:
@ -298,13 +324,13 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False, *, async_: bo
return wrapper return wrapper
return decorator return decorator
def none_pass(func: Callable, *args, **kwargs) -> Callable: def none_pass(func: Callable[_T, _U], *args, **kwargs) -> Callable[_T, _U]:
""" """
Wrap callable so that gets called only on not None values. Wrap callable so that gets called only on not None values.
Shorthand for func(x) if x is not None else None Shorthand for func(x) if x is not None else None
NEW 0.5.0 *New in 0.5.0*
""" """
@wraps(func) @wraps(func)
def wrapper(x): def wrapper(x):

69
src/suou/glue.py Normal file
View file

@ -0,0 +1,69 @@
"""
Helpers for "Glue" code, aka code meant to adapt or patch other libraries
---
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.
"""
import importlib
from types import ModuleType
from functools import wraps
from suou.classtools import MISSING
from suou.functools import future
@future()
class FakeModule(ModuleType):
"""
Fake module used in @glue() in case of import error
"""
def __init__(self, name: str, exc: Exception):
super().__init__(name)
self._exc = exc
def __getattr__(self, name: str):
raise AttributeError(f'Module {self.__name__} not found; this feature is not available ({self._exc})') from self._exc
@future()
def glue(*modules):
"""
Helper for "glue" code -- it imports the given modules and passes them as keyword arguments to the wrapped functions.
EXPERIMENTAL
"""
module_dict = dict()
imports_succeeded = True
for module in modules:
try:
module_dict[module] = importlib.import_module(module)
except Exception as e:
imports_succeeded = False
module_dict[module] = FakeModule(module, e)
def decorator(func):
@wraps(func)
def wrapper(*a, **k):
try:
result = func(*a, **k)
except Exception:
if not imports_succeeded:
## XXX return an iterable? A Fake****?
return MISSING
raise
return result
return wrapper
return decorator
# This module is experimental and therefore not re-exported into __init__
__all__ = ('glue', 'FakeModule')

View file

@ -31,6 +31,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import binascii import binascii
import datetime
import enum import enum
from functools import cached_property from functools import cached_property
import hashlib import hashlib
@ -40,6 +41,8 @@ import os
from typing import Iterable, override from typing import Iterable, override
import warnings import warnings
from suou.calendar import want_timestamp
from .functools import deprecated from .functools import deprecated
from .codecs import b32lencode, b64encode, cb32decode, cb32encode, want_str from .codecs import b32lencode, b64encode, cb32decode, cb32encode, want_str
@ -120,20 +123,30 @@ class SiqGen:
""" """
Implement a SIS-compliant SIQ generator. Implement a SIS-compliant SIQ generator.
""" """
__slots__ = ('domain_hash', 'last_gen_ts', 'counters', 'shard_id', '__weakref__') __slots__ = ('domain_hash', 'last_gen_ts', 'counters', 'shard_id', '_test_cur_ts', '__weakref__')
domain_hash: int domain_hash: int
last_gen_ts: int last_gen_ts: int
shard_id: int shard_id: int
counters: dict[SiqType, int] counters: dict[SiqType, int]
_test_cur_timestamp: int | None
def __init__(self, domain: str, last_siq: int = 0, local_id: int | None = None, shard_id: int | None = None): def __init__(self, domain: str, last_siq: int = 0, local_id: int | None = None, shard_id: int | None = None):
self.domain_hash = make_domain_hash(domain, local_id) self.domain_hash = make_domain_hash(domain, local_id)
self._test_cur_ts = None ## test only
self.last_gen_ts = min(last_siq >> 56, self.cur_timestamp()) self.last_gen_ts = min(last_siq >> 56, self.cur_timestamp())
self.counters = dict() self.counters = dict()
self.shard_id = (shard_id or os.getpid()) % 256 self.shard_id = (shard_id or os.getpid()) % 256
def cur_timestamp(self) -> int: def cur_timestamp(self) -> int:
if self._test_cur_ts is not None:
return self._test_cur_ts
return int(time.time() * (1 << 16)) return int(time.time() * (1 << 16))
def set_cur_timestamp(self, value: datetime.datetime):
"""
Intended to be used by tests only! Do not use in production!
"""
self._test_cur_ts = int(want_timestamp(value) * 2 ** 16)
self.last_gen_ts = int(want_timestamp(value) * 2 ** 16)
def generate(self, /, typ: SiqType, n: int = 1) -> Iterable[int]: def generate(self, /, typ: SiqType, n: int = 1) -> Iterable[int]:
""" """
Generate one or more SIQ's. Generate one or more SIQ's.
@ -152,7 +165,7 @@ class SiqGen:
elif now > self.last_gen_ts: elif now > self.last_gen_ts:
self.counters[typ] = 0 self.counters[typ] = 0
while n: while n:
idseq = typ.prepend(self.counters[typ]) idseq = typ.prepend(self.counters.setdefault(typ, 0))
if idseq >= (1 << 16): if idseq >= (1 << 16):
while (now := self.cur_timestamp()) <= self.last_gen_ts: while (now := self.cur_timestamp()) <= self.last_gen_ts:
time.sleep(1 / (1 << 16)) time.sleep(1 / (1 << 16))
@ -236,13 +249,20 @@ class Siq(int):
def to_base64(self, length: int = 15, *, strip: bool = True) -> str: def to_base64(self, length: int = 15, *, strip: bool = True) -> str:
return b64encode(self.to_bytes(length), strip=strip) return b64encode(self.to_bytes(length), strip=strip)
def to_cb32(self) -> str: def to_cb32(self) -> str:
return cb32encode(self.to_bytes(15, 'big')).lstrip('0') return cb32encode(self.to_bytes(15, 'big')).lstrip('0')
to_crockford = to_cb32 to_crockford = to_cb32
@classmethod
def from_cb32(cls, val: str | bytes):
return cls.from_bytes(cb32decode(want_str(val).zfill(24)))
def to_hex(self) -> str: def to_hex(self) -> str:
return f'{self:x}' return f'{self:x}'
def to_oct(self) -> str: def to_oct(self) -> str:
return f'{self:o}' return f'{self:o}'
def to_b32l(self) -> str: def to_b32l(self) -> str:
""" """
This is NOT the URI serializer! This is NOT the URI serializer!
@ -292,12 +312,10 @@ class Siq(int):
raise ValueError('checksum mismatch') raise ValueError('checksum mismatch')
return cls(int.from_bytes(b, 'big')) return cls(int.from_bytes(b, 'big'))
@classmethod
def from_cb32(cls, val: str | bytes):
return cls.from_bytes(cb32decode(want_str(val).zfill(24)))
def to_mastodon(self, /, domain: str | None = None): def to_mastodon(self, /, domain: str | None = None):
return f'@{self:u}{"@" if domain else ""}{domain}' return f'@{self:u}{"@" if domain else ""}{domain}'
def to_matrix(self, /, domain: str): def to_matrix(self, /, domain: str):
return f'@{self:u}:{domain}' return f'@{self:u}:{domain}'

View file

@ -22,12 +22,14 @@ from suou.classtools import MISSING
_T = TypeVar('_T') _T = TypeVar('_T')
def makelist(l: Any, *, wrap: bool = True) -> list | Callable[Any, list]: def makelist(l: Any, wrap: bool = True) -> list | Callable[Any, list]:
''' '''
Make a list out of an iterable or a single value. Make a list out of an iterable or a single value.
NEW 0.4.0: Now supports a callable: can be used to decorate generators and turn them into lists. *Changed in 0.4.0* Now supports a callable: can be used to decorate generators and turn them into lists.
Pass wrap=False to return instead the unwrapped function in a list. Pass wrap=False to return instead the unwrapped function in a list.
*Changed in 0.11.0*: ``wrap`` argument is now no more keyword only.
''' '''
if callable(l) and wrap: if callable(l) and wrap:
return wraps(l)(lambda *a, **k: makelist(l(*a, **k), wrap=False)) return wraps(l)(lambda *a, **k: makelist(l(*a, **k), wrap=False))

View file

@ -18,6 +18,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# TODO more snippets # TODO more snippets
from .strtools import SpitText
INDEMNIFY = """ INDEMNIFY = """
You agree to indemnify and hold harmless {0} from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable counsel and attorneys fees, arising out of any breach of this agreement. You agree to indemnify and hold harmless {0} from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable counsel and attorneys fees, arising out of any breach of this agreement.
""" """
@ -27,7 +30,7 @@ Except as represented in this agreement, the {0} is provided “AS IS”. Other
""" """
GOVERNING_LAW = """ GOVERNING_LAW = """
These terms of services are governed by, and shall be interpreted in accordance with, the laws of {0}. You consent to the sole jurisdiction of {1} for all disputes between You and , and You consent to the sole application of {2} law for all such disputes. These terms of services are governed by, and shall be interpreted in accordance with, the laws of {0}. You consent to the sole jurisdiction of {1} for all disputes between You and {2}, and You consent to the sole application of {3} law for all such disputes.
""" """
ENGLISH_FIRST = """ ENGLISH_FIRST = """
@ -45,5 +48,51 @@ If one clause of these Terms of Service or any policy incorporated here by refer
""" """
COMPLETENESS = """ COMPLETENESS = """
These Terms, together with the other policies incorporated into them by reference, contain all the terms and conditions agreed upon by You and {{ app_name }} regarding Your use of the {{ app_name }} service. No other agreement, oral or otherwise, will be deemed to exist or to bind either of the parties to this Agreement. These Terms, together with the other policies incorporated into them by reference, contain all the terms and conditions agreed upon by You and {0} regarding Your use of the {0} service. No other agreement, oral or otherwise, will be deemed to exist or to bind either of the parties to this Agreement.
""" """
class Lawyer(SpitText):
"""
A tool to ease the writing of Terms of Service for web apps.
NOT A REPLACEMENT FOR A REAL LAWYER AND NOT LEGAL ADVICE
*New in 0.11.0*
"""
def __init__(self, /,
app_name: str, domain_name: str,
company_name: str, jurisdiction: str,
country: str, country_adjective: str
):
self.app_name = app_name
self.domain_name = domain_name
self.company_name = company_name
self.jurisdiction = jurisdiction
self.country = country
self.country_adjective = country_adjective
def indemnify(self):
return self.format(INDEMNIFY, 'app_name')
def no_warranty(self):
return self.format(NO_WARRANTY, 'app_name', 'company_name')
def governing_law(self) -> str:
return self.format(GOVERNING_LAW, 'country', 'jurisdiction', 'app_name', 'country_adjective')
def english_first(self) -> str:
return ENGLISH_FIRST
def expect_updates(self) -> str:
return self.format(EXPECT_UPDATES, 'app_name')
def severability(self) -> str:
return SEVERABILITY
def completeness(self) -> str:
return self.format(COMPLETENESS, 'app_name')
# This module is experimental and therefore not re-exported into __init__
__all__ = ('Lawyer',)

View file

@ -2,6 +2,16 @@
Utilities for tokenization of text. Utilities for tokenization of text.
--- ---
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 re import Match from re import Match

116
src/suou/luck.py Normal file
View file

@ -0,0 +1,116 @@
"""
Fortune, RNG and esoterism.
*New in 0.7.0*
---
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 functools import wraps
from typing import Callable, Generic, Iterable, TypeVar
import random
from suou.exceptions import BadLuckError
_T = TypeVar('_T')
_U = TypeVar('_U')
def lucky(validators: Iterable[Callable[[_U], bool]] = ()):
"""
Add one or more constraint on a function's return value.
Each validator must return a boolean. If false, the result is considered
unlucky and BadLuckError() is raised.
UNTESTED
*New in 0.7.0*
"""
def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
@wraps(func)
def wrapper(*args, **kwargs) -> _U:
try:
result = func(*args, **kwargs)
except Exception as e:
raise BadLuckError(f'exception happened: {e}') from e
for v in validators:
try:
if not v(result):
message = 'result not expected'
raise BadLuckError(f'{message}: {result!r}')
except BadLuckError:
raise
except Exception as e:
raise BadLuckError(f'cannot validate: {e}') from e
return result
return wrapper
return decorator
class RngCallable(Callable, Generic[_T, _U]):
"""
Overloaded ...randomly chosen callable.
UNTESTED
*New in 0.7.0*
"""
def __init__(self, /, func: Callable[_T, _U] | None = None, weight: int = 1):
self._callables = []
self._max_weight = 0
if callable(func):
self.add_callable(func, weight)
def add_callable(self, func: Callable[_T, _U], weight: int = 1):
"""
"""
weight = int(weight)
if weight <= 0:
return
self._callables.append((func, weight))
self._max_weight += weight
def __call__(self, *a, **ka) -> _U:
choice = random.randrange(self._max_weight)
for w, c in self._callables:
if choice < w:
return c(*a, **ka)
elif choice < 0:
raise RuntimeError('inconsistent state')
else:
choice -= w
def rng_overload(prev_func: RngCallable[..., _U] | int | None, /, *, weight: int = 1) -> RngCallable[..., _U]:
"""
Decorate the first function with @rng_overload and the weight= parameter
(default 1, must be an integer) to create a "RNG" overloaded callable.
Each call chooses randomly one candidate (weight is taken in consideration),
calls it, and returns the result.
UNTESTED
*New in 0.7.0*
"""
if isinstance(prev_func, int) and weight == 1:
weight, prev_func = prev_func, None
def decorator(func: Callable[_T, _U]) -> RngCallable[_T, _U]:
nonlocal prev_func
if prev_func is None:
prev_func = RngCallable(func, weight=weight)
else:
prev_func.add_callable(func, weight=weight)
return prev_func
return decorator
# This module is experimental and therefore not re-exported into __init__
__all__ = ('lucky', 'rng_overload')

143
src/suou/mat.py Normal file
View file

@ -0,0 +1,143 @@
"""
Matrix (not the movie...)
*New in 0.12.0*
---
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 Collection, Iterable, TypeVar
from .functools import deprecated
_T = TypeVar('_T')
class Matrix(Collection[_T]):
"""
Minimalist reimplementation of matrices in pure Python.
This to avoid adding numpy as a dependency.
*New in 0.12.0*
"""
_shape: tuple[int, int]
_elements: list[_T]
def shape(self):
return self._shape
def __init__(self, iterable: Iterable[_T] | Iterable[Collection[_T]], shape: tuple[int, int] | None = None):
elements = []
boundary_x = boundary_y = 0
for row in iterable:
if isinstance(row, Collection):
if not boundary_y:
boundary_y = len(row)
elements.extend(row)
boundary_x += 1
elif boundary_y != len(row):
raise ValueError('row length mismatch')
else:
elements.extend(row)
boundary_x += 1
elif shape:
if not boundary_x:
boundary_x, boundary_y = shape
elements.append(row)
self._shape = boundary_x, boundary_y
self._elements = elements
assert len(self._elements) == boundary_x * boundary_y
def __getitem__(self, key: tuple[int, int]) -> _T:
(x, y), (_, sy) = key, self.shape()
return self._elements[x * sy + y]
@property
def T(self):
sx, sy = self.shape()
return Matrix(
[
[
self[j, i] for j in range(sx)
] for i in range(sy)
]
)
def __matmul__(self, other: Matrix) -> Matrix:
(ax, ay), (bx, by) = self.shape(), other.shape()
if ay != bx:
raise ValueError('cannot multiply matrices with incompatible shape')
return Matrix([
[
sum(self[i, k] * other[k, j] for k in range(ay)) for j in range(by)
] for i in range(ax)
])
def __eq__(self, other: Matrix):
try:
return self._elements == other._elements and self._shape == other._shape
except Exception:
return False
def __len__(self):
ax, ay = self.shape()
return ax * ay
@deprecated('please use .rows() or .columns() instead')
def __iter__(self):
return iter(self._elements)
def __contains__(self, x: object, /) -> bool:
return x in self._elements
def __repr__(self):
return f'{self.__class__.__name__}({list(self.rows())})'
def rows(self):
sx, sy = self.shape()
return (
[self[j, i] for j in range(sy)] for i in range(sx)
)
def columns(self):
sx, sy = self.shape()
return (
[self[j, i] for j in range(sx)] for i in range(sy)
)
@classmethod
def as_row(cls, iterable: Iterable):
return cls([[*iterable]])
@classmethod
def as_column(cls, iterable: Iterable):
return cls([[x] for x in iterable])
def get_column(self, idx = 0):
sx, _ = self.shape()
return [
self[j, idx] for j in range(sx)
]
def get_row(self, idx = 0):
_, sy = self.shape()
return [
self[idx, j] for j in range(sy)
]
__all__ = ('Matrix', )

View file

@ -1,239 +0,0 @@
"""
Utilities for parsing config variables.
BREAKING older, non-generalized version, kept for backwards compability
in case 0.4+ version happens to break.
WILL BE removed in 0.5.0.
---
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 ast import TypeVar
from collections.abc import Mapping
from configparser import ConfigParser as _ConfigParser
import os
from typing import Any, Callable, Iterator
from collections import OrderedDict
import warnings
from ..functools import deprecated
from ..exceptions import MissingConfigError, MissingConfigWarning
warnings.warn('This module will be removed in 0.5.0 and is kept only in case new implementation breaks!\n'\
'Do not use unless you know what you are doing.', DeprecationWarning)
MISSING = object()
_T = TypeVar('T')
@deprecated('use configparse')
class ConfigSource(Mapping):
'''
Abstract config source.
'''
__slots__ = ()
@deprecated('use configparse')
class EnvConfigSource(ConfigSource):
'''
Config source from os.environ aka .env
'''
def __getitem__(self, key: str, /) -> str:
return os.environ[key]
def get(self, key: str, fallback = None, /):
return os.getenv(key, fallback)
def __contains__(self, key: str, /) -> bool:
return key in os.environ
def __iter__(self) -> Iterator[str]:
yield from os.environ
def __len__(self) -> int:
return len(os.environ)
@deprecated('use configparse')
class ConfigParserConfigSource(ConfigSource):
'''
Config source from ConfigParser
'''
__slots__ = ('_cfp', )
_cfp: _ConfigParser
def __init__(self, cfp: _ConfigParser):
if not isinstance(cfp, _ConfigParser):
raise TypeError(f'a ConfigParser object is required (got {cfp.__class__.__name__!r})')
self._cfp = cfp
def __getitem__(self, key: str, /) -> str:
k1, _, k2 = key.partition('.')
return self._cfp.get(k1, k2)
def get(self, key: str, fallback = None, /):
k1, _, k2 = key.partition('.')
return self._cfp.get(k1, k2, fallback=fallback)
def __contains__(self, key: str, /) -> bool:
k1, _, k2 = key.partition('.')
return self._cfp.has_option(k1, k2)
def __iter__(self) -> Iterator[str]:
for k1, v1 in self._cfp.items():
for k2 in v1:
yield f'{k1}.{k2}'
def __len__(self) -> int:
## XXX might be incorrect but who cares
return sum(len(x) for x in self._cfp)
@deprecated('use configparse')
class DictConfigSource(ConfigSource):
'''
Config source from Python mappings. Useful with JSON/TOML config
'''
__slots__ = ('_d',)
_d: dict[str, Any]
def __init__(self, mapping: dict[str, Any]):
self._d = mapping
def __getitem__(self, key: str, /) -> str:
return self._d[key]
def get(self, key: str, fallback: _T = None, /):
return self._d.get(key, fallback)
def __contains__(self, key: str, /) -> bool:
return key in self._d
def __iter__(self) -> Iterator[str]:
yield from self._d
def __len__(self) -> int:
return len(self._d)
@deprecated('use configparse')
class ConfigValue:
"""
A single config property.
By default, it is sourced from os.environ i.e. environment variables,
and property name is upper cased.
You can specify further sources, if the parent ConfigOptions class
supports them.
Arguments:
- public: mark value as public, making it available across the app (e.g. in Jinja2 templates).
- prefix: src but for the lazy
- preserve_case: if True, src is not CAPITALIZED. Useful for parsing from Python dictionaries or ConfigParser's
- required: throw an error if empty or not supplied
"""
# XXX disabled per https://stackoverflow.com/questions/45864273/slots-conflicts-with-a-class-variable-in-a-generic-class
#__slots__ = ('_srcs', '_val', '_default', '_cast', '_required', '_preserve_case')
_srcs: dict[str, str] | None
_preserve_case: bool = False
_val: Any | MISSING = MISSING
_default: Any | None
_cast: Callable | None
_required: bool
_pub_name: str | bool = False
def __init__(self, /,
src: str | None = None, *, default = None, cast: Callable | None = None,
required: bool = False, preserve_case: bool = False, prefix: str | None = None,
public: str | bool = False, **kwargs):
self._srcs = dict()
self._preserve_case = preserve_case
if src:
self._srcs['default'] = src if preserve_case else src.upper()
elif prefix:
self._srcs['default'] = f'{prefix if preserve_case else prefix.upper}?'
self._default = default
self._cast = cast
self._required = required
self._pub_name = public
for k, v in kwargs.items():
if k.endswith('_src'):
self._srcs[k[:-4]] = v
else:
raise TypeError(f'unknown keyword argument {k!r}')
def __set_name__(self, owner, name: str):
if 'default' not in self._srcs:
self._srcs['default'] = name if self._preserve_case else name.upper()
elif self._srcs['default'].endswith('?'):
self._srcs['default'] = self._srcs['default'].rstrip('?') + (name if self._preserve_case else name.upper() )
if self._pub_name is True:
self._pub_name = name
if self._pub_name and isinstance(owner, ConfigOptions):
owner.expose(self._pub_name, name)
def __get__(self, obj: ConfigOptions, owner=False):
if self._val is MISSING:
v = MISSING
for srckey, src in obj._srcs.items():
if srckey in self._srcs:
v = src.get(self._srcs[srckey], v)
if self._required and (not v or v is MISSING):
raise MissingConfigError(f'required config {self._srcs['default']} not set!')
if v is MISSING:
v = self._default
if callable(self._cast):
v = self._cast(v) if v is not None else self._cast()
self._val = v
return self._val
@property
def source(self, /):
return self._srcs['default']
@deprecated('use configparse')
class ConfigOptions:
"""
Base class for loading config values.
It is intended to get subclassed; config values must be defined as
ConfigValue() properties.
Further config sources can be added with .add_source()
"""
__slots__ = ('_srcs', '_pub')
_srcs: OrderedDict[str, ConfigSource]
_pub: dict[str, str]
def __init__(self, /):
self._srcs = OrderedDict(
default = EnvConfigSource()
)
self._pub = dict()
def add_source(self, key: str, csrc: ConfigSource, /, *, first: bool = False):
self._srcs[key] = csrc
if first:
self._srcs.move_to_end(key, False)
add_config_source = deprecated_alias(add_source)
def expose(self, public_name: str, attr_name: str | None = None) -> None:
'''
Mark a config value as public.
Called automatically by ConfigValue.__set_name__().
'''
attr_name = attr_name or public_name
self._pub[public_name] = attr_name
def to_dict(self, /):
d = dict()
for k, v in self._pub.items():
d[k] = getattr(self, v)
return d
__all__ = (
'MissingConfigError', 'MissingConfigWarning', 'ConfigOptions', 'EnvConfigSource', 'ConfigParserConfigSource', 'DictConfigSource', 'ConfigSource', 'ConfigValue'
)

View file

@ -18,10 +18,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from contextvars import ContextVar from contextvars import ContextVar
from typing import Iterable from typing import Iterable
from playhouse.shortcuts import ReconnectMixin from playhouse.shortcuts import ReconnectMixin
from peewee import CharField, Database, MySQLDatabase, _ConnectionState from peewee import BigIntegerField, CharField, Database, Field, MySQLDatabase, _ConnectionState
import re import re
from suou.iding import Siq from suou.iding import Siq
from suou.snowflake import Snowflake
from .codecs import StringCase from .codecs import StringCase
@ -117,6 +118,26 @@ class SiqField(Field):
def python_value(self, value: bytes) -> Siq: def python_value(self, value: bytes) -> Siq:
return Siq.from_bytes(value) return Siq.from_bytes(value)
# Optional dependency: do not import into __init__.py
__all__ = ('connect_reconnect', 'RegexCharField', 'SiqField') class SnowflakeField(BigIntegerField):
'''
Field holding a snowflake.
Stored as bigint.
XXX UNTESTED!
'''
field_type = 'bigint'
def db_value(self, value: int | Snowflake) -> int:
if isinstance(value, Snowflake):
value = int(value)
if not isinstance(value, int):
raise TypeError
return value
def python_value(self, value: int) -> Snowflake:
return Snowflake(value)
# Optional dependency: do not import into __init__.py
__all__ = ('connect_reconnect', 'RegexCharField', 'SiqField', 'Snowflake')

View file

@ -1,7 +1,7 @@
""" """
"Security through obscurity" helpers for less sensitive logging "Security through obscurity" helpers for less sensitive logging
NEW 0.5.0 *New in 0.5.0*
--- ---
@ -27,7 +27,7 @@ def redact_url_password(u: str) -> str:
scheme://username:password@hostname/path?query scheme://username:password@hostname/path?query
^------^ ^------^
NEW 0.5.0 *New in 0.5.0*
""" """
return re.sub(r':[^@:/ ]+@', ':***@', u) return re.sub(r':[^@:/ ]+@', ':***@', u)

View file

@ -20,6 +20,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from __future__ import annotations from __future__ import annotations
from binascii import unhexlify
import os import os
from threading import Lock from threading import Lock
import time import time
@ -28,7 +29,7 @@ import warnings
from .migrate import SnowflakeSiqMigrator from .migrate import SnowflakeSiqMigrator
from .iding import SiqType from .iding import SiqType
from .codecs import b32ldecode, b32lencode, b64encode, cb32encode from .codecs import b32ldecode, b32lencode, b64encode, b64decode, cb32encode, cb32decode
from .functools import deprecated from .functools import deprecated
@ -121,27 +122,46 @@ class Snowflake(int):
def to_bytes(self, length: int = 14, byteorder = "big", *, signed: bool = False) -> bytes: def to_bytes(self, length: int = 14, byteorder = "big", *, signed: bool = False) -> bytes:
return super().to_bytes(length, byteorder, signed=signed) return super().to_bytes(length, byteorder, signed=signed)
def to_base64(self, length: int = 9, *, strip: bool = True) -> str:
return b64encode(self.to_bytes(length), strip=strip)
def to_cb32(self)-> str:
return cb32encode(self.to_bytes(8, 'big'))
to_crockford = to_cb32
def to_hex(self) -> str:
return f'{self:x}'
def to_oct(self) -> str:
return f'{self:o}'
def to_b32l(self) -> str:
# PSA Snowflake Base32 representations are padded to 10 bytes!
if self < 0:
return '_' + Snowflake.to_b32l(-self)
return b32lencode(self.to_bytes(10, 'big')).lstrip('a')
@classmethod @classmethod
def from_bytes(cls, b: bytes, byteorder = 'big', *, signed: bool = False) -> Snowflake: def from_bytes(cls, b: bytes, byteorder = 'big', *, signed: bool = False) -> Snowflake:
if len(b) not in (8, 10): if len(b) not in (8, 10):
warnings.warn('Snowflakes are exactly 8 bytes long', BytesWarning) warnings.warn('Snowflakes are exactly 8 bytes long', BytesWarning)
return super().from_bytes(b, byteorder, signed=signed) return super().from_bytes(b, byteorder, signed=signed)
def to_base64(self, length: int = 9, *, strip: bool = True) -> str:
return b64encode(self.to_bytes(length), strip=strip)
@classmethod
def from_base64(cls, val:str) -> Snowflake:
return Snowflake.from_bytes(b64decode(val))
def to_cb32(self)-> str:
return cb32encode(self.to_bytes(8, 'big'))
to_crockford = to_cb32
@classmethod
def from_cb32(cls, val:str) -> Snowflake:
return Snowflake.from_bytes(cb32decode(val))
def to_hex(self) -> str:
return f'{self:x}'
@classmethod
def from_hex(cls, val:str) -> Snowflake:
if val.startswith('_'):
return -cls.from_hex(val.lstrip('_'))
return Snowflake.from_bytes(unhexlify(val))
def to_oct(self) -> str:
return f'{self:o}'
@classmethod
def from_oct(cls, val:str) -> Snowflake:
if val.startswith('_'):
return -cls.from_hex(val.lstrip('_'))
return Snowflake(int(val, base=8))
def to_b32l(self) -> str:
# PSA Snowflake Base32 representations are padded to 10 bytes!
if self < 0:
return '_' + Snowflake.to_b32l(-self)
return b32lencode(self.to_bytes(10, 'big')).lstrip('a')
@classmethod @classmethod
def from_b32l(cls, val: str) -> Snowflake: def from_b32l(cls, val: str) -> Snowflake:
if val.startswith('_'): if val.startswith('_'):
@ -149,6 +169,14 @@ class Snowflake(int):
return -cls.from_b32l(val.lstrip('_')) return -cls.from_b32l(val.lstrip('_'))
return Snowflake.from_bytes(b32ldecode(val.rjust(16, 'a'))) return Snowflake.from_bytes(b32ldecode(val.rjust(16, 'a')))
def to_siq(self, domain: str, epoch: int, target_type: SiqType, **kwargs):
"""
Convenience method for conversion to SIQ.
(!) This does not check for existence! Always do the check yourself.
"""
return SnowflakeSiqMigrator(domain, epoch, **kwargs).to_siq(self, target_type)
@override @override
def __format__(self, opt: str, /) -> str: def __format__(self, opt: str, /) -> str:
try: try:
@ -179,15 +207,6 @@ class Snowflake(int):
def __repr__(self): def __repr__(self):
return f'{self.__class__.__name__}({super().__repr__()})' return f'{self.__class__.__name__}({super().__repr__()})'
def to_siq(self, domain: str, epoch: int, target_type: SiqType, **kwargs):
"""
Convenience method for conversion to SIQ.
(!) This does not check for existence! Always do the check yourself.
"""
return SnowflakeSiqMigrator(domain, epoch, **kwargs).to_siq(self, target_type)
__all__ = ( __all__ = (
'Snowflake', 'SnowflakeGen' 'Snowflake', 'SnowflakeGen'

View file

@ -1,5 +1,5 @@
""" """
Utilities for SQLAlchemy Utilities for SQLAlchemy.
--- ---
@ -33,12 +33,16 @@ from ..iding import Siq, SiqGen, SiqType, SiqCache
from ..classtools import Incomplete, Wanted from ..classtools import Incomplete, Wanted
_T = TypeVar('_T') _T = TypeVar('_T')
_U = TypeVar('_U')
# SIQs are 14 bytes long. Storage is padded for alignment
# Not to be confused with SiqType.
IdType: TypeEngine = LargeBinary(16) IdType: TypeEngine = LargeBinary(16)
"""
Database type for SIQ.
SIQs are 14 bytes long. Storage is padded for alignment
Not to be confused with SiqType.
"""
def create_session(url: str) -> Session: def create_session(url: str) -> Session:
""" """
@ -52,7 +56,6 @@ def create_session(url: str) -> Session:
return Session(bind = engine) return Session(bind = engine)
def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete[UserSigner]: def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete[UserSigner]:
""" """
Generate a user signing function. Generate a user signing function.
@ -80,12 +83,9 @@ def token_signer(id_attr: Column | str, secret_attr: Column | str) -> Incomplete
return Incomplete(Wanted(token_signer_factory)) return Incomplete(Wanted(token_signer_factory))
## (in)Utilities for use in web apps below ## (in)Utilities for use in web apps below
@deprecated('not part of the public API and not even working') @deprecated('not part of the public API and not even working. Will be removed in 0.14.0')
class AuthSrc(metaclass=ABCMeta): class AuthSrc(metaclass=ABCMeta):
''' '''
AuthSrc object required for require_auth_base(). AuthSrc object required for require_auth_base().
@ -93,6 +93,8 @@ class AuthSrc(metaclass=ABCMeta):
This is an abstract class and is NOT usable directly. This is an abstract class and is NOT usable directly.
This is not part of the public API This is not part of the public API
DEPRECATED
''' '''
def required_exc(self) -> Never: def required_exc(self) -> Never:
raise ValueError('required field missing') raise ValueError('required field missing')
@ -111,7 +113,7 @@ class AuthSrc(metaclass=ABCMeta):
pass pass
@deprecated('not working and too complex to use') @deprecated('not working and too complex to use. Will be removed in 0.14.0')
def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user', def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str | Column[_T] = 'id', dest: str = 'user',
required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None): required: bool = False, signed: bool = False, sig_dest: str = 'signature', validators: Callable | Iterable[Callable] | None = None):
''' '''
@ -140,7 +142,7 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str |
invalid_exc = src.invalid_exc or _default_invalid invalid_exc = src.invalid_exc or _default_invalid
required_exc = src.required_exc or (lambda: _default_invalid('Login required')) required_exc = src.required_exc or (lambda: _default_invalid('Login required'))
def decorator(func: Callable): def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
@wraps(func) @wraps(func)
def wrapper(*a, **ka): def wrapper(*a, **ka):
ka[dest] = get_user(src.get_token()) ka[dest] = get_user(src.get_token())
@ -159,7 +161,7 @@ def require_auth_base(cls: type[DeclarativeBase], *, src: AuthSrc, column: str |
from .asyncio import SQLAlchemy, AsyncSelectPagination, async_query from .asyncio import SQLAlchemy, AsyncSelectPagination, async_query
from .orm import ( from .orm import (
id_column, snowflake_column, match_column, match_constraint, bool_column, declarative_base, parent_children, id_column, snowflake_column, match_column, match_constraint, bool_column, declarative_base, parent_children,
author_pair, age_pair, bound_fk, unbound_fk, want_column, a_relationship, BitSelector, secret_column author_pair, age_pair, bound_fk, unbound_fk, want_column, a_relationship, BitSelector, secret_column, username_column
) )
# Optional dependency: do not import into __init__.py # Optional dependency: do not import into __init__.py
@ -167,7 +169,7 @@ __all__ = (
'IdType', 'id_column', 'snowflake_column', 'entity_base', 'declarative_base', 'token_signer', 'IdType', 'id_column', 'snowflake_column', 'entity_base', 'declarative_base', 'token_signer',
'match_column', 'match_constraint', 'bool_column', 'parent_children', 'match_column', 'match_constraint', 'bool_column', 'parent_children',
'author_pair', 'age_pair', 'bound_fk', 'unbound_fk', 'want_column', 'author_pair', 'age_pair', 'bound_fk', 'unbound_fk', 'want_column',
'a_relationship', 'BitSelector', 'secret_column', 'a_relationship', 'BitSelector', 'secret_column', 'username_column',
# .asyncio # .asyncio
'SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper' 'SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper'
) )

View file

@ -2,7 +2,7 @@
""" """
Helpers for asynchronous use of SQLAlchemy. Helpers for asynchronous use of SQLAlchemy.
NEW 0.5.0; moved to current location 0.6.0 *New in 0.5.0; moved to current location in 0.6.0*
--- ---
@ -21,13 +21,17 @@ from __future__ import annotations
from functools import wraps from functools import wraps
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from typing import Callable, TypeVar
from sqlalchemy import Select, Table, func, select from sqlalchemy import Select, Table, func, select
from sqlalchemy.orm import DeclarativeBase, lazyload from sqlalchemy.orm import DeclarativeBase, lazyload
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from flask_sqlalchemy.pagination import Pagination from flask_sqlalchemy.pagination import Pagination
from suou.classtools import MISSING
from suou.exceptions import NotFoundError from suou.exceptions import NotFoundError
from suou.glue import glue
_T = TypeVar('_T')
_U = TypeVar('_U')
class SQLAlchemy: class SQLAlchemy:
""" """
@ -43,21 +47,23 @@ class SQLAlchemy:
user = (await session.execute(select(User).where(User.id == userid))).scalar() user = (await session.execute(select(User).where(User.id == userid))).scalar()
# ... # ...
NEW 0.5.0 *New in 0.5.0*
UPDATED 0.6.0: added wrap=True *Changed in 0.6.0*: added wrap=True
UPDATED 0.6.1: expire_on_commit is now configurable per-SQLAlchemy(); *Changed in 0.6.1*: expire_on_commit is now configurable per-SQLAlchemy();
now sessions are stored as context variables now sessions are stored as context variables
*Changed in 0.11.0*: sessions are now wrapped by default; turn it off by instantiating it with wrap=False
""" """
base: DeclarativeBase base: DeclarativeBase
engine: AsyncEngine engine: AsyncEngine
_session_tok: list[Token[AsyncSession]] _session_tok: list[Token[AsyncSession]]
_wrapsessions: bool _wrapsessions: bool | None
_xocommit: bool _xocommit: bool | None
NotFound = NotFoundError NotFound = NotFoundError
def __init__(self, model_class: DeclarativeBase, *, expire_on_commit = False, wrap = False): def __init__(self, model_class: DeclarativeBase, *, expire_on_commit = False, wrap = True):
self.base = model_class self.base = model_class
self.engine = None self.engine = None
self._wrapsessions = wrap self._wrapsessions = wrap
@ -67,13 +73,13 @@ class SQLAlchemy:
def _ensure_engine(self): def _ensure_engine(self):
if self.engine is None: if self.engine is None:
raise RuntimeError('database is not connected') raise RuntimeError('database is not connected')
async def begin(self, *, expire_on_commit = None, wrap = False, **kw) -> AsyncSession: async def begin(self, *, expire_on_commit = None, wrap = None, **kw) -> AsyncSession:
self._ensure_engine() self._ensure_engine()
## XXX is it accurate? ## XXX is it accurate?
s = AsyncSession(self.engine, s = AsyncSession(self.engine,
expire_on_commit=expire_on_commit if expire_on_commit is not None else self._xocommit, expire_on_commit=expire_on_commit if expire_on_commit is not None else self._xocommit,
**kw) **kw)
if wrap: if (wrap if wrap is not None else self._wrapsessions):
s = SessionWrapper(s) s = SessionWrapper(s)
current_session.set(s) current_session.set(s)
return s return s
@ -116,6 +122,9 @@ class SQLAlchemy:
# XXX NOT public API! DO NOT USE # XXX NOT public API! DO NOT USE
current_session: ContextVar[AsyncSession] = ContextVar('current_session') current_session: ContextVar[AsyncSession] = ContextVar('current_session')
class AsyncSelectPagination(Pagination): class AsyncSelectPagination(Pagination):
""" """
flask_sqlalchemy.SelectPagination but asynchronous. flask_sqlalchemy.SelectPagination but asynchronous.
@ -180,13 +189,14 @@ class AsyncSelectPagination(Pagination):
yield i yield i
def async_query(db: SQLAlchemy, multi: False): def async_query(db: SQLAlchemy, multi: False):
""" """
Wraps a query returning function into an executor coroutine. Wraps a query returning function into an executor coroutine.
The query function remains available as the .q or .query attribute. The query function remains available as the .q or .query attribute.
""" """
def decorator(func): def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
@wraps(func) @wraps(func)
async def executor(*args, **kwargs): async def executor(*args, **kwargs):
async with db as session: async with db as session:
@ -244,5 +254,8 @@ class SessionWrapper:
""" """
return getattr(self._session, key) return getattr(self._session, key)
def __del__(self):
self._session.close()
# Optional dependency: do not import into __init__.py # Optional dependency: do not import into __init__.py
__all__ = ('SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper') __all__ = ('SQLAlchemy', 'AsyncSelectPagination', 'async_query', 'SessionWrapper')

View file

@ -1,7 +1,7 @@
""" """
Utilities for SQLAlchemy; ORM Utilities for SQLAlchemy; ORM
NEW 0.6.0 *New in 0.6.0 (moved)*
--- ---
@ -20,12 +20,14 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
from binascii import Incomplete from binascii import Incomplete
import os import os
import re
from typing import Any, Callable, TypeVar from typing import Any, Callable, TypeVar
import warnings import warnings
from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, Date, ForeignKey, LargeBinary, MetaData, SmallInteger, String, text from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, Date, ForeignKey, LargeBinary, MetaData, SmallInteger, String, text
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, declarative_base as _declarative_base, relationship from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, declarative_base as _declarative_base, relationship
from sqlalchemy.types import TypeEngine from sqlalchemy.types import TypeEngine
from sqlalchemy.ext.hybrid import Comparator from sqlalchemy.ext.hybrid import Comparator
from suou.functools import future
from suou.classtools import Wanted, Incomplete from suou.classtools import Wanted, Incomplete
from suou.codecs import StringCase from suou.codecs import StringCase
from suou.dei import dei_args from suou.dei import dei_args
@ -101,7 +103,7 @@ match_constraint.TEXT_DIALECTS = {
'mariadb': ':n RLIKE :re' 'mariadb': ':n RLIKE :re'
} }
def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS, *args, constraint_name: str | None = None, **kwargs) -> Incomplete[Column[str]]: def match_column(length: int, regex: str | re.Pattern, /, case: StringCase = StringCase.AS_IS, *args, constraint_name: str | None = None, **kwargs) -> Incomplete[Column[str]]:
""" """
Syntactic sugar to create a String() column with a check constraint matching the given regular expression. Syntactic sugar to create a String() column with a check constraint matching the given regular expression.
@ -112,11 +114,28 @@ def match_column(length: int, regex: str, /, case: StringCase = StringCase.AS_IS
return Incomplete(Column, String(length), Wanted(lambda x, n: match_constraint(n, regex, #dialect=x.metadata.engine.dialect.name, return Incomplete(Column, String(length), Wanted(lambda x, n: match_constraint(n, regex, #dialect=x.metadata.engine.dialect.name,
constraint_name=constraint_name or f'{x.__tablename__}_{n}_valid')), *args, **kwargs) constraint_name=constraint_name or f'{x.__tablename__}_{n}_valid')), *args, **kwargs)
def username_column(
length: int = 32, regex: str | re.Pattern = '[a-z_][a-z0-9_-]+', *args, case: StringCase = StringCase.LOWER,
nullable : bool = False, **kwargs) -> Incomplete[Column[str] | Column[str | None]]:
"""
Construct a column containing a unique handle / username.
Username must match the given `regex` and be at most `length` characters long.
*New in 0.8.0*
"""
if case is StringCase.AS_IS:
warnings.warn('case sensitive usernames may lead to impersonation and unexpected behavior', UserWarning)
return match_column(length, regex, case=case, nullable=nullable, unique=True, *args, **kwargs)
def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column[bool]: def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column[bool]:
""" """
Column for a single boolean value. Column for a single boolean value.
NEW in 0.4.0 *New in 0.4.0*
""" """
def_val = text('true') if value else text('false') def_val = text('true') if value else text('false')
return Column(Boolean, server_default=def_val, nullable=nullable, **kwargs) return Column(Boolean, server_default=def_val, nullable=nullable, **kwargs)
@ -178,7 +197,7 @@ def secret_column(length: int = 64, max_length: int | None = None, gen: Callable
""" """
Column filled in by default with random bits (64 by default). Useful for secrets. Column filled in by default with random bits (64 by default). Useful for secrets.
NEW 0.6.0 *New in 0.6.0*
""" """
max_length = max_length or length max_length = max_length or length
return Column(LargeBinary(max_length), default=lambda: gen(length), nullable=nullable, **kwargs) return Column(LargeBinary(max_length), default=lambda: gen(length), nullable=nullable, **kwargs)
@ -196,7 +215,7 @@ def parent_children(keyword: str, /, *, lazy='selectin', **kwargs) -> tuple[Inco
Additional keyword arguments can be sourced with parent_ and child_ argument prefixes, Additional keyword arguments can be sourced with parent_ and child_ argument prefixes,
obviously. obviously.
CHANGED 0.5.0: the both relationship()s use lazy='selectin' attribute now by default. *Changed in 0.5.0*: the both relationship()s use lazy='selectin' attribute now by default.
""" """
parent_kwargs = kwargs_prefix(kwargs, 'parent_') parent_kwargs = kwargs_prefix(kwargs, 'parent_')
@ -212,7 +231,7 @@ def a_relationship(primary = None, /, j=None, *, lazy='selectin', **kwargs):
""" """
Shorthand for relationship() that sets lazy='selectin' by default. Shorthand for relationship() that sets lazy='selectin' by default.
NEW 0.6.0 *New in 0.6.0*
""" """
if j: if j:
kwargs['primaryjoin'] = j kwargs['primaryjoin'] = j
@ -227,7 +246,7 @@ def unbound_fk(target: str | Column | InstrumentedAttribute, typ: _T | None = No
If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))!
NEW 0.5.0 *New in 0.5.0*
""" """
if isinstance(target, (Column, InstrumentedAttribute)): if isinstance(target, (Column, InstrumentedAttribute)):
target_name = f'{target.table.name}.{target.name}' target_name = f'{target.table.name}.{target.name}'
@ -236,6 +255,8 @@ def unbound_fk(target: str | Column | InstrumentedAttribute, typ: _T | None = No
target_name = target target_name = target
if typ is None: if typ is None:
typ = IdType typ = IdType
else:
raise TypeError('target must be a str, a Column or a InstrumentedAttribute')
return Column(typ, ForeignKey(target_name, ondelete='SET NULL'), nullable=True, **kwargs) return Column(typ, ForeignKey(target_name, ondelete='SET NULL'), nullable=True, **kwargs)
@ -248,7 +269,7 @@ def bound_fk(target: str | Column | InstrumentedAttribute, typ: _T = None, **kwa
If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))! If target is a string, make sure to pass the column type at typ= (default: IdType aka varbinary(16))!
NEW 0.5.0 *New in 0.5.0*
""" """
if isinstance(target, (Column, InstrumentedAttribute)): if isinstance(target, (Column, InstrumentedAttribute)):
target_name = f'{target.table.name}.{target.name}' target_name = f'{target.table.name}.{target.name}'
@ -257,6 +278,8 @@ def bound_fk(target: str | Column | InstrumentedAttribute, typ: _T = None, **kwa
target_name = target target_name = target
if typ is None: if typ is None:
typ = IdType typ = IdType
else:
raise TypeError('target must be a str, a Column or a InstrumentedAttribute')
return Column(typ, ForeignKey(target_name, ondelete='CASCADE'), nullable=False, **kwargs) return Column(typ, ForeignKey(target_name, ondelete='CASCADE'), nullable=False, **kwargs)
@ -265,7 +288,7 @@ class _BitComparator(Comparator):
""" """
Comparator object for BitSelector() Comparator object for BitSelector()
NEW 0.6.0 *New in 0.6.0*
""" """
_column: Column _column: Column
_flag: int _flag: int
@ -291,7 +314,7 @@ class BitSelector:
Mimicks peewee's 'BitField()' behavior, with SQLAlchemy. Mimicks peewee's 'BitField()' behavior, with SQLAlchemy.
NEW 0.6.0 *New in 0.6.0*
""" """
_column: Column _column: Column
_flag: int _flag: int

View file

@ -1,7 +1,7 @@
""" """
Helpers for asynchronous use of SQLAlchemy. Helpers for asynchronous use of SQLAlchemy.
NEW 0.5.0; MOVED to sqlalchemy.asyncio in 0.6.0 *New in 0.5.0; moved to ``sqlalchemy.asyncio`` in 0.6.0*
--- ---

View file

@ -46,5 +46,18 @@ class PrefixIdentifier:
def __str__(self): def __str__(self):
return f'{self._prefix}' return f'{self._prefix}'
class SpitText:
"""
A formatter for pre-compiled strings.
*New in 0.11.0*
"""
def format(self, templ: str, *attrs: Iterable[str]) -> str:
attrs = [getattr(self, attr, f'{{{{ {attr} }}}}') for attr in attrs]
return templ.format(*attrs).strip()
__all__ = ('PrefixIdentifier',) __all__ = ('PrefixIdentifier',)

37
src/suou/terminal.py Normal file
View file

@ -0,0 +1,37 @@
"""
Utilities for console I/O and text user interfaces (TUI)
---
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 functools import wraps
import sys
from suou.exceptions import TerminalRequiredError
def terminal_required(func):
"""
Requires the decorated callable to be fully connected to a terminal.
*New in 0.7.0*
"""
@wraps(func)
def wrapper(*a, **ka):
if not (sys.stdin.isatty() and sys.stdout.isatty() and sys.stderr.isatty()):
raise TerminalRequiredError('this program must be run from a terminal')
return func(*a, **ka)
return wrapper
__all__ = ('terminal_required',)

View file

@ -18,11 +18,13 @@ import re
from typing import Any, Iterable, TypeVar from typing import Any, Iterable, TypeVar
from suou.classtools import MISSING
_T = TypeVar('_T') _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.
""" """
if isinstance(regex, int): if isinstance(regex, int):
length = regex length = regex
@ -31,13 +33,48 @@ 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: 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. Raise TypeError if the requested object is not of the desired type(s), with a nice message.
(Not properly a validator.)
""" """
if not isinstance(obj, typ): if not isinstance(obj, typ):
raise TypeError(f'{message}, not {obj.__class__.__name__!r}') raise TypeError(f'{message}, not {obj.__class__.__name__!r}')
return obj return obj
__all__ = ('matches', ) def not_greater_than(y):
"""
Return a function that returns True if X is not greater than (i.e. lesser than or equal to) the given value.
"""
return lambda x: x <= y
def not_less_than(y):
"""
Return a function that returns True if X is not less than (i.e. greater than or equal to) the given value.
"""
return lambda x: x >= y
def yesno(x: str | int | bool | None) -> bool:
"""
Returns False if x.lower() is in '0', '', 'no', 'n', 'false' or 'off'.
*New in 0.9.0*
*Changed in 0.11.1*: now accepts None and bool.
"""
if x in (None, MISSING):
return False
if isinstance(x, bool):
return x
if isinstance(x, int):
return x != 0
if isinstance(x, str):
return x.lower() not in ('', '0', 'off', 'n', 'no', 'false', 'f')
return True
__all__ = ('matches', 'must_be', 'not_greater_than', 'not_less_than', 'yesno')

View file

@ -1,7 +1,7 @@
""" """
Content serving API over HTTP, based on Starlette. Content serving API over HTTP, based on Starlette.
NEW 0.6.0 *New in 0.6.0*
--- ---
@ -16,21 +16,55 @@ This software is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
""" """
from typing import Callable
import warnings import warnings
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.responses import JSONResponse, PlainTextResponse, Response from starlette.responses import JSONResponse, PlainTextResponse, Response
from starlette.routing import Route from starlette.routing import Route
from suou.itertools import makelist
from suou.functools import future
@future()
class Waiter(): class Waiter():
_cached_app: Callable | None = None
def __init__(self): def __init__(self):
self.routes: list[Route] = [] self.routes: list[Route] = []
self.production = False self.production = False
async def __call__(self, *args):
return await self._build_app()(*args)
def _build_app(self) -> Starlette: def _build_app(self) -> Starlette:
return Starlette( if not self._cached_app:
debug = not self.production, self._cached_app = Starlette(
routes= self.routes debug = not self.production,
) routes= self.routes
)
return self._cached_app
def get(self, endpoint: str, *a, **k):
return self._route('GET', endpoint, *a, **k)
def post(self, endpoint: str, *a, **k):
return self._route('POST', endpoint, *a, **k)
def delete(self, endpoint: str, *a, **k):
return self._route('DELETE', endpoint, *a, **k)
def put(self, endpoint: str, *a, **k):
return self._route('PUT', endpoint, *a, **k)
def patch(self, endpoint: str, *a, **k):
return self._route('PATCH', endpoint, *a, **k)
def _route(self, methods: list[str], endpoint: str, **kwargs):
def decorator(func):
self.routes.append(Route(endpoint, func, methods=makelist(methods, False), **kwargs))
return func
return decorator
## TODO get, post, etc. ## TODO get, post, etc.
@ -54,4 +88,5 @@ def ko(status: int, /, content = None, **ka):
return PlainTextResponse(content, status_code=status, **ka) return PlainTextResponse(content, status_code=status, **ka)
return content return content
# This module is experimental and therefore not re-exported into __init__
__all__ = ('ko', 'ok', 'Waiter') __all__ = ('ko', 'ok', 'Waiter')

27
tests/test_color.py Normal file
View file

@ -0,0 +1,27 @@
import unittest
from suou import chalk
class TestColor(unittest.TestCase):
def setUp(self) -> None:
...
def tearDown(self) -> None:
...
def test_chalk_colors(self):
strg = "The quick brown fox jumps over the lazy dog"
self.assertEqual(f'\x1b[31m{strg}\x1b[39m', chalk.red(strg))
self.assertEqual(f'\x1b[32m{strg}\x1b[39m', chalk.green(strg))
self.assertEqual(f'\x1b[34m{strg}\x1b[39m', chalk.blue(strg))
self.assertEqual(f'\x1b[36m{strg}\x1b[39m', chalk.cyan(strg))
self.assertEqual(f'\x1b[33m{strg}\x1b[39m', chalk.yellow(strg))
self.assertEqual(f'\x1b[35m{strg}\x1b[39m', chalk.purple(strg))
def test_chalk_bold(self):
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))

35
tests/test_iding.py Normal file
View file

@ -0,0 +1,35 @@
import datetime
import unittest
from suou.iding import Siq, SiqType, SiqGen, make_domain_hash
class TestIding(unittest.TestCase):
def setUp(self) -> None:
...
def tearDown(self) -> None:
...
def test_generation(self):
gen = SiqGen('0', shard_id=256)
gen.set_cur_timestamp(datetime.datetime(2020,1,1))
i1 = gen.generate_one(SiqType.CONTENT)
self.assertEqual(i1, 7451106619238957490390643507207)
i2_16 = gen.generate_list(SiqType.CONTENT, 15)
self.assertIsInstance(i2_16, list)
self.assertEqual(i2_16[0], i1 + 8)
self.assertEqual(i2_16[14], i1 + 120)
gen.set_cur_timestamp(datetime.datetime(2021, 1, 1))
i17 = gen.generate_one(SiqType.CONTENT)
self.assertEqual(i17, 7600439181106854559196223897735)
def test_domain_hash(self):
self.assertEqual(make_domain_hash('0'), 0)
self.assertEqual(make_domain_hash('example.com'), 2261653831)
def test_representation(self):
i1 = Siq(7451106619238957490390643507207)
self.assertEqual(i1.to_hex(), "5e0bd2f0000000000000000007")
self.assertEqual(i1.to_did(), "did:siq:iuxvojaaf4c6s6aaaaaaaaaaaaaah")

42
tests/test_legal.py Normal file
View file

@ -0,0 +1,42 @@
import unittest
from suou.legal import Lawyer
EXPECTED_INDEMNIFY = """
You agree to indemnify and hold harmless TNT from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable counsel and attorneys fees, arising out of any breach of this agreement.
""".strip()
EXPECTED_GOVERNING_LAW = """
These terms of services are governed by, and shall be interpreted in accordance with, the laws of Wakanda. You consent to the sole jurisdiction of Asgard, Wakanda for all disputes between You and TNT, and You consent to the sole application of Wakandan law for all such disputes.
""".strip()
class TestLegal(unittest.TestCase):
def setUp(self) -> None:
self.lawyer = Lawyer(
app_name = "TNT",
company_name= "ACME, Ltd.",
country = "Wakanda",
domain_name= "example.com",
jurisdiction= "Asgard, Wakanda",
country_adjective= "Wakandan"
)
def tearDown(self) -> None:
...
def test_indemnify(self):
self.assertEqual(
self.lawyer.indemnify(),
EXPECTED_INDEMNIFY
)
def test_governing_law(self):
self.assertEqual(
self.lawyer.governing_law(),
EXPECTED_GOVERNING_LAW
)

47
tests/test_mat.py Normal file
View file

@ -0,0 +1,47 @@
import unittest
from suou.mat import Matrix
class TestMat(unittest.TestCase):
def setUp(self):
self.m_a = Matrix([
[2, 2],
[1, 3]
])
self.m_b = Matrix([
[1], [-4]
])
def tearDown(self) -> None:
...
def test_transpose(self):
self.assertEqual(
self.m_a.T,
Matrix([
[2, 1],
[2, 3]
])
)
self.assertEqual(
self.m_b.T,
Matrix([[1, -4]])
)
def test_mul(self):
self.assertEqual(
self.m_b.T @ self.m_a,
Matrix([
[-2, -10]
])
)
self.assertEqual(
self.m_a @ self.m_b,
Matrix([
[-6], [-11]
])
)
def test_shape(self):
self.assertEqual(self.m_a.shape(), (2, 2))
self.assertEqual(self.m_b.shape(), (2, 1))
self.assertEqual(self.m_b.T.shape(), (1, 2))

38
tests/test_validators.py Normal file
View file

@ -0,0 +1,38 @@
import unittest
from suou.calendar import not_greater_than
from suou.validators import not_less_than, yesno
class TestValidators(unittest.TestCase):
def setUp(self):
...
def tearDown(self):
...
def test_yesno(self):
self.assertFalse(yesno('false'))
self.assertFalse(yesno('FALSe'))
self.assertTrue(yesno('fasle'))
self.assertTrue(yesno('falso'))
self.assertTrue(yesno('zero'))
self.assertTrue(yesno('true'))
self.assertFalse(yesno('0'))
self.assertTrue(yesno('00'))
self.assertTrue(yesno('.'))
self.assertTrue(yesno('2'))
self.assertTrue(yesno('o'))
self.assertFalse(yesno('oFF'))
self.assertFalse(yesno('no'))
self.assertFalse(yesno(False))
self.assertTrue(yesno(True))
self.assertFalse(yesno(''))
def test_not_greater_than(self):
self.assertTrue(not_greater_than(5)(5))
self.assertTrue(not_greater_than(5)(3))
self.assertFalse(not_greater_than(3)(8))
def test_not_less_than(self):
self.assertTrue(not_less_than(5)(5))
self.assertFalse(not_less_than(5)(3))
self.assertTrue(not_less_than(3)(8))