From 13b0397edc7ac5c62e48b97856fd97ce801cd55a Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:16:18 -0500 Subject: [PATCH 1/7] feat(sdk): add LocalDate, LocalTime and Instant --- packages/morphir/src/morphir/sdk/instant.py | 76 +++ .../morphir/src/morphir/sdk/local_date.py | 436 ++++++++++++++++++ .../morphir/src/morphir/sdk/local_time.py | 267 +++++++++++ tests/unit/sdk/test_instant.py | 66 +++ tests/unit/sdk/test_local_date.py | 410 ++++++++++++++++ tests/unit/sdk/test_local_time.py | 335 ++++++++++++++ 6 files changed, 1590 insertions(+) create mode 100644 packages/morphir/src/morphir/sdk/instant.py create mode 100644 packages/morphir/src/morphir/sdk/local_date.py create mode 100644 packages/morphir/src/morphir/sdk/local_time.py create mode 100644 tests/unit/sdk/test_instant.py create mode 100644 tests/unit/sdk/test_local_date.py create mode 100644 tests/unit/sdk/test_local_time.py diff --git a/packages/morphir/src/morphir/sdk/instant.py b/packages/morphir/src/morphir/sdk/instant.py new file mode 100644 index 00000000..7d0491b9 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/instant.py @@ -0,0 +1,76 @@ +"""Morphir.SDK.Instant: a point on the timeline. + +An `Instant` is the standard library `datetime.datetime` with a time zone, and +the recommended time zone is `datetime.UTC`. Two aware `datetime` values are +equal when they are the same point on the timeline, whatever their time zones. +A naive `datetime` (one with no `tzinfo`) is not an `Instant`, because it does +not identify a point on the timeline. + +In the Elm runtime an `Instant` is a `Time.Posix`, a whole number of +milliseconds since 1970-01-01T00:00:00Z. The IR specification has the type and +no functions. `from_milliseconds_since_epoch` and `to_milliseconds_since_epoch` +are additions to the specification that convert between the two forms: + + >>> from morphir.sdk import instant as Instant + >>> Instant.from_milliseconds_since_epoch(1643374590000).isoformat() + '2022-01-28T12:56:30+00:00' + >>> Instant.to_milliseconds_since_epoch( + ... datetime.datetime(2022, 1, 28, 12, 56, 30, tzinfo=datetime.UTC) + ... ) + 1643374590000 + +Departure from the Elm runtime: a `datetime` holds the years 1 to 9999 only, with +a precision of one microsecond. +""" + +import datetime + +__all__ = [ + "Instant", + "from_milliseconds_since_epoch", + "to_milliseconds_since_epoch", +] + +Instant = datetime.datetime +"""The standard library datetime type, used with a time zone.""" + +_EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC) +_MILLISECOND = datetime.timedelta(milliseconds=1) + + +def from_milliseconds_since_epoch(millis: int) -> Instant: + """Make an instant from a number of milliseconds since the epoch. + + This function is an addition to the specification. + + Args: + millis: The number of milliseconds since 1970-01-01T00:00:00Z. It can be + negative. + + Returns: + The instant, in UTC. + + Raises: + OverflowError: If the instant is not in the years 1 to 9999. + """ + return _EPOCH + datetime.timedelta(milliseconds=millis) + + +def to_milliseconds_since_epoch(instant: Instant) -> int: + """Return the number of milliseconds from the epoch to an instant. + + This function is an addition to the specification. + + Args: + instant: The instant. It must have a time zone. + + Returns: + The number of milliseconds since 1970-01-01T00:00:00Z. A part below one + millisecond is rounded down. + + Raises: + ValueError: If the `datetime` has no time zone. + """ + if instant.utcoffset() is None: + raise ValueError("to_milliseconds_since_epoch: the datetime has no time zone") + return (instant - _EPOCH) // _MILLISECOND diff --git a/packages/morphir/src/morphir/sdk/local_date.py b/packages/morphir/src/morphir/sdk/local_date.py new file mode 100644 index 00000000..7150e3d7 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/local_date.py @@ -0,0 +1,436 @@ +"""Morphir.SDK.LocalDate: a date with no time zone. + +A `LocalDate` is the standard library `datetime.date`, a date in the proleptic +Gregorian calendar. The reference behaviour is the Elm runtime, which uses +justinmimbs/date. + + >>> from morphir.sdk import local_date as LocalDate + >>> LocalDate.from_iso("2020-01-31") + Just(value=datetime.date(2020, 1, 31)) + >>> LocalDate.to_iso_string(LocalDate.add_months(1, datetime.date(2020, 1, 31))) + '2020-02-29' + +`from_iso` accepts the ISO 8601 date forms that the Elm parser accepts: calendar +dates (`2018-09-26`, `20180926`, `2018-09`, `201809`), ordinal dates +(`2018-269`, `2018269`), week dates (`2018-W39-3`, `2018W393`, `2018-W39`, +`2018W39`) and a year alone (`2018`). Text with a time part gives `Nothing`. + +`int_to_month` is in the Elm runtime but not in the IR specification. + +Departures from the Elm runtime: + +* A `datetime.date` holds the years 1 to 9999 only. `from_iso` and `from_parts` + give `Nothing` for a year outside these limits. `from_calendar_date` and + `from_ordinal_date` raise `ValueError`. The `add_*` functions raise + `OverflowError` when the result is outside these limits. +* `diff_in_months` and `diff_in_years` use integer arithmetic. The Elm runtime + uses a float formula, which in rare cases loses one month when the two dates + have the same day of the month (for example between the years 1366 and 1367). +""" + +import calendar +import datetime +import re +from enum import Enum + +from morphir.sdk.maybe import Just, Maybe, Nothing + +__all__ = [ + "DayOfWeek", + "LocalDate", + "Month", + "add_days", + "add_months", + "add_weeks", + "add_years", + "day", + "day_of_week", + "diff_in_days", + "diff_in_months", + "diff_in_weeks", + "diff_in_years", + "from_calendar_date", + "from_iso", + "from_ordinal_date", + "from_parts", + "int_to_month", + "is_weekday", + "is_weekend", + "month", + "month_number", + "month_to_int", + "to_iso_string", + "year", +] + +LocalDate = datetime.date +"""The standard library date type.""" + + +class DayOfWeek(Enum): + """A day of the week.""" + + Monday = "Monday" + Tuesday = "Tuesday" + Wednesday = "Wednesday" + Thursday = "Thursday" + Friday = "Friday" + Saturday = "Saturday" + Sunday = "Sunday" + + +class Month(Enum): + """A month of the Gregorian calendar.""" + + January = "January" + February = "February" + March = "March" + April = "April" + May = "May" + June = "June" + July = "July" + August = "August" + September = "September" + October = "October" + November = "November" + December = "December" + + +_MONTHS = tuple(Month) +_DAYS_OF_WEEK = tuple(DayOfWeek) + +# The year can have a minus sign. Only ASCII digits are accepted, as in Elm. +_ISO = re.compile( + r"(?P-?[0-9]{4})" + r"(?:" + r"-(?P[0-9]{3})" + r"|-(?P[0-9]{2})(?:-(?P[0-9]{2}))?" + r"|-W(?P[0-9]{2})(?:-(?P[0-9]))?" + r"|(?P[0-9]{2})(?P[0-9]{2})?" + r"|(?P[0-9]{3})" + r"|W(?P[0-9]{2})(?P[0-9])?" + r")?" +) + + +def _check_year(year: int, owner: str) -> None: + if not datetime.MINYEAR <= year <= datetime.MAXYEAR: + raise ValueError( + f"{owner}: year {year} is not in the range " + f"{datetime.MINYEAR} to {datetime.MAXYEAR}" + ) + + +def _days_in_month(year: int, month_number: int) -> int: + return calendar.monthrange(year, month_number)[1] + + +def _days_in_year(year: int) -> int: + return 366 if calendar.isleap(year) else 365 + + +def _truncated_div(a: int, b: int) -> int: + quotient = abs(a) // abs(b) + return quotient if (a >= 0) == (b >= 0) else -quotient + + +def _month_units(date: LocalDate) -> int: + # Whole months since year 1, scaled by 100, plus the day of the month. The + # day is at most 31, so the day part never carries into the month part. + return (12 * (date.year - 1) + (date.month - 1)) * 100 + date.day + + +def diff_in_days(date1: LocalDate, date2: LocalDate) -> int: + """Find the number of days from the first date to the second date. + + Args: + date1: The start date. + date2: The end date. + + Returns: + The number of days. It is negative when `date2` is before `date1`. + """ + return date2.toordinal() - date1.toordinal() + + +def diff_in_weeks(date1: LocalDate, date2: LocalDate) -> int: + """Find the number of whole weeks from the first date to the second date. + + Args: + date1: The start date. + date2: The end date. + + Returns: + The number of whole weeks, rounded toward zero. + """ + return _truncated_div(diff_in_days(date1, date2), 7) + + +def diff_in_months(date1: LocalDate, date2: LocalDate) -> int: + """Find the number of whole months from the first date to the second date. + + A month is complete when the day of the month of `date2` is equal to or + after the day of the month of `date1`. + + Args: + date1: The start date. + date2: The end date. + + Returns: + The number of whole months, rounded toward zero. + """ + return _truncated_div(_month_units(date2) - _month_units(date1), 100) + + +def diff_in_years(date1: LocalDate, date2: LocalDate) -> int: + """Find the number of whole years from the first date to the second date. + + Args: + date1: The start date. + date2: The end date. + + Returns: + The number of whole years, rounded toward zero. + """ + return _truncated_div(diff_in_months(date1, date2), 12) + + +def add_days(offset: int, start_date: LocalDate) -> LocalDate: + """Add a number of days to a date. + + Args: + offset: The number of days. It can be negative. + start_date: The date to start from. + + Returns: + The new date. + + Raises: + OverflowError: If the result is not in the years 1 to 9999. + """ + return start_date + datetime.timedelta(days=offset) + + +def add_weeks(offset: int, start_date: LocalDate) -> LocalDate: + """Add a number of weeks to a date. + + Args: + offset: The number of weeks. It can be negative. + start_date: The date to start from. + + Returns: + The new date. + + Raises: + OverflowError: If the result is not in the years 1 to 9999. + """ + return start_date + datetime.timedelta(weeks=offset) + + +def add_months(offset: int, start_date: LocalDate) -> LocalDate: + """Add a number of months to a date. + + When the new month has fewer days than the day of the start date, the result + is the last day of the new month: one month after 31 January is 28 or 29 + February. + + Args: + offset: The number of months. It can be negative. + start_date: The date to start from. + + Returns: + The new date. + + Raises: + OverflowError: If the result is not in the years 1 to 9999. + """ + whole_months = 12 * (start_date.year - 1) + (start_date.month - 1) + offset + new_year = whole_months // 12 + 1 + new_month = whole_months % 12 + 1 + if not datetime.MINYEAR <= new_year <= datetime.MAXYEAR: + raise OverflowError("add_months: date value out of range") + new_day = min(start_date.day, _days_in_month(new_year, new_month)) + return datetime.date(new_year, new_month, new_day) + + +def add_years(offset: int, start_date: LocalDate) -> LocalDate: + """Add a number of years to a date. + + One year after 29 February is 28 February. + + Args: + offset: The number of years. It can be negative. + start_date: The date to start from. + + Returns: + The new date. + + Raises: + OverflowError: If the result is not in the years 1 to 9999. + """ + return add_months(12 * offset, start_date) + + +def from_calendar_date(y: int, m: Month, d: int) -> LocalDate: + """Make a date from a year, a month and a day of the month. + + A day that is out of range is clamped to the first or the last day of the + month. + + Args: + y: The year. + m: The month. + d: The day of the month. + + Returns: + The date. + + Raises: + ValueError: If the year is not in the range 1 to 9999. + """ + _check_year(y, "from_calendar_date") + month_num = month_to_int(m) + return datetime.date(y, month_num, max(1, min(d, _days_in_month(y, month_num)))) + + +def from_ordinal_date(y: int, day_of_year: int) -> LocalDate: + """Make a date from a year and a day of the year. + + A day that is out of range is clamped to the first or the last day of the + year. + + Args: + y: The year. + day_of_year: The day of the year, where 1 January is day 1. + + Returns: + The date. + + Raises: + ValueError: If the year is not in the range 1 to 9999. + """ + _check_year(y, "from_ordinal_date") + ordinal = max(1, min(day_of_year, _days_in_year(y))) + return datetime.date(y, 1, 1) + datetime.timedelta(days=ordinal - 1) + + +def _from_iso_match(match: re.Match[str]) -> LocalDate: + # Raises ValueError when a part is out of range. + year_num = int(match["year"]) + if not datetime.MINYEAR <= year_num <= datetime.MAXYEAR: + raise ValueError("year out of range") + month_text = match["month"] or match["basic_month"] + if month_text is not None: + day_text = match["day"] or match["basic_day"] or "01" + return datetime.date(year_num, int(month_text), int(day_text)) + week_text = match["week"] or match["basic_week"] + if week_text is not None: + weekday_text = match["weekday"] or match["basic_weekday"] or "1" + return datetime.date.fromisocalendar( + year_num, int(week_text), int(weekday_text) + ) + ordinal = int(match["ordinal"] or match["basic_ordinal"] or "001") + if not 1 <= ordinal <= _days_in_year(year_num): + raise ValueError("day of year out of range") + return datetime.date(year_num, 1, 1) + datetime.timedelta(days=ordinal - 1) + + +def from_iso(iso: str) -> Maybe[LocalDate]: + """Read a date from ISO 8601 text. + + The module docstring lists the accepted forms. Parts that are out of range + are not clamped: `2020-01-55` gives `Nothing`. + + Args: + iso: The text to read. + + Returns: + `Just` the date, or `Nothing` when the text is not a valid date. + """ + match = _ISO.fullmatch(iso) + if match is None: + return Nothing() + try: + return Just(_from_iso_match(match)) + except ValueError, OverflowError: + return Nothing() + + +def to_iso_string(date: LocalDate) -> str: + """Convert a date to ISO 8601 text, in the form `YYYY-MM-DD`.""" + return f"{date.year:04d}-{date.month:02d}-{date.day:02d}" + + +def from_parts(year: int, month: int, day: int) -> Maybe[LocalDate]: + """Make a date from a year, a month number and a day of the month. + + No value is clamped: `from_parts(2000, 2, 30)` gives `Nothing`. + + Args: + year: The year. + month: The month number, where January is 1. + day: The day of the month. + + Returns: + `Just` the date, or `Nothing` when a part is out of range. + """ + try: + return Just(datetime.date(year, month, day)) + except ValueError, OverflowError: + return Nothing() + + +def year(local_date: LocalDate) -> int: + """Return the year of a date.""" + return local_date.year + + +def month(local_date: LocalDate) -> Month: + """Return the month of a date.""" + return _MONTHS[local_date.month - 1] + + +def month_number(local_date: LocalDate) -> int: + """Return the month of a date as a number, where January is 1.""" + return local_date.month + + +def month_to_int(m: Month) -> int: + """Convert a month to a number, where January is 1 and December is 12.""" + return _MONTHS.index(m) + 1 + + +def int_to_month(i: int) -> Maybe[Month]: + """Convert a number to a month, where January is 1. + + This function is in the Elm runtime but not in the IR specification. + + Args: + i: The month number. + + Returns: + `Just` the month, or `Nothing` when the number is not in 1 to 12. + """ + if 1 <= i <= 12: + return Just(_MONTHS[i - 1]) + return Nothing() + + +def day(local_date: LocalDate) -> int: + """Return the day of the month of a date (1 to 31).""" + return local_date.day + + +def day_of_week(local_date: LocalDate) -> DayOfWeek: + """Return the day of the week of a date.""" + return _DAYS_OF_WEEK[local_date.weekday()] + + +def is_weekend(local_date: LocalDate) -> bool: + """Check that a date is a Saturday or a Sunday.""" + return local_date.weekday() >= 5 + + +def is_weekday(local_date: LocalDate) -> bool: + """Check that a date is not a Saturday or a Sunday.""" + return not is_weekend(local_date) diff --git a/packages/morphir/src/morphir/sdk/local_time.py b/packages/morphir/src/morphir/sdk/local_time.py new file mode 100644 index 00000000..a42c12c8 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/local_time.py @@ -0,0 +1,267 @@ +"""Morphir.SDK.LocalTime: a point in time with no time zone. + +A `LocalTime` is an instant: a whole number of milliseconds from the Unix epoch, +1970-01-01T00:00:00Z. It holds a date and a time of day. This is the data of the +Elm runtime, where `LocalTime` is an alias of `Time.Posix`. Thus arithmetic does +not go around midnight: 25 hours after a time is on the next day, and the +difference between two times can be more than 24 hours. + + >>> from morphir.sdk import local_time as LocalTime + >>> LocalTime.from_iso("2022-01-28T12:56:30") + Just(value=LocalTime(milliseconds=1643374590000)) + >>> start = LocalTime.from_milliseconds(1643374590000) + >>> LocalTime.diff_in_hours(LocalTime.add_hours(25, start), start) + 25 + +The number of milliseconds is a Python `int` with no limit. `LocalTime` values +have value equality, a hash and an order (`<`, `sorted`). + +`from_iso` reads the text that `Iso8601.toTime` of +rtfeldman/elm-iso8601-date-strings reads: a date `YYYY-MM-DD`, then optionally +`T` and a time. Text with a time and no date (`12:56:30`) gives `Nothing`. + +Additions to the specification: `to_milliseconds` and `compare`. + +Departures from the Elm runtime: + +* `from_iso` gives `Nothing` for an hour above 23, a minute or a second above + 59, a UTC offset above 23:59 and the day number 00. The Elm parser accepts + these values and carries them into the result. +* For a negative UTC offset with minutes (`-05:30`), `from_iso` subtracts the + minutes. The Elm parser adds them. +* `from_iso` gives `Nothing` for the year 0000. The text form permits the years + 0000 to 9999 only, in Elm also. A UTC offset can move the result a small + distance out of the years 1 to 9999, and that result is given. +* A value has no upper or lower limit. In Elm the number is a JavaScript number, + which is exact only to 2^53. +""" + +import datetime +import math +import re +from dataclasses import dataclass + +from morphir.sdk._compare import Order +from morphir.sdk.maybe import Just, Maybe, Nothing + +__all__ = [ + "LocalTime", + "add_hours", + "add_minutes", + "add_seconds", + "compare", + "diff_in_hours", + "diff_in_minutes", + "diff_in_seconds", + "from_iso", + "from_milliseconds", + "to_milliseconds", +] + + +@dataclass(frozen=True, slots=True, order=True) +class LocalTime: + """A point in time, as milliseconds from 1970-01-01T00:00:00Z.""" + + milliseconds: int + """The number of milliseconds from the epoch. It can be negative.""" + + +_MILLIS_PER_SECOND = 1000 +_MILLIS_PER_MINUTE = 60_000 +_MILLIS_PER_HOUR = 3_600_000 +_MILLIS_PER_DAY = 86_400_000 +_EPOCH_ORDINAL = datetime.date(1970, 1, 1).toordinal() + +# Only ASCII digits are accepted, as in Elm. +_ISO = re.compile( + r"(?P[0-9]{4})-?(?P[0-9]{2})-?(?P[0-9]{2})" + r"(?:T(?P[0-9]{2}):?(?P[0-9]{2})(?::?(?P[0-9]{2}))?" + r"(?:\.(?P[0-9]{0,9}))?" + r"(?PZ|(?P[+-])(?P[0-9]{2})" + r"(?::?(?P[0-9]{2}))?)?" + r")?" +) + + +def _truncated_div(a: int, b: int) -> int: + # Elm's `//` rounds toward zero. Python's `//` rounds down. + quotient = abs(a) // abs(b) + return quotient if (a >= 0) == (b >= 0) else -quotient + + +def from_milliseconds(millis: int) -> LocalTime: + """Make a time from a number of milliseconds from the epoch. + + The number is kept as it is, with no limit and no wrap at the end of a day. + + Args: + millis: The number of milliseconds from 1970-01-01T00:00:00Z. It can be + negative. A `float` is truncated toward zero. + + Returns: + The time. + """ + return LocalTime(int(millis)) + + +def to_milliseconds(time: LocalTime) -> int: + """Return the number of milliseconds from the epoch to a time. + + This function is an addition to the specification. + """ + return time.milliseconds + + +def compare(time_a: LocalTime, time_b: LocalTime) -> Order: + """Compare two times. The earlier time is the smaller one. + + This function is an addition to the specification. + + Args: + time_a: The first time. + time_b: The second time. + + Returns: + `Order.LT`, `Order.EQ` or `Order.GT`. + """ + if time_a.milliseconds < time_b.milliseconds: + return Order.LT + if time_a.milliseconds > time_b.milliseconds: + return Order.GT + return Order.EQ + + +def add_hours(hours: int, time: LocalTime) -> LocalTime: + """Add a number of hours to a time. The result can be on a different day. + + Args: + hours: The number of hours. It can be negative. + time: The time to start from. + + Returns: + The new time. + """ + return LocalTime(time.milliseconds + hours * _MILLIS_PER_HOUR) + + +def add_minutes(minutes: int, time: LocalTime) -> LocalTime: + """Add a number of minutes to a time. The result can be on a different day. + + Args: + minutes: The number of minutes. It can be negative. + time: The time to start from. + + Returns: + The new time. + """ + return LocalTime(time.milliseconds + minutes * _MILLIS_PER_MINUTE) + + +def add_seconds(seconds: int, time: LocalTime) -> LocalTime: + """Add a number of seconds to a time. The result can be on a different day. + + Args: + seconds: The number of seconds. It can be negative. + time: The time to start from. + + Returns: + The new time. + """ + return LocalTime(time.milliseconds + seconds * _MILLIS_PER_SECOND) + + +def diff_in_hours(time_a: LocalTime, time_b: LocalTime) -> int: + """Find the number of whole hours from the second time to the first time. + + Args: + time_a: The time to subtract from. + time_b: The time that is subtracted. + + Returns: + `time_a - time_b` in hours, rounded toward zero. It is negative when + `time_a` is before `time_b`. + """ + return _truncated_div(time_a.milliseconds - time_b.milliseconds, _MILLIS_PER_HOUR) + + +def diff_in_minutes(time_a: LocalTime, time_b: LocalTime) -> int: + """Find the number of whole minutes from the second time to the first time. + + Args: + time_a: The time to subtract from. + time_b: The time that is subtracted. + + Returns: + `time_a - time_b` in minutes, rounded toward zero. It is negative when + `time_a` is before `time_b`. + """ + return _truncated_div(time_a.milliseconds - time_b.milliseconds, _MILLIS_PER_MINUTE) + + +def diff_in_seconds(time_a: LocalTime, time_b: LocalTime) -> int: + """Find the number of whole seconds from the second time to the first time. + + Args: + time_a: The time to subtract from. + time_b: The time that is subtracted. + + Returns: + `time_a - time_b` in seconds, rounded toward zero. It is negative when + `time_a` is before `time_b`. + """ + return _truncated_div(time_a.milliseconds - time_b.milliseconds, _MILLIS_PER_SECOND) + + +def from_iso(iso: str) -> Maybe[LocalTime]: + """Read a time from ISO 8601 text that has a date. + + The accepted text is `YYYY-MM-DD`, then optionally `T`, `HH:MM`, optional + `:SS`, an optional fraction of a second with at most 9 digits, and an + optional UTC offset (`Z`, `+HH:MM`, `+HHMM` or `+HH`). The `-` and `:` + separators are optional. A fraction is rounded to the nearest millisecond. + Text with no UTC offset is read as UTC. Text with no time part gives + midnight. + + Args: + iso: The text to read. + + Returns: + `Just` the time, or `Nothing` when the text is not valid, a part is out + of range, or the year is 0000. + """ + match = _ISO.fullmatch(iso) + if match is None: + return Nothing() + try: + date = datetime.date(int(match["year"]), int(match["month"]), int(match["day"])) + except ValueError: + return Nothing() + hour = int(match["hour"] or "0") + minute = int(match["minute"] or "0") + second = int(match["second"] or "0") + offset_hour = int(match["offset_hour"] or "0") + offset_minute = int(match["offset_minute"] or "0") + if ( + hour > 23 + or minute > 59 + or second > 59 + or offset_hour > 23 + or offset_minute > 59 + ): + return Nothing() + fraction = match["fraction"] + millisecond = math.floor(float(f"0.{fraction}") * 1000 + 0.5) if fraction else 0 + offset = offset_hour * _MILLIS_PER_HOUR + offset_minute * _MILLIS_PER_MINUTE + if match["sign"] == "-": + offset = -offset + return Just( + LocalTime( + (date.toordinal() - _EPOCH_ORDINAL) * _MILLIS_PER_DAY + + hour * _MILLIS_PER_HOUR + + minute * _MILLIS_PER_MINUTE + + second * _MILLIS_PER_SECOND + + millisecond + - offset + ) + ) diff --git a/tests/unit/sdk/test_instant.py b/tests/unit/sdk/test_instant.py new file mode 100644 index 00000000..15cd9815 --- /dev/null +++ b/tests/unit/sdk/test_instant.py @@ -0,0 +1,66 @@ +"""Tests for morphir.sdk.instant (Morphir.SDK.Instant).""" + +import datetime + +import pytest + +from morphir.sdk import instant +from morphir.sdk.instant import Instant + + +class TestType: + def test_instant_is_the_standard_library_datetime(self) -> None: + assert Instant is datetime.datetime + + +class TestFromMillisecondsSinceEpoch: + def test_epoch(self) -> None: + assert instant.from_milliseconds_since_epoch(0) == datetime.datetime( + 1970, 1, 1, tzinfo=datetime.UTC + ) + + def test_result_is_in_utc(self) -> None: + value = instant.from_milliseconds_since_epoch(1643374590000) + assert value.tzinfo is datetime.UTC + assert value == datetime.datetime(2022, 1, 28, 12, 56, 30, tzinfo=datetime.UTC) + + def test_milliseconds_are_exact(self) -> None: + value = instant.from_milliseconds_since_epoch(1643374590123) + assert value.microsecond == 123000 + + def test_negative(self) -> None: + assert instant.from_milliseconds_since_epoch(-1) == datetime.datetime( + 1969, 12, 31, 23, 59, 59, 999000, tzinfo=datetime.UTC + ) + + def test_out_of_range_raises(self) -> None: + with pytest.raises(OverflowError): + instant.from_milliseconds_since_epoch(10**18) + + +class TestToMillisecondsSinceEpoch: + def test_round_trip(self) -> None: + for millis in (0, 1, -1, 1643374590123, -62135596800000, 253402300799999): + assert ( + instant.to_milliseconds_since_epoch( + instant.from_milliseconds_since_epoch(millis) + ) + == millis + ) + + def test_other_time_zone(self) -> None: + zone = datetime.timezone(datetime.timedelta(hours=1)) + value = datetime.datetime(2022, 1, 28, 13, 56, 30, tzinfo=zone) + assert instant.to_milliseconds_since_epoch(value) == 1643374590000 + + def test_part_below_a_millisecond_rounds_down(self) -> None: + after = datetime.datetime(1970, 1, 1, 0, 0, 0, 1999, tzinfo=datetime.UTC) + assert instant.to_milliseconds_since_epoch(after) == 1 + before = datetime.datetime( + 1969, 12, 31, 23, 59, 59, 999999, tzinfo=datetime.UTC + ) + assert instant.to_milliseconds_since_epoch(before) == -1 + + def test_naive_datetime_raises(self) -> None: + with pytest.raises(ValueError, match="time zone"): + instant.to_milliseconds_since_epoch(datetime.datetime(2022, 1, 28)) diff --git a/tests/unit/sdk/test_local_date.py b/tests/unit/sdk/test_local_date.py new file mode 100644 index 00000000..8a1c3056 --- /dev/null +++ b/tests/unit/sdk/test_local_date.py @@ -0,0 +1,410 @@ +"""Tests for morphir.sdk.local_date (Morphir.SDK.LocalDate).""" + +import datetime + +import pytest + +from morphir.sdk import local_date +from morphir.sdk.local_date import DayOfWeek, LocalDate, Month +from morphir.sdk.maybe import Just, Nothing, with_default + +FALLBACK = datetime.date(2019, 2, 1) + + +def _elm_diff_in_months(a: LocalDate, b: LocalDate) -> int: + """The float formula of justinmimbs/date, used as an oracle.""" + + def to_months(d: LocalDate) -> float: + return float(12 * (d.year - 1) + (d.month - 1)) + d.day / 100 + + return int(to_months(b) - to_months(a)) + + +class TestType: + def test_local_date_is_the_standard_library_date(self) -> None: + assert LocalDate is datetime.date + + def test_month_has_the_elm_constructor_names(self) -> None: + assert [m.name for m in Month] == [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] + + def test_day_of_week_has_the_elm_constructor_names(self) -> None: + assert [d.name for d in DayOfWeek] == [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] + + +class TestMath: + """The Elm `mathTests`.""" + + def test_add_day(self) -> None: + assert local_date.add_days(1, datetime.date(2020, 1, 1)) == datetime.date( + 2020, 1, 2 + ) + + def test_subtract_day(self) -> None: + assert local_date.add_days(-1, datetime.date(2020, 1, 2)) == datetime.date( + 2020, 1, 1 + ) + + def test_add_week(self) -> None: + assert local_date.add_weeks(1, datetime.date(2020, 1, 7)) == datetime.date( + 2020, 1, 14 + ) + + def test_subtract_week(self) -> None: + assert local_date.add_weeks(-1, datetime.date(2020, 1, 14)) == datetime.date( + 2020, 1, 7 + ) + + def test_add_month(self) -> None: + assert local_date.add_months(1, datetime.date(2020, 1, 1)) == datetime.date( + 2020, 2, 1 + ) + + def test_subtract_month(self) -> None: + assert local_date.add_months(-1, datetime.date(2020, 2, 1)) == datetime.date( + 2020, 1, 1 + ) + + def test_add_year(self) -> None: + assert local_date.add_years(1, datetime.date(2020, 1, 1)) == datetime.date( + 2021, 1, 1 + ) + + def test_subtract_year(self) -> None: + assert local_date.add_years(-1, datetime.date(2020, 2, 1)) == datetime.date( + 2019, 2, 1 + ) + + def test_from_iso_string_to_local_date(self) -> None: + assert with_default( + FALLBACK, local_date.from_iso("2023-06-13") + ) == datetime.date(2023, 6, 13) + + def test_to_iso_string(self) -> None: + assert local_date.to_iso_string(datetime.date(2023, 6, 13)) == "2023-06-13" + + def test_from_parts(self) -> None: + assert with_default( + FALLBACK, local_date.from_parts(2023, 6, 9) + ) == datetime.date(2023, 6, 9) + + def test_diff_in_days(self) -> None: + assert ( + local_date.diff_in_days( + datetime.date(2023, 6, 9), datetime.date(2023, 6, 19) + ) + == 10 + ) + + def test_diff_in_weeks(self) -> None: + assert ( + local_date.diff_in_weeks( + datetime.date(2023, 6, 9), datetime.date(2023, 6, 19) + ) + == 1 + ) + + def test_diff_in_months(self) -> None: + assert ( + local_date.diff_in_months( + datetime.date(2023, 6, 9), datetime.date(2023, 6, 19) + ) + == 0 + ) + + def test_diff_in_years(self) -> None: + assert ( + local_date.diff_in_years( + datetime.date(2023, 6, 9), datetime.date(2024, 6, 19) + ) + == 1 + ) + + +class TestConstructors: + """The Elm `constructorTests`.""" + + def test_valid_from_iso(self) -> None: + assert local_date.from_iso("2020-01-01") == Just(datetime.date(2020, 1, 1)) + + def test_invalid_from_iso_parsing(self) -> None: + assert local_date.from_iso("2020-01 hello") == Nothing() + + def test_invalid_from_iso_numeric(self) -> None: + assert local_date.from_iso("2020-01-55") == Nothing() + + def test_valid_from_parts(self) -> None: + assert local_date.from_parts(2020, 1, 1) == Just(datetime.date(2020, 1, 1)) + + def test_invalid_month_from_parts(self) -> None: + assert local_date.from_parts(2020, 13, 1) == Nothing() + assert local_date.from_parts(2020, 0, 1) == Nothing() + + def test_invalid_day_from_parts(self) -> None: + assert local_date.from_parts(2020, 2, 30) == Nothing() + assert local_date.from_parts(2020, 2, 0) == Nothing() + + def test_valid_from_calendar_date(self) -> None: + assert local_date.from_calendar_date(2023, Month.December, 25) == datetime.date( + 2023, 12, 25 + ) + + def test_invalid_but_pinned_from_calendar_date(self) -> None: + assert local_date.from_calendar_date(2023, Month.December, 39) == datetime.date( + 2023, 12, 31 + ) + assert local_date.from_calendar_date(2023, Month.February, -4) == datetime.date( + 2023, 2, 1 + ) + + def test_valid_from_ordinal_date(self) -> None: + assert local_date.from_ordinal_date(2023, 15) == datetime.date(2023, 1, 15) + + +class TestFromIsoForms: + """The forms that `Date.fromIsoString` of justinmimbs/date accepts.""" + + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("2018", datetime.date(2018, 1, 1)), + ("2018-09", datetime.date(2018, 9, 1)), + ("2018-09-26", datetime.date(2018, 9, 26)), + ("201809", datetime.date(2018, 9, 1)), + ("20180926", datetime.date(2018, 9, 26)), + ("2018-269", datetime.date(2018, 9, 26)), + ("2018269", datetime.date(2018, 9, 26)), + ("2018-W39", datetime.date(2018, 9, 24)), + ("2018-W39-3", datetime.date(2018, 9, 26)), + ("2018W39", datetime.date(2018, 9, 24)), + ("2018W393", datetime.date(2018, 9, 26)), + ("2020-366", datetime.date(2020, 12, 31)), + ("2020-W53-7", datetime.date(2021, 1, 3)), + ("2009-W01-1", datetime.date(2008, 12, 29)), + ], + ) + def test_accepted(self, text: str, expected: LocalDate) -> None: + assert local_date.from_iso(text) == Just(expected) + + @pytest.mark.parametrize( + "text", + [ + "", + "18-09-26", + "2018-9-26", + "2018-09-26T00:00:00.000Z", + "2018-09-26 ", + " 2018-09-26", + "2018-09-26\n", + "2018-00-01", + "2018-13-01", + "2018-02-29", + "2018-09-00", + "2018-000", + "2018-366", + "2018-W00-1", + "2018-W53-1", + "2018-W39-0", + "2018-W39-8", + "2018-09-", + "2018-0926", + "2018/09/26", + "\u0662\u0660\u0661\u0668-\u0660\u0669-\u0662\u0666", + ], + ) + def test_rejected(self, text: str) -> None: + assert local_date.from_iso(text) == Nothing() + + def test_a_year_that_date_cannot_hold_gives_nothing(self) -> None: + assert local_date.from_iso("0000-01-01") == Nothing() + assert local_date.from_iso("-0001-01-01") == Nothing() + + def test_round_trip(self) -> None: + d = datetime.date(987, 3, 4) + assert local_date.to_iso_string(d) == "0987-03-04" + assert local_date.from_iso("0987-03-04") == Just(d) + + +class TestOrdinalAndCalendar: + def test_from_ordinal_date_clamps(self) -> None: + assert local_date.from_ordinal_date(2023, 0) == datetime.date(2023, 1, 1) + assert local_date.from_ordinal_date(2023, -9) == datetime.date(2023, 1, 1) + assert local_date.from_ordinal_date(2023, 366) == datetime.date(2023, 12, 31) + assert local_date.from_ordinal_date(2024, 366) == datetime.date(2024, 12, 31) + assert local_date.from_ordinal_date(2024, 999) == datetime.date(2024, 12, 31) + + def test_from_calendar_date_leap_february(self) -> None: + assert local_date.from_calendar_date(2024, Month.February, 30) == datetime.date( + 2024, 2, 29 + ) + assert local_date.from_calendar_date(2023, Month.February, 30) == datetime.date( + 2023, 2, 28 + ) + + def test_year_out_of_range_raises(self) -> None: + with pytest.raises(ValueError, match="year"): + local_date.from_calendar_date(0, Month.January, 1) + with pytest.raises(ValueError, match="year"): + local_date.from_ordinal_date(10000, 1) + + def test_from_parts_year_out_of_range_gives_nothing(self) -> None: + assert local_date.from_parts(0, 1, 1) == Nothing() + assert local_date.from_parts(10000, 1, 1) == Nothing() + + +class TestAdd: + def test_add_months_clamps_to_the_end_of_the_month(self) -> None: + assert local_date.add_months(1, datetime.date(2020, 1, 31)) == datetime.date( + 2020, 2, 29 + ) + assert local_date.add_months(1, datetime.date(2021, 1, 31)) == datetime.date( + 2021, 2, 28 + ) + assert local_date.add_months(-1, datetime.date(2021, 3, 31)) == datetime.date( + 2021, 2, 28 + ) + assert local_date.add_months(1, datetime.date(2021, 3, 31)) == datetime.date( + 2021, 4, 30 + ) + + def test_add_months_across_years(self) -> None: + assert local_date.add_months(14, datetime.date(2020, 11, 15)) == datetime.date( + 2022, 1, 15 + ) + assert local_date.add_months(-11, datetime.date(2020, 11, 15)) == datetime.date( + 2019, 12, 15 + ) + assert local_date.add_months(-12, datetime.date(2020, 1, 15)) == datetime.date( + 2019, 1, 15 + ) + assert local_date.add_months(0, datetime.date(2020, 1, 15)) == datetime.date( + 2020, 1, 15 + ) + + def test_add_years_clamps_february_29(self) -> None: + assert local_date.add_years(1, datetime.date(2020, 2, 29)) == datetime.date( + 2021, 2, 28 + ) + assert local_date.add_years(4, datetime.date(2020, 2, 29)) == datetime.date( + 2024, 2, 29 + ) + + def test_add_days_across_a_year(self) -> None: + assert local_date.add_days(366, datetime.date(2020, 1, 1)) == datetime.date( + 2021, 1, 1 + ) + + def test_result_out_of_range_raises(self) -> None: + with pytest.raises(OverflowError): + local_date.add_days(1, datetime.date.max) + with pytest.raises(OverflowError): + local_date.add_weeks(-1, datetime.date.min) + with pytest.raises(OverflowError): + local_date.add_months(1, datetime.date(9999, 12, 1)) + with pytest.raises(OverflowError): + local_date.add_years(-1, datetime.date(1, 1, 1)) + + +class TestDiff: + def test_diff_is_negative_when_the_second_date_is_earlier(self) -> None: + a = datetime.date(2023, 6, 19) + b = datetime.date(2023, 6, 9) + assert local_date.diff_in_days(a, b) == -10 + assert local_date.diff_in_weeks(a, b) == -1 + assert local_date.diff_in_months(a, b) == 0 + assert local_date.diff_in_years(a, b) == 0 + + def test_diff_in_weeks_truncates_toward_zero(self) -> None: + a = datetime.date(2023, 6, 1) + assert local_date.diff_in_weeks(a, datetime.date(2023, 6, 14)) == 1 + assert local_date.diff_in_weeks(a, datetime.date(2023, 6, 15)) == 2 + assert local_date.diff_in_weeks(datetime.date(2023, 6, 14), a) == -1 + assert local_date.diff_in_weeks(datetime.date(2023, 6, 15), a) == -2 + + def test_diff_in_months_counts_whole_months(self) -> None: + a = datetime.date(2023, 1, 31) + assert local_date.diff_in_months(a, datetime.date(2023, 2, 28)) == 0 + assert local_date.diff_in_months(a, datetime.date(2023, 3, 30)) == 1 + assert local_date.diff_in_months(a, datetime.date(2023, 3, 31)) == 2 + assert local_date.diff_in_months(datetime.date(2023, 3, 31), a) == -2 + assert local_date.diff_in_months(datetime.date(2023, 3, 30), a) == -1 + + def test_diff_in_years(self) -> None: + a = datetime.date(2020, 2, 29) + assert local_date.diff_in_years(a, datetime.date(2021, 2, 28)) == 0 + assert local_date.diff_in_years(a, datetime.date(2021, 3, 1)) == 1 + assert local_date.diff_in_years(datetime.date(2021, 3, 1), a) == -1 + assert local_date.diff_in_years(a, datetime.date(2030, 2, 28)) == 9 + + def test_diff_in_months_agrees_with_the_elm_formula(self) -> None: + start = datetime.date(2019, 1, 1) + dates = [start + datetime.timedelta(days=17 * i) for i in range(60)] + disagreements = [ + (a, b) + for a in dates + for b in dates + if local_date.diff_in_months(a, b) != _elm_diff_in_months(a, b) + ] + assert disagreements == [] + + def test_same_day_of_month_is_a_whole_number_of_months(self) -> None: + for months in range(-40, 41): + a = datetime.date(2020, 5, 15) + b = local_date.add_months(months, a) + assert local_date.diff_in_months(a, b) == months + + +class TestQuery: + def test_year_month_day(self) -> None: + d = datetime.date(2023, 6, 13) + assert local_date.year(d) == 2023 + assert local_date.month(d) is Month.June + assert local_date.month_number(d) == 6 + assert local_date.day(d) == 13 + + def test_day_of_week(self) -> None: + monday = datetime.date(2023, 6, 12) + for offset, expected in enumerate(DayOfWeek): + assert ( + local_date.day_of_week(local_date.add_days(offset, monday)) is expected + ) + + def test_is_weekend_and_is_weekday(self) -> None: + monday = datetime.date(2023, 6, 12) + weekend = [ + local_date.is_weekend(local_date.add_days(i, monday)) for i in range(7) + ] + assert weekend == [False, False, False, False, False, True, True] + weekday = [ + local_date.is_weekday(local_date.add_days(i, monday)) for i in range(7) + ] + assert weekday == [True, True, True, True, True, False, False] + + def test_month_to_int(self) -> None: + assert [local_date.month_to_int(m) for m in Month] == list(range(1, 13)) + + def test_int_to_month(self) -> None: + for number, expected in enumerate(Month, start=1): + assert local_date.int_to_month(number) == Just(expected) + assert local_date.int_to_month(0) == Nothing() + assert local_date.int_to_month(13) == Nothing() diff --git a/tests/unit/sdk/test_local_time.py b/tests/unit/sdk/test_local_time.py new file mode 100644 index 00000000..cea0335d --- /dev/null +++ b/tests/unit/sdk/test_local_time.py @@ -0,0 +1,335 @@ +"""Tests for morphir.sdk.local_time (Morphir.SDK.LocalTime).""" + +import dataclasses +import datetime + +import pytest + +from morphir.sdk import local_time +from morphir.sdk.basics import Order +from morphir.sdk.local_time import LocalTime +from morphir.sdk.maybe import Just, Nothing, with_default + +BASE = 1643374590000 +"""2022-01-28T12:56:30Z in milliseconds from the epoch.""" + +HOUR = 3_600_000 +MINUTE = 60_000 +SECOND = 1000 +DAY = 24 * HOUR + +_ARABIC_INDIC_DIGITS = {ord("0") + i: 0x0660 + i for i in range(10)} +"""A map from the ASCII digits to digits that Elm's `Char.isDigit` rejects.""" + + +def _iso(text: str) -> LocalTime: + return with_default(local_time.from_milliseconds(0), local_time.from_iso(text)) + + +def _utc(time: LocalTime) -> datetime.datetime: + """The UTC date and time of a value, as `Time.toHour utc` gives in Elm.""" + epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC) + return epoch + datetime.timedelta(milliseconds=time.milliseconds) + + +class TestType: + def test_local_time_holds_milliseconds_from_the_epoch(self) -> None: + assert LocalTime(5).milliseconds == 5 + + def test_local_time_is_frozen(self) -> None: + value = LocalTime(5) + with pytest.raises(dataclasses.FrozenInstanceError): + value.milliseconds = 6 # type: ignore[misc] + + def test_local_time_has_value_equality_and_a_hash(self) -> None: + assert LocalTime(5) == LocalTime(5) + assert LocalTime(5) != LocalTime(6) + assert hash(LocalTime(5)) == hash(LocalTime(5)) + + def test_local_time_has_an_order(self) -> None: + assert LocalTime(5) < LocalTime(6) + assert LocalTime(-1) < LocalTime(0) + assert sorted([LocalTime(3), LocalTime(-2), LocalTime(1)]) == [ + LocalTime(-2), + LocalTime(1), + LocalTime(3), + ] + + +class TestMath: + """The Elm `mathTests`.""" + + def test_add_hours(self) -> None: + assert _utc(local_time.add_hours(1, _iso("2022-01-28T12:56:30"))).hour == 13 + + def test_subtract_hours(self) -> None: + assert _utc(local_time.add_hours(-1, _iso("2022-01-28T12:56:30"))).hour == 11 + + def test_add_minutes(self) -> None: + assert _utc(local_time.add_minutes(1, _iso("2022-01-28T12:56:30"))).minute == 57 + + def test_subtract_minutes(self) -> None: + assert ( + _utc(local_time.add_minutes(-1, _iso("2022-01-28T12:56:30"))).minute == 55 + ) + + def test_add_seconds(self) -> None: + assert _utc(local_time.add_seconds(1, _iso("2022-01-28T12:56:30"))).second == 31 + + def test_subtract_seconds(self) -> None: + assert ( + _utc(local_time.add_seconds(-1, _iso("2022-01-28T12:56:30"))).second == 29 + ) + + def test_diff_in_minutes(self) -> None: + assert ( + local_time.diff_in_minutes( + _iso("2022-01-28T13:49:30"), _iso("2022-01-28T12:56:30") + ) + == 53 + ) + + def test_diff_in_hours_less_than_an_hour_elapsed(self) -> None: + assert ( + local_time.diff_in_hours( + _iso("2022-01-28T13:49:30"), _iso("2022-01-28T12:56:30") + ) + == 0 + ) + + def test_diff_in_hours_more_than_an_hour_elapsed(self) -> None: + assert ( + local_time.diff_in_hours( + _iso("2022-01-28T15:49:30"), _iso("2022-01-28T12:56:30") + ) + == 2 + ) + + def test_diff_in_seconds(self) -> None: + assert ( + local_time.diff_in_seconds( + _iso("2022-01-28T12:58:30"), _iso("2022-01-28T12:56:30") + ) + == 120 + ) + + +class TestConstructors: + """The Elm `constructorTests`.""" + + def test_valid_from_iso(self) -> None: + assert local_time.from_iso("2022-01-28T12:56:30") == Just( + LocalTime(1643374590000) + ) + + def test_invalid_from_iso_parsing(self) -> None: + assert local_time.from_iso("2022-01-28TTTTT") == Nothing() + + def test_invalid_from_iso_numeric(self) -> None: + assert local_time.from_iso("12:56:30") == Nothing() + + def test_from_milliseconds(self) -> None: + assert local_time.from_milliseconds(0) == LocalTime(0) + + +class TestFromMilliseconds: + def test_the_value_is_stored_as_given(self) -> None: + for millis in (0, 1, -1, DAY, DAY + 1, -DAY - 1, BASE, 10**30, -(10**30)): + assert local_time.from_milliseconds(millis) == LocalTime(millis) + + def test_no_wrap_at_the_end_of_the_day(self) -> None: + assert local_time.from_milliseconds(DAY) != local_time.from_milliseconds(0) + assert local_time.from_milliseconds(DAY).milliseconds == 86_400_000 + + def test_a_float_is_truncated_toward_zero(self) -> None: + assert local_time.from_milliseconds(1.9) == LocalTime(1) # type: ignore[arg-type] + assert local_time.from_milliseconds(-1.9) == LocalTime(-1) # type: ignore[arg-type] + result = local_time.from_milliseconds(2.0) # type: ignore[arg-type] + assert type(result.milliseconds) is int + + def test_to_milliseconds_is_the_inverse(self) -> None: + for millis in (0, 1, -1, 999, BASE, DAY * 400_000, -DAY * 400_000): + assert ( + local_time.to_milliseconds(local_time.from_milliseconds(millis)) + == millis + ) + + +class TestFromIsoForms: + """The forms that `Iso8601.toTime` of rtfeldman/elm-iso8601-date-strings reads.""" + + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("1970-01-01", 0), + ("1970-01-01T00:00:00.000Z", 0), + ("1970-01-02", DAY), + ("1969-12-31T23:59:59.999Z", -1), + ("2022-01-28", BASE - 12 * HOUR - 56 * MINUTE - 30 * SECOND), + ("20220128", BASE - 12 * HOUR - 56 * MINUTE - 30 * SECOND), + ("2022-01-28T12:56", BASE - 30 * SECOND), + ("2022-01-28T1256", BASE - 30 * SECOND), + ("2022-01-28T125630", BASE), + ("20220128T125630Z", BASE), + ("2022-01-28T12:56:30Z", BASE), + ("2022-01-28T12:56:30.5", BASE + 500), + ("2022-01-28T12:56:30.123Z", BASE + 123), + ("2022-01-28T12:56:30.123456789Z", BASE + 123), + ("2022-01-28T12:56:30.1236Z", BASE + 124), + ("2022-01-28T12:56:30.9996Z", BASE + 1000), + ("2022-01-28T12:56:30.", BASE), + ("2022-01-28T12:56:30+01:00", BASE - HOUR), + ("2022-01-28T12:56:30+0100", BASE - HOUR), + ("2022-01-28T12:56:30+01", BASE - HOUR), + ("2022-01-28T12:56:30-05:30", BASE + 5 * HOUR + 30 * MINUTE), + ("2022-01-28T23:56:30-01:00", BASE + 12 * HOUR), + ("2022-01-28T00:10:00+01:00", BASE - 13 * HOUR - 46 * MINUTE - 30 * SECOND), + ("2020-02-29T00:00:00", 1582934400000), + ("2000-03-01", 951868800000), + ("1900-03-01", -2203891200000), + ("0001-01-01", -62135596800000), + ("9999-12-31T23:59:59.999Z", 253402300799999), + ], + ) + def test_accepted(self, text: str, expected: int) -> None: + assert local_time.from_iso(text) == Just(LocalTime(expected)) + + def test_agrees_with_the_standard_library(self) -> None: + for text in ("1583-10-15T01:02:03", "2024-02-29T23:59:59", "2399-12-31T00:00"): + aware = datetime.datetime.fromisoformat(text).replace(tzinfo=datetime.UTC) + assert _utc(_iso(text)) == aware + + @pytest.mark.parametrize( + "text", + [ + "", + "T12:56:30", + "2022-01-28T", + "2022-01-28T12", + "2022-01-28T12:56:", + "2022-01-28T12:56:3", + "2022-01-28 12:56:30", + "2022-01-28t12:56:30", + "2022-01-28T12:56:30z", + "2022-01-28T12:56:30.1234567890", + "2022-01-28T12:56:30+1", + "2022-01-28T12:56:30Z ", + "2022-01-28T12:56:30\n", + "2022-13-28T12:56:30", + "2022-00-28T12:56:30", + "2022-01-32T12:56:30", + "2021-02-29T12:56:30", + "2022-04-31T12:56:30", + "22-01-28T12:56:30", + "-2022-01-28T12:56:30", + "2022-01-28".translate(_ARABIC_INDIC_DIGITS), + ], + ) + def test_rejected(self, text: str) -> None: + assert local_time.from_iso(text) == Nothing() + + @pytest.mark.parametrize( + "text", + [ + "2022-01-00T12:56:30", + "2022-01-28T24:00:00", + "2022-01-28T12:60:00", + "2022-01-28T12:56:60", + "2022-01-28T12:56:30+24:00", + "2022-01-28T12:56:30+01:60", + ], + ) + def test_a_part_out_of_range_gives_nothing(self, text: str) -> None: + assert local_time.from_iso(text) == Nothing() + + def test_the_year_0000_gives_nothing(self) -> None: + assert local_time.from_iso("0000-01-01T00:00:00") == Nothing() + + def test_an_offset_can_move_the_result_out_of_the_years_1_to_9999(self) -> None: + assert local_time.from_iso("0001-01-01T00:00:00+01:00") == Just( + LocalTime(-62135596800000 - HOUR) + ) + + +class TestAdd: + def test_add_is_plain_addition(self) -> None: + start = LocalTime(BASE) + assert local_time.add_hours(2, start) == LocalTime(BASE + 2 * HOUR) + assert local_time.add_hours(-2, start) == LocalTime(BASE - 2 * HOUR) + assert local_time.add_minutes(45, start) == LocalTime(BASE + 45 * MINUTE) + assert local_time.add_minutes(-45, start) == LocalTime(BASE - 45 * MINUTE) + assert local_time.add_seconds(7, start) == LocalTime(BASE + 7 * SECOND) + assert local_time.add_seconds(-7, start) == LocalTime(BASE - 7 * SECOND) + assert local_time.add_hours(0, start) == start + + def test_add_hours_does_not_wrap_at_midnight(self) -> None: + start = _iso("2022-01-28T12:56:30") + later = local_time.add_hours(25, start) + assert later == _iso("2022-01-29T13:56:30") + assert later != local_time.add_hours(1, start) + assert _utc(later).day == 29 + + def test_add_minutes_and_seconds_do_not_wrap_at_midnight(self) -> None: + start = _iso("2022-01-28T23:30:00") + assert local_time.add_minutes(45, start) == _iso("2022-01-29T00:15:00") + assert local_time.add_seconds(86_400, start) == _iso("2022-01-29T23:30:00") + assert local_time.add_seconds(-1, _iso("2022-01-28T00:00:00")) == _iso( + "2022-01-27T23:59:59" + ) + + def test_add_keeps_the_milliseconds(self) -> None: + assert local_time.add_seconds(1, LocalTime(BASE + 456)) == LocalTime( + BASE + 1456 + ) + + def test_add_has_no_limit(self) -> None: + assert local_time.add_hours(10**20, LocalTime(0)) == LocalTime(10**20 * HOUR) + + +class TestDiff: + def test_diff_is_the_first_argument_minus_the_second(self) -> None: + a = _iso("2022-01-28T12:56:30") + b = _iso("2022-01-28T15:49:30") + assert local_time.diff_in_hours(b, a) == 2 + assert local_time.diff_in_hours(a, b) == -2 + assert local_time.diff_in_minutes(b, a) == 173 + assert local_time.diff_in_minutes(a, b) == -173 + assert local_time.diff_in_seconds(b, a) == 173 * 60 + assert local_time.diff_in_seconds(a, b) == -173 * 60 + + def test_diff_truncates_toward_zero(self) -> None: + a = LocalTime(BASE) + b = LocalTime(BASE + 1500) + assert local_time.diff_in_seconds(b, a) == 1 + assert local_time.diff_in_seconds(a, b) == -1 + assert local_time.diff_in_minutes(a, b) == 0 + c = LocalTime(BASE + 2 * HOUR - 1) + assert local_time.diff_in_hours(c, a) == 1 + assert local_time.diff_in_hours(a, c) == -1 + assert local_time.diff_in_minutes(a, c) == -119 + + def test_diff_does_not_wrap_across_days(self) -> None: + a = _iso("2022-01-28T23:00:00") + b = _iso("2022-01-30T01:00:00") + assert local_time.diff_in_hours(b, a) == 26 + assert local_time.diff_in_hours(a, b) == -26 + assert local_time.diff_in_minutes(b, a) == 26 * 60 + assert local_time.diff_in_seconds(b, a) == 26 * 3600 + + def test_diff_of_equal_values_is_zero(self) -> None: + a = LocalTime(BASE) + assert local_time.diff_in_hours(a, a) == 0 + assert local_time.diff_in_minutes(a, a) == 0 + assert local_time.diff_in_seconds(a, a) == 0 + + +class TestCompare: + def test_compare(self) -> None: + assert local_time.compare(LocalTime(1), LocalTime(2)) is Order.LT + assert local_time.compare(LocalTime(2), LocalTime(1)) is Order.GT + assert local_time.compare(LocalTime(2), LocalTime(2)) is Order.EQ + + def test_compare_across_days(self) -> None: + early = _iso("2022-01-28T23:00:00") + late = _iso("2022-01-29T01:00:00") + assert local_time.compare(early, late) is Order.LT From 6f494fd70fe592b115db4dd616691f4fdd501e2a Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:16:18 -0500 Subject: [PATCH 2/7] feat(sdk): add UUID with the elm-uuid parsing rules --- packages/morphir/src/morphir/sdk/uuid.py | 209 ++++++++++++++++++ tests/unit/sdk/test_uuid.py | 263 +++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 packages/morphir/src/morphir/sdk/uuid.py create mode 100644 tests/unit/sdk/test_uuid.py diff --git a/packages/morphir/src/morphir/sdk/uuid.py b/packages/morphir/src/morphir/sdk/uuid.py new file mode 100644 index 00000000..b100a579 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/uuid.py @@ -0,0 +1,209 @@ +"""Morphir.SDK.UUID: universally unique identifiers. + +A `UUID` is the standard library `uuid.UUID`. The reference behaviour is the Elm +runtime, which uses TSFoster/elm-uuid. + +The name `compare` and the module name are also names in Python. Import the +module with an alias: + + >>> from morphir.sdk import uuid as UUID + >>> UUID.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8") == Ok(UUID.dns_namespace) + True + >>> UUID.to_string(UUID.for_name("python.org", UUID.dns_namespace)) + '886313e1-3b8a-5372-9b90-0c9aee199e5d' + >>> UUID.parse("not a uuid") + Err(error=) + +`parse` and `from_string` read the same text forms as the Elm runtime. They +remove all spaces, tabs, line feeds and hyphens, and ignore the case. They then +accept 32 hexadecimal digits, optionally after the prefix `urn:uuid:` or +between `{` and `}`. They do not use the parser of `uuid.UUID`, which accepts +other forms (for example `{` with no `}`, or `urn:uuid:` with braces). + +A UUID from `parse` is never the nil UUID, has the RFC 4122 variant and has a +version from 0 to 8. A `uuid.UUID` from a different source can break these +rules. All functions in this module accept such a value. + +Departure from the Elm runtime: `parse` gives `NoVersion` for a version above +8, as TSFoster/elm-uuid 4.3.1 does. Release 4.2.0 of that package, which +morphir-elm also permits, gives `NoVersion` for a version above 5. +""" + +import uuid as _uuid +from enum import Enum + +from morphir.sdk._compare import Order +from morphir.sdk.maybe import Just, Maybe, Nothing +from morphir.sdk.result import Err, Ok, Result + +__all__ = [ + "UUID", + "Error", + "compare", + "dns_namespace", + "for_name", + "from_string", + "is_nil_string", + "nil_string", + "oid_namespace", + "parse", + "to_string", + "url_namespace", + "version", + "x500_namespace", +] + +UUID = _uuid.UUID +"""The standard library UUID type.""" + + +class Error(Enum): + """The reason why `parse` did not accept a text.""" + + WrongFormat = "WrongFormat" + """The text has a character that is not a hexadecimal digit.""" + + WrongLength = "WrongLength" + """The text does not have 32 hexadecimal digits.""" + + UnsupportedVariant = "UnsupportedVariant" + """The UUID does not have the RFC 4122 variant.""" + + IsNil = "IsNil" + """The UUID is the nil UUID, which has only zeros.""" + + NoVersion = "NoVersion" + """The version of the UUID is not in the range 0 to 8.""" + + +_HEX_DIGITS = frozenset("0123456789abcdef") +_MAX_VERSION = 8 +_URN_PREFIX = "urn:uuid:" + +nil_string: str = "00000000-0000-0000-0000-000000000000" +"""The text of the nil UUID. `parse` does not accept it.""" + +dns_namespace: UUID = _uuid.NAMESPACE_DNS +"""The namespace for domain names, `6ba7b810-9dad-11d1-80b4-00c04fd430c8`.""" + +url_namespace: UUID = _uuid.NAMESPACE_URL +"""The namespace for URLs, `6ba7b811-9dad-11d1-80b4-00c04fd430c8`.""" + +oid_namespace: UUID = _uuid.NAMESPACE_OID +"""The namespace for ISO object IDs, `6ba7b812-9dad-11d1-80b4-00c04fd430c8`.""" + +x500_namespace: UUID = _uuid.NAMESPACE_X500 +"""The namespace for X.500 names, `6ba7b814-9dad-11d1-80b4-00c04fd430c8`.""" + + +def _utf16_length(text: str) -> int: + # Elm measures a string in UTF-16 code units. + return sum(2 if ord(char) > 0xFFFF else 1 for char in text) + + +def _normalize(s: str) -> str: + text = s + for removed in ("\n", "\t", " ", "-"): + text = text.replace(removed, "") + text = text.lower() + if text.startswith(_URN_PREFIX): + return text[len(_URN_PREFIX) :] + if text.startswith("{") and text.endswith("}"): + return text[1:-1] + return text + + +def parse(s: str) -> Result[Error, UUID]: + """Read a UUID from text. + + The module docstring gives the accepted text forms. + + Args: + s: The text to read. + + Returns: + `Ok` with the UUID, or `Err` with the reason. The checks are in this + order: `WrongLength`, `WrongFormat`, `IsNil`, `NoVersion`, + `UnsupportedVariant`. + """ + normalized = _normalize(s) + if _utf16_length(normalized) != 32: + return Err(Error.WrongLength) + if not _HEX_DIGITS.issuperset(normalized): + return Err(Error.WrongFormat) + value = _uuid.UUID(int=int(normalized, 16)) + if value.int == 0: + return Err(Error.IsNil) + if version(value) > _MAX_VERSION: + return Err(Error.NoVersion) + if (value.int >> 62) & 0b11 != 0b10: + return Err(Error.UnsupportedVariant) + return Ok(value) + + +def from_string(s: str) -> Maybe[UUID]: + """Read a UUID from text, as `parse` does, with no reason for a failure. + + Args: + s: The text to read. + + Returns: + `Just` the UUID, or `Nothing` when `parse` gives an error. + """ + match parse(s): + case Ok(value): + return Just(value) + case Err(): + return Nothing() + + +def for_name(s: str, uuid: UUID) -> UUID: + """Make a version 5 UUID from a name and a namespace. + + The same name and namespace always give the same UUID. The result can be the + namespace of other UUIDs. + + Args: + s: The name. It is encoded as UTF-8. + uuid: The namespace. + + Returns: + The version 5 (SHA-1) UUID of RFC 4122. + """ + return _uuid.uuid5(uuid, s) + + +def to_string(uuid: UUID) -> str: + """Convert a UUID to the canonical text: lower case, with four hyphens.""" + return str(uuid) + + +def version(uuid: UUID) -> int: + """Return the version number of a UUID. + + The result is the value of the four version bits. Unlike `uuid.UUID.version`, + it is a number for a UUID of any variant. + """ + return (uuid.int >> 76) & 0xF + + +def compare(uuid1: UUID, uuid2: UUID) -> Order: + """Compare two UUIDs as unsigned 128-bit numbers. + + Args: + uuid1: The first UUID. + uuid2: The second UUID. + + Returns: + `Order.LT`, `Order.EQ` or `Order.GT`. + """ + if uuid1.int < uuid2.int: + return Order.LT + if uuid1.int > uuid2.int: + return Order.GT + return Order.EQ + + +def is_nil_string(s: str) -> bool: + """Check that a text is the nil UUID in one of the forms that `parse` reads.""" + return parse(s) == Err(Error.IsNil) diff --git a/tests/unit/sdk/test_uuid.py b/tests/unit/sdk/test_uuid.py new file mode 100644 index 00000000..eeec79ad --- /dev/null +++ b/tests/unit/sdk/test_uuid.py @@ -0,0 +1,263 @@ +"""Tests for morphir.sdk.uuid (Morphir.SDK.UUID).""" + +import uuid as stdlib_uuid + +import pytest + +from morphir.sdk import uuid as uuid_ +from morphir.sdk.basics import Order +from morphir.sdk.maybe import Just, Nothing +from morphir.sdk.result import Err, Ok +from morphir.sdk.uuid import UUID, Error + +DNS = "6ba7b810-9dad-11d1-80b4-00c04fd430c8" + + +class TestType: + def test_uuid_is_the_standard_library_uuid(self) -> None: + assert UUID is stdlib_uuid.UUID + + def test_error_has_the_elm_constructor_names(self) -> None: + assert [e.name for e in Error] == [ + "WrongFormat", + "WrongLength", + "UnsupportedVariant", + "IsNil", + "NoVersion", + ] + + +class TestNamespaces: + """The Elm `namespaceTests`.""" + + def test_dns_namespace(self) -> None: + assert uuid_.to_string(uuid_.dns_namespace) == DNS + assert uuid_.dns_namespace == stdlib_uuid.NAMESPACE_DNS + + def test_url_namespace(self) -> None: + assert ( + uuid_.to_string(uuid_.url_namespace) + == "6ba7b811-9dad-11d1-80b4-00c04fd430c8" + ) + assert uuid_.url_namespace == stdlib_uuid.NAMESPACE_URL + + def test_oid_namespace(self) -> None: + assert ( + uuid_.to_string(uuid_.oid_namespace) + == "6ba7b812-9dad-11d1-80b4-00c04fd430c8" + ) + assert uuid_.oid_namespace == stdlib_uuid.NAMESPACE_OID + + def test_x500_namespace(self) -> None: + assert ( + uuid_.to_string(uuid_.x500_namespace) + == "6ba7b814-9dad-11d1-80b4-00c04fd430c8" + ) + assert uuid_.x500_namespace == stdlib_uuid.NAMESPACE_X500 + + +class TestNil: + """The Elm `toStringTests`.""" + + def test_nil_string(self) -> None: + assert uuid_.nil_string == "00000000-0000-0000-0000-000000000000" + + def test_is_nil_string(self) -> None: + assert uuid_.is_nil_string(uuid_.nil_string) is True + + def test_is_nil_string_other_forms(self) -> None: + assert uuid_.is_nil_string("0" * 32) is True + assert uuid_.is_nil_string("{" + uuid_.nil_string + "}") is True + assert uuid_.is_nil_string("urn:uuid:" + uuid_.nil_string) is True + + def test_is_nil_string_false(self) -> None: + assert uuid_.is_nil_string(DNS) is False + assert uuid_.is_nil_string("") is False + assert uuid_.is_nil_string("0" * 31) is False + assert uuid_.is_nil_string("nil") is False + + +class TestFromString: + """The Elm `fromStringTests`.""" + + def test_from_string_basic_uuid(self) -> None: + assert uuid_.from_string(DNS) == Just(stdlib_uuid.UUID(DNS)) + + def test_from_string_invalid_uuid(self) -> None: + assert uuid_.from_string("c72c207b-0847-386d-bdbc-2e5def81cg81") == Nothing() + + def test_from_string_incorrect_length(self) -> None: + assert uuid_.from_string("6ba7b811-9dad-11d1-80b4-00c04fd430d80") == Nothing() + + +class TestParse: + """The Elm `parseTests`.""" + + def test_parse_basic_uuid(self) -> None: + assert uuid_.parse(DNS) == Ok(uuid_.dns_namespace) + + def test_parse_invalid_uuid(self) -> None: + assert uuid_.parse("c72c207b-0847-386d-bdbc-2e5def81cg81") == Err( + Error.WrongFormat + ) + + def test_parse_incorrect_length(self) -> None: + assert uuid_.parse("6ba7b811-9dad-11d1-80b4-00c04fd430d80") == Err( + Error.WrongLength + ) + + def test_parse_uuid_version_mapping(self) -> None: + match uuid_.parse("c72c207b-0847-386d-bdbc-2e5def81cf81"): + case Ok(value): + assert uuid_.version(value) == 3 + case Err(error): + pytest.fail(f"parse failed: {error}") + + +class TestParseForms: + """The text forms that `UUID.fromString` of TSFoster/elm-uuid accepts.""" + + @pytest.mark.parametrize( + "text", + [ + DNS, + DNS.upper(), + DNS.replace("-", ""), + "{" + DNS + "}", + "{" + DNS.replace("-", "") + "}", + "urn:uuid:" + DNS, + "URN:UUID:" + DNS.upper(), + " " + DNS + "\n", + "6ba7b810 9dad 11d1 80b4 00c04fd430c8", + "6ba7b810\t9dad\t11d1\t80b4\t00c04fd430c8", + "6b-a7b8109dad11d1-80b400c0-4fd430c8", + "-{6ba7b810-9dad-11d1-80b4-00c04fd430c8}-", + ], + ) + def test_accepted(self, text: str) -> None: + assert uuid_.parse(text) == Ok(uuid_.dns_namespace) + + @pytest.mark.parametrize( + ("text", "error"), + [ + ("", Error.WrongLength), + ("6ba7b810", Error.WrongLength), + (DNS + "0", Error.WrongLength), + ("{" + DNS, Error.WrongLength), + (DNS + "}", Error.WrongLength), + ("urn:uuid:{" + DNS + "}", Error.WrongLength), + ("{urn:uuid:" + DNS + "}", Error.WrongLength), + ("uuid:" + DNS, Error.WrongLength), + ("6ba7b810\r9dad-11d1-80b4-00c04fd430c8", Error.WrongLength), + ("0x" + DNS.replace("-", "")[2:], Error.WrongFormat), + ("6ba7b810_9dad-11d1-80b4-0c04fd430c8", Error.WrongFormat), + ( + "6ba7b810-9dad-11d1-80b4-00c04fd430c\N{FULLWIDTH DIGIT EIGHT}", + Error.WrongFormat, + ), + ("\N{GRINNING FACE}" + DNS.replace("-", "")[2:], Error.WrongFormat), + ("00000000-0000-0000-0000-000000000000", Error.IsNil), + ("6ba7b810-9dad-91d1-80b4-00c04fd430c8", Error.NoVersion), + ("6ba7b810-9dad-f1d1-80b4-00c04fd430c8", Error.NoVersion), + ("6ba7b810-9dad-11d1-00b4-00c04fd430c8", Error.UnsupportedVariant), + ("6ba7b810-9dad-11d1-c0b4-00c04fd430c8", Error.UnsupportedVariant), + ("6ba7b810-9dad-11d1-e0b4-00c04fd430c8", Error.UnsupportedVariant), + ], + ) + def test_rejected(self, text: str, error: Error) -> None: + assert uuid_.parse(text) == Err(error) + assert uuid_.from_string(text) == Nothing() + + def test_the_version_check_comes_before_the_variant_check(self) -> None: + assert uuid_.parse("6ba7b810-9dad-f1d1-00b4-00c04fd430c8") == Err( + Error.NoVersion + ) + + @pytest.mark.parametrize("version", range(9)) + def test_versions_0_to_8_are_accepted(self, version: int) -> None: + text = f"6ba7b810-9dad-{version:x}1d1-80b4-00c04fd430c8" + match uuid_.parse(text): + case Ok(value): + assert uuid_.version(value) == version + assert uuid_.to_string(value) == text + case Err(error): + pytest.fail(f"parse failed: {error}") + + +class TestForName: + """The Elm `forNameTests`, with the expected values of RFC 4122 version 5.""" + + def test_for_name_dns_namespace(self) -> None: + assert uuid_.for_name("foo", uuid_.dns_namespace) == stdlib_uuid.uuid5( + stdlib_uuid.NAMESPACE_DNS, "foo" + ) + + def test_for_name_url_namespace(self) -> None: + assert uuid_.for_name("foo", uuid_.url_namespace) == stdlib_uuid.uuid5( + stdlib_uuid.NAMESPACE_URL, "foo" + ) + + def test_for_name_oid_namespace(self) -> None: + assert uuid_.for_name("foo", uuid_.oid_namespace) == stdlib_uuid.uuid5( + stdlib_uuid.NAMESPACE_OID, "foo" + ) + + def test_for_name_x500_namespace(self) -> None: + assert uuid_.for_name("foo", uuid_.x500_namespace) == stdlib_uuid.uuid5( + stdlib_uuid.NAMESPACE_X500, "foo" + ) + + def test_examples_from_the_elm_documentation(self) -> None: + api = uuid_.for_name("https://api.example.com/v2/", uuid_.dns_namespace) + assert uuid_.to_string(api) == "bad122ad-b5b6-527c-b544-4406328d8b13" + widget = uuid_.for_name("Widget", api) + assert uuid_.to_string(widget) == "7b0db628-d793-550b-a883-937a276f4908" + + def test_known_value(self) -> None: + assert ( + uuid_.to_string(uuid_.for_name("python.org", uuid_.dns_namespace)) + == "886313e1-3b8a-5372-9b90-0c9aee199e5d" + ) + + def test_version_is_5(self) -> None: + assert uuid_.version(uuid_.for_name("foo", uuid_.dns_namespace)) == 5 + + def test_name_is_encoded_as_utf_8(self) -> None: + assert uuid_.for_name("héllo \N{GRINNING FACE}", uuid_.url_namespace) == ( + stdlib_uuid.uuid5(stdlib_uuid.NAMESPACE_URL, "héllo \N{GRINNING FACE}") + ) + + +class TestToString: + def test_canonical_lower_case(self) -> None: + assert uuid_.to_string(stdlib_uuid.UUID(DNS.upper())) == DNS + + def test_round_trip(self) -> None: + value = uuid_.for_name("round trip", uuid_.oid_namespace) + assert uuid_.parse(uuid_.to_string(value)) == Ok(value) + + +class TestVersion: + def test_version(self) -> None: + assert uuid_.version(uuid_.dns_namespace) == 1 + assert uuid_.version(stdlib_uuid.uuid4()) == 4 + + def test_version_of_the_nil_uuid(self) -> None: + assert uuid_.version(stdlib_uuid.UUID(int=0)) == 0 + + +class TestCompare: + def test_compare(self) -> None: + assert uuid_.compare(uuid_.dns_namespace, uuid_.url_namespace) is Order.LT + assert uuid_.compare(uuid_.url_namespace, uuid_.dns_namespace) is Order.GT + assert uuid_.compare(uuid_.dns_namespace, uuid_.dns_namespace) is Order.EQ + + def test_compare_is_unsigned(self) -> None: + low = stdlib_uuid.UUID("7fffffff-ffff-4fff-bfff-ffffffffffff") + high = stdlib_uuid.UUID("80000000-0000-4000-8000-000000000000") + assert uuid_.compare(low, high) is Order.LT + + def test_compare_uses_the_last_part(self) -> None: + a = stdlib_uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") + b = stdlib_uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c9") + assert uuid_.compare(a, b) is Order.LT From 86cf27de239d078ac3e7e9a4ed742ecab4970f53 Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:16:18 -0500 Subject: [PATCH 3/7] feat(sdk): add Regex over the re module --- packages/morphir/src/morphir/sdk/regex.py | 287 ++++++++++++++++++++ tests/unit/sdk/test_regex.py | 310 ++++++++++++++++++++++ 2 files changed, 597 insertions(+) create mode 100644 packages/morphir/src/morphir/sdk/regex.py create mode 100644 tests/unit/sdk/test_regex.py diff --git a/packages/morphir/src/morphir/sdk/regex.py b/packages/morphir/src/morphir/sdk/regex.py new file mode 100644 index 00000000..ecafbeed --- /dev/null +++ b/packages/morphir/src/morphir/sdk/regex.py @@ -0,0 +1,287 @@ +r"""Morphir.SDK.Regex: regular expressions. + +The reference is the API of elm/regex 1.0.0. A `Regex` holds a compiled pattern +of the standard library `re` module. Lists are tuples, as in `morphir.sdk.list`. + + >>> from morphir.sdk import regex as Regex + >>> from morphir.sdk import maybe as Maybe + >>> comma = Maybe.with_default(Regex.never, Regex.from_string(" *, *")) + >>> Regex.split(comma, "tom , 99, 90") + ('tom', '99', '90') + >>> Regex.replace(comma, lambda match: ";", "tom , 99, 90") + 'tom;99;90' + +Departures from elm/regex: + +* **Pattern syntax.** elm/regex compiles a pattern with the JavaScript `RegExp` + (with no `u` flag). This module compiles it with Python `re`. Most patterns + mean the same in the two, but not all. The known differences are: + + * A named group is `(?P...)` and a reference to it is `(?P=name)`. The + JavaScript forms `(?...)` and `\k` are not valid. + * `\d`, `\w`, `\s` and `\b` use the Unicode character properties. In + JavaScript `\d` and `\w` match ASCII characters only. Write `[0-9]` for the + ASCII digits. + * `case_insensitive` uses the Unicode case rules. + * `$` with no `multiline` option also matches before a line feed at the end + of the text. Use `\Z` for the end of the text only. + * Python rejects an escape of an ASCII letter that it does not know, such as + the JavaScript control escape `\cJ`. The JavaScript classes `[^]` and `[]` + are not valid. + * A lookbehind must have a fixed width. + * A pattern that JavaScript accepts can therefore give `Nothing` here, and the + reverse is also possible. + +* **Index.** `Match.index` counts Unicode code points, as all string functions + in this SDK do. In Elm it counts UTF-16 code units. +* **Empty matches in `split`.** In Elm, `split` does not stop when the pattern + matches the empty string. Here the search continues after an empty match one + character later, as `re.finditer` does: `split` with the pattern `""` gives + the characters with an empty string at the two ends. +* **A negative count.** `split_at_most` with a negative count does no split. In + Elm a negative count means no limit. + +Behaviour of elm/regex that this module keeps: + +* A group that matched the empty string is `Nothing` in `Match.submatches`, the + same as a group that did not take part in the match. +* `find` and `find_at_most` stop at an empty match that ends where the search + for it started: `find` with `a*` on `"baaa"` gives one empty match. +* `replace` replaces empty matches in the same way as JavaScript and Python. +""" + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from morphir.sdk.maybe import Just, Maybe, Nothing + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = [ + "Match", + "Options", + "Regex", + "contains", + "find", + "find_at_most", + "from_string", + "from_string_with", + "never", + "replace", + "replace_at_most", + "split", + "split_at_most", +] + + +@dataclass(frozen=True, slots=True) +class Regex: + """A compiled regular expression. + + Make one with `from_string` or `from_string_with`. Two values are equal when + they have the same pattern text and the same options. + """ + + pattern: re.Pattern[str] + + +@dataclass(frozen=True, slots=True) +class Options: + """The options of `from_string_with`.""" + + case_insensitive: bool + """Ignore the case of letters.""" + + multiline: bool + """Let `^` and `$` match at the start and the end of each line.""" + + +@dataclass(frozen=True, slots=True) +class Match: + """The data of one match.""" + + match: str + """The text that matched.""" + + index: int + """The position of the match in the text, in code points from 0.""" + + number: int + """The number of the match: 1 for the first match, 2 for the second.""" + + submatches: tuple[Maybe[str], ...] + """The text of each group in the pattern. + + A group that took no part in the match, or that matched the empty string, is + `Nothing`. + """ + + +def from_string_with(options: Options, string: str) -> Maybe[Regex]: + """Compile a pattern with options. + + Args: + options: The options. + string: The pattern, in the syntax of the Python `re` module. + + Returns: + `Just` the regular expression, or `Nothing` when the pattern is not + valid. + """ + flags = re.NOFLAG + if options.case_insensitive: + flags |= re.IGNORECASE + if options.multiline: + flags |= re.MULTILINE + try: + return Just(Regex(re.compile(string, flags))) + except (re.PatternError, OverflowError, RecursionError): + return Nothing() + + +def from_string(string: str) -> Maybe[Regex]: + """Compile a pattern that is case sensitive and not multiline. + + Args: + string: The pattern, in the syntax of the Python `re` module. + + Returns: + `Just` the regular expression, or `Nothing` when the pattern is not + valid. + """ + return from_string_with(Options(case_insensitive=False, multiline=False), string) + + +never: Regex = Regex(re.compile(r".^")) +"""A regular expression that never matches.""" + + +def contains(regex: Regex, string: str) -> bool: + """Check that a regular expression matches somewhere in a string.""" + return regex.pattern.search(string) is not None + + +def _to_match(found: re.Match[str], number: int) -> Match: + return Match( + match=found.group(0), + index=found.start(), + number=number, + submatches=tuple( + Just(group) if group else Nothing() for group in found.groups() + ), + ) + + +def split_at_most(number: int, regex: Regex, string: str) -> tuple[str, ...]: + """Split a string at the first matches of a regular expression. + + Args: + number: The maximum number of splits. Zero or less gives no split. + regex: The regular expression that matches the separators. + string: The string to split. + + Returns: + The parts between the separators. The text of the groups in the pattern + is not in the result. + """ + parts: list[str] = [] + start = 0 + for count, found in enumerate(regex.pattern.finditer(string)): + if count >= number: + break + parts.append(string[start : found.start()]) + start = found.end() + parts.append(string[start:]) + return tuple(parts) + + +def split(regex: Regex, string: str) -> tuple[str, ...]: + """Split a string at all matches of a regular expression. + + Args: + regex: The regular expression that matches the separators. + string: The string to split. + + Returns: + The parts between the separators. + """ + return split_at_most(len(string) + 1, regex, string) + + +def find_at_most(number: int, regex: Regex, string: str) -> tuple[Match, ...]: + """Find the first matches of a regular expression in a string. + + Args: + number: The maximum number of matches. + regex: The regular expression. + string: The string to search. + + Returns: + The matches, in the sequence in which they occur. + """ + matches: list[Match] = [] + position = 0 + previous_end = -1 + while len(matches) < number: + found = regex.pattern.search(string, position) + if found is None or found.end() == previous_end: + break + matches.append(_to_match(found, len(matches) + 1)) + previous_end = position = found.end() + return tuple(matches) + + +def find(regex: Regex, string: str) -> tuple[Match, ...]: + """Find all matches of a regular expression in a string. + + Args: + regex: The regular expression. + string: The string to search. + + Returns: + The matches, in the sequence in which they occur. + """ + return find_at_most(len(string) + 1, regex, string) + + +def replace_at_most( + number: int, regex: Regex, with_: Callable[[Match], str], string: str +) -> str: + r"""Replace the first matches of a regular expression in a string. + + Args: + number: The maximum number of replacements. + regex: The regular expression. + with_: A function that gives the replacement text for a match. The text + is used as it is: `\1` and `$1` have no special meaning. + string: The string to change. + + Returns: + The string with the replacements. + """ + if number <= 0: + return string + count = 0 + + def replacement(found: re.Match[str]) -> str: + nonlocal count + count += 1 + return with_(_to_match(found, count)) + + return regex.pattern.sub(replacement, string, count=number) + + +def replace(regex: Regex, with_: Callable[[Match], str], string: str) -> str: + """Replace all matches of a regular expression in a string. + + Args: + regex: The regular expression. + with_: A function that gives the replacement text for a match. + string: The string to change. + + Returns: + The string with the replacements. + """ + return replace_at_most(len(string) + 1, regex, with_, string) diff --git a/tests/unit/sdk/test_regex.py b/tests/unit/sdk/test_regex.py new file mode 100644 index 00000000..2485daf3 --- /dev/null +++ b/tests/unit/sdk/test_regex.py @@ -0,0 +1,310 @@ +"""Tests for morphir.sdk.regex (Morphir.SDK.Regex, elm/regex 1.0.0).""" + +import dataclasses +import re + +import pytest + +from morphir.sdk import regex +from morphir.sdk.maybe import Just, Nothing, with_default +from morphir.sdk.regex import Match, Options, Regex + + +def _regex(pattern: str) -> Regex: + return with_default(regex.never, regex.from_string(pattern)) + + +class TestTypes: + def test_regex_is_frozen(self) -> None: + value = _regex("a") + with pytest.raises(dataclasses.FrozenInstanceError): + value.pattern = re.compile("b") # type: ignore[misc] + + def test_regex_values_are_equal_by_pattern_and_options(self) -> None: + assert regex.from_string("a+") == regex.from_string("a+") + assert regex.from_string("a+") != regex.from_string("a*") + assert regex.from_string("a+") != regex.from_string_with( + Options(case_insensitive=True, multiline=False), "a+" + ) + assert hash(_regex("a+")) == hash(_regex("a+")) + + def test_match_fields(self) -> None: + value = Match(match="ab", index=3, number=1, submatches=(Just("a"), Nothing())) + assert value.match == "ab" + assert value.index == 3 + assert value.number == 1 + assert value.submatches == (Just("a"), Nothing()) + with pytest.raises(dataclasses.FrozenInstanceError): + value.index = 4 # type: ignore[misc] + + def test_options_fields(self) -> None: + options = Options(case_insensitive=True, multiline=False) + assert options.case_insensitive is True + assert options.multiline is False + + +class TestFromString: + def test_valid(self) -> None: + match regex.from_string("[0-9]+"): + case Just(value): + assert value.pattern.pattern == "[0-9]+" + case Nothing(): + pytest.fail("from_string gave Nothing") + + @pytest.mark.parametrize( + "pattern", ["(", "[a", "a**", "(? None: + assert regex.from_string(pattern) == Nothing() + + def test_default_options(self) -> None: + lower = _regex("abc") + assert regex.contains(lower, "ABC") is False + start = _regex("^b") + assert regex.contains(start, "a\nb") is False + + def test_dot_does_not_match_a_line_feed(self) -> None: + assert regex.contains(_regex("a.b"), "a\nb") is False + + +class TestFromStringWith: + def test_case_insensitive(self) -> None: + value = with_default( + regex.never, + regex.from_string_with( + Options(case_insensitive=True, multiline=False), "abc" + ), + ) + assert regex.contains(value, "xABCx") is True + + def test_multiline(self) -> None: + value = with_default( + regex.never, + regex.from_string_with( + Options(case_insensitive=False, multiline=True), "^b$" + ), + ) + assert regex.contains(value, "a\nb\nc") is True + assert regex.contains(value, "a\nB\nc") is False + + def test_both(self) -> None: + value = with_default( + regex.never, + regex.from_string_with( + Options(case_insensitive=True, multiline=True), "^b$" + ), + ) + assert regex.contains(value, "a\nB\nc") is True + + def test_invalid_gives_nothing(self) -> None: + assert ( + regex.from_string_with(Options(case_insensitive=True, multiline=True), "(") + == Nothing() + ) + + +class TestNever: + @pytest.mark.parametrize("text", ["", "a", ".^", "\n", "abc\ndef"]) + def test_never_matches_nothing(self, text: str) -> None: + assert regex.contains(regex.never, text) is False + assert regex.find(regex.never, text) == () + assert regex.split(regex.never, text) == (text,) + assert regex.replace(regex.never, lambda _: "x", text) == text + + +class TestContains: + """The examples of the elm/regex documentation.""" + + def test_contains(self) -> None: + digit = _regex("[0-9]") + assert regex.contains(digit, "abc123") is True + assert regex.contains(digit, "abcxyz") is False + + def test_contains_searches_the_whole_string(self) -> None: + assert regex.contains(_regex("c$"), "abc") is True + assert regex.contains(_regex("^b"), "abc") is False + + +class TestSplit: + def test_split(self) -> None: + comma = _regex(" *, *") + expected = ("tom", "99", "90", "85") + assert regex.split(comma, "tom,99,90,85") == expected + assert regex.split(comma, "tom, 99, 90, 85") == expected + assert regex.split(comma, "tom , 99, 90, 85") == expected + + def test_no_match(self) -> None: + assert regex.split(_regex(","), "abc") == ("abc",) + assert regex.split(_regex(","), "") == ("",) + + def test_match_at_the_ends(self) -> None: + assert regex.split(_regex(","), ",a,,b,") == ("", "a", "", "b", "") + + def test_groups_are_not_in_the_result(self) -> None: + assert regex.split(_regex("(,)( )?"), "a,b, c") == ("a", "b", "c") + + def test_split_at_most(self) -> None: + comma = _regex(",") + assert regex.split_at_most(1, comma, "tom,99,90,85") == ("tom", "99,90,85") + assert regex.split_at_most(2, comma, "tom,99,90,85") == ("tom", "99", "90,85") + assert regex.split_at_most(3, comma, "tom,99,90,85") == ( + "tom", + "99", + "90", + "85", + ) + assert regex.split_at_most(99, comma, "tom,99,90,85") == ( + "tom", + "99", + "90", + "85", + ) + + def test_split_at_most_zero_or_less(self) -> None: + assert regex.split_at_most(0, _regex(","), "a,b") == ("a,b",) + assert regex.split_at_most(-1, _regex(","), "a,b") == ("a,b",) + + def test_empty_matches(self) -> None: + assert regex.split(_regex(""), "abc") == ("", "a", "b", "c", "") + assert regex.split(_regex("x*"), "axxb") == ("", "a", "", "b", "") + + +class TestFind: + def test_find(self) -> None: + location = _regex(r"[oi]n a (\w+)") + assert regex.find(location, "I am on a boat in a lake.") == ( + Match(match="on a boat", index=5, number=1, submatches=(Just("boat"),)), + Match(match="in a lake", index=15, number=2, submatches=(Just("lake"),)), + ) + + def test_no_match(self) -> None: + assert regex.find(_regex("x"), "abc") == () + + def test_no_groups(self) -> None: + assert regex.find(_regex("b"), "abc") == ( + Match(match="b", index=1, number=1, submatches=()), + ) + + def test_a_group_that_did_not_take_part_is_nothing(self) -> None: + assert regex.find(_regex("(a)|(b)"), "ab") == ( + Match(match="a", index=0, number=1, submatches=(Just("a"), Nothing())), + Match(match="b", index=1, number=2, submatches=(Nothing(), Just("b"))), + ) + + def test_an_empty_group_is_nothing_as_in_elm(self) -> None: + assert regex.find(_regex("(x*)b"), "ab") == ( + Match(match="b", index=1, number=1, submatches=(Nothing(),)), + ) + + def test_named_group_is_a_submatch(self) -> None: + assert regex.find(_regex(r"(?P\w+)"), "hi") == ( + Match(match="hi", index=0, number=1, submatches=(Just("hi"),)), + ) + + def test_find_at_most(self) -> None: + digit = _regex("[0-9]") + assert regex.find_at_most(2, digit, "a1b2c3") == ( + Match(match="1", index=1, number=1, submatches=()), + Match(match="2", index=3, number=2, submatches=()), + ) + assert len(regex.find_at_most(99, digit, "a1b2c3")) == 3 + + def test_find_at_most_zero_or_less(self) -> None: + assert regex.find_at_most(0, _regex("a"), "aaa") == () + assert regex.find_at_most(-1, _regex("a"), "aaa") == () + + def test_find_stops_at_an_empty_match_as_in_elm(self) -> None: + assert regex.find(_regex("a*"), "baaa") == ( + Match(match="", index=0, number=1, submatches=()), + ) + assert regex.find(_regex("a*"), "aab") == ( + Match(match="aa", index=0, number=1, submatches=()), + ) + assert regex.find(_regex(""), "") == ( + Match(match="", index=0, number=1, submatches=()), + ) + + def test_start_anchor_matches_only_at_the_start(self) -> None: + assert regex.find(_regex("^a"), "aaa") == ( + Match(match="a", index=0, number=1, submatches=()), + ) + + def test_lookbehind_sees_the_text_before_the_match(self) -> None: + assert [m.index for m in regex.find(_regex("(?<=a)a"), "aaa")] == [1, 2] + + def test_index_counts_code_points(self) -> None: + assert regex.find(_regex("b"), "\N{GRINNING FACE}b") == ( + Match(match="b", index=1, number=1, submatches=()), + ) + + +class TestReplace: + def test_replace(self) -> None: + vowel = _regex("[aeiou]") + assert regex.replace(vowel, lambda _: "", "The quick brown fox") == ( + "Th qck brwn fx" + ) + + def test_replace_uses_the_match(self) -> None: + word = _regex(r"\w+") + assert ( + regex.replace(word, lambda m: m.match[::-1], "deliver mined parts") + == "reviled denim strap" + ) + + def test_replace_gives_the_match_data(self) -> None: + seen: list[Match] = [] + + def record(match: Match) -> str: + seen.append(match) + return f"<{match.number}>" + + assert regex.replace(_regex("(a)|(b)"), record, "xaxb") == "x<1>x<2>" + assert seen == [ + Match(match="a", index=1, number=1, submatches=(Just("a"), Nothing())), + Match(match="b", index=3, number=2, submatches=(Nothing(), Just("b"))), + ] + + def test_replacement_text_is_literal(self) -> None: + assert regex.replace(_regex("(a)"), lambda _: r"\1$1\g<1>", "a") == ( + r"\1$1\g<1>" + ) + + def test_no_match(self) -> None: + assert regex.replace(_regex("x"), lambda _: "y", "abc") == "abc" + + def test_empty_matches(self) -> None: + assert regex.replace(_regex("x*"), lambda _: "-", "abxd") == "-a-b--d-" + + def test_replace_at_most(self) -> None: + vowel = _regex("[aeiou]") + assert regex.replace_at_most(2, vowel, lambda _: "_", "banana bread") == ( + "b_n_na bread" + ) + assert regex.replace_at_most(99, vowel, lambda _: "_", "banana") == "b_n_n_" + + def test_replace_at_most_zero_or_less(self) -> None: + def fail(_: Match) -> str: + raise AssertionError("the function must not be called") + + assert regex.replace_at_most(0, _regex("a"), fail, "banana") == "banana" + assert regex.replace_at_most(-1, _regex("a"), fail, "banana") == "banana" + + +class TestPythonSyntax: + """The departures from JavaScript that the module docstring gives.""" + + def test_digit_class_is_unicode(self) -> None: + assert regex.contains(_regex(r"\d"), "\N{ARABIC-INDIC DIGIT TWO}") is True + + def test_javascript_named_group_is_not_valid(self) -> None: + assert regex.from_string(r"(?\w+)") == Nothing() + + def test_case_insensitive_is_unicode(self) -> None: + value = with_default( + regex.never, + regex.from_string_with( + Options(case_insensitive=True, multiline=False), "straße" + ), + ) + assert regex.contains(value, "STRAßE") is True From 001dcda225ed41f83017dfabbf43ac38f968d17a Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:16:18 -0500 Subject: [PATCH 4/7] feat(sdk): add Aggregate and Key --- packages/morphir/src/morphir/sdk/aggregate.py | 421 ++++++++++++++ packages/morphir/src/morphir/sdk/key.py | 522 ++++++++++++++++++ tests/unit/sdk/test_aggregate.py | 307 ++++++++++ tests/unit/sdk/test_key.py | 79 +++ 4 files changed, 1329 insertions(+) create mode 100644 packages/morphir/src/morphir/sdk/aggregate.py create mode 100644 packages/morphir/src/morphir/sdk/key.py create mode 100644 tests/unit/sdk/test_aggregate.py create mode 100644 tests/unit/sdk/test_key.py diff --git a/packages/morphir/src/morphir/sdk/aggregate.py b/packages/morphir/src/morphir/sdk/aggregate.py new file mode 100644 index 00000000..0a0031e0 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/aggregate.py @@ -0,0 +1,421 @@ +"""Morphir.SDK.Aggregate: aggregations for large data sets. + +An `Aggregation[A, K]` tells how to aggregate rows of type `A`: the key that +groups the rows, a filter that removes rows before the aggregation, and the +operator (count, sum, average, minimum, maximum or weighted average). Build one +with `count`, `sum_of`, `average_of`, `minimum_of`, `maximum_of` or +`weighted_average_of`, then set the key with `by_key` and the filter with +`with_filter`. All aggregated values are floats. + +There are two ways to use an aggregation: + +* `aggregate_map` to `aggregate_map4` map each row together with the aggregated + values of the group of that row. +* `group_by` and then `aggregate` yield one item for each group. + + >>> from morphir.sdk import aggregate as Aggregate + >>> rows = (("a", 1.0), ("a", 2.0), ("b", 5.0)) + >>> total = Aggregate.by_key(lambda r: r[0], Aggregate.sum_of(lambda r: r[1])) + >>> Aggregate.aggregate_map(total, lambda t, r: (r[0], r[1] / t), rows) + (('a', 0.3333333333333333), ('a', 0.6666666666666666), ('b', 1.0)) + >>> Aggregate.aggregate( + ... lambda key, inputs: (key, inputs(Aggregate.count())), + ... Aggregate.group_by(lambda r: r[0], rows), + ... ) + (('a', 2.0), ('b', 1.0)) + +All functions take their arguments in Elm order, with the data last, and are +not curried. Lists are tuples: the functions accept any `Sequence` and yield a +tuple. + +This module is the runtime API only. The Elm module also has +`constructAggregationCall`, `AggregationCall`, `AggregateValue` and +`ConstructAggregationError`. Those inspect Morphir IR values (`TypedValue`) for +the backends. They are tooling, they are not in the `Morphir.IR.SDK.Aggregate` +specification, and they are not part of this module. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from morphir.sdk import _compare, basics +from morphir.sdk import dict as dict_ +from morphir.sdk.key import Key0, key0 + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from morphir.sdk._compare import Comparable + from morphir.sdk.dict import Dict + +__all__ = [ + "Aggregation", + "Aggregator", + "Avg", + "Count", + "Max", + "Min", + "Operator", + "Sum", + "WAvg", + "aggregate", + "aggregate_map", + "aggregate_map2", + "aggregate_map3", + "aggregate_map4", + "average_of", + "by_key", + "count", + "group_by", + "maximum_of", + "minimum_of", + "sum_of", + "weighted_average_of", + "with_filter", +] + + +# Operators + + +@dataclass(frozen=True, slots=True) +class Count: + """The operator that counts the rows.""" + + +@dataclass(frozen=True, slots=True) +class Sum[A]: + """The operator that adds the values of the rows.""" + + get_value: Callable[[A], float] + + +@dataclass(frozen=True, slots=True) +class Avg[A]: + """The operator that gives the average of the values of the rows.""" + + get_value: Callable[[A], float] + + +@dataclass(frozen=True, slots=True) +class Min[A]: + """The operator that gives the smallest of the values of the rows.""" + + get_value: Callable[[A], float] + + +@dataclass(frozen=True, slots=True) +class Max[A]: + """The operator that gives the largest of the values of the rows.""" + + get_value: Callable[[A], float] + + +@dataclass(frozen=True, slots=True) +class WAvg[A]: + """The operator that gives the weighted average of the values of the rows.""" + + get_weight: Callable[[A], float] + get_value: Callable[[A], float] + + +type Operator[A] = Count | Sum[A] | Avg[A] | Min[A] | Max[A] | WAvg[A] +"""The aggregation operation to apply to the rows of a group.""" + + +@dataclass(frozen=True, slots=True) +class Aggregation[A, K]: + """An aggregation on rows of type `A` with a key of type `K`. + + Attributes: + key: A function that gives the key of a row. Rows with equal keys are + aggregated together. + filter: A test that a row must pass to be part of the aggregation. + operator: The aggregation operation to apply. + """ + + key: Callable[[A], K] + filter: Callable[[A], bool] + operator: Operator[A] + + +type Aggregator[A, K] = Callable[[Aggregation[A, K]], float] +"""A function that applies an aggregation to the rows of one group.""" + + +def _always_true(_: object) -> bool: + return True + + +def _one(_: object) -> float: + return 1.0 + + +def _operator_to_aggregation[A](operator: Operator[A]) -> Aggregation[A, Key0]: + return Aggregation(key=key0, filter=_always_true, operator=operator) + + +def count[A]() -> Aggregation[A, Key0]: # pyright: ignore[reportInvalidTypeVarUse] + """Count the rows in a group. + + In Elm `count` is a value. Here it is a function with no arguments, so that + the type checker can give each use its own row type. + """ + return _operator_to_aggregation(Count()) + + +def sum_of[A](get_value: Callable[[A], float]) -> Aggregation[A, Key0]: + """Add the values that a function gives for each row of a group.""" + return _operator_to_aggregation(Sum(get_value)) + + +def average_of[A](get_value: Callable[[A], float]) -> Aggregation[A, Key0]: + """Give the average of the values that a function gives for each row.""" + return _operator_to_aggregation(Avg(get_value)) + + +def minimum_of[A](get_value: Callable[[A], float]) -> Aggregation[A, Key0]: + """Give the smallest of the values that a function gives for each row.""" + return _operator_to_aggregation(Min(get_value)) + + +def maximum_of[A](get_value: Callable[[A], float]) -> Aggregation[A, Key0]: + """Give the largest of the values that a function gives for each row.""" + return _operator_to_aggregation(Max(get_value)) + + +def weighted_average_of[A]( + get_weight: Callable[[A], float], get_value: Callable[[A], float] +) -> Aggregation[A, Key0]: + """Give the weighted average of the values of the rows of a group. + + Args: + get_weight: A function that gives the weight of a row. + get_value: A function that gives the value of a row. + + Returns: + An aggregation that divides the sum of `weight * value` by the sum of + the weights. When the sum of the weights is zero the division follows + IEEE 754, as in Elm: the result is `NaN` or an infinity. + """ + return _operator_to_aggregation(WAvg(get_weight, get_value)) + + +def by_key[A, K]( + key: Callable[[A], K], aggregation: Aggregation[A, Key0] +) -> Aggregation[A, K]: + """Set the key of an aggregation. The filter and the operator stay.""" + return Aggregation( + key=key, filter=aggregation.filter, operator=aggregation.operator + ) + + +def with_filter[A, K]( + filter: Callable[[A], bool], + aggregation: Aggregation[A, K], +) -> Aggregation[A, K]: + """Set the filter of an aggregation. The key and the operator stay. + + A filter that the aggregation already has is replaced, as in Elm. + """ + return Aggregation( + key=aggregation.key, filter=filter, operator=aggregation.operator + ) + + +# Aggregation of rows + + +def _elm_min(a: float, b: float) -> float: + return a if a < b else b + + +def _elm_max(a: float, b: float) -> float: + return a if a > b else b + + +def _fold[A]( + get_key: Callable[[A], object], + get_value: Callable[[A], float], + combine: Callable[[float, float], float], + rows: Sequence[A], +) -> dict[object, float]: + totals: dict[object, float] = {} + for row in rows: + key = _compare.freeze(get_key(row)) + value = get_value(row) + totals[key] = combine(totals[key], value) if key in totals else value + return totals + + +def _add(a: float, b: float) -> float: + return a + b + + +def _ratio( + numerators: dict[object, float], denominators: dict[object, float] +) -> dict[object, float]: + return { + key: basics.divide(numerator, denominators.get(key, 0.0)) + for key, numerator in numerators.items() + } + + +def _aggregate_help[A]( + get_key: Callable[[A], object], operator: Operator[A], rows: Sequence[A] +) -> dict[object, float]: + match operator: + case Count(): + return _fold(get_key, _one, _add, rows) + case Sum(get_value): + return _fold(get_key, get_value, _add, rows) + case Avg(get_value): + return _ratio( + _fold(get_key, get_value, _add, rows), + _fold(get_key, _one, _add, rows), + ) + case Min(get_value): + return _fold(get_key, get_value, _elm_min, rows) + case Max(get_value): + return _fold(get_key, get_value, _elm_max, rows) + case WAvg(get_weight, get_value): + + def weighted(row: A) -> float: + return get_weight(row) * get_value(row) + + return _ratio( + _fold(get_key, weighted, _add, rows), + _fold(get_key, get_weight, _add, rows), + ) + + +def _lookup[A, K]( + aggregation: Aggregation[A, K], rows: Sequence[A] +) -> Callable[[A], float]: + included = tuple(row for row in rows if aggregation.filter(row)) + totals = _aggregate_help(aggregation.key, aggregation.operator, included) + + def value_for(row: A) -> float: + return totals.get(_compare.freeze(aggregation.key(row)), 0.0) + + return value_for + + +def aggregate_map[A, B, K1]( + agg1: Aggregation[A, K1], f: Callable[[float, A], B], xs: Sequence[A] +) -> tuple[B, ...]: + """Map each row together with one aggregated value. + + The aggregation is computed one time on the rows that pass its filter. Each + row of the list, also one that does not pass the filter, then gets the + aggregated value for its key. + + Args: + agg1: The aggregation. + f: The mapping function. It gets the aggregated value for the key of + the row, then the row. The value is 0 when no row with that key + passed the filter. + xs: The rows. + + Returns: + The mapped rows, in the order of the input. + """ + value1 = _lookup(agg1, xs) + return tuple(f(value1(x), x) for x in xs) + + +def aggregate_map2[A, B, K1, K2]( + agg1: Aggregation[A, K1], + agg2: Aggregation[A, K2], + f: Callable[[float, float, A], B], + xs: Sequence[A], +) -> tuple[B, ...]: + """Map each row together with two aggregated values. See `aggregate_map`.""" + value1 = _lookup(agg1, xs) + value2 = _lookup(agg2, xs) + return tuple(f(value1(x), value2(x), x) for x in xs) + + +def aggregate_map3[A, B, K1, K2, K3]( + agg1: Aggregation[A, K1], + agg2: Aggregation[A, K2], + agg3: Aggregation[A, K3], + f: Callable[[float, float, float, A], B], + xs: Sequence[A], +) -> tuple[B, ...]: + """Map each row together with three aggregated values. See `aggregate_map`.""" + value1 = _lookup(agg1, xs) + value2 = _lookup(agg2, xs) + value3 = _lookup(agg3, xs) + return tuple(f(value1(x), value2(x), value3(x), x) for x in xs) + + +def aggregate_map4[A, B, K1, K2, K3, K4]( + agg1: Aggregation[A, K1], + agg2: Aggregation[A, K2], + agg3: Aggregation[A, K3], + agg4: Aggregation[A, K4], + f: Callable[[float, float, float, float, A], B], + xs: Sequence[A], +) -> tuple[B, ...]: + """Map each row together with four aggregated values. See `aggregate_map`.""" + value1 = _lookup(agg1, xs) + value2 = _lookup(agg2, xs) + value3 = _lookup(agg3, xs) + value4 = _lookup(agg4, xs) + return tuple(f(value1(x), value2(x), value3(x), value4(x), x) for x in xs) + + +# Grouping + + +def group_by[A, K: Comparable]( + get_key: Callable[[A], K], xs: Sequence[A] +) -> Dict[K, tuple[A, ...]]: + """Group the rows of a list into a dictionary. + + Args: + get_key: A function that gives the key of a row. + xs: The rows. + + Returns: + A dictionary from each key to the rows with that key. The rows of a + group keep the order that they have in the list. + + Raises: + TypeError: If the keys are not comparable. + """ + groups: dict[K, list[A]] = {} + for x in xs: + groups.setdefault(_compare.freeze(get_key(x)), []).append(x) + return dict_.from_list([(key, tuple(rows)) for key, rows in groups.items()]) + + +def aggregate[A, B, K: Comparable]( + f: Callable[[K, Aggregator[A, Key0]], B], groups: Dict[K, tuple[A, ...]] +) -> tuple[B, ...]: + """Aggregate a dictionary of groups into a list with one item for each key. + + This function is for use after `group_by`. + + Args: + f: A function that gets a key and an aggregator, and yields the item for + that key. The aggregator is a function that takes an aggregation + with no key (`count()`, `sum_of(...)`, also with `with_filter`) and + yields the aggregated value for the rows of the group. The value is + 0 when no row passes the filter. + groups: The dictionary of groups. + + Returns: + One item for each key, in key order. + """ + + def item(key: K, rows: tuple[A, ...]) -> B: + def aggregator(aggregation: Aggregation[A, Key0]) -> float: + included = tuple(row for row in rows if aggregation.filter(row)) + totals = _aggregate_help(aggregation.key, aggregation.operator, included) + return totals.get(0, 0.0) + + return f(key, aggregator) + + return tuple(item(key, rows) for key, rows in dict_.to_list(groups)) diff --git a/packages/morphir/src/morphir/sdk/key.py b/packages/morphir/src/morphir/sdk/key.py new file mode 100644 index 00000000..3cca7426 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/key.py @@ -0,0 +1,522 @@ +"""Morphir.SDK.Key: helpers for composite keys. + +Elm needs this module because only tuples of comparables are comparable, a tuple +has at most three elements, and the unit value is not comparable. `key2` to +`key16` build a composite key from a row with one getter function for each +element: + + >>> from dataclasses import dataclass + >>> from morphir.sdk import key as Key + >>> @dataclass(frozen=True) + ... class Trade: + ... desk: str + ... product: str + ... quantity: int + >>> Key.key2(lambda t: t.desk, lambda t: t.product, Trade("fx", "spot", 10)) + ('fx', 'spot') + +A composite key is a tuple, so `basics.compare` gives it a structural order and +it works as a `Dict` key or a `Set` member when its elements are comparable. + +Elm nests the tuples of `Key4` and larger (`( k1, k2, ( k3, k4 ) )`), because an +Elm tuple has at most three elements. Python has no such limit, so every key +here is one flat tuple. The order of two keys is the same in the two forms. + +`Key0` is the key with zero elements. It is the integer 0, as in Elm, so that it +stays comparable. `no_key` and `key0` yield it. + +The functions are not curried. To get a key function of one argument, for +example for `aggregate.by_key`, use a `lambda` or `functools.partial`: + + >>> from functools import partial + >>> desk_and_product = partial(Key.key2, lambda t: t.desk, lambda t: t.product) + >>> desk_and_product(Trade("fx", "swap", 5)) + ('fx', 'swap') +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = [ + "Key0", + "Key2", + "Key3", + "Key4", + "Key5", + "Key6", + "Key7", + "Key8", + "Key9", + "Key10", + "Key11", + "Key12", + "Key13", + "Key14", + "Key15", + "Key16", + "key0", + "key2", + "key3", + "key4", + "key5", + "key6", + "key7", + "key8", + "key9", + "key10", + "key11", + "key12", + "key13", + "key14", + "key15", + "key16", + "no_key", +] + +type Key0 = int +"""A key with zero elements. The only value is 0.""" + +type Key2[K1, K2] = tuple[K1, K2] +"""A composite key with 2 elements.""" + +type Key3[K1, K2, K3] = tuple[K1, K2, K3] +"""A composite key with 3 elements.""" + +type Key4[K1, K2, K3, K4] = tuple[K1, K2, K3, K4] +"""A composite key with 4 elements.""" + +type Key5[K1, K2, K3, K4, K5] = tuple[K1, K2, K3, K4, K5] +"""A composite key with 5 elements.""" + +type Key6[K1, K2, K3, K4, K5, K6] = tuple[K1, K2, K3, K4, K5, K6] +"""A composite key with 6 elements.""" + +type Key7[K1, K2, K3, K4, K5, K6, K7] = tuple[K1, K2, K3, K4, K5, K6, K7] +"""A composite key with 7 elements.""" + +type Key8[K1, K2, K3, K4, K5, K6, K7, K8] = tuple[K1, K2, K3, K4, K5, K6, K7, K8] +"""A composite key with 8 elements.""" + +type Key9[K1, K2, K3, K4, K5, K6, K7, K8, K9] = tuple[ + K1, K2, K3, K4, K5, K6, K7, K8, K9 +] +"""A composite key with 9 elements.""" + +type Key10[K1, K2, K3, K4, K5, K6, K7, K8, K9, K10] = tuple[ + K1, K2, K3, K4, K5, K6, K7, K8, K9, K10 +] +"""A composite key with 10 elements.""" + +type Key11[K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11] = tuple[ + K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11 +] +"""A composite key with 11 elements.""" + +type Key12[K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12] = tuple[ + K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12 +] +"""A composite key with 12 elements.""" + +type Key13[K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12, K13] = tuple[ + K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12, K13 +] +"""A composite key with 13 elements.""" + +type Key14[K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12, K13, K14] = tuple[ + K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12, K13, K14 +] +"""A composite key with 14 elements.""" + +type Key15[K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12, K13, K14, K15] = tuple[ + K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12, K13, K14, K15 +] +"""A composite key with 15 elements.""" + +type Key16[K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12, K13, K14, K15, K16] = ( + tuple[K1, K2, K3, K4, K5, K6, K7, K8, K9, K10, K11, K12, K13, K14, K15, K16] +) +"""A composite key with 16 elements.""" + + +def no_key(a: object) -> Key0: + """Create a key with zero elements. The row is ignored; the key is always 0.""" + return 0 + + +def key0(a: object) -> Key0: + """Create a key with zero elements. The row is ignored; the key is always 0.""" + return 0 + + +def key2[A, B1, B2]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + a: A, +) -> Key2[B1, B2]: + """Create a composite key with 2 elements from a row.""" + return (get_key1(a), get_key2(a)) + + +def key3[A, B1, B2, B3]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + a: A, +) -> Key3[B1, B2, B3]: + """Create a composite key with 3 elements from a row.""" + return (get_key1(a), get_key2(a), get_key3(a)) + + +def key4[A, B1, B2, B3, B4]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + a: A, +) -> Key4[B1, B2, B3, B4]: + """Create a composite key with 4 elements from a row.""" + return (get_key1(a), get_key2(a), get_key3(a), get_key4(a)) + + +def key5[A, B1, B2, B3, B4, B5]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + a: A, +) -> Key5[B1, B2, B3, B4, B5]: + """Create a composite key with 5 elements from a row.""" + return (get_key1(a), get_key2(a), get_key3(a), get_key4(a), get_key5(a)) + + +def key6[A, B1, B2, B3, B4, B5, B6]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + a: A, +) -> Key6[B1, B2, B3, B4, B5, B6]: + """Create a composite key with 6 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + ) + + +def key7[A, B1, B2, B3, B4, B5, B6, B7]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + a: A, +) -> Key7[B1, B2, B3, B4, B5, B6, B7]: + """Create a composite key with 7 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + ) + + +def key8[A, B1, B2, B3, B4, B5, B6, B7, B8]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + get_key8: Callable[[A], B8], + a: A, +) -> Key8[B1, B2, B3, B4, B5, B6, B7, B8]: + """Create a composite key with 8 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + get_key8(a), + ) + + +def key9[A, B1, B2, B3, B4, B5, B6, B7, B8, B9]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + get_key8: Callable[[A], B8], + get_key9: Callable[[A], B9], + a: A, +) -> Key9[B1, B2, B3, B4, B5, B6, B7, B8, B9]: + """Create a composite key with 9 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + get_key8(a), + get_key9(a), + ) + + +def key10[A, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + get_key8: Callable[[A], B8], + get_key9: Callable[[A], B9], + get_key10: Callable[[A], B10], + a: A, +) -> Key10[B1, B2, B3, B4, B5, B6, B7, B8, B9, B10]: + """Create a composite key with 10 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + get_key8(a), + get_key9(a), + get_key10(a), + ) + + +def key11[A, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + get_key8: Callable[[A], B8], + get_key9: Callable[[A], B9], + get_key10: Callable[[A], B10], + get_key11: Callable[[A], B11], + a: A, +) -> Key11[B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11]: + """Create a composite key with 11 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + get_key8(a), + get_key9(a), + get_key10(a), + get_key11(a), + ) + + +def key12[A, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + get_key8: Callable[[A], B8], + get_key9: Callable[[A], B9], + get_key10: Callable[[A], B10], + get_key11: Callable[[A], B11], + get_key12: Callable[[A], B12], + a: A, +) -> Key12[B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12]: + """Create a composite key with 12 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + get_key8(a), + get_key9(a), + get_key10(a), + get_key11(a), + get_key12(a), + ) + + +def key13[A, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + get_key8: Callable[[A], B8], + get_key9: Callable[[A], B9], + get_key10: Callable[[A], B10], + get_key11: Callable[[A], B11], + get_key12: Callable[[A], B12], + get_key13: Callable[[A], B13], + a: A, +) -> Key13[B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13]: + """Create a composite key with 13 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + get_key8(a), + get_key9(a), + get_key10(a), + get_key11(a), + get_key12(a), + get_key13(a), + ) + + +def key14[A, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + get_key8: Callable[[A], B8], + get_key9: Callable[[A], B9], + get_key10: Callable[[A], B10], + get_key11: Callable[[A], B11], + get_key12: Callable[[A], B12], + get_key13: Callable[[A], B13], + get_key14: Callable[[A], B14], + a: A, +) -> Key14[B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14]: + """Create a composite key with 14 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + get_key8(a), + get_key9(a), + get_key10(a), + get_key11(a), + get_key12(a), + get_key13(a), + get_key14(a), + ) + + +def key15[A, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14, B15]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + get_key8: Callable[[A], B8], + get_key9: Callable[[A], B9], + get_key10: Callable[[A], B10], + get_key11: Callable[[A], B11], + get_key12: Callable[[A], B12], + get_key13: Callable[[A], B13], + get_key14: Callable[[A], B14], + get_key15: Callable[[A], B15], + a: A, +) -> Key15[B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14, B15]: + """Create a composite key with 15 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + get_key8(a), + get_key9(a), + get_key10(a), + get_key11(a), + get_key12(a), + get_key13(a), + get_key14(a), + get_key15(a), + ) + + +def key16[A, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14, B15, B16]( + get_key1: Callable[[A], B1], + get_key2: Callable[[A], B2], + get_key3: Callable[[A], B3], + get_key4: Callable[[A], B4], + get_key5: Callable[[A], B5], + get_key6: Callable[[A], B6], + get_key7: Callable[[A], B7], + get_key8: Callable[[A], B8], + get_key9: Callable[[A], B9], + get_key10: Callable[[A], B10], + get_key11: Callable[[A], B11], + get_key12: Callable[[A], B12], + get_key13: Callable[[A], B13], + get_key14: Callable[[A], B14], + get_key15: Callable[[A], B15], + get_key16: Callable[[A], B16], + a: A, +) -> Key16[B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14, B15, B16]: + """Create a composite key with 16 elements from a row.""" + return ( + get_key1(a), + get_key2(a), + get_key3(a), + get_key4(a), + get_key5(a), + get_key6(a), + get_key7(a), + get_key8(a), + get_key9(a), + get_key10(a), + get_key11(a), + get_key12(a), + get_key13(a), + get_key14(a), + get_key15(a), + get_key16(a), + ) diff --git a/tests/unit/sdk/test_aggregate.py b/tests/unit/sdk/test_aggregate.py new file mode 100644 index 00000000..4ac73587 --- /dev/null +++ b/tests/unit/sdk/test_aggregate.py @@ -0,0 +1,307 @@ +"""Tests for morphir.sdk.aggregate (Morphir.SDK.Aggregate). + +The `TestAggregateMap` cases are a port of `Morphir.SDK.AggregateTests`. +""" + +import math +from dataclasses import dataclass + +from morphir.sdk import aggregate as agg +from morphir.sdk import dict as dict_ +from morphir.sdk import key +from morphir.sdk.aggregate import Aggregation, Aggregator + + +@dataclass(frozen=True, slots=True) +class _Input: + key1: str + key2: str + value: float + + +_DATA: tuple[_Input, ...] = ( + _Input("k1_1", "k2_1", 1), + _Input("k1_1", "k2_1", 2), + _Input("k1_1", "k2_2", 3), + _Input("k1_1", "k2_2", 4), + _Input("k1_2", "k2_1", 5), + _Input("k1_2", "k2_1", 6), + _Input("k1_2", "k2_2", 7), + _Input("k1_2", "k2_2", 8), +) + + +def _value(a: _Input) -> float: + return a.value + + +def _key1(a: _Input) -> str: + return a.key1 + + +def _key2(a: _Input) -> str: + return a.key2 + + +def _key12(a: _Input) -> tuple[str, str]: + return key.key2(_key1, _key2, a) + + +def _expect(values: tuple[float, ...]) -> tuple[tuple[_Input, float], ...]: + return tuple(zip(_DATA, values, strict=True)) + + +def _pair(total: float, a: _Input) -> tuple[_Input, float]: + return (a, total) + + +def _ratio(total: float, a: _Input) -> tuple[_Input, float]: + return (a, total / a.value) + + +class TestAggregateMap: + def test_aggregate_by_single_key(self) -> None: + result = agg.aggregate_map(agg.by_key(_key1, agg.sum_of(_value)), _ratio, _DATA) + assert result == _expect( + (10 / 1, 10 / 2, 10 / 3, 10 / 4, 26 / 5, 26 / 6, 26 / 7, 26 / 8) + ) + + def test_aggregate_by_composite_key(self) -> None: + result = agg.aggregate_map( + agg.by_key(_key12, agg.sum_of(_value)), _ratio, _DATA + ) + assert result == _expect( + (3 / 1, 3 / 2, 7 / 3, 7 / 4, 11 / 5, 11 / 6, 15 / 7, 15 / 8) + ) + + def test_aggregate_by_no_key_and_filter(self) -> None: + result = agg.aggregate_map( + agg.with_filter(lambda a: a.value > 3, agg.sum_of(_value)), _ratio, _DATA + ) + assert result == _expect(tuple(30 / n for n in range(1, 9))) + + def test_aggregate_2(self) -> None: + result = agg.aggregate_map2( + agg.by_key(_key1, agg.sum_of(_value)), + agg.by_key(_key2, agg.maximum_of(_value)), + lambda total, maximum, a: (a, total * maximum / a.value), + _DATA, + ) + assert result == _expect( + ( + 10 * 6 / 1, + 10 * 6 / 2, + 10 * 8 / 3, + 10 * 8 / 4, + 26 * 6 / 5, + 26 * 6 / 6, + 26 * 8 / 7, + 26 * 8 / 8, + ) + ) + + def test_aggregate_3(self) -> None: + result = agg.aggregate_map3( + agg.by_key(_key1, agg.sum_of(_value)), + agg.by_key(_key2, agg.maximum_of(_value)), + agg.by_key(_key12, agg.minimum_of(_value)), + lambda total, maximum, minimum, a: ( + a, + total * maximum / a.value + minimum, + ), + _DATA, + ) + assert result == _expect( + ( + 10 * 6 / 1 + 1, + 10 * 6 / 2 + 1, + 10 * 8 / 3 + 3, + 10 * 8 / 4 + 3, + 26 * 6 / 5 + 5, + 26 * 6 / 6 + 5, + 26 * 8 / 7 + 7, + 26 * 8 / 8 + 7, + ) + ) + + def test_aggregate_4(self) -> None: + result = agg.aggregate_map4( + agg.by_key(_key1, agg.sum_of(_value)), + agg.by_key(_key2, agg.maximum_of(_value)), + agg.by_key(_key12, agg.minimum_of(_value)), + agg.by_key(_key12, agg.average_of(_value)), + lambda total, maximum, minimum, average, a: ( + a, + total * maximum / a.value + minimum + average, + ), + _DATA, + ) + assert result == _expect( + ( + 10 * 6 / 1 + 1 + 1.5, + 10 * 6 / 2 + 1 + 1.5, + 10 * 8 / 3 + 3 + 3.5, + 10 * 8 / 4 + 3 + 3.5, + 26 * 6 / 5 + 5 + 5.5, + 26 * 6 / 6 + 5 + 5.5, + 26 * 8 / 7 + 7 + 7.5, + 26 * 8 / 8 + 7 + 7.5, + ) + ) + + def test_count_by_single_key(self) -> None: + result = agg.aggregate_map(agg.by_key(_key1, agg.count()), _pair, _DATA) + assert result == _expect((4, 4, 4, 4, 4, 4, 4, 4)) + + def test_sum_by_single_key(self) -> None: + result = agg.aggregate_map(agg.by_key(_key1, agg.sum_of(_value)), _pair, _DATA) + assert result == _expect((10, 10, 10, 10, 26, 26, 26, 26)) + + def test_avg_by_single_key(self) -> None: + result = agg.aggregate_map( + agg.by_key(_key1, agg.average_of(_value)), _pair, _DATA + ) + assert result == _expect((2.5, 2.5, 2.5, 2.5, 6.5, 6.5, 6.5, 6.5)) + + def test_min_by_single_key(self) -> None: + result = agg.aggregate_map( + agg.by_key(_key1, agg.minimum_of(_value)), _pair, _DATA + ) + assert result == _expect((1, 1, 1, 1, 5, 5, 5, 5)) + + def test_max_by_single_key(self) -> None: + result = agg.aggregate_map( + agg.by_key(_key1, agg.maximum_of(_value)), _pair, _DATA + ) + assert result == _expect((4, 4, 4, 4, 8, 8, 8, 8)) + + def test_weighted_average_by_single_key(self) -> None: + result = agg.aggregate_map( + agg.by_key(_key1, agg.weighted_average_of(_value, _value)), _pair, _DATA + ) + assert result == _expect((3, 3, 3, 3, 174 / 26, 174 / 26, 174 / 26, 174 / 26)) + + +class TestAggregateMapEdges: + def test_key_with_no_row_after_the_filter_gives_zero(self) -> None: + result = agg.aggregate_map( + agg.with_filter( + lambda a: a.key1 == "k1_2", agg.by_key(_key1, agg.sum_of(_value)) + ), + _pair, + _DATA, + ) + assert result == _expect((0, 0, 0, 0, 26, 26, 26, 26)) + + def test_empty_list(self) -> None: + assert agg.aggregate_map(agg.count(), _pair, ()) == () + + def test_accepts_a_list_and_yields_a_tuple(self) -> None: + result = agg.aggregate_map(agg.count(), _pair, list(_DATA[:2])) + assert result == ((_DATA[0], 2), (_DATA[1], 2)) + + def test_average_with_zero_weight_is_nan(self) -> None: + result = agg.aggregate_map( + agg.weighted_average_of(lambda _: 0.0, _value), _pair, _DATA[:1] + ) + assert math.isnan(result[0][1]) + + +class TestOperators: + def test_operators_start_with_no_key_and_no_filter(self) -> None: + aggregation = agg.sum_of(_value) + assert aggregation.key(_DATA[0]) == 0 + assert aggregation.filter(_DATA[0]) is True + assert aggregation.operator == agg.Sum(_value) + + def test_operator_cases(self) -> None: + assert agg.count().operator == agg.Count() + assert agg.average_of(_value).operator == agg.Avg(_value) + assert agg.minimum_of(_value).operator == agg.Min(_value) + assert agg.maximum_of(_value).operator == agg.Max(_value) + assert agg.weighted_average_of(_key_weight, _value).operator == agg.WAvg( + _key_weight, _value + ) + + def test_by_key_changes_only_the_key(self) -> None: + source = agg.with_filter(_is_small, agg.sum_of(_value)) + keyed = agg.by_key(_key1, source) + assert keyed == Aggregation(_key1, _is_small, agg.Sum(_value)) + + def test_with_filter_changes_only_the_filter(self) -> None: + source: Aggregation[_Input, str] = agg.by_key(_key1, agg.count()) + assert agg.with_filter(_is_small, source) == Aggregation( + _key1, _is_small, agg.Count() + ) + + +def _key_weight(a: _Input) -> float: + return a.value * 2 + + +def _is_small(a: _Input) -> bool: + return a.value < 7 + + +class TestGroupBy: + def test_group_by(self) -> None: + assert agg.group_by(_key1, _DATA) == dict_.from_list( + (("k1_1", _DATA[:4]), ("k1_2", _DATA[4:])) + ) + + def test_group_by_composite_key(self) -> None: + grouped = agg.group_by(_key12, _DATA) + assert dict_.keys(grouped) == ( + ("k1_1", "k2_1"), + ("k1_1", "k2_2"), + ("k1_2", "k2_1"), + ("k1_2", "k2_2"), + ) + assert dict_.values(grouped)[0] == _DATA[:2] + + def test_group_by_keeps_the_source_order_in_a_group(self) -> None: + grouped = agg.group_by(_key2, _DATA) + assert dict_.to_list(grouped) == ( + ("k2_1", (_DATA[0], _DATA[1], _DATA[4], _DATA[5])), + ("k2_2", (_DATA[2], _DATA[3], _DATA[6], _DATA[7])), + ) + + def test_group_by_empty_list(self) -> None: + assert agg.group_by(_key1, ()) == dict_.empty() + + +@dataclass(frozen=True, slots=True) +class _Summary: + key: str + count: float + sum: float + max: float + min: float + + +def _summarise(group: str, inputs: Aggregator[_Input, int]) -> _Summary: + return _Summary( + key=group, + count=inputs(agg.with_filter(_is_small, agg.count())), + sum=inputs(agg.sum_of(_value)), + max=inputs(agg.maximum_of(_value)), + min=inputs(agg.minimum_of(_value)), + ) + + +class TestAggregate: + def test_aggregate_after_group_by(self) -> None: + assert agg.aggregate(_summarise, agg.group_by(_key1, _DATA)) == ( + _Summary("k1_1", 4, 10, 4, 1), + _Summary("k1_2", 2, 26, 8, 5), + ) + + def test_aggregate_with_all_rows_filtered_out_gives_zero(self) -> None: + def count_none(_: str, inputs: Aggregator[_Input, int]) -> float: + return inputs(agg.with_filter(lambda _: False, agg.count())) + + result = agg.aggregate(count_none, agg.group_by(_key1, _DATA)) + assert result == (0, 0) + + def test_aggregate_empty_dict(self) -> None: + assert agg.aggregate(_summarise, dict_.empty()) == () diff --git a/tests/unit/sdk/test_key.py b/tests/unit/sdk/test_key.py new file mode 100644 index 00000000..0b14918d --- /dev/null +++ b/tests/unit/sdk/test_key.py @@ -0,0 +1,79 @@ +"""Tests for morphir.sdk.key (Morphir.SDK.Key).""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import pytest + +from morphir.sdk import dict as dict_ +from morphir.sdk import key +from morphir.sdk.basics import Order, compare +from morphir.sdk.maybe import Just + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True, slots=True) +class _Entity: + foo: str + bar: int + baz: float + + +_ENTITY = _Entity("a", 1, 2.5) + + +def _getter(index: int) -> Callable[[tuple[int, ...]], int]: + return lambda row: row[index] + + +_ROW = tuple(range(100, 116)) + + +class TestKey0: + def test_no_key_is_zero(self) -> None: + assert key.no_key(_ENTITY) == 0 + assert key.no_key("anything") == 0 + + def test_key0_is_zero(self) -> None: + assert key.key0(_ENTITY) == 0 + + def test_key0_is_comparable(self) -> None: + assert compare(key.key0(1), key.no_key(2)) is Order.EQ + + +class TestCompositeKeys: + def test_key2(self) -> None: + assert key.key2(lambda e: e.foo, lambda e: e.bar, _ENTITY) == ("a", 1) + + def test_key3(self) -> None: + assert key.key3(lambda e: e.bar, lambda e: e.foo, lambda e: e.baz, _ENTITY) == ( + 1, + "a", + 2.5, + ) + + @pytest.mark.parametrize("size", range(2, 17)) + def test_every_size_yields_a_flat_tuple(self, size: int) -> None: + make = getattr(key, f"key{size}") + getters = [_getter(index) for index in range(size)] + assert make(*getters, _ROW) == _ROW[:size] + + @pytest.mark.parametrize("size", range(2, 17)) + def test_keys_are_comparable(self, size: int) -> None: + make = getattr(key, f"key{size}") + getters = [_getter(index) for index in range(size)] + low = make(*getters, _ROW) + high = make(*getters, (*_ROW[: size - 1], 999, *_ROW[size:])) + assert compare(low, high) is Order.LT + assert compare(low, low) is Order.EQ + + def test_keys_work_as_dict_keys(self) -> None: + rows = (_Entity("b", 1, 0.0), _Entity("a", 2, 0.0), _Entity("a", 1, 0.0)) + entries = tuple( + (key.key2(lambda e: e.foo, lambda e: e.bar, row), row) for row in rows + ) + by_key = dict_.from_list(entries) + assert dict_.keys(by_key) == (("a", 1), ("a", 2), ("b", 1)) + assert dict_.get(("a", 2), by_key) == Just(rows[1]) From 29338836695877a793435afef1a0ab5d7e93d800 Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:16:18 -0500 Subject: [PATCH 5/7] feat(sdk): add Rule, StatefulApp and ResultList --- .../morphir/src/morphir/sdk/result_list.py | 169 ++++++++++++++++++ packages/morphir/src/morphir/sdk/rule.py | 103 +++++++++++ .../morphir/src/morphir/sdk/stateful_app.py | 49 +++++ tests/unit/sdk/test_result_list.py | 126 +++++++++++++ tests/unit/sdk/test_rule.py | 89 +++++++++ tests/unit/sdk/test_stateful_app.py | 37 ++++ 6 files changed, 573 insertions(+) create mode 100644 packages/morphir/src/morphir/sdk/result_list.py create mode 100644 packages/morphir/src/morphir/sdk/rule.py create mode 100644 packages/morphir/src/morphir/sdk/stateful_app.py create mode 100644 tests/unit/sdk/test_result_list.py create mode 100644 tests/unit/sdk/test_rule.py create mode 100644 tests/unit/sdk/test_stateful_app.py diff --git a/packages/morphir/src/morphir/sdk/result_list.py b/packages/morphir/src/morphir/sdk/result_list.py new file mode 100644 index 00000000..3936f229 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/result_list.py @@ -0,0 +1,169 @@ +"""Morphir.SDK.ResultList: operations on lists of results. + +A `ResultList[E, A]` is a tuple of `Result[E, A]` values: a list that holds a +mix of failed and successful records. These operations model a processing +pipeline where an error can occur at any step, but must not stop the pipeline. +A record that failed at an earlier step stays in the list as it is, and each +step works on the successful records only. + +All functions take their arguments in Elm order, with the result list last. +They accept any `Sequence` and always yield a tuple. + +`filter` and `map` shadow Python built-ins. Import the module with an alias, +not the names: + + >>> from morphir.sdk import result_list as ResultList + >>> from morphir.sdk.result import Err, Ok + >>> ResultList.keep_all_errors((Ok(1), Err("foo"), Ok(3), Err("bar"))) + Err(error=('foo', 'bar')) +""" + +from typing import TYPE_CHECKING + +from morphir.sdk.result import Err, Ok, Result + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + +__all__ = [ + "ResultList", + "errors", + "filter", + "filter_or_fail", + "from_list", + "keep_all_errors", + "keep_first_error", + "map", + "map_or_fail", + "partition", + "successes", +] + +type ResultList[E, A] = tuple[Result[E, A], ...] +"""A list that holds a mix of failed (`Err`) and successful (`Ok`) records.""" + + +# Create + + +def from_list[E, A](xs: Sequence[A]) -> ResultList[E, A]: # pyright: ignore[reportInvalidTypeVarUse] + """Create a result list from a list. Every element becomes an `Ok`.""" + return tuple(Ok(x) for x in xs) + + +# Process + + +def filter[E, A]( + f: Callable[[A], bool], result_list: Sequence[Result[E, A]] +) -> ResultList[E, A]: + """Keep the successful records that pass the test, and all failed records.""" + return tuple( + result for result in result_list if isinstance(result, Err) or f(result.value) + ) + + +def filter_or_fail[E, A]( + f: Callable[[A], Result[E, bool]], result_list: Sequence[Result[E, A]] +) -> ResultList[E, A]: + """Filter with a test that can fail. + + A successful record stays when the test yields `Ok(True)` and goes when the + test yields `Ok(False)`. When the test yields an `Err`, that error replaces + the record. All records that failed before stay. + + Args: + f: The test. + result_list: The result list. + + Returns: + The filtered result list. + """ + kept: list[Result[E, A]] = [] + for result in result_list: + if isinstance(result, Err): + kept.append(result) + continue + match f(result.value): + case Ok(passed): + if passed: + kept.append(result) + case Err() as failure: + kept.append(failure) + return tuple(kept) + + +def map[E, A, B]( + f: Callable[[A], B], result_list: Sequence[Result[E, A]] +) -> ResultList[E, B]: + """Apply a function to every successful record. Failed records stay.""" + return tuple( + Ok(f(result.value)) if isinstance(result, Ok) else result + for result in result_list + ) + + +def map_or_fail[E, A, B]( + f: Callable[[A], Result[E, B]], result_list: Sequence[Result[E, A]] +) -> ResultList[E, B]: + """Apply a function that can fail to every successful record. + + An `Err` from the function replaces the record. All records that failed + before stay. + """ + return tuple( + f(result.value) if isinstance(result, Ok) else result for result in result_list + ) + + +# Decompose + + +def errors[E, A](result_list: Sequence[Result[E, A]]) -> tuple[E, ...]: + """Extract the errors of all failed records.""" + return tuple(result.error for result in result_list if isinstance(result, Err)) + + +def successes[E, A](result_list: Sequence[Result[E, A]]) -> tuple[A, ...]: + """Extract the values of all successful records.""" + return tuple(result.value for result in result_list if isinstance(result, Ok)) + + +def partition[E, A]( + result_list: Sequence[Result[E, A]], +) -> tuple[tuple[E, ...], tuple[A, ...]]: + """Divide a result list into its errors and its successful values.""" + return (errors(result_list), successes(result_list)) + + +# Map to a single result + + +def keep_all_errors[E, A]( + result_list: Sequence[Result[E, A]], +) -> Result[tuple[E, ...], tuple[A, ...]]: + """Turn a result list into a single result that keeps all errors. + + Returns: + `Ok` with all values when no record failed (also for an empty list), + else `Err` with all errors in list order. + """ + errs = errors(result_list) + if errs: + return Err(errs) + return Ok(successes(result_list)) + + +def keep_first_error[E, A]( + result_list: Sequence[Result[E, A]], +) -> Result[E, tuple[A, ...]]: + """Turn a result list into a single result that keeps only the first error. + + Returns: + `Ok` with all values when no record failed (also for an empty list), + else `Err` with the error of the first failed record. + """ + errs = errors(result_list) + if errs: + return Err(errs[0]) + return Ok(successes(result_list)) diff --git a/packages/morphir/src/morphir/sdk/rule.py b/packages/morphir/src/morphir/sdk/rule.py new file mode 100644 index 00000000..980de53e --- /dev/null +++ b/packages/morphir/src/morphir/sdk/rule.py @@ -0,0 +1,103 @@ +"""Morphir.SDK.Rule: business logic as a sequence of rules. + +A `Rule[A, B]` is a partial function: a function from `A` to `Maybe[B]` that +yields `Just` a result when it applies to the input, and `Nothing` when it does +not. `chain` composes rules into one rule that tries them in sequence. Morphir +supports only sequential evaluation, so the result is always predictable. + +`any`, `is_`, `any_of` and `none_of` are the matchers for the cells of a +decision table: + + >>> from morphir.sdk import rule as Rule + >>> from morphir.sdk.maybe import Just, Maybe, Nothing + >>> def discount(order: tuple[str, int]) -> Maybe[float]: + ... kind, quantity = order + ... if Rule.is_("gold", kind) and Rule.any(quantity): + ... return Just(0.2) + ... if Rule.any_of(("silver", "bronze"), kind) and Rule.none_of((0,), quantity): + ... return Just(0.1) + ... return Nothing() + >>> Rule.chain((discount, lambda order: Just(0.0)))(("bronze", 3)) + Just(value=0.1) + +`any` shadows the Python built-in, and `is` is a Python keyword, so it is `is_`. +Import the module with an alias, not the names. +""" + +from typing import TYPE_CHECKING + +from morphir.sdk import _compare +from morphir.sdk.maybe import Just, Maybe, Nothing + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + +__all__ = [ + "Rule", + "any", + "any_of", + "chain", + "is_", + "none_of", +] + +type Rule[A, B] = Callable[[A], Maybe[B]] +"""A function that yields `Just` a result when it applies, else `Nothing`.""" + + +def chain[A, B](rules: Sequence[Rule[A, B]]) -> Rule[A, B]: + """Chain a list of rules into a single rule. + + The rules are evaluated in the sequence in which they were supplied. The + first rule that yields a `Just` gives the result, and the rules after it do + not run. + + Args: + rules: The rules, in the sequence of evaluation. + + Returns: + A rule that yields the result of the first rule that matches, or + `Nothing` when no rule matches. + """ + fixed = tuple(rules) + + def chained(value: A) -> Maybe[B]: + for rule in fixed: + result = rule(value) + if isinstance(result, Just): + return result + return Nothing() + + return chained + + +def any(value: object) -> bool: + """Return `True` for any input. Use as a wildcard in a decision table.""" + return True + + +def is_[A](ref: A, value: A) -> bool: + """Check that the value is structurally equal to the reference (Elm `is`). + + Raises: + TypeError: If a value is a function or holds a function. + """ + return _compare.equal(ref, value) + + +def any_of[A](ref: Sequence[A], value: A) -> bool: + """Check that the value is in the list of references, by structural equality. + + Raises: + TypeError: If a value is a function or holds a function. + """ + return next((True for item in ref if _compare.equal(value, item)), False) + + +def none_of[A](ref: Sequence[A], value: A) -> bool: + """Check that the value is not in the list of references. + + Raises: + TypeError: If a value is a function or holds a function. + """ + return not any_of(ref, value) diff --git a/packages/morphir/src/morphir/sdk/stateful_app.py b/packages/morphir/src/morphir/sdk/stateful_app.py new file mode 100644 index 00000000..19035a52 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/stateful_app.py @@ -0,0 +1,49 @@ +"""Morphir.SDK.StatefulApp: the model of a stateful application. + +A `StatefulApp` holds one function, the business logic. The function gets the +current state (`Nothing` when there is no state yet) and a command. It yields +the new state (`Nothing` to delete the state) and an event: + + >>> from morphir.sdk.maybe import Just, Maybe, Nothing + >>> from morphir.sdk.stateful_app import StatefulApp + >>> def logic(state: Maybe[int], command: str) -> tuple[Maybe[int], str]: + ... total = (state.value if isinstance(state, Just) else 0) + 1 + ... return (Just(total), f"{command}: {total}") + >>> app: StatefulApp[str, str, int, str] = StatefulApp(logic) + >>> app.logic(Nothing(), "open") + (Just(value=1), 'open: 1') + +The module has a type and no functions, as in Elm. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from morphir.sdk.maybe import ( + Maybe, # noqa: TC001 - the dataclass field annotation needs it at run time +) + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = ["StatefulApp"] + + +@dataclass(frozen=True, slots=True) +class StatefulApp[K, C, S, E]: + """A stateful application (Elm `StatefulApp (Maybe s -> c -> ( Maybe s, e ))`). + + The type parameters fit the application to a use case: + + * `K`: the key that partitions commands, events and state. No field uses + it, as in Elm; it is there for the type checker only. + * `C`: the commands that the application accepts. + * `S`: the state that the application manages. + * `E`: the events that the application publishes. + + Attributes: + logic: The business logic. It gets the current state and a command, and + yields the new state and an event. + """ + + logic: Callable[[Maybe[S], C], tuple[Maybe[S], E]] diff --git a/tests/unit/sdk/test_result_list.py b/tests/unit/sdk/test_result_list.py new file mode 100644 index 00000000..ec77fd59 --- /dev/null +++ b/tests/unit/sdk/test_result_list.py @@ -0,0 +1,126 @@ +"""Tests for morphir.sdk.result_list (Morphir.SDK.ResultList).""" + +from typing import TYPE_CHECKING + +from morphir.sdk import result_list +from morphir.sdk.result import Err, Ok, Result + +if TYPE_CHECKING: + from morphir.sdk.result_list import ResultList + + +def _is_odd(n: int) -> bool: + return n % 2 == 1 + + +def _odd_or_fail(n: int) -> Result[str, bool]: + if n == 0: + return Err("division by zero") + return Ok(_is_odd(n)) + + +def _divide(n: int) -> Result[str, float]: + if n == 0: + return Err("division by zero") + return Ok(100 / n) + + +_ALL_OK: ResultList[str, int] = (Ok(1), Ok(2), Ok(3), Ok(4)) +_MIXED: ResultList[str, int] = (Ok(1), Err("foo"), Ok(3), Err("bar")) +_ALL_ERR: ResultList[str, int] = (Err("foo"), Err("bar")) + + +class TestCreate: + def test_from_list(self) -> None: + assert result_list.from_list((1, 2, 3)) == (Ok(1), Ok(2), Ok(3)) + assert result_list.from_list(()) == () + + def test_from_list_accepts_any_sequence(self) -> None: + assert result_list.from_list([1, 2]) == (Ok(1), Ok(2)) + + +class TestProcess: + def test_filter(self) -> None: + assert result_list.filter(_is_odd, (Ok(1), Ok(2), Ok(3))) == (Ok(1), Ok(3)) + + def test_filter_keeps_earlier_errors(self) -> None: + source: ResultList[str, int] = (Err("foo"), Ok(2), Ok(3)) + assert result_list.filter(_is_odd, source) == (Err("foo"), Ok(3)) + + def test_filter_or_fail(self) -> None: + source: ResultList[str, int] = (Ok(-1), Ok(0), Err("earlier"), Ok(2)) + assert result_list.filter_or_fail(_odd_or_fail, source) == ( + Ok(-1), + Err("division by zero"), + Err("earlier"), + ) + + def test_map(self) -> None: + assert result_list.map(lambda n: n * 2, (Ok(1), Ok(2), Ok(3))) == ( + Ok(2), + Ok(4), + Ok(6), + ) + + def test_map_keeps_earlier_errors(self) -> None: + source: ResultList[str, int] = (Err("foo"), Ok(2), Ok(3)) + assert result_list.map(lambda n: n * 2, source) == (Err("foo"), Ok(4), Ok(6)) + + def test_map_or_fail(self) -> None: + source: ResultList[str, int] = (Ok(-1), Ok(0), Err("earlier")) + assert result_list.map_or_fail(_divide, source) == ( + Ok(-100.0), + Err("division by zero"), + Err("earlier"), + ) + + def test_functions_do_not_run_on_errors(self) -> None: + calls: list[int] = [] + + def spy(n: int) -> int: + calls.append(n) + return n + + result_list.map(spy, _MIXED) + assert calls == [1, 3] + + def test_accepts_a_list_and_yields_a_tuple(self) -> None: + source: list[Result[str, int]] = [Ok(1), Err("foo")] + assert result_list.map(lambda n: n + 1, source) == (Ok(2), Err("foo")) + assert source == [Ok(1), Err("foo")] + + +class TestDecompose: + def test_errors(self) -> None: + assert result_list.errors(_ALL_OK) == () + assert result_list.errors(_MIXED) == ("foo", "bar") + assert result_list.errors(_ALL_ERR) == ("foo", "bar") + + def test_successes(self) -> None: + assert result_list.successes(_ALL_OK) == (1, 2, 3, 4) + assert result_list.successes(_MIXED) == (1, 3) + assert result_list.successes(_ALL_ERR) == () + + def test_partition(self) -> None: + assert result_list.partition(_ALL_OK) == ((), (1, 2, 3, 4)) + assert result_list.partition(_MIXED) == (("foo", "bar"), (1, 3)) + assert result_list.partition(_ALL_ERR) == (("foo", "bar"), ()) + assert result_list.partition(()) == ((), ()) + + +class TestToSingleResult: + def test_keep_all_errors(self) -> None: + assert result_list.keep_all_errors(_ALL_OK) == Ok((1, 2, 3, 4)) + assert result_list.keep_all_errors(_MIXED) == Err(("foo", "bar")) + assert result_list.keep_all_errors(_ALL_ERR) == Err(("foo", "bar")) + + def test_keep_all_errors_of_empty_list(self) -> None: + assert result_list.keep_all_errors(()) == Ok(()) + + def test_keep_first_error(self) -> None: + assert result_list.keep_first_error(_ALL_OK) == Ok((1, 2, 3, 4)) + assert result_list.keep_first_error(_MIXED) == Err("foo") + assert result_list.keep_first_error(_ALL_ERR) == Err("foo") + + def test_keep_first_error_of_empty_list(self) -> None: + assert result_list.keep_first_error(()) == Ok(()) diff --git a/tests/unit/sdk/test_rule.py b/tests/unit/sdk/test_rule.py new file mode 100644 index 00000000..2fb42aa0 --- /dev/null +++ b/tests/unit/sdk/test_rule.py @@ -0,0 +1,89 @@ +"""Tests for morphir.sdk.rule (Morphir.SDK.Rule).""" + +import pytest + +from morphir.sdk import rule +from morphir.sdk.maybe import Just, Maybe, Nothing + + +def _never(_: int) -> Maybe[int]: + return Nothing() + + +def _identity(a: int) -> Maybe[int]: + return Just(a) + + +def _double(a: int) -> Maybe[int]: + return Just(a * 2) + + +class TestChain: + def test_first_match_wins(self) -> None: + my_chain = rule.chain((_never, _identity)) + assert my_chain(42) == Just(42) + + def test_rules_run_in_order(self) -> None: + assert rule.chain((_double, _identity))(21) == Just(42) + assert rule.chain((_identity, _double))(21) == Just(21) + + def test_no_match_is_nothing(self) -> None: + assert rule.chain((_never, _never))(1) == Nothing() + + def test_no_rules_is_nothing(self) -> None: + no_rules: tuple[rule.Rule[int, int], ...] = () + assert rule.chain(no_rules)(1) == Nothing() + + def test_rules_after_the_match_do_not_run(self) -> None: + calls: list[int] = [] + + def spy(a: int) -> Maybe[int]: + calls.append(a) + return Nothing() + + assert rule.chain([_identity, spy])(7) == Just(7) + assert calls == [] + + def test_decision_table(self) -> None: + def table(row: tuple[str, int]) -> Maybe[str]: + kind, size = row + if rule.is_("a", kind) and rule.any(size): + return Just("is a") + if rule.any_of(("b", "c"), kind) and rule.none_of((0,), size): + return Just("b or c, not empty") + return Nothing() + + chained = rule.chain((table, lambda _: Just("other"))) + assert chained(("a", 0)) == Just("is a") + assert chained(("c", 3)) == Just("b or c, not empty") + assert chained(("c", 0)) == Just("other") + + +class TestMatchers: + def test_any_is_always_true(self) -> None: + assert rule.any(1) is True + assert rule.any("x") is True + assert rule.any(Nothing()) is True + + def test_is(self) -> None: + assert rule.is_(1, 1) is True + assert rule.is_(1, 2) is False + + def test_is_is_structural(self) -> None: + assert rule.is_((1, Just("a")), (1, Just("a"))) is True + assert rule.is_((1, Just("a")), (1, Just("b"))) is False + + def test_is_fails_on_functions(self) -> None: + with pytest.raises(TypeError): + rule.is_(_never, _never) + + def test_any_of(self) -> None: + assert rule.any_of((1, 2, 3), 2) is True + assert rule.any_of((1, 2, 3), 4) is False + assert rule.any_of((), 4) is False + assert rule.any_of([(1, "a")], (1, "a")) is True + + def test_none_of(self) -> None: + assert rule.none_of((1, 2, 3), 2) is False + assert rule.none_of((1, 2, 3), 4) is True + assert rule.none_of((), 4) is True diff --git a/tests/unit/sdk/test_stateful_app.py b/tests/unit/sdk/test_stateful_app.py new file mode 100644 index 00000000..bf561a4c --- /dev/null +++ b/tests/unit/sdk/test_stateful_app.py @@ -0,0 +1,37 @@ +"""Tests for morphir.sdk.stateful_app (Morphir.SDK.StatefulApp).""" + +import dataclasses + +import pytest + +from morphir.sdk.maybe import Just, Maybe, Nothing +from morphir.sdk.stateful_app import StatefulApp + + +def _counter(state: Maybe[int], command: str) -> tuple[Maybe[int], str]: + current = state.value if isinstance(state, Just) else 0 + if command == "reset": + return (Nothing(), "was reset") + return (Just(current + 1), f"now {current + 1}") + + +class TestStatefulApp: + def test_holds_the_logic(self) -> None: + app: StatefulApp[str, str, int, str] = StatefulApp(_counter) + assert app.logic(Nothing(), "inc") == (Just(1), "now 1") + assert app.logic(Just(4), "inc") == (Just(5), "now 5") + assert app.logic(Just(4), "reset") == (Nothing(), "was reset") + + def test_matches_as_a_pattern(self) -> None: + app: StatefulApp[str, str, int, str] = StatefulApp(_counter) + match app: + case StatefulApp(logic): + assert logic is _counter + + def test_is_frozen(self) -> None: + app: StatefulApp[str, str, int, str] = StatefulApp(_counter) + with pytest.raises(dataclasses.FrozenInstanceError): + app.logic = _counter # type: ignore[misc] + + def test_equal_when_the_logic_is_the_same_function(self) -> None: + assert StatefulApp(_counter) == StatefulApp(_counter) From bff4c3f8b3623278699f3eab4438a595ad56202a Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:16:18 -0500 Subject: [PATCH 6/7] feat(sdk): expose the new modules and check the full SDK specification Add the ten new modules to morphir.sdk, extend the specification coverage test to all 22 modules of the Morphir.SDK package specification, and document them. --- packages/morphir/README.md | 29 +++- packages/morphir/src/morphir/sdk/__init__.py | 62 ++++++-- tests/unit/sdk/test_spec_coverage.py | 148 ++++++++++++++++++- 3 files changed, 216 insertions(+), 23 deletions(-) diff --git a/packages/morphir/README.md b/packages/morphir/README.md index 973bb7e4..1fa80f02 100644 --- a/packages/morphir/README.md +++ b/packages/morphir/README.md @@ -51,9 +51,32 @@ dependencies and does no IO. | `morphir.sdk.decimal` | `Morphir.SDK.Decimal` | `decimal.Decimal` | | `morphir.sdk.int` | `Morphir.SDK.Int` | `Int8`, `Int16`, `Int32`, `Int64` (`NewType` over `int`) | | `morphir.sdk.number` | `Morphir.SDK.Number` | `Number`, an exact rational | - -Not included yet: `LocalDate`, `LocalTime`, `Instant`, `UUID`, `Regex`, -`Aggregate`, `Rule`, `Key`, `StatefulApp`, `ResultList` and `Json`. +| `morphir.sdk.local_date` | `Morphir.SDK.LocalDate` | `datetime.date`, no time zone | +| `morphir.sdk.local_time` | `Morphir.SDK.LocalTime` | `LocalTime`, milliseconds from the epoch | +| `morphir.sdk.instant` | `Morphir.SDK.Instant` | `datetime.datetime` with a time zone | +| `morphir.sdk.uuid` | `Morphir.SDK.UUID` | `uuid.UUID` | +| `morphir.sdk.regex` | `Morphir.SDK.Regex` | `Regex` over the `re` module | +| `morphir.sdk.aggregate` | `Morphir.SDK.Aggregate` | `Aggregation`, `Operator` | +| `morphir.sdk.rule` | `Morphir.SDK.Rule` | `Callable[[A], Maybe[B]]` | +| `morphir.sdk.key` | `Morphir.SDK.Key` | `int` and flat tuples | +| `morphir.sdk.stateful_app` | `Morphir.SDK.StatefulApp` | `StatefulApp` | +| `morphir.sdk.result_list` | `Morphir.SDK.ResultList` | `tuple[Result[E, A], ...]` | + +This is every module of the `Morphir.SDK` package specification. The `Json` +modules of morphir-elm are not part of that specification and are not included. + +Notes on the modules beyond elm/core: + +- `local_time.LocalTime` is a point in time, not a time of day. In Elm it is an + alias of `Time.Posix`, so it does not go around midnight. +- `regex` patterns use Python `re` syntax, which differs from JavaScript in + places (named groups are `(?P...)`, `\d` and `\w` follow Unicode). The + module docstring lists the differences. +- `aggregate` leaves out the part of the Elm module that inspects Morphir IR + values (`constructAggregationCall`); the specification does not list it. + `aggregate.aggregate` yields its rows in ascending key order. +- `key.Key4` and larger are flat tuples. Elm nests them only because an Elm tuple + holds three elements at most; the order of two keys is the same. ### Conventions diff --git a/packages/morphir/src/morphir/sdk/__init__.py b/packages/morphir/src/morphir/sdk/__init__.py index 54396889..5693cc73 100644 --- a/packages/morphir/src/morphir/sdk/__init__.py +++ b/packages/morphir/src/morphir/sdk/__init__.py @@ -4,22 +4,32 @@ `Morphir.IR.SDK.*` package specification of finos/morphir-elm. The reference behaviour is elm/core 1.0.5 plus the `Morphir.SDK.*` Elm runtime. -======== ====================== ========================================= -Module Elm module Python data -======== ====================== ========================================= -basics Morphir.SDK.Basics `int`, `float`, `bool`, `Order` -char Morphir.SDK.Char `str` with one code point -string Morphir.SDK.String `str` -list Morphir.SDK.List `tuple[A, ...]` -dict Morphir.SDK.Dict `Dict[K, V]`, sorted by key -set Morphir.SDK.Set `Set[A]`, sorted -maybe Morphir.SDK.Maybe `Just[A] | Nothing` -result Morphir.SDK.Result `Ok[A] | Err[E]` -tuple Morphir.SDK.Tuple `tuple[A, B]` -decimal Morphir.SDK.Decimal `decimal.Decimal` -int Morphir.SDK.Int `Int8`, `Int16`, `Int32`, `Int64` -number Morphir.SDK.Number `Number`, an exact rational -======== ====================== ========================================= +============ ======================= ======================================== +Module Elm module Python data +============ ======================= ======================================== +basics Morphir.SDK.Basics `int`, `float`, `bool`, `Order` +char Morphir.SDK.Char `str` with one code point +string Morphir.SDK.String `str` +list Morphir.SDK.List `tuple[A, ...]` +dict Morphir.SDK.Dict `Dict[K, V]`, sorted by key +set Morphir.SDK.Set `Set[A]`, sorted +maybe Morphir.SDK.Maybe `Just[A] | Nothing` +result Morphir.SDK.Result `Ok[A] | Err[E]` +tuple Morphir.SDK.Tuple `tuple[A, B]` +decimal Morphir.SDK.Decimal `decimal.Decimal` +int Morphir.SDK.Int `Int8`, `Int16`, `Int32`, `Int64` +number Morphir.SDK.Number `Number`, an exact rational +local_date Morphir.SDK.LocalDate `datetime.date` +local_time Morphir.SDK.LocalTime `LocalTime`, milliseconds from the epoch +instant Morphir.SDK.Instant `datetime.datetime` with a time zone +uuid Morphir.SDK.UUID `uuid.UUID` +regex Morphir.SDK.Regex `Regex` over the `re` module +aggregate Morphir.SDK.Aggregate `Aggregation`, `Operator` +rule Morphir.SDK.Rule `Callable[[A], Maybe[B]]` +key Morphir.SDK.Key `int` and flat tuples +stateful_app Morphir.SDK.StatefulApp `StatefulApp` +result_list Morphir.SDK.ResultList `tuple[Result[E, A], ...]` +============ ======================= ======================================== Conventions: @@ -50,31 +60,51 @@ """ from morphir.sdk import ( + aggregate, basics, char, decimal, dict, + instant, int, + key, list, + local_date, + local_time, maybe, number, + regex, result, + result_list, + rule, set, + stateful_app, string, tuple, + uuid, ) __all__ = [ + "aggregate", "basics", "char", "decimal", "dict", + "instant", "int", + "key", "list", + "local_date", + "local_time", "maybe", "number", + "regex", "result", + "result_list", + "rule", "set", + "stateful_app", "string", "tuple", + "uuid", ] diff --git a/tests/unit/sdk/test_spec_coverage.py b/tests/unit/sdk/test_spec_coverage.py index c7a8a268..4767722e 100644 --- a/tests/unit/sdk/test_spec_coverage.py +++ b/tests/unit/sdk/test_spec_coverage.py @@ -3,8 +3,9 @@ The Elm names below come from the `vSpec` entries of the matching `Morphir.IR.SDK.` file in finos/morphir-elm. Each one maps to the Python name that `morphir.sdk` must expose. The rule is snake_case, with a trailing -underscore where the result is a Python keyword (`not_`, `and_`, `or_`). The one -exception is `isNaN`, which maps to `is_nan`. +underscore where the result is a Python keyword (`not_`, `and_`, `or_`, `is_`). +The exceptions are names with an initialism: `isNaN` maps to `is_nan`, +`toISOString` to `to_iso_string` and `fromISO` to `from_iso`. """ import importlib @@ -316,6 +317,121 @@ "zero": "zero", "one": "one", }, + "local_date": { + "fromCalendarDate": "from_calendar_date", + "toISOString": "to_iso_string", + "fromISO": "from_iso", + "fromOrdinalDate": "from_ordinal_date", + "fromParts": "from_parts", + "day": "day", + "dayOfWeek": "day_of_week", + "diffInDays": "diff_in_days", + "diffInWeeks": "diff_in_weeks", + "diffInMonths": "diff_in_months", + "diffInYears": "diff_in_years", + "addDays": "add_days", + "addWeeks": "add_weeks", + "addMonths": "add_months", + "addYears": "add_years", + "isWeekend": "is_weekend", + "isWeekday": "is_weekday", + "month": "month", + "monthNumber": "month_number", + "monthToInt": "month_to_int", + "year": "year", + }, + "local_time": { + "fromISO": "from_iso", + "fromMilliseconds": "from_milliseconds", + "diffInSeconds": "diff_in_seconds", + "diffInMinutes": "diff_in_minutes", + "diffInHours": "diff_in_hours", + "addSeconds": "add_seconds", + "addMinutes": "add_minutes", + "addHours": "add_hours", + }, + "instant": {}, + "uuid": { + "parse": "parse", + "fromString": "from_string", + "forName": "for_name", + "toString": "to_string", + "version": "version", + "compare": "compare", + "nilString": "nil_string", + "isNilString": "is_nil_string", + "dnsNamespace": "dns_namespace", + "urlNamespace": "url_namespace", + "oidNamespace": "oid_namespace", + "x500Namespace": "x500_namespace", + }, + "regex": { + "fromString": "from_string", + "fromStringWith": "from_string_with", + "never": "never", + "contains": "contains", + "split": "split", + "find": "find", + "replace": "replace", + "splitAtMost": "split_at_most", + "findAtMost": "find_at_most", + "replaceAtMost": "replace_at_most", + }, + "aggregate": { + "count": "count", + "sumOf": "sum_of", + "averageOf": "average_of", + "minimumOf": "minimum_of", + "maximumOf": "maximum_of", + "weightedAverageOf": "weighted_average_of", + "byKey": "by_key", + "withFilter": "with_filter", + "aggregateMap": "aggregate_map", + "aggregateMap2": "aggregate_map2", + "aggregateMap3": "aggregate_map3", + "aggregateMap4": "aggregate_map4", + "groupBy": "group_by", + "aggregate": "aggregate", + }, + "rule": { + "chain": "chain", + "any": "any", + "is": "is_", + "anyOf": "any_of", + "noneOf": "none_of", + }, + "key": { + "noKey": "no_key", + "key0": "key0", + "key2": "key2", + "key3": "key3", + "key4": "key4", + "key5": "key5", + "key6": "key6", + "key7": "key7", + "key8": "key8", + "key9": "key9", + "key10": "key10", + "key11": "key11", + "key12": "key12", + "key13": "key13", + "key14": "key14", + "key15": "key15", + "key16": "key16", + }, + "stateful_app": {}, + "result_list": { + "fromList": "from_list", + "filter": "filter", + "filterOrFail": "filter_or_fail", + "map": "map", + "mapOrFail": "map_or_fail", + "errors": "errors", + "successes": "successes", + "partition": "partition", + "keepAllErrors": "keep_all_errors", + "keepFirstError": "keep_first_error", + }, } # Values that Elm defines as constants, not functions. @@ -323,10 +439,24 @@ "basics": frozenset({"e", "pi"}), "decimal": frozenset({"zero", "one", "minus_one"}), "number": frozenset({"zero", "one"}), + "uuid": frozenset( + { + "nil_string", + "dns_namespace", + "url_namespace", + "oid_namespace", + "x500_namespace", + } + ), + "regex": frozenset({"never"}), } # Elm names that do not follow the snake_case rule. -EXCEPTIONS: dict[str, str] = {"isNaN": "is_nan"} +EXCEPTIONS: dict[str, str] = { + "isNaN": "is_nan", + "toISOString": "to_iso_string", + "fromISO": "from_iso", +} _CASES = [ (module, elm_name, python_name) @@ -343,7 +473,7 @@ def _to_snake_case(elm_name: str) -> str: class TestSpecCoverage: - def test_all_core_modules_are_listed(self) -> None: + def test_all_modules_are_listed(self) -> None: assert sorted(SPEC) == sorted(morphir.sdk.__all__) def test_spec_size(self) -> None: @@ -360,6 +490,16 @@ def test_spec_size(self) -> None: "decimal": 34, "int": 8, "number": 21, + "local_date": 21, + "local_time": 8, + "instant": 0, + "uuid": 12, + "regex": 10, + "aggregate": 14, + "rule": 5, + "key": 17, + "stateful_app": 0, + "result_list": 10, } @pytest.mark.parametrize(("module", "elm_name", "python_name"), _CASES) From ca825320bfdd809b2cb56d8802eca3eee48eb04e Mon Sep 17 00:00:00 2001 From: Damian Reeves <957246+DamianReeves@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:22:44 -0500 Subject: [PATCH 7/7] fix(sdk): make Instant a class of epoch milliseconds Instant was an alias of datetime.datetime, so a naive datetime passed every annotation and failed later on comparison or conversion. Instant is now a frozen dataclass that holds milliseconds since the epoch, as Elm's Time.Posix and LocalTime do, so a value that is not a point on the timeline cannot be made. from_datetime rejects a naive datetime; to_datetime yields UTC. --- packages/morphir/README.md | 5 +- packages/morphir/src/morphir/sdk/__init__.py | 2 +- packages/morphir/src/morphir/sdk/instant.py | 112 +++++++++++------ tests/unit/sdk/test_instant.py | 125 ++++++++++++------- 4 files changed, 164 insertions(+), 80 deletions(-) diff --git a/packages/morphir/README.md b/packages/morphir/README.md index 1fa80f02..9edc29e0 100644 --- a/packages/morphir/README.md +++ b/packages/morphir/README.md @@ -53,7 +53,7 @@ dependencies and does no IO. | `morphir.sdk.number` | `Morphir.SDK.Number` | `Number`, an exact rational | | `morphir.sdk.local_date` | `Morphir.SDK.LocalDate` | `datetime.date`, no time zone | | `morphir.sdk.local_time` | `Morphir.SDK.LocalTime` | `LocalTime`, milliseconds from the epoch | -| `morphir.sdk.instant` | `Morphir.SDK.Instant` | `datetime.datetime` with a time zone | +| `morphir.sdk.instant` | `Morphir.SDK.Instant` | `Instant`, milliseconds from the epoch | | `morphir.sdk.uuid` | `Morphir.SDK.UUID` | `uuid.UUID` | | `morphir.sdk.regex` | `Morphir.SDK.Regex` | `Regex` over the `re` module | | `morphir.sdk.aggregate` | `Morphir.SDK.Aggregate` | `Aggregation`, `Operator` | @@ -67,6 +67,9 @@ modules of morphir-elm are not part of that specification and are not included. Notes on the modules beyond elm/core: +- `instant.Instant` is its own class over a number of milliseconds, not an alias + of `datetime.datetime`, so a naive `datetime` cannot pass as an instant. Use + `instant.from_datetime` and `instant.to_datetime` to convert. - `local_time.LocalTime` is a point in time, not a time of day. In Elm it is an alias of `Time.Posix`, so it does not go around midnight. - `regex` patterns use Python `re` syntax, which differs from JavaScript in diff --git a/packages/morphir/src/morphir/sdk/__init__.py b/packages/morphir/src/morphir/sdk/__init__.py index 5693cc73..92651eb9 100644 --- a/packages/morphir/src/morphir/sdk/__init__.py +++ b/packages/morphir/src/morphir/sdk/__init__.py @@ -21,7 +21,7 @@ number Morphir.SDK.Number `Number`, an exact rational local_date Morphir.SDK.LocalDate `datetime.date` local_time Morphir.SDK.LocalTime `LocalTime`, milliseconds from the epoch -instant Morphir.SDK.Instant `datetime.datetime` with a time zone +instant Morphir.SDK.Instant `Instant`, milliseconds from the epoch uuid Morphir.SDK.UUID `uuid.UUID` regex Morphir.SDK.Regex `Regex` over the `re` module aggregate Morphir.SDK.Aggregate `Aggregation`, `Operator` diff --git a/packages/morphir/src/morphir/sdk/instant.py b/packages/morphir/src/morphir/sdk/instant.py index 7d0491b9..977bce1c 100644 --- a/packages/morphir/src/morphir/sdk/instant.py +++ b/packages/morphir/src/morphir/sdk/instant.py @@ -1,76 +1,118 @@ """Morphir.SDK.Instant: a point on the timeline. -An `Instant` is the standard library `datetime.datetime` with a time zone, and -the recommended time zone is `datetime.UTC`. Two aware `datetime` values are -equal when they are the same point on the timeline, whatever their time zones. -A naive `datetime` (one with no `tzinfo`) is not an `Instant`, because it does -not identify a point on the timeline. +An `Instant` holds a whole number of milliseconds since 1970-01-01T00:00:00Z. +This is the form of the Elm runtime, where an `Instant` is a `Time.Posix`, and of +`morphir.sdk.local_time.LocalTime`. The number has no limit. -In the Elm runtime an `Instant` is a `Time.Posix`, a whole number of -milliseconds since 1970-01-01T00:00:00Z. The IR specification has the type and -no functions. `from_milliseconds_since_epoch` and `to_milliseconds_since_epoch` -are additions to the specification that convert between the two forms: +The type is its own class, not an alias of `datetime.datetime`. A `datetime` can +be naive (it can have no time zone), and a naive `datetime` does not identify a +point on the timeline. With a class that holds only the number, a value that is +not a point on the timeline cannot be made. + +The IR specification has the type and no functions. All the functions here are +additions to the specification: >>> from morphir.sdk import instant as Instant - >>> Instant.from_milliseconds_since_epoch(1643374590000).isoformat() + >>> moment = Instant.from_milliseconds_since_epoch(1643374590000) + >>> Instant.to_datetime(moment).isoformat() '2022-01-28T12:56:30+00:00' >>> Instant.to_milliseconds_since_epoch( - ... datetime.datetime(2022, 1, 28, 12, 56, 30, tzinfo=datetime.UTC) + ... Instant.from_datetime( + ... datetime.datetime(2022, 1, 28, 12, 56, 30, tzinfo=datetime.UTC) + ... ) ... ) 1643374590000 - -Departure from the Elm runtime: a `datetime` holds the years 1 to 9999 only, with -a precision of one microsecond. """ import datetime +from dataclasses import dataclass + +from morphir.sdk._compare import Order __all__ = [ "Instant", + "compare", + "from_datetime", "from_milliseconds_since_epoch", + "to_datetime", "to_milliseconds_since_epoch", ] -Instant = datetime.datetime -"""The standard library datetime type, used with a time zone.""" - _EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC) _MILLISECOND = datetime.timedelta(milliseconds=1) +@dataclass(frozen=True, slots=True, order=True) +class Instant: + """A point on the timeline, as milliseconds since 1970-01-01T00:00:00Z. + + Raises: + TypeError: If `milliseconds` is not an `int`. A `bool`, a `float` and a + `datetime` are rejected. + """ + + milliseconds: int + + def __post_init__(self) -> None: + value: object = self.milliseconds + if not isinstance(value, int) or isinstance(value, bool): + raise TypeError( + f"Instant: milliseconds must be an int, not {type(value).__name__}; " + f"use from_datetime to convert a datetime" + ) + + def from_milliseconds_since_epoch(millis: int) -> Instant: """Make an instant from a number of milliseconds since the epoch. - This function is an addition to the specification. - Args: millis: The number of milliseconds since 1970-01-01T00:00:00Z. It can be negative. - Returns: - The instant, in UTC. - Raises: - OverflowError: If the instant is not in the years 1 to 9999. + TypeError: If `millis` is not an `int`. """ - return _EPOCH + datetime.timedelta(milliseconds=millis) + return Instant(millis) def to_milliseconds_since_epoch(instant: Instant) -> int: - """Return the number of milliseconds from the epoch to an instant. + """Return the number of milliseconds from the epoch to an instant.""" + return instant.milliseconds - This function is an addition to the specification. - Args: - instant: The instant. It must have a time zone. +def compare(a: Instant, b: Instant) -> Order: + """Compare two instants. The earlier instant is less.""" + if a.milliseconds < b.milliseconds: + return Order.LT + if a.milliseconds > b.milliseconds: + return Order.GT + return Order.EQ + + +def from_datetime(moment: datetime.datetime) -> Instant: + """Make an instant from a `datetime` that has a time zone. + + A part below one millisecond is rounded down. + + Raises: + ValueError: If the `datetime` has no time zone. A naive `datetime` does + not identify a point on the timeline. + """ + if moment.utcoffset() is None: + raise ValueError("from_datetime: the datetime has no time zone") + return Instant((moment - _EPOCH) // _MILLISECOND) + - Returns: - The number of milliseconds since 1970-01-01T00:00:00Z. A part below one - millisecond is rounded down. +def to_datetime(instant: Instant) -> datetime.datetime: + """Return the instant as a `datetime` in UTC. Raises: - ValueError: If the `datetime` has no time zone. + OverflowError: If the instant is not in the years 1 to 9999, which is + the range of a `datetime`. """ - if instant.utcoffset() is None: - raise ValueError("to_milliseconds_since_epoch: the datetime has no time zone") - return (instant - _EPOCH) // _MILLISECOND + try: + return _EPOCH + datetime.timedelta(milliseconds=instant.milliseconds) + except OverflowError: + raise + except (ValueError, OSError) as error: + raise OverflowError(str(error)) from error diff --git a/tests/unit/sdk/test_instant.py b/tests/unit/sdk/test_instant.py index 15cd9815..9abc2399 100644 --- a/tests/unit/sdk/test_instant.py +++ b/tests/unit/sdk/test_instant.py @@ -1,66 +1,105 @@ """Tests for morphir.sdk.instant (Morphir.SDK.Instant).""" +import dataclasses import datetime import pytest -from morphir.sdk import instant +from morphir.sdk import basics, instant +from morphir.sdk._compare import Order from morphir.sdk.instant import Instant class TestType: - def test_instant_is_the_standard_library_datetime(self) -> None: - assert Instant is datetime.datetime - - -class TestFromMillisecondsSinceEpoch: - def test_epoch(self) -> None: - assert instant.from_milliseconds_since_epoch(0) == datetime.datetime( - 1970, 1, 1, tzinfo=datetime.UTC - ) - - def test_result_is_in_utc(self) -> None: - value = instant.from_milliseconds_since_epoch(1643374590000) - assert value.tzinfo is datetime.UTC - assert value == datetime.datetime(2022, 1, 28, 12, 56, 30, tzinfo=datetime.UTC) - - def test_milliseconds_are_exact(self) -> None: + def test_instant_holds_milliseconds_since_the_epoch(self) -> None: + assert Instant(1643374590000).milliseconds == 1643374590000 + + def test_instant_is_immutable(self) -> None: + value = Instant(0) + with pytest.raises(dataclasses.FrozenInstanceError): + value.milliseconds = 1 # type: ignore[misc] + + def test_a_naive_datetime_is_not_an_instant(self) -> None: + with pytest.raises(TypeError): + Instant(datetime.datetime(2026, 9, 18)) # type: ignore[arg-type] + + def test_a_float_or_a_bool_is_not_a_number_of_milliseconds(self) -> None: + with pytest.raises(TypeError): + Instant(1.5) # type: ignore[arg-type] + with pytest.raises(TypeError): + Instant(True) + + def test_equality_and_hash_are_by_value(self) -> None: + assert Instant(5) == Instant(5) + assert Instant(5) != Instant(6) + assert hash(Instant(5)) == hash(Instant(5)) + assert basics.equal(Instant(5), Instant(5)) + + def test_instants_are_ordered(self) -> None: + assert Instant(1) < Instant(2) + assert sorted([Instant(3), Instant(-1), Instant(2)]) == [ + Instant(-1), + Instant(2), + Instant(3), + ] + + def test_there_is_no_limit(self) -> None: + far = Instant(10**30) + assert instant.to_milliseconds_since_epoch(far) == 10**30 + + +class TestMillisecondsSinceEpoch: + def test_from_and_to(self) -> None: value = instant.from_milliseconds_since_epoch(1643374590123) - assert value.microsecond == 123000 + assert value == Instant(1643374590123) + assert instant.to_milliseconds_since_epoch(value) == 1643374590123 def test_negative(self) -> None: - assert instant.from_milliseconds_since_epoch(-1) == datetime.datetime( - 1969, 12, 31, 23, 59, 59, 999000, tzinfo=datetime.UTC - ) + assert instant.from_milliseconds_since_epoch(-1) == Instant(-1) - def test_out_of_range_raises(self) -> None: - with pytest.raises(OverflowError): - instant.from_milliseconds_since_epoch(10**18) +class TestCompare: + def test_compare(self) -> None: + assert instant.compare(Instant(1), Instant(2)) is Order.LT + assert instant.compare(Instant(2), Instant(2)) is Order.EQ + assert instant.compare(Instant(3), Instant(2)) is Order.GT -class TestToMillisecondsSinceEpoch: - def test_round_trip(self) -> None: - for millis in (0, 1, -1, 1643374590123, -62135596800000, 253402300799999): - assert ( - instant.to_milliseconds_since_epoch( - instant.from_milliseconds_since_epoch(millis) - ) - == millis - ) - def test_other_time_zone(self) -> None: - zone = datetime.timezone(datetime.timedelta(hours=1)) - value = datetime.datetime(2022, 1, 28, 13, 56, 30, tzinfo=zone) - assert instant.to_milliseconds_since_epoch(value) == 1643374590000 +class TestDatetimeConversion: + def test_from_datetime_in_utc(self) -> None: + moment = datetime.datetime(2022, 1, 28, 12, 56, 30, tzinfo=datetime.UTC) + assert instant.from_datetime(moment) == Instant(1643374590000) - def test_part_below_a_millisecond_rounds_down(self) -> None: + def test_from_datetime_in_another_time_zone(self) -> None: + zone = datetime.timezone(datetime.timedelta(hours=-5)) + moment = datetime.datetime(2022, 1, 28, 7, 56, 30, tzinfo=zone) + assert instant.from_datetime(moment) == Instant(1643374590000) + + def test_from_datetime_rounds_a_part_below_a_millisecond_down(self) -> None: after = datetime.datetime(1970, 1, 1, 0, 0, 0, 1999, tzinfo=datetime.UTC) - assert instant.to_milliseconds_since_epoch(after) == 1 before = datetime.datetime( - 1969, 12, 31, 23, 59, 59, 999999, tzinfo=datetime.UTC + 1969, 12, 31, 23, 59, 59, 999001, tzinfo=datetime.UTC ) - assert instant.to_milliseconds_since_epoch(before) == -1 + assert instant.from_datetime(after) == Instant(1) + assert instant.from_datetime(before) == Instant(-1) - def test_naive_datetime_raises(self) -> None: + def test_from_datetime_rejects_a_naive_datetime(self) -> None: with pytest.raises(ValueError, match="time zone"): - instant.to_milliseconds_since_epoch(datetime.datetime(2022, 1, 28)) + instant.from_datetime(datetime.datetime(2022, 1, 28)) + + def test_to_datetime_is_in_utc(self) -> None: + moment = instant.to_datetime(Instant(1643374590123)) + assert moment.tzinfo is datetime.UTC + assert moment == datetime.datetime( + 2022, 1, 28, 12, 56, 30, 123000, tzinfo=datetime.UTC + ) + + def test_round_trip(self) -> None: + for millis in (0, -1, 1, 1643374590123, -62135596800000, 253402300799999): + assert instant.from_datetime(instant.to_datetime(Instant(millis))) == ( + Instant(millis) + ) + + def test_to_datetime_out_of_range_raises(self) -> None: + with pytest.raises(OverflowError): + instant.to_datetime(Instant(10**18))