From 38ff59c76a4f8d0bf1df7c54674f1e905aec6aa3 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 25 Jul 2025 08:24:46 +0200 Subject: [PATCH] add calendar module, drop Quart-SQLAlchemy --- CHANGELOG.md | 3 +- pyproject.toml | 17 +++++++---- src/suou/__init__.py | 18 +++++++----- src/suou/calendar.py | 66 ++++++++++++++++++++++++++++++++++++++++++ src/suou/codecs.py | 3 +- tests/test_calendar.py | 27 +++++++++++++++++ 6 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 src/suou/calendar.py create mode 100644 tests/test_calendar.py diff --git a/CHANGELOG.md b/CHANGELOG.md index da8638d..cce9c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## 0.5.0 + `sqlalchemy`: add `unbound_fk()`, `bound_fk()` -+ Add `timed_cache()`, `TimedDict()` ++ Add `timed_cache()`, `TimedDict()`, `age_and_days()` ++ Add date conversion utilities + Move obsolete stuff to `obsolete` package (includes configparse 0.3 as of now) + Add more exceptions: `NotFoundError()` diff --git a/pyproject.toml b/pyproject.toml index 9ef670d..e73689a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,8 @@ readme = "README.md" dependencies = [ "itsdangerous", "toml", - "pydantic" + "pydantic", + "uvloop; os_name=='posix'" ] # - further devdependencies below - # @@ -43,16 +44,17 @@ flask_sqlalchemy = [ "Flask-SqlAlchemy", ] peewee = [ - "peewee>=3.0.0, <4.0" + "peewee>=3.0.0" ] markdown = [ "markdown>=3.0.0" ] quart = [ - "Flask>=2.0.0", "Quart", - "Quart-Schema", - "uvloop; os_name=='posix'" + "Quart-Schema" +] +quart_sqlalchemy = [ + "Quart_SQLALchemy>=3.0.0, <4.0" ] full = [ @@ -60,7 +62,10 @@ full = [ "sakuragasaki46-suou[flask]", "sakuragasaki46-suou[quart]", "sakuragasaki46-suou[peewee]", - "sakuragasaki46-suou[markdown]" + "sakuragasaki46-suou[markdown]", + "sakuragasaki46-suou[flask-sqlalchemy]" + # disabled: quart-sqlalchemy causes issues with anyone else. WON'T BE IMPLEMENTED + #"sakuragasaki46-suou[quart-sqlalchemy]" ] diff --git a/src/suou/__init__.py b/src/suou/__init__.py index eb2e0e0..81955cf 100644 --- a/src/suou/__init__.py +++ b/src/suou/__init__.py @@ -18,13 +18,14 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. from .iding import Siq, SiqCache, SiqType, SiqGen from .codecs import (StringCase, cb32encode, cb32decode, b32lencode, b32ldecode, b64encode, b64decode, b2048encode, b2048decode, - jsonencode, want_bytes, want_str, ssv_list, want_urlsafe) + jsonencode, want_bytes, want_str, ssv_list, want_urlsafe, want_urlsafe_bytes) from .bits import count_ones, mask_shift, split_bits, join_bits, mod_ceil, mod_floor +from .calendar import want_datetime, want_isodate, want_timestamp, age_and_days from .configparse import MissingConfigError, MissingConfigWarning, ConfigOptions, ConfigParserConfigSource, ConfigSource, DictConfigSource, ConfigValue, EnvConfigSource from .collections import TimedDict from .functools import deprecated, not_implemented, timed_cache from .classtools import Wanted, Incomplete -from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem +from .itertools import makelist, kwargs_prefix, ltuple, rtuple, additem, addattr from .i18n import I18n, JsonI18n, TomlI18n from .snowflake import Snowflake, SnowflakeGen from .lex import symbol_table, lex, ilex @@ -37,10 +38,11 @@ __all__ = ( 'DictConfigSource', 'EnvConfigSource', 'I18n', 'Incomplete', 'JsonI18n', 'MissingConfigError', 'MissingConfigWarning', 'PrefixIdentifier', 'Siq', 'SiqCache', 'SiqGen', 'SiqType', 'Snowflake', 'SnowflakeGen', 'StringCase', 'TimedDict', 'TomlI18n', 'Wanted', - 'additem', 'b2048decode', 'b2048encode', 'b32ldecode', 'b32lencode', - 'b64encode', 'b64decode', 'cb32encode', 'cb32decode', 'count_ones', - 'deprecated', 'ilex', 'join_bits', 'jsonencode', 'kwargs_prefix', 'lex', - 'ltuple', 'makelist', 'mask_shift', 'mod_ceil', 'mod_floor', - 'not_implemented', 'rtuple', 'split_bits', 'ssv_list', 'symbol_table', - 'timed_cache', 'want_bytes', 'want_str', 'want_urlsafe' + 'addattr', 'additem', 'age_and_days', 'b2048decode', 'b2048encode', + 'b32ldecode', 'b32lencode', 'b64encode', 'b64decode', 'cb32encode', + 'cb32decode', 'count_ones', 'deprecated', 'ilex', 'join_bits', + 'jsonencode', 'kwargs_prefix', 'lex', 'ltuple', 'makelist', 'mask_shift', + 'mod_ceil', 'mod_floor', 'not_implemented', 'rtuple', 'split_bits', + 'ssv_list', 'symbol_table', 'timed_cache', 'want_bytes', 'want_datetime', + 'want_isodate', 'want_str', 'want_timestamp', 'want_urlsafe', 'want_urlsafe_bytes' ) diff --git a/src/suou/calendar.py b/src/suou/calendar.py new file mode 100644 index 0000000..1733853 --- /dev/null +++ b/src/suou/calendar.py @@ -0,0 +1,66 @@ +""" +Calendar utilities (mainly Gregorian oof) + +--- + +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 datetime + +from suou.functools import not_implemented + + +def want_isodate(d: datetime.datetime | str | float | int, *, tz = None) -> str: + """ + Convert a date into ISO timestamp (e.g. 2020-01-01T02:03:04) + """ + if isinstance(d, (int, float)): + d = datetime.datetime.fromtimestamp(d, tz=tz) + if isinstance(d, str): + return d + return d.isoformat() + + +def want_datetime(d: datetime.datetime | str | float | int, *, tz = None) -> datetime.datetime: + """ + Convert a date into Python datetime.datetime (e.g. datetime.datetime(2020, 1, 1, 2, 3, 4)). + + If a string is passed, ISO format is assumed + """ + if isinstance(d, str): + d = datetime.datetime.fromisoformat(d) + elif isinstance(d, (int, float)): + d = datetime.datetime.fromtimestamp(d, tz=tz) + return d + +def want_timestamp(d: datetime.datetime | str | float | int, *, tz = None) -> float: + """ + Convert a date into UNIX timestamp (e.g. 1577840584.0). Returned as a float; decimals are milliseconds. + """ + if isinstance(d, str): + d = want_datetime(d, tz=tz) + if isinstance(d, (int, float)): + return d + return d.timestamp() + +def age_and_days(date: datetime.datetime, now: datetime.datetime | None = None) -> tuple[int, int]: + """ + Compute age / duration of a timespan in years and days. + """ + if now is None: + now = datetime.date.today() + y = now.year - date.year - ((now.month, now.day) < (date.month, date.day)) + d = (now - datetime.date(date.year + y, date.month, date.day)).days + return y, d + +__all__ = ('want_datetime', 'want_timestamp', 'want_isodate', 'age_and_days') \ No newline at end of file diff --git a/src/suou/codecs.py b/src/suou/codecs.py index f8dbf13..9740024 100644 --- a/src/suou/codecs.py +++ b/src/suou/codecs.py @@ -57,6 +57,7 @@ def want_urlsafe(s: str | bytes) -> str: Force a Base64 string into its urlsafe representation. Behavior is unchecked and undefined with anything else than Base64 strings. + In particular, this is NOT an URL encoder. Used by b64encode() and b64decode(). """ @@ -328,5 +329,5 @@ class StringCase(enum.Enum): __all__ = ( 'cb32encode', 'cb32decode', 'b32lencode', 'b32ldecode', 'b64encode', 'b64decode', 'jsonencode' - 'StringCase', 'want_bytes', 'want_str', 'jsondecode', 'ssv_list' + 'StringCase', 'want_bytes', 'want_str', 'jsondecode', 'ssv_list', 'want_urlsafe', 'want_urlsafe_bytes' ) \ No newline at end of file diff --git a/tests/test_calendar.py b/tests/test_calendar.py new file mode 100644 index 0000000..14c0aca --- /dev/null +++ b/tests/test_calendar.py @@ -0,0 +1,27 @@ + + +from datetime import timezone +import datetime +from suou.calendar import want_datetime, want_isodate + +import unittest + + +class TestCalendar(unittest.TestCase): + def setUp(self) -> None: + ... + def tearDown(self) -> None: + ... + + def test_want_isodate(self): + ## if test fails, make sure time zone is set to UTC. + self.assertEqual(want_isodate(0, tz=timezone.utc), '1970-01-01T00:00:00+00:00') + self.assertEqual(want_isodate(86400, tz=timezone.utc), '1970-01-02T00:00:00+00:00') + self.assertEqual(want_isodate(1577840584.0, tz=timezone.utc), '2020-01-01T01:03:04+00:00') + # TODO + + def test_want_datetime(self): + self.assertEqual(want_datetime('2017-04-10T19:00:01', tz=timezone.utc) - want_datetime('2017-04-10T18:00:00', tz=timezone.utc), datetime.timedelta(seconds=3601)) + # TODO + + \ No newline at end of file