Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 41 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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+
Expand Down Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions features/morphir_sdk.feature
Original file line number Diff line number Diff line change
@@ -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 "<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"
91 changes: 91 additions & 0 deletions features/steps/sdk_steps.py
Original file line number Diff line number Diff line change
@@ -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}"
93 changes: 93 additions & 0 deletions packages/morphir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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+
Expand Down
80 changes: 80 additions & 0 deletions packages/morphir/src/morphir/sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading
Loading