diff --git a/packages/morphir/README.md b/packages/morphir/README.md index 973bb7e4..9edc29e0 100644 --- a/packages/morphir/README.md +++ b/packages/morphir/README.md @@ -51,9 +51,35 @@ 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` | `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` | +| `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: + +- `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 + 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..92651eb9 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 `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` +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/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/instant.py b/packages/morphir/src/morphir/sdk/instant.py new file mode 100644 index 00000000..977bce1c --- /dev/null +++ b/packages/morphir/src/morphir/sdk/instant.py @@ -0,0 +1,118 @@ +"""Morphir.SDK.Instant: 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. + +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 + >>> moment = Instant.from_milliseconds_since_epoch(1643374590000) + >>> Instant.to_datetime(moment).isoformat() + '2022-01-28T12:56:30+00:00' + >>> Instant.to_milliseconds_since_epoch( + ... Instant.from_datetime( + ... datetime.datetime(2022, 1, 28, 12, 56, 30, tzinfo=datetime.UTC) + ... ) + ... ) + 1643374590000 +""" + +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", +] + +_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. + + Args: + millis: The number of milliseconds since 1970-01-01T00:00:00Z. It can be + negative. + + Raises: + TypeError: If `millis` is not an `int`. + """ + return Instant(millis) + + +def to_milliseconds_since_epoch(instant: Instant) -> int: + """Return the number of milliseconds from the epoch to an instant.""" + return instant.milliseconds + + +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) + + +def to_datetime(instant: Instant) -> datetime.datetime: + """Return the instant as a `datetime` in UTC. + + Raises: + OverflowError: If the instant is not in the years 1 to 9999, which is + the range of a `datetime`. + """ + 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/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/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/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/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/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_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_instant.py b/tests/unit/sdk/test_instant.py new file mode 100644 index 00000000..9abc2399 --- /dev/null +++ b/tests/unit/sdk/test_instant.py @@ -0,0 +1,105 @@ +"""Tests for morphir.sdk.instant (Morphir.SDK.Instant).""" + +import dataclasses +import datetime + +import pytest + +from morphir.sdk import basics, instant +from morphir.sdk._compare import Order +from morphir.sdk.instant import Instant + + +class TestType: + 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 == Instant(1643374590123) + assert instant.to_milliseconds_since_epoch(value) == 1643374590123 + + def test_negative(self) -> None: + assert instant.from_milliseconds_since_epoch(-1) == Instant(-1) + + +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 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_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) + before = datetime.datetime( + 1969, 12, 31, 23, 59, 59, 999001, tzinfo=datetime.UTC + ) + assert instant.from_datetime(after) == Instant(1) + assert instant.from_datetime(before) == Instant(-1) + + def test_from_datetime_rejects_a_naive_datetime(self) -> None: + with pytest.raises(ValueError, match="time zone"): + 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)) 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]) 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 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 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_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) 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) 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