sysml-windtrader 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/CHANGELOG.md +28 -0
  2. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/PKG-INFO +37 -6
  3. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/README.md +35 -4
  4. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/pyproject.toml +2 -2
  5. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/src/windtrader/cli.py +4 -1
  6. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/src/windtrader/client.py +40 -2
  7. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/tests/test_cli.py +8 -4
  8. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/tests/test_client.py +62 -4
  9. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/.github/workflows/ci.yml +0 -0
  10. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/.github/workflows/docstring-badge.yml +0 -0
  11. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/.github/workflows/publish.yml +0 -0
  12. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/.github/workflows/release.yml +0 -0
  13. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/.gitignore +0 -0
  14. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/.pre-commit-config.yaml +0 -0
  15. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/LICENSE +0 -0
  16. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/src/windtrader/__init__.py +0 -0
  17. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/src/windtrader/_jars.py +0 -0
  18. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/tests/test_integration_jar.py +0 -0
  19. {sysml_windtrader-0.2.2 → sysml_windtrader-0.2.4}/tests/test_jars.py +0 -0
@@ -2,6 +2,34 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.2.4 (2026-10-17)
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - Normalize any process exit code outside the documented 0/2/3 contract to **3**
10
+ (runtime error) in `CommandResult` (and thus the CLI). A JVM startup failure on an
11
+ old JRE (e.g. `UnsupportedClassVersionError` hitting the Java-21-compiled
12
+ `windtrader-java` 0.2.0 JAR) previously leaked a foreign code like **1**, breaking
13
+ the contract and making `is_runtime_error` (and CLI exit-code branching) miss the
14
+ case. The underlying JVM detail is preserved on `stderr`.
15
+ - Add a Java-executable override so users with an old default `java` can point the
16
+ wrapper at a Java 21+ JVM: `WINDTRADER_JAVA` (explicit path) → `JAVA_HOME/bin/java`
17
+ → `java` on PATH.
18
+
19
+ ### 📝 Documentation
20
+
21
+ - README: state the **Java 21+** requirement in Installation; document the exit-code
22
+ normalization and the Java-executable resolution order in Exit Code Contract and
23
+ Configuration.
24
+ - pyproject description: note "requires Java 21+".
25
+
26
+ ## v0.2.3 (2026-10-17)
27
+
28
+ ### 📝 Documentation
29
+
30
+ - README: reflect `sysml-windtrader` distribution name; document
31
+ `validate_across_versions` with its real signature
32
+
5
33
  ## v0.2.2 (2026-10-17)
6
34
 
7
35
  ### 🐛 Bug Fixes
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sysml-windtrader
3
- Version: 0.2.2
4
- Summary: Python wrapper around windtrader-java for SysML v2 parse-only validation.
3
+ Version: 0.2.4
4
+ Summary: Python wrapper around windtrader-java for SysML v2 parse-only validation (requires Java 21+).
5
5
  Author: Westfall-io
6
6
  License: MIT License
7
7
 
@@ -36,9 +36,10 @@ Requires-Dist: python-semantic-release>=10.0.0; extra == 'dev'
36
36
  Requires-Dist: ruff>=0.6; extra == 'dev'
37
37
  Description-Content-Type: text/markdown
38
38
 
39
- # windtrader
39
+ # sysml-windtrader
40
40
 
41
- **windtrader** is a lightweight Python wrapper around the `windtrader-java` SysML v2 validator.
41
+ **sysml-windtrader** (`windtrader` on import) is a lightweight Python wrapper around the
42
+ `windtrader-java` SysML v2 validator.
42
43
  It provides a simple, scriptable way to validate SysML v2 textual syntax from Python while
43
44
  delegating all grammar authority to the official Java-based validator.
44
45
 
@@ -79,7 +80,11 @@ Or for development:
79
80
  pip install -e ".[dev]"
80
81
  ```
81
82
 
82
- Java **must** be available on your system (CI uses Temurin JDK).
83
+ **Java requirement:** the default `windtrader-java` release (0.2.x) is compiled for
84
+ **Java 21** (class file version 65). Java **21 or newer** must be available on your
85
+ system (CI uses Temurin JDK 21). If your default `java` is older, point the wrapper at a
86
+ Java 21+ JVM via `WINDTRADER_JAVA` (explicit executable path) or `JAVA_HOME` — see
87
+ [Configuration](#configuration).
83
88
 
84
89
  ---
85
90
 
@@ -162,6 +167,18 @@ else:
162
167
  print("Failed:", res.stderr)
163
168
  ```
