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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ reconstructed from the git log.

## [Unreleased]

### Added

- `OpenRocketInstance.profile_exact`: `True` when a profile for the jar's
exact version is checked in, `False` when orlab fell back to the nearest
older profile because it does not know that release. The fallback was
previously reported only through a log warning, which an application that
raises its log level silences entirely — leaving nothing on the object to
branch on. Comparing `or_version` to `profile.version_string` is not a
substitute: `24.12.RC.01` is an exact match whose strings differ, so that
reconstruction reports a fallback that is not one (#61).
- `SimulationPool.profile_exact`: the same verdict on the pool, which builds
an instance to validate the jar and previously discarded it. A pool running
on stale enums is the case where it matters most (#61).

## [0.9.0] — 2026-07-17

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ started, API reference, and maintainer procedures.
| 23.09 | CI-tested (JDK 17, 21) | |
| 22.02 | CI-tested (JDK 17, 21) | |
| 15.03 | CI-tested (JDK 17, 21) | |
| newer releases | forward fallback | run day-one on the nearest older profile, with a warning; full support is one profile-regeneration PR |
| newer releases | forward fallback | run day-one on the nearest older profile (`profile_exact` is `False`); full support is one profile-regeneration PR |

Every version in the table runs real simulations in CI (no display server
needed on any of them) on every pull request and push to main, and a monthly
Expand Down
14 changes: 13 additions & 1 deletion docs/guides/multi-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,19 @@ a crash. On 24.12, expect `SIM_WARN` events for simulation warnings and
## Newer releases than orlab knows

A jar newer than the newest profile runs immediately on the **nearest older
profile**, with a warning at instantiation. At startup, orlab compares the
profile**. `OpenRocketInstance.profile_exact` is `False` in that case, and
`instance.profile` names the profile actually in use:

```python
instance = OpenRocketInstance(jar_path="OpenRocket-99.99.jar")
if not instance.profile_exact:
print(f"running {instance.or_version} on {instance.profile.version_string} facts")
```

A warning is logged too, but branch on the flag rather than the log line — an
application that raises its log level silences the warning, and comparing
`or_version` to `profile.version_string` is **not** equivalent: `24.12.RC.01`
is an exact match whose strings differ. At startup, orlab also compares the
live jar's constants against the profile and logs any drift. Full support
for a new release is one PR — see the
[maintainer notes](../maintainers.md#supporting-a-new-openrocket-release).
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ with orlab.OpenRocketInstance(jar_path="OpenRocket-24.12.jar") as instance:
| 23.09 | CI-tested (JDK 17, 21) |
| 22.02 | CI-tested (JDK 17, 21) |
| 15.03 | CI-tested (JDK 17, 21) |
| newer releases | forward fallback: run day-one on the nearest older profile, with a warning |
| newer releases | forward fallback: run day-one on the nearest older profile; `profile_exact` says so |

orlab detects the jar's version before the JVM starts and adapts to it —
package roots, startup path, and available flight-data constants all come
Expand Down
7 changes: 4 additions & 3 deletions docs/maintainers.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ One PR:
`_pins.py` also rolls the CI jar caches).
4. `just test && just test-integration`.

Until that PR lands, the new release runs on the nearest older profile with
a warning (the monthly `canary.yml` run checks exactly this and opens an
issue on failure).
Until that PR lands, the new release runs on the nearest older profile
(the monthly `canary.yml` run exercises exactly this path against the newest
upstream release and opens an issue on failure). `OpenRocketInstance.profile_exact`
is `False` throughout — the canary does not assert the flag, it runs on it.

## Test tiers

Expand Down
13 changes: 11 additions & 2 deletions src/orlab/core/openrocket_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ class OpenRocketInstance:
blocks and notebook re-runs work); a different jar raises OrlabError.
OpenRocket's log level is process-global: the most recently entered
instance's log_level wins.

:ivar or_version: The jar's version string, from its ``build.properties``.
:ivar profile: The version profile in use — which may not be the jar's own.
:ivar profile_exact: ``True`` when a profile for this exact version is
checked in; ``False`` when orlab fell back to the nearest older profile
because it does not know this release, in which case newer constants
may be missing from orlab's enums. A warning is logged too, but a log
line is not something a caller can act on — branch on this flag if
correctness on unknown versions matters to you.
"""

# Deprecated: pass jvm_path to __init__ instead. Honored (with a warning)
Expand Down Expand Up @@ -163,14 +172,14 @@ def __init__(
try:
self.or_version = read_or_version(jar_path)
# UnsupportedOpenRocketVersion (too-old jar) passes through untouched
self.profile, exact = get_profile(self.or_version)
self.profile, self.profile_exact = get_profile(self.or_version)
except (zipfile.BadZipFile, KeyError, ValueError) as e:
# covers a corrupt zip, missing build.properties/build.version,
# and an unparseable version string
raise NotAnOpenRocketJar(
f"{os.path.abspath(jar_path)} is not an OpenRocket jar ({e})"
) from e
if not exact:
if not self.profile_exact:
logger.warning(
"No profile for OpenRocket %s; falling back to the nearest older "
"profile (%s). Newer constants may be missing from orlab's enums.",
Expand Down
8 changes: 6 additions & 2 deletions src/orlab/parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,12 @@ def __init__(
else os.path.abspath(_default_jar_path())
)
# validates the jar and fires the fallback-profile warning once,
# in the parent, instead of once per worker
OpenRocketInstance(jar, log_level=log_level, jvm_path=jvm_path, jvm_args=tuple(jvm_args))
# in the parent, instead of once per worker. The instance is discarded
# but its verdict is not: a pool running on stale enums is exactly the
# case a caller needs to branch on, and a log line is not branchable.
self.profile_exact = OpenRocketInstance(
jar, log_level=log_level, jvm_path=jvm_path, jvm_args=tuple(jvm_args)
).profile_exact
count = _count_simulations(ork)
if count is not None and not 0 <= simulation_index < max(count, 1):
raise IndexError(
Expand Down
17 changes: 17 additions & 0 deletions tests/test_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ def _pool(ork, jar, **kwargs):
# --- pure validation ---


def test_pool_exposes_whether_the_profile_is_exact(ork, tmp_path):
"""A pool running on stale enums is exactly what a caller must branch on.

The pool builds an OpenRocketInstance to validate the jar and fire the
fallback warning once in the parent. That instance's verdict is the
pool's verdict, and a log line is not something a caller can act on (#61).
"""
newer = tmp_path / "OpenRocket-26.xx-SNAPSHOT.jar"
with zipfile.ZipFile(newer, "w") as z:
z.writestr("build.properties", "build.version=26.xx-SNAPSHOT\n")
assert _pool(ork, newer).profile_exact is False


def test_pool_profile_exact_is_true_on_a_profiled_jar(ork, jar):
assert _pool(ork, jar).profile_exact is True


def test_heterogeneous_key_sets_rejected(ork, jar):
pool = _pool(ork, jar)
with pytest.raises(ValueError, match="share one key set"):
Expand Down
42 changes: 42 additions & 0 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,48 @@ def test_instance_warns_on_fallback_profile(tmp_path, caplog):
assert any("nearest older" in r.message for r in caplog.records)


@pytest.mark.parametrize(
("version", "expected_profile", "expected_exact"),
[
("24.12", (24, 12), True),
("24.12.RC.01", (24, 12), True),
("26.xx-SNAPSHOT", (24, 12), False),
],
)
def test_instance_exposes_whether_the_profile_is_exact(
tmp_path, version, expected_profile, expected_exact
):
"""The fallback is a 'could not tell': it must reach the caller as a value.

Logging it is not enough — an application that sets the root logger above
WARNING silences the only signal, and there is then nothing on the object
to branch on (#61).
"""
instance = OpenRocketInstance(jar_path=_fake_jar(tmp_path, version))
assert instance.profile.version == expected_profile
assert instance.profile_exact is expected_exact


def test_string_comparison_is_not_a_substitute_for_profile_exact(tmp_path):
"""An RC of a profiled version is an EXACT match whose strings differ.

This pins the trap the flag exists to close: reconstructing it as
``or_version != profile.version_string`` reports a fallback here, and is
wrong on every RC and point release.
"""
instance = OpenRocketInstance(jar_path=_fake_jar(tmp_path, "24.12.RC.01"))
assert instance.profile_exact is True
assert instance.or_version != instance.profile.version_string


def test_profile_exact_survives_a_silenced_logger(tmp_path, caplog):
"""The flag is readable even when the warning is suppressed entirely."""
with caplog.at_level(logging.CRITICAL, logger="orlab.core.openrocket_instance"):
instance = OpenRocketInstance(jar_path=_fake_jar(tmp_path, "26.xx-SNAPSHOT"))
assert not any("nearest older" in r.message for r in caplog.records)
assert instance.profile_exact is False


def test_instance_rejects_too_old_jar(tmp_path):
with pytest.raises(UnsupportedOpenRocketVersion, match="15.03"):
OpenRocketInstance(jar_path=_fake_jar(tmp_path, "14.11"))
Expand Down