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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/unreleased/api-json-model-loading.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **Load API element-form JSON files directly.** Explicitly named `.json` model files are converted to SysML notation before parsing; diagnostics refer to that notation.
2 changes: 2 additions & 0 deletions changes/unreleased/toolkit-json-import.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- **Toolkit full-JSON imports preserve flows, transitions, and library references.** Flow ends owned through `EndFeatureMembership` are folded into the flow head; transitions read trigger, guard, and effect features by their membership kind rather than mistaking parameters for effects; and document-defined library packages remain declarations. Library stubs with stale ids can resolve by their stated qualified name or graph-stated name and owner chain, with a warning, while unmatched identities report both the id and name. Apollo API-JSON import avoids rescanning the whole graph for each owner.
- Reference-usage return parameters retain their metaclass on toolkit JSON round trips. Bulk API JSON decoding delays duplicate indexing until parsing is complete, and graph normalization reuses the imported graph's storage while releasing excess triple capacity.
2 changes: 1 addition & 1 deletion client/python/opensysml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ def parse_sources(documents, host='localhost', port=None, strict=False,

def load(file_path, host='localhost', port=None, strict=False,
strict_conformance=False):
"""Load a SysML model from file using the default connection.
"""Load a SysML model from a .sysml, .kerml, or API element-form .json file.

Convenience function that uses a module-level singleton connection.

Expand Down
75 changes: 75 additions & 0 deletions cmd/sysml/api_json_load_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"bytes"
"errors"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func runValidatePath(t *testing.T, binary, path string) runOutcome {
t.Helper()
cmd := exec.Command(binary, "-validate", path)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
result := runOutcome{stdout: stdout.String(), stderr: stderr.String()}
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
result.status = exit.ExitCode()
default:
t.Fatalf("running -validate %s: %v\n%s", path, err, result.output())
}
return result
}

func apiJSONFixture(t *testing.T, name string) string {
t.Helper()
path, err := filepath.Abs(filepath.Join("..", "..", "tests", "export", "testdata", "interchange", name))
if err != nil {
t.Fatal(err)
}
return path
}

func TestValidateAPIJSONFiles(t *testing.T) {
binary := buildCLI(t)

flowEnds := runValidatePath(t, binary, apiJSONFixture(t, "flow_ends.toolkit.full.json"))
if flowEnds.status != 0 || !strings.Contains(flowEnds.output(), "no errors") {
t.Errorf("validating toolkit flow ends exited %d:\n%s", flowEnds.status, flowEnds.output())
}

library := runValidatePath(t, binary, apiJSONFixture(t, "library_identity.toolkit.full.json"))
if library.status != 0 || !strings.Contains(library.output(), "warning: the library element") {
t.Errorf("validating toolkit library identities exited %d:\n%s", library.status, library.output())
}
}

func TestValidateUnconvertibleAPIJSONNamesFile(t *testing.T) {
binary := buildCLI(t)
path := apiJSONFixture(t, "library_unmatched.toolkit.full.json")

result := runValidatePath(t, binary, path)
if result.status == 0 || !strings.Contains(result.output(), path) {
t.Fatalf("failed conversion did not name %s (exit %d):\n%s", path, result.status, result.output())
}
}

func TestSessionLoadsAPIJSONFiles(t *testing.T) {
session := newSession()
report, err := session.LoadPathsReport([]string{apiJSONFixture(t, "library_identity.toolkit.full.json")})
if err != nil {
t.Fatal(err)
}
if report.Errors || len(report.Declared) == 0 {
t.Fatalf("converted API JSON did not load cleanly: %+v", report)
}
if !strings.Contains(strings.Join(report.Found, "\n"), "warning: the library element") {
t.Fatalf("load report omitted the conversion warning: %v", report.Found)
}
}
4 changes: 3 additions & 1 deletion cmd/sysml/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,9 @@ func convertRecorded(input string, to convert.Format) (int, error) {
// convertOptions are the conversion settings -id asks for, refusing it for a
// direction it does not apply to.
func convertOptions(from, to convert.Format) (convert.Options, error) {
opts := convert.Options{}
opts := convert.Options{Warn: func(message string) {
fmt.Fprintf(os.Stderr, "warning: %s\n", message)
}}
if idForm == "" {
return opts, nil
}
Expand Down
57 changes: 57 additions & 0 deletions cmd/sysml/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"sync"
"testing"

"github.com/Open-MBEE/OpenSysML/internal/translate/convert"
"github.com/Open-MBEE/OpenSysML/internal/translate/migrate"
"github.com/Open-MBEE/OpenSysML/tests/testutil/gobuild"
)
Expand Down Expand Up @@ -120,6 +121,62 @@ func TestConvertToStdout(t *testing.T) {
}
}