164
169
 
170
+ ### Validate across versions
171
+
172
+ ```python
173
+ from windtrader import validate_across_versions
174
+
175
+ results = validate_across_versions("part { attribute mass; }", versions=["0.2.0"])
176
+
177
+ # One CommandResult per version, in the order given.
178
+ for r in results:
179
+ print(r.version, "->", "valid" if r.ok else "invalid")
180
+ ```
181
+
165
182
  ### CommandResult
166
183
 
167
184
  Returned objects include:
@@ -186,7 +203,14 @@ The Java validator defines:
186
203
  | 2 | Invalid SysML |
187
204
  | 3 | Runtime error |
188
205
 
189
- Python preserves these codes exactly.
206
+ Python preserves these codes exactly. Any process exit code **outside** (0, 2, 3) —
207
+ for example the JVM's own startup failure code (1) when an old Java hits a
208
+ Java-21-compiled JAR — is normalized to **3** (runtime error), so the contract never
209
+ leaks a foreign code. The underlying JVM detail is kept on `stderr` (e.g.
210
+ `UnsupportedClassVersionError ... class file version 65.0 ... only recognizes ... 62.0`).
211
+
212
+ In the Python API, `CommandResult.exit_code` is always one of 0/2/3; `is_invalid_syntax`
213
+ is `exit_code == 2` and `is_runtime_error` is `exit_code not in (0, 2)` (i.e. 3).
190
214
 
191
215
  ---
192
216
 
@@ -197,6 +221,13 @@ Environment variables:
197
221
  - `WINDTRADER_CACHE_DIR` – override JAR cache directory
198
222
  - `WINDTRADER_JAVA_REPO` – override GitHub repo for JAR downloads
199
223
  - `WINDTRADER_JAVA_STABLE_ASSET` – explicit asset URL (optional)
224
+ - `WINDTRADER_JAVA` – explicit path to a `java` executable (or a JVM launcher) to use
225
+ instead of `java` on PATH. Handy on machines whose default Java is too old for the
226
+ target JAR — set it to a Java 21+ executable.
227
+ - `JAVA_HOME` – if set (and `WINDTRADER_JAVA` is not), the wrapper uses
228
+ `<JAVA_HOME>/bin/java` as the Java executable.
229
+
230
+ Java executable resolution order: `WINDTRADER_JAVA` → `JAVA_HOME/bin/java` → `java` on PATH.
200
231
 
201
232
  ---
202
233
 
@@ -1,6 +1,7 @@
1
- # windtrader
1
+ # sysml-windtrader
2
2
 
3
- **windtrader** is a lightweight Python wrapper around the `windtrader-java` SysML v2 validator.
3
+ **sysml-windtrader** (`windtrader` on import) is a lightweight Python wrapper around the
4
+ `windtrader-java` SysML v2 validator.
4
5
  It provides a simple, scriptable way to validate SysML v2 textual syntax from Python while
5
6
  delegating all grammar authority to the official Java-based validator.
6
7
 
@@ -41,7 +42,11 @@ Or for development:
41
42
  pip install -e ".[dev]"
42
43
  ```
43
44
 
44
- Java **must** be available on your system (CI uses Temurin JDK).
45
+ **Java requirement:** the default `windtrader-java` release (0.2.x) is compiled for
46
+ **Java 21** (class file version 65). Java **21 or newer** must be available on your
47
+ system (CI uses Temurin JDK 21). If your default `java` is older, point the wrapper at a
48
+ Java 21+ JVM via `WINDTRADER_JAVA` (explicit executable path) or `JAVA_HOME` — see
49
+ [Configuration](#configuration).
45
50
 
46
51
  ---
47
52
 
@@ -124,6 +129,18 @@ else:
124
129
  print("Failed:", res.stderr)
125
130
  ```
126
131
 
132
+ ### Validate across versions
133
+
134
+ ```python
135
+ from windtrader import validate_across_versions
136
+
137
+ results = validate_across_versions("part { attribute mass; }", versions=["0.2.0"])
138
+
139
+ # One CommandResult per version, in the order given.
140
+ for r in results:
141
+ print(r.version, "->", "valid" if r.ok else "invalid")
142
+ ```
143
+
127
144
  ### CommandResult
