sysml-windtrader 0.2.3__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.3 → sysml_windtrader-0.2.4}/CHANGELOG.md +21 -0
  2. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/PKG-INFO +22 -4
  3. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/README.md +20 -2
  4. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/pyproject.toml +2 -2
  5. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/src/windtrader/cli.py +4 -1
  6. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/src/windtrader/client.py +40 -2
  7. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/tests/test_cli.py +8 -4
  8. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/tests/test_client.py +62 -4
  9. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/.github/workflows/ci.yml +0 -0
  10. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/.github/workflows/docstring-badge.yml +0 -0
  11. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/.github/workflows/publish.yml +0 -0
  12. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/.github/workflows/release.yml +0 -0
  13. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/.gitignore +0 -0
  14. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/.pre-commit-config.yaml +0 -0
  15. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/LICENSE +0 -0
  16. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/src/windtrader/__init__.py +0 -0
  17. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/src/windtrader/_jars.py +0 -0
  18. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/tests/test_integration_jar.py +0 -0
  19. {sysml_windtrader-0.2.3 → sysml_windtrader-0.2.4}/tests/test_jars.py +0 -0
@@ -2,6 +2,27 @@
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
+
5
26
  ## v0.2.3 (2026-10-17)
6
27
 
7
28
  ### 📝 Documentation
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sysml-windtrader
3
- Version: 0.2.3
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
 
@@ -80,7 +80,11 @@ Or for development:
80
80
  pip install -e ".[dev]"
81
81
  ```
82
82
 
83
- 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).
84
88
 
85
89
  ---
86
90
 
@@ -199,7 +203,14 @@ The Java validator defines:
199
203
  | 2 | Invalid SysML |
200
204
  | 3 | Runtime error |
201
205
 
202
- 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).
203
214
 
204
215
  ---
205
216
 
@@ -210,6 +221,13 @@ Environment variables:
210
221
  - `WINDTRADER_CACHE_DIR` – override JAR cache directory
211
222
  - `WINDTRADER_JAVA_REPO` – override GitHub repo for JAR downloads
212
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.
213
231
 
214
232
  ---
215
233
 
@@ -42,7 +42,11 @@ Or for development:
42
42
  pip install -e ".[dev]"
43
43
  ```
44
44
 
45
- 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).
46
50
 
47
51
  ---
48
52
 
@@ -161,7 +165,14 @@ The Java validator defines:
161
165
  | 2 | Invalid SysML |
162
166
  | 3 | Runtime error |
163
167
 
164
- 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).
165
176
 
166
177
  ---
167
178
 
@@ -172,6 +183,13 @@ Environment variables:
172
183
  - `WINDTRADER_CACHE_DIR` – override JAR cache directory
173
184
  - `WINDTRADER_JAVA_REPO` – override GitHub repo for JAR downloads
174
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.
175
193
 
176
194
  ---
177
195
 
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sysml-windtrader"
7
- version = "0.2.3"
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; }")