Morphir
Python port of Morphir - functional domain modeling for the enterprise.
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
The morphir distribution contains the library and SDK with no third-party
runtime dependencies. Python code uses import morphir. Install morphir-python
separately if you need the CLI.
pip install morphir
Or with uv:
uv add morphir
Usage
from morphir.ir import Type, Value
# Example usage will be added as the library develops
Morphir IR versions
morphir.ir holds two first-class IR versions side by side:
| Package | IR | Source of truth | JSON codec |
|---|---|---|---|
morphir.ir.v3 |
format 3.0.0, what morphir-elm writes today, and its 3.1.0 revision | morphir-elm at bc99af69 for 3.0.0; finos/morphir at efa36060 for what 3.1.0 adds |
morphir.ir.v3.codec, matches morphir-elm byte for byte for 3.0.0 |
morphir.ir.v3 (legacy) |
formats 1 and 2, read-only | morphir-elm */CodecV1.elm at bc99af69; Value/CodecV2.elm at 02aa36af |
morphir.ir.v3.codec_v1, morphir.ir.v3.codec_v2, decoders only |
morphir.ir.v4 |
IR v4, draft upstream | finos/morphir at efa36060 |
morphir.ir.v4.codec, draft |
morphir.ir and its old submodule paths (morphir.ir.name, morphir.ir.value,
and so on) still re-export the v4 classes; morphir.ir.Name is
morphir.ir.v4.Name. The v3 classes are distinct, so a type checker reports a v4
node where a v3 node is expected.
from pathlib import Path
from morphir.ir import v3, v4
from morphir.ir.format_version import read_format_version
from morphir.ir.migrate import Ok, v3_to_v4, v4_to_v3
from morphir.ir.v3 import codec as v3_codec
from morphir.ir.v4 import codec as v4_codec
text = Path("tests/fixtures/ir/v3/elm-simpleValueTree.json").read_text()
library = v3_codec.decode_versioned_distribution(text)
assert isinstance(library, v3.Library)
upgraded = v3_to_v4(library, package_version="1.2.0")
assert isinstance(upgraded.value, v4.LibraryDistribution)
document = v4_codec.encode_versioned_distribution(upgraded.value)
assert document["formatVersion"] == 4
downgraded = v4_to_v3(upgraded.value)
assert isinstance(downgraded, Ok) and downgraded.value == library
assert read_format_version("3.0.0") == read_format_version(3)
specs = v3_codec.decode_versioned_distribution(
Path("tests/fixtures/ir/v3/mck-distributions-0011-specs.json").read_text()
)
assert isinstance(specs, v3.Specs)
assert v3_codec.encode_versioned_distribution(specs)["formatVersion"] == "3.1.0"
assert isinstance(v3_to_v4(specs).value, v4.SpecsDistribution)
IR 3.1.0 and the v3 Specs distribution
IR 3.1.0 is a minor revision of format 3
(finos/morphir decision record 0018).
It adds v3.Specs, a distribution that publishes a package's specification
without its definitions:
{"formatVersion": "3.1.0", "distribution": ["Specs", [["my"], ["pkg"]], [], {"modules": []}]}
A v3 distribution is v3.Library | v3.Specs, and
v3_codec.decode_versioned_distribution returns either. The rules are:
- A
Specsneeds3.1.0or later. ASpecsthat declares3,"3"or any"3.0.x"fails withspecs_before_3_1. ALibraryreads at every3.0.xand3.1.xrelease. - The modules of a
Specsdistribution's own package are module specifications. A member other thantypes,valuesanddoc, such as theaccessof a module definition, fails withunknown_member.typesandvaluesare required, as morphir-elm requires them in every module specification; morphir-rust lets them default to empty. - Writers emit the lowest version that fits:
3for aLibrary, even one read as"3.1.0", and"3.1.0"for aSpecs. Readers that stay on 3.0.0, such as morphir-elm, still read everyLibrarythis package writes. v3_to_v4turns a v3Specsinto a v4SpecsDistribution, andv4_to_v3turns a v4SpecsDistributioninto a v3Specs, with the diagnostics of aLibrary's dependencies.
Decision 0018 also adds v3 JSON and YAML document trees. This package has no
document tree reader or writer, for v3 or v4; bead morphir-ukvn.8.17 tracks
that work.
Reading formatVersion
morphir.ir.format_version reads the version of every IR document before any
decoder runs. It accepts the integer 3, the string "3", and the release
string "3.0.0", and normalizes all three to release 3.0.0. Writers are
canonical: integer N for N.0.0, otherwise the exact release string.
Accepting strings is a deliberate divergence, so that migration tools can read files written by other tools:
- morphir-elm decodes
formatVersionas an integer only, so it rejects"3"and"3.0.0"; - the finos/morphir format-version contract
accepts
3and"3.0.0"but rejects"3".
The support table is [3.0.0,3.2.0),[4.0.0,4.1.0), the finos reference table.
Formats 1 and 2 are not in it (see below) and fail there with
unsupported_format_version_major; a later 3.x minor such as "3.2.0" fails
with unsupported_format_version_minor.
Failures raise morphir.ir.diagnostics.DiagnosticError, whose diagnostics carry
the stable codes of the finos contract.
Legacy formats 1 and 2
morphir-elm wrote "formatVersion": 1 until October 2021 and 2 until May 2023.
The finos release grammar starts at major 3, so these legacy formats are outside
every support table. read_document_with_legacy and read_legacy_document
recognize them, only as the integers 1 and 2, and morphir.ir.v3.codec_v1 and
morphir.ir.v3.codec_v2 decode them into the format 3.0.0 model, as morphir-elm
does. Encoding the result with morphir.ir.v3.codec writes format 3, so decoding
is the upgrade. No writer emits format 1 or 2.
Format 2 is format 3.0.0 with snake_case value and pattern tags
("let_definition", "wildcard_pattern"). Format 1 is snake_case throughout and
also differs in access control, documentation, record fields, and package modules;
the codec_v1 docstring lists the differences. morphir-elm reads format 1 but no
longer reads format 2; reading format 2 here is a deliberate divergence.
from pathlib import Path
from morphir.ir.format_version import LegacyFormat, read_document_with_legacy
from morphir.ir.v3 import codec, codec_v1, codec_v2
legacy = Path("tests/fixtures/ir/legacy")
text = (legacy / "elm-rentals-format-1.json").read_text()
version, _ = read_document_with_legacy(text)
assert version is LegacyFormat.V1
library = codec_v1.decode_versioned_distribution(text)
same = codec_v2.decode_versioned_distribution(
(legacy / "elm-rentals-format-2.json").read_text()
)
assert library == same
assert codec.encode_versioned_distribution(library)["formatVersion"] == 3
Draft IR v4
IR v4 is not frozen upstream. morphir.ir.v4.codec follows the finos/morphir v4
JSON profile at commit efa36060 (FINOS_MORPHIR_PIN), and its spellings may
change in a later minor release of this package. It writes formatVersion 4 and
the distribution wrapped by its kind, never a format-3 shape. The module
docstring lists the gaps between the Python v4 model and upstream v4, each of
which fails with a diagnostic instead of guessing or dropping data.
The reader accepts every spelling the pinned schema allows. The writer emits the
compact canonical spelling that the profile prefers and Rust morphir-core
writes, so {"Reference": {"fqname": "morphir/SDK:basics#int"}} is written as
"morphir/SDK:basics#int". Pass spelling=v4_codec.Spelling.EXPANDED, or use
with v4_codec.use_spelling(v4_codec.Spelling.EXPANDED):, to write every node in
the expanded form. Both spellings decode to the same model.
The v4 model follows the upstream reference model at that commit:
- A value definition is one of four bodies (
ValueDefinitionBody): the expression bodyValueDefinition,NativeBody,ExternalBodyandIncompleteBody. Module values and let bindings hold any of them. IncompleteTypeDefinitionandIncompleteBodycarry anIncompleteness: a hole with a typedHoleReason(UnresolvedReference,DeletedDuringRefactor,TypeMismatch) or a draft. AHolevalue'sreasonis aHoleReasontoo.- Module, type and value specifications carry
annotations, compact (CompactAnnotation) or structured (StructuredAnnotation). morphir.ir.v4.type.Unionis the draft untagged union, with two or more alternatives.- The
NativeandExternalvalue nodes andTypeConstraintsare deprecated: upstream v4 cannot spell them.
from morphir.ir.v4 import Name, codec
from morphir.ir.v4 import type as t
from morphir.ir.v4 import value as v
int_type = t.Reference(
t.TypeAttributes(), codec.decode_fqname("morphir/SDK:basics#int")
)
add = v.NativeBody(
[(Name(("a",)), int_type), (Name(("b",)), int_type)],
int_type,
v.NativeInfo(v.Arithmetic()),
)
assert codec.decode_value_definition(codec.encode_value_definition(add)) == add
PackageInfo.version is optional (str | None). Neither format 3 nor v4 JSON has
a package release version, so the draft codec does not write it and decodes it as
absent. v3_to_v4 sets it only from package_version, and v4_to_v3 drops it
with the warning package_version_dropped.
Migrations
morphir.ir.migrate.v3_to_v4 always succeeds for a v3 Library or Specs.
v4_to_v3 returns Ok(value, warnings) or Err(diagnostics) and reports every
construct format 3 cannot hold in one pass: Application distributions, Hole,
Native, External, DocumentLiteral, native, external and incomplete
bodies, incomplete type definitions, Union, annotations, a missing
inferred_type, semantic attributes, and missing member documentation. Value
source locations fail unless you pass drop_source_locations=True.
v4 JSON spells a value-definition input as a bare type, so it has no place for
the per-input attributes of a typed v3 model. morphir-elm writes the resolved
type there, which can differ from the declared type (an alias expanded, a type
variable instantiated), and the draft codec then fails with no_v4_spelling.
Opt in to dropping them with v3_to_v4(..., drop_input_attributes=True),
drop_input_attributes(dist) for any v4 distribution, or
morphir-python ir convert --to v4 --drop-input-attributes. Each input that loses
information reports input_attributes_dropped; without the opt-in the in-memory
migration stays lossless.
from pathlib import Path
from morphir.ir.migrate import v3_to_v4
from morphir.ir.v3 import codec as v3_codec
from morphir.ir.v4 import codec as v4_codec
text = Path("tests/fixtures/ir/v3/elm-reference-model.json").read_text()
upgraded = v3_to_v4(
v3_codec.decode_versioned_distribution(text), drop_input_attributes=True
)
assert {w.code for w in upgraded.warnings} == {"input_attributes_dropped"}
assert v4_codec.encode_versioned_distribution(upgraded.value)["formatVersion"] == 4
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. 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 |
morphir.sdk.local_date |
Morphir.SDK.LocalDate |
datetime.date, no time zone |
morphir.sdk.local_time |
Morphir.SDK.LocalTime |
LocalTime, milliseconds from the epoch |
morphir.sdk.instant |
Morphir.SDK.Instant |
Instant, milliseconds from the epoch |
morphir.sdk.uuid |
Morphir.SDK.UUID |
uuid.UUID |
morphir.sdk.regex |
Morphir.SDK.Regex |
Regex over the re module |
morphir.sdk.aggregate |
Morphir.SDK.Aggregate |
Aggregation, Operator |
morphir.sdk.rule |
Morphir.SDK.Rule |
Callable[[A], Maybe[B]] |
morphir.sdk.key |
Morphir.SDK.Key |
int and flat tuples |
morphir.sdk.stateful_app |
Morphir.SDK.StatefulApp |
StatefulApp |
morphir.sdk.result_list |
Morphir.SDK.ResultList |
tuple[Result[E, A], ...] |
This is every module of the Morphir.SDK package specification. The Json
modules of morphir-elm are not part of that specification and are not included.
Notes on the modules beyond elm/core:
instant.Instantis its own class over a number of milliseconds, not an alias ofdatetime.datetime, so a naivedatetimecannot pass as an instant. Useinstant.from_datetimeandinstant.to_datetimeto convert.local_time.LocalTimeis a point in time, not a time of day. In Elm it is an alias ofTime.Posix, so it does not go around midnight.regexpatterns use Pythonresyntax, which differs from JavaScript in places (named groups are(?P<name>...),\dand\wfollow Unicode). The module docstring lists the differences.aggregateleaves out the part of the Elm module that inspects Morphir IR values (constructAggregationCall); the specification does not list it.aggregate.aggregateyields its rows in ascending key order.key.Key4and larger are flat tuples. Elm nests them only because an Elm tuple holds three elements at most; the order of two keys is the same.
Conventions
-
Names are the Elm names in snake_case:
withDefaultiswith_default,integerDivideisinteger_divide,toInt8isto_int8. A name that is a Python keyword gets a trailing underscore:not_,and_,or_. The one exception isisNaN, which isis_nan. The testtests/unit/sdk/test_spec_coverage.pylists 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
Sequenceand yield a tuple.Maybe,Result,Dict,SetandNumberare frozen dataclasses, so they compare by value and work withmatch. -
Module names are the Elm names in lower case, so
list,dict,set,int,tuple,stringanddecimalhave the name of a built-in or of a standard library module. This is safe because Python 3 imports are absolute:import decimalalways gives the standard library. (Do not run a file of thesdkdirectory as a script, or put that directory onsys.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:from morphir.sdk import dict as Dict from morphir.sdk import list as List
-
Comparable keys.
Dictkeys,Setmembers and the arguments ofbasics.compare,list.sortand so on must be numbers, strings, or tuples or lists of these. Other values (alsobool, and a mix ofintandstr) raiseTypeError, because Python has no compile-time check for Elm'scomparable. 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 aDictorSetwithfrom_list,singletonorinsert; the class constructor accepts only entries that are already sorted, and raisesValueErrorotherwise. -
Failures. Where Elm's runtime fails, the Python function raises:
basics.mod_by(0, x)raisesZeroDivisionError, andbasics.equalraisesTypeErroron a function, also one held inside a tuple, a list, aMaybe, aResult, aDictor aSet. -
Powers stay real.
basics.powerfollows JavaScriptMath.pow, as the Elm runtime does:power(-1.0, 0.5)isNaNandpower(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.
Intarithmetic never overflows,Int64covers the full 64-bit range, andNumberis exact at any size. - A
stris a sequence of code points, not of UTF-16 code units.string.length,string.sliceand 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)raisesZeroDivisionError; Elm yieldsNaN.basics.round,floor,ceilingandtruncateraise onNaNand the infinities, because the result is anint.char.to_locale_upperandchar.to_locale_lowerdo the same asto_upperandto_lower; Python has no locale rules for case.decimal.from_floatraisesValueErroronNaNand the infinities.decimal.shift_decimal_leftmoves 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 globaldecimalcontext is never read or changed.numbercomparisons give the correct answer for a negative denominator (the Elm runtime compares3/-4and1/2wrongly), andnumber.simplifyyields a positive denominator (3/-6becomes-1/2). As in Elm, arithmetic results are not reduced, so==on twoNumbervalues is structural; usenumber.equalto compare by value.
Requirements
- Python 3.14+
Design Principles
This library follows functional programming principles:
- Immutability: All data structures are immutable (frozen dataclasses)
- Type Safety: Complete type annotations with strict mypy/pyright checking
- Algebraic Data Types: Using unions and frozen dataclasses
- Making Illegal States Unrepresentable: Domain modeling prevents invalid states
License
Apache-2.0 - see the LICENSE file for details.
Related Packages
- morphir-python - CLI tools and extensions
Metadata
Release files for morphir 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| morphir-0.1.0.tar.gz | 120.0 kB | With provenance | Details for morphir-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| morphir-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for morphir-0.1.0-py3-none-any.whl |
Total release size: 274.6 kB
Release files / morphir-0.1.0.tar.gz
| Download URL | morphir-0.1.0.tar.gz |
|---|---|
| Size | 120.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8c33c29f101f43da3f1605358bc9e84cae8aa85fdbd347dd4b7b6d4680f8d521
|
|
BLAKE2b-256 checksum How to use checksums |
ebb247fe1d11fd10890e761bc18b9b5d0ad9aff7b5ed84ae90632073d971b4e8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logRelease files / morphir-0.1.0-py3-none-any.whl
| Download URL | morphir-0.1.0-py3-none-any.whl |
|---|---|
| Size | 154.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8c772e1f6fd33a9ae5ff56cee606347bf42a916026a1e5693ff9f91d4dacffb1
|
|
BLAKE2b-256 checksum How to use checksums |
48809ecb2712f558835ba78032c4d66f38919fe0c58e67ec97114fd7508206e8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logSecurity for morphir 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for morphir-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 28, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.