128
145
 
129
146
  Returned objects include:
@@ -148,7 +165,14 @@ The Java validator defines:
148
165
  | 2 | Invalid SysML |
149
166
  | 3 | Runtime error |
150
167
 
151
- Python preserves these codes exactly.
168
+ Python preserves these codes exactly. Any process exit code **outside** (0, 2, 3) —
169
+ for example the JVM's own startup failure code (1) when an old Java hits a
170
+ Java-21-compiled JAR — is normalized to **3** (runtime error), so the contract never
171
+ leaks a foreign code. The underlying JVM detail is kept on `stderr` (e.g.
172
+ `UnsupportedClassVersionError ... class file version 65.0 ... only recognizes ... 62.0`).
173
+
174
+ In the Python API, `CommandResult.exit_code` is always one of 0/2/3; `is_invalid_syntax`
175
+ is `exit_code == 2` and `is_runtime_error` is `exit_code not in (0, 2)` (i.e. 3).
152
176
 
153
177
  ---
154
178
 
@@ -159,6 +183,13 @@ Environment variables:
159
183
  - `WINDTRADER_CACHE_DIR` – override JAR cache directory
160
184
  - `WINDTRADER_JAVA_REPO` – override GitHub repo for JAR downloads
161
185
  - `WINDTRADER_JAVA_STABLE_ASSET` – explicit asset URL (optional)
186
+ - `WINDTRADER_JAVA` – explicit path to a `java` executable (or a JVM launcher) to use
187
+ instead of `java` on PATH. Handy on machines whose default Java is too old for the
188
+ target JAR — set it to a Java 21+ executable.
189
+ - `JAVA_HOME` – if set (and `WINDTRADER_JAVA` is not), the wrapper uses
190
+ `<JAVA_HOME>/bin/java` as the Java executable.
191
+
192
+ Java executable resolution order: `WINDTRADER_JAVA` → `JAVA_HOME/bin/java` → `java` on PATH.
162
193
 
163
194
  ---
164
195
 
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sysml-windtrader"
7
- version = "0.2.2"
8
- description = "Python wrapper around windtrader-java for SysML v2 parse-only validation."
7
+ version = "0.2.4"
8
+ description = "Python wrapper around windtrader-java for SysML v2 parse-only validation (requires Java 21+)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { file = "LICENSE" }
@@ -22,12 +22,15 @@ Exit codes (as defined by windtrader-java)
22
22
  - 0: Valid SysML (parse succeeded)
23
23
  - 2: Invalid SysML (syntax/parse error)
24
24
  - 3: Runtime error (tool failure)
25
- - Any other non-zero: Unexpected failure
25
+ - Any other non-zero: mapped to 3 (runtime error)
26
26
 
27
27
  Notes
28
28
  -----
29
29
  - `--version` prints the Python package version (not the Java backend version).
30
30
  - Use `--java-version` to select which `windtrader-java` release asset to download/use.
31
+ - The Java executable is resolved via `WINDTRADER_JAVA` (explicit path) →
32
+ `JAVA_HOME/bin/java` → `java` on PATH. Point `WINDTRADER_JAVA` at a Java 21+ JVM
33
+ if your default `java` is too old to load the default `windtrader-java` JAR.
31
34
  - `--timeout` defaults per subcommand: 10s for `check`/`echo`, 120s for `export`
32
35
  (export loads the standard library and runs resolve/transform).
33
36
  - An unknown subcommand is rejected by argparse with exit 2, the same code used
@@ -17,14 +17,39 @@ The Python API mirrors these exactly: `validate`/`check`, `echo`, `export`.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import os
20
21
  import subprocess
21
22
  import time
22
23
  from collections.abc import Sequence
23
24
  from dataclasses import dataclass
25
+ from pathlib import Path
24
26
 
25
27
  from ._jars import DEFAULT_VERSION, get_jar_path
26
28
 
27
29
 
