diff --git a/AGENTS.md b/AGENTS.md index b7b0e436..aa6f7b13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ morphir-python/ - Morphir IR (Intermediate Representation) models - Type definitions and type algebra - Pure functional primitives +- Morphir SDK runtime (`morphir.sdk`): Basics, Char, String, List, Dict, Set, Maybe, Result, Tuple, Decimal, Int, Number - Zero CLI or IO dependencies - Should be usable as a standalone library diff --git a/README.md b/README.md index d7b4eace..a8fc2b29 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This monorepo contains two packages: | Package | Description | PyPI | |---------|-------------|------| -| `morphir` | Core library - IR models, types, and functional domain modeling primitives | [![PyPI](https://img.shields.io/pypi/v/morphir)](https://pypi.org/project/morphir/) | +| `morphir` | Core library - IR models, the Morphir SDK runtime (`morphir.sdk`), types, and functional domain modeling primitives | [![PyPI](https://img.shields.io/pypi/v/morphir)](https://pypi.org/project/morphir/) | | `morphir-tools` | CLI tools and extensions for working with Morphir | [![PyPI](https://img.shields.io/pypi/v/morphir-tools)](https://pypi.org/project/morphir-tools/) | ## Installation @@ -54,6 +54,41 @@ from morphir.ir import Type, Value # Example usage will be added as the library develops ``` +## Morphir SDK + +`morphir.sdk` is the Python runtime for the Morphir SDK, the standard library that +Morphir models compile against. It covers the core modules of the +`Morphir.IR.SDK` specification: `basics`, `char`, `string`, `list`, `dict`, `set`, +`maybe`, `result`, `tuple`, `decimal`, `int` and `number`. The behaviour follows +elm/core 1.0.5 and the `Morphir.SDK` Elm runtime. + +```python +from morphir.sdk import basics +from morphir.sdk import decimal as Decimal +from morphir.sdk import dict as Dict +from morphir.sdk import list as List +from morphir.sdk import maybe as Maybe +from morphir.sdk.maybe import Just, Nothing + +# Elm argument order, data last, not curried +List.map(lambda n: n * 2, (1, 2, 3)) # (2, 4, 6) +Maybe.with_default(0, List.head(())) # 0 + +# Immutable Dict, sorted by structural comparable keys +prices = Dict.from_list(((("EUR", 2), 1.5), (("EUR", 1), 1.2))) +Dict.keys(prices) # (("EUR", 1), ("EUR", 2)) +Dict.get(("EUR", 1), prices) # Just(value=1.2) + +# Elm number rules +basics.round(2.5) # 3, not Python's 2 +basics.integer_divide(-7, 2) # -3 +Decimal.to_string(Decimal.add(Decimal.tenth(1), Decimal.tenth(2))) # "0.3" +``` + +Names are the Elm names in snake_case (`withDefault` is `with_default`). See the +[`morphir` package README](packages/morphir/README.md#morphir-sdk) for the +conventions and the list of departures from Elm. + ## Requirements - Python 3.14+ @@ -119,10 +154,11 @@ This project follows functional programming principles: ## Roadmap 1. Core IR model implementation -2. JSON serialization/deserialization -3. Type checking and validation -4. Code generation backends -5. CLI tooling +2. Morphir SDK runtime (`morphir.sdk`) - core modules done +3. JSON serialization/deserialization +4. Type checking and validation +5. Code generation backends +6. CLI tooling ## Contributing diff --git a/features/morphir_sdk.feature b/features/morphir_sdk.feature new file mode 100644 index 00000000..764b9cad --- /dev/null +++ b/features/morphir_sdk.feature @@ -0,0 +1,41 @@ +Feature: Morphir SDK + As a developer of Morphir models + I want a Python runtime for the Morphir SDK + So that compiled models behave as they do in Elm + + Scenario Outline: Every core SDK module can be imported + When I import the SDK module "" + Then the SDK module should be available + + Examples: + | module | + | basics | + | char | + | string | + | list | + | dict | + | set | + | maybe | + | result | + | tuple | + | decimal | + | int | + | number | + + Scenario: A dictionary lists its entries in key order + Given an SDK dictionary built from the pairs "b=2, a=1, c=3" + When I list the keys of the dictionary + Then the keys should be "a, b, c" + + Scenario: Two dictionaries built in a different order are equal + Given an SDK dictionary built from the pairs "b=2, a=1" + And another SDK dictionary built from the pairs "a=1, b=2" + Then the two dictionaries should be equal + + Scenario: Rounding follows Elm and not Python + When I round the float 2.5 with the SDK + Then the rounded value should be 3 + + Scenario: Decimal arithmetic is exact + When I add the decimals "0.1" and "0.2" with the SDK + Then the decimal result should print as "0.3" diff --git a/features/steps/sdk_steps.py b/features/steps/sdk_steps.py new file mode 100644 index 00000000..a7b8ff4d --- /dev/null +++ b/features/steps/sdk_steps.py @@ -0,0 +1,91 @@ +"""Step definitions for the Morphir SDK feature.""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +from behave import given, then, when + +from morphir.sdk import basics, maybe +from morphir.sdk import decimal as sdk_decimal +from morphir.sdk import dict as sdk_dict + +if TYPE_CHECKING: + from behave.runner import Context + + +def _parse_pairs(text: str) -> tuple[tuple[str, int], ...]: + pairs = (item.strip().split("=") for item in text.split(",")) + return tuple((key, int(value)) for key, value in pairs) + + +@when('I import the SDK module "{name}"') +def step_import_sdk_module(context: Context, name: str) -> None: + """Import one module of the morphir.sdk package.""" + context.sdk_module = importlib.import_module(f"morphir.sdk.{name}") + + +@then("the SDK module should be available") +def step_sdk_module_available(context: Context) -> None: + """Verify the SDK module was imported and exports names.""" + assert context.sdk_module is not None + assert context.sdk_module.__all__, "The SDK module exports no names" + + +@given('an SDK dictionary built from the pairs "{pairs}"') +def step_build_dictionary(context: Context, pairs: str) -> None: + """Build a dictionary from text such as "b=2, a=1".""" + context.dictionary = sdk_dict.from_list(_parse_pairs(pairs)) + + +@given('another SDK dictionary built from the pairs "{pairs}"') +def step_build_other_dictionary(context: Context, pairs: str) -> None: + """Build a second dictionary from text such as "a=1, b=2".""" + context.other_dictionary = sdk_dict.from_list(_parse_pairs(pairs)) + + +@when("I list the keys of the dictionary") +def step_list_keys(context: Context) -> None: + """List the keys of the dictionary.""" + context.keys = sdk_dict.keys(context.dictionary) + + +@then('the keys should be "{expected}"') +def step_keys_should_be(context: Context, expected: str) -> None: + """Verify the keys and their order.""" + assert ", ".join(context.keys) == expected, f"Got {context.keys}" + + +@then("the two dictionaries should be equal") +def step_dictionaries_equal(context: Context) -> None: + """Verify structural equality of the two dictionaries.""" + assert basics.equal(context.dictionary, context.other_dictionary) + assert context.dictionary == context.other_dictionary + + +@when("I round the float {value:f} with the SDK") +def step_round_float(context: Context, value: float) -> None: + """Round a float with Basics.round.""" + context.rounded = basics.round(value) + + +@then("the rounded value should be {expected:d}") +def step_rounded_value(context: Context, expected: int) -> None: + """Verify the rounded value.""" + assert context.rounded == expected, f"Got {context.rounded}" + + +@when('I add the decimals "{left}" and "{right}" with the SDK') +def step_add_decimals(context: Context, left: str, right: str) -> None: + """Parse and add two decimals.""" + a = maybe.with_default(sdk_decimal.zero, sdk_decimal.from_string(left)) + b = maybe.with_default(sdk_decimal.zero, sdk_decimal.from_string(right)) + context.decimal_result = sdk_decimal.add(a, b) + + +@then('the decimal result should print as "{expected}"') +def step_decimal_prints_as(context: Context, expected: str) -> None: + """Verify the positional string form of the result.""" + actual = sdk_decimal.to_string(context.decimal_result) + assert actual == expected, f"Got {actual}" diff --git a/packages/morphir/README.md b/packages/morphir/README.md index c3545d9d..973bb7e4 100644 --- a/packages/morphir/README.md +++ b/packages/morphir/README.md @@ -7,6 +7,7 @@ This is the core library package containing: - Morphir IR (Intermediate Representation) models - Type definitions and type algebra - Pure functional primitives +- The Morphir SDK runtime (`morphir.sdk`) ## Installation @@ -28,6 +29,98 @@ from morphir.ir import Type, Value # Example usage will be added as the library develops ``` +## Morphir SDK + +`morphir.sdk` is the standard library that Morphir models compile against. The +set of functions is the `Morphir.IR.SDK.*` package specification of +[morphir-elm](https://github.com/finos/morphir-elm). The reference behaviour is +elm/core 1.0.5 plus the `Morphir.SDK.*` Elm runtime. The package has no +dependencies and does no IO. + +| Module | Elm module | Python data | +|--------|------------|-------------| +| `morphir.sdk.basics` | `Morphir.SDK.Basics` | `int`, `float`, `bool`, `Order` (`LT`, `EQ`, `GT`) | +| `morphir.sdk.char` | `Morphir.SDK.Char` | `str` with one code point | +| `morphir.sdk.string` | `Morphir.SDK.String` | `str` | +| `morphir.sdk.list` | `Morphir.SDK.List` | `tuple[A, ...]` | +| `morphir.sdk.dict` | `Morphir.SDK.Dict` | `Dict[K, V]`, entries sorted by key | +| `morphir.sdk.set` | `Morphir.SDK.Set` | `Set[A]`, members sorted | +| `morphir.sdk.maybe` | `Morphir.SDK.Maybe` | `Just[A] \| Nothing` | +| `morphir.sdk.result` | `Morphir.SDK.Result` | `Ok[A] \| Err[E]`, as `Result[E, A]` | +| `morphir.sdk.tuple` | `Morphir.SDK.Tuple` | `tuple[A, B]` | +| `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`. + +### Conventions + +- **Names** are the Elm names in snake_case: `withDefault` is `with_default`, + `integerDivide` is `integer_divide`, `toInt8` is `to_int8`. A name that is a + Python keyword gets a trailing underscore: `not_`, `and_`, `or_`. The one + exception is `isNaN`, which is `is_nan`. The test + `tests/unit/sdk/test_spec_coverage.py` lists every Elm name with its Python name. +- **Arguments** are in Elm order, with the data last, and are not curried: + `list.map(f, xs)`, `dict.get(key, d)`, `maybe.with_default(0, m)`. +- **Data is immutable.** An Elm list is a tuple; functions accept any `Sequence` + and yield a tuple. `Maybe`, `Result`, `Dict`, `Set` and `Number` are frozen + dataclasses, so they compare by value and work with `match`. +- **Module names** are the Elm names in lower case, so `list`, `dict`, `set`, + `int`, `tuple`, `string` and `decimal` have the name of a built-in or of a + standard library module. This is safe because Python 3 imports are absolute: + `import decimal` always gives the standard library. (Do not run a file of the + `sdk` directory as a script, or put that directory on `sys.path`; that would hide + the standard library modules.) Many functions also have the + name of a built-in (`map`, `filter`, `round`, ...). Import the modules with an + alias and call the functions through the module: + + ```python + from morphir.sdk import dict as Dict + from morphir.sdk import list as List + ``` + +- **Comparable keys.** `Dict` keys, `Set` members and the arguments of + `basics.compare`, `list.sort` and so on must be numbers, strings, or tuples or + lists of these. Other values (also `bool`, and a mix of `int` and `str`) raise + `TypeError`, because Python has no compile-time check for Elm's `comparable`. + A list inside a key or member is stored as a tuple, so a later change to the + list cannot break the sorted order, and the value stays hashable. Build a + `Dict` or `Set` with `from_list`, `singleton` or `insert`; the class constructor + accepts only entries that are already sorted, and raises `ValueError` otherwise. +- **Failures.** Where Elm's runtime fails, the Python function raises: + `basics.mod_by(0, x)` raises `ZeroDivisionError`, and `basics.equal` raises + `TypeError` on a function, also one held inside a tuple, a list, a `Maybe`, a + `Result`, a `Dict` or a `Set`. +- **Powers stay real.** `basics.power` follows JavaScript `Math.pow`, as the Elm + runtime does: `power(-1.0, 0.5)` is `NaN` and `power(0.0, -1.0)` is infinity, + where Python `**` gives a complex number and raises. Two integers with an + exponent of zero or more give an exact integer. + +### Departures from Elm + +- Python integers have no size limit. `Int` arithmetic never overflows, `Int64` + covers the full 64-bit range, and `Number` is exact at any size. +- A `str` is a sequence of code points, not of UTF-16 code units. `string.length`, + `string.slice` and the other index functions count a character above U+FFFF as + 1, where Elm counts 2. Strings compare by code point. +- `basics.remainder_by(0, x)` raises `ZeroDivisionError`; Elm yields `NaN`. + `basics.round`, `floor`, `ceiling` and `truncate` raise on `NaN` and the + infinities, because the result is an `int`. +- `char.to_locale_upper` and `char.to_locale_lower` do the same as `to_upper` and + `to_lower`; Python has no locale rules for case. +- `decimal.from_float` raises `ValueError` on `NaN` and the infinities. + `decimal.shift_decimal_left` moves the exponent; the Elm runtime multiplies by a + float. Arithmetic results keep 50 significant digits with half-up rounding, from + a context that is local to the module. The global `decimal` context is never + read or changed. +- `number` comparisons give the correct answer for a negative denominator (the + Elm runtime compares `3/-4` and `1/2` wrongly), and `number.simplify` yields a + positive denominator (`3/-6` becomes `-1/2`). As in Elm, arithmetic results are + not reduced, so `==` on two `Number` values is structural; use `number.equal` + to compare by value. + ## Requirements - Python 3.14+ diff --git a/packages/morphir/src/morphir/sdk/__init__.py b/packages/morphir/src/morphir/sdk/__init__.py new file mode 100644 index 00000000..54396889 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/__init__.py @@ -0,0 +1,80 @@ +"""The Morphir SDK: the standard library that Morphir models compile against. + +Each module mirrors one `Morphir.SDK` module. The set of functions is the +`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 +======== ====================== ========================================= + +Conventions: + +* Names are the Elm names in snake_case: `withDefault` is `with_default`, + `integerDivide` is `integer_divide`, `toInt8` is `to_int8`. A name that is a + Python keyword gets a trailing underscore: `not_`, `and_`, `or_`. The one + exception is `isNaN`, which is `is_nan`. +* Arguments are in Elm order, with the data last, and are not curried: + `list.map(f, xs)`, `dict.get(key, d)`, `maybe.with_default(0, m)`. +* All data is immutable. Functions are pure and never mutate their input. +* Where Elm's runtime fails (`mod_by(0, x)`, comparison of functions), the + Python function raises an exception. Each docstring names the exception. + +Several modules and functions have the name of a Python built-in (`list`, +`dict`, `map`, `filter`, ...). Import the modules with an alias, and call the +functions through the module: + + >>> from morphir.sdk import dict as Dict + >>> from morphir.sdk import list as List + >>> from morphir.sdk import maybe as Maybe + >>> ages = Dict.from_list((("tom", 42), ("sue", 38))) + >>> Maybe.with_default(0, Dict.get("sue", ages)) + 38 + >>> List.map(lambda n: n * 2, Dict.values(ages)) + (76, 84) + +This package has no dependencies and does no IO. +""" + +from morphir.sdk import ( + basics, + char, + decimal, + dict, + int, + list, + maybe, + number, + result, + set, + string, + tuple, +) + +__all__ = [ + "basics", + "char", + "decimal", + "dict", + "int", + "list", + "maybe", + "number", + "result", + "set", + "string", + "tuple", +] diff --git a/packages/morphir/src/morphir/sdk/_compare.py b/packages/morphir/src/morphir/sdk/_compare.py new file mode 100644 index 00000000..f49aec15 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/_compare.py @@ -0,0 +1,187 @@ +"""Ordering and structural equality shared by the SDK modules. + +Elm's `compare` works on the comparable types only: `Int`, `Float`, `Char`, +`String`, and tuples or lists of comparables. The Elm type checker rejects +anything else. Python has no such check, so `compare` raises `TypeError` at run +time instead. +""" + +import dataclasses +from enum import Enum +from functools import cmp_to_key +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + +class Order(Enum): + """The result of a comparison: less than, equal or greater than.""" + + LT = "LT" + EQ = "EQ" + GT = "GT" + + +type Comparable = int | float | str | Sequence[Comparable] +"""The Elm comparable types: numbers, strings, and sequences of comparables.""" + + +def _is_number(value: object) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _order(a: Any, b: Any) -> Order: + if a < b: + return Order.LT + if a > b: + return Order.GT + return Order.EQ + + +def compare(a: object, b: object) -> Order: + """Compare two comparable values. + + Numbers compare by value and strings by code point. Tuples and lists compare + element by element; when one is a prefix of the other, the shorter one is + less. + + Args: + a: The first value. + b: The second value. + + Returns: + `Order.LT`, `Order.EQ` or `Order.GT`. + + Raises: + TypeError: If the values are not comparable, or not of the same kind + (for example an `int` and a `str`, or two `bool` values). + """ + if _is_number(a) and _is_number(b): + return _order(a, b) + if isinstance(a, str) and isinstance(b, str): + return _order(a, b) + if isinstance(a, (tuple, list)) and isinstance(b, (tuple, list)): + for x, y in zip(a, b, strict=False): + order = compare(x, y) + if order is not Order.EQ: + return order + return _order(len(a), len(b)) + raise TypeError( + f"compare: values are not comparable " + f"({type(a).__name__} and {type(b).__name__})" + ) + + +def to_int(order: Order) -> int: + """Convert an `Order` to the -1, 0 or 1 that Python sort functions expect.""" + if order is Order.LT: + return -1 + if order is Order.GT: + return 1 + return 0 + + +def sort_key[A](by: Callable[[A, A], Order]) -> Callable[[A], Any]: + """Build a `key` function for `sorted` from a function that yields an `Order`.""" + return cmp_to_key(lambda a, b: to_int(by(a, b))) + + +def search[K: Comparable, T]( + key: K, items: Sequence[T], key_of: Callable[[T], K] +) -> tuple[bool, int]: + """Find a key in a sequence of items sorted by key in ascending order. + + Args: + key: The key to find. + items: The sorted items, with no key twice. + key_of: A function that gives the key of an item. + + Returns: + A pair `(found, index)`. When `found` is true, `index` is the position + of the item with the key. Otherwise `index` is the position where the + key must go to keep the sequence sorted. + + Raises: + TypeError: If the key is not comparable with the keys in the sequence. + """ + low = 0 + high = len(items) + while low < high: + middle = (low + high) // 2 + order = compare(key_of(items[middle]), key) + if order is Order.EQ: + return (True, middle) + if order is Order.LT: + low = middle + 1 + else: + high = middle + return (False, low) + + +def freeze(value: Any) -> Any: + """Turn every list inside a comparable value into a tuple. + + `Dict` and `Set` keep their keys in sorted order. A list key that changed + after it was stored would break that order, and a list cannot be hashed. A + stored key is therefore always the tuple form. A value with no list in it is + returned as it is. + """ + if isinstance(value, list): + return tuple(freeze(item) for item in value) + if isinstance(value, tuple): + frozen = tuple(freeze(item) for item in value) + if all(new is old for new, old in zip(frozen, value, strict=True)): + return value + return frozen + return value + + +def check_strictly_ascending(keys: Sequence[Any], owner: str) -> None: + """Check that keys are sorted in ascending order and that none repeats. + + Raises: + ValueError: If a key is not greater than the key before it. + TypeError: If two keys are not comparable with each other. + """ + for index in range(1, len(keys)): + if compare(keys[index - 1], keys[index]) is not Order.LT: + raise ValueError( + f"{owner}: entries must be sorted by key with no key twice; " + f"use from_list to build one from entries in any order" + ) + + +def _contains_function(value: object) -> bool: + if isinstance(value, (tuple, list)): + return any(_contains_function(item) for item in value) + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return any( + _contains_function(getattr(value, field.name)) + for field in dataclasses.fields(value) + ) + return callable(value) and not isinstance(value, type) + + +def equal(a: object, b: object) -> bool: + """Check structural equality, like Elm's `==`. + + Frozen dataclasses, tuples, strings, numbers, `Decimal`, `Dict` and `Set` + values all compare by value in Python, so this function uses `==`. `NaN` is + not equal to itself, as in Elm. + + Args: + a: The first value. + b: The second value. + + Returns: + True when the two values are structurally equal. + + Raises: + TypeError: If a value is a function, or holds a function anywhere + inside a tuple, a list or an SDK data type such as `Just`, `Ok` or + `Dict`. Elm's runtime also fails on these values. + """ + if _contains_function(a) or _contains_function(b): + raise TypeError("equal: cannot compare functions") + return bool(a == b) diff --git a/packages/morphir/src/morphir/sdk/basics.py b/packages/morphir/src/morphir/sdk/basics.py new file mode 100644 index 00000000..9f13f283 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/basics.py @@ -0,0 +1,504 @@ +"""Morphir.SDK.Basics: Elm's Basics module. + +Operators have the names that the Morphir IR SDK specification gives them +(`add`, `equal`, `append`, ...). Elm's `Int` is a Python `int` and `Float` is a +Python `float`. The names `not`, `and` and `or` are Python keywords, so they are +`not_`, `and_` and `or_` here. + +Some names in this module shadow Python built-ins (`abs`, `max`, `min`, `round`). +Import the module, not the names: + + >>> from morphir.sdk import basics + >>> basics.round(2.5) + 3 + +Departures from Elm: + +* Python integers do not overflow, so `Int` arithmetic is exact at any size. +* `remainder_by(0, x)` raises `ZeroDivisionError`. Elm yields `NaN`, which a + Python `int` cannot hold. +* `round`, `floor`, `ceiling` and `truncate` raise on `NaN` and the infinities, + which a Python `int` cannot hold. +* Strings compare by code point. Elm compares by UTF-16 code unit, so the order + differs only between characters above U+FFFF and those in U+E000..U+FFFF. +""" + +import builtins +import math +from typing import TYPE_CHECKING, Any, Never, overload + +from morphir.sdk import _compare +from morphir.sdk._compare import Comparable, Order + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + +__all__ = [ + "Comparable", + "Order", + "abs", + "acos", + "add", + "always", + "and_", + "append", + "asin", + "atan", + "atan2", + "ceiling", + "clamp", + "compare", + "compose_left", + "compose_right", + "cos", + "degrees", + "divide", + "e", + "equal", + "floor", + "from_polar", + "greater_than", + "greater_than_or_equal", + "identity", + "integer_divide", + "is_infinite", + "is_nan", + "less_than", + "less_than_or_equal", + "log_base", + "max", + "min", + "mod_by", + "multiply", + "negate", + "never", + "not_", + "not_equal", + "or_", + "pi", + "power", + "radians", + "remainder_by", + "round", + "sin", + "sqrt", + "subtract", + "tan", + "to_float", + "to_polar", + "truncate", + "turns", + "xor", +] + +# Numbers + + +def add[N: (int, float)](a: N, b: N) -> N: + """Add two numbers (Elm `+`).""" + return a + b + + +def subtract[N: (int, float)](a: N, b: N) -> N: + """Subtract the second number from the first (Elm `-`).""" + return a - b + + +def multiply[N: (int, float)](a: N, b: N) -> N: + """Multiply two numbers (Elm `*`).""" + return a * b + + +def divide(a: float, b: float) -> float: + """Divide two floats (Elm `/`). + + A zero divisor follows IEEE 754 like Elm, and does not raise: the result is + an infinity, or `NaN` for `0 / 0`. + """ + if b == 0: + if a == 0 or math.isnan(a): + return math.nan + return math.copysign(math.inf, a) * math.copysign(1.0, b) + return a / b + + +def integer_divide(a: int, b: int) -> int: + """Divide two integers (Elm `//`). + + The result is truncated toward zero. A zero divisor yields 0, as in Elm. + """ + if b == 0: + return 0 + quotient = builtins.abs(a) // builtins.abs(b) + return quotient if (a < 0) == (b < 0) else -quotient + + +def _is_odd_integer(value: float) -> bool: + return value.is_integer() and value % 2 == 1 + + +def _float_power(base: float, exponent: float) -> float: + if math.isnan(exponent): + return math.nan + if exponent == 0: + return 1.0 + if math.isnan(base) or (math.isinf(exponent) and abs(base) == 1): + return math.nan + negative = math.copysign(1.0, base) < 0 and _is_odd_integer(exponent) + try: + return math.pow(base, exponent) + except ValueError: + # Zero to a negative power is an infinity. A negative base with an + # exponent that is not an integer has no real result. + if base == 0: + return -math.inf if negative else math.inf + return math.nan + except OverflowError: + return -math.inf if negative else math.inf + + +def power[N: (int, float)](base: N, exponent: N) -> N: + """Raise a number to a power (Elm `^`). + + Two integers with an exponent of zero or more give an exact integer. Every + other case follows JavaScript `Math.pow`, as the Elm runtime does, so the + result is always a real number, `NaN` or an infinity: `power(-1.0, 0.5)` is + `NaN` and `power(0.0, -1.0)` is infinity. Python `**` gives a complex number + for the first and raises for the second. + + An integer with a negative exponent gives a float (`power(2, -1)` is 0.5), + as it does in Elm at run time. + """ + if ( + isinstance(base, int) + and isinstance(exponent, int) + and not isinstance(base, bool) + and not isinstance(exponent, bool) + and exponent >= 0 + ): + exact: N = base**exponent + return exact + try: + real = _float_power(float(base), float(exponent)) + except OverflowError: + # An integer too large for a float. + negative = base < 0 and _is_odd_integer(float(exponent)) + real = -math.inf if negative else math.inf + # The result is a float even when N is int: an integer with a negative + # exponent has no integer result, in Elm as here. + result: Any = real + return result # type: ignore[no-any-return] + + +def to_float(a: int) -> float: + """Convert an integer to a float.""" + return float(a) + + +def round(a: float) -> int: + """Round to the nearest integer; a half goes toward positive infinity. + + This is the behaviour of Elm and of JavaScript `Math.round`, not the + half-to-even rule of the Python built-in: `round(2.5)` is 3 and + `round(-2.5)` is -2. + + Raises: + ValueError: If the value is `NaN`. + OverflowError: If the value is an infinity. + """ + lower = math.floor(a) + return lower + 1 if a - lower >= 0.5 else lower + + +def floor(a: float) -> int: + """Round down to an integer.""" + return math.floor(a) + + +def ceiling(a: float) -> int: + """Round up to an integer.""" + return math.ceil(a) + + +def truncate(a: float) -> int: + """Round toward zero to an integer.""" + return math.trunc(a) + + +def mod_by(modulus: int, a: int) -> int: + """Perform modular arithmetic; the result has the sign of the modulus. + + Raises: + ZeroDivisionError: If the modulus is 0. Elm's runtime also fails. + """ + if modulus == 0: + raise ZeroDivisionError("mod_by: cannot perform mod 0") + return a % modulus + + +def remainder_by(divisor: int, a: int) -> int: + """Return the remainder of a division; the result has the sign of `a`. + + Raises: + ZeroDivisionError: If the divisor is 0. Elm yields `NaN` here. + """ + if divisor == 0: + raise ZeroDivisionError("remainder_by: cannot divide by 0") + remainder = builtins.abs(a) % builtins.abs(divisor) + return -remainder if a < 0 else remainder + + +def negate[N: (int, float)](a: N) -> N: + """Change the sign of a number.""" + return -a + + +def abs[N: (int, float)](a: N) -> N: + """Return the absolute value of a number.""" + return builtins.abs(a) # pyright: ignore[reportReturnType] + + +def clamp[A: Comparable](low: A, high: A, a: A) -> A: + """Keep a value in the range from `low` to `high`.""" + if less_than(a, low): + return low + if greater_than(a, high): + return high + return a + + +def is_nan(a: float) -> bool: + """Check that a float is `NaN` (Elm `isNaN`).""" + return math.isnan(a) + + +def is_infinite(a: float) -> bool: + """Check that a float is positive or negative infinity.""" + return math.isinf(a) + + +def sqrt(a: float) -> float: + """Return the square root. A negative input yields `NaN`, as in Elm.""" + return math.nan if a < 0 else math.sqrt(a) + + +def _ln(a: float) -> float: + if a > 0: + return math.log(a) + return -math.inf if a == 0 else math.nan + + +def log_base(base: float, a: float) -> float: + """Return the logarithm of `a` in the given base. + + Inputs outside the domain yield `NaN` or an infinity, as in Elm. + """ + return divide(_ln(a), _ln(base)) + + +e: float = math.e +"""Euler's number.""" + +pi: float = math.pi +"""The ratio of the circumference of a circle to its diameter.""" + + +def cos(a: float) -> float: + """Return the cosine of an angle in radians.""" + return math.cos(a) + + +def sin(a: float) -> float: + """Return the sine of an angle in radians.""" + return math.sin(a) + + +def tan(a: float) -> float: + """Return the tangent of an angle in radians.""" + return math.tan(a) + + +def acos(a: float) -> float: + """Return the arc cosine. An input outside -1..1 yields `NaN`, as in Elm.""" + return math.acos(a) if -1 <= a <= 1 else math.nan + + +def asin(a: float) -> float: + """Return the arc sine. An input outside -1..1 yields `NaN`, as in Elm.""" + return math.asin(a) if -1 <= a <= 1 else math.nan + + +def atan(a: float) -> float: + """Return the arc tangent.""" + return math.atan(a) + + +def atan2(y: float, x: float) -> float: + """Return the angle of the vector `(x, y)`, in the correct quadrant.""" + return math.atan2(y, x) + + +def degrees(a: float) -> float: + """Convert degrees to radians.""" + return a * math.pi / 180 + + +def radians(a: float) -> float: + """Convert radians to radians, which does nothing.""" + return a + + +def turns(a: float) -> float: + """Convert turns to radians. One turn is 360 degrees.""" + return a * 2 * math.pi + + +def to_polar(point: tuple[float, float]) -> tuple[float, float]: + """Convert Cartesian coordinates `(x, y)` to polar coordinates `(r, theta)`.""" + x, y = point + return (math.hypot(x, y), math.atan2(y, x)) + + +def from_polar(polar: tuple[float, float]) -> tuple[float, float]: + """Convert polar coordinates `(r, theta)` to Cartesian coordinates `(x, y)`.""" + r, theta = polar + return (r * math.cos(theta), r * math.sin(theta)) + + +# Equality and ordering + + +def equal(a: object, b: object) -> bool: + """Check structural equality (Elm `==`). + + Raises: + TypeError: If a value is a function. + """ + return _compare.equal(a, b) + + +def not_equal(a: object, b: object) -> bool: + """Check structural inequality (Elm `/=`). + + Raises: + TypeError: If a value is a function. + """ + return not _compare.equal(a, b) + + +def compare[A: Comparable](a: A, b: A) -> Order: + """Compare two comparable values. + + Comparable values are numbers, strings, and tuples or lists of comparables. + + Raises: + TypeError: If the values are not comparable or not of the same kind. + """ + return _compare.compare(a, b) + + +def less_than[A: Comparable](a: A, b: A) -> bool: + """Check `a < b`.""" + return _compare.compare(a, b) is Order.LT + + +def greater_than[A: Comparable](a: A, b: A) -> bool: + """Check `a > b`.""" + return _compare.compare(a, b) is Order.GT + + +def less_than_or_equal[A: Comparable](a: A, b: A) -> bool: + """Check `a <= b`.""" + return _compare.compare(a, b) is not Order.GT + + +def greater_than_or_equal[A: Comparable](a: A, b: A) -> bool: + """Check `a >= b`.""" + return _compare.compare(a, b) is not Order.LT + + +def max[A: Comparable](a: A, b: A) -> A: + """Return the larger of two comparable values.""" + return a if greater_than(a, b) else b + + +def min[A: Comparable](a: A, b: A) -> A: + """Return the smaller of two comparable values.""" + return a if less_than(a, b) else b + + +# Booleans + + +def not_(a: bool) -> bool: + """Negate a boolean (Elm `not`).""" + return not a + + +def and_(a: bool, b: bool) -> bool: + """Return the logical AND of two booleans (Elm `&&`).""" + return a and b + + +def or_(a: bool, b: bool) -> bool: + """Return the logical OR of two booleans (Elm `||`).""" + return a or b + + +def xor(a: bool, b: bool) -> bool: + """Return the exclusive OR of two booleans.""" + return a != b + + +# Appendables + + +@overload +def append(a: str, b: str) -> str: ... # type: ignore[overload-overlap] # pyright: ignore[reportOverlappingOverload] +@overload +def append[A](a: Sequence[A], b: Sequence[A]) -> tuple[A, ...]: ... +def append[A](a: str | Sequence[A], b: str | Sequence[A]) -> str | tuple[A, ...]: + """Append two strings or two lists (Elm `++`). + + Raises: + TypeError: If one argument is a string and the other is not. + """ + if isinstance(a, str) and isinstance(b, str): + return a + b + if isinstance(a, str) or isinstance(b, str): + raise TypeError("append: cannot append a string and a list") + return (*a, *b) + + +# Functions + + +def identity[A](a: A) -> A: + """Return the argument.""" + return a + + +def always[A](a: A, _b: object) -> A: + """Return the first argument and ignore the second.""" + return a + + +def compose_left[A, B, C](g: Callable[[B], C], f: Callable[[A], B]) -> Callable[[A], C]: + """Compose two functions (Elm `g << f`): the result calls `f`, then `g`.""" + return lambda a: g(f(a)) + + +def compose_right[A, B, C]( + f: Callable[[A], B], g: Callable[[B], C] +) -> Callable[[A], C]: + """Compose two functions (Elm `f >> g`): the result calls `f`, then `g`.""" + return lambda a: g(f(a)) + + +def never(_a: Never) -> Never: + """Handle a value of type `Never`. No such value exists, so this raises. + + Raises: + TypeError: Always. + """ + raise TypeError("never: a value of type Never cannot exist") diff --git a/packages/morphir/src/morphir/sdk/char.py b/packages/morphir/src/morphir/sdk/char.py new file mode 100644 index 00000000..ec8f2841 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/char.py @@ -0,0 +1,110 @@ +"""Morphir.SDK.Char: Elm's Char module. + +A `Char` is a Python `str` that holds one code point. The classification +functions are ASCII-only, like elm/core: `is_alpha("é")` is false. They yield +false for a string that does not hold exactly one code point. +""" + +__all__ = [ + "Char", + "from_code", + "is_alpha", + "is_alpha_num", + "is_digit", + "is_hex_digit", + "is_lower", + "is_oct_digit", + "is_upper", + "to_code", + "to_locale_lower", + "to_locale_upper", + "to_lower", + "to_upper", +] + +type Char = str +"""A string that holds one code point.""" + +_MAX_CODE_POINT = 0x10FFFF +_REPLACEMENT_CHAR = "\N{REPLACEMENT CHARACTER}" + + +def _in_range(low: str, high: str, c: Char) -> bool: + return len(c) == 1 and low <= c <= high + + +def is_upper(c: Char) -> bool: + """Check for an ASCII upper case letter, `A` to `Z`.""" + return _in_range("A", "Z", c) + + +def is_lower(c: Char) -> bool: + """Check for an ASCII lower case letter, `a` to `z`.""" + return _in_range("a", "z", c) + + +def is_alpha(c: Char) -> bool: + """Check for an ASCII letter.""" + return is_lower(c) or is_upper(c) + + +def is_alpha_num(c: Char) -> bool: + """Check for an ASCII letter or digit.""" + return is_alpha(c) or is_digit(c) + + +def is_digit(c: Char) -> bool: + """Check for an ASCII digit, `0` to `9`.""" + return _in_range("0", "9", c) + + +def is_oct_digit(c: Char) -> bool: + """Check for an ASCII octal digit, `0` to `7`.""" + return _in_range("0", "7", c) + + +def is_hex_digit(c: Char) -> bool: + """Check for an ASCII hexadecimal digit: `0` to `9`, `a` to `f`, `A` to `F`.""" + return is_digit(c) or _in_range("a", "f", c) or _in_range("A", "F", c) + + +def to_upper(c: Char) -> Char: + """Convert to upper case. + + Some characters expand to more than one code point ("ß" gives "SS"), as they + do in Elm. + """ + return c.upper() + + +def to_lower(c: Char) -> Char: + """Convert to lower case.""" + return c.lower() + + +def to_locale_upper(c: Char) -> Char: + """Convert to upper case. Python has no locale rules here; see `to_upper`.""" + return c.upper() + + +def to_locale_lower(c: Char) -> Char: + """Convert to lower case. Python has no locale rules here; see `to_lower`.""" + return c.lower() + + +def to_code(c: Char) -> int: + """Return the Unicode code point of a character. + + Raises: + TypeError: If the string does not hold exactly one code point. + """ + return ord(c) + + +def from_code(code: int) -> Char: + """Return the character for a Unicode code point. + + A code outside the Unicode range yields U+FFFD, the replacement character, + like elm/core. + """ + return chr(code) if 0 <= code <= _MAX_CODE_POINT else _REPLACEMENT_CHAR diff --git a/packages/morphir/src/morphir/sdk/decimal.py b/packages/morphir/src/morphir/sdk/decimal.py new file mode 100644 index 00000000..8ea9a346 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/decimal.py @@ -0,0 +1,311 @@ +"""Morphir.SDK.Decimal: arbitrary-precision decimal numbers. + +A `Decimal` is the standard library `decimal.Decimal`. All arithmetic goes +through a context that is local to this module, with 50 significant digits and +half-up rounding. The module never reads or changes the global decimal context, +so other users of the `decimal` module see no effect, and a change to the global +context has no effect on the results here. + +`Decimal` values compare by numeric value: `1.50 == 1.5`, and both have the same +hash. + +Some names in this module shadow Python built-ins (`abs`, `round`). Import the +module with an alias, not the names: + + >>> from morphir.sdk import decimal as Decimal + >>> Decimal.to_string(Decimal.add(Decimal.from_float(0.1), Decimal.bps(5))) + '0.1005' + +Departures from the Elm runtime: + +* `from_float` raises `ValueError` for `NaN` and the infinities, which a + `Decimal` in this module cannot hold. +* `shift_decimal_left` moves the exponent and uses no float. The Elm runtime + multiplies by the float `10 ^ -n`. +""" + +import decimal as _decimal +import math +import re + +from morphir.sdk import maybe +from morphir.sdk._compare import Order +from morphir.sdk.maybe import Just, Maybe, Nothing + +__all__ = [ + "Decimal", + "abs", + "add", + "bps", + "compare", + "div", + "div_with_default", + "eq", + "from_float", + "from_int", + "from_string", + "gt", + "gte", + "hundred", + "hundredth", + "lt", + "lte", + "million", + "millionth", + "minus_one", + "mul", + "negate", + "neq", + "one", + "round", + "shift_decimal_left", + "shift_decimal_right", + "sub", + "tenth", + "thousand", + "thousandth", + "to_float", + "to_string", + "truncate", + "zero", +] + +Decimal = _decimal.Decimal +"""The standard library decimal type.""" + +PRECISION = 50 +"""The number of significant digits that arithmetic results keep.""" + +# The exponent limits are as wide as the library allows, so that overflow is not +# a practical concern. +_CONTEXT = _decimal.Context( + prec=PRECISION, + rounding=_decimal.ROUND_HALF_UP, + Emin=_decimal.MIN_EMIN, + Emax=_decimal.MAX_EMAX, +) + +# Elm's grammar: [][.][e[]]. The standard +# library also accepts "Infinity", "NaN", underscores, white space and digits +# that are not ASCII; Elm parses none of these. +_ELM_DECIMAL = re.compile(r"[+-]?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?") + + +# Constants + +zero: Decimal = Decimal(0) +"""The number 0.""" + +one: Decimal = Decimal(1) +"""The number 1.""" + +minus_one: Decimal = Decimal(-1) +"""The number -1.""" + + +# Convert from + + +def from_int(n: int) -> Decimal: + """Convert an integer to a `Decimal`. The conversion is exact.""" + return Decimal(n) + + +def from_float(f: float) -> Decimal: + """Convert a float to a `Decimal`. + + The result has the shortest digits that identify the float, so + `from_float(0.1)` is exactly 0.1. Negative zero becomes zero. + + Raises: + ValueError: If the float is `NaN` or an infinity. + """ + if not math.isfinite(f): + raise ValueError(f"from_float: {f!r} is not a finite number") + return zero if f == 0 else Decimal(repr(f)) + + +def from_string(s: str) -> Maybe[Decimal]: + """Parse a `Decimal`. + + The accepted form is an optional sign, digits, an optional fraction and an + optional exponent: `[+-]?digits(.digits)?([eE][+-]?digits)?`. Anything else + yields `Nothing`. The conversion is exact; it keeps all the digits. + """ + if not _ELM_DECIMAL.fullmatch(s): + return Nothing() + try: + parsed = Decimal(s) + except _decimal.InvalidOperation: + # The exponent is outside the range that the library can hold. + return Nothing() + return Just(parsed) if parsed.is_finite() else Nothing() + + +# Convert from a known exponent + + +def hundred(n: int) -> Decimal: + """Return `n` hundreds.""" + return Decimal(n).scaleb(2, _CONTEXT) + + +def thousand(n: int) -> Decimal: + """Return `n` thousands.""" + return Decimal(n).scaleb(3, _CONTEXT) + + +def million(n: int) -> Decimal: + """Return `n` millions.""" + return Decimal(n).scaleb(6, _CONTEXT) + + +def tenth(n: int) -> Decimal: + """Return `n` tenths, with no float arithmetic.""" + return Decimal(n).scaleb(-1, _CONTEXT) + + +def hundredth(n: int) -> Decimal: + """Return `n` hundredths, with no float arithmetic.""" + return Decimal(n).scaleb(-2, _CONTEXT) + + +def thousandth(n: int) -> Decimal: + """Return `n` thousandths, with no float arithmetic.""" + return Decimal(n).scaleb(-3, _CONTEXT) + + +def bps(n: int) -> Decimal: + """Return `n` basis points (ten-thousandths), with no float arithmetic.""" + return Decimal(n).scaleb(-4, _CONTEXT) + + +def millionth(n: int) -> Decimal: + """Return `n` millionths, with no float arithmetic.""" + return Decimal(n).scaleb(-6, _CONTEXT) + + +# Convert to + + +def to_string(d: Decimal) -> str: + """Convert a `Decimal` to a string. + + The result is always in positional notation, never in exponent notation, + and has no trailing zeros in the fraction. + """ + text = format(d, "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return "0" if text in ("-0", "") else text + + +def to_float(d: Decimal) -> float: + """Convert a `Decimal` to the nearest float. This can lose precision.""" + return float(d) + + +# Arithmetic + + +def add(a: Decimal, b: Decimal) -> Decimal: + """Add two decimals.""" + return _CONTEXT.add(a, b) + + +def sub(a: Decimal, b: Decimal) -> Decimal: + """Subtract the second decimal from the first.""" + return _CONTEXT.subtract(a, b) + + +def mul(a: Decimal, b: Decimal) -> Decimal: + """Multiply two decimals.""" + return _CONTEXT.multiply(a, b) + + +def div(a: Decimal, b: Decimal) -> Maybe[Decimal]: + """Divide two decimals, or yield `Nothing` when the divisor is zero. + + A quotient that is not exact has 50 significant digits. + """ + return Nothing() if b.is_zero() else Just(_CONTEXT.divide(a, b)) + + +def div_with_default(default: Decimal, a: Decimal, b: Decimal) -> Decimal: + """Divide two decimals, or yield the default when the divisor is zero.""" + return maybe.with_default(default, div(a, b)) + + +def shift_decimal_left(n: int, d: Decimal) -> Decimal: + """Move the decimal point `n` digits to the left, which divides by 10^n.""" + return d.scaleb(-n, _CONTEXT) + + +def shift_decimal_right(n: int, d: Decimal) -> Decimal: + """Move the decimal point `n` digits to the right, which multiplies by 10^n.""" + return d.scaleb(n, _CONTEXT) + + +def negate(d: Decimal) -> Decimal: + """Change the sign of a decimal. Zero stays zero.""" + return zero if d.is_zero() else d.copy_negate() + + +def abs(d: Decimal) -> Decimal: + """Return the absolute value of a decimal.""" + return d.copy_abs() + + +# Rounding + + +def truncate(d: Decimal) -> Decimal: + """Round toward zero to an integer.""" + return d.to_integral_value(rounding=_decimal.ROUND_DOWN, context=_CONTEXT) + + +def round(d: Decimal) -> Decimal: + """Round to the nearest integer. A half goes away from zero.""" + return d.to_integral_value(rounding=_decimal.ROUND_HALF_UP, context=_CONTEXT) + + +# Compare + + +def compare(a: Decimal, b: Decimal) -> Order: + """Compare two decimals by numeric value.""" + if a < b: + return Order.LT + if a > b: + return Order.GT + return Order.EQ + + +def eq(a: Decimal, b: Decimal) -> bool: + """Check `a == b` by numeric value, so 1.50 equals 1.5.""" + return a == b + + +def neq(a: Decimal, b: Decimal) -> bool: + """Check `a /= b` by numeric value.""" + return a != b + + +def gt(a: Decimal, b: Decimal) -> bool: + """Check `a > b`.""" + return a > b + + +def gte(a: Decimal, b: Decimal) -> bool: + """Check `a >= b`.""" + return a >= b + + +def lt(a: Decimal, b: Decimal) -> bool: + """Check `a < b`.""" + return a < b + + +def lte(a: Decimal, b: Decimal) -> bool: + """Check `a <= b`.""" + return a <= b diff --git a/packages/morphir/src/morphir/sdk/dict.py b/packages/morphir/src/morphir/sdk/dict.py new file mode 100644 index 00000000..cdbd01af --- /dev/null +++ b/packages/morphir/src/morphir/sdk/dict.py @@ -0,0 +1,336 @@ +"""Morphir.SDK.Dict: a mapping from comparable keys to values. + +This module follows elm/core 1.0.5. A `Dict[K, V]` is a frozen dataclass that +holds its entries as a tuple of `(key, value)` pairs, sorted in ascending order +by `basics.compare` on the key, with no key twice. Thus: + +* `to_list`, `keys` and `values` yield the entries in key order, as in Elm. +* Two dicts with the same entries are equal (`==`) and have the same hash, no + matter in which order the entries were inserted. +* Keys can be any comparable value: numbers, strings, and tuples or lists of + comparables. A key that is not comparable raises `TypeError`. + +Build a `Dict` with `empty`, `singleton`, `from_list` and `insert`. Do not pass +entries to the `Dict` constructor, because it does not sort them. + +All functions take their arguments in Elm order, with the dict last, and yield +a new `Dict`; nothing is mutated. Lookup is a binary search, O(log n). `insert` +and `remove` copy the entries, O(n). + +Some names in this module shadow Python built-ins (`filter`, `map`). Import the +module with an alias, not the names: + + >>> from morphir.sdk import dict as Dict + >>> Dict.get("a", Dict.from_list((("b", 2), ("a", 1)))) + Just(value=1) +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from morphir.sdk import _compare +from morphir.sdk._compare import Comparable, Order +from morphir.sdk.maybe import Just, Maybe, Nothing + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Sequence + +__all__ = [ + "Dict", + "diff", + "empty", + "filter", + "foldl", + "foldr", + "from_list", + "get", + "insert", + "intersect", + "is_empty", + "keys", + "map", + "member", + "merge", + "partition", + "remove", + "singleton", + "size", + "to_list", + "union", + "update", + "values", +] + + +@dataclass(frozen=True, slots=True) +class Dict[K: Comparable, V]: + """An immutable dictionary with its entries sorted by key. + + Use `from_list`, `singleton` or `insert` to build one. The constructor + accepts only entries that are already sorted by key with no key twice, and + stores every list inside a key as a tuple. + + Raises: + ValueError: If the entries are not sorted by key, or a key repeats. + TypeError: If two keys are not comparable with each other. + """ + + entries: tuple[tuple[K, V], ...] = () + + def __post_init__(self) -> None: + entries = tuple((_compare.freeze(key), value) for key, value in self.entries) + _compare.check_strictly_ascending([key for key, _ in entries], "Dict") + object.__setattr__(self, "entries", entries) + + +def _key_of[K: Comparable, V](entry: tuple[K, V]) -> K: + return entry[0] + + +def _search[K: Comparable, V](key: K, dict_: Dict[K, V]) -> tuple[bool, int]: + return _compare.search(key, dict_.entries, _key_of) + + +# Build + + +def empty[K: Comparable, V]() -> Dict[K, V]: # pyright: ignore[reportInvalidTypeVarUse] + """Create an empty dictionary.""" + return Dict() + + +def singleton[K: Comparable, V](key: K, value: V) -> Dict[K, V]: + """Create a dictionary with one entry.""" + return Dict(((key, value),)) + + +def insert[K: Comparable, V](key: K, value: V, dict_: Dict[K, V]) -> Dict[K, V]: + """Insert an entry. A value that is already there for the key is replaced. + + Raises: + TypeError: If the key is not comparable with the keys in the dictionary. + """ + found, index = _search(key, dict_) + entries = dict_.entries + after = entries[index + 1 :] if found else entries[index:] + return Dict((*entries[:index], (key, value), *after)) + + +def update[K: Comparable, V]( + key: K, alter: Callable[[Maybe[V]], Maybe[V]], dict_: Dict[K, V] +) -> Dict[K, V]: + """Change the value for a key. + + The function gets `Just` the current value, or `Nothing` when the key is not + there. When it yields `Nothing`, the key is removed. + """ + match alter(get(key, dict_)): + case Just(value): + return insert(key, value, dict_) + case Nothing(): + return remove(key, dict_) + + +def remove[K: Comparable, V](key: K, dict_: Dict[K, V]) -> Dict[K, V]: + """Remove the entry for a key. A key that is not there changes nothing.""" + found, index = _search(key, dict_) + if not found: + return dict_ + return Dict((*dict_.entries[:index], *dict_.entries[index + 1 :])) + + +# Query + + +def is_empty[K: Comparable, V](dict_: Dict[K, V]) -> bool: + """Check that a dictionary is empty.""" + return not dict_.entries + + +def member[K: Comparable, V](key: K, dict_: Dict[K, V]) -> bool: + """Check that a key is in a dictionary.""" + return _search(key, dict_)[0] + + +def get[K: Comparable, V](key: K, dict_: Dict[K, V]) -> Maybe[V]: + """Return the value for a key, or `Nothing` when the key is not there.""" + found, index = _search(key, dict_) + return Just(dict_.entries[index][1]) if found else Nothing() + + +def size[K: Comparable, V](dict_: Dict[K, V]) -> int: + """Return the number of entries in a dictionary.""" + return len(dict_.entries) + + +# Lists + + +def keys[K: Comparable, V](dict_: Dict[K, V]) -> tuple[K, ...]: + """Return the keys, from lowest to highest.""" + return tuple(key for key, _ in dict_.entries) + + +def values[K: Comparable, V](dict_: Dict[K, V]) -> tuple[V, ...]: + """Return the values, in the order of their keys.""" + return tuple(value for _, value in dict_.entries) + + +def to_list[K: Comparable, V](dict_: Dict[K, V]) -> tuple[tuple[K, V], ...]: + """Return the `(key, value)` pairs, sorted by key.""" + return dict_.entries + + +def from_list[K: Comparable, V](pairs: Sequence[tuple[K, V]]) -> Dict[K, V]: + """Create a dictionary from `(key, value)` pairs. + + When a key occurs more than once, the last pair wins, as in Elm. + + Raises: + TypeError: If the keys are not comparable with each other. + """ + key_of = _compare.sort_key(_compare.compare) + entries: list[tuple[K, V]] = [] + # The sort is stable, so the last pair for a key is the last one in its run. + for pair in sorted(pairs, key=lambda pair: key_of(pair[0])): + if entries and _compare.compare(entries[-1][0], pair[0]) is Order.EQ: + entries[-1] = pair + else: + entries.append(pair) + return Dict(tuple(entries)) + + +# Transform + + +def map[K: Comparable, A, B](f: Callable[[K, A], B], dict_: Dict[K, A]) -> Dict[K, B]: + """Apply a function to every value. The function gets the key first.""" + return Dict(tuple((key, f(key, value)) for key, value in dict_.entries)) + + +def foldl[K: Comparable, V, R]( + f: Callable[[K, V, R], R], initial: R, dict_: Dict[K, V] +) -> R: + """Reduce the entries from the lowest key to the highest.""" + accumulator = initial + for key, value in dict_.entries: + accumulator = f(key, value, accumulator) + return accumulator + + +def foldr[K: Comparable, V, R]( + f: Callable[[K, V, R], R], initial: R, dict_: Dict[K, V] +) -> R: + """Reduce the entries from the highest key to the lowest.""" + accumulator = initial + for key, value in reversed(dict_.entries): + accumulator = f(key, value, accumulator) + return accumulator + + +def filter[K: Comparable, V]( + predicate: Callable[[K, V], bool], dict_: Dict[K, V] +) -> Dict[K, V]: + """Keep the entries that pass the test.""" + return Dict(tuple(entry for entry in dict_.entries if predicate(*entry))) + + +def partition[K: Comparable, V]( + predicate: Callable[[K, V], bool], dict_: Dict[K, V] +) -> tuple[Dict[K, V], Dict[K, V]]: + """Split a dictionary into the entries that pass the test and those that fail.""" + passed: list[tuple[K, V]] = [] + failed: list[tuple[K, V]] = [] + for entry in dict_.entries: + (passed if predicate(*entry) else failed).append(entry) + return (Dict(tuple(passed)), Dict(tuple(failed))) + + +# Combine + + +def _walk[K: Comparable, A, B]( + left: Dict[K, A], right: Dict[K, B] +) -> Iterator[tuple[K, Maybe[A], Maybe[B]]]: + """Walk two dictionaries in one pass, in ascending key order. + + Each step yields the key with `Just` the value from each side that has it. + """ + lefts = left.entries + rights = right.entries + i = 0 + j = 0 + while i < len(lefts) and j < len(rights): + order = _compare.compare(lefts[i][0], rights[j][0]) + if order is Order.LT: + yield (lefts[i][0], Just(lefts[i][1]), Nothing()) + i += 1 + elif order is Order.GT: + yield (rights[j][0], Nothing(), Just(rights[j][1])) + j += 1 + else: + yield (lefts[i][0], Just(lefts[i][1]), Just(rights[j][1])) + i += 1 + j += 1 + for key, a in lefts[i:]: + yield (key, Just(a), Nothing()) + for key, b in rights[j:]: + yield (key, Nothing(), Just(b)) + + +def union[K: Comparable, V](left: Dict[K, V], right: Dict[K, V]) -> Dict[K, V]: + """Combine two dictionaries. For a key in both, the first dictionary wins.""" + entries: list[tuple[K, V]] = [] + for key, a, b in _walk(left, right): + if isinstance(a, Just): + entries.append((key, a.value)) + elif isinstance(b, Just): + entries.append((key, b.value)) + return Dict(tuple(entries)) + + +def intersect[K: Comparable, V, W](left: Dict[K, V], right: Dict[K, W]) -> Dict[K, V]: + """Keep the entries of the first dictionary whose keys are in the second.""" + return Dict( + tuple( + (key, a.value) + for key, a, b in _walk(left, right) + if isinstance(a, Just) and isinstance(b, Just) + ) + ) + + +def diff[K: Comparable, V, W](left: Dict[K, V], right: Dict[K, W]) -> Dict[K, V]: + """Keep the entries of the first dictionary whose keys are not in the second.""" + return Dict( + tuple( + (key, a.value) + for key, a, b in _walk(left, right) + if isinstance(a, Just) and isinstance(b, Nothing) + ) + ) + + +def merge[K: Comparable, A, B, R]( + left_step: Callable[[K, A, R], R], + both_step: Callable[[K, A, B, R], R], + right_step: Callable[[K, B, R], R], + left: Dict[K, A], + right: Dict[K, B], + initial: R, +) -> R: + """Combine two dictionaries in the most general way. + + The keys are visited from lowest to highest. `left_step` gets the keys that + are only in the left dictionary, `both_step` the keys in both, and + `right_step` the keys that are only in the right dictionary. + """ + accumulator = initial + for key, a, b in _walk(left, right): + if isinstance(a, Just) and isinstance(b, Just): + accumulator = both_step(key, a.value, b.value, accumulator) + elif isinstance(a, Just): + accumulator = left_step(key, a.value, accumulator) + elif isinstance(b, Just): + accumulator = right_step(key, b.value, accumulator) + return accumulator diff --git a/packages/morphir/src/morphir/sdk/int.py b/packages/morphir/src/morphir/sdk/int.py new file mode 100644 index 00000000..fa0434eb --- /dev/null +++ b/packages/morphir/src/morphir/sdk/int.py @@ -0,0 +1,86 @@ +"""Morphir.SDK.Int: fixed-width integers. + +`Int8`, `Int16`, `Int32` and `Int64` are `NewType` wrappers over `int`. They +have no run-time cost and a type checker keeps them apart. `to_int8` and the +other `to_` functions check the range; `from_int8` and the other `from_` +functions give the plain `int` back. + +Python integers have no size limit, so `Int64` covers the full signed 64-bit +range. +""" + +from typing import NewType + +from morphir.sdk.maybe import Just, Maybe, Nothing + +__all__ = [ + "Int8", + "Int16", + "Int32", + "Int64", + "from_int8", + "from_int16", + "from_int32", + "from_int64", + "to_int8", + "to_int16", + "to_int32", + "to_int64", +] + +Int8 = NewType("Int8", int) +"""A signed 8-bit integer.""" + +Int16 = NewType("Int16", int) +"""A signed 16-bit integer.""" + +Int32 = NewType("Int32", int) +"""A signed 32-bit integer.""" + +Int64 = NewType("Int64", int) +"""A signed 64-bit integer.""" + + +def _fits(bits: int, n: int) -> bool: + limit = 1 << (bits - 1) + return isinstance(n, int) and not isinstance(n, bool) and -limit <= n < limit + + +def from_int8(n: Int8) -> int: + """Convert an `Int8` to an `int`.""" + return int(n) + + +def to_int8(n: int) -> Maybe[Int8]: + """Convert an `int` to an `Int8`, or `Nothing` when it is out of range.""" + return Just(Int8(n)) if _fits(8, n) else Nothing() + + +def from_int16(n: Int16) -> int: + """Convert an `Int16` to an `int`.""" + return int(n) + + +def to_int16(n: int) -> Maybe[Int16]: + """Convert an `int` to an `Int16`, or `Nothing` when it is out of range.""" + return Just(Int16(n)) if _fits(16, n) else Nothing() + + +def from_int32(n: Int32) -> int: + """Convert an `Int32` to an `int`.""" + return int(n) + + +def to_int32(n: int) -> Maybe[Int32]: + """Convert an `int` to an `Int32`, or `Nothing` when it is out of range.""" + return Just(Int32(n)) if _fits(32, n) else Nothing() + + +def from_int64(n: Int64) -> int: + """Convert an `Int64` to an `int`.""" + return int(n) + + +def to_int64(n: int) -> Maybe[Int64]: + """Convert an `int` to an `Int64`, or `Nothing` when it is out of range.""" + return Just(Int64(n)) if _fits(64, n) else Nothing() diff --git a/packages/morphir/src/morphir/sdk/list.py b/packages/morphir/src/morphir/sdk/list.py new file mode 100644 index 00000000..0ada2263 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/list.py @@ -0,0 +1,356 @@ +"""Morphir.SDK.List: elm/core's List module plus the Morphir joins. + +An Elm `List a` is an immutable Python `tuple[A, ...]`. Functions accept any +`Sequence` as input and always yield a tuple; no function mutates its input. +All functions take their arguments in Elm order, with the list last. + +Many names in this module shadow Python built-ins (`all`, `any`, `filter`, +`map`, `range`, `sum`). Import the module with an alias, not the names: + + >>> from morphir.sdk import list as List + >>> List.map(lambda n: n * 2, (1, 2, 3)) + (2, 4, 6) +""" + +import builtins +import math +from typing import TYPE_CHECKING + +from morphir.sdk import _compare +from morphir.sdk._compare import Comparable, Order +from morphir.sdk.maybe import Just, Maybe, Nothing + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + +__all__ = [ + "List", + "all", + "any", + "append", + "concat", + "concat_map", + "cons", + "drop", + "filter", + "filter_map", + "foldl", + "foldr", + "head", + "indexed_map", + "inner_join", + "intersperse", + "is_empty", + "left_join", + "length", + "map", + "map2", + "map3", + "map4", + "map5", + "maximum", + "member", + "minimum", + "partition", + "product", + "range", + "repeat", + "reverse", + "singleton", + "sort", + "sort_by", + "sort_with", + "sum", + "tail", + "take", + "unzip", +] + +type List[A] = tuple[A, ...] +"""An immutable list.""" + + +# Create + + +def singleton[A](a: A) -> List[A]: + """Create a list with one element.""" + return (a,) + + +def repeat[A](n: int, a: A) -> List[A]: + """Create a list with `n` copies of a value. A count below 1 yields `()`.""" + return (a,) * n if n > 0 else () + + +def range(low: int, high: int) -> List[int]: + """Create the list of integers from `low` to `high`, both included.""" + return tuple(builtins.range(low, high + 1)) + + +def cons[A](head: A, tail: Sequence[A]) -> List[A]: + """Add an element to the front of a list (Elm `::`).""" + return (head, *tail) + + +# Transform + + +def map[A, B](f: Callable[[A], B], xs: Sequence[A]) -> List[B]: + """Apply a function to every element of a list.""" + return tuple(f(x) for x in xs) + + +def indexed_map[A, B](f: Callable[[int, A], B], xs: Sequence[A]) -> List[B]: + """Apply a function to every element and its index, which starts at 0.""" + return tuple(f(index, x) for index, x in enumerate(xs)) + + +def foldl[A, B](f: Callable[[A, B], B], initial: B, xs: Sequence[A]) -> B: + """Reduce a list from the left. The function gets the element first.""" + accumulator = initial + for x in xs: + accumulator = f(x, accumulator) + return accumulator + + +def foldr[A, B](f: Callable[[A, B], B], initial: B, xs: Sequence[A]) -> B: + """Reduce a list from the right. The function gets the element first.""" + accumulator = initial + for x in reversed(xs): + accumulator = f(x, accumulator) + return accumulator + + +def filter[A](predicate: Callable[[A], bool], xs: Sequence[A]) -> List[A]: + """Keep the elements that pass the test.""" + return tuple(x for x in xs if predicate(x)) + + +def filter_map[A, B](f: Callable[[A], Maybe[B]], xs: Sequence[A]) -> List[B]: + """Apply a function to every element and keep the `Just` values.""" + return tuple(m.value for m in (f(x) for x in xs) if isinstance(m, Just)) + + +# Utilities + + +def length[A](xs: Sequence[A]) -> int: + """Return the number of elements in a list.""" + return len(xs) + + +def reverse[A](xs: Sequence[A]) -> List[A]: + """Reverse a list.""" + return tuple(reversed(xs)) + + +def member[A](a: A, xs: Sequence[A]) -> bool: + """Check that a list contains a value, by structural equality.""" + return next((True for x in xs if _compare.equal(a, x)), False) + + +def all[A](predicate: Callable[[A], bool], xs: Sequence[A]) -> bool: + """Check that every element passes the test.""" + return next((False for x in xs if not predicate(x)), True) + + +def any[A](predicate: Callable[[A], bool], xs: Sequence[A]) -> bool: + """Check that at least one element passes the test.""" + return next((True for x in xs if predicate(x)), False) + + +def _extreme[A: Comparable](wanted: Order, xs: Sequence[A]) -> Maybe[A]: + if not xs: + return Nothing() + best = xs[0] + for x in xs[1:]: + if _compare.compare(x, best) is wanted: + best = x + return Just(best) + + +def maximum[A: Comparable](xs: Sequence[A]) -> Maybe[A]: + """Return the largest element, or `Nothing` for an empty list.""" + return _extreme(Order.GT, xs) + + +def minimum[A: Comparable](xs: Sequence[A]) -> Maybe[A]: + """Return the smallest element, or `Nothing` for an empty list.""" + return _extreme(Order.LT, xs) + + +def sum[N: (int, float)](xs: Sequence[N]) -> N | int: + """Return the sum of the elements. The sum of an empty list is 0.""" + return builtins.sum(xs) + + +def product[N: (int, float)](xs: Sequence[N]) -> N | int: + """Return the product of the elements. The product of an empty list is 1.""" + return math.prod(xs) + + +# Combine + + +def append[A](xs: Sequence[A], ys: Sequence[A]) -> List[A]: + """Put two lists together.""" + return (*xs, *ys) + + +def concat[A](lists: Sequence[Sequence[A]]) -> List[A]: + """Concatenate many lists into one.""" + return tuple(x for xs in lists for x in xs) + + +def concat_map[A, B](f: Callable[[A], Sequence[B]], xs: Sequence[A]) -> List[B]: + """Apply a function to every element and concatenate the lists it yields.""" + return tuple(y for x in xs for y in f(x)) + + +def intersperse[A](separator: A, xs: Sequence[A]) -> List[A]: + """Put a value between all the elements of a list.""" + result: list[A] = [] + for x in xs: + if result: + result.append(separator) + result.append(x) + return tuple(result) + + +def map2[A, B, R](f: Callable[[A, B], R], xs: Sequence[A], ys: Sequence[B]) -> List[R]: + """Combine two lists element by element; stop at the end of the shortest.""" + return tuple(f(*args) for args in zip(xs, ys, strict=False)) + + +def map3[A, B, C, R]( + f: Callable[[A, B, C], R], xs: Sequence[A], ys: Sequence[B], zs: Sequence[C] +) -> List[R]: + """Combine three lists element by element; stop at the end of the shortest.""" + return tuple(f(*args) for args in zip(xs, ys, zs, strict=False)) + + +def map4[A, B, C, D, R]( + f: Callable[[A, B, C, D], R], + ws: Sequence[A], + xs: Sequence[B], + ys: Sequence[C], + zs: Sequence[D], +) -> List[R]: + """Combine four lists element by element; stop at the end of the shortest.""" + return tuple(f(*args) for args in zip(ws, xs, ys, zs, strict=False)) + + +def map5[A, B, C, D, E, R]( + f: Callable[[A, B, C, D, E], R], + vs: Sequence[A], + ws: Sequence[B], + xs: Sequence[C], + ys: Sequence[D], + zs: Sequence[E], +) -> List[R]: + """Combine five lists element by element; stop at the end of the shortest.""" + return tuple(f(*args) for args in zip(vs, ws, xs, ys, zs, strict=False)) + + +# Sort + + +def sort[A: Comparable](xs: Sequence[A]) -> List[A]: + """Sort a list of comparable values from lowest to highest. + + Raises: + TypeError: If the elements are not comparable with each other. + """ + return tuple(sorted(xs, key=_compare.sort_key(_compare.compare))) + + +def sort_by[A, K: Comparable](f: Callable[[A], K], xs: Sequence[A]) -> List[A]: + """Sort a list by a derived comparable key. The sort is stable. + + Raises: + TypeError: If the keys are not comparable with each other. + """ + key = _compare.sort_key(_compare.compare) + return tuple(sorted(xs, key=lambda x: key(f(x)))) + + +def sort_with[A](f: Callable[[A, A], Order], xs: Sequence[A]) -> List[A]: + """Sort a list with a custom comparison function. The sort is stable.""" + return tuple(sorted(xs, key=_compare.sort_key(f))) + + +# Deconstruct + + +def is_empty[A](xs: Sequence[A]) -> bool: + """Check that a list is empty.""" + return len(xs) == 0 + + +def head[A](xs: Sequence[A]) -> Maybe[A]: + """Return the first element, or `Nothing` for an empty list.""" + return Just(xs[0]) if xs else Nothing() + + +def tail[A](xs: Sequence[A]) -> Maybe[List[A]]: + """Return all elements but the first, or `Nothing` for an empty list.""" + return Just(tuple(xs[1:])) if xs else Nothing() + + +def take[A](n: int, xs: Sequence[A]) -> List[A]: + """Take the first `n` elements of a list.""" + return tuple(xs[:n]) if n > 0 else () + + +def drop[A](n: int, xs: Sequence[A]) -> List[A]: + """Drop the first `n` elements of a list.""" + return tuple(xs[n:]) if n > 0 else tuple(xs) + + +def partition[A]( + predicate: Callable[[A], bool], xs: Sequence[A] +) -> tuple[List[A], List[A]]: + """Split a list into the elements that pass the test and those that fail.""" + passed: list[A] = [] + failed: list[A] = [] + for x in xs: + (passed if predicate(x) else failed).append(x) + return (tuple(passed), tuple(failed)) + + +def unzip[A, B](pairs: Sequence[tuple[A, B]]) -> tuple[List[A], List[B]]: + """Split a list of pairs into a pair of lists.""" + return (tuple(a for a, _ in pairs), tuple(b for _, b in pairs)) + + +# Morphir additions to elm/core + + +def inner_join[A, B]( + list_b: Sequence[B], on: Callable[[A, B], bool], list_a: Sequence[A] +) -> List[tuple[A, B]]: + """Join two lists like an SQL inner join. + + The result keeps the order of `list_a` and has one pair for each element of + `list_b` that matches. + """ + return tuple((a, b) for a in list_a for b in list_b if on(a, b)) + + +def left_join[A, B]( + list_b: Sequence[B], on: Callable[[A, B], bool], list_a: Sequence[A] +) -> List[tuple[A, Maybe[B]]]: + """Join two lists like an SQL left outer join. + + The result keeps the order of `list_a`. An element with no match in `list_b` + yields one pair with `Nothing`. + """ + result: list[tuple[A, Maybe[B]]] = [] + for a in list_a: + matches = [b for b in list_b if on(a, b)] + if matches: + result.extend((a, Just(b)) for b in matches) + else: + result.append((a, Nothing())) + return tuple(result) diff --git a/packages/morphir/src/morphir/sdk/maybe.py b/packages/morphir/src/morphir/sdk/maybe.py new file mode 100644 index 00000000..89f917a2 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/maybe.py @@ -0,0 +1,135 @@ +"""Morphir.SDK.Maybe: a value that may or may not exist. + +`Maybe[A]` is the union of two frozen dataclasses, `Just[A]` and `Nothing`. Use +structural pattern matching to take a `Maybe` apart: + + >>> from morphir.sdk.maybe import Just, Maybe, Nothing + >>> def describe(m: Maybe[int]) -> str: + ... match m: + ... case Just(value): + ... return f"just {value}" + ... case Nothing(): + ... return "nothing" + >>> describe(Just(1)) + 'just 1' + +All functions take their arguments in Elm order, with the `Maybe` last. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = [ + "Just", + "Maybe", + "Nothing", + "and_then", + "has_value", + "map", + "map2", + "map3", + "map4", + "map5", + "with_default", +] + + +@dataclass(frozen=True, slots=True) +class Just[A]: + """A `Maybe` that holds a value.""" + + value: A + + +@dataclass(frozen=True, slots=True) +class Nothing: + """A `Maybe` that holds no value. All instances are equal.""" + + +type Maybe[A] = Just[A] | Nothing +"""Either `Just[A]` or `Nothing`.""" + + +def with_default[A](default: A, maybe: Maybe[A]) -> A: + """Return the value of a `Just`, or the default for `Nothing`.""" + match maybe: + case Just(value): + return value + case Nothing(): + return default + + +def has_value[A](maybe: Maybe[A]) -> bool: + """Check that a `Maybe` is a `Just`. This is a Morphir addition to elm/core.""" + return isinstance(maybe, Just) + + +def map[A, B](f: Callable[[A], B], maybe: Maybe[A]) -> Maybe[B]: + """Apply a function to the value of a `Just`.""" + if isinstance(maybe, Just): + return Just(f(maybe.value)) + return Nothing() + + +def map2[A, B, R](f: Callable[[A, B], R], ma: Maybe[A], mb: Maybe[B]) -> Maybe[R]: + """Apply a function when both arguments are `Just`.""" + if isinstance(ma, Just) and isinstance(mb, Just): + return Just(f(ma.value, mb.value)) + return Nothing() + + +def map3[A, B, C, R]( + f: Callable[[A, B, C], R], ma: Maybe[A], mb: Maybe[B], mc: Maybe[C] +) -> Maybe[R]: + """Apply a function when all three arguments are `Just`.""" + if isinstance(ma, Just) and isinstance(mb, Just) and isinstance(mc, Just): + return Just(f(ma.value, mb.value, mc.value)) + return Nothing() + + +def map4[A, B, C, D, R]( + f: Callable[[A, B, C, D], R], + ma: Maybe[A], + mb: Maybe[B], + mc: Maybe[C], + md: Maybe[D], +) -> Maybe[R]: + """Apply a function when all four arguments are `Just`.""" + if ( + isinstance(ma, Just) + and isinstance(mb, Just) + and isinstance(mc, Just) + and isinstance(md, Just) + ): + return Just(f(ma.value, mb.value, mc.value, md.value)) + return Nothing() + + +def map5[A, B, C, D, E, R]( + f: Callable[[A, B, C, D, E], R], + ma: Maybe[A], + mb: Maybe[B], + mc: Maybe[C], + md: Maybe[D], + me: Maybe[E], +) -> Maybe[R]: + """Apply a function when all five arguments are `Just`.""" + if ( + isinstance(ma, Just) + and isinstance(mb, Just) + and isinstance(mc, Just) + and isinstance(md, Just) + and isinstance(me, Just) + ): + return Just(f(ma.value, mb.value, mc.value, md.value, me.value)) + return Nothing() + + +def and_then[A, B](f: Callable[[A], Maybe[B]], maybe: Maybe[A]) -> Maybe[B]: + """Chain a computation that can fail onto a `Maybe`.""" + if isinstance(maybe, Just): + return f(maybe.value) + return Nothing() diff --git a/packages/morphir/src/morphir/sdk/number.py b/packages/morphir/src/morphir/sdk/number.py new file mode 100644 index 00000000..5554eaf5 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/number.py @@ -0,0 +1,237 @@ +"""Morphir.SDK.Number: exact rational numbers. + +A `Number` is a frozen dataclass with an integer `numerator` and `denominator`. +Python integers have no size limit, so all arithmetic is exact. + +As in the Elm runtime, arithmetic does not reduce its result: +`add(1/2, 1/2)` is `4/4`. Use `simplify` to reduce a number. Because of this, +`==` on two `Number` values is structural (`Number(1, 2) != Number(2, 4)`); use +`equal` to compare by value. + +The name `abs` in this module shadows a Python built-in. Import the module with +an alias, not the names: + + >>> from morphir.sdk import number as Number + >>> Number.to_fractional_string(Number.add(Number.from_int(1), Number.one)) + '2/1' + +Departures from the Elm runtime: + +* Comparisons move a negative sign out of the denominator before they + cross-multiply. The Elm runtime does not, so it orders `3/-4` and `1/2` + wrongly. +* `simplify` yields a positive denominator: `3/-6` becomes `-1/2`. The Elm + runtime yields `1/-2`. +""" + +import builtins +import math +from dataclasses import dataclass + +from morphir.sdk import decimal as _sdk_decimal +from morphir.sdk import maybe +from morphir.sdk.maybe import Just, Maybe, Nothing +from morphir.sdk.result import Err, Ok, Result + +__all__ = [ + "DivisionByZero", + "Number", + "abs", + "add", + "coerce_to_decimal", + "divide", + "equal", + "from_int", + "greater_than", + "greater_than_or_equal", + "is_simplified", + "less_than", + "less_than_or_equal", + "multiply", + "negate", + "not_equal", + "one", + "reciprocal", + "simplify", + "subtract", + "to_decimal", + "to_fractional_string", + "zero", +] + + +@dataclass(frozen=True, slots=True) +class Number: + """A rational number. The fraction is not reduced.""" + + numerator: int + denominator: int + + +@dataclass(frozen=True, slots=True) +class DivisionByZero: + """The error that `divide` yields for a zero divisor.""" + + +zero: Number = Number(0, 1) +"""The number 0, as 0/1.""" + +one: Number = Number(1, 1) +"""The number 1, as 1/1.""" + + +def from_int(n: int) -> Number: + """Convert an integer to a `Number` with a denominator of 1.""" + return Number(n, 1) + + +# Convert to + + +def to_decimal(n: Number) -> Maybe[_sdk_decimal.Decimal]: + """Convert to a `Decimal`, or `Nothing` when the denominator is zero. + + A quotient that is not exact has 50 significant digits. + """ + return _sdk_decimal.div( + _sdk_decimal.from_int(n.numerator), _sdk_decimal.from_int(n.denominator) + ) + + +def coerce_to_decimal(default: _sdk_decimal.Decimal, n: Number) -> _sdk_decimal.Decimal: + """Convert to a `Decimal`, or yield the default when the denominator is zero.""" + return maybe.with_default(default, to_decimal(n)) + + +def to_fractional_string(n: Number) -> str: + """Convert to a string of the form "numerator/denominator".""" + return f"{n.numerator}/{n.denominator}" + + +# Comparison + + +def _cross_products(a: Number, b: Number) -> tuple[int, int]: + """Return the two integers whose order is the order of `a` and `b`. + + A negative denominator changes the direction of the comparison, so the sign + moves to the numerator first. + """ + sign = (-1 if a.denominator < 0 else 1) * (-1 if b.denominator < 0 else 1) + return ( + sign * a.numerator * b.denominator, + sign * b.numerator * a.denominator, + ) + + +def equal(a: Number, b: Number) -> bool: + """Check `a == b` by value, so 1/2 equals 2/4.""" + x, y = _cross_products(a, b) + return x == y + + +def not_equal(a: Number, b: Number) -> bool: + """Check `a /= b` by value.""" + return not equal(a, b) + + +def less_than(a: Number, b: Number) -> bool: + """Check `a < b`.""" + x, y = _cross_products(a, b) + return x < y + + +def less_than_or_equal(a: Number, b: Number) -> bool: + """Check `a <= b`.""" + x, y = _cross_products(a, b) + return x <= y + + +def greater_than(a: Number, b: Number) -> bool: + """Check `a > b`.""" + x, y = _cross_products(a, b) + return x > y + + +def greater_than_or_equal(a: Number, b: Number) -> bool: + """Check `a >= b`.""" + x, y = _cross_products(a, b) + return x >= y + + +# Arithmetic + + +def _is_zero(n: Number) -> bool: + return n.numerator == 0 + + +def negate(n: Number) -> Number: + """Change the sign of a number.""" + return Number(-n.numerator, n.denominator) + + +def abs(n: Number) -> Number: + """Return the absolute value of a number.""" + return Number(builtins.abs(n.numerator), builtins.abs(n.denominator)) + + +def reciprocal(n: Number) -> Number: + """Swap the numerator and the denominator. Zero stays as it is, as in Elm.""" + return n if _is_zero(n) else Number(n.denominator, n.numerator) + + +def add(a: Number, b: Number) -> Number: + """Add two numbers. The result is not reduced.""" + return Number( + a.numerator * b.denominator + a.denominator * b.numerator, + a.denominator * b.denominator, + ) + + +def subtract(a: Number, b: Number) -> Number: + """Subtract the second number from the first. The result is not reduced.""" + return Number( + a.numerator * b.denominator - a.denominator * b.numerator, + a.denominator * b.denominator, + ) + + +def multiply(a: Number, b: Number) -> Number: + """Multiply two numbers. The result is not reduced.""" + return Number(a.numerator * b.numerator, a.denominator * b.denominator) + + +def divide(a: Number, b: Number) -> Result[DivisionByZero, Number]: + """Divide two numbers, or yield `Err(DivisionByZero())` for a zero divisor. + + The result is not reduced. + """ + if _is_zero(b): + return Err(DivisionByZero()) + return Ok(Number(a.numerator * b.denominator, a.denominator * b.numerator)) + + +# Simplify + + +def simplify(n: Number) -> Maybe[Number]: + """Reduce a number to lowest terms, with a positive denominator. + + Yields `Nothing` when the denominator is zero. + """ + if n.denominator == 0: + return Nothing() + factor = math.gcd(n.numerator, n.denominator) + if n.denominator < 0: + factor = -factor + return Just(Number(n.numerator // factor, n.denominator // factor)) + + +def is_simplified(n: Number) -> bool: + """Check that `simplify` does not change a number. + + A number with a zero denominator counts as simplified, as in the Elm + runtime. + """ + return maybe.with_default(n, simplify(n)) == n diff --git a/packages/morphir/src/morphir/sdk/result.py b/packages/morphir/src/morphir/sdk/result.py new file mode 100644 index 00000000..8e9f08cd --- /dev/null +++ b/packages/morphir/src/morphir/sdk/result.py @@ -0,0 +1,162 @@ +"""Morphir.SDK.Result: a computation that may fail. + +`Result[E, A]` is the union of two frozen dataclasses, `Ok[A]` and `Err[E]`. +The type parameter order is Elm's: the error type first, then the value type. +All functions take their arguments in Elm order, with the `Result` last. +""" + +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__ = [ + "Err", + "Ok", + "Result", + "and_then", + "from_maybe", + "map", + "map2", + "map3", + "map4", + "map5", + "map_error", + "to_maybe", + "with_default", +] + + +@dataclass(frozen=True, slots=True) +class Ok[A]: + """A `Result` that holds a success value.""" + + value: A + + +@dataclass(frozen=True, slots=True) +class Err[E]: + """A `Result` that holds an error.""" + + error: E + + +type Result[E, A] = Ok[A] | Err[E] +"""Either `Ok[A]` or `Err[E]`.""" + + +def with_default[E, A](default: A, result: Result[E, A]) -> A: + """Return the value of an `Ok`, or the default for an `Err`.""" + match result: + case Ok(value): + return value + case Err(): + return default + + +def map[E, A, B](f: Callable[[A], B], result: Result[E, A]) -> Result[E, B]: + """Apply a function to the value of an `Ok`.""" + if isinstance(result, Ok): + return Ok(f(result.value)) + return result + + +def map2[E, A, B, R]( + f: Callable[[A, B], R], ra: Result[E, A], rb: Result[E, B] +) -> Result[E, R]: + """Apply a function when both arguments are `Ok`; else yield the first `Err`.""" + if isinstance(ra, Err): + return ra + if isinstance(rb, Err): + return rb + return Ok(f(ra.value, rb.value)) + + +def map3[E, A, B, C, R]( + f: Callable[[A, B, C], R], + ra: Result[E, A], + rb: Result[E, B], + rc: Result[E, C], +) -> Result[E, R]: + """Apply a function when all arguments are `Ok`; else yield the first `Err`.""" + if isinstance(ra, Err): + return ra + if isinstance(rb, Err): + return rb + if isinstance(rc, Err): + return rc + return Ok(f(ra.value, rb.value, rc.value)) + + +def map4[E, A, B, C, D, R]( + f: Callable[[A, B, C, D], R], + ra: Result[E, A], + rb: Result[E, B], + rc: Result[E, C], + rd: Result[E, D], +) -> Result[E, R]: + """Apply a function when all arguments are `Ok`; else yield the first `Err`.""" + if isinstance(ra, Err): + return ra + if isinstance(rb, Err): + return rb + if isinstance(rc, Err): + return rc + if isinstance(rd, Err): + return rd + return Ok(f(ra.value, rb.value, rc.value, rd.value)) + + +def map5[E, A, B, C, D, F, R]( + f: Callable[[A, B, C, D, F], R], + ra: Result[E, A], + rb: Result[E, B], + rc: Result[E, C], + rd: Result[E, D], + re: Result[E, F], +) -> Result[E, R]: + """Apply a function when all arguments are `Ok`; else yield the first `Err`.""" + if isinstance(ra, Err): + return ra + if isinstance(rb, Err): + return rb + if isinstance(rc, Err): + return rc + if isinstance(rd, Err): + return rd + if isinstance(re, Err): + return re + return Ok(f(ra.value, rb.value, rc.value, rd.value, re.value)) + + +def and_then[E, A, B]( + f: Callable[[A], Result[E, B]], result: Result[E, A] +) -> Result[E, B]: + """Chain a computation that can fail onto a `Result`.""" + if isinstance(result, Ok): + return f(result.value) + return result + + +def map_error[E, F, A](f: Callable[[E], F], result: Result[E, A]) -> Result[F, A]: + """Apply a function to the error of an `Err`.""" + if isinstance(result, Err): + return Err(f(result.error)) + return result + + +def to_maybe[E, A](result: Result[E, A]) -> Maybe[A]: + """Convert to a `Maybe`, which drops the error.""" + if isinstance(result, Ok): + return Just(result.value) + return Nothing() + + +def from_maybe[E, A](error: E, maybe: Maybe[A]) -> Result[E, A]: + """Convert from a `Maybe`, with the given error for `Nothing`.""" + if isinstance(maybe, Just): + return Ok(maybe.value) + return Err(error) diff --git a/packages/morphir/src/morphir/sdk/set.py b/packages/morphir/src/morphir/sdk/set.py new file mode 100644 index 00000000..2841ebbf --- /dev/null +++ b/packages/morphir/src/morphir/sdk/set.py @@ -0,0 +1,214 @@ +"""Morphir.SDK.Set: a collection of unique comparable values. + +This module follows elm/core 1.0.5. A `Set[A]` is a frozen dataclass that holds +its members as a tuple, sorted in ascending order by `basics.compare`, with no +member twice. Thus `to_list` yields the members in order, and two sets with the +same members are equal (`==`) and have the same hash, no matter in which order +the members were inserted. + +Members can be any comparable value: numbers, strings, and tuples or lists of +comparables. A member that is not comparable raises `TypeError`. + +Build a `Set` with `empty`, `singleton`, `from_list` and `insert`. Do not pass +members to the `Set` constructor, because it does not sort them. + +Some names in this module shadow Python built-ins (`filter`, `map`). Import the +module with an alias, not the names: + + >>> from morphir.sdk import set as Set + >>> Set.to_list(Set.from_list((3, 1, 3, 2))) + (1, 2, 3) +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from morphir.sdk import _compare +from morphir.sdk._compare import Comparable, Order + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + +__all__ = [ + "Set", + "diff", + "empty", + "filter", + "foldl", + "foldr", + "from_list", + "insert", + "intersect", + "is_empty", + "map", + "member", + "partition", + "remove", + "singleton", + "size", + "to_list", + "union", +] + + +@dataclass(frozen=True, slots=True) +class Set[A: Comparable]: + """An immutable set with its members sorted. + + Use `from_list`, `singleton` or `insert` to build one. The constructor + accepts only members that are already sorted with no member twice, and + stores every list inside a member as a tuple. + + Raises: + ValueError: If the members are not sorted, or a member repeats. + TypeError: If two members are not comparable with each other. + """ + + entries: tuple[A, ...] = () + + def __post_init__(self) -> None: + entries = tuple(_compare.freeze(member) for member in self.entries) + _compare.check_strictly_ascending(entries, "Set") + object.__setattr__(self, "entries", entries) + + +def _identity[A](value: A) -> A: + return value + + +def _search[A: Comparable](value: A, set_: Set[A]) -> tuple[bool, int]: + return _compare.search(value, set_.entries, _identity) + + +# Build + + +def empty[A: Comparable]() -> Set[A]: # pyright: ignore[reportInvalidTypeVarUse] + """Create an empty set.""" + return Set() + + +def singleton[A: Comparable](value: A) -> Set[A]: + """Create a set with one member.""" + return Set((value,)) + + +def insert[A: Comparable](value: A, set_: Set[A]) -> Set[A]: + """Insert a value into a set. + + Raises: + TypeError: If the value is not comparable with the members of the set. + """ + found, index = _search(value, set_) + if found: + return set_ + return Set((*set_.entries[:index], value, *set_.entries[index:])) + + +def remove[A: Comparable](value: A, set_: Set[A]) -> Set[A]: + """Remove a value from a set. A value that is not there changes nothing.""" + found, index = _search(value, set_) + if not found: + return set_ + return Set((*set_.entries[:index], *set_.entries[index + 1 :])) + + +# Query + + +def is_empty[A: Comparable](set_: Set[A]) -> bool: + """Check that a set is empty.""" + return not set_.entries + + +def member[A: Comparable](value: A, set_: Set[A]) -> bool: + """Check that a value is in a set.""" + return _search(value, set_)[0] + + +def size[A: Comparable](set_: Set[A]) -> int: + """Return the number of members in a set.""" + return len(set_.entries) + + +# Lists + + +def to_list[A: Comparable](set_: Set[A]) -> tuple[A, ...]: + """Return the members, from lowest to highest.""" + return set_.entries + + +def from_list[A: Comparable](values: Sequence[A]) -> Set[A]: + """Create a set from a list of values. + + Raises: + TypeError: If the values are not comparable with each other. + """ + entries: list[A] = [] + for value in sorted(values, key=_compare.sort_key(_compare.compare)): + if not entries or _compare.compare(entries[-1], value) is not Order.EQ: + entries.append(value) + return Set(tuple(entries)) + + +# Transform + + +def map[A: Comparable, B: Comparable](f: Callable[[A], B], set_: Set[A]) -> Set[B]: + """Apply a function to every member. + + The result is sorted again and has no duplicates, so it can be smaller than + the input. + """ + return from_list(tuple(f(value) for value in set_.entries)) + + +def foldl[A: Comparable, R](f: Callable[[A, R], R], initial: R, set_: Set[A]) -> R: + """Reduce the members from lowest to highest.""" + accumulator = initial + for value in set_.entries: + accumulator = f(value, accumulator) + return accumulator + + +def foldr[A: Comparable, R](f: Callable[[A, R], R], initial: R, set_: Set[A]) -> R: + """Reduce the members from highest to lowest.""" + accumulator = initial + for value in reversed(set_.entries): + accumulator = f(value, accumulator) + return accumulator + + +def filter[A: Comparable](predicate: Callable[[A], bool], set_: Set[A]) -> Set[A]: + """Keep the members that pass the test.""" + return Set(tuple(value for value in set_.entries if predicate(value))) + + +def partition[A: Comparable]( + predicate: Callable[[A], bool], set_: Set[A] +) -> tuple[Set[A], Set[A]]: + """Split a set into the members that pass the test and those that fail.""" + passed: list[A] = [] + failed: list[A] = [] + for value in set_.entries: + (passed if predicate(value) else failed).append(value) + return (Set(tuple(passed)), Set(tuple(failed))) + + +# Combine + + +def union[A: Comparable](left: Set[A], right: Set[A]) -> Set[A]: + """Return the values that are in one set or the other.""" + return from_list((*left.entries, *right.entries)) + + +def intersect[A: Comparable](left: Set[A], right: Set[A]) -> Set[A]: + """Return the values that are in both sets.""" + return Set(tuple(value for value in left.entries if member(value, right))) + + +def diff[A: Comparable](left: Set[A], right: Set[A]) -> Set[A]: + """Return the values of the first set that are not in the second.""" + return Set(tuple(value for value in left.entries if not member(value, right))) diff --git a/packages/morphir/src/morphir/sdk/string.py b/packages/morphir/src/morphir/sdk/string.py new file mode 100644 index 00000000..64a25e24 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/string.py @@ -0,0 +1,427 @@ +"""Morphir.SDK.String: Elm's String module. + +A `String` is a Python `str` and a `Char` is a `str` that holds one code point. +All functions take their arguments in Elm order, with the string last. Functions +that yield a list yield a tuple. + +Some names in this module shadow Python built-ins (`all`, `any`, `filter`, +`map`, `slice`). Import the module, not the names: + + >>> from morphir.sdk import string + >>> string.pad(5, ".", "11") + '..11.' + +Departure from Elm: a Python `str` is a sequence of code points, while an Elm +string is a sequence of UTF-16 code units. `length`, `slice`, `left`, `right`, +`drop_left`, `drop_right`, `indexes` and the `pad` functions count code points, +so a character above U+FFFF counts as 1 here and as 2 in Elm. Python never +splits such a character in two. +""" + +import math +import re +from decimal import Decimal +from typing import TYPE_CHECKING + +from morphir.sdk.maybe import Just, Maybe, Nothing + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from morphir.sdk.char import Char + +__all__ = [ + "all", + "any", + "append", + "concat", + "cons", + "contains", + "drop_left", + "drop_right", + "ends_with", + "filter", + "foldl", + "foldr", + "from_char", + "from_float", + "from_int", + "from_list", + "indexes", + "indices", + "is_empty", + "join", + "left", + "length", + "lines", + "map", + "pad", + "pad_left", + "pad_right", + "repeat", + "replace", + "reverse", + "right", + "slice", + "split", + "starts_with", + "to_float", + "to_int", + "to_list", + "to_lower", + "to_upper", + "trim", + "trim_left", + "trim_right", + "uncons", + "words", +] + +# The characters that JavaScript `trim` and the regex class `\s` treat as white +# space. Python's `str.strip` and `\s` use a different set. +_JS_SPACE = ( + "\t\n\v\f\r " + "\N{NO-BREAK SPACE}" + "\N{OGHAM SPACE MARK}" + "\N{EN QUAD}\N{EM QUAD}\N{EN SPACE}\N{EM SPACE}" + "\N{THREE-PER-EM SPACE}\N{FOUR-PER-EM SPACE}\N{SIX-PER-EM SPACE}" + "\N{FIGURE SPACE}\N{PUNCTUATION SPACE}\N{THIN SPACE}\N{HAIR SPACE}" + "\N{LINE SEPARATOR}\N{PARAGRAPH SEPARATOR}" + "\N{NARROW NO-BREAK SPACE}" + "\N{MEDIUM MATHEMATICAL SPACE}" + "\N{IDEOGRAPHIC SPACE}" + "\N{ZERO WIDTH NO-BREAK SPACE}" +) +_JS_SPACE_RUN = re.compile(f"[{re.escape(_JS_SPACE)}]+") +_LINE_BREAK = re.compile(r"\r\n|\r|\n") +_INT = re.compile(r"[+-]?[0-9]+") +_FLOAT_REJECT = re.compile(f"[{re.escape(_JS_SPACE)}xbo]") +_JS_NUMBER = re.compile(r"[+-]?(Infinity|([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?)") +# JavaScript prints a number in positional notation when its decimal exponent is +# in this range, and in exponent notation otherwise. +_JS_MIN_POSITIONAL_EXPONENT = -6 +_JS_MAX_POSITIONAL_EXPONENT = 21 + + +# Basics + + +def is_empty(s: str) -> bool: + """Check that a string is empty.""" + return s == "" + + +def length(s: str) -> int: + """Return the number of code points in a string.""" + return len(s) + + +def reverse(s: str) -> str: + """Reverse a string.""" + return s[::-1] + + +def repeat(n: int, s: str) -> str: + """Repeat a string `n` times. A count below 1 yields the empty string.""" + return s * n if n > 0 else "" + + +def replace(match: str, replacement: str, s: str) -> str: + """Replace every occurrence of `match`. + + An empty `match` puts the replacement between the characters, as in Elm. + """ + if match == "": + return replacement.join(s) + return s.replace(match, replacement) + + +# Building and splitting + + +def append(s1: str, s2: str) -> str: + """Append two strings.""" + return s1 + s2 + + +def concat(strings: Sequence[str]) -> str: + """Concatenate many strings into one.""" + return "".join(strings) + + +def split(separator: str, s: str) -> tuple[str, ...]: + """Split a string on a separator. + + An empty separator splits the string into its characters, as in Elm. + """ + if separator == "": + return tuple(s) + return tuple(s.split(separator)) + + +def join(separator: str, strings: Sequence[str]) -> str: + """Put many strings together with a separator.""" + return separator.join(strings) + + +def words(s: str) -> tuple[str, ...]: + """Break a string into words, split on runs of white space. + + As in elm/core, the string is trimmed first, so an empty or all-white-space + string yields `("",)`. + """ + return tuple(_JS_SPACE_RUN.split(trim(s))) + + +def lines(s: str) -> tuple[str, ...]: + r"""Break a string into lines, split on `\n`, `\r\n` and `\r`.""" + return tuple(_LINE_BREAK.split(s)) + + +# Substrings + + +def slice(start: int, end: int, s: str) -> str: + """Take a substring from `start` up to, but not including, `end`. + + A negative index counts from the end of the string, as in Elm. + """ + return s[start:end] + + +def left(n: int, s: str) -> str: + """Take `n` characters from the left side of a string.""" + return "" if n < 1 else s[:n] + + +def right(n: int, s: str) -> str: + """Take `n` characters from the right side of a string.""" + return "" if n < 1 else s[-n:] + + +def drop_left(n: int, s: str) -> str: + """Drop `n` characters from the left side of a string.""" + return s if n < 1 else s[n:] + + +def drop_right(n: int, s: str) -> str: + """Drop `n` characters from the right side of a string.""" + return s if n < 1 else s[:-n] + + +# Checks + + +def contains(sub: str, s: str) -> bool: + """Check that the second string contains the first one.""" + return sub in s + + +def starts_with(prefix: str, s: str) -> bool: + """Check that the second string starts with the first one.""" + return s.startswith(prefix) + + +def ends_with(suffix: str, s: str) -> bool: + """Check that the second string ends with the first one.""" + return s.endswith(suffix) + + +def indexes(sub: str, s: str) -> tuple[int, ...]: + """Return the start positions of a substring in a string. + + Matches do not overlap: `indexes("aa", "aaaa")` is `(0, 2)`, as in elm/core. + An empty substring yields no positions. + """ + if sub == "": + return () + found: list[int] = [] + index = s.find(sub) + while index > -1: + found.append(index) + index = s.find(sub, index + len(sub)) + return tuple(found) + + +indices = indexes +"""Alias of `indexes`.""" + + +# Int conversions + + +def to_int(s: str) -> Maybe[int]: + """Parse an integer. + + Elm accepts an optional sign and then ASCII digits only: no white space, no + decimal point, no exponent, no radix prefix and no underscores. + """ + return Just(int(s)) if _INT.fullmatch(s) else Nothing() + + +def from_int(n: int) -> str: + """Convert an integer to a string.""" + return str(n) + + +# Float conversions + + +def to_float(s: str) -> Maybe[float]: + """Parse a float, like elm/core. + + elm/core rejects the empty string and any string that contains white space + or one of the letters `x`, `b` and `o`. It then converts the string with + JavaScript number rules and rejects `NaN`. Thus ".5", "1." and "Infinity" + are accepted, and " 1", "0x10", "1_000", "inf" and "nan" are not. + """ + if s == "" or _FLOAT_REJECT.search(s) or not _JS_NUMBER.fullmatch(s): + return Nothing() + return Just(float(s)) + + +def from_float(f: float) -> str: + """Convert a float to a string, like Elm. + + Elm prints a float as JavaScript does: `1.0` prints as "1", `1e21` as + "1e+21" and `1.5e-7` as "1.5e-7". `NaN` and the infinities print as "NaN", + "Infinity" and "-Infinity". + """ + if math.isnan(f): + return "NaN" + if math.isinf(f): + return "Infinity" if f > 0 else "-Infinity" + if f == 0: + return "0" + # `repr` gives the shortest digits that round-trip, as JavaScript does. + sign, digit_tuple, exponent = Decimal(repr(f)).as_tuple() + digits = "".join(str(d) for d in digit_tuple).rstrip("0") + assert isinstance(exponent, int) + # The decimal point goes after `point` digits: 0.d1d2... * 10 ** point. + point = len(digit_tuple) + exponent + count = len(digits) + if count <= point <= _JS_MAX_POSITIONAL_EXPONENT: + body = digits + "0" * (point - count) + elif 0 < point <= _JS_MAX_POSITIONAL_EXPONENT: + body = f"{digits[:point]}.{digits[point:]}" + elif _JS_MIN_POSITIONAL_EXPONENT < point <= 0: + body = "0." + "0" * -point + digits + else: + mantissa = digits if count == 1 else f"{digits[0]}.{digits[1:]}" + body = f"{mantissa}e{'+' if point > 0 else '-'}{abs(point - 1)}" + return f"-{body}" if sign else body + + +# Char conversions + + +def from_char(c: Char) -> str: + """Create a string from a character.""" + return c + + +def cons(c: Char, s: str) -> str: + """Add a character to the start of a string.""" + return c + s + + +def uncons(s: str) -> Maybe[tuple[Char, str]]: + """Split a non-empty string into its first character and the remainder.""" + return Just((s[0], s[1:])) if s else Nothing() + + +def to_list(s: str) -> tuple[Char, ...]: + """Convert a string to a list of characters.""" + return tuple(s) + + +def from_list(chars: Sequence[Char]) -> str: + """Convert a list of characters to a string.""" + return "".join(chars) + + +# Formatting + + +def to_upper(s: str) -> str: + """Convert a string to upper case.""" + return s.upper() + + +def to_lower(s: str) -> str: + """Convert a string to lower case.""" + return s.lower() + + +def pad(n: int, c: Char, s: str) -> str: + """Pad a string on both sides until it has `n` characters. + + When the padding is odd, the extra character goes on the left, as in + elm/core: `pad(5, ".", "11")` is "..11.". + """ + total = n - len(s) + return repeat(total - total // 2, c) + s + repeat(total // 2, c) + + +def pad_left(n: int, c: Char, s: str) -> str: + """Pad a string on the left until it has `n` characters.""" + return repeat(n - len(s), c) + s + + +def pad_right(n: int, c: Char, s: str) -> str: + """Pad a string on the right until it has `n` characters.""" + return s + repeat(n - len(s), c) + + +def trim(s: str) -> str: + """Remove white space on both sides of a string.""" + return s.strip(_JS_SPACE) + + +def trim_left(s: str) -> str: + """Remove white space on the left of a string.""" + return s.lstrip(_JS_SPACE) + + +def trim_right(s: str) -> str: + """Remove white space on the right of a string.""" + return s.rstrip(_JS_SPACE) + + +# Higher-order functions + + +def map(f: Callable[[Char], Char], s: str) -> str: + """Transform every character in a string.""" + return "".join(f(c) for c in s) + + +def filter(predicate: Callable[[Char], bool], s: str) -> str: + """Keep only the characters that pass the test.""" + return "".join(c for c in s if predicate(c)) + + +def foldl[B](f: Callable[[Char, B], B], initial: B, s: str) -> B: + """Reduce a string from the left. The function gets the character first.""" + accumulator = initial + for c in s: + accumulator = f(c, accumulator) + return accumulator + + +def foldr[B](f: Callable[[Char, B], B], initial: B, s: str) -> B: + """Reduce a string from the right. The function gets the character first.""" + accumulator = initial + for c in reversed(s): + accumulator = f(c, accumulator) + return accumulator + + +def any(predicate: Callable[[Char], bool], s: str) -> bool: + """Check that at least one character passes the test.""" + return next((True for c in s if predicate(c)), False) + + +def all(predicate: Callable[[Char], bool], s: str) -> bool: + """Check that every character passes the test.""" + return next((False for c in s if not predicate(c)), True) diff --git a/packages/morphir/src/morphir/sdk/tuple.py b/packages/morphir/src/morphir/sdk/tuple.py new file mode 100644 index 00000000..05209f53 --- /dev/null +++ b/packages/morphir/src/morphir/sdk/tuple.py @@ -0,0 +1,43 @@ +"""Morphir.SDK.Tuple: helpers for pairs. + +An Elm tuple `( a, b )` is a Python `tuple[A, B]`. +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = ["first", "map_both", "map_first", "map_second", "pair", "second"] + + +def pair[A, B](a: A, b: B) -> tuple[A, B]: + """Create a pair.""" + return (a, b) + + +def first[A, B](pair: tuple[A, B]) -> A: + """Return the first value of a pair.""" + return pair[0] + + +def second[A, B](pair: tuple[A, B]) -> B: + """Return the second value of a pair.""" + return pair[1] + + +def map_first[A, B, X](f: Callable[[A], X], pair: tuple[A, B]) -> tuple[X, B]: + """Apply a function to the first value of a pair.""" + return (f(pair[0]), pair[1]) + + +def map_second[A, B, Y](f: Callable[[B], Y], pair: tuple[A, B]) -> tuple[A, Y]: + """Apply a function to the second value of a pair.""" + return (pair[0], f(pair[1])) + + +def map_both[A, B, X, Y]( + f: Callable[[A], X], g: Callable[[B], Y], pair: tuple[A, B] +) -> tuple[X, Y]: + """Apply one function to each value of a pair.""" + return (f(pair[0]), g(pair[1])) diff --git a/tests/unit/sdk/__init__.py b/tests/unit/sdk/__init__.py new file mode 100644 index 00000000..59ad8e3d --- /dev/null +++ b/tests/unit/sdk/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the morphir.sdk package.""" diff --git a/tests/unit/sdk/test_basics.py b/tests/unit/sdk/test_basics.py new file mode 100644 index 00000000..ecc9ea74 --- /dev/null +++ b/tests/unit/sdk/test_basics.py @@ -0,0 +1,239 @@ +"""Tests for morphir.sdk.basics (Morphir.SDK.Basics).""" + +import math + +import pytest + +from morphir.sdk import basics +from morphir.sdk.basics import Order +from morphir.sdk.maybe import Just, Nothing + + +class TestArithmetic: + def test_basic_operators(self) -> None: + assert basics.add(1, 2) == 3 + assert basics.subtract(5, 2) == 3 + assert basics.multiply(3, 4) == 12 + assert basics.divide(7, 2) == 3.5 + assert basics.power(2, 10) == 1024 + assert basics.negate(3) == -3 + assert basics.abs(-3) == 3 + assert basics.abs(-2.5) == 2.5 + + def test_divide_by_zero_follows_ieee(self) -> None: + assert basics.divide(1, 0) == math.inf + assert basics.divide(-1, 0) == -math.inf + assert basics.divide(1, -0.0) == -math.inf + assert math.isnan(basics.divide(0, 0)) + + def test_integer_divide_truncates_toward_zero(self) -> None: + assert basics.integer_divide(7, 2) == 3 + assert basics.integer_divide(-7, 2) == -3 + assert basics.integer_divide(7, -2) == -3 + assert basics.integer_divide(-7, -2) == 3 + assert basics.integer_divide(7, 0) == 0 + + def test_integer_divide_is_exact_for_big_ints(self) -> None: + big = 10**30 + 1 + assert basics.integer_divide(big, 1) == big + assert basics.integer_divide(-big, 2) == -(big // 2) + + def test_mod_by(self) -> None: + assert basics.mod_by(4, -1) == 3 + assert basics.mod_by(4, 5) == 1 + assert basics.mod_by(-4, 1) == -3 + assert basics.mod_by(-4, -1) == -1 + assert basics.mod_by(3, 9) == 0 + + def test_mod_by_zero_raises(self) -> None: + with pytest.raises(ZeroDivisionError): + basics.mod_by(0, 1) + + def test_remainder_by_takes_the_sign_of_the_dividend(self) -> None: + assert basics.remainder_by(4, -1) == -1 + assert basics.remainder_by(4, 5) == 1 + assert basics.remainder_by(-4, 5) == 1 + assert basics.remainder_by(-4, -5) == -1 + assert basics.remainder_by(3, 9) == 0 + + def test_remainder_by_zero_raises(self) -> None: + with pytest.raises(ZeroDivisionError): + basics.remainder_by(0, 1) + + +class TestRounding: + def test_round_goes_half_toward_positive_infinity(self) -> None: + assert basics.round(1.5) == 2 + assert basics.round(2.5) == 3 + assert basics.round(0.5) == 1 + assert basics.round(-0.5) == 0 + assert basics.round(-1.5) == -1 + assert basics.round(-2.5) == -2 + assert basics.round(2.4) == 2 + assert basics.round(-2.6) == -3 + + def test_round_does_not_lose_precision_near_a_half(self) -> None: + assert basics.round(0.49999999999999994) == 0 + + def test_round_returns_int(self) -> None: + assert isinstance(basics.round(1.5), int) + + def test_floor_ceiling_truncate(self) -> None: + assert basics.floor(-1.2) == -2 + assert basics.ceiling(1.2) == 2 + assert basics.truncate(-1.7) == -1 + assert basics.truncate(1.7) == 1 + + def test_to_float(self) -> None: + result = basics.to_float(3) + assert result == 3.0 + assert isinstance(result, float) + + +class TestComparison: + def test_compare_numbers(self) -> None: + assert basics.compare(1, 2) is Order.LT + assert basics.compare(2, 2) is Order.EQ + assert basics.compare(3, 2) is Order.GT + assert basics.compare(1, 1.5) is Order.LT + + def test_compare_strings(self) -> None: + assert basics.compare("a", "b") is Order.LT + assert basics.compare("B", "a") is Order.LT + assert basics.compare("abc", "ab") is Order.GT + + def test_compare_tuples_and_lists_lexicographically(self) -> None: + assert basics.compare((1, "b"), (1, "a")) is Order.GT + assert basics.compare((1, "a"), (2, "a")) is Order.LT + assert basics.compare((1, 2), (1, 2, 3)) is Order.LT + assert basics.compare((), ()) is Order.EQ + assert basics.compare([1, 2], [1, 3]) is Order.LT + assert basics.compare(((1, "x"), 2), ((1, "x"), 1)) is Order.GT + + def test_compare_rejects_values_that_are_not_comparable(self) -> None: + with pytest.raises(TypeError): + basics.compare(1, "a") # type: ignore[misc] + with pytest.raises(TypeError): + basics.compare({}, 1) # type: ignore[misc] + with pytest.raises(TypeError): + basics.compare(True, False) + with pytest.raises(TypeError): + basics.compare(Just(1), Nothing()) # type: ignore[misc] + with pytest.raises(TypeError): + basics.compare(len, len) # type: ignore[misc] + + def test_ordering_predicates(self) -> None: + assert basics.less_than(1, 2) + assert not basics.less_than(2, 2) + assert basics.greater_than("b", "a") + assert basics.less_than_or_equal((1, "a"), (1, "b")) + assert basics.greater_than_or_equal(2, 2) + assert not basics.greater_than_or_equal(1, 2) + + def test_max_min_clamp(self) -> None: + assert basics.max(1, 2) == 2 + assert basics.min("a", "b") == "a" + assert basics.clamp(0, 10, 15) == 10 + assert basics.clamp(0, 10, -5) == 0 + assert basics.clamp(0, 10, 5) == 5 + + +class TestEquality: + def test_equal_is_structural(self) -> None: + assert basics.equal((1, 2), (1, 2)) + assert basics.equal((1, (2, 3)), (1, (2, 3))) + assert basics.not_equal((1, 2), (1, 3)) + assert basics.equal(Just(1), Just(1)) + assert not basics.equal(Just(1), Nothing()) + assert basics.equal("a", "a") + assert not basics.equal(math.nan, math.nan) + + def test_equal_raises_on_functions(self) -> None: + with pytest.raises(TypeError): + basics.equal(len, len) + with pytest.raises(TypeError): + basics.not_equal((1, len), (1, len)) + + +class TestBooleans: + def test_operators(self) -> None: + assert basics.not_(True) is False + assert basics.and_(True, False) is False + assert basics.or_(True, False) is True + assert basics.xor(True, True) is False + assert basics.xor(True, False) is True + + +class TestAppend: + def test_strings(self) -> None: + assert basics.append("ab", "cd") == "abcd" + + def test_lists(self) -> None: + assert basics.append((1,), (2, 3)) == (1, 2, 3) + assert basics.append([1], [2, 3]) == (1, 2, 3) + + def test_mixed_kinds_raise(self) -> None: + with pytest.raises(TypeError): + basics.append("ab", (1,)) # type: ignore[call-overload] + + +class TestFloats: + def test_predicates(self) -> None: + assert basics.is_nan(math.nan) + assert not basics.is_nan(1.0) + assert basics.is_infinite(-math.inf) + assert not basics.is_infinite(1.0) + + def test_functions(self) -> None: + assert basics.sqrt(16) == 4 + assert basics.log_base(10, 1000) == pytest.approx(3) + assert basics.log_base(2, 256) == pytest.approx(8) + assert basics.e == math.e + assert basics.pi == math.pi + assert basics.cos(0) == 1 + assert basics.sin(0) == 0 + assert basics.tan(0) == 0 + assert basics.acos(1) == 0 + assert basics.asin(0) == 0 + assert basics.atan(0) == 0 + assert basics.atan2(1, 1) == pytest.approx(math.pi / 4) + + def test_out_of_domain_inputs_yield_nan_or_infinity(self) -> None: + assert math.isnan(basics.sqrt(-1)) + assert math.isnan(basics.acos(2)) + assert math.isnan(basics.asin(-2)) + assert basics.log_base(10, 0) == -math.inf + assert math.isnan(basics.log_base(10, -1)) + + def test_angles(self) -> None: + assert basics.degrees(180) == pytest.approx(math.pi) + assert basics.radians(math.pi) == math.pi + assert basics.turns(0.5) == pytest.approx(math.pi) + + def test_polar(self) -> None: + r, theta = basics.to_polar((3, 4)) + assert r == 5 + assert theta == pytest.approx(math.atan2(4, 3)) + x, y = basics.from_polar((5, math.atan2(4, 3))) + assert x == pytest.approx(3) + assert y == pytest.approx(4) + + +class TestFunctions: + def test_identity_and_always(self) -> None: + assert basics.identity(7) == 7 + assert basics.always(1, "ignored") == 1 + + def test_composition(self) -> None: + def inc(n: int) -> int: + return n + 1 + + def dbl(n: int) -> int: + return n * 2 + + assert basics.compose_left(inc, dbl)(3) == 7 + assert basics.compose_right(inc, dbl)(3) == 8 + + def test_never_raises(self) -> None: + with pytest.raises(TypeError): + basics.never(None) # type: ignore[arg-type] diff --git a/tests/unit/sdk/test_char.py b/tests/unit/sdk/test_char.py new file mode 100644 index 00000000..135e91e8 --- /dev/null +++ b/tests/unit/sdk/test_char.py @@ -0,0 +1,84 @@ +"""Tests for morphir.sdk.char (Morphir.SDK.Char).""" + +from morphir.sdk import char + + +class TestClassification: + def test_is_upper(self) -> None: + assert char.is_upper("A") + assert char.is_upper("Z") + assert not char.is_upper("a") + assert not char.is_upper("0") + assert not char.is_upper("Σ") + + def test_is_lower(self) -> None: + assert char.is_lower("a") + assert char.is_lower("z") + assert not char.is_lower("A") + assert not char.is_lower("π") + + def test_is_alpha(self) -> None: + assert char.is_alpha("a") + assert char.is_alpha("B") + assert not char.is_alpha("1") + assert not char.is_alpha("é") + + def test_is_alpha_num(self) -> None: + assert char.is_alpha_num("a") + assert char.is_alpha_num("Z") + assert char.is_alpha_num("7") + assert not char.is_alpha_num("-") + assert not char.is_alpha_num("π") + + def test_is_digit_is_ascii_only(self) -> None: + assert char.is_digit("0") + assert char.is_digit("9") + assert not char.is_digit("a") + assert not char.is_digit("٣") + + def test_is_oct_digit(self) -> None: + assert char.is_oct_digit("0") + assert char.is_oct_digit("7") + assert not char.is_oct_digit("8") + assert not char.is_oct_digit("a") + + def test_is_hex_digit(self) -> None: + assert char.is_hex_digit("0") + assert char.is_hex_digit("a") + assert char.is_hex_digit("F") + assert not char.is_hex_digit("g") + assert not char.is_hex_digit("G") + + def test_predicates_reject_strings_that_are_not_one_char(self) -> None: + assert not char.is_upper("") + assert not char.is_digit("12") + assert not char.is_alpha("ab") + + +class TestConversion: + def test_case(self) -> None: + assert char.to_upper("a") == "A" + assert char.to_upper("A") == "A" + assert char.to_lower("A") == "a" + assert char.to_locale_upper("z") == "Z" + assert char.to_locale_lower("Z") == "z" + assert char.to_upper("1") == "1" + + def test_to_code(self) -> None: + assert char.to_code("A") == 65 + assert char.to_code("B") == 66 + assert char.to_code("木") == 0x6728 + assert char.to_code("😃") == 0x1F603 + + def test_from_code(self) -> None: + assert char.from_code(65) == "A" + assert char.from_code(0x6728) == "木" + assert char.from_code(0x1F603) == "😃" + + def test_from_code_outside_unicode_yields_replacement_char(self) -> None: + assert char.from_code(-1) == "\N{REPLACEMENT CHARACTER}" + assert char.from_code(0x110000) == "\N{REPLACEMENT CHARACTER}" + + def test_round_trip(self) -> None: + for c in ("a", "Z", "0", "木", "😃"): + assert char.from_code(char.to_code(c)) == c diff --git a/tests/unit/sdk/test_decimal.py b/tests/unit/sdk/test_decimal.py new file mode 100644 index 00000000..3f563d74 --- /dev/null +++ b/tests/unit/sdk/test_decimal.py @@ -0,0 +1,324 @@ +"""Tests for morphir.sdk.decimal (Morphir.SDK.Decimal).""" + +import decimal as stdlib_decimal +import math + +import pytest + +from morphir.sdk import decimal as decimal_ +from morphir.sdk.basics import Order +from morphir.sdk.decimal import ( + Decimal, + from_float, + from_int, + from_string, + to_string, +) +from morphir.sdk.maybe import Just, Nothing, with_default + +SAMPLE_INTS = (0, 1, -1, 7, -42, 1000, 123456789, -987654321) +SAMPLE_DECIMALS = ( + from_float(0.0), + from_float(1.5), + from_float(-2.25), + from_float(3.14), + from_float(-1000.001), + with_default(decimal_.zero, from_string("123456789.987654321")), +) + + +def _parse(s: str) -> Decimal: + return with_default(decimal_.zero, from_string(s)) + + +class TestAbs: + def test_abs(self) -> None: + assert decimal_.abs(from_int(-42)) == from_int(42) + assert decimal_.abs(from_int(42)) == from_int(42) + assert decimal_.abs(from_int(0)) == from_int(0) + + +class TestArithmetic: + def test_add_mirrors_normal_addition(self) -> None: + for a in SAMPLE_INTS: + for b in SAMPLE_INTS: + assert decimal_.add(from_int(a), from_int(b)) == from_int(a + b) + + def test_add_is_commutative(self) -> None: + for a in SAMPLE_DECIMALS: + for b in SAMPLE_DECIMALS: + assert decimal_.add(a, b) == decimal_.add(b, a) + + def test_add_is_exact_where_floats_are_not(self) -> None: + assert to_string(decimal_.add(from_float(0.1), from_float(0.2))) == "0.3" + + def test_sub_mirrors_normal_subtraction(self) -> None: + for a in SAMPLE_INTS: + for b in SAMPLE_INTS: + assert decimal_.sub(from_int(a), from_int(b)) == from_int(a - b) + + def test_sub_in_the_other_order_is_the_negation(self) -> None: + for a in SAMPLE_DECIMALS: + for b in SAMPLE_DECIMALS: + assert decimal_.sub(a, b) == decimal_.negate(decimal_.sub(b, a)) + + def test_mul_mirrors_normal_multiplication(self) -> None: + for a in (0, 1, -1, 3, -7, 46340, -46340): + for b in (0, 1, -1, 3, -7, 46340, -46340): + assert decimal_.mul(from_int(a), from_int(b)) == from_int(a * b) + + def test_mul_is_commutative(self) -> None: + for a in SAMPLE_DECIMALS: + for b in SAMPLE_DECIMALS: + assert decimal_.mul(a, b) == decimal_.mul(b, a) + + def test_results_are_rounded_half_up_to_50_digits(self) -> None: + big = _parse("1" + "0" * 49 + "5") + assert to_string(decimal_.add(big, decimal_.zero)) == "1" + "0" * 48 + "10" + + def test_div_divides_exactly(self) -> None: + assert decimal_.div(from_int(10), from_int(4)) == Just(from_float(2.5)) + + def test_div_is_nothing_on_a_zero_divisor(self) -> None: + assert decimal_.div(from_int(1), decimal_.zero) == Nothing() + assert decimal_.div(decimal_.zero, decimal_.zero) == Nothing() + + def test_div_gives_50_significant_digits(self) -> None: + third = with_default(decimal_.zero, decimal_.div(decimal_.one, from_int(3))) + assert to_string(third) == "0." + "3" * 50 + two_thirds = with_default(decimal_.zero, decimal_.div(from_int(2), from_int(3))) + assert to_string(two_thirds) == "0." + "6" * 49 + "7" + + def test_div_with_default(self) -> None: + minus_one = decimal_.minus_one + assert ( + decimal_.div_with_default(minus_one, from_int(1), decimal_.zero) + == minus_one + ) + assert decimal_.div_with_default( + minus_one, from_int(6), from_int(3) + ) == from_int(2) + + +class TestConstruction: + def test_from_int(self) -> None: + for n in SAMPLE_INTS: + assert to_string(from_int(n)) == str(n) + + def test_from_int_is_exact_for_big_ints(self) -> None: + assert to_string(from_int(10**60 + 1)) == str(10**60 + 1) + + def test_from_string(self) -> None: + assert from_string("42") == Just(from_int(42)) + assert from_string("-21") == Just(from_int(-21)) + assert from_string("0") == Just(from_int(0)) + assert from_string("esdf") == Nothing() + assert from_string("1.1") == Just(from_float(1.1)) + + def test_from_string_with_exponent_and_explicit_sign(self) -> None: + assert from_string("1.5e3") == Just(from_int(1500)) + assert from_string("+7") == Just(from_int(7)) + assert from_string("2E-2") == Just(from_float(0.02)) + + def test_from_string_rejects_forms_that_elm_does_not_parse(self) -> None: + for s in ( + "0x10", + "0b1", + "0o7", + "Infinity", + "-Infinity", + "NaN", + "nan", + "sNaN", + "inf", + " 1", + "1 ", + "1\n", + "", + ".5", + "1.", + "1_000", + "١٢", + ): + assert from_string(s) == Nothing(), s + + def test_from_float(self) -> None: + assert from_float(1.0) == from_int(1) + assert from_float(-1.0) == from_int(-1) + assert from_float(0.0) == from_int(0) + assert to_string(from_float(-0.0)) == "0" + assert from_float(3.3) == decimal_.shift_decimal_left(1, from_int(33)) + assert from_float(1.1) == decimal_.shift_decimal_left(1, from_int(11)) + + def test_from_float_is_the_same_as_from_string(self) -> None: + for text in ("0", "1", "-1", "0.5", "3.14", "-1234.5678", "1e10", "123456.789"): + assert from_float(float(text)) == _parse(text) + + def test_from_float_rejects_values_that_are_not_finite(self) -> None: + for f in (math.nan, math.inf, -math.inf): + with pytest.raises(ValueError): + from_float(f) + + +class TestKnownExponents: + def test_hundred_thousand_million(self) -> None: + assert to_string(decimal_.hundred(42)) == "4200" + assert to_string(decimal_.hundred(-2)) == "-200" + assert to_string(decimal_.thousand(4)) == "4000" + assert to_string(decimal_.thousand(-7)) == "-7000" + assert to_string(decimal_.million(21)) == "21000000" + assert to_string(decimal_.million(-99)) == "-99000000" + + def test_tenth(self) -> None: + assert decimal_.tenth(1000) == from_int(100) + assert decimal_.tenth(10) == from_float(1.0) + assert decimal_.tenth(-1000) == from_float(-100.0) + assert decimal_.tenth(-50) == from_float(-5.0) + assert to_string(decimal_.tenth(3)) == "0.3" + + def test_hundredth(self) -> None: + assert decimal_.hundredth(100) == from_float(1.0) + assert decimal_.hundredth(10) == from_float(0.1) + assert decimal_.hundredth(-1000) == from_float(-10.0) + assert decimal_.hundredth(-50) == from_float(-0.5) + + def test_thousandth(self) -> None: + assert to_string(decimal_.thousandth(7)) == "0.007" + assert decimal_.thousandth(-1500) == from_float(-1.5) + + def test_bps(self) -> None: + assert decimal_.bps(10001) == from_float(1.0001) + assert to_string(decimal_.bps(2)) == "0.0002" + assert decimal_.bps(-100001) == from_float(-10.0001) + assert decimal_.bps(-5) == from_float(-0.0005) + + def test_millionth(self) -> None: + assert decimal_.millionth(1000000) == from_float(1.0) + assert decimal_.millionth(-10000000) == from_float(-10.0) + assert to_string(decimal_.millionth(1)) == "0.000001" + + +class TestConvertTo: + def test_to_string(self) -> None: + assert to_string(from_int(1)) == "1" + assert to_string(from_int(0)) == "0" + assert to_string(from_int(-1)) == "-1" + assert to_string(from_float(-1234.5678)) == "-1234.5678" + + def test_to_string_never_uses_exponent_notation(self) -> None: + assert to_string(_parse("1e21")) == "1000000000000000000000" + assert to_string(_parse("1e-7")) == "0.0000001" + assert to_string(_parse("-1.5e-10")) == "-0.00000000015" + + def test_to_string_drops_trailing_zeros(self) -> None: + assert to_string(_parse("1.50")) == "1.5" + assert to_string(_parse("100")) == "100" + assert to_string(_parse("2.000")) == "2" + assert to_string(_parse("-0.0")) == "0" + assert to_string(decimal_.mul(_parse("2.5"), _parse("4"))) == "10" + + def test_to_float(self) -> None: + assert decimal_.to_float(from_int(1)) == 1.0 + assert decimal_.to_float(from_int(0)) == 0.0 + assert decimal_.to_float(from_int(-1)) == -1.0 + assert decimal_.to_float(from_float(3.14)) == 3.14 + assert isinstance(decimal_.to_float(from_int(1)), float) + + +class TestCompare: + def test_compare(self) -> None: + for a in SAMPLE_INTS: + assert decimal_.compare(from_int(a), from_int(a)) is Order.EQ + assert decimal_.compare(from_int(a), from_int(a + 1)) is Order.LT + assert decimal_.compare(from_int(a), from_int(a - 1)) is Order.GT + + def test_compare_ignores_trailing_zeros(self) -> None: + assert decimal_.compare(_parse("1.50"), from_float(1.5)) is Order.EQ + + def test_eq_and_neq(self) -> None: + for a in SAMPLE_DECIMALS: + assert decimal_.neq(a, decimal_.add(a, decimal_.minus_one)) + assert not decimal_.neq(a, a) + assert decimal_.eq(a, a) + + def test_ordering_predicates(self) -> None: + a = from_int(1) + b = from_int(2) + assert decimal_.eq(a, from_float(1.0)) + assert decimal_.gt(b, a) + assert not decimal_.gt(a, a) + assert decimal_.gte(a, a) + assert not decimal_.gte(a, b) + assert decimal_.lt(a, b) + assert not decimal_.lt(a, a) + assert decimal_.lte(a, a) + assert not decimal_.lte(b, a) + + +class TestShift: + def test_shift_decimal_left(self) -> None: + assert decimal_.shift_decimal_left(2, from_int(314)) == from_float(3.14) + assert decimal_.shift_decimal_left(2, from_float(199.95)) == from_float(1.9995) + + def test_shift_decimal_right(self) -> None: + assert decimal_.shift_decimal_right(3, from_int(314)) == from_float(314000.0) + assert decimal_.shift_decimal_right(1, from_float(199.95)) == from_float(1999.5) + + def test_negative_shifts_go_the_other_way(self) -> None: + assert decimal_.shift_decimal_left(-2, from_int(3)) == from_int(300) + assert decimal_.shift_decimal_right(-2, from_int(300)) == from_int(3) + + +class TestRounding: + def test_truncate_goes_toward_zero(self) -> None: + assert to_string(decimal_.truncate(from_float(2.7))) == "2" + assert to_string(decimal_.truncate(from_float(-2.7))) == "-2" + assert to_string(decimal_.truncate(from_int(5))) == "5" + + def test_round_goes_half_up(self) -> None: + assert to_string(decimal_.round(from_float(2.4))) == "2" + assert to_string(decimal_.round(from_float(2.5))) == "3" + assert to_string(decimal_.round(from_float(3.5))) == "4" + assert to_string(decimal_.round(from_float(-2.4))) == "-2" + assert to_string(decimal_.round(from_float(-2.5))) == "-3" + + +class TestNegateAndConstants: + def test_negate(self) -> None: + assert decimal_.negate(from_int(3)) == from_int(-3) + assert decimal_.negate(decimal_.zero) == decimal_.zero + assert to_string(decimal_.negate(decimal_.zero)) == "0" + + def test_constants(self) -> None: + assert to_string(decimal_.zero) == "0" + assert to_string(decimal_.one) == "1" + assert to_string(decimal_.minus_one) == "-1" + + +class TestContext: + def test_the_global_decimal_context_does_not_change(self) -> None: + before = stdlib_decimal.getcontext().copy() + decimal_.div(decimal_.one, from_int(3)) + decimal_.round(from_float(2.5)) + decimal_.mul(from_float(1.1), from_float(2.2)) + after = stdlib_decimal.getcontext() + assert after.prec == before.prec == 28 + assert after.rounding == before.rounding == stdlib_decimal.ROUND_HALF_EVEN + + def test_the_global_decimal_context_has_no_effect(self) -> None: + with stdlib_decimal.localcontext() as ctx: + ctx.prec = 5 + third = with_default(decimal_.zero, decimal_.div(decimal_.one, from_int(3))) + assert to_string(third) == "0." + "3" * 50 + + def test_decimal_is_the_stdlib_type(self) -> None: + assert isinstance(from_int(1), stdlib_decimal.Decimal) + + +class TestLimits: + def test_from_string_keeps_all_digits(self) -> None: + text = "1234567890" * 6 + ".5" + assert to_string(_parse(text)) == text + + def test_from_string_is_nothing_for_an_exponent_out_of_range(self) -> None: + assert from_string("1e99999999999999999999") == Nothing() diff --git a/tests/unit/sdk/test_dict.py b/tests/unit/sdk/test_dict.py new file mode 100644 index 00000000..27beaf89 --- /dev/null +++ b/tests/unit/sdk/test_dict.py @@ -0,0 +1,198 @@ +"""Tests for morphir.sdk.dict (Morphir.SDK.Dict).""" + +import dataclasses + +import pytest + +from morphir.sdk import basics +from morphir.sdk import dict as dict_ +from morphir.sdk.dict import Dict +from morphir.sdk.maybe import Just, Maybe, Nothing + +ABC: Dict[str, int] = dict_.from_list((("b", 2), ("a", 1), ("c", 3))) + + +def _is_odd_value(_key: str, value: int) -> bool: + return value % 2 == 1 + + +class TestBuild: + def test_empty_and_singleton(self) -> None: + assert dict_.to_list(dict_.empty()) == () + assert dict_.is_empty(dict_.empty()) + assert dict_.to_list(dict_.singleton("k", 1)) == (("k", 1),) + assert not dict_.is_empty(dict_.singleton("k", 1)) + assert isinstance(dict_.empty(), Dict) + + def test_from_list_sorts_and_later_entries_win(self) -> None: + assert dict_.to_list(dict_.from_list(((2, "b"), (1, "a")))) == ( + (1, "a"), + (2, "b"), + ) + assert dict_.to_list(dict_.from_list(((1, "first"), (1, "second")))) == ( + (1, "second"), + ) + + def test_string_keys_sort_by_code_point(self) -> None: + d = dict_.from_list((("b", 1), ("B", 2), ("a", 3), ("A", 4), ("ab", 5))) + assert dict_.keys(d) == ("A", "B", "a", "ab", "b") + + def test_tuple_keys(self) -> None: + d = dict_.from_list((((2, "a"), "x"), ((1, "b"), "y"), ((1, "a"), "z"))) + assert dict_.keys(d) == ((1, "a"), (1, "b"), (2, "a")) + assert dict_.get((1, "a"), d) == Just("z") + assert dict_.get((1, "c"), d) == Nothing() + assert dict_.member((2, "a"), d) + + def test_list_keys(self) -> None: + d = dict_.from_list((([2, 1], "x"), ([1, 9], "y"))) + # A list key is stored as a tuple, so a later change cannot reorder it. + assert dict_.keys(d) == ((1, 9), (2, 1)) + assert dict_.get([2, 1], d) == Just("x") + + def test_float_and_int_keys_mix(self) -> None: + d = dict_.from_list(((1.5, "x"), (1, "y"), (-2, "z"))) + assert dict_.keys(d) == (-2, 1, 1.5) + + def test_keys_that_are_not_comparable_raise(self) -> None: + with pytest.raises(TypeError): + dict_.from_list(((1, "x"), ("a", "y"))) # type: ignore[arg-type] + with pytest.raises(TypeError): + dict_.insert(1, 0, ABC) # type: ignore[arg-type] + + def test_insert_replaces_an_existing_key_and_does_not_mutate(self) -> None: + d1 = dict_.insert("a", 10, ABC) + assert dict_.to_list(d1) == (("a", 10), ("b", 2), ("c", 3)) + assert dict_.to_list(ABC) == (("a", 1), ("b", 2), ("c", 3)) + assert dict_.to_list(dict_.insert("d", 4, ABC)) == ( + ("a", 1), + ("b", 2), + ("c", 3), + ("d", 4), + ) + assert dict_.to_list(dict_.insert("0", 0, ABC)) == ( + ("0", 0), + ("a", 1), + ("b", 2), + ("c", 3), + ) + + def test_dict_is_frozen(self) -> None: + with pytest.raises(dataclasses.FrozenInstanceError): + ABC.entries = () # type: ignore[misc] + + def test_remove(self) -> None: + assert dict_.to_list(dict_.remove("b", ABC)) == (("a", 1), ("c", 3)) + assert dict_.remove("z", ABC) == ABC + assert dict_.size(ABC) == 3 + + def test_update_inserts_changes_and_removes(self) -> None: + def inc(m: Maybe[int]) -> Maybe[int]: + match m: + case Just(value): + return Just(value + 1) + case Nothing(): + return Just(0) + + assert dict_.to_list(dict_.update("a", inc, ABC)) == ( + ("a", 2), + ("b", 2), + ("c", 3), + ) + assert dict_.to_list(dict_.update("d", inc, ABC)) == ( + ("a", 1), + ("b", 2), + ("c", 3), + ("d", 0), + ) + assert dict_.to_list(dict_.update("b", lambda _m: Nothing(), ABC)) == ( + ("a", 1), + ("c", 3), + ) + assert dict_.update("z", lambda _m: Nothing(), ABC) == ABC + + +class TestQuery: + def test_get_and_member(self) -> None: + assert dict_.get("b", ABC) == Just(2) + assert dict_.get("z", ABC) == Nothing() + assert dict_.get("a", dict_.empty()) == Nothing() + assert dict_.member("c", ABC) + assert not dict_.member("z", ABC) + + def test_size_keys_values(self) -> None: + assert dict_.size(dict_.empty()) == 0 + assert dict_.size(ABC) == 3 + assert dict_.keys(ABC) == ("a", "b", "c") + assert dict_.values(ABC) == (1, 2, 3) + + +class TestTransform: + def test_map(self) -> None: + assert dict_.to_list(dict_.map(lambda k, v: f"{k}{v}", ABC)) == ( + ("a", "a1"), + ("b", "b2"), + ("c", "c3"), + ) + + def test_foldl_ascending_and_foldr_descending(self) -> None: + assert dict_.foldl(lambda k, _v, acc: acc + k, "", ABC) == "abc" + assert dict_.foldr(lambda k, _v, acc: acc + k, "", ABC) == "cba" + assert dict_.foldl(lambda _k, v, acc: acc + v, 0, ABC) == 6 + + def test_filter_and_partition(self) -> None: + assert dict_.to_list(dict_.filter(_is_odd_value, ABC)) == (("a", 1), ("c", 3)) + odd, even = dict_.partition(_is_odd_value, ABC) + assert dict_.to_list(odd) == (("a", 1), ("c", 3)) + assert dict_.to_list(even) == (("b", 2),) + + +class TestCombine: + def test_union_prefers_the_first_dict(self) -> None: + other = dict_.from_list((("b", 20), ("d", 4))) + assert dict_.to_list(dict_.union(ABC, other)) == ( + ("a", 1), + ("b", 2), + ("c", 3), + ("d", 4), + ) + assert dict_.to_list(dict_.union(other, ABC)) == ( + ("a", 1), + ("b", 20), + ("c", 3), + ("d", 4), + ) + + def test_intersect_keeps_values_from_the_first_dict(self) -> None: + other = dict_.from_list((("b", "x"), ("c", "y"), ("d", "z"))) + assert dict_.to_list(dict_.intersect(ABC, other)) == (("b", 2), ("c", 3)) + + def test_diff_removes_keys_found_in_the_second_dict(self) -> None: + other = dict_.from_list((("b", "x"), ("z", "y"))) + assert dict_.to_list(dict_.diff(ABC, other)) == (("a", 1), ("c", 3)) + + def test_merge_visits_keys_in_ascending_order(self) -> None: + left = dict_.from_list((("a", 1), ("c", 3), ("b", 2))) + right = dict_.from_list((("b", "B"), ("d", "D"), ("c", "C"))) + result = dict_.merge( + lambda k, a, acc: (*acc, f"left {k} {a}"), + lambda k, a, b, acc: (*acc, f"both {k} {a} {b}"), + lambda k, b, acc: (*acc, f"right {k} {b}"), + left, + right, + (), + ) + assert result == ("left a 1", "both b 2 B", "both c 3 C", "right d D") + + +class TestEquality: + def test_structural_equality_is_independent_of_insertion_order(self) -> None: + empty: Dict[str, int] = dict_.empty() + d1 = dict_.insert("c", 3, dict_.insert("a", 1, dict_.insert("b", 2, empty))) + d2 = dict_.insert("a", 1, dict_.insert("b", 2, dict_.insert("c", 3, empty))) + assert d1 == d2 + assert basics.equal(d1, d2) + assert basics.equal(d1, ABC) + assert not basics.equal(d1, dict_.insert("a", 9, d2)) + assert hash(d1) == hash(d2) + assert d1.entries == (("a", 1), ("b", 2), ("c", 3)) diff --git a/tests/unit/sdk/test_doctests.py b/tests/unit/sdk/test_doctests.py new file mode 100644 index 00000000..8377811a --- /dev/null +++ b/tests/unit/sdk/test_doctests.py @@ -0,0 +1,17 @@ +"""Runs the examples in the morphir.sdk docstrings.""" + +import doctest +import importlib + +import pytest + +import morphir.sdk + +_MODULES = ["morphir.sdk", *(f"morphir.sdk.{name}" for name in morphir.sdk.__all__)] + + +@pytest.mark.parametrize("module_name", _MODULES) +def test_docstring_examples(module_name: str) -> None: + module = importlib.import_module(module_name) + results = doctest.testmod(module) + assert results.failed == 0 diff --git a/tests/unit/sdk/test_int.py b/tests/unit/sdk/test_int.py new file mode 100644 index 00000000..ba0e628b --- /dev/null +++ b/tests/unit/sdk/test_int.py @@ -0,0 +1,39 @@ +"""Tests for morphir.sdk.int (Morphir.SDK.Int).""" + +import pytest + +from morphir.sdk import int as int_ +from morphir.sdk import maybe +from morphir.sdk.maybe import Just, Nothing + +_CASES = [ + (int_.to_int8, int_.from_int8, -(2**7), 2**7 - 1), + (int_.to_int16, int_.from_int16, -(2**15), 2**15 - 1), + (int_.to_int32, int_.from_int32, -(2**31), 2**31 - 1), + (int_.to_int64, int_.from_int64, -(2**63), 2**63 - 1), +] + + +class TestFixedWidthInts: + @pytest.mark.parametrize(("to_int", "from_int", "low", "high"), _CASES) + def test_accepts_the_full_range(self, to_int, from_int, low, high) -> None: + for n in (low, -1, 0, 1, high): + assert to_int(n) == Just(n) + + @pytest.mark.parametrize(("to_int", "from_int", "low", "high"), _CASES) + def test_rejects_values_out_of_range(self, to_int, from_int, low, high) -> None: + assert to_int(low - 1) == Nothing() + assert to_int(high + 1) == Nothing() + + @pytest.mark.parametrize(("to_int", "from_int", "low", "high"), _CASES) + def test_round_trip(self, to_int, from_int, low, high) -> None: + for n in (low, 0, 42, high): + assert maybe.map(from_int, to_int(n)) == Just(n) + + def test_rejects_bools(self) -> None: + assert int_.to_int8(True) == Nothing() + + def test_from_int_yields_a_plain_int(self) -> None: + wrapped = maybe.with_default(int_.Int8(0), int_.to_int8(5)) + assert int_.from_int8(wrapped) == 5 + assert type(int_.from_int8(wrapped)) is int diff --git a/tests/unit/sdk/test_list.py b/tests/unit/sdk/test_list.py new file mode 100644 index 00000000..dec54b31 --- /dev/null +++ b/tests/unit/sdk/test_list.py @@ -0,0 +1,242 @@ +"""Tests for morphir.sdk.list (Morphir.SDK.List).""" + +import pytest + +from morphir.sdk import list as list_ +from morphir.sdk.basics import Order, compare +from morphir.sdk.maybe import Just, Maybe, Nothing + + +def _is_even(n: int) -> bool: + return n % 2 == 0 + + +def _eq(a: int, b: int) -> bool: + return a == b + + +class TestCreate: + def test_singleton(self) -> None: + assert list_.singleton(1234) == (1234,) + + def test_repeat(self) -> None: + assert list_.repeat(3, (0, 0)) == ((0, 0), (0, 0), (0, 0)) + assert list_.repeat(0, "a") == () + assert list_.repeat(-1, "a") == () + + def test_range_is_inclusive(self) -> None: + assert list_.range(3, 6) == (3, 4, 5, 6) + assert list_.range(3, 3) == (3,) + assert list_.range(6, 3) == () + + def test_cons(self) -> None: + assert list_.cons(1, (2, 3)) == (1, 2, 3) + assert list_.cons(1, ()) == (1,) + + +class TestTransform: + def test_map(self) -> None: + assert list_.map(lambda n: n * 2, (1, 2, 3)) == (2, 4, 6) + assert list_.map(lambda n: n * 2, ()) == () + + def test_accepts_any_sequence_and_yields_a_tuple(self) -> None: + source = [1, 2, 3] + assert list_.map(lambda n: n + 1, source) == (2, 3, 4) + assert source == [1, 2, 3] + + def test_indexed_map(self) -> None: + assert list_.indexed_map(lambda i, s: (i, s), ("Tom", "Sue", "Bob")) == ( + (0, "Tom"), + (1, "Sue"), + (2, "Bob"), + ) + + def test_foldl_and_foldr(self) -> None: + assert list_.foldl(lambda x, acc: (x, *acc), (), (1, 2, 3)) == (3, 2, 1) + assert list_.foldr(lambda x, acc: (x, *acc), (), (1, 2, 3)) == (1, 2, 3) + assert list_.foldl(lambda x, acc: x + acc, 0, (1, 2, 3)) == 6 + + def test_filter(self) -> None: + assert list_.filter(_is_even, (1, 2, 3, 4, 5, 6)) == (2, 4, 6) + + def test_filter_map(self) -> None: + def even_half(n: int) -> Maybe[int]: + return Just(n // 2) if _is_even(n) else Nothing() + + assert list_.filter_map(even_half, (1, 2, 3, 4)) == (1, 2) + + +class TestUtilities: + def test_length(self) -> None: + assert list_.length((1, 2, 3)) == 3 + assert list_.length(()) == 0 + + def test_reverse(self) -> None: + assert list_.reverse((1, 2, 3, 4)) == (4, 3, 2, 1) + + def test_member_is_structural(self) -> None: + assert list_.member(9, (1, 2, 3, 4)) is False + assert list_.member(4, (1, 2, 3, 4)) is True + assert list_.member((1, "a"), ((0, "z"), (1, "a"))) is True + assert list_.member(Just(1), (Nothing(), Just(1))) is True + + def test_all_and_any(self) -> None: + assert list_.all(_is_even, (2, 4)) + assert not list_.all(_is_even, (2, 3)) + assert list_.all(_is_even, ()) + assert list_.any(_is_even, (2, 3)) + assert not list_.any(_is_even, (1, 3)) + assert not list_.any(_is_even, ()) + + def test_maximum_and_minimum(self) -> None: + assert list_.maximum((1, 4, 2)) == Just(4) + assert list_.maximum(()) == Nothing() + assert list_.minimum((3, 2, 1)) == Just(1) + assert list_.minimum(()) == Nothing() + assert list_.maximum(("a", "c", "b")) == Just("c") + assert list_.minimum(((2, "a"), (1, "b"))) == Just((1, "b")) + + def test_sum_and_product(self) -> None: + assert list_.sum((1, 2, 3, 4)) == 10 + assert list_.sum(()) == 0 + assert list_.sum((0.5, 0.25)) == 0.75 + assert list_.product((1, 2, 3, 4)) == 24 + assert list_.product(()) == 1 + + +class TestCombine: + def test_append(self) -> None: + assert list_.append((1, 1, 2), (3, 5, 8)) == (1, 1, 2, 3, 5, 8) + assert list_.append(("a", "b"), ("c",)) == ("a", "b", "c") + + def test_concat(self) -> None: + assert list_.concat(((1, 2), (3,), (4, 5))) == (1, 2, 3, 4, 5) + assert list_.concat(()) == () + + def test_concat_map(self) -> None: + assert list_.concat_map(lambda n: (n, n), (1, 2)) == (1, 1, 2, 2) + + def test_intersperse(self) -> None: + assert list_.intersperse("on", ("turtles", "turtles", "turtles")) == ( + "turtles", + "on", + "turtles", + "on", + "turtles", + ) + assert list_.intersperse(0, ()) == () + assert list_.intersperse(0, (1,)) == (1,) + + def test_map2_to_map5_stop_at_the_shortest_list(self) -> None: + assert list_.map2(lambda a, b: a + b, (1, 2, 3), (1, 2, 3, 4)) == (2, 4, 6) + assert list_.map2(lambda a, b: (a, b), (1, 2, 3), ("a", "b")) == ( + (1, "a"), + (2, "b"), + ) + assert list_.map3(lambda a, b, c: a + b + c, (1, 2), (10, 20), (100,)) == (111,) + assert list_.map4( + lambda a, b, c, d: a + b + c + d, (1, 2), (10, 20), (100, 200), (1000, 2000) + ) == (1111, 2222) + assert list_.map5( + lambda a, b, c, d, e: a + b + c + d + e, (1,), (2,), (3,), (4,), (5, 6) + ) == (15,) + + +class TestSort: + def test_sort(self) -> None: + assert list_.sort((3, 1, 5)) == (1, 3, 5) + assert list_.sort(("b", "B", "a")) == ("B", "a", "b") + assert list_.sort(((2, "a"), (1, "b"), (1, "a"))) == ( + (1, "a"), + (1, "b"), + (2, "a"), + ) + + def test_sort_rejects_values_that_are_not_comparable(self) -> None: + with pytest.raises(TypeError): + list_.sort((1, "a")) # type: ignore[arg-type] + + def test_sort_by_is_stable(self) -> None: + assert list_.sort_by(len, ("mouse", "cat", "chuck", "cow", "alpha")) == ( + "cat", + "cow", + "mouse", + "chuck", + "alpha", + ) + assert list_.sort_by(lambda s: s, ("chuck", "alice", "bob")) == ( + "alice", + "bob", + "chuck", + ) + + def test_sort_with_uses_order(self) -> None: + def flipped(a: int, b: int) -> Order: + return compare(b, a) + + assert list_.sort_with(flipped, (1, 2, 3, 4, 5)) == (5, 4, 3, 2, 1) + assert list_.sort_with(lambda _a, _b: Order.EQ, (3, 1, 2)) == (3, 1, 2) + + +class TestDeconstruct: + def test_is_empty(self) -> None: + assert list_.is_empty(()) + assert not list_.is_empty((1,)) + + def test_head_and_tail(self) -> None: + assert list_.head((1, 2, 3)) == Just(1) + assert list_.head(()) == Nothing() + assert list_.tail((1, 2, 3)) == Just((2, 3)) + assert list_.tail((1,)) == Just(()) + assert list_.tail(()) == Nothing() + + def test_take(self) -> None: + assert list_.take(2, (1, 2, 3, 4)) == (1, 2) + assert list_.take(0, (1, 2)) == () + assert list_.take(-1, (1, 2)) == () + assert list_.take(5, (1, 2)) == (1, 2) + + def test_drop(self) -> None: + assert list_.drop(2, (1, 2, 3, 4)) == (3, 4) + assert list_.drop(0, (1, 2)) == (1, 2) + assert list_.drop(-1, (1, 2)) == (1, 2) + assert list_.drop(5, (1, 2)) == () + + def test_partition(self) -> None: + assert list_.partition(lambda x: x < 3, (0, 1, 2, 3, 4, 5)) == ( + (0, 1, 2), + (3, 4, 5), + ) + assert list_.partition(_is_even, (0, 1, 2, 3, 4, 5)) == ((0, 2, 4), (1, 3, 5)) + assert list_.partition(_is_even, ()) == ((), ()) + + def test_unzip(self) -> None: + assert list_.unzip(((0, True), (17, False), (1337, True))) == ( + (0, 17, 1337), + (True, False, True), + ) + assert list_.unzip(()) == ((), ()) + + +class TestJoins: + def test_inner_filters_left(self) -> None: + assert list_.inner_join((1, 3), _eq, (1, 2, 3)) == ((1, 1), (3, 3)) + + def test_inner_filters_right(self) -> None: + assert list_.inner_join((1, 2, 3), _eq, (1, 2)) == ((1, 1), (2, 2)) + + def test_inner_filters_both(self) -> None: + assert list_.inner_join((1, 3), _eq, (1, 2)) == ((1, 1),) + + def test_inner_yields_every_matching_pair(self) -> None: + assert list_.inner_join((1, 1), _eq, (1,)) == ((1, 1), (1, 1)) + + def test_left_outer_keeps_left(self) -> None: + assert list_.left_join((1, 3), _eq, (1, 2)) == ((1, Just(1)), (2, Nothing())) + + def test_left_outer_yields_every_matching_pair(self) -> None: + assert list_.left_join((1, 1, 3), _eq, (1, 2)) == ( + (1, Just(1)), + (1, Just(1)), + (2, Nothing()), + ) diff --git a/tests/unit/sdk/test_maybe.py b/tests/unit/sdk/test_maybe.py new file mode 100644 index 00000000..e57359fa --- /dev/null +++ b/tests/unit/sdk/test_maybe.py @@ -0,0 +1,95 @@ +"""Tests for morphir.sdk.maybe (Morphir.SDK.Maybe).""" + +import dataclasses + +import pytest + +from morphir.sdk import maybe +from morphir.sdk.maybe import Just, Maybe, Nothing + + +class TestMaybeType: + def test_structural_equality_and_hash(self) -> None: + assert Just(1) == Just(1) + assert Just(1) != Just(2) + assert Nothing() == Nothing() + assert Just(1) != Nothing() + assert hash(Just((1, "a"))) == hash(Just((1, "a"))) + assert hash(Nothing()) == hash(Nothing()) + + def test_values_are_frozen(self) -> None: + with pytest.raises(dataclasses.FrozenInstanceError): + Just(1).value = 2 # type: ignore[misc] + + def test_pattern_matching(self) -> None: + def describe(m: Maybe[int]) -> str: + match m: + case Just(value): + return f"just {value}" + case Nothing(): + return "nothing" + + assert describe(Just(3)) == "just 3" + assert describe(Nothing()) == "nothing" + + +class TestMaybeFunctions: + def test_with_default(self) -> None: + assert maybe.with_default(0, Just(5)) == 5 + assert maybe.with_default(0, Nothing()) == 0 + + def test_has_value(self) -> None: + assert maybe.has_value(Just(1)) + assert not maybe.has_value(Nothing()) + + def test_map(self) -> None: + assert maybe.map(lambda n: n + 1, Just(1)) == Just(2) + assert maybe.map(lambda n: n + 1, Nothing()) == Nothing() + + def test_map2_to_map5(self) -> None: + assert maybe.map2(lambda a, b: a + b, Just(1), Just(2)) == Just(3) + assert maybe.map2(lambda a, b: a + b, Just(1), Nothing()) == Nothing() + assert maybe.map2(lambda a, b: a + b, Nothing(), Just(2)) == Nothing() + assert maybe.map3(lambda a, b, c: a + b + c, Just(1), Just(2), Just(3)) == Just( + 6 + ) + assert ( + maybe.map3(lambda a, b, c: a + b + c, Just(1), Just(2), Nothing()) + == Nothing() + ) + assert maybe.map4( + lambda a, b, c, d: a + b + c + d, Just(1), Just(2), Just(3), Just(4) + ) == Just(10) + assert ( + maybe.map4( + lambda a, b, c, d: a + b + c + d, Just(1), Just(2), Nothing(), Just(4) + ) + == Nothing() + ) + assert maybe.map5( + lambda a, b, c, d, e: a + b + c + d + e, + Just(1), + Just(2), + Just(3), + Just(4), + Just(5), + ) == Just(15) + assert ( + maybe.map5( + lambda a, b, c, d, e: a + b + c + d + e, + Just(1), + Just(2), + Just(3), + Just(4), + Nothing(), + ) + == Nothing() + ) + + def test_and_then(self) -> None: + def half(n: int) -> Maybe[int]: + return Just(n // 2) if n % 2 == 0 else Nothing() + + assert maybe.and_then(half, Just(4)) == Just(2) + assert maybe.and_then(half, Just(3)) == Nothing() + assert maybe.and_then(half, Nothing()) == Nothing() diff --git a/tests/unit/sdk/test_number.py b/tests/unit/sdk/test_number.py new file mode 100644 index 00000000..1b7e82a2 --- /dev/null +++ b/tests/unit/sdk/test_number.py @@ -0,0 +1,173 @@ +"""Tests for morphir.sdk.number (Morphir.SDK.Number).""" + +import decimal as stdlib_decimal + +from morphir.sdk import number +from morphir.sdk.maybe import Just, Nothing +from morphir.sdk.number import DivisionByZero, Number +from morphir.sdk.result import Err, Ok + + +def make_number(n: int, d: int) -> Number: + """Build n/d through `divide`, like the Elm test helper.""" + match number.divide(number.from_int(n), number.from_int(d)): + case Ok(value): + return value + case Err(_): + return number.zero + + +SAMPLES = ( + make_number(0, 1), + make_number(1, 1), + make_number(-1, 1), + make_number(3, 4), + make_number(-3, 4), + make_number(3, -4), + make_number(-3, -4), + make_number(22, 7), + make_number(1000000007, 13), + make_number(-7, 5), + make_number(10**40 + 1, 3), +) + + +class TestConstruction: + def test_from_int_builds_an_integral_rational(self) -> None: + assert number.from_int(5) == Number(5, 1) + assert number.zero == Number(0, 1) + assert number.one == Number(1, 1) + + +class TestComparison: + def test_equal(self) -> None: + for a in SAMPLES: + assert not number.equal(a, number.add(a, number.from_int(1))) + assert number.equal(a, a) + assert number.equal(number.one, make_number(10, 10)) + assert not number.equal(number.one, number.zero) + assert number.equal(make_number(1, 2), make_number(-2, -4)) + + def test_not_equal(self) -> None: + for a in SAMPLES: + assert number.not_equal(a, number.add(a, number.from_int(1))) + assert not number.not_equal(a, a) + + def test_less_than_and_greater_than(self) -> None: + assert number.less_than(make_number(1, 2), make_number(2, 3)) + assert not number.less_than(make_number(2, 3), make_number(1, 2)) + assert number.greater_than(make_number(2, 3), make_number(1, 2)) + assert not number.greater_than(make_number(1, 2), make_number(1, 2)) + + def test_less_than_or_equal_and_greater_than_or_equal(self) -> None: + assert number.less_than_or_equal(make_number(1, 2), make_number(2, 4)) + assert not number.less_than_or_equal(make_number(2, 3), make_number(1, 2)) + assert number.greater_than_or_equal(make_number(1, 2), make_number(2, 4)) + assert not number.greater_than_or_equal(make_number(1, 2), make_number(2, 3)) + + def test_negative_denominators_compare_by_value(self) -> None: + # 3/-4 is -0.75, which is less than 1/2. + assert number.less_than(make_number(3, -4), make_number(1, 2)) + assert number.greater_than(make_number(1, 2), make_number(3, -4)) + assert not number.less_than(make_number(-3, -4), make_number(1, 2)) + assert number.equal(make_number(3, -4), make_number(-3, 4)) + + +class TestArithmetic: + def test_add_does_not_reduce(self) -> None: + assert number.add(make_number(1, 2), make_number(1, 3)) == Number(5, 6) + assert number.add(make_number(1, 2), make_number(1, 2)) == Number(4, 4) + + def test_subtract(self) -> None: + assert number.subtract(make_number(1, 2), make_number(1, 3)) == Number(1, 6) + + def test_multiply_does_not_reduce(self) -> None: + assert number.multiply(make_number(2, 3), make_number(3, 4)) == Number(6, 12) + + def test_negate(self) -> None: + assert number.negate(make_number(3, 4)) == Number(-3, 4) + total = number.add(make_number(3, 4), number.negate(make_number(3, 4))) + assert number.equal(number.zero, total) + + def test_abs(self) -> None: + assert number.abs(make_number(-3, 4)) == Number(3, 4) + assert number.abs(make_number(3, -4)) == Number(3, 4) + + def test_reciprocal_swaps_but_leaves_zero_alone(self) -> None: + assert number.reciprocal(make_number(3, 4)) == Number(4, 3) + assert number.reciprocal(number.zero) == number.zero + assert number.reciprocal(make_number(0, 5)) == Number(0, 5) + + def test_big_values_stay_exact(self) -> None: + big = make_number(10**40 + 1, 3) + assert number.multiply(big, big) == Number((10**40 + 1) ** 2, 9) + + +class TestDivide: + def test_dividing_by_zero_yields_division_by_zero(self) -> None: + forty_two = number.from_int(42) + assert number.divide(forty_two, number.from_int(0)) == Err(DivisionByZero()) + assert number.divide(forty_two, make_number(0, 7)) == Err(DivisionByZero()) + + def test_dividing_a_number_by_itself_equals_one(self) -> None: + for n in (1, -1, 2, 7, -13, 1000003): + result = number.divide(number.from_int(n), number.from_int(n)) + assert isinstance(result, Ok) + assert number.equal(number.one, result.value) + + def test_divide_cross_multiplies(self) -> None: + assert number.divide(make_number(1, 2), make_number(3, 4)) == Ok(Number(4, 6)) + + +class TestSimplify: + def test_four_over_two_reduces_to_two_over_one(self) -> None: + assert number.simplify(make_number(4, 2)) == Just(Number(2, 1)) + + def test_seven_over_five_does_not_simplify(self) -> None: + assert number.simplify(make_number(7, 5)) == Just(Number(7, 5)) + + def test_a_zero_numerator_simplifies_to_zero(self) -> None: + for d in (2, 3, 17, 999): + assert number.simplify(make_number(0, d)) == Just(number.zero) + + def test_a_zero_denominator_cannot_simplify(self) -> None: + assert number.simplify(Number(3, 0)) == Nothing() + assert number.simplify(Number(0, 0)) == Nothing() + + def test_simplify_keeps_the_denominator_positive(self) -> None: + assert number.simplify(make_number(3, -6)) == Just(Number(-1, 2)) + assert number.simplify(make_number(-4, -2)) == Just(Number(2, 1)) + + def test_is_simplified(self) -> None: + assert number.is_simplified(make_number(7, 5)) + assert not number.is_simplified(make_number(4, 2)) + assert number.is_simplified(number.zero) + assert not number.is_simplified(make_number(0, 5)) + + def test_a_zero_denominator_counts_as_simplified_like_elm(self) -> None: + assert number.is_simplified(Number(3, 0)) + + +class TestConvertTo: + def test_to_fractional_string(self) -> None: + assert number.to_fractional_string(make_number(3, 4)) == "3/4" + assert number.to_fractional_string(make_number(-3, 4)) == "-3/4" + assert number.to_fractional_string(number.from_int(7)) == "7/1" + + def test_to_decimal_divides_the_components(self) -> None: + assert number.to_decimal(make_number(3, 4)) == Just( + stdlib_decimal.Decimal("0.75") + ) + assert number.to_decimal(make_number(1, 3)) == Just( + stdlib_decimal.Decimal("0." + "3" * 50) + ) + + def test_to_decimal_is_nothing_when_the_denominator_is_zero(self) -> None: + assert number.to_decimal(Number(3, 0)) == Nothing() + + def test_coerce_to_decimal_falls_back_on_a_zero_denominator(self) -> None: + fallback = stdlib_decimal.Decimal(-1) + assert number.coerce_to_decimal( + fallback, make_number(1, 2) + ) == stdlib_decimal.Decimal("0.5") + assert number.coerce_to_decimal(fallback, Number(3, 0)) is fallback diff --git a/tests/unit/sdk/test_result.py b/tests/unit/sdk/test_result.py new file mode 100644 index 00000000..e2fdcf77 --- /dev/null +++ b/tests/unit/sdk/test_result.py @@ -0,0 +1,82 @@ +"""Tests for morphir.sdk.result (Morphir.SDK.Result).""" + +from morphir.sdk import result +from morphir.sdk.maybe import Just, Nothing +from morphir.sdk.result import Err, Ok, Result + + +def _add(*args: int) -> int: + return sum(args) + + +class TestResultType: + def test_structural_equality(self) -> None: + assert Ok(1) == Ok(1) + assert Err("bad") == Err("bad") + assert Ok(1) != Err(1) + + def test_pattern_matching(self) -> None: + def describe(r: Result[str, int]) -> str: + match r: + case Ok(value): + return f"ok {value}" + case Err(error): + return f"err {error}" + + assert describe(Ok(3)) == "ok 3" + assert describe(Err("bad")) == "err bad" + + +class TestResultFunctions: + def test_with_default(self) -> None: + assert result.with_default(0, Ok(5)) == 5 + assert result.with_default(0, Err("bad")) == 0 + + def test_map(self) -> None: + assert result.map(lambda n: n + 1, Ok(1)) == Ok(2) + assert result.map(lambda n: n + 1, Err("bad")) == Err("bad") + + def test_map2_to_map5_yield_the_first_error(self) -> None: + assert result.map2(_add, Ok(1), Ok(2)) == Ok(3) + assert result.map2(_add, Err("x"), Err("y")) == Err("x") + assert result.map2(_add, Ok(1), Err("y")) == Err("y") + assert result.map3(_add, Ok(1), Ok(2), Ok(3)) == Ok(6) + assert result.map3(_add, Ok(1), Ok(2), Err("z")) == Err("z") + assert result.map4(_add, Ok(1), Ok(2), Ok(3), Ok(4)) == Ok(10) + assert result.map4(_add, Ok(1), Err("b"), Ok(3), Err("d")) == Err("b") + assert result.map5(_add, Ok(1), Ok(2), Ok(3), Ok(4), Ok(5)) == Ok(15) + assert result.map5(_add, Ok(1), Ok(2), Ok(3), Ok(4), Err("e")) == Err("e") + + def test_and_then(self) -> None: + def half(n: int) -> Result[str, int]: + return Ok(n // 2) if n % 2 == 0 else Err("odd") + + assert result.and_then(half, Ok(4)) == Ok(2) + assert result.and_then(half, Ok(3)) == Err("odd") + assert result.and_then(half, Err("bad")) == Err("bad") + + def test_map_error(self) -> None: + assert result.map_error(str.upper, Err("bad")) == Err("BAD") + assert result.map_error(str.upper, Ok(1)) == Ok(1) + + def test_to_maybe(self) -> None: + assert result.to_maybe(Ok(1)) == Just(1) + assert result.to_maybe(Err("bad")) == Nothing() + + def test_from_maybe(self) -> None: + assert result.from_maybe("missing", Just(1)) == Ok(1) + assert result.from_maybe("missing", Nothing()) == Err("missing") + + +class TestFirstError: + def test_every_position_can_hold_the_first_error(self) -> None: + functions = { + 2: result.map2, + 3: result.map3, + 4: result.map4, + 5: result.map5, + } + for arity, function in functions.items(): + for position in range(arity): + args = [Err(f"e{i}") if i >= position else Ok(i) for i in range(arity)] + assert function(_add, *args) == Err(f"e{position}") diff --git a/tests/unit/sdk/test_review_invariants.py b/tests/unit/sdk/test_review_invariants.py new file mode 100644 index 00000000..afdbbbaa --- /dev/null +++ b/tests/unit/sdk/test_review_invariants.py @@ -0,0 +1,182 @@ +"""Invariants that keep invalid SDK states from being represented. + +These cover the raw `Dict` and `Set` constructors, mutable list keys, equality +over functions held inside SDK data types, and the real-number domain of +`basics.power`. +""" + +import math + +import pytest + +from morphir.sdk import basics +from morphir.sdk import dict as Dict # noqa: N812 +from morphir.sdk import set as Set # noqa: N812 +from morphir.sdk.maybe import Just, Nothing +from morphir.sdk.result import Err, Ok + + +class TestDictConstructorInvariant: + """The raw `Dict` constructor must not hold unsorted or repeated keys.""" + + def test_rejects_unsorted_entries(self) -> None: + with pytest.raises(ValueError, match="sorted"): + Dict.Dict((("z", 1), ("a", 2))) + + def test_rejects_a_key_that_appears_twice(self) -> None: + with pytest.raises(ValueError, match="sorted"): + Dict.Dict((("a", 1), ("a", 2))) + + def test_rejects_keys_that_are_not_comparable(self) -> None: + with pytest.raises(TypeError): + Dict.Dict(((1, "x"), ("a", "y"))) + + def test_accepts_sorted_entries(self) -> None: + d = Dict.Dict((("a", 2), ("z", 1))) + assert Dict.get("z", d) == Just(1) + assert Dict.get("a", d) == Just(2) + + def test_empty_needs_no_arguments(self) -> None: + assert Dict.Dict() == Dict.empty() + + +class TestSetConstructorInvariant: + """The raw `Set` constructor must not hold unsorted or repeated members.""" + + def test_rejects_unsorted_members(self) -> None: + with pytest.raises(ValueError, match="sorted"): + Set.Set((3, 1, 2)) + + def test_rejects_a_member_that_appears_twice(self) -> None: + with pytest.raises(ValueError, match="sorted"): + Set.Set((1, 1)) + + def test_accepts_sorted_members(self) -> None: + assert Set.member(2, Set.Set((1, 2, 3))) + + +class TestListKeysAreFrozen: + """A list used as a key is stored as a tuple, so a later change is harmless.""" + + def test_dict_key_does_not_follow_a_later_mutation(self) -> None: + first = [1] + d = Dict.from_list([(first, "one"), ([2], "two")]) + first[0] = 3 + assert Dict.get([1], d) == Just("one") + assert Dict.get([2], d) == Just("two") + assert Dict.get([3], d) == Nothing() + assert Dict.keys(d) == ((1,), (2,)) + + def test_nested_list_keys_are_frozen(self) -> None: + d = Dict.singleton([[1, 2], [3]], "v") + assert Dict.keys(d) == (((1, 2), (3,)),) + assert Dict.get(((1, 2), (3,)), d) == Just("v") + + def test_insert_freezes_the_key(self) -> None: + key = ["a", "b"] + d = Dict.insert(key, 1, Dict.empty()) + key.append("c") + assert Dict.get(["a", "b"], d) == Just(1) + + def test_dict_with_list_keys_is_hashable(self) -> None: + d = Dict.from_list([([1], "one")]) + assert hash(d) == hash(Dict.from_list([((1,), "one")])) + + def test_list_and_tuple_keys_build_equal_dicts(self) -> None: + assert Dict.from_list([([1], "x")]) == Dict.from_list([((1,), "x")]) + + def test_set_member_does_not_follow_a_later_mutation(self) -> None: + first = [1] + s = Set.from_list([first, [2]]) + first[0] = 3 + assert Set.member([1], s) + assert not Set.member([3], s) + assert Set.to_list(s) == ((1,), (2,)) + + def test_set_insert_and_singleton_freeze_the_member(self) -> None: + assert Set.to_list(Set.insert([2], Set.singleton([1]))) == ((1,), (2,)) + + def test_set_map_freezes_the_results(self) -> None: + assert Set.to_list(Set.map(lambda n: [n], Set.from_list([2, 1]))) == ( + (1,), + (2,), + ) + + +def _f(x: int) -> int: + return x + + +def _g(x: int) -> int: + return x + 1 + + +class TestEqualOverFunctionsInsideDataTypes: + """`equal` must fail on a function wherever it is held, as Elm does.""" + + def test_just(self) -> None: + with pytest.raises(TypeError): + basics.equal(Just(_f), Just(_f)) + + def test_ok_and_err(self) -> None: + with pytest.raises(TypeError): + basics.equal(Ok(_f), Ok(_g)) + with pytest.raises(TypeError): + basics.equal(Err(_f), Err(_f)) + + def test_dict_value(self) -> None: + d = Dict.singleton("k", _f) + with pytest.raises(TypeError): + basics.equal(d, d) + + def test_nested(self) -> None: + with pytest.raises(TypeError): + basics.equal((1, Just([Ok(_f)])), (1, Just([Ok(_f)]))) + + def test_not_equal_also_fails(self) -> None: + with pytest.raises(TypeError): + basics.not_equal(Just(_f), Nothing()) + + def test_data_without_functions_still_compares(self) -> None: + assert basics.equal(Just((1, Ok("a"))), Just((1, Ok("a")))) + assert not basics.equal(Just(1), Nothing()) + assert basics.equal(Dict.singleton("k", 1), Dict.singleton("k", 1)) + + +class TestPowerStaysReal: + """`power` follows JavaScript `Math.pow`: a real number, NaN or an infinity.""" + + def test_negative_base_with_fractional_exponent_is_nan(self) -> None: + result = basics.power(-1.0, 0.5) + assert isinstance(result, float) + assert math.isnan(result) + + def test_zero_to_a_negative_power_is_infinity(self) -> None: + assert basics.power(0.0, -1.0) == math.inf + assert basics.power(-0.0, -1.0) == -math.inf + assert basics.power(-0.0, -2.0) == math.inf + assert basics.power(0, -1) == math.inf + + def test_overflow_is_an_infinity(self) -> None: + assert basics.power(10.0, 400.0) == math.inf + assert basics.power(-10.0, 401.0) == -math.inf + + def test_integers_stay_exact_integers(self) -> None: + result = basics.power(2, 100) + assert isinstance(result, int) + assert result == 2**100 + assert basics.power(-2, 3) == -8 + assert basics.power(0, 0) == 1 + + def test_integer_with_negative_exponent_is_a_float(self) -> None: + assert basics.power(2, -1) == 0.5 + + def test_ordinary_floats(self) -> None: + assert basics.power(2.0, 10.0) == 1024.0 + assert basics.power(4.0, 0.5) == 2.0 + assert basics.power(-8.0, 3.0) == -512.0 + + def test_nan_and_one_follow_javascript(self) -> None: + assert math.isnan(basics.power(math.nan, 1.0)) + assert basics.power(math.nan, 0.0) == 1.0 + assert math.isnan(basics.power(1.0, math.inf)) diff --git a/tests/unit/sdk/test_set.py b/tests/unit/sdk/test_set.py new file mode 100644 index 00000000..62191e6d --- /dev/null +++ b/tests/unit/sdk/test_set.py @@ -0,0 +1,90 @@ +"""Tests for morphir.sdk.set (Morphir.SDK.Set).""" + +import pytest + +from morphir.sdk import basics +from morphir.sdk import set as set_ +from morphir.sdk.set import Set + +S123: Set[int] = set_.from_list((3, 1, 2)) + + +def _is_even(n: int) -> bool: + return n % 2 == 0 + + +class TestBuild: + def test_empty_and_singleton(self) -> None: + assert set_.to_list(set_.empty()) == () + assert set_.is_empty(set_.empty()) + assert set_.to_list(set_.singleton(1)) == (1,) + assert not set_.is_empty(set_.singleton(1)) + assert isinstance(set_.empty(), Set) + + def test_from_list_sorts_and_removes_duplicates(self) -> None: + assert set_.to_list(set_.from_list((3, 1, 2, 3, 1))) == (1, 2, 3) + assert set_.to_list(set_.from_list(("b", "B", "a"))) == ("B", "a", "b") + + def test_tuple_members(self) -> None: + s = set_.from_list(((2, "a"), (1, "b"), (1, "a"), (1, "b"))) + assert set_.to_list(s) == ((1, "a"), (1, "b"), (2, "a")) + assert set_.member((1, "b"), s) + assert not set_.member((3, "a"), s) + + def test_members_that_are_not_comparable_raise(self) -> None: + with pytest.raises(TypeError): + set_.from_list((1, "a")) # type: ignore[arg-type] + + def test_insert(self) -> None: + assert set_.to_list(set_.insert(0, S123)) == (0, 1, 2, 3) + assert set_.to_list(set_.insert(4, S123)) == (1, 2, 3, 4) + assert set_.insert(2, S123) == S123 + assert set_.to_list(S123) == (1, 2, 3) + + def test_remove(self) -> None: + assert set_.to_list(set_.remove(2, S123)) == (1, 3) + assert set_.remove(9, S123) == S123 + + +class TestQuery: + def test_member_and_size(self) -> None: + assert set_.member(1, S123) + assert not set_.member(9, S123) + assert set_.size(S123) == 3 + assert set_.size(set_.empty()) == 0 + + +class TestTransform: + def test_map_sorts_again_and_removes_duplicates(self) -> None: + assert set_.to_list(set_.map(lambda n: -n, S123)) == (-3, -2, -1) + assert set_.to_list(set_.map(lambda n: n // 2, S123)) == (0, 1) + + def test_foldl_ascending_and_foldr_descending(self) -> None: + assert set_.foldl(lambda n, acc: (*acc, n), (), S123) == (1, 2, 3) + assert set_.foldr(lambda n, acc: (*acc, n), (), S123) == (3, 2, 1) + + def test_filter_and_partition(self) -> None: + assert set_.to_list(set_.filter(_is_even, S123)) == (2,) + even, odd = set_.partition(_is_even, S123) + assert set_.to_list(even) == (2,) + assert set_.to_list(odd) == (1, 3) + + +class TestCombine: + def test_union_intersect_diff(self) -> None: + other = set_.from_list((2, 3, 4)) + assert set_.to_list(set_.union(S123, other)) == (1, 2, 3, 4) + assert set_.to_list(set_.intersect(S123, other)) == (2, 3) + assert set_.to_list(set_.diff(S123, other)) == (1,) + assert set_.to_list(set_.diff(other, S123)) == (4,) + + +class TestEquality: + def test_structural_equality_is_independent_of_insertion_order(self) -> None: + empty: Set[int] = set_.empty() + s1 = set_.insert(3, set_.insert(1, set_.insert(2, empty))) + s2 = set_.insert(1, set_.insert(2, set_.insert(3, empty))) + assert s1 == s2 + assert basics.equal(s1, S123) + assert hash(s1) == hash(s2) + assert not basics.equal(s1, set_.insert(4, s2)) diff --git a/tests/unit/sdk/test_spec_coverage.py b/tests/unit/sdk/test_spec_coverage.py new file mode 100644 index 00000000..c7a8a268 --- /dev/null +++ b/tests/unit/sdk/test_spec_coverage.py @@ -0,0 +1,385 @@ +"""Checks that every value in the Morphir.IR.SDK specification has a Python name. + +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`. +""" + +import importlib +import keyword +import re + +import pytest + +import morphir.sdk + +# Module name -> {Elm value name: Python attribute name}. +SPEC: dict[str, dict[str, str]] = { + "basics": { + "add": "add", + "subtract": "subtract", + "multiply": "multiply", + "divide": "divide", + "integerDivide": "integer_divide", + "power": "power", + "toFloat": "to_float", + "round": "round", + "floor": "floor", + "ceiling": "ceiling", + "truncate": "truncate", + "modBy": "mod_by", + "remainderBy": "remainder_by", + "negate": "negate", + "abs": "abs", + "clamp": "clamp", + "isNaN": "is_nan", + "isInfinite": "is_infinite", + "sqrt": "sqrt", + "logBase": "log_base", + "e": "e", + "pi": "pi", + "cos": "cos", + "sin": "sin", + "tan": "tan", + "acos": "acos", + "asin": "asin", + "atan": "atan", + "atan2": "atan2", + "degrees": "degrees", + "radians": "radians", + "turns": "turns", + "toPolar": "to_polar", + "fromPolar": "from_polar", + "equal": "equal", + "notEqual": "not_equal", + "lessThan": "less_than", + "greaterThan": "greater_than", + "lessThanOrEqual": "less_than_or_equal", + "greaterThanOrEqual": "greater_than_or_equal", + "max": "max", + "min": "min", + "compare": "compare", + "not": "not_", + "and": "and_", + "or": "or_", + "xor": "xor", + "append": "append", + "identity": "identity", + "always": "always", + "composeLeft": "compose_left", + "composeRight": "compose_right", + "never": "never", + }, + "char": { + "isUpper": "is_upper", + "isLower": "is_lower", + "isAlpha": "is_alpha", + "isAlphaNum": "is_alpha_num", + "isDigit": "is_digit", + "isOctDigit": "is_oct_digit", + "isHexDigit": "is_hex_digit", + "toUpper": "to_upper", + "toLower": "to_lower", + "toLocaleUpper": "to_locale_upper", + "toLocaleLower": "to_locale_lower", + "toCode": "to_code", + "fromCode": "from_code", + }, + "string": { + "isEmpty": "is_empty", + "length": "length", + "reverse": "reverse", + "repeat": "repeat", + "replace": "replace", + "append": "append", + "concat": "concat", + "split": "split", + "join": "join", + "words": "words", + "lines": "lines", + "slice": "slice", + "left": "left", + "right": "right", + "dropLeft": "drop_left", + "dropRight": "drop_right", + "contains": "contains", + "startsWith": "starts_with", + "endsWith": "ends_with", + "indexes": "indexes", + "indices": "indices", + "toInt": "to_int", + "fromInt": "from_int", + "toFloat": "to_float", + "fromFloat": "from_float", + "fromChar": "from_char", + "cons": "cons", + "uncons": "uncons", + "toList": "to_list", + "fromList": "from_list", + "toUpper": "to_upper", + "toLower": "to_lower", + "pad": "pad", + "padLeft": "pad_left", + "padRight": "pad_right", + "trim": "trim", + "trimLeft": "trim_left", + "trimRight": "trim_right", + "map": "map", + "filter": "filter", + "foldl": "foldl", + "foldr": "foldr", + "any": "any", + "all": "all", + }, + "list": { + "singleton": "singleton", + "repeat": "repeat", + "range": "range", + "cons": "cons", + "map": "map", + "indexedMap": "indexed_map", + "foldl": "foldl", + "foldr": "foldr", + "filter": "filter", + "filterMap": "filter_map", + "length": "length", + "reverse": "reverse", + "member": "member", + "all": "all", + "any": "any", + "maximum": "maximum", + "minimum": "minimum", + "sum": "sum", + "product": "product", + "append": "append", + "concat": "concat", + "concatMap": "concat_map", + "intersperse": "intersperse", + "map2": "map2", + "map3": "map3", + "map4": "map4", + "map5": "map5", + "sort": "sort", + "sortBy": "sort_by", + "sortWith": "sort_with", + "isEmpty": "is_empty", + "head": "head", + "tail": "tail", + "take": "take", + "drop": "drop", + "partition": "partition", + "unzip": "unzip", + "innerJoin": "inner_join", + "leftJoin": "left_join", + }, + "dict": { + "empty": "empty", + "singleton": "singleton", + "insert": "insert", + "update": "update", + "remove": "remove", + "isEmpty": "is_empty", + "member": "member", + "get": "get", + "size": "size", + "keys": "keys", + "values": "values", + "toList": "to_list", + "fromList": "from_list", + "map": "map", + "foldl": "foldl", + "foldr": "foldr", + "filter": "filter", + "partition": "partition", + "union": "union", + "intersect": "intersect", + "diff": "diff", + "merge": "merge", + }, + "set": { + "empty": "empty", + "singleton": "singleton", + "insert": "insert", + "remove": "remove", + "isEmpty": "is_empty", + "member": "member", + "size": "size", + "toList": "to_list", + "fromList": "from_list", + "map": "map", + "foldl": "foldl", + "foldr": "foldr", + "filter": "filter", + "partition": "partition", + "union": "union", + "intersect": "intersect", + "diff": "diff", + }, + "maybe": { + "andThen": "and_then", + "map": "map", + "map2": "map2", + "map3": "map3", + "map4": "map4", + "map5": "map5", + "withDefault": "with_default", + "hasValue": "has_value", + }, + "result": { + "andThen": "and_then", + "map": "map", + "map2": "map2", + "map3": "map3", + "map4": "map4", + "map5": "map5", + "withDefault": "with_default", + "toMaybe": "to_maybe", + "fromMaybe": "from_maybe", + "mapError": "map_error", + }, + "tuple": { + "pair": "pair", + "first": "first", + "second": "second", + "mapFirst": "map_first", + "mapSecond": "map_second", + "mapBoth": "map_both", + }, + "decimal": { + "fromInt": "from_int", + "fromFloat": "from_float", + "fromString": "from_string", + "hundred": "hundred", + "thousand": "thousand", + "million": "million", + "tenth": "tenth", + "hundredth": "hundredth", + "thousandth": "thousandth", + "millionth": "millionth", + "bps": "bps", + "toString": "to_string", + "toFloat": "to_float", + "add": "add", + "sub": "sub", + "negate": "negate", + "mul": "mul", + "div": "div", + "divWithDefault": "div_with_default", + "truncate": "truncate", + "round": "round", + "gt": "gt", + "gte": "gte", + "eq": "eq", + "neq": "neq", + "lt": "lt", + "lte": "lte", + "compare": "compare", + "abs": "abs", + "shiftDecimalLeft": "shift_decimal_left", + "shiftDecimalRight": "shift_decimal_right", + "zero": "zero", + "one": "one", + "minusOne": "minus_one", + }, + "int": { + "fromInt8": "from_int8", + "toInt8": "to_int8", + "fromInt16": "from_int16", + "toInt16": "to_int16", + "fromInt32": "from_int32", + "toInt32": "to_int32", + "fromInt64": "from_int64", + "toInt64": "to_int64", + }, + "number": { + "fromInt": "from_int", + "equal": "equal", + "notEqual": "not_equal", + "lessThan": "less_than", + "lessThanOrEqual": "less_than_or_equal", + "greaterThan": "greater_than", + "greaterThanOrEqual": "greater_than_or_equal", + "add": "add", + "subtract": "subtract", + "multiply": "multiply", + "divide": "divide", + "abs": "abs", + "negate": "negate", + "reciprocal": "reciprocal", + "coerceToDecimal": "coerce_to_decimal", + "toDecimal": "to_decimal", + "toFractionalString": "to_fractional_string", + "simplify": "simplify", + "isSimplified": "is_simplified", + "zero": "zero", + "one": "one", + }, +} + +# Values that Elm defines as constants, not functions. +CONSTANTS: dict[str, frozenset[str]] = { + "basics": frozenset({"e", "pi"}), + "decimal": frozenset({"zero", "one", "minus_one"}), + "number": frozenset({"zero", "one"}), +} + +# Elm names that do not follow the snake_case rule. +EXCEPTIONS: dict[str, str] = {"isNaN": "is_nan"} + +_CASES = [ + (module, elm_name, python_name) + for module, names in SPEC.items() + for elm_name, python_name in names.items() +] + + +def _to_snake_case(elm_name: str) -> str: + if elm_name in EXCEPTIONS: + return EXCEPTIONS[elm_name] + snake = re.sub(r"(?<=[a-z0-9])([A-Z])", r"_\1", elm_name).lower() + return f"{snake}_" if keyword.iskeyword(snake) else snake + + +class TestSpecCoverage: + def test_all_core_modules_are_listed(self) -> None: + assert sorted(SPEC) == sorted(morphir.sdk.__all__) + + def test_spec_size(self) -> None: + assert {module: len(names) for module, names in SPEC.items()} == { + "basics": 53, + "char": 13, + "string": 44, + "list": 39, + "dict": 22, + "set": 17, + "maybe": 8, + "result": 10, + "tuple": 6, + "decimal": 34, + "int": 8, + "number": 21, + } + + @pytest.mark.parametrize(("module", "elm_name", "python_name"), _CASES) + def test_value_exists(self, module: str, elm_name: str, python_name: str) -> None: + sdk_module = importlib.import_module(f"morphir.sdk.{module}") + assert hasattr(sdk_module, python_name), f"{module}.{python_name} is missing" + value = getattr(sdk_module, python_name) + if python_name in CONSTANTS.get(module, frozenset()): + assert not callable(value) + else: + assert callable(value), f"{module}.{python_name} is not callable" + + @pytest.mark.parametrize(("module", "elm_name", "python_name"), _CASES) + def test_name_follows_the_mapping_rule( + self, module: str, elm_name: str, python_name: str + ) -> None: + assert python_name == _to_snake_case(elm_name) + + @pytest.mark.parametrize("module", sorted(SPEC)) + def test_values_are_in_dunder_all(self, module: str) -> None: + sdk_module = importlib.import_module(f"morphir.sdk.{module}") + missing = set(SPEC[module].values()) - set(sdk_module.__all__) + assert not missing diff --git a/tests/unit/sdk/test_string.py b/tests/unit/sdk/test_string.py new file mode 100644 index 00000000..92c797a1 --- /dev/null +++ b/tests/unit/sdk/test_string.py @@ -0,0 +1,325 @@ +"""Tests for morphir.sdk.string (Morphir.SDK.String).""" + +import math + +from morphir.sdk import string +from morphir.sdk.maybe import Just, Nothing + + +def _is_digit(c: str) -> bool: + return "0" <= c <= "9" + + +class TestBasics: + def test_is_empty(self) -> None: + assert string.is_empty("") + assert not string.is_empty("the world") + + def test_length_counts_code_points(self) -> None: + assert string.length("innumerable") == 11 + assert string.length("") == 0 + assert string.length("😃") == 1 + + def test_reverse(self) -> None: + assert string.reverse("stressed") == "desserts" + assert string.reverse("") == "" + assert string.reverse("a😃b") == "b😃a" + + def test_repeat(self) -> None: + assert string.repeat(3, "ha") == "hahaha" + assert string.repeat(0, "ha") == "" + assert string.repeat(-2, "ha") == "" + + def test_replace_replaces_every_occurrence(self) -> None: + assert string.replace(".", "-", "Json.Decode.succeed") == "Json-Decode-succeed" + assert string.replace(",", "/", "a,b,c,d,e") == "a/b/c/d/e" + assert string.replace("x", "y", "abc") == "abc" + + def test_replace_with_an_empty_match_goes_between_chars(self) -> None: + assert string.replace("", "-", "abc") == "a-b-c" + assert string.replace("", "-", "") == "" + + +class TestBuildingAndSplitting: + def test_append(self) -> None: + assert string.append("butter", "fly") == "butterfly" + assert string.append("", "") == "" + + def test_concat(self) -> None: + assert string.concat(("never", "the", "less")) == "nevertheless" + assert string.concat(()) == "" + + def test_split(self) -> None: + assert string.split(",", "cat,dog,cow") == ("cat", "dog", "cow") + assert string.split("/", "home/evan/Desktop/") == ( + "home", + "evan", + "Desktop", + "", + ) + assert string.split(",", "") == ("",) + + def test_split_on_an_empty_separator_yields_chars(self) -> None: + assert string.split("", "abc") == ("a", "b", "c") + assert string.split("", "") == () + + def test_join(self) -> None: + assert string.join("a", ("H", "w", "ii", "n")) == "Hawaiian" + assert string.join(" ", ("cat", "dog", "cow")) == "cat dog cow" + assert string.join(",", ()) == "" + + def test_words_splits_on_whitespace_runs_after_trimming(self) -> None: + assert string.words("How are \t you? \n Good?") == ( + "How", + "are", + "you?", + "Good?", + ) + assert string.words(" leading and trailing ") == ( + "leading", + "and", + "trailing", + ) + assert string.words("") == ("",) + assert string.words(" ") == ("",) + + def test_lines(self) -> None: + assert string.lines("How are you?\nGood?") == ("How are you?", "Good?") + assert string.lines("a\r\nb\nc\rd") == ("a", "b", "c", "d") + assert string.lines("") == ("",) + assert string.lines("a\n") == ("a", "") + + +class TestSubstrings: + def test_slice_with_elm_negative_index_semantics(self) -> None: + assert string.slice(7, 9, "snakes on a plane!") == "on" + assert string.slice(0, 6, "snakes on a plane!") == "snakes" + assert string.slice(0, -7, "snakes on a plane!") == "snakes on a" + assert string.slice(-6, -1, "snakes on a plane!") == "plane" + assert string.slice(-3, -1, "abcde") == "cd" + assert string.slice(0, 3, "abcde") == "abc" + assert string.slice(3, 1, "abcde") == "" + assert string.slice(-100, 100, "abcde") == "abcde" + + def test_left(self) -> None: + assert string.left(2, "Mulder") == "Mu" + assert string.left(0, "Mulder") == "" + assert string.left(-1, "Mulder") == "" + assert string.left(10, "Mulder") == "Mulder" + + def test_right(self) -> None: + assert string.right(2, "Scully") == "ly" + assert string.right(0, "Scully") == "" + assert string.right(-1, "Scully") == "" + assert string.right(10, "Scully") == "Scully" + + def test_drop_left(self) -> None: + assert string.drop_left(2, "The Lone Gunmen") == "e Lone Gunmen" + assert string.drop_left(0, "abc") == "abc" + assert string.drop_left(-1, "abc") == "abc" + assert string.drop_left(10, "abc") == "" + + def test_drop_right(self) -> None: + assert string.drop_right(2, "Cigarette Smoking Man") == "Cigarette Smoking M" + assert string.drop_right(0, "abc") == "abc" + assert string.drop_right(-1, "abc") == "abc" + assert string.drop_right(10, "abc") == "" + + +class TestChecks: + def test_contains(self) -> None: + assert string.contains("the", "theory") + assert not string.contains("hat", "theory") + assert not string.contains("THE", "theory") + assert string.contains("", "theory") + + def test_starts_with(self) -> None: + assert string.starts_with("the", "theory") + assert not string.starts_with("ory", "theory") + + def test_ends_with(self) -> None: + assert not string.ends_with("the", "theory") + assert string.ends_with("ory", "theory") + + def test_indexes_yields_every_non_overlapping_match_start(self) -> None: + assert string.indexes("i", "Mississippi") == (1, 4, 7, 10) + assert string.indexes("ss", "Mississippi") == (2, 5) + assert string.indexes("needle", "haystack") == () + assert string.indexes("", "abc") == () + assert string.indexes("aa", "aaaa") == (0, 2) + + def test_indices_is_an_alias_of_indexes(self) -> None: + assert string.indices("i", "Mississippi") == (1, 4, 7, 10) + assert string.indices is string.indexes + + +class TestIntConversions: + def test_to_int(self) -> None: + assert string.to_int("123") == Just(123) + assert string.to_int("-42") == Just(-42) + assert string.to_int("+7") == Just(7) + assert string.to_int("0") == Just(0) + assert string.to_int("007") == Just(7) + + def test_to_int_rejects_everything_else(self) -> None: + for s in ("3.1", "1.5", "31a", "", " 1", "1 ", "-", "+", "0x10", "1_000", "٣"): + assert string.to_int(s) == Nothing(), s + + def test_from_int(self) -> None: + assert string.from_int(123) == "123" + assert string.from_int(-42) == "-42" + assert string.from_int(0) == "0" + + +class TestFloatConversions: + def test_to_float(self) -> None: + assert string.to_float("123") == Just(123.0) + assert string.to_float("-42") == Just(-42.0) + assert string.to_float("3.1") == Just(3.1) + assert string.to_float("+1.5") == Just(1.5) + assert string.to_float("1e3") == Just(1000.0) + assert string.to_float("1.5E-2") == Just(0.015) + + def test_to_float_yields_a_float(self) -> None: + result = string.to_float("123") + assert isinstance(result, Just) + assert isinstance(result.value, float) + + def test_to_float_rejects_everything_else(self) -> None: + for s in ( + "31a", + "", + " 1", + "1 ", + "1\n", + "0x10", + "0b1", + "0o7", + "-", + ".", + "1e", + "abc", + "nan", + "NaN", + "inf", + "infinity", + "1_000", + ): + assert string.to_float(s) == Nothing(), s + + def test_to_float_accepts_js_numeric_forms_like_elm(self) -> None: + assert string.to_float(".5") == Just(0.5) + assert string.to_float("1.") == Just(1.0) + assert string.to_float("Infinity") == Just(math.inf) + assert string.to_float("-Infinity") == Just(-math.inf) + + def test_from_float_prints_like_elm(self) -> None: + assert string.from_float(123.0) == "123" + assert string.from_float(-42.0) == "-42" + assert string.from_float(3.9) == "3.9" + assert string.from_float(1.0) == "1" + assert string.from_float(0.1 + 0.2) == "0.30000000000000004" + assert string.from_float(0.0) == "0" + assert string.from_float(-0.0) == "0" + assert string.from_float(1e21) == "1e+21" + assert string.from_float(1e20) == "100000000000000000000" + assert string.from_float(1.5e-7) == "1.5e-7" + assert string.from_float(0.000001) == "0.000001" + assert string.from_float(123456789012345680000.0) == "123456789012345680000" + assert string.from_float(math.nan) == "NaN" + assert string.from_float(math.inf) == "Infinity" + assert string.from_float(-math.inf) == "-Infinity" + + +class TestCharConversions: + def test_from_char(self) -> None: + assert string.from_char("a") == "a" + + def test_cons(self) -> None: + assert string.cons("T", "he truth is out there") == "The truth is out there" + assert string.cons("a", "") == "a" + + def test_uncons(self) -> None: + assert string.uncons("abc") == Just(("a", "bc")) + assert string.uncons("a") == Just(("a", "")) + assert string.uncons("") == Nothing() + assert string.uncons("😃x") == Just(("😃", "x")) + + def test_to_list(self) -> None: + assert string.to_list("abc") == ("a", "b", "c") + assert string.to_list("🙈🙉🙊") == ("🙈", "🙉", "🙊") + assert string.to_list("") == () + + def test_from_list(self) -> None: + assert string.from_list(("a", "b", "c")) == "abc" + assert string.from_list(()) == "" + + +class TestFormatting: + def test_to_upper_and_to_lower(self) -> None: + assert string.to_upper("skinner") == "SKINNER" + assert string.to_lower("X-FILES") == "x-files" + + def test_pad_puts_the_extra_character_on_the_left(self) -> None: + assert string.pad(5, " ", "1") == " 1 " + assert string.pad(5, " ", "11") == " 11 " + assert string.pad(5, " ", "121") == " 121 " + assert string.pad(5, ".", "1") == "..1.." + assert string.pad(5, ".", "11") == "..11." + assert string.pad(2, ".", "12345") == "12345" + + def test_pad_left(self) -> None: + assert string.pad_left(5, ".", "1") == "....1" + assert string.pad_left(5, ".", "11") == "...11" + assert string.pad_left(5, ".", "121") == "..121" + assert string.pad_left(2, ".", "12345") == "12345" + + def test_pad_right(self) -> None: + assert string.pad_right(5, ".", "1") == "1...." + assert string.pad_right(5, ".", "11") == "11..." + assert string.pad_right(5, ".", "121") == "121.." + assert string.pad_right(2, ".", "12345") == "12345" + + def test_trim(self) -> None: + assert string.trim(" hats \n") == "hats" + assert string.trim_left(" hats \n") == "hats \n" + assert string.trim_right(" hats \n") == " hats" + assert string.trim("") == "" + assert ( + string.trim("\N{ZERO WIDTH NO-BREAK SPACE}\N{NO-BREAK SPACE}x\N{EM SPACE}") + == "x" + ) + + def test_trim_keeps_control_chars_that_js_does_not_treat_as_space(self) -> None: + assert string.trim("\x1cx\x1f") == "\x1cx\x1f" + + +class TestHigherOrderHelpers: + def test_map(self) -> None: + assert string.map(lambda c: "." if c == "/" else c, "a/b/c") == "a.b.c" + assert string.map(str.upper, "") == "" + assert string.map(lambda c: "!" if c == "😃" else c, "a😃b") == "a!b" + + def test_filter(self) -> None: + assert string.filter(_is_digit, "R2-D2") == "22" + assert string.filter(_is_digit, "") == "" + + def test_foldl_calls_f_with_char_then_acc_from_the_left(self) -> None: + assert string.foldl(lambda c, acc: c + acc, "", "time") == "emit" + assert string.foldl(lambda _c, acc: acc + 1, 0, "time") == 4 + + def test_foldr_calls_f_with_char_then_acc_from_the_right(self) -> None: + assert string.foldr(lambda c, acc: c + acc, "", "time") == "time" + assert string.foldr(lambda c, acc: (*acc, c), (), "abc") == ("c", "b", "a") + + def test_any(self) -> None: + assert string.any(_is_digit, "90210") + assert string.any(_is_digit, "R2-D2") + assert not string.any(_is_digit, "heart") + assert not string.any(_is_digit, "") + + def test_all(self) -> None: + assert string.all(_is_digit, "90210") + assert not string.all(_is_digit, "R2-D2") + assert not string.all(_is_digit, "heart") + assert string.all(_is_digit, "") diff --git a/tests/unit/sdk/test_tuple.py b/tests/unit/sdk/test_tuple.py new file mode 100644 index 00000000..d3fe4d72 --- /dev/null +++ b/tests/unit/sdk/test_tuple.py @@ -0,0 +1,24 @@ +"""Tests for morphir.sdk.tuple (Morphir.SDK.Tuple).""" + +from morphir.sdk import tuple as tuple_ + + +class TestTuple: + def test_pair(self) -> None: + assert tuple_.pair(3, "a") == (3, "a") + + def test_first_and_second(self) -> None: + assert tuple_.first((3, "a")) == 3 + assert tuple_.second((3, "a")) == "a" + + def test_map_first(self) -> None: + assert tuple_.map_first(str.upper, ("stressed", 16)) == ("STRESSED", 16) + + def test_map_second(self) -> None: + assert tuple_.map_second(lambda n: n + 1, ("stressed", 16)) == ("stressed", 17) + + def test_map_both(self) -> None: + assert tuple_.map_both(str.upper, lambda n: n + 1, ("stressed", 16)) == ( + "STRESSED", + 17, + )