diff --git a/features/ir_interchange.feature b/features/ir_interchange.feature index c65372e4..63128812 100644 --- a/features/ir_interchange.feature +++ b/features/ir_interchange.feature @@ -1,7 +1,8 @@ Feature: IR interchange As a Morphir tooling user I want to inspect and convert versioned morphir-ir.json files - So that I can move models between IR format 3 (3.0.x and 3.1.x) and draft IR v4 + So that I can move models between IR format 3 (3.0.x and 3.1.x) and draft IR v4, + and upgrade files in the legacy formats 1 and 2 Scenario: Inspect a morphir-elm format 3 file Given the golden fixture "elm-simpleValueTree.json" @@ -67,3 +68,21 @@ Feature: IR interchange Then the command exits with code 1 And the errors contain "unsupported_v4_downgrade" And no converted file exists + + Scenario: Upgrade a legacy format 1 file to format 3 + Given the legacy fixture "elm-rentals-format-1.json" + When I run "ir inspect" on it + Then the command exits with code 0 + And the output contains "format: 1 (legacy)" + And the output contains "package: morphir/example/app" + When I convert it to "v3" + Then the command exits with code 0 + And the converted file declares formatVersion 3 + When I inspect the converted file + Then the output contains "format: 3.0.0" + + Scenario: Upgrade a legacy format 2 file to draft v4 + Given the legacy fixture "elm-rentals-format-2.json" + When I convert it to "v4" + Then the command exits with code 0 + And the converted file declares formatVersion 4 diff --git a/features/steps/ir_interchange_steps.py b/features/steps/ir_interchange_steps.py index 2e69beb3..81d4b57e 100644 --- a/features/steps/ir_interchange_steps.py +++ b/features/steps/ir_interchange_steps.py @@ -16,6 +16,7 @@ from behave.runner import Context FIXTURES = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "ir" / "v3" +LEGACY_FIXTURES = FIXTURES.parent / "legacy" def _workdir(context: Context) -> Path: @@ -35,6 +36,13 @@ def step_fixture(context: Context, name: str) -> None: context.fixture = context.input +@given('the legacy fixture "{name}"') +def step_legacy_fixture(context: Context, name: str) -> None: + """Select a morphir-elm format-1 or format-2 fixture as input.""" + context.input = LEGACY_FIXTURES / name + context.fixture = context.input + + @given("a file with the JSON '{text}'") def step_json_file(context: Context, text: str) -> None: """Write JSON text to an input file.""" diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/.openspec.yaml b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/.openspec.yaml new file mode 100644 index 00000000..dda53b8a --- /dev/null +++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/.openspec.yaml @@ -0,0 +1,2 @@ +schema: html-design +created: 2026-10-27 diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/design.html b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/design.html new file mode 100644 index 00000000..2a6c17d4 --- /dev/null +++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/design.html @@ -0,0 +1,210 @@ + + + + + +add-ir-formats-1-2: design + + + + + + + + +
+

add-ir-formats-1-2: design

+

OpenSpec change · Bead morphir-ukvn.8.12 · 2026-10-27 · draft

+ + + +

Context

+