30
+ def _resolve_java() -> list[str]:
31
+ """
32
+ Return the argv prefix for the Java executable (allows an override).
33
+
34
+ Resolution order:
35
+ 1. ``WINDTRADER_JAVA`` env var — explicit path to a ``java`` (or JVM) executable.
36
+ 2. ``JAVA_HOME`` env var — ``<JAVA_HOME>/bin/java``.
37
+ 3. ``java`` on PATH (default).
38
+
39
+ This lets users on a machine whose default ``java`` is too old point the wrapper
40
+ at a newer JVM without modifying PATH.
41
+ """
42
+ explicit = os.environ.get("WINDTRADER_JAVA", "").strip()
43
+ if explicit:
44
+ return [explicit]
45
+
46
+ java_home = os.environ.get("JAVA_HOME", "").strip()
47
+ if java_home:
48
+ return [str(Path(java_home) / "bin" / "java")]
49
+
50
+ return ["java"]
51
+
52
+
28
53
  @dataclass(frozen=True)
29
54
  class CommandResult:
30
55
  """
@@ -37,7 +62,12 @@ class CommandResult:
37
62
  version:
38
63
  windtrader-java version string used to resolve/download the jar.
39
64
  exit_code:
40
- Process exit code returned by the jar.
65
+ Process exit code, normalized to the documented contract (0 / 2 / 3).
66
+ Any exit code outside (0, 2, 3) is mapped to 3 (runtime error), because a
67
+ JVM-level startup failure (e.g. an old JRE hitting a Java-21 jar ->
68
+ UnsupportedClassVersionError) returns a foreign code like 1 that would
69
+ otherwise break the documented 0/2/3 contract. The underlying detail is
70
+ preserved in `stderr`.
41
71
  stdout:
42
72
  Captured standard output from the jar.
43
73
  stderr:
@@ -56,6 +86,14 @@ class CommandResult:
56
86
  jar_path: str
57
87
  duration_s: float
58
88
 
89
+ def __post_init__(self) -> None:
90
+ # Normalize any process exit code outside the documented 0/2/3 contract to
91
+ # 3 (runtime error). See the `exit_code` docstring for why. Recompute `ok`
92
+ # from the normalized code so the two can never disagree.
93
+ if self.exit_code not in (0, 2, 3):
94
+ object.__setattr__(self, "exit_code", 3)
95
+ object.__setattr__(self, "ok", self.exit_code == 0)
96
+
59
97
  @property
60
98
  def is_invalid_syntax(self) -> bool:
61
99
  """
@@ -103,7 +141,7 @@ class WindtraderClient:
103
141
 
104
142
  t0 = time.time()