func TestConvertAPIJSONLibraryFallbackWarnsOnStderr(t *testing.T) {
binary := buildCLI(t)
data, err := os.ReadFile(filepath.Join("..", "..", "tests", "export", "testdata", "interchange", "library_identity.toolkit.full.json"))
if err != nil {
t.Fatal(err)
}
input := filepath.Join(t.TempDir(), "library.json")
if err := os.WriteFile(input, data, 0o644); err != nil {
t.Fatal(err)
}
result := runCommand(t, exec.Command(binary, input, "-from", "api-json", "-convert", "sysml"))
if result.status != 0 {
t.Fatalf("conversion exited %d:\n%s%s", result.status, result.stdout, result.stderr)
}
if !strings.Contains(result.stderr, "warning: the library element") ||
!strings.Contains(result.stderr, "resolved by its qualified name ScalarValues::Real") {
t.Errorf("the library identity warning was not written to stderr:\n%s", result.stderr)
}
if strings.Contains(result.stdout, "warning:") || !strings.Contains(result.stdout, "library package DocumentLibrary") {
t.Errorf("stdout contains the wrong conversion output:\n%s", result.stdout)
}
}

func TestConvertRecordedReportsAPIJSONWarning(t *testing.T) {
stdout, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = stdout.Close() })
stderr, err := os.CreateTemp(t.TempDir(), "stderr")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = stderr.Close() })
oldStdout, oldStderr := os.Stdout, os.Stderr
os.Stdout, os.Stderr = stdout, stderr
defer func() {
os.Stdout, os.Stderr = oldStdout, oldStderr
}()

status, err := convertRecorded(apiJSONFixture(t, "library_identity.toolkit.full.json"), convert.FormatSysML)
if err != nil || status != 0 {
t.Fatalf("recorded conversion returned status %d, error %v", status, err)
}
if err := stderr.Sync(); err != nil {
t.Fatal(err)
}
warnings, err := os.ReadFile(stderr.Name())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(warnings), "warning: the library element") {
t.Fatalf("recorded conversion omitted the library warning:\n%s", warnings)
}
}

// TestConvertFlagOrder checks that the model may be named before or after the
// flags that apply to it, since Go's flag package stops at the first file name
// unless the arguments are reordered.
Expand Down
2 changes: 1 addition & 1 deletion cmd/sysml/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,7 @@ func resolveRunBounds() int {
// newSession returns a session in the output modes the flags asked for, under
// the run bounds resolved at startup.
func newSession() *repl.Session {
sess := repl.NewSession()
sess := repl.NewSessionWithSourceConverter(convert.ModelSource)
sess.SetToolVersion("sysml " + Version)
if err := sess.SetBudgets(budgets); err != nil {
// Unreachable: budgets are validated in main before any session exists.
Expand Down
3 changes: 3 additions & 0 deletions docs/guide/03-command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ sysml: bad.sysml did not analyse cleanly; no check was made
exit=2
```

An explicitly named API element-form `.json` file is converted before validation. Diagnostic
positions refer to the converted SysML notation, not to positions in the JSON document.

A single `-` stands for standard input wherever a file name is accepted, so you can pipe a model
in; its diagnostics are reported against `<stdin>`. To read a file that is actually named `-`,
write `./-`. `-convert` needs `-from` for piped input, because a stream has no file
Expand Down
6 changes: 6 additions & 0 deletions docs/guide/04-repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ it again at the prompt *replaces* it (`note: replaced package …`) rather than
loaded file, edit it and load it again. Tab completion completes paths after `%load` and `%save`,
and meta-commands and symbol names everywhere else.

A specifically named API element-form `.json` file is converted and loaded; directory and glob
expansion still collects only `.sysml` and `.kerml` files.

A loaded file's imports are followed to its neighbors: when a file imports a root namespace that
neither the loaded files nor the standard library declare, the `.sysml` and `.kerml` files beside
and below it are searched for one declaring that name, and each is loaded too, its own imports
Expand All @@ -130,6 +133,9 @@ session, so the next submission is parsed against the model as it stood before t
non-interactive use, a load's diagnostics are errors, so a script that loads a malformed
file fails rather than continuing against an empty session.

An explicitly named API element-form `.json` file is converted to SysML notation before loading;
diagnostic positions refer to the converted notation, not to positions in the JSON document.

Each loaded file is a document of its own, analysed as the editor and the checker analyse it,
while everything typed at the prompt forms one transcript document. The transcript is kept
under the name `<repl>`, which is therefore reserved: a file whose path is literally `<repl>`
Expand Down
Loading
Loading