See proposal.md for the motivation. Today morphir.ir.format_version normalizes the integers +1 and 2 to releases 1.0.0 and 2.0.0 only so that check_supported can refuse +them with unsupported_format_version_major ("formats 1 and 2 are follow-up work"). The support table is +[3.0.0,3.2.0),[4.0.0,4.1.0) since bead morphir-ukvn.8.13 (IR 3.1.0, PR #34) merged; this +change does not touch the table. That change also made the v3 distribution Library | Specs; the legacy +formats only ever produce a Library, because Specs needs format 3.1.0.

+

The finos/morphir format-version contract (kb/bundles/morphir/morphir-ir/format-version-support.md) +says the release grammar starts at major 3. A support table therefore cannot hold format 1 or 2.

+

morphir-elm at bc99af69 is the source. Its FormatVersion/Codec.elm reads format 3 +with Distribution.Codec, format 1 with Distribution.CodecV1, and refuses everything +else, format 2 included, with "Please regenerate it!".

+ +

What formats 1 and 2 are

+

From the morphir-elm history:

+ + + + + + + + +
CommitDateEffect
8e0e729d2021-10-08Copies the codecs to */CodecV1.elm, sets +currentFormatVersion = 2, and decodes format 1 with CodecV1.
e742a6202021-10-08Format 2 shapes: TitleCase distribution, access, type and +literal tags; {"access", "value"}; {"doc", "value"}; [path, module] package +entries; {"name", "tpe"} fields.
02aa36af2023-05-18Format 3: value and pattern tags become TitleCase. The old +value codec is kept as Value/CodecV2.elm with Distribution/CodecV2.elm.
7cb7b3002023-05-18Removes the V2 codecs. Format 2 is refused from then +on.
+

git diff 02aa36af^ bc99af69 over src/Morphir/IR/*/Codec.elm shows that only the value +codec changed shape between format 2 and today's format 3 (the Decoration, Component and Source codecs also +changed, and the Library distribution uses none of them). So format 2 is format 3.0.0 with snake_case value and pattern tags.

+

The removed Distribution/CodecV2.elm called Package.Codec, which by then used the TitleCase +value codec, so elm's own format-2 reader could not read real format-2 values. This design ports the format, not +that reader.

+ +

Goals and non-goals

+

Goals: read formats 1 and 2 exactly as morphir-elm wrote them, into the existing v3 model; let +the CLI upgrade them; keep the support table, the diagnostic codes, and the format 3.0.0 codec as they are.

+

Non-goals: writing formats 1 or 2; files with no formatVersion (still +missing_format_version); the string spellings "1" and "2", which no tool +wrote; a separate Python model for the legacy formats.

+ +

Decisions

+ +

FV-2. A legacy path beside the support table

+

format_version gains a LegacyFormat enum (V1, V2) and two +readers:

+
def recognize_legacy_format(value: object) -> LegacyFormat | None: ...
+
+def read_document_with_legacy(
+    document: str | object, table: SupportTable = SUPPORT_TABLE
+) -> tuple[FormatVersion | LegacyFormat, dict[str, object]]: ...
+
+def read_legacy_document(document: str | object, accepted: LegacyFormat) -> dict[str, object]: ...
+

Only a JSON integer 1 or 2 (not a boolean) is legacy. Every other value goes through +read_format_version unchanged, so it fails with the same codes. read_format_version and +read_document keep refusing 1 and 2 with unsupported_format_version_major; only the +message changes, from "follow-up work" to the names of the legacy readers. read_legacy_document refuses +any version but the one it is asked for with unsupported_format_version_major. No new diagnostic code +is needed.

+

Alternatives. Adding [1.0.0,1.1.0) and [2.0.0,2.1.0) to the table breaks +the finos grammar and changes the table spelling that tools and reports compare. A boolean flag on +read_document would put the legacy rule inside the code path that bead morphir-ukvn.8.13 +edits. The new functions sit at the end of the module; the only shared edits are a message string, a refactor of +read_document into a private root reader, and a guard in write_format_version.

+ +

FV-3. Writers never emit 1 or 2

+

write_format_version raises ValueError for a major below 3. A +FormatVersion(1, 0, 0) can still come out of recognize_format_version, so the guard keeps a +mistake from writing it. The legacy modules have no encoders.

+ +

CDC-1. Legacy decoders in their own modules, into the v3 model

+

As in elm, the legacy decoders produce the same model as format 3.0.0, so decoding is the upgrade and +morphir.ir.migrate needs no new function. The modules are:

+ + + + + + + +
ModulePortsPublic decoders
morphir.ir.v3.codec_v1morphir-elm */CodecV1.elm at bc99af69 +(decoders)literal, type, type spec and definition, access control, documented, pattern, value, value spec +and definition, module, package, distribution, versioned distribution
morphir.ir.v3.codec_v2Value/CodecV2.elm at 02aa36af, with +the format 3.0.0 codec for the restpattern, value, value definition, module and package definition, +distribution, versioned distribution
morphir.ir.v3._legacy_value (private)The snake_case value and pattern decoders that +formats 1 and 2 shareBuilt from a literal decoder and a type decoder, because literals and types differ +between the two formats
+

The format 3.0.0 codec is not edited: it still refuses 1 and 2, and bead morphir-ukvn.8.13 can change +it without conflicts. The legacy modules import its primitive helpers (_tag, _field, and so on) +rather than copy them.

+

Alternatives. A tag-renaming JSON rewrite cannot work, because "variable", +"reference", "tuple", "record" and "unit" are both type and value +tags and the shapes around them differ. Parameterizing the 3.0.0 codec by spelling would branch inside it.

+ +

CDC-2. Faithful to the elm decoders, including their quirks

+ + +

CDC-3. morphir-elm wins over the finos v2 schema docs

+

finos/morphir docs/spec/ir/schemas/v2/whats-new.md says format 2 literals stay snake_case and names +an integer literal whole_number_literal. morphir-elm wrote "StringLiteral" in format 2 (see the +rentals fixture) and "int_literal" in format 1. As for format 3, morphir-elm is normative.

+ +

CLI-2. Dispatch in the CLI

+

ir inspect and ir convert read the root with read_document_with_legacy and +choose codec_v1, codec_v2, the 3.0.0 codec or the v4 codec. Inspect prints +format: 1 (legacy) and a note that ir convert upgrades the file. Convert writes format +3.0.0 or, through v3_to_v4, draft v4. The Target enum stays v3 and +v4: there is no way to ask for format 1 or 2.

+ +

TST-2. Real fixtures, one model in two formats

+

morphir-elm tests-integration/cli/test-data/rentals/ has the same example written as format 1 +(expected-morphir-ir-v1.json) and as format 2 (expected-morphir-ir.json). Both are copied +byte for byte to tests/fixtures/ir/legacy/. Decoding both must give the same Library, which checks the +two decoders against each other. Hand-written JSON covers every tag that the small fixtures do not use, with the +spellings taken from the elm source.

+ +

Spelling table

+ + + + + + + + + + + + + + +
ConstructFormat 1Format 2Format 3.0.0
Distribution"library""Library""Library"
Access control["public", x]{"access": "Public", "value": x}same as 2
Documented[doc, x] or x{"doc", "value"} or xsame as 2
Package modules{"name", "spec"}, {"name", "def"}[path, module]same as 2
Module docnoneoptional "doc"optional "doc"
Type tags"variable", "extensible_record", ..."Variable", "ExtensibleRecord", ...same as 2
Record field[name, type]{"name", "tpe"}same as 2
Type spec and definition"custom_type_definition", ..."CustomTypeDefinition", ...same as 2
Literals"int_literal", "bool_literal", ..."WholeNumberLiteral", "BoolLiteral", ...same as 2
Value and pattern tags"let_definition", "wildcard_pattern", ...same as 1"LetDefinition", "WildcardPattern", ...
+ +

Risks and trade-offs

+ + + + + + + + +
RiskMitigation
Format-1 files written before later CodecV1 edits (value documentation in 2022, decimals) may differ from +what the elm decoder reads today.The port follows today's elm decoder, which is what morphir-elm users get. +Documented entries accept the bare value, which covers files written before value documentation.
The fixtures are small and use few tags.Per-tag unit tests with hand-written JSON for every +type, value, pattern, literal and wrapper tag.
Importing private helpers from codec.py couples the modules.The helpers are stable +JSON-shape checks. Moving them to a shared module would edit codec.py, which bead +morphir-ukvn.8.13 edits in parallel; that can be a later refactor.
Rebase conflicts with IR 3.1.0 in format_version.py, the CLI, and the specs.The +support table and the 3.1 message are not touched; the legacy code is appended in its own section. This branch +was rebased on PR #34 and its spec deltas rewritten against the merged specs.
+ +

Migration plan

+

No data migration. Users who got unsupported_format_version_major for a format-1 or format-2 file +can now run morphir-tools ir convert old.json --to v3 -o new.json. Rollback is a revert: no stored +data or public name is removed.

+ +
+ + diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/proposal.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/proposal.md new file mode 100644 index 00000000..e9e1c4d7 --- /dev/null +++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/proposal.md @@ -0,0 +1,37 @@ +# Proposal + +## Why + +morphir-elm wrote `"formatVersion": 1` until October 2021 and `"formatVersion": 2` until May 2023, and old `morphir-ir.json` files in those formats still exist. morphir-python refuses both with `unsupported_format_version_major`, so a user with an old file must find an old morphir-elm to regenerate it. morphir-elm still reads format 1 through its `CodecV1` modules; this change gives morphir-python the same ability, extends it to format 2, and upgrades both to format 3.0.0. + +## What Changes + +- Add read-only decoders for format 1 (port of morphir-elm `*/CodecV1.elm` at `bc99af69`) and format 2 (format 3.0.0 shapes with the snake_case value and pattern tags of morphir-elm `Value/CodecV2.elm` at `02aa36af`). Both decode into the existing format 3.0.0 model, as elm does, so encoding the result with the 3.0.0 codec is the upgrade. +- Reading format 2 is a deliberate divergence from morphir-elm today, which refuses it ("Please regenerate it!"). +- Teach the shared format-version reader to recognize the legacy integers `1` and `2` through a separate legacy path. The support table, its spelling, and the existing diagnostic codes stay as they are: the finos release grammar starts at major 3, so formats 1 and 2 never enter a support table, and `read_format_version` still refuses them. No writer emits format 1 or 2. +- `morphir-tools ir inspect` reports legacy files, and `ir convert` upgrades them to format 3.0.0 or draft v4. Today both commands refuse formats 1 and 2. +- Add the morphir-elm rentals example in format 1 and format 2 as golden fixtures, with provenance. + +## Capabilities + +### New Capabilities + +- (none) + +### Modified Capabilities + +- `ir/format-version`: formats 1 and 2 are recognized as legacy, read-only formats outside the support table, instead of being refused as follow-up work; legacy formats are never written. +- `ir/v3`: legacy decoders read formats 1 and 2 into the format 3.0.0 model. +- `tools/ir-interchange`: inspect and convert accept formats 1 and 2 and upgrade them; later 3.x minors (3.2.0 on) stay refused. + +## Beads + +- **Tracked by**: `morphir-ukvn.8.12` +- **Related**: `morphir-ukvn.8` (parent epic, first-class IR v3), `morphir-ukvn.8.13` (IR 3.1.0, merged first; it set the support table to `[3.0.0,3.2.0),[4.0.0,4.1.0)` and added the v3 `Specs` distribution, which legacy formats never produce) + +## Impact + +- `packages/morphir`: `ir.format_version` gains `LegacyFormat`, `recognize_legacy_format`, `read_document_with_legacy` and `read_legacy_document`; `write_format_version` refuses majors 1 and 2. New modules `ir.v3.codec_v1`, `ir.v3.codec_v2` and the private `ir.v3._legacy_value`. The 3.0.0 codec and the v4 package do not change. No new dependency. +- `packages/morphir-tools`: `ir inspect` and `ir convert` dispatch on legacy formats. +- Tests and fixtures: `tests/fixtures/ir/legacy/` with two morphir-elm files; unit, CLI and BDD tests. The CLI tests that expected formats 1 and 2 to be refused change. +- Documentation: the `morphir` and `morphir-tools` READMEs. diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/format-version/spec.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/format-version/spec.md new file mode 100644 index 00000000..8ed4ca94 --- /dev/null +++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/format-version/spec.md @@ -0,0 +1,53 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Support table with IR 3.1 is checked before decoding +The reader SHALL declare the support table `[3.0.0,3.2.0),[4.0.0,4.1.0)` and MUST finish recognition and the support check before invoking any distribution decoder or migration. A release outside the table SHALL fail with `unsupported_format_version_major` or `unsupported_format_version_minor`. (bead: morphir-ukvn.8.13) + +#### Scenario: IR 3.1.0 is supported +- **WHEN** a document declares `formatVersion` `"3.1.0"` or `"3.1.4"` +- **THEN** the support check passes and the distribution is decoded + +#### Scenario: A later 3.x minor is refused +- **WHEN** a document declares `formatVersion` `"3.2.0"` +- **THEN** reading fails with `unsupported_format_version_minor` before the distribution is decoded, and the message names the accepted table `[3.0.0,3.2.0),[4.0.0,4.1.0)` + +#### Scenario: Historical versions are refused +- **WHEN** a document declares `formatVersion` `1` or `2` and is read against a support table +- **THEN** reading fails with `unsupported_format_version_major` and a message that names the legacy readers for formats 1 and 2 + +### Requirement: Canonical format version output +Writers SHALL emit `formatVersion` first, as the integer `N` for a baseline release `N.0.0` and as the exact release string otherwise, regardless of the spelling that was read. Writers MUST NOT emit the legacy formats 1 and 2. (bead: morphir-ukvn.8) + +#### Scenario: Read string, write integer +- **WHEN** a document read with `formatVersion` `"3.0.0"` is written again +- **THEN** the output declares `formatVersion` as the integer `3` + +#### Scenario: Legacy versions are never written +- **WHEN** a caller asks for the spelling of format 1 or 2 +- **THEN** the writer refuses instead of returning `1` or `2` + +## ADDED Requirements + +### Requirement: Legacy formats are recognized outside the support table +A legacy-aware read SHALL recognize the JSON integers `1` and `2` as legacy formats 1 and 2 and return them without a support check, because the finos release grammar starts at major 3. Every other value SHALL be read and checked as before, with the same diagnostics. The strings `"1"`, `"2"` and `"1.0.0"` MUST stay invalid, because morphir-elm wrote legacy formats only as integers. (bead: morphir-ukvn.8.12) + +#### Scenario: Integer legacy version +- **WHEN** a legacy-aware read sees `formatVersion` `1` or `2` +- **THEN** it reports legacy format 1 or 2 and does not consult the support table + +#### Scenario: Other versions keep their diagnostics +- **WHEN** a legacy-aware read sees `formatVersion` `"1"`, `true`, `5`, or no `formatVersion` +- **THEN** it fails with `invalid_format_version_syntax`, `invalid_format_version_type`, `unsupported_format_version_major`, or `missing_format_version`, as the table reader does + +#### Scenario: Support table keeps the release grammar +- **WHEN** a caller reads the support table +- **THEN** every interval starts at major 3 or above and its spelling is unchanged + +### Requirement: A legacy reader accepts exactly one legacy format +A reader for one legacy format SHALL accept a document only when its `formatVersion` is that legacy integer. Any other recognized version SHALL fail with `unsupported_format_version_major`, naming the version found and the one legacy format accepted. (bead: morphir-ukvn.8.12) + +#### Scenario: Format 3 at the format-1 reader +- **WHEN** a document with `formatVersion` 3 is passed to the format-1 reader +- **THEN** reading fails with `unsupported_format_version_major` and says that only legacy format 1 is accepted diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/v3/spec.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/v3/spec.md new file mode 100644 index 00000000..e10c75bf --- /dev/null +++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/v3/spec.md @@ -0,0 +1,48 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Format 1 is read into the format 3.0.0 model +The library SHALL decode `"formatVersion": 1` documents with the spellings of the morphir-elm `*/CodecV1.elm` decoders at `bc99af69` into a v3 `Library`, never a `Specs`: snake_case tags, `["public", x]` access control, `[doc, x]` or bare documentation, `[name, type]` record fields, `{"name", "spec"}` and `{"name", "def"}` package modules, and no module documentation. (bead: morphir-ukvn.8.12) + +#### Scenario: morphir-elm format-1 output decodes +- **WHEN** the morphir-elm rentals example written as format 1 is decoded +- **THEN** decoding succeeds and gives a v3 Library with the package name `morphir/example/app` + +#### Scenario: Format-3 tags are refused in format 1 +- **WHEN** a format-1 type, value, literal, or distribution uses a TitleCase tag such as `"Variable"` or `"Library"` +- **THEN** decoding fails with `unknown_node` + +#### Scenario: Documentation pair or bare value +- **WHEN** a format-1 documented entry is `["a doc", x]` or the bare `x` +- **THEN** it decodes with doc `"a doc"` or `""`, and when both readings fail the error carries the diagnostics of both + +#### Scenario: Derived types use the elm decoder spelling +- **WHEN** a format-1 type specification is tagged `"derived_type_specification"` +- **THEN** it decodes, while `"DerivedTypeSpecification"`, which only the elm encoder wrote, fails with `unknown_node` + +### Requirement: Format 2 is read into the format 3.0.0 model +The library SHALL decode `"formatVersion": 2` documents whose shapes are those of format 3.0.0 except that value and pattern tags are snake_case, as in morphir-elm `Value/CodecV2.elm` at `02aa36af`. Literals and types SHALL use their format-3 TitleCase tags, as morphir-elm wrote them. Reading format 2 is a deliberate divergence from morphir-elm, which now refuses it. (bead: morphir-ukvn.8.12) + +#### Scenario: morphir-elm format-2 output decodes +- **WHEN** the morphir-elm rentals example written as format 2 is decoded +- **THEN** decoding succeeds and gives a v3 Library + +#### Scenario: Mixed spellings are refused +- **WHEN** a format-2 value uses the tag `"Literal"`, or a literal inside it uses `"bool_literal"` +- **THEN** decoding fails with `unknown_node` + +### Requirement: Legacy formats upgrade to format 3.0.0 +Formats 1 and 2 SHALL decode into the same v3 model as format 3.0.0, so encoding the result with the format 3.0.0 codec is the upgrade, and the v3-to-v4 migration applies unchanged. The library MUST NOT provide encoders for formats 1 and 2, and the format 3.0.0 decoder SHALL keep refusing them. (bead: morphir-ukvn.8.12) + +#### Scenario: Both legacy formats give one model +- **WHEN** the rentals example is decoded from its format-1 file and from its format-2 file +- **THEN** both give the same v3 Library + +#### Scenario: Upgrade writes format 3 +- **WHEN** a decoded legacy document is encoded with the format 3.0.0 codec +- **THEN** the output declares `formatVersion` 3 and decodes back to the same Library + +#### Scenario: The format 3.0.0 entry point stays strict +- **WHEN** a format-1 or format-2 document is passed to the format 3.0.0 decoder +- **THEN** decoding fails with `unsupported_format_version_major` diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/tools/ir-interchange/spec.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/tools/ir-interchange/spec.md new file mode 100644 index 00000000..9360ff5c --- /dev/null +++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/tools/ir-interchange/spec.md @@ -0,0 +1,56 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Inspect versioned morphir-ir.json +The `ir inspect` command SHALL read a file with the shared format-version reader, decode it with the matching v3 or v4 codec, and report the normalized format version, the distribution kind, and the package name. A legacy format 1 or 2 file SHALL be decoded with its legacy decoder and reported as legacy. (bead: morphir-ukvn.8.13) + +#### Scenario: Report format version 3 +- **WHEN** the user inspects a morphir-elm `morphir-ir.json` with `formatVersion` 3 +- **THEN** the tool reports format 3.0.0, kind Library, and the package name, and exits 0 + +#### Scenario: Report a v3 Specs +- **WHEN** the user inspects a file with `formatVersion` `"3.1.0"` and a `Specs` distribution +- **THEN** the tool reports format 3.1.0, kind Specs, and the package name, and exits 0 + +#### Scenario: Unversioned IR is rejected clearly +- **WHEN** the input JSON has no `formatVersion` member +- **THEN** the tool exits non-zero, reports `missing_format_version`, and says the IR should be regenerated + +#### Scenario: Draft v4 is labelled +- **WHEN** the user inspects a v4 file +- **THEN** the report states that v4 support is draft + +#### Scenario: Legacy format is labelled +- **WHEN** the user inspects a morphir-elm file with `formatVersion` 1 or 2 +- **THEN** the tool reports the legacy format, kind Library, and the package name, says that `ir convert` upgrades it to format 3.0.0, and exits 0 + +## ADDED Requirements + +### Requirement: Legacy files are upgraded, never written +The `ir convert` command SHALL read format 1 and format 2 files with the library legacy decoders and write them as a format 3.0.0 `Library` (`--to v3`) or draft v4 (`--to v4`, through the v3-to-v4 migration). A legacy file SHALL never become a `Specs` distribution, and no target SHALL write format 1 or 2. (bead: morphir-ukvn.8.12) + +#### Scenario: Upgrade a legacy file to format 3 +- **WHEN** the user converts the morphir-elm rentals example in format 1 or format 2 with `--to v3` +- **THEN** the tool writes a `formatVersion` 3 Library file, the same file for both inputs, and exits 0 + +#### Scenario: Upgrade a legacy file to v4 +- **WHEN** the user converts a format-1 file with `--to v4` +- **THEN** the tool writes a `formatVersion` 4 file and exits 0 + +#### Scenario: A broken legacy file leaves no output +- **WHEN** the user converts a format-1 file whose distribution uses a format-3 tag +- **THEN** the tool exits non-zero with `unknown_node` and does not create the output file + +### Requirement: Scope excludes later 3.x minors +The tools MUST reject any 3.x release from 3.2.0 on, and any major outside the support table other than the legacy formats 1 and 2, rather than load or convert them. (bead: morphir-ukvn.8.12) + +#### Scenario: Unsupported format version is named +- **WHEN** the user supplies a file whose `formatVersion` is `"3.2.0"` or 5 +- **THEN** the tool exits non-zero with the format-version diagnostic code and says only 3.0.x, 3.1.x and draft 4.0.x are supported + +## REMOVED Requirements + +### Requirement: Scope excludes formats 1 and 2 and later 3.x minors +**Reason**: Formats 1 and 2 are now read and upgraded. The refusal of later 3.x minors moves to the requirement "Scope excludes later 3.x minors". +**Migration**: Convert format 1 and 2 files with `ir convert --to v3`. diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/tasks.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/tasks.md new file mode 100644 index 00000000..493b638b --- /dev/null +++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/tasks.md @@ -0,0 +1,30 @@ +# Tasks + +## 1. Legacy format-version reading (morphir-ukvn.8.12.1) + +- [x] 1.1 Add `LegacyFormat`, `recognize_legacy_format`, `read_document_with_legacy` and `read_legacy_document` to `morphir.ir.format_version` without changing the support table; verify with `tests/unit/ir/test_format_version.py::TestLegacy` +- [x] 1.2 Name the legacy readers in the `unsupported_format_version_major` message for 1 and 2, and make `write_format_version` refuse majors 1 and 2; verify with the updated `TestSupport` and `TestWriting` tests +- [x] 1.3 Document the legacy path in the `format_version` module docstring; verify that the doctests in `tests/unit/ir/test_ir_docs.py` pass + +## 2. Format 1 and format 2 decoders (morphir-ukvn.8.12.2) + +- [x] 2.1 Copy the morphir-elm rentals example in format 1 and format 2 to `tests/fixtures/ir/legacy/` with a `PROVENANCE.md`, and point the v3 provenance note at it; verify that the files match the morphir-elm `bc99af69` files byte for byte +- [x] 2.2 Add the shared snake_case value and pattern decoders in `morphir.ir.v3._legacy_value`; verify through the value and pattern tests of 2.3 and 2.4 +- [x] 2.3 Port the CodecV1 decoders to `morphir.ir.v3.codec_v1`; verify every tag and wrapper with `tests/unit/ir/test_v3_legacy_codec.py` (format-1 classes) +- [x] 2.4 Add the format-2 decoders in `morphir.ir.v3.codec_v2`; verify with the `TestFormat2` tests +- [x] 2.5 Check both rentals fixtures decode to the same Library, upgrade to format 3 and v4, and stay refused by the 3.0.0 decoder; verify with `TestElmFixtures` + +## 3. CLI upgrade of legacy files (morphir-ukvn.8.12.3) + +- [x] 3.1 Make `ir inspect` report legacy files and `ir convert` upgrade them to v3 and v4; verify with the legacy tests in `tests/tools/test_cli.py`, which replace the tests that expected 1 and 2 to be refused +- [x] 3.2 Add BDD scenarios for inspecting and upgrading a legacy file to `features/ir_interchange.feature`; verify with `mise run test-bdd` + +## 4. Docs and integration checks (morphir-ukvn.8.12.4) + +- [x] 4.1 Describe legacy formats 1 and 2 in `packages/morphir/README.md` and `packages/morphir-tools/README.md`, and list the new modules in the `morphir.ir.v3` docstring; verify with `tests/unit/ir/test_ir_docs.py` +- [x] 4.2 Run `mise run check` and `mise run test-bdd`; verify lint, format, mypy, pyright, tests and coverage of at least 80% pass + +## Workflow follow-up + +- Archive the change in the same branch with the `openspec-archive-change` skill; fix the design.html asset prefix and set the epic spec id to the archived path. +- Open the pull request, watch CI and review, and close the group beads. diff --git a/openspec/specs/ir/format-version/spec.md b/openspec/specs/ir/format-version/spec.md index b9b20b79..a9a475d6 100644 --- a/openspec/specs/ir/format-version/spec.md +++ b/openspec/specs/ir/format-version/spec.md @@ -39,12 +39,16 @@ Format-version failures SHALL use the finos/morphir contract codes: `missing_for - **THEN** reading fails with `duplicate_format_version` rather than keeping the last value ### Requirement: Canonical format version output -Writers SHALL emit `formatVersion` first, as the integer `N` for a baseline release `N.0.0` and as the exact release string otherwise, regardless of the spelling that was read. (bead: morphir-ukvn.8) +Writers SHALL emit `formatVersion` first, as the integer `N` for a baseline release `N.0.0` and as the exact release string otherwise, regardless of the spelling that was read. Writers MUST NOT emit the legacy formats 1 and 2. (bead: morphir-ukvn.8) #### Scenario: Read string, write integer - **WHEN** a document read with `formatVersion` `"3.0.0"` is written again - **THEN** the output declares `formatVersion` as the integer `3` +#### Scenario: Legacy versions are never written +- **WHEN** a caller asks for the spelling of format 1 or 2 +- **THEN** the writer refuses instead of returning `1` or `2` + ### Requirement: Support table with IR 3.1 is checked before decoding The reader SHALL declare the support table `[3.0.0,3.2.0),[4.0.0,4.1.0)` and MUST finish recognition and the support check before invoking any distribution decoder or migration. A release outside the table SHALL fail with `unsupported_format_version_major` or `unsupported_format_version_minor`. (bead: morphir-ukvn.8.13) @@ -57,5 +61,27 @@ The reader SHALL declare the support table `[3.0.0,3.2.0),[4.0.0,4.1.0)` and MUS - **THEN** reading fails with `unsupported_format_version_minor` before the distribution is decoded, and the message names the accepted table `[3.0.0,3.2.0),[4.0.0,4.1.0)` #### Scenario: Historical versions are refused -- **WHEN** a document declares `formatVersion` `1` or `2` -- **THEN** reading fails with `unsupported_format_version_major` and a message that formats 1 and 2 are follow-up work +- **WHEN** a document declares `formatVersion` `1` or `2` and is read against a support table +- **THEN** reading fails with `unsupported_format_version_major` and a message that names the legacy readers for formats 1 and 2 + +### Requirement: Legacy formats are recognized outside the support table +A legacy-aware read SHALL recognize the JSON integers `1` and `2` as legacy formats 1 and 2 and return them without a support check, because the finos release grammar starts at major 3. Every other value SHALL be read and checked as before, with the same diagnostics. The strings `"1"`, `"2"` and `"1.0.0"` MUST stay invalid, because morphir-elm wrote legacy formats only as integers. (bead: morphir-ukvn.8.12) + +#### Scenario: Integer legacy version +- **WHEN** a legacy-aware read sees `formatVersion` `1` or `2` +- **THEN** it reports legacy format 1 or 2 and does not consult the support table + +#### Scenario: Other versions keep their diagnostics +- **WHEN** a legacy-aware read sees `formatVersion` `"1"`, `true`, `5`, or no `formatVersion` +- **THEN** it fails with `invalid_format_version_syntax`, `invalid_format_version_type`, `unsupported_format_version_major`, or `missing_format_version`, as the table reader does + +#### Scenario: Support table keeps the release grammar +- **WHEN** a caller reads the support table +- **THEN** every interval starts at major 3 or above and its spelling is unchanged + +### Requirement: A legacy reader accepts exactly one legacy format +A reader for one legacy format SHALL accept a document only when its `formatVersion` is that legacy integer. Any other recognized version SHALL fail with `unsupported_format_version_major`, naming the version found and the one legacy format accepted. (bead: morphir-ukvn.8.12) + +#### Scenario: Format 3 at the format-1 reader +- **WHEN** a document with `formatVersion` 3 is passed to the format-1 reader +- **THEN** reading fails with `unsupported_format_version_major` and says that only legacy format 1 is accepted diff --git a/openspec/specs/ir/v3/spec.md b/openspec/specs/ir/v3/spec.md index c0ff87c9..99032f71 100644 --- a/openspec/specs/ir/v3/spec.md +++ b/openspec/specs/ir/v3/spec.md @@ -100,3 +100,48 @@ The v3 writer SHALL emit `formatVersion` `3` for a `Library` and `"3.1.0"` for a #### Scenario: Specs is written as 3.1.0 - **WHEN** a v3 `Specs` is encoded - **THEN** the output declares `formatVersion` `"3.1.0"` + +### Requirement: Format 1 is read into the format 3.0.0 model +The library SHALL decode `"formatVersion": 1` documents with the spellings of the morphir-elm `*/CodecV1.elm` decoders at `bc99af69` into a v3 `Library`, never a `Specs`: snake_case tags, `["public", x]` access control, `[doc, x]` or bare documentation, `[name, type]` record fields, `{"name", "spec"}` and `{"name", "def"}` package modules, and no module documentation. (bead: morphir-ukvn.8.12) + +#### Scenario: morphir-elm format-1 output decodes +- **WHEN** the morphir-elm rentals example written as format 1 is decoded +- **THEN** decoding succeeds and gives a v3 Library with the package name `morphir/example/app` + +#### Scenario: Format-3 tags are refused in format 1 +- **WHEN** a format-1 type, value, literal, or distribution uses a TitleCase tag such as `"Variable"` or `"Library"` +- **THEN** decoding fails with `unknown_node` + +#### Scenario: Documentation pair or bare value +- **WHEN** a format-1 documented entry is `["a doc", x]` or the bare `x` +- **THEN** it decodes with doc `"a doc"` or `""`, and when both readings fail the error carries the diagnostics of both + +#### Scenario: Derived types use the elm decoder spelling +- **WHEN** a format-1 type specification is tagged `"derived_type_specification"` +- **THEN** it decodes, while `"DerivedTypeSpecification"`, which only the elm encoder wrote, fails with `unknown_node` + +### Requirement: Format 2 is read into the format 3.0.0 model +The library SHALL decode `"formatVersion": 2` documents whose shapes are those of format 3.0.0 except that value and pattern tags are snake_case, as in morphir-elm `Value/CodecV2.elm` at `02aa36af`. Literals and types SHALL use their format-3 TitleCase tags, as morphir-elm wrote them. Reading format 2 is a deliberate divergence from morphir-elm, which now refuses it. (bead: morphir-ukvn.8.12) + +#### Scenario: morphir-elm format-2 output decodes +- **WHEN** the morphir-elm rentals example written as format 2 is decoded +- **THEN** decoding succeeds and gives a v3 Library + +#### Scenario: Mixed spellings are refused +- **WHEN** a format-2 value uses the tag `"Literal"`, or a literal inside it uses `"bool_literal"` +- **THEN** decoding fails with `unknown_node` + +### Requirement: Legacy formats upgrade to format 3.0.0 +Formats 1 and 2 SHALL decode into the same v3 model as format 3.0.0, so encoding the result with the format 3.0.0 codec is the upgrade, and the v3-to-v4 migration applies unchanged. The library MUST NOT provide encoders for formats 1 and 2, and the format 3.0.0 decoder SHALL keep refusing them. (bead: morphir-ukvn.8.12) + +#### Scenario: Both legacy formats give one model +- **WHEN** the rentals example is decoded from its format-1 file and from its format-2 file +- **THEN** both give the same v3 Library + +#### Scenario: Upgrade writes format 3 +- **WHEN** a decoded legacy document is encoded with the format 3.0.0 codec +- **THEN** the output declares `formatVersion` 3 and decodes back to the same Library + +#### Scenario: The format 3.0.0 entry point stays strict +- **WHEN** a format-1 or format-2 document is passed to the format 3.0.0 decoder +- **THEN** decoding fails with `unsupported_format_version_major` diff --git a/openspec/specs/tools/ir-interchange/spec.md b/openspec/specs/tools/ir-interchange/spec.md index c686c1e7..1e3cc50c 100644 --- a/openspec/specs/tools/ir-interchange/spec.md +++ b/openspec/specs/tools/ir-interchange/spec.md @@ -6,7 +6,7 @@ Gives morphir-tools users commands to inspect versioned `morphir-ir.json` files ## Requirements ### Requirement: Inspect versioned morphir-ir.json -The `ir inspect` command SHALL read a file with the shared format-version reader, decode it with the matching v3 or v4 codec, and report the normalized format version, the distribution kind, and the package name. (bead: morphir-ukvn.8.13) +The `ir inspect` command SHALL read a file with the shared format-version reader, decode it with the matching v3 or v4 codec, and report the normalized format version, the distribution kind, and the package name. A legacy format 1 or 2 file SHALL be decoded with its legacy decoder and reported as legacy. (bead: morphir-ukvn.8.13) #### Scenario: Report format version 3 - **WHEN** the user inspects a morphir-elm `morphir-ir.json` with `formatVersion` 3 @@ -24,6 +24,10 @@ The `ir inspect` command SHALL read a file with the shared format-version reader - **WHEN** the user inspects a v4 file - **THEN** the report states that v4 support is draft +#### Scenario: Legacy format is labelled +- **WHEN** the user inspects a morphir-elm file with `formatVersion` 1 or 2 +- **THEN** the tool reports the legacy format, kind Library, and the package name, says that `ir convert` upgrades it to format 3.0.0, and exits 0 + ### Requirement: CLI uses library code paths The tools MUST NOT parse IR JSON with ad-hoc CLI code; reading, decoding, encoding, and migration SHALL go through the library APIs. (bead: morphir-ukvn.8) @@ -58,9 +62,24 @@ The `ir convert` command SHALL convert format 3 (3.0.x and 3.1.x) to v4 and v4 t - **WHEN** the user converts the same file with `--drop-input-attributes` - **THEN** the tool writes a `formatVersion` 4 file, prints each `input_attributes_dropped` warning, and exits 0 -### Requirement: Scope excludes formats 1 and 2 and later 3.x minors -The tools MUST reject formats 1 and 2 and any 3.x release from 3.2.0 on, rather than load or convert them. This differs from morphir-elm, which still reads format 1. (bead: morphir-ukvn.8.13) +### Requirement: Legacy files are upgraded, never written +The `ir convert` command SHALL read format 1 and format 2 files with the library legacy decoders and write them as a format 3.0.0 `Library` (`--to v3`) or draft v4 (`--to v4`, through the v3-to-v4 migration). A legacy file SHALL never become a `Specs` distribution, and no target SHALL write format 1 or 2. (bead: morphir-ukvn.8.12) + +#### Scenario: Upgrade a legacy file to format 3 +- **WHEN** the user converts the morphir-elm rentals example in format 1 or format 2 with `--to v3` +- **THEN** the tool writes a `formatVersion` 3 Library file, the same file for both inputs, and exits 0 + +#### Scenario: Upgrade a legacy file to v4 +- **WHEN** the user converts a format-1 file with `--to v4` +- **THEN** the tool writes a `formatVersion` 4 file and exits 0 + +#### Scenario: A broken legacy file leaves no output +- **WHEN** the user converts a format-1 file whose distribution uses a format-3 tag +- **THEN** the tool exits non-zero with `unknown_node` and does not create the output file + +### Requirement: Scope excludes later 3.x minors +The tools MUST reject any 3.x release from 3.2.0 on, and any major outside the support table other than the legacy formats 1 and 2, rather than load or convert them. (bead: morphir-ukvn.8.12) #### Scenario: Unsupported format version is named -- **WHEN** the user supplies a file whose `formatVersion` is 1, 2, or `"3.2.0"` +- **WHEN** the user supplies a file whose `formatVersion` is `"3.2.0"` or 5 - **THEN** the tool exits non-zero with the format-version diagnostic code and says only 3.0.x, 3.1.x and draft 4.0.x are supported diff --git a/packages/morphir-tools/README.md b/packages/morphir-tools/README.md index 495cc08f..6fae6017 100644 --- a/packages/morphir-tools/README.md +++ b/packages/morphir-tools/README.md @@ -27,25 +27,29 @@ This will also install the `morphir` core library as a dependency. The `morphir-tools` command inspects and converts versioned `morphir-ir.json` files. It reads format 3.0.x (what morphir-elm writes), format 3.1.x (which adds -the v3 `Specs` distribution) and draft IR 4.0.x, and uses the `morphir` library -for every read, write, and migration. +the v3 `Specs` distribution) and draft IR 4.0.x, reads the legacy formats 1 and 2 +to upgrade them, and uses the `morphir` library for every read, write, and +migration. ```bash morphir-tools ir inspect morphir-ir.json morphir-tools ir convert morphir-ir.json --to v4 -o out.json --package-version 1.2.0 morphir-tools ir convert v4.json --to v3 -o out.json --drop-source-locations +morphir-tools ir convert old-format-1.json --to v3 -o out.json ``` `inspect` prints the normalized format version, the distribution kind (`Library`, `Specs` or `Application`), and the package name, and labels v4 as -draft. `convert` handles `Library` and `Specs` in both directions. It writes a -v3 `Library` as format `3` and a v3 `Specs` as `"3.1.0"`, the lowest version that -holds each. `convert` writes through a temporary file -and renames it only on success, so a failed conversion leaves no output file. +draft and formats 1 and 2 as legacy. `convert` handles `Library` and `Specs` in +both directions. It writes a v3 `Library` as format `3` and a v3 `Specs` as +`"3.1.0"`, the lowest version that holds each. A legacy format 1 or 2 file +converts to a format 3.0.0 `Library` or draft v4; nothing writes format 1 or 2. +`convert` writes through a temporary file and renames it only on success, so a +failed conversion leaves no output file. Diagnostics go to standard error as `code at location: message`. Exit codes are 0 for success (also with warnings), 1 for a diagnostic, and 2 for -a usage error. Formats 1 and 2 and 3.x releases from 3.2.0 on are rejected; see the +a usage error. 3.x releases from 3.2.0 on are rejected; see the [morphir package README](../morphir/README.md#morphir-ir-versions) for the format-version rules and the draft v4 status. diff --git a/packages/morphir-tools/src/morphir_tools/cli.py b/packages/morphir-tools/src/morphir_tools/cli.py index 7aa857d5..f3338177 100644 --- a/packages/morphir-tools/src/morphir_tools/cli.py +++ b/packages/morphir-tools/src/morphir_tools/cli.py @@ -23,6 +23,7 @@ from morphir.ir.migrate import Err, Ok, v3_to_v4, v4_to_v3 from morphir.ir.migrate import drop_input_attributes as drop_inputs from morphir.ir.v3 import codec as v3_codec +from morphir.ir.v3 import codec_v1, codec_v2 from morphir.ir.v3 import distribution as v3_dist from morphir.ir.v4 import codec as v4_codec from morphir.ir.v4 import distribution as v4_dist @@ -36,7 +37,8 @@ ) ir_app = typer.Typer( help="Inspect and convert versioned morphir-ir.json files " - "(format 3.0.x and 3.1.x, and draft 4.0.x).", + "(format 3.0.x and 3.1.x, and draft 4.0.x; legacy formats 1 and 2 are read " + "and upgraded).", no_args_is_help=True, ) app.add_typer(ir_app, name="ir") @@ -91,6 +93,26 @@ def _kind(dist: v4_dist.Distribution | v3_dist.Distribution) -> str: return "Application" +def _read_v3_family( + text: str, version: fv.FormatVersion | fv.LegacyFormat +) -> v3_dist.Distribution: + """Decode a format 1, 2, or 3.x document into the v3 model. + + Legacy formats 1 and 2 always give a format 3.0.0 Library. + """ + match version: + case fv.LegacyFormat.V1: + return codec_v1.decode_versioned_distribution(text) + case fv.LegacyFormat.V2: + return codec_v2.decode_versioned_distribution(text) + case _: + return v3_codec.decode_versioned_distribution(text) + + +def _is_v4(version: fv.FormatVersion | fv.LegacyFormat) -> bool: + return isinstance(version, fv.FormatVersion) and version.major == 4 + + @ir_app.command() def inspect( file: Annotated[Path, typer.Argument(help="A versioned morphir-ir.json file.")], @@ -98,25 +120,31 @@ def inspect( """Report the format version, distribution kind, and package name.""" text = _read(file) try: - version, _ = fv.read_document(text) - if version.major == 3: - classic = v3_codec.decode_versioned_distribution(text) + version, _ = fv.read_document_with_legacy(text) + if _is_v4(version): + dist = v4_codec.decode_versioned_distribution(text) + kind = _kind(dist) + package = v4_codec.encode_path(dist.package.name) + else: + classic = _read_v3_family(text, version) kind = _kind(classic) package = v4_codec.encode_path( Path4(tuple(Name4(tuple(n)) for n in classic.package_name)) ) - else: - dist = v4_codec.decode_versioned_distribution(text) - kind = _kind(dist) - package = v4_codec.encode_path(dist.package.name) except DiagnosticError as error: _report(error.diagnostics) raise typer.Exit(1) from None - typer.echo(f"format: {version}") + legacy = isinstance(version, fv.LegacyFormat) + typer.echo(f"format: {version}{' (legacy)' if legacy else ''}") typer.echo(f"kind: {kind}") typer.echo(f"package: {package}") - if version.major == 4: + if _is_v4(version): typer.echo(f"note: {v4_codec.DRAFT_NOTICE}") + if legacy: + typer.echo( + f"note: legacy format {version} is read-only; " + "ir convert upgrades it to format 3.0.0 or draft v4" + ) @ir_app.command() @@ -145,21 +173,22 @@ def convert( """Convert between format 3 (3.0.x and 3.1.x) and draft IR v4. A v3 Library is written as format 3 and a v3 Specs as format 3.1.0, the lowest - version that holds each. + version that holds each. Legacy formats 1 and 2 are read as a format 3.0.0 + Library and never written. The output is written to a temporary file and renamed only on success, so a failed conversion leaves no output file. """ text = _read(file) try: - version, _ = fv.read_document(text) + version, _ = fv.read_document_with_legacy(text) if to is Target.V4: upgraded: Ok[v4_dist.Distribution] - if version.major == 4: + if _is_v4(version): upgraded = Ok(v4_codec.decode_versioned_distribution(text)) else: migrated = v3_to_v4( - v3_codec.decode_versioned_distribution(text), + _read_v3_family(text, version), package_version=package_version, ) upgraded = Ok(migrated.value, migrated.warnings) @@ -168,8 +197,8 @@ def convert( document = v4_codec.encode_versioned_distribution(upgraded.value) warnings: tuple[Diagnostic, ...] = upgraded.warnings else: - if version.major == 3: - classic = v3_codec.decode_versioned_distribution(text) + if not _is_v4(version): + classic = _read_v3_family(text, version) warnings = () else: result = v4_to_v3( diff --git a/packages/morphir/README.md b/packages/morphir/README.md index d1ccf028..22aa052d 100644 --- a/packages/morphir/README.md +++ b/packages/morphir/README.md @@ -36,6 +36,7 @@ from morphir.ir import Type, Value | 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](https://github.com/finos/morphir-elm) at `bc99af69` for 3.0.0; [finos/morphir](https://github.com/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](https://github.com/finos/morphir) at `efa36060` | `morphir.ir.v4.codec`, **draft** | `morphir.ir` and its old submodule paths (`morphir.ir.name`, `morphir.ir.value`, @@ -122,11 +123,47 @@ files written by other tools: accepts `3` and `"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 fail with `unsupported_format_version_major`; a later 3.x minor -such as `"3.2.0"` fails with `unsupported_format_version_minor`. +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. + +```python +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 diff --git a/packages/morphir/src/morphir/ir/format_version.py b/packages/morphir/src/morphir/ir/format_version.py index 4a0d4034..437f4a93 100644 --- a/packages/morphir/src/morphir/ir/format_version.py +++ b/packages/morphir/src/morphir/ir/format_version.py @@ -16,6 +16,13 @@ (finos/morphir decision record 0018). IR 3.1.0 added the v3 ``Specs`` distribution; `V3_1` names that release. v4 support is draft. +Formats 1 and 2 are legacy: the finos release grammar starts at major 3, so they +are in no support table, and `read_format_version` refuses them with +``unsupported_format_version_major``. `read_document_with_legacy` and +`read_legacy_document` recognize them, only as the integers morphir-elm wrote, for +the read-only decoders in `morphir.ir.v3.codec_v1` and `morphir.ir.v3.codec_v2`. +`write_format_version` never writes them. + Example: >>> read_format_version(3) FormatVersion(major=3, minor=0, patch=0) @@ -30,6 +37,7 @@ import json import re from dataclasses import dataclass +from enum import Enum from . import diagnostics as d from .diagnostics import DiagnosticError, fail @@ -195,7 +203,7 @@ def check_supported( loc: d.Location = (_CONSTRUCT,) if not any(interval.lower.major == version.major for interval in table): if version.major in (1, 2): - detail = "formats 1 and 2 are follow-up work" + detail = _LEGACY_DETAIL else: detail = f"format family {version.major} is not supported" raise fail( @@ -250,7 +258,13 @@ def read_format_version( def write_format_version(version: FormatVersion) -> int | str: - """Return the canonical spelling: integer N for N.0.0, else the release string.""" + """Return the canonical spelling: integer N for N.0.0, else the release string. + + Raises: + ValueError: For a legacy major (1 or 2); legacy formats are never written. + """ + if version.major < 3: + raise ValueError(f"format {version.major} is legacy and is never written") if version.minor == 0 and version.patch == 0: return version.major return str(version) @@ -327,6 +341,11 @@ def read_document( Raises: DiagnosticError: With a format-version or JSON diagnostic. """ + obj = _versioned_root(document) + return read_format_version(obj[_CONSTRUCT], table), obj + + +def _versioned_root(document: str | object) -> dict[str, object]: root = parse_json(document) if isinstance(document, str) else document if not isinstance(root, dict): raise fail( @@ -343,8 +362,7 @@ def read_document( "the IR has no formatVersion member; it is in an old format. " "Please regenerate it!", ) - obj: dict[str, object] = dict(root) - return read_format_version(obj[_CONSTRUCT], table), obj + return dict(root) def dump_json(value: object) -> str: @@ -352,6 +370,93 @@ def dump_json(value: object) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":")) +# --- Legacy formats 1 and 2 (read-only) --- + +_LEGACY_DETAIL = ( + "formats 1 and 2 are legacy formats outside the support table; " + "morphir.ir.v3.codec_v1 and codec_v2 read them as format 3.0.0" +) + + +class LegacyFormat(Enum): + """A morphir-elm format from before the release grammar, which starts at 3. + + morphir-elm wrote formats 1 and 2 only as the JSON integers ``1`` and ``2``. + They are in no support table: the legacy readers decode them into the + format 3.0.0 model, and no writer emits them. + """ + + V1 = 1 + V2 = 2 + + def __str__(self) -> str: + return str(self.value) + + +def recognize_legacy_format(value: object) -> LegacyFormat | None: + """Return the legacy format for the JSON integer ``1`` or ``2``, else ``None``. + + Example: + >>> recognize_legacy_format(1) + + >>> recognize_legacy_format("1") is None + True + """ + if isinstance(value, bool) or not isinstance(value, int): + return None + return {1: LegacyFormat.V1, 2: LegacyFormat.V2}.get(value) + + +def read_document_with_legacy( + document: str | object, table: SupportTable = SUPPORT_TABLE +) -> tuple[FormatVersion | LegacyFormat, dict[str, object]]: + """Read a document root like `read_document`, but report legacy formats. + + The integers ``1`` and ``2`` give a `LegacyFormat`; every other value goes + through `read_format_version` with ``table`` and fails as it does there. + + Raises: + DiagnosticError: With a format-version or JSON diagnostic. + """ + obj = _versioned_root(document) + legacy = recognize_legacy_format(obj[_CONSTRUCT]) + if legacy is not None: + return legacy, obj + return read_format_version(obj[_CONSTRUCT], table), obj + + +def read_legacy_document( + document: str | object, accepted: LegacyFormat +) -> dict[str, object]: + """Read the root of a legacy document and check that it is ``accepted``. + + Args: + document: JSON text, or an already-decoded JSON value. + accepted: The one legacy format the caller decodes. + + Returns: + The root object. + + Raises: + DiagnosticError: With ``unsupported_format_version_major`` for any other + format, or with the recognition diagnostic for a malformed version. + """ + obj = _versioned_root(document) + value = obj[_CONSTRUCT] + found: FormatVersion | LegacyFormat | None = recognize_legacy_format(value) + if found is accepted: + return obj + if found is None: + found = recognize_format_version(value) + raise fail( + d.UNSUPPORTED_FORMAT_VERSION_MAJOR, + _CONSTRUCT, + (_CONSTRUCT,), + f"format version {found} is not supported; " + f"this reader accepts only legacy format {accepted}", + ) + + __all__ = [ "SUPPORT_TABLE", "V3", @@ -362,13 +467,17 @@ def dump_json(value: object) -> str: "DiagnosticError", "FormatVersion", "Interval", + "LegacyFormat", "SupportTable", "check_supported", "dump_json", "parse_json", "read_document", + "read_document_with_legacy", "read_format_version", + "read_legacy_document", "recognize_format_version", + "recognize_legacy_format", "table_to_string", "write_format_version", ] diff --git a/packages/morphir/src/morphir/ir/v3/__init__.py b/packages/morphir/src/morphir/ir/v3/__init__.py index 87d7f0ac..0ec42b7c 100644 --- a/packages/morphir/src/morphir/ir/v3/__init__.py +++ b/packages/morphir/src/morphir/ir/v3/__init__.py @@ -4,11 +4,17 @@ shapes. What 3.1.0 adds, the `Specs` distribution, follows the finos/morphir v3 specification (decision record 0018). The classes here are distinct from `morphir.ir.v4`; use `morphir.ir.migrate` to move between versions. + +`morphir.ir.v3.codec_v1` and `morphir.ir.v3.codec_v2` read the legacy formats 1 +and 2 into the 3.0.0 `Library` model, which upgrades them to format 3.0.0. +Nothing writes them. """ from . import ( access_controlled, codec, + codec_v1, + codec_v2, distribution, documented, fqname, @@ -42,6 +48,8 @@ "Specs", "access_controlled", "codec", + "codec_v1", + "codec_v2", "distribution", "documented", "fqname", diff --git a/packages/morphir/src/morphir/ir/v3/_legacy_value.py b/packages/morphir/src/morphir/ir/v3/_legacy_value.py new file mode 100644 index 00000000..175b7e5e --- /dev/null +++ b/packages/morphir/src/morphir/ir/v3/_legacy_value.py @@ -0,0 +1,201 @@ +"""Value and pattern decoders with the snake_case tags of formats 1 and 2. + +morphir-elm wrote value and pattern tags in snake_case (``"let_definition"``, +``"wildcard_pattern"``) in formats 1 and 2, and in TitleCase from format 3 +(commit 02aa36af). The node shapes did not change. Literals and types inside +values did change between format 1 and format 2, so each decoder here takes the +literal decoder and the type decoder of its format. + +Port of the decoders in ``Value/CodecV1.elm`` (morphir-elm bc99af69) and in +``Value/CodecV2.elm`` (morphir-elm 02aa36af, removed in 7cb7b300). +""" + +from __future__ import annotations + +from ..diagnostics import Location +from . import literal as lit +from . import type as t +from . import value as v +from .codec import ( + Decoder, + _expect_list, + _expect_object, + _field, + _index, + _list_of, + _pair_list_to_dict, + _tag, + _unknown, + decode_fqname, + decode_name, +) +from .name import Name + + +def decode_pattern[A]( + decode_literal: Decoder[lit.Literal], decode_attributes: Decoder[A] +) -> Decoder[v.Pattern[A]]: + """Build a snake_case pattern decoder (elm ``decodePattern``).""" + + def decode(j: object, loc: Location = ()) -> v.Pattern[A]: + tag, items = _tag(j, loc, "Pattern") + + def at(i: int) -> tuple[object, Location]: + return _index(items, i, loc, tag), (*loc, i) + + match tag: + case "wildcard_pattern": + return v.WildcardPattern(decode_attributes(*at(1))) + case "as_pattern": + return v.AsPattern( + decode_attributes(*at(1)), decode(*at(2)), decode_name(*at(3)) + ) + case "tuple_pattern": + return v.TuplePattern( + decode_attributes(*at(1)), _list_of(decode, *at(2), "Pattern") + ) + case "constructor_pattern": + return v.ConstructorPattern( + decode_attributes(*at(1)), + decode_fqname(*at(2)), + _list_of(decode, *at(3), "Pattern"), + ) + case "empty_list_pattern": + return v.EmptyListPattern(decode_attributes(*at(1))) + case "head_tail_pattern": + return v.HeadTailPattern( + decode_attributes(*at(1)), decode(*at(2)), decode(*at(3)) + ) + case "literal_pattern": + return v.LiteralPattern( + decode_attributes(*at(1)), decode_literal(*at(2)) + ) + case "unit_pattern": + return v.UnitPattern(decode_attributes(*at(1))) + case _: + raise _unknown(tag, loc, "Pattern") + + return decode + + +def decode_value_definition[TA, VA]( + decode_type: Decoder[t.Type[TA]], + decode_value_attributes: Decoder[VA], + decode_body: Decoder[v.Value[TA, VA]], +) -> Decoder[v.Definition[TA, VA]]: + """Build a decoder for ``{"inputTypes", "outputType", "body"}``.""" + construct = "ValueDefinition" + + def decode_input(j: object, loc: Location) -> tuple[Name, VA, t.Type[TA]]: + triple = _expect_list(j, loc, "Input") + return ( + decode_name(_index(triple, 0, loc, "Input"), (*loc, 0)), + decode_value_attributes(_index(triple, 1, loc, "Input"), (*loc, 1)), + decode_type(_index(triple, 2, loc, "Input"), (*loc, 2)), + ) + + def decode(j: object, loc: Location = ()) -> v.Definition[TA, VA]: + obj = _expect_object(j, loc, construct) + return v.Definition( + _list_of( + decode_input, + _field(obj, "inputTypes", loc, construct), + (*loc, "inputTypes"), + "Input", + ), + decode_type( + _field(obj, "outputType", loc, construct), (*loc, "outputType") + ), + decode_body(_field(obj, "body", loc, construct), (*loc, "body")), + ) + + return decode + + +def decode_value[TA, VA]( + decode_literal: Decoder[lit.Literal], + decode_type: Decoder[t.Type[TA]], + decode_value_attributes: Decoder[VA], +) -> Decoder[v.Value[TA, VA]]: + """Build a snake_case value decoder (elm ``decodeValue``). + + Args: + decode_literal: The literal decoder of the format. + decode_type: The decoder for the types inside value definitions. + decode_value_attributes: The decoder for value and pattern attributes. + """ + decode_p = decode_pattern(decode_literal, decode_value_attributes) + + def decode_named(j: object, loc: Location) -> dict[Name, v.Value[TA, VA]]: + return _pair_list_to_dict(decode_name, decode, j, loc, "Value") + + def decode_case(j: object, loc: Location) -> tuple[v.Pattern[VA], v.Value[TA, VA]]: + pair = _expect_list(j, loc, "Case") + return ( + decode_p(_index(pair, 0, loc, "Case"), (*loc, 0)), + decode(_index(pair, 1, loc, "Case"), (*loc, 1)), + ) + + def decode(j: object, loc: Location = ()) -> v.Value[TA, VA]: + tag, items = _tag(j, loc, "Value") + + def at(i: int) -> tuple[object, Location]: + return _index(items, i, loc, tag), (*loc, i) + + def attrs() -> VA: + return decode_value_attributes(*at(1)) + + match tag: + case "literal": + return v.LiteralValue(attrs(), decode_literal(*at(2))) + case "constructor": + return v.Constructor(attrs(), decode_fqname(*at(2))) + case "tuple": + return v.Tuple(attrs(), _list_of(decode, *at(2), "Value")) + case "list": + return v.List(attrs(), _list_of(decode, *at(2), "Value")) + case "record": + return v.Record(attrs(), decode_named(*at(2))) + case "variable": + return v.Variable(attrs(), decode_name(*at(2))) + case "reference": + return v.Reference(attrs(), decode_fqname(*at(2))) + case "field": + return v.Field(attrs(), decode(*at(2)), decode_name(*at(3))) + case "field_function": + return v.FieldFunction(attrs(), decode_name(*at(2))) + case "apply": + return v.Apply(attrs(), decode(*at(2)), decode(*at(3))) + case "lambda": + return v.Lambda(attrs(), decode_p(*at(2)), decode(*at(3))) + case "let_definition": + return v.LetDefinition( + attrs(), decode_name(*at(2)), decode_def(*at(3)), decode(*at(4)) + ) + case "let_recursion": + return v.LetRecursion( + attrs(), + _pair_list_to_dict(decode_name, decode_def, *at(2), "Definition"), + decode(*at(3)), + ) + case "destructure": + return v.Destructure( + attrs(), decode_p(*at(2)), decode(*at(3)), decode(*at(4)) + ) + case "if_then_else": + return v.IfThenElse( + attrs(), decode(*at(2)), decode(*at(3)), decode(*at(4)) + ) + case "pattern_match": + return v.PatternMatch( + attrs(), decode(*at(2)), _list_of(decode_case, *at(3), "Case") + ) + case "update_record": + return v.UpdateRecord(attrs(), decode(*at(2)), decode_named(*at(3))) + case "unit": + return v.Unit(attrs()) + case _: + raise _unknown(tag, loc, "Value") + + decode_def = decode_value_definition(decode_type, decode_value_attributes, decode) + return decode diff --git a/packages/morphir/src/morphir/ir/v3/codec_v1.py b/packages/morphir/src/morphir/ir/v3/codec_v1.py new file mode 100644 index 00000000..89ffc2de --- /dev/null +++ b/packages/morphir/src/morphir/ir/v3/codec_v1.py @@ -0,0 +1,491 @@ +"""Read-only JSON decoders for morphir-elm IR format 1, into the 3.0.0 model. + +Port of the decoders in morphir-elm ``src/Morphir/IR/*/CodecV1.elm`` at commit +bc99af69, which morphir-elm still uses to read ``"formatVersion": 1``. As in elm, +format 1 decodes into the same model as format 3.0.0, so decoding is the upgrade: +encode the result with `morphir.ir.v3.codec` to write format 3. The encoders are +not ported, because no writer emits format 1. + +Format 1 differs from format 3.0.0 only in these spellings: + +- every tag is snake_case: ``"library"``, ``"public"``, ``"variable"``, + ``"custom_type_definition"``, ``"let_definition"``, ``"wildcard_pattern"``; +- literal tags are ``"bool_literal"``, ``"char_literal"``, ``"string_literal"``, + ``"int_literal"``, ``"float_literal"`` and ``"decimal_literal"``; +- access control is ``["public", x]``, documentation is ``[doc, x]`` or the bare + ``x``, and a record field type is ``[name, type]``; +- package modules are ``{"name", "spec"}`` and ``{"name", "def"}`` objects; +- modules have no ``doc``; a ``doc`` member is ignored, as in elm. + +Names, paths, and qualified names are spelled as in format 3.0.0; use the +`morphir.ir.v3.codec` decoders for them. elm's ``encodeSpecification`` writes a +derived type as ``"DerivedTypeSpecification"`` but ``decodeSpecification`` reads +only ``"derived_type_specification"``; this decoder follows the elm decoder. +""" + +from __future__ import annotations + +from .. import diagnostics as d +from ..diagnostics import DiagnosticError, Location, fail +from ..format_version import LegacyFormat, read_legacy_document +from . import _legacy_value, module, package +from . import literal as lit +from . import type as t +from . import value as v +from .access_controlled import Access, AccessControlled +from .codec import ( + Decoder, + _expect_list, + _expect_object, + _field, + _index, + _list_of, + _pair_list_to_dict, + _tag, + _unknown, + decode_fqname, + decode_name, + decode_path, + decode_unit, +) +from .codec import decode_literal as decode_literal_3 +from .distribution import Library +from .documented import Documented +from .name import Name +from .path import Path + +# --- Literal --- + +_LITERAL_TAGS = { + "bool_literal": "BoolLiteral", + "char_literal": "CharLiteral", + "string_literal": "StringLiteral", + "int_literal": "WholeNumberLiteral", + "float_literal": "FloatLiteral", + "decimal_literal": "DecimalLiteral", +} + + +def decode_literal(j: object, loc: Location = ()) -> lit.Literal: + """Decode a format-1 literal; the payloads are checked as in format 3.0.0.""" + tag, items = _tag(j, loc, "Literal") + title = _LITERAL_TAGS.get(tag) + if title is None: + raise _unknown(tag, loc, "Literal") + return decode_literal_3([title, *items[1:]], loc) + + +# --- AccessControlled, Documented --- + + +_ACCESS = {"public": Access.PUBLIC, "private": Access.PRIVATE} + + +def decode_access_controlled[A]( + decode_value: Decoder[A], +) -> Decoder[AccessControlled[A]]: + """Decode ``["public", x]`` or ``["private", x]``.""" + + def decode(j: object, loc: Location = ()) -> AccessControlled[A]: + tag, items = _tag(j, loc, "AccessControlled") + access = _ACCESS.get(tag) + if access is None: + raise _unknown(tag, loc, "AccessControlled") + return AccessControlled( + access, decode_value(_index(items, 1, loc, tag), (*loc, 1)) + ) + + return decode + + +def decode_documented[A](decode_value: Decoder[A]) -> Decoder[Documented[A]]: + """Decode ``[doc, x]``, or an undocumented ``x`` with doc ``""``. + + As elm's ``Decode.oneOf``, the pair is tried first. When both readings fail, + the error holds the diagnostics of both. + """ + + def decode(j: object, loc: Location = ()) -> Documented[A]: + if isinstance(j, list) and len(j) > 1 and isinstance(j[0], str): + try: + return Documented(j[0], decode_value(j[1], (*loc, 1))) + except DiagnosticError as pair_error: + try: + return Documented("", decode_value(j, loc)) + except DiagnosticError as bare_error: + raise DiagnosticError( + *pair_error.diagnostics, *bare_error.diagnostics + ) from None + return Documented("", decode_value(j, loc)) + + return decode + + +# --- Type --- + + +def decode_type[A](decode_attributes: Decoder[A]) -> Decoder[t.Type[A]]: + """Build a format-1 type decoder (elm ``decodeType``).""" + + def decode(j: object, loc: Location = ()) -> t.Type[A]: + tag, items = _tag(j, loc, "Type") + + def at(i: int) -> tuple[object, Location]: + return _index(items, i, loc, tag), (*loc, i) + + def attrs() -> A: + return decode_attributes(*at(1)) + + match tag: + case "variable": + return t.Variable(attrs(), decode_name(*at(2))) + case "reference": + return t.Reference( + attrs(), decode_fqname(*at(2)), _list_of(decode, *at(3), "Type") + ) + case "tuple": + return t.Tuple(attrs(), _list_of(decode, *at(2), "Type")) + case "record": + return t.Record(attrs(), _list_of(decode_field, *at(2), "Field")) + case "extensible_record": + return t.ExtensibleRecord( + attrs(), + decode_name(*at(2)), + _list_of(decode_field, *at(3), "Field"), + ) + case "function": + return t.Function(attrs(), decode(*at(2)), decode(*at(3))) + case "unit": + return t.Unit(attrs()) + case _: + raise _unknown(tag, loc, "Type") + + def decode_field(j: object, loc: Location = ()) -> t.Field[A]: + pair = _expect_list(j, loc, "Field") + return t.Field( + decode_name(_index(pair, 0, loc, "Field"), (*loc, 0)), + decode(_index(pair, 1, loc, "Field"), (*loc, 1)), + ) + + return decode + + +def _decode_constructors[A]( + decode_t: Decoder[t.Type[A]], +) -> Decoder[t.Constructors[A]]: + def decode_arg(j: object, loc: Location) -> tuple[Name, t.Type[A]]: + pair = _expect_list(j, loc, "ConstructorArg") + return ( + decode_name(_index(pair, 0, loc, "ConstructorArg"), (*loc, 0)), + decode_t(_index(pair, 1, loc, "ConstructorArg"), (*loc, 1)), + ) + + def decode_args(j: object, loc: Location) -> t.ConstructorArgs[A]: + return _list_of(decode_arg, j, loc, "Constructor") + + def decode(j: object, loc: Location = ()) -> t.Constructors[A]: + return _pair_list_to_dict(decode_name, decode_args, j, loc, "Constructors") + + return decode + + +def decode_type_specification[A]( + decode_attributes: Decoder[A], +) -> Decoder[t.Specification[A]]: + """Build a format-1 type specification decoder.""" + decode_t = decode_type(decode_attributes) + decode_ctors = _decode_constructors(decode_t) + + def decode(j: object, loc: Location = ()) -> t.Specification[A]: + tag, items = _tag(j, loc, "TypeSpecification") + + def at(i: int) -> tuple[object, Location]: + return _index(items, i, loc, tag), (*loc, i) + + def params() -> tuple[Name, ...]: + return _list_of(decode_name, *at(1), "Name") + + match tag: + case "type_alias_specification": + return t.TypeAliasSpecification(params(), decode_t(*at(2))) + case "opaque_type_specification": + return t.OpaqueTypeSpecification(params()) + case "custom_type_specification": + return t.CustomTypeSpecification(params(), decode_ctors(*at(2))) + case "derived_type_specification": + config_json, config_loc = at(2) + config = _expect_object(config_json, config_loc, tag) + + def member(key: str) -> tuple[object, Location]: + return _field(config, key, config_loc, tag), (*config_loc, key) + + return t.DerivedTypeSpecification( + params(), + decode_t(*member("baseType")), + decode_fqname(*member("fromBaseType")), + decode_fqname(*member("toBaseType")), + ) + case _: + raise _unknown(tag, loc, "TypeSpecification") + + return decode + + +def decode_type_definition[A]( + decode_attributes: Decoder[A], +) -> Decoder[t.Definition[A]]: + """Build a format-1 type definition decoder.""" + decode_t = decode_type(decode_attributes) + decode_ctors = decode_access_controlled(_decode_constructors(decode_t)) + + def decode(j: object, loc: Location = ()) -> t.Definition[A]: + tag, items = _tag(j, loc, "TypeDefinition") + + def at(i: int) -> tuple[object, Location]: + return _index(items, i, loc, tag), (*loc, i) + + match tag: + case "type_alias_definition": + return t.TypeAliasDefinition( + _list_of(decode_name, *at(1), "Name"), decode_t(*at(2)) + ) + case "custom_type_definition": + return t.CustomTypeDefinition( + _list_of(decode_name, *at(1), "Name"), decode_ctors(*at(2)) + ) + case _: + raise _unknown(tag, loc, "TypeDefinition") + + return decode + + +# --- Value --- + + +def decode_pattern[A](decode_attributes: Decoder[A]) -> Decoder[v.Pattern[A]]: + """Build a format-1 pattern decoder.""" + return _legacy_value.decode_pattern(decode_literal, decode_attributes) + + +def decode_value[TA, VA]( + decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA] +) -> Decoder[v.Value[TA, VA]]: + """Build a format-1 value decoder.""" + return _legacy_value.decode_value( + decode_literal, decode_type(decode_type_attributes), decode_value_attributes + ) + + +def decode_value_specification[A]( + decode_attributes: Decoder[A], +) -> Decoder[v.Specification[A]]: + """Build a decoder for ``{"inputs", "output"}`` with format-1 types.""" + decode_t = decode_type(decode_attributes) + construct = "ValueSpecification" + + def decode_input(j: object, loc: Location) -> tuple[Name, t.Type[A]]: + pair = _expect_list(j, loc, "Input") + return ( + decode_name(_index(pair, 0, loc, "Input"), (*loc, 0)), + decode_t(_index(pair, 1, loc, "Input"), (*loc, 1)), + ) + + def decode(j: object, loc: Location = ()) -> v.Specification[A]: + obj = _expect_object(j, loc, construct) + return v.Specification( + _list_of( + decode_input, + _field(obj, "inputs", loc, construct), + (*loc, "inputs"), + "Input", + ), + decode_t(_field(obj, "output", loc, construct), (*loc, "output")), + ) + + return decode + + +def decode_value_definition[TA, VA]( + decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA] +) -> Decoder[v.Definition[TA, VA]]: + """Build a format-1 value definition decoder.""" + return _legacy_value.decode_value_definition( + decode_type(decode_type_attributes), + decode_value_attributes, + decode_value(decode_type_attributes, decode_value_attributes), + ) + + +# --- Module --- + + +def _named[A]( + decode_entry: Decoder[A], + obj: dict[str, object], + key: str, + loc: Location, + construct: str, +) -> dict[Name, A]: + return _pair_list_to_dict( + decode_name, + decode_entry, + _field(obj, key, loc, construct), + (*loc, key), + key.capitalize(), + ) + + +def decode_module_specification[TA]( + decode_type_attributes: Decoder[TA], +) -> Decoder[module.Specification[TA]]: + """Build a format-1 module specification decoder; ``doc`` is always None.""" + decode_ts = decode_documented(decode_type_specification(decode_type_attributes)) + decode_vs = decode_documented(decode_value_specification(decode_type_attributes)) + + def decode(j: object, loc: Location = ()) -> module.Specification[TA]: + construct = "ModuleSpecification" + obj = _expect_object(j, loc, construct) + return module.Specification( + _named(decode_ts, obj, "types", loc, construct), + _named(decode_vs, obj, "values", loc, construct), + ) + + return decode + + +def decode_module_definition[TA, VA]( + decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA] +) -> Decoder[module.Definition[TA, VA]]: + """Build a format-1 module definition decoder; ``doc`` is always None.""" + decode_td = decode_access_controlled( + decode_documented(decode_type_definition(decode_type_attributes)) + ) + decode_vd = decode_access_controlled( + decode_documented( + decode_value_definition(decode_type_attributes, decode_value_attributes) + ) + ) + + def decode(j: object, loc: Location = ()) -> module.Definition[TA, VA]: + construct = "ModuleDefinition" + obj = _expect_object(j, loc, construct) + return module.Definition( + _named(decode_td, obj, "types", loc, construct), + _named(decode_vd, obj, "values", loc, construct), + ) + + return decode + + +# --- Package --- + + +def _modules[A]( + decode_module: Decoder[A], member: str, j: object, loc: Location, construct: str +) -> dict[Path, A]: + obj = _expect_object(j, loc, construct) + result: dict[Path, A] = {} + entries = _field(obj, "modules", loc, construct) + for i, entry in enumerate(_expect_list(entries, (*loc, "modules"), "Modules")): + here = (*loc, "modules", i) + named = _expect_object(entry, here, "Module") + name = decode_path(_field(named, "name", here, "Module"), (*here, "name")) + result[name] = decode_module( + _field(named, member, here, "Module"), (*here, member) + ) + return result + + +def decode_package_specification[TA]( + decode_type_attributes: Decoder[TA], +) -> Decoder[package.Specification[TA]]: + """Build a decoder for ``{"modules": [{"name", "spec"}]}``.""" + decode_m = decode_module_specification(decode_type_attributes) + + def decode(j: object, loc: Location = ()) -> package.Specification[TA]: + return package.Specification( + _modules(decode_m, "spec", j, loc, "PackageSpecification") + ) + + return decode + + +def decode_package_definition[TA, VA]( + decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA] +) -> Decoder[package.Definition[TA, VA]]: + """Build a decoder for ``{"modules": [{"name", "def": ["public", ...]}]}``.""" + decode_m = decode_access_controlled( + decode_module_definition(decode_type_attributes, decode_value_attributes) + ) + + def decode(j: object, loc: Location = ()) -> package.Definition[TA, VA]: + return package.Definition( + _modules(decode_m, "def", j, loc, "PackageDefinition") + ) + + return decode + + +# --- Distribution --- + +_decode_dependency = decode_package_specification(decode_unit) +_decode_library_def = decode_package_definition(decode_unit, decode_type(decode_unit)) + + +def decode_distribution(j: object, loc: Location = ()) -> Library: + """Decode ``["library", name, dependencies, definition]``.""" + tag, items = _tag(j, loc, "Distribution") + if tag != "library": + raise fail( + d.UNKNOWN_NODE, + "Distribution", + (*loc, 0), + f"unknown distribution kind {tag!r}; format 1 has only library", + ) + + def at(i: int) -> tuple[object, Location]: + return _index(items, i, loc, tag), (*loc, i) + + return Library( + package_name=decode_path(*at(1)), + dependencies=_pair_list_to_dict( + decode_path, _decode_dependency, *at(2), "Dependencies" + ), + definition=_decode_library_def(*at(3)), + ) + + +def decode_versioned_distribution(document: str | object) -> Library: + """Decode a ``"formatVersion": 1`` document into the format 3.0.0 model. + + Args: + document: JSON text or an already-decoded JSON value. + + Raises: + DiagnosticError: With ``unsupported_format_version_major`` for any format + other than 1, or with a decoding diagnostic. + """ + root = read_legacy_document(document, LegacyFormat.V1) + return decode_distribution( + _field(root, "distribution", (), "document"), ("distribution",) + ) + + +__all__ = [ + "decode_access_controlled", + "decode_distribution", + "decode_documented", + "decode_literal", + "decode_module_definition", + "decode_module_specification", + "decode_package_definition", + "decode_package_specification", + "decode_pattern", + "decode_type", + "decode_type_definition", + "decode_type_specification", + "decode_value", + "decode_value_definition", + "decode_value_specification", + "decode_versioned_distribution", +] diff --git a/packages/morphir/src/morphir/ir/v3/codec_v2.py b/packages/morphir/src/morphir/ir/v3/codec_v2.py new file mode 100644 index 00000000..2e2779c1 --- /dev/null +++ b/packages/morphir/src/morphir/ir/v3/codec_v2.py @@ -0,0 +1,187 @@ +"""Read-only JSON decoders for morphir-elm IR format 2, into the 3.0.0 model. + +morphir-elm wrote format 2 from commit 8e0e729d (2021-10-08) to commit 02aa36af +(2023-05-18). Format 3 changed one thing: value and pattern tags went from +snake_case (``"let_definition"``, ``"wildcard_pattern"``) to TitleCase. Every +other shape, including TitleCase literals and types, ``{"access", "value"}``, +``{"doc", "value"}``, and ``[path, module]`` package entries, is the format 3.0.0 +shape, so this module reuses `morphir.ir.v3.codec` for everything except values, +patterns, and the containers that hold them. + +The value and pattern decoders port ``Value/CodecV2.elm`` from morphir-elm commit +02aa36af. morphir-elm removed that module in commit 7cb7b300, and today refuses +format 2; reading it here is a deliberate divergence, so that old files can be +upgraded. As in elm, format 2 decodes into the format 3.0.0 model; encode the +result with `morphir.ir.v3.codec` to write format 3. No writer emits format 2. + +The finos/morphir v2 schema documentation says that format 2 literals stay +snake_case. morphir-elm wrote them in TitleCase from the first format-2 commit +(e742a620), and morphir-elm is normative. +""" + +from __future__ import annotations + +from .. import diagnostics as d +from ..diagnostics import Location, fail +from ..format_version import LegacyFormat, read_legacy_document +from . import _legacy_value, module, package +from . import value as v +from .codec import ( + Decoder, + _expect_object, + _field, + _index, + _pair_list_to_dict, + _tag, + decode_access_controlled, + decode_documented, + decode_literal, + decode_name, + decode_package_specification, + decode_path, + decode_type, + decode_type_definition, + decode_unit, +) +from .codec import _decode_doc as decode_module_doc +from .distribution import Library + + +def decode_pattern[A](decode_attributes: Decoder[A]) -> Decoder[v.Pattern[A]]: + """Build a format-2 pattern decoder: snake_case tags, format-3 literals.""" + return _legacy_value.decode_pattern(decode_literal, decode_attributes) + + +def decode_value[TA, VA]( + decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA] +) -> Decoder[v.Value[TA, VA]]: + """Build a format-2 value decoder: snake_case tags, format-3 types and literals.""" + return _legacy_value.decode_value( + decode_literal, decode_type(decode_type_attributes), decode_value_attributes + ) + + +def decode_value_definition[TA, VA]( + decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA] +) -> Decoder[v.Definition[TA, VA]]: + """Build a format-2 value definition decoder.""" + return _legacy_value.decode_value_definition( + decode_type(decode_type_attributes), + decode_value_attributes, + decode_value(decode_type_attributes, decode_value_attributes), + ) + + +def decode_module_definition[TA, VA]( + decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA] +) -> Decoder[module.Definition[TA, VA]]: + """Build a format-2 module definition decoder, with the optional ``doc``.""" + decode_td = decode_access_controlled( + decode_documented(decode_type_definition(decode_type_attributes)) + ) + decode_vd = decode_access_controlled( + decode_documented( + decode_value_definition(decode_type_attributes, decode_value_attributes) + ) + ) + construct = "ModuleDefinition" + + def decode(j: object, loc: Location = ()) -> module.Definition[TA, VA]: + obj = _expect_object(j, loc, construct) + return module.Definition( + _pair_list_to_dict( + decode_name, + decode_td, + _field(obj, "types", loc, construct), + (*loc, "types"), + "Types", + ), + _pair_list_to_dict( + decode_name, + decode_vd, + _field(obj, "values", loc, construct), + (*loc, "values"), + "Values", + ), + decode_module_doc(obj), + ) + + return decode + + +def decode_package_definition[TA, VA]( + decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA] +) -> Decoder[package.Definition[TA, VA]]: + """Build a decoder for ``{"modules": [[path, {"access", "value"}]]}``.""" + decode_m = decode_access_controlled( + decode_module_definition(decode_type_attributes, decode_value_attributes) + ) + construct = "PackageDefinition" + + def decode(j: object, loc: Location = ()) -> package.Definition[TA, VA]: + obj = _expect_object(j, loc, construct) + return package.Definition( + _pair_list_to_dict( + decode_path, + decode_m, + _field(obj, "modules", loc, construct), + (*loc, "modules"), + "Modules", + ) + ) + + return decode + + +_decode_dependency = decode_package_specification(decode_unit) +_decode_library_def = decode_package_definition(decode_unit, decode_type(decode_unit)) + + +def decode_distribution(j: object, loc: Location = ()) -> Library: + """Decode ``["Library", name, dependencies, definition]``.""" + tag, items = _tag(j, loc, "Distribution") + if tag != "Library": + raise fail( + d.UNKNOWN_NODE, + "Distribution", + (*loc, 0), + f"unknown distribution kind {tag!r}; format 2 has only Library", + ) + + def at(i: int) -> tuple[object, Location]: + return _index(items, i, loc, tag), (*loc, i) + + return Library( + package_name=decode_path(*at(1)), + dependencies=_pair_list_to_dict( + decode_path, _decode_dependency, *at(2), "Dependencies" + ), + definition=_decode_library_def(*at(3)), + ) + + +def decode_versioned_distribution(document: str | object) -> Library: + """Decode a ``"formatVersion": 2`` document into the format 3.0.0 model. + + Args: + document: JSON text or an already-decoded JSON value. + + Raises: + DiagnosticError: With ``unsupported_format_version_major`` for any format + other than 2, or with a decoding diagnostic. + """ + root = read_legacy_document(document, LegacyFormat.V2) + return decode_distribution( + _field(root, "distribution", (), "document"), ("distribution",) + ) + + +__all__ = [ + "decode_distribution", + "decode_module_definition", + "decode_package_definition", + "decode_pattern", + "decode_value", + "decode_value_definition", + "decode_versioned_distribution", +] diff --git a/tests/fixtures/ir/legacy/PROVENANCE.md b/tests/fixtures/ir/legacy/PROVENANCE.md new file mode 100644 index 00000000..a4fc2ed9 --- /dev/null +++ b/tests/fixtures/ir/legacy/PROVENANCE.md @@ -0,0 +1,18 @@ +# Legacy format 1 and 2 golden fixtures + +These files are Morphir IR format 1 (`"formatVersion": 1`) and format 2 +(`"formatVersion": 2`) as written by morphir-elm. They are the same rentals +example, written once in each format, so both must decode to the same model. They +are copied byte-for-byte and are not edited. Tests read them directly; morphir-elm +is not a test-time dependency. + +The source repository is licensed under the Apache License, Version 2.0. +Copyright 2020 Morgan Stanley and the FINOS Morphir contributors. + +| Fixture | Format | Repository | Commit | Path | +| --- | --- | --- | --- | --- | +| `elm-rentals-format-1.json` | 1 | [finos/morphir-elm](https://github.com/finos/morphir-elm) | `bc99af69a8b24d391311fae3822a87eafef3c334` | `tests-integration/cli/test-data/rentals/expected-morphir-ir-v1.json` | +| `elm-rentals-format-2.json` | 2 | finos/morphir-elm | `bc99af69` | `tests-integration/cli/test-data/rentals/expected-morphir-ir.json` | + +Both files were added to morphir-elm in commit `9497ed82` and have not changed +since. diff --git a/tests/fixtures/ir/legacy/elm-rentals-format-1.json b/tests/fixtures/ir/legacy/elm-rentals-format-1.json new file mode 100644 index 00000000..7f26211e --- /dev/null +++ b/tests/fixtures/ir/legacy/elm-rentals-format-1.json @@ -0,0 +1,1050 @@ +{ + "formatVersion": 1, + "distribution": [ + "library", + [ + [ + "morphir" + ], + [ + "example" + ], + [ + "app" + ] + ], + [], + { + "modules": [ + { + "name": [ + [ + "rentals" + ] + ], + "def": [ + "public", + { + "types": [], + "values": [ + [ + [ + "request" + ], + [ + "public", + { + "inputTypes": [ + [ + [ + "availability" + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ], + [ + [ + "requested", + "quantity" + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + "outputType": [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + "body": [ + "if_then_else", + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + [ + "apply", + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "bool" + ] + ], + [] + ], + [ + "apply", + [ + "function", + {}, + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "bool" + ] + ], + [] + ] + ], + [ + "reference", + [ + "function", + {}, + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "function", + {}, + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "bool" + ] + ], + [] + ] + ] + ], + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "less", + "than", + "or", + "equal" + ] + ] + ], + [ + "variable", + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "requested", + "quantity" + ] + ] + ], + [ + "variable", + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "availability" + ] + ] + ], + [ + "apply", + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + [ + "constructor", + [ + "function", + {}, + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ] + ], + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "ok" + ] + ] + ], + [ + "variable", + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "requested", + "quantity" + ] + ] + ], + [ + "apply", + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + [ + "constructor", + [ + "function", + {}, + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ] + ], + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "err" + ] + ] + ], + [ + "literal", + [ + "reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "string_literal", + "Insufficient availability" + ] + ] + ] + ] + } + ] + ] + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/ir/legacy/elm-rentals-format-2.json b/tests/fixtures/ir/legacy/elm-rentals-format-2.json new file mode 100644 index 00000000..3bb852d7 --- /dev/null +++ b/tests/fixtures/ir/legacy/elm-rentals-format-2.json @@ -0,0 +1,1050 @@ +{ + "formatVersion": 2, + "distribution": [ + "Library", + [ + [ + "morphir" + ], + [ + "example" + ], + [ + "app" + ] + ], + [], + { + "modules": [ + [ + [ + [ + "rentals" + ] + ], + { + "access": "Public", + "value": { + "types": [], + "values": [ + [ + [ + "request" + ], + { + "access": "Public", + "value": { + "inputTypes": [ + [ + [ + "availability" + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ], + [ + [ + "requested", + "quantity" + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + "outputType": [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + "body": [ + "if_then_else", + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + [ + "apply", + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "bool" + ] + ], + [] + ], + [ + "apply", + [ + "Function", + {}, + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "bool" + ] + ], + [] + ] + ], + [ + "reference", + [ + "Function", + {}, + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "Function", + {}, + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "bool" + ] + ], + [] + ] + ] + ], + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "less", + "than", + "or", + "equal" + ] + ] + ], + [ + "variable", + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "requested", + "quantity" + ] + ] + ], + [ + "variable", + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "availability" + ] + ] + ], + [ + "apply", + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + [ + "constructor", + [ + "Function", + {}, + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ] + ], + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "ok" + ] + ] + ], + [ + "variable", + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ], + [ + "requested", + "quantity" + ] + ] + ], + [ + "apply", + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ], + [ + "constructor", + [ + "Function", + {}, + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "result" + ] + ], + [ + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "basics" + ] + ], + [ + "int" + ] + ], + [] + ] + ] + ] + ], + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "result" + ] + ], + [ + "err" + ] + ] + ], + [ + "literal", + [ + "Reference", + {}, + [ + [ + [ + "morphir" + ], + [ + "s", + "d", + "k" + ] + ], + [ + [ + "string" + ] + ], + [ + "string" + ] + ], + [] + ], + [ + "StringLiteral", + "Insufficient availability" + ] + ] + ] + ] + } + } + ] + ] + } + } + ] + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/ir/v3/PROVENANCE.md b/tests/fixtures/ir/v3/PROVENANCE.md index e36f130f..c66be84b 100644 --- a/tests/fixtures/ir/v3/PROVENANCE.md +++ b/tests/fixtures/ir/v3/PROVENANCE.md @@ -20,7 +20,8 @@ Copyright 2020 Morgan Stanley and the FINOS Morphir contributors. | `finos-greeting-example.json` | [finos/morphir](https://github.com/finos/morphir) | `efa36060` | `website/static/ir/examples/v3/greeting-example.json` | | `finos-rule-set-example.json` | finos/morphir | `efa36060` | `website/static/ir/examples/v3/rule-set-example.json` | -The rentals example is format 2 and is deliberately not included. +The rentals example is format 2, so it is in [`../legacy/`](../legacy/PROVENANCE.md) +with its format-1 twin, not here. ## Format 3.1.0 fixtures diff --git a/tests/tools/test_cli.py b/tests/tools/test_cli.py index d4294f14..9914728c 100644 --- a/tests/tools/test_cli.py +++ b/tests/tools/test_cli.py @@ -14,6 +14,9 @@ REFERENCE = FIXTURES / "elm-reference-model.json" KIT_SPECS = FIXTURES / "mck-distributions-0011-specs.json" RUST_SPECS = FIXTURES / "rust-classic-specs-transport.json" +LEGACY = FIXTURES.parent / "legacy" +RENTALS_1 = LEGACY / "elm-rentals-format-1.json" +RENTALS_2 = LEGACY / "elm-rentals-format-2.json" runner = CliRunner() @@ -98,9 +101,8 @@ def test_missing_version(self, tmp_path: Path) -> None: @pytest.mark.parametrize( ("version", "code"), [ - (1, d.UNSUPPORTED_FORMAT_VERSION_MAJOR), - (2, d.UNSUPPORTED_FORMAT_VERSION_MAJOR), ("3.2.0", d.UNSUPPORTED_FORMAT_VERSION_MINOR), + (5, d.UNSUPPORTED_FORMAT_VERSION_MAJOR), ], ) def test_unsupported_versions( @@ -276,3 +278,46 @@ def test_downgrade_warnings_exit_zero(self, tmp_path: Path) -> None: def test_usage_error(self) -> None: assert run("ir", "convert", VALUE_TREE, "--to", "v9", "-o", "x").exit_code == 2 + + +class TestLegacy: + @pytest.mark.parametrize(("fixture", "label"), [(RENTALS_1, "1"), (RENTALS_2, "2")]) + def test_inspect(self, fixture: Path, label: str) -> None: + result = run("ir", "inspect", fixture) + assert result.exit_code == 0 + assert f"format: {label} (legacy)" in result.stdout + assert "kind: Library" in result.stdout + assert "package: morphir/example/app" in result.stdout + assert "ir convert" in result.stdout + + def test_both_formats_upgrade_to_one_format_3_file(self, tmp_path: Path) -> None: + out1, out2 = tmp_path / "from1.json", tmp_path / "from2.json" + assert run("ir", "convert", RENTALS_1, "--to", "v3", "-o", out1).exit_code == 0 + assert run("ir", "convert", RENTALS_2, "--to", "v3", "-o", out2).exit_code == 0 + assert out1.read_text() == out2.read_text() + assert json.loads(out1.read_text())["formatVersion"] == 3 + inspected = run("ir", "inspect", out1) + assert "format: 3.0.0" in inspected.stdout + + @pytest.mark.parametrize("fixture", [RENTALS_1, RENTALS_2]) + def test_upgrade_to_v4(self, tmp_path: Path, fixture: Path) -> None: + out = tmp_path / "v4.json" + result = run("ir", "convert", fixture, "--to", "v4", "-o", out) + assert result.exit_code == 0 + assert json.loads(out.read_text())["formatVersion"] == 4 + + def test_broken_legacy_file_leaves_no_output(self, tmp_path: Path) -> None: + doc = {"formatVersion": 1, "distribution": ["Library", [], [], {}]} + out = tmp_path / "out.json" + src = write(tmp_path, "x.json", doc) + result = run("ir", "convert", src, "--to", "v3", "-o", out) + assert result.exit_code == 1 + assert d.UNKNOWN_NODE in result.stderr + assert not out.exists() + assert run("ir", "inspect", src).exit_code == 1 + + def test_string_legacy_version_is_invalid(self, tmp_path: Path) -> None: + doc = {"formatVersion": "1", "distribution": []} + result = run("ir", "inspect", write(tmp_path, "x.json", doc)) + assert result.exit_code == 1 + assert d.INVALID_FORMAT_VERSION_SYNTAX in result.stderr diff --git a/tests/unit/ir/test_format_version.py b/tests/unit/ir/test_format_version.py index e3328d01..cb205487 100644 --- a/tests/unit/ir/test_format_version.py +++ b/tests/unit/ir/test_format_version.py @@ -10,11 +10,15 @@ V3_TABLE, FormatVersion, Interval, + LegacyFormat, dump_json, parse_json, read_document, + read_document_with_legacy, read_format_version, + read_legacy_document, recognize_format_version, + recognize_legacy_format, table_to_string, write_format_version, ) @@ -72,11 +76,12 @@ def test_historical_integers_are_recognized(self) -> None: class TestSupport: @pytest.mark.parametrize("value", [1, 2]) - def test_historical_versions_are_follow_up_work(self, value: int) -> None: + def test_legacy_versions_are_outside_the_table(self, value: int) -> None: with pytest.raises(DiagnosticError) as info: read_format_version(value) assert info.value.codes == (d.UNSUPPORTED_FORMAT_VERSION_MAJOR,) - assert "formats 1 and 2 are follow-up work" in str(info.value) + assert "legacy" in str(info.value) + assert "codec_v1" in str(info.value) @pytest.mark.parametrize("value", ["3.1.0", "3.1.4"]) def test_ir_3_1_is_supported(self, value: str) -> None: @@ -125,6 +130,11 @@ class TestWriting: def test_baseline_is_integer(self) -> None: assert write_format_version(read_format_version("3.0.0")) == 3 + @pytest.mark.parametrize("major", [1, 2]) + def test_legacy_formats_are_never_written(self, major: int) -> None: + with pytest.raises(ValueError, match="never written"): + write_format_version(FormatVersion(major, 0, 0)) + def test_patch_is_string(self) -> None: assert write_format_version(FormatVersion(4, 0, 2)) == "4.0.2" @@ -177,3 +187,78 @@ def test_diagnostic_render(self) -> None: diagnostic = d.Diagnostic("c", "X", ("a", 1), "msg") assert diagnostic.render() == "c at a/1: msg" assert d.Diagnostic("c", "X", (), "msg").render() == "c at : msg" + + +class TestLegacy: + @pytest.mark.parametrize( + ("value", "expected"), [(1, LegacyFormat.V1), (2, LegacyFormat.V2)] + ) + def test_integers_are_legacy(self, value: int, expected: LegacyFormat) -> None: + assert recognize_legacy_format(value) is expected + assert str(expected) == str(value) + + @pytest.mark.parametrize("value", [True, "1", "2", "1.0.0", 1.0, 3, 0, None]) + def test_other_spellings_are_not_legacy(self, value: object) -> None: + assert recognize_legacy_format(value) is None + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (1, LegacyFormat.V1), + (2, LegacyFormat.V2), + (3, FormatVersion(3, 0, 0)), + ("4.0.0", FormatVersion(4, 0, 0)), + ], + ) + def test_document_with_legacy( + self, value: object, expected: FormatVersion | LegacyFormat + ) -> None: + version, root = read_document_with_legacy({"formatVersion": value, "x": 1}) + assert version == expected + assert root["x"] == 1 + + @pytest.mark.parametrize( + ("document", "code"), + [ + ('{"distribution": []}', d.MISSING_FORMAT_VERSION), + ('{"formatVersion": "1"}', d.INVALID_FORMAT_VERSION_SYNTAX), + ('{"formatVersion": true}', d.INVALID_FORMAT_VERSION_TYPE), + ('{"formatVersion": 5}', d.UNSUPPORTED_FORMAT_VERSION_MAJOR), + ('{"formatVersion": 1, "formatVersion": 3}', d.DUPLICATE_FORMAT_VERSION), + ], + ) + def test_document_with_legacy_keeps_diagnostics( + self, document: str, code: str + ) -> None: + with pytest.raises(DiagnosticError) as info: + read_document_with_legacy(document) + assert info.value.codes == (code,) + + def test_legacy_document(self) -> None: + root = read_legacy_document('{"formatVersion": 2, "x": 1}', LegacyFormat.V2) + assert root["x"] == 1 + + @pytest.mark.parametrize( + ("value", "found"), [(2, "2"), (3, "3.0.0"), ("3.1.0", "3.1.0"), (4, "4.0.0")] + ) + def test_legacy_document_refuses_other_formats( + self, value: object, found: str + ) -> None: + with pytest.raises(DiagnosticError) as info: + read_legacy_document({"formatVersion": value}, LegacyFormat.V1) + assert info.value.codes == (d.UNSUPPORTED_FORMAT_VERSION_MAJOR,) + assert f"format version {found} " in str(info.value) + assert "only legacy format 1" in str(info.value) + + def test_legacy_document_keeps_recognition_errors(self) -> None: + with pytest.raises(DiagnosticError) as info: + read_legacy_document({"formatVersion": "1"}, LegacyFormat.V1) + assert info.value.codes == (d.INVALID_FORMAT_VERSION_SYNTAX,) + + def test_legacy_document_missing_version(self) -> None: + with pytest.raises(DiagnosticError) as info: + read_legacy_document("{}", LegacyFormat.V1) + assert info.value.codes == (d.MISSING_FORMAT_VERSION,) + + def test_support_table_stays_in_the_release_grammar(self) -> None: + assert all(interval.lower.major >= 3 for interval in SUPPORT_TABLE) diff --git a/tests/unit/ir/test_v3_legacy_codec.py b/tests/unit/ir/test_v3_legacy_codec.py new file mode 100644 index 00000000..a62fce7e --- /dev/null +++ b/tests/unit/ir/test_v3_legacy_codec.py @@ -0,0 +1,537 @@ +"""Tests for the legacy format 1 and 2 decoders (capability ir/v3). + +The JSON spellings come from morphir-elm at bc99af69: ``*/CodecV1.elm`` for +format 1, and for format 2 the codecs before commit 02aa36af, whose value and +pattern tags survive as ``Value/CodecV2.elm`` in that commit. +""" + +import json +from decimal import Decimal +from pathlib import Path + +import pytest + +from morphir.ir import diagnostics as d +from morphir.ir import v3 +from morphir.ir.diagnostics import DiagnosticError +from morphir.ir.migrate import v3_to_v4 +from morphir.ir.v3 import codec, codec_v1, codec_v2, module, package +from morphir.ir.v3 import literal as lit +from morphir.ir.v3 import type as t +from morphir.ir.v3 import value as v +from morphir.ir.v3.access_controlled import private, public +from morphir.ir.v3.documented import Documented +from morphir.ir.v3.fqname import fqn +from morphir.ir.v3.name import Name, from_string +from morphir.ir.v3.path import from_string as path_from_string + +FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" / "ir" / "legacy" +RENTALS_1 = FIXTURES / "elm-rentals-format-1.json" +RENTALS_2 = FIXTURES / "elm-rentals-format-2.json" + +FQ = [[["morphir"], ["s", "d", "k"]], [["basics"]], ["int"]] +INT_FQN = fqn("Morphir.SDK", "Basics", "Int") +INT = t.Reference(None, INT_FQN) +INT_1 = ["reference", {}, FQ, []] +INT_3 = ["Reference", {}, FQ, []] +UNIT_V = ["unit", {}] +WILD = ["wildcard_pattern", {}] +DEF_1 = { + "inputTypes": [[["a"], {}, ["unit", {}]]], + "outputType": ["unit", {}], + "body": UNIT_V, +} +DEF_2 = { + "inputTypes": [[["a"], {}, ["Unit", {}]]], + "outputType": ["Unit", {}], + "body": UNIT_V, +} +DEF = v.Definition(((("a",), None, t.Unit(None)),), t.Unit(None), v.Unit(None)) + + +def n(text: str) -> Name: + return from_string(text) + + +def codes(fn, j) -> tuple[str, ...]: # type: ignore[no-untyped-def] + with pytest.raises(DiagnosticError) as info: + fn(j) + return info.value.codes + + +decode_type_1 = codec_v1.decode_type(codec.decode_unit) +decode_pattern_1 = codec_v1.decode_pattern(codec.decode_unit) +decode_value_1 = codec_v1.decode_value(codec.decode_unit, codec.decode_unit) +decode_pattern_2 = codec_v2.decode_pattern(codec.decode_unit) +decode_value_2 = codec_v2.decode_value(codec.decode_unit, codec.decode_unit) + + +class TestFormat1Literals: + @pytest.mark.parametrize( + ("j", "expected"), + [ + (["bool_literal", True], lit.BoolLiteral(True)), + (["char_literal", "x"], lit.CharLiteral("x")), + (["string_literal", "s"], lit.StringLiteral("s")), + (["int_literal", 42], lit.WholeNumberLiteral(42)), + (["float_literal", 1.5], lit.FloatLiteral(1.5)), + (["decimal_literal", "1.25"], lit.DecimalLiteral(Decimal("1.25"))), + ], + ) + def test_tags(self, j: object, expected: lit.Literal) -> None: + assert codec_v1.decode_literal(j) == expected + + @pytest.mark.parametrize( + ("j", "code"), + [ + (["BoolLiteral", True], d.UNKNOWN_NODE), + (["whole_number_literal", 1], d.UNKNOWN_NODE), + (["int_literal", "1"], d.INVALID_LITERAL), + ([], d.MISSING_MEMBER), + ], + ) + def test_errors(self, j: object, code: str) -> None: + assert codes(codec_v1.decode_literal, j) == (code,) + + +class TestFormat1Types: + @pytest.mark.parametrize( + ("j", "expected"), + [ + (["variable", {}, ["a"]], t.Variable(None, ("a",))), + ( + ["reference", {}, FQ, [["variable", {}, ["a"]]]], + t.Reference(None, INT_FQN, (t.Variable(None, ("a",)),)), + ), + (["tuple", {}, [INT_1, INT_1]], t.Tuple(None, (INT, INT))), + (["record", {}, [[["a"], INT_1]]], t.Record(None, (t.Field(("a",), INT),))), + ( + ["extensible_record", {}, ["r"], [[["a"], INT_1]]], + t.ExtensibleRecord(None, ("r",), (t.Field(("a",), INT),)), + ), + (["function", {}, INT_1, INT_1], t.Function(None, INT, INT)), + (["unit", {}], t.Unit(None)), + ], + ) + def test_tags(self, j: object, expected: t.Type[None]) -> None: + assert decode_type_1(j) == expected + + @pytest.mark.parametrize( + ("j", "code"), + [ + (["Variable", {}, ["a"]], d.UNKNOWN_NODE), + (["record", {}, [{"name": ["a"], "tpe": INT_1}]], d.INVALID_JSON), + (["variable", {}], d.MISSING_MEMBER), + ], + ) + def test_errors(self, j: object, code: str) -> None: + assert codes(decode_type_1, j) == (code,) + + def test_specifications(self) -> None: + decode = codec_v1.decode_type_specification(codec.decode_unit) + assert decode( + ["type_alias_specification", [["a"]], INT_1] + ) == t.TypeAliasSpecification((("a",),), INT) + assert decode(["opaque_type_specification", []]) == t.OpaqueTypeSpecification( + () + ) + assert decode( + ["custom_type_specification", [], [[["foo"], [[["x"], INT_1]]]]] + ) == t.CustomTypeSpecification((), {("foo",): ((("x",), INT),)}) + config = {"baseType": INT_1, "fromBaseType": FQ, "toBaseType": FQ} + assert decode( + ["derived_type_specification", [], config] + ) == t.DerivedTypeSpecification((), INT, INT_FQN, INT_FQN) + + def test_derived_type_uses_the_decoder_spelling(self) -> None: + # elm CodecV1 encodes "DerivedTypeSpecification" but decodes only + # "derived_type_specification"; the decoder is what reads files. + decode = codec_v1.decode_type_specification(codec.decode_unit) + config = {"baseType": INT_1, "fromBaseType": FQ, "toBaseType": FQ} + assert codes(decode, ["DerivedTypeSpecification", [], config]) == ( + d.UNKNOWN_NODE, + ) + assert codes(decode, ["derived_type_specification", [], {}]) == ( + d.MISSING_MEMBER, + ) + + def test_definitions(self) -> None: + decode = codec_v1.decode_type_definition(codec.decode_unit) + assert decode(["type_alias_definition", [], INT_1]) == t.TypeAliasDefinition( + (), INT + ) + assert decode( + ["custom_type_definition", [], ["private", [[["foo"], []]]]] + ) == t.CustomTypeDefinition((), private({("foo",): ()})) + assert codes(decode, ["TypeAliasDefinition", [], INT_1]) == (d.UNKNOWN_NODE,) + + +class TestFormat1Wrappers: + def test_access_controlled_is_a_tagged_array(self) -> None: + decode = codec_v1.decode_access_controlled(decode_type_1) + assert decode(["public", INT_1]) == public(INT) + assert decode(["private", INT_1]) == private(INT) + + @pytest.mark.parametrize( + ("j", "code"), + [ + ({"access": "Public", "value": INT_1}, d.INVALID_JSON), + (["Public", INT_1], d.UNKNOWN_NODE), + (["public"], d.MISSING_MEMBER), + ], + ) + def test_access_controlled_errors(self, j: object, code: str) -> None: + decode = codec_v1.decode_access_controlled(decode_type_1) + assert codes(decode, j) == (code,) + + def test_documented_is_a_pair_or_bare(self) -> None: + decode = codec_v1.decode_documented(decode_type_1) + assert decode(["doc", INT_1]) == Documented("doc", INT) + assert decode(INT_1) == Documented("", INT) + assert decode(UNIT_V) == Documented("", t.Unit(None)) + + def test_documented_reports_both_alternatives(self) -> None: + decode = codec_v1.decode_documented(decode_type_1) + assert codes(decode, ["doc", ["bogus", {}]]) == ( + d.UNKNOWN_NODE, + d.UNKNOWN_NODE, + ) + assert codes(decode, {"doc": "x"}) == (d.INVALID_JSON,) + + +PATTERNS: list[tuple[object, v.Pattern[None]]] = [ + (WILD, v.WildcardPattern(None)), + ( + ["as_pattern", {}, WILD, ["x"]], + v.AsPattern(None, v.WildcardPattern(None), n("x")), + ), + (["tuple_pattern", {}, [WILD]], v.TuplePattern(None, (v.WildcardPattern(None),))), + ( + ["constructor_pattern", {}, FQ, [WILD]], + v.ConstructorPattern(None, INT_FQN, (v.WildcardPattern(None),)), + ), + (["empty_list_pattern", {}], v.EmptyListPattern(None)), + ( + ["head_tail_pattern", {}, WILD, WILD], + v.HeadTailPattern(None, v.WildcardPattern(None), v.WildcardPattern(None)), + ), + (["unit_pattern", {}], v.UnitPattern(None)), +] + +U = v.Unit(None) +VALUES: list[tuple[object, v.Value[None, None]]] = [ + (["constructor", {}, FQ], v.Constructor(None, INT_FQN)), + (["tuple", {}, [UNIT_V]], v.Tuple(None, (U,))), + (["list", {}, [UNIT_V]], v.List(None, (U,))), + (["record", {}, [[["a"], UNIT_V]]], v.Record(None, {("a",): U})), + (["variable", {}, ["x"]], v.Variable(None, ("x",))), + (["reference", {}, FQ], v.Reference(None, INT_FQN)), + (["field", {}, UNIT_V, ["a"]], v.Field(None, U, ("a",))), + (["field_function", {}, ["a"]], v.FieldFunction(None, ("a",))), + (["apply", {}, UNIT_V, UNIT_V], v.Apply(None, U, U)), + (["lambda", {}, WILD, UNIT_V], v.Lambda(None, v.WildcardPattern(None), U)), + ( + ["destructure", {}, WILD, UNIT_V, UNIT_V], + v.Destructure(None, v.WildcardPattern(None), U, U), + ), + (["if_then_else", {}, UNIT_V, UNIT_V, UNIT_V], v.IfThenElse(None, U, U, U)), + ( + ["pattern_match", {}, UNIT_V, [[WILD, UNIT_V]]], + v.PatternMatch(None, U, ((v.WildcardPattern(None), U),)), + ), + ( + ["update_record", {}, UNIT_V, [[["a"], UNIT_V]]], + v.UpdateRecord(None, U, {("a",): U}), + ), + (UNIT_V, U), +] + + +class TestFormat1Values: + @pytest.mark.parametrize(("j", "expected"), PATTERNS) + def test_pattern_tags(self, j: object, expected: v.Pattern[None]) -> None: + assert decode_pattern_1(j) == expected + + def test_literal_pattern_uses_format_1_literals(self) -> None: + j = ["literal_pattern", {}, ["int_literal", 1]] + assert decode_pattern_1(j) == v.LiteralPattern(None, lit.WholeNumberLiteral(1)) + + @pytest.mark.parametrize("j", [["WildcardPattern", {}], ["bogus_pattern", {}]]) + def test_unknown_pattern(self, j: object) -> None: + assert codes(decode_pattern_1, j) == (d.UNKNOWN_NODE,) + + @pytest.mark.parametrize(("j", "expected"), VALUES) + def test_value_tags(self, j: object, expected: v.Value[None, None]) -> None: + assert decode_value_1(j) == expected + + def test_literal_value_uses_format_1_literals(self) -> None: + j = ["literal", {}, ["bool_literal", True]] + assert decode_value_1(j) == v.LiteralValue(None, lit.BoolLiteral(True)) + + def test_let_definitions_use_format_1_types(self) -> None: + assert decode_value_1( + ["let_definition", {}, ["x"], DEF_1, UNIT_V] + ) == v.LetDefinition(None, ("x",), DEF, U) + assert decode_value_1( + ["let_recursion", {}, [[["x"], DEF_1]], UNIT_V] + ) == v.LetRecursion(None, {("x",): DEF}, U) + + @pytest.mark.parametrize( + ("j", "code"), + [ + (["Literal", {}, ["bool_literal", True]], d.UNKNOWN_NODE), + (["literal", {}, ["BoolLiteral", True]], d.UNKNOWN_NODE), + (["let_definition", {}, ["x"], DEF_2, UNIT_V], d.UNKNOWN_NODE), + (["pattern_match", {}, UNIT_V, [[WILD]]], d.MISSING_MEMBER), + (["apply", {}, UNIT_V], d.MISSING_MEMBER), + ], + ) + def test_value_errors(self, j: object, code: str) -> None: + assert codes(decode_value_1, j) == (code,) + + def test_typed_value_attributes(self) -> None: + decode = codec_v1.decode_value(codec.decode_unit, decode_type_1) + assert decode(["variable", INT_1, ["x"]]) == v.Variable(INT, ("x",)) + + def test_value_specification_and_definition(self) -> None: + spec = codec_v1.decode_value_specification(codec.decode_unit) + assert spec({"inputs": [[["a"], INT_1]], "output": INT_1}) == ( + v.Specification(((("a",), INT),), INT) + ) + defn = codec_v1.decode_value_definition(codec.decode_unit, codec.decode_unit) + assert defn(DEF_1) == DEF + assert codes(defn, {"inputTypes": [], "outputType": INT_1}) == ( + d.MISSING_MEMBER, + ) + + +MODULE_SPEC_1 = { + "types": [[["t"], ["a type", ["opaque_type_specification", []]]]], + "values": [[["f"], {"inputs": [], "output": INT_1}]], +} +MODULE_DEF_1 = { + "types": [[["t"], ["public", ["a type", ["type_alias_definition", [], INT_1]]]]], + "values": [[["f"], ["private", DEF_1]]], +} +MODULE_SPEC = module.Specification( + {("t",): Documented("a type", t.OpaqueTypeSpecification(()))}, + {("f",): Documented("", v.Specification((), INT))}, +) +MODULE_DEF: module.Definition[None, None] = module.Definition( + {("t",): public(Documented("a type", t.TypeAliasDefinition((), INT)))}, + {("f",): private(Documented("", DEF))}, +) + + +class TestFormat1Packages: + def test_module_specification(self) -> None: + decode = codec_v1.decode_module_specification(codec.decode_unit) + assert decode(MODULE_SPEC_1) == MODULE_SPEC + + def test_module_doc_is_not_read(self) -> None: + decode = codec_v1.decode_module_specification(codec.decode_unit) + assert decode({**MODULE_SPEC_1, "doc": "ignored"}).doc is None + decode_def = codec_v1.decode_module_definition( + codec.decode_unit, codec.decode_unit + ) + assert decode_def({**MODULE_DEF_1, "doc": "ignored"}).doc is None + + def test_module_definition(self) -> None: + decode = codec_v1.decode_module_definition(codec.decode_unit, codec.decode_unit) + assert decode(MODULE_DEF_1) == MODULE_DEF + assert codes(decode, {"types": []}) == (d.MISSING_MEMBER,) + + def test_package_specification_uses_name_and_spec(self) -> None: + decode = codec_v1.decode_package_specification(codec.decode_unit) + j = {"modules": [{"name": [["a"]], "spec": MODULE_SPEC_1}]} + assert decode(j) == package.Specification({(("a",),): MODULE_SPEC}) + assert codes(decode, {"modules": [[[["a"]], MODULE_SPEC_1]]}) == ( + d.INVALID_JSON, + ) + + def test_package_definition_uses_name_and_def(self) -> None: + decode = codec_v1.decode_package_definition( + codec.decode_unit, codec.decode_unit + ) + j = {"modules": [{"name": [["a"]], "def": ["public", MODULE_DEF_1]}]} + assert decode(j) == package.Definition({(("a",),): public(MODULE_DEF)}) + assert codes(decode, {"modules": [{"name": [["a"]]}]}) == (d.MISSING_MEMBER,) + + def test_distribution(self) -> None: + j = ["library", [["p"]], [], {"modules": []}] + assert codec_v1.decode_distribution(j) == v3.Library( + path_from_string("P"), package.Definition() + ) + assert codes(codec_v1.decode_distribution, ["Library", [], [], {}]) == ( + d.UNKNOWN_NODE, + ) + + def test_distribution_with_dependency(self) -> None: + spec = {"modules": [{"name": [["a"]], "spec": MODULE_SPEC_1}]} + j = ["library", [["p"]], [[[["dep"]], spec]], {"modules": []}] + lib = codec_v1.decode_distribution(j) + assert lib.dependencies == { + path_from_string("Dep"): package.Specification({(("a",),): MODULE_SPEC}) + } + + def test_versioned_distribution(self) -> None: + j = {"formatVersion": 1, "distribution": ["library", [], [], {"modules": []}]} + assert codec_v1.decode_versioned_distribution(j) == v3.Library( + path_from_string(""), package.Definition() + ) + + @pytest.mark.parametrize("version", [2, 3, "3.0.0", 4]) + def test_versioned_distribution_refuses_other_formats( + self, version: object + ) -> None: + j = {"formatVersion": version, "distribution": []} + assert codes(codec_v1.decode_versioned_distribution, j) == ( + d.UNSUPPORTED_FORMAT_VERSION_MAJOR, + ) + + def test_versioned_distribution_needs_a_distribution(self) -> None: + assert codes(codec_v1.decode_versioned_distribution, {"formatVersion": 1}) == ( + d.MISSING_MEMBER, + ) + + +class TestFormat2: + @pytest.mark.parametrize(("j", "expected"), PATTERNS) + def test_pattern_tags_are_snake_case( + self, j: object, expected: v.Pattern[None] + ) -> None: + assert decode_pattern_2(j) == expected + + @pytest.mark.parametrize(("j", "expected"), VALUES) + def test_value_tags_are_snake_case( + self, j: object, expected: v.Value[None, None] + ) -> None: + assert decode_value_2(j) == expected + + def test_literals_and_types_are_format_3(self) -> None: + assert decode_value_2(["literal", {}, ["BoolLiteral", True]]) == v.LiteralValue( + None, lit.BoolLiteral(True) + ) + assert decode_pattern_2( + ["literal_pattern", {}, ["WholeNumberLiteral", 1]] + ) == v.LiteralPattern(None, lit.WholeNumberLiteral(1)) + assert decode_value_2( + ["let_definition", {}, ["x"], DEF_2, UNIT_V] + ) == v.LetDefinition(None, ("x",), DEF, U) + decode = codec_v2.decode_value( + codec.decode_unit, codec.decode_type(codec.decode_unit) + ) + assert decode(["variable", INT_3, ["x"]]) == v.Variable(INT, ("x",)) + + @pytest.mark.parametrize( + "j", + [ + ["Literal", {}, ["BoolLiteral", True]], + ["literal", {}, ["bool_literal", True]], + ["let_definition", {}, ["x"], DEF_1, UNIT_V], + ], + ) + def test_other_spellings_are_refused(self, j: object) -> None: + assert codes(decode_value_2, j) == (d.UNKNOWN_NODE,) + + def test_value_definition(self) -> None: + decode = codec_v2.decode_value_definition(codec.decode_unit, codec.decode_unit) + assert decode(DEF_2) == DEF + + def test_module_and_package_definition(self) -> None: + module_def = { + "types": [ + [ + ["t"], + { + "access": "Public", + "value": { + "doc": "a type", + "value": ["TypeAliasDefinition", [], INT_3], + }, + }, + ] + ], + "values": [[["f"], {"access": "Private", "value": DEF_2}]], + "doc": "a module", + } + decode_module = codec_v2.decode_module_definition( + codec.decode_unit, codec.decode_unit + ) + expected = module.Definition(MODULE_DEF.types, MODULE_DEF.values, "a module") + assert decode_module(module_def) == expected + decode_package = codec_v2.decode_package_definition( + codec.decode_unit, codec.decode_unit + ) + j = {"modules": [[[["a"]], {"access": "Public", "value": module_def}]]} + assert decode_package(j) == package.Definition({(("a",),): public(expected)}) + + def test_distribution(self) -> None: + j = ["Library", [["p"]], [], {"modules": []}] + assert codec_v2.decode_distribution(j) == v3.Library( + path_from_string("P"), package.Definition() + ) + assert codes(codec_v2.decode_distribution, ["library", [], [], {}]) == ( + d.UNKNOWN_NODE, + ) + + def test_versioned_distribution(self) -> None: + j = {"formatVersion": 2, "distribution": ["Library", [], [], {"modules": []}]} + assert codec_v2.decode_versioned_distribution(json.dumps(j)) == v3.Library( + path_from_string(""), package.Definition() + ) + + @pytest.mark.parametrize("version", [1, 3, 4]) + def test_versioned_distribution_refuses_other_formats(self, version: int) -> None: + j = {"formatVersion": version, "distribution": []} + assert codes(codec_v2.decode_versioned_distribution, j) == ( + d.UNSUPPORTED_FORMAT_VERSION_MAJOR, + ) + + +class TestElmFixtures: + """The morphir-elm rentals example, written once as format 1 and once as 2.""" + + def test_format_1_decodes(self) -> None: + lib = codec_v1.decode_versioned_distribution(RENTALS_1.read_text()) + assert lib.package_name == path_from_string("Morphir.Example.App") + assert list(lib.definition.modules) == [path_from_string("Rentals")] + + def test_format_2_decodes(self) -> None: + lib = codec_v2.decode_versioned_distribution(RENTALS_2.read_text()) + assert lib.package_name == path_from_string("Morphir.Example.App") + + def test_both_formats_hold_the_same_model(self) -> None: + assert codec_v1.decode_versioned_distribution( + RENTALS_1.read_text() + ) == codec_v2.decode_versioned_distribution(RENTALS_2.read_text()) + + @pytest.mark.parametrize( + ("reader", "fixture"), + [ + (codec_v1.decode_versioned_distribution, RENTALS_1), + (codec_v2.decode_versioned_distribution, RENTALS_2), + ], + ) + def test_legacy_gives_a_3_0_0_library_never_specs(self, reader, fixture) -> None: # type: ignore[no-untyped-def] + lib = reader(fixture.read_text()) + assert isinstance(lib, v3.Library) + assert not isinstance(lib, v3.Specs) + document = codec.encode_versioned_distribution(lib) + assert document["formatVersion"] == 3 + + def test_upgrade_writes_format_3(self) -> None: + lib = codec_v1.decode_versioned_distribution(RENTALS_1.read_text()) + document = codec.encode_versioned_distribution(lib) + assert document["formatVersion"] == 3 + assert codec.decode_versioned_distribution(document) == lib + + def test_upgrade_continues_to_v4(self) -> None: + lib = codec_v2.decode_versioned_distribution(RENTALS_2.read_text()) + assert v3_to_v4(lib).value.package.name is not None + + @pytest.mark.parametrize("fixture", [RENTALS_1, RENTALS_2]) + def test_format_3_reader_still_refuses_legacy(self, fixture: Path) -> None: + assert codes(codec.decode_versioned_distribution, fixture.read_text()) == ( + d.UNSUPPORTED_FORMAT_VERSION_MAJOR, + )