105
143
  p = subprocess.run(
106
- ["java", "-jar", str(jar), subcommand],
144
+ [*_resolve_java(), "-jar", str(jar), subcommand],
107
145
  input=text,
108
146
  text=True,
109
147
  capture_output=True,
@@ -174,13 +174,17 @@ def test_invalid_timeout_flag_exits_with_usage_error(fake_validate, stdin_text):
174
174
  # ---------------------------------------------------------------------------
175
175
 
176
176
 
177
- @pytest.mark.parametrize("exit_code", [0, 2, 3, 1, 42])
178
- def test_forwards_jar_exit_code(fake_validate, stdin_text, exit_code):
179
- """`main()` returns exactly the exit code reported by the jar."""
177
+ @pytest.mark.parametrize("exit_code,expected", [(0, 0), (2, 2), (3, 3), (1, 3), (42, 3)])
178
+ def test_forwards_jar_exit_code(fake_validate, stdin_text, exit_code, expected):
179
+ """`main()` returns the jar's exit code, normalized to the 0/2/3 contract.
180
+
181
+ Codes inside the contract (0/2/3) pass through exactly; any foreign code (e.g. 1
182
+ from a JVM startup failure) is mapped to 3 (runtime error).
183
+ """
180
184
  stdin_text("part def P;")
181
185
  fake_validate.result = _result(exit_code)
182
186
 
183
- assert cli.main([]) == exit_code
187
+ assert cli.main([]) == expected
184
188
 
185
189
 
186
190
  def test_exit_code_is_an_int(fake_validate, stdin_text):
@@ -110,13 +110,25 @@ def test_parse_error_result_flags():
110
110
  assert res.is_runtime_error is False
111
111
 
112
112
 
113
- @pytest.mark.parametrize("exit_code", [1, 3, 127, -9])
114
- def test_runtime_error_result_flags(exit_code):
115
- """Any exit code outside (0, 2) is surfaced as a runtime/tool error."""
113
+ @pytest.mark.parametrize("exit_code", [1, 127, -9, 42, 137])
114
+ def test_runtime_error_result_flags_normalized(exit_code):
115
+ """Any exit code outside (0, 2, 3) surfaces as is_runtime_error AND is normalized to 3."""
116
116
  res = _result(exit_code)
117
117
  assert res.ok is False
118
118
  assert res.is_invalid_syntax is False
119
119
  assert res.is_runtime_error is True
120
+ # Normalization keeps the documented 0/2/3 contract (regression: a JVM startup
121
+ # failure like an old JRE hitting a Java-21 jar leaks code 1).
122
+ assert res.exit_code == 3
123
+
124
+
125
+ def test_exit_code_3_is_runtime_error_not_normalized():
126
+ """3 is already inside the contract and stays 3."""
127
+ res = _result(3)
128
+ assert res.exit_code == 3
129
+ assert res.ok is False
130
+ assert res.is_runtime_error is True
131
+ assert res.is_invalid_syntax is False
120
132
 
121
133
 
122
134
  def test_validation_result_is_frozen():
@@ -132,12 +144,58 @@ def test_validation_result_is_frozen():
132
144
 
133
145
 
134
146
  def test_validate_text_builds_exact_check_argv(fake_run):
135
- """`check` is invoked as exactly [\"java\", \"-jar\", <jar>, \"check\"]."""
147
+ """`check` is invoked as exactly [\"java\", \"-jar\", <jar>, \"check\"] by default."""
136
148
  WindtraderClient().validate_text("part { attribute mass; }")
137
149
 
138
150
  assert fake_run.argv == ["java", "-jar", str(FAKE_JAR), "check"]
139
151
 
140
152
 
153
+ def test_java_executable_resolution_defaults_to_java(fake_run, monkeypatch):
154
+ """With no WINDTRADER_JAVA / JAVA_HOME, the argv prefix is plain `java`."""
155
+ monkeypatch.delenv("WINDTRADER_JAVA", raising=False)
156
+ monkeypatch.delenv("JAVA_HOME", raising=False)
157
+
158
+ WindtraderClient().validate_text("part def P;")
159
+ assert fake_run.argv[0] == "java"
160
+
161
+
162
+ def test_java_executable_resolution_uses_java_home(fake_run, monkeypatch):
163
+ """`JAVA_HOME` is used as `<JAVA_HOME>/bin/java` when WINDTRADER_JAVA is absent."""
164
+ monkeypatch.delenv("WINDTRADER_JAVA", raising=False)
165
+ monkeypatch.setenv("JAVA_HOME", "/opt/jdk21")
166
+
167
+ WindtraderClient().validate_text("part def P;")
168
+ assert fake_run.argv[0] == "/opt/jdk21/bin/java"
169
+
170
+
171
+ def test_java_executable_resolution_prefers_windtrader_java(fake_run, monkeypatch):
172
+ """`WINDTRADER_JAVA` (explicit path) wins over JAVA_HOME."""
173
+ monkeypatch.setenv("WINDTRADER_JAVA", "/custom/bin/java21")
174
+ monkeypatch.setenv("JAVA_HOME", "/opt/jdk-not-used")
175
+
176
+ WindtraderClient().validate_text("part def P;")
177
+ assert fake_run.argv[0] == "/custom/bin/java21"
178
+
179
+
180
+ def test_jvm_startup_failure_normalized_and_stderr_preserved(fake_run):
181
+ """A JVM startup failure (rc=1, e.g. old Java vs Java-21 jar) -> 3, stderr kept."""
182
+ fake_run.returncode = 1
183
+ fake_run.stdout = ""
184
+ fake_run.stderr = (
185
+ "Error: LinkageError occurred while loading main class "
186
+ "io.westfall.windtrader.RoundTrip\n\tjava.lang.UnsupportedClassVersionError: "
187
+ "class file version 65.0 ... only recognizes class file versions up to 62.0"
188
+ )
189
+
190
+ res = WindtraderClient().validate_text("part { attribute mass; }")
191
+
192
+ assert res.exit_code == 3
193
+ assert res.is_runtime_error is True
194
+ assert res.is_invalid_syntax is False
195
+ assert res.ok is False
196
+ assert "UnsupportedClassVersionError" in res.stderr
197
+
198
+
141
199
  def test_echo_builds_exact_echo_argv(fake_run):
142
200
  """`echo` is invoked as exactly [\"java\", \"-jar\", <jar>, \"echo\"]."""
143
201
  WindtraderClient().echo("part { attribute mass; }")