Skip to content

Commit cbbc710

Browse files
committed
refactor(core): make release metadata lightweight
1 parent 046af77 commit cbbc710

7 files changed

Lines changed: 244 additions & 17 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Paper = "https://arxiv.org/abs/2605.08703"
3434

3535
[tool.setuptools.dynamic]
3636
dependencies = { file = ["requirements.txt"] }
37-
version = { attr = "rewardharness.__version__" }
37+
version = { attr = "rewardharness._version.__version__" }
3838

3939
[tool.setuptools.packages.find]
4040
include = ["rewardharness*", "src*"]

rewardharness/__init__.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
"""Public package for RewardHarness."""
1+
"""Public package for RewardHarness with lazy top-level exports."""
22

3-
from rewardharness.config import RewardHarnessConfig
4-
from rewardharness.domain import EvaluationExample, EvaluationResult, Preference, ScoreCard
5-
from rewardharness.evaluation.engine import SubAgent
6-
from rewardharness.evolution.pipeline import SelfEvolutionPipeline
7-
from rewardharness.library import Library, LibraryRepository
3+
from __future__ import annotations
4+
5+
from importlib import import_module
6+
from typing import TYPE_CHECKING, Any
7+
8+
from rewardharness._version import __version__
9+
10+
if TYPE_CHECKING:
11+
from rewardharness.config import RewardHarnessConfig
12+
from rewardharness.domain import EvaluationExample, EvaluationResult, Preference, ScoreCard
13+
from rewardharness.evaluation.engine import SubAgent
14+
from rewardharness.evolution.pipeline import SelfEvolutionPipeline
15+
from rewardharness.library import Library, LibraryRepository
816

917
__all__ = [
1018
"EvaluationExample",
@@ -16,6 +24,32 @@
1624
"ScoreCard",
1725
"SelfEvolutionPipeline",
1826
"SubAgent",
27+
"__version__",
1928
]
2029

21-
__version__ = "0.2.0rc1"
30+
_EXPORTS = {
31+
"EvaluationExample": ("rewardharness.domain", "EvaluationExample"),
32+
"EvaluationResult": ("rewardharness.domain", "EvaluationResult"),
33+
"Library": ("rewardharness.library", "Library"),
34+
"LibraryRepository": ("rewardharness.library", "LibraryRepository"),
35+
"Preference": ("rewardharness.domain", "Preference"),
36+
"RewardHarnessConfig": ("rewardharness.config", "RewardHarnessConfig"),
37+
"ScoreCard": ("rewardharness.domain", "ScoreCard"),
38+
"SelfEvolutionPipeline": ("rewardharness.evolution.pipeline", "SelfEvolutionPipeline"),
39+
"SubAgent": ("rewardharness.evaluation.engine", "SubAgent"),
40+
}
41+
42+
43+
def __getattr__(name: str) -> Any:
44+
"""Load public objects on first access to keep metadata imports lightweight."""
45+
target = _EXPORTS.get(name)
46+
if target is None:
47+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
48+
module_name, attribute = target
49+
value = getattr(import_module(module_name), attribute)
50+
globals()[name] = value
51+
return value
52+
53+
54+
def __dir__() -> list[str]:
55+
return sorted({*globals(), *__all__})

rewardharness/_version.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Single source of truth for the RewardHarness package version."""
2+
3+
__version__ = "0.2.0rc1"

rewardharness/cli.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,14 @@
88
import logging
99
from collections.abc import Sequence
1010
from pathlib import Path
11+
from typing import TYPE_CHECKING
1112

12-
from rewardharness import __version__
13-
from rewardharness.benchmark import run_benchmark
14-
from rewardharness.clients.endpoints import EndpointPool
15-
from rewardharness.config import RewardHarnessConfig
16-
from rewardharness.evaluation.engine import SUBAGENT_MODEL, SubAgent
17-
from rewardharness.evaluation.router import Router
18-
from rewardharness.evolution.pipeline import SelfEvolutionPipeline
19-
from rewardharness.library import Library
13+
from rewardharness._version import __version__
2014
from rewardharness.paths import PROJECT_ROOT, default_endpoints_path, default_library_path
15+
from rewardharness.release import ReleaseIdentity
16+
17+
if TYPE_CHECKING:
18+
from rewardharness.config import RewardHarnessConfig
2119

2220

2321
def _path(value: str) -> Path:
@@ -41,6 +39,7 @@ def build_parser() -> argparse.ArgumentParser:
4139
subparsers = parser.add_subparsers(dest="command", required=True)
4240

4341
subparsers.add_parser("check", help="validate local credentials and endpoints")
42+
subparsers.add_parser("release-status", help="show canonical package and release identifiers")
4443

4544
inspect_parser = subparsers.add_parser("inspect", help="inspect a Library registry")
4645
inspect_parser.add_argument("--library-dir", default=str(default_library_path()))
@@ -68,10 +67,14 @@ def build_parser() -> argparse.ArgumentParser:
6867

6968

7069
def _load_config(path: str) -> RewardHarnessConfig:
70+
from rewardharness.config import RewardHarnessConfig
71+
7172
return RewardHarnessConfig.from_yaml(_path(path))
7273

7374

7475
def _inspect(library_dir: str, validate: bool = False) -> int:
76+
from rewardharness.library import Library
77+
7578
library = Library(str(_path(library_dir)))
7679
if validate:
7780
for name in library.registry:
@@ -81,6 +84,8 @@ def _inspect(library_dir: str, validate: bool = False) -> int:
8184

8285

8386
def _evolve(args: argparse.Namespace) -> int:
87+
from rewardharness.evolution.pipeline import SelfEvolutionPipeline
88+
8489
config = _load_config(args.config)
8590
pipeline = SelfEvolutionPipeline(config, args.library_dir, args.results_dir)
8691
iterations = args.max_iters or config.evolution.max_iterations
@@ -92,12 +97,19 @@ def _evolve(args: argparse.Namespace) -> int:
9297

9398

9499
def _benchmark(args: argparse.Namespace) -> int:
100+
from rewardharness.benchmark import run_benchmark
101+
95102
config = _load_config(args.config)
96103
run_benchmark(config.to_legacy_dict(), args.library_dir, args.results_dir)
97104
return 0
98105

99106

100107
def _score_pair(args: argparse.Namespace) -> int:
108+
from rewardharness.clients.endpoints import EndpointPool
109+
from rewardharness.evaluation.engine import SUBAGENT_MODEL, SubAgent
110+
from rewardharness.evaluation.router import Router
111+
from rewardharness.library import Library
112+
101113
library = Library(str(_path(args.library_dir)))
102114
context = Router(library).prepare_context(args.prompt)
103115
pool = EndpointPool(endpoints_file=str(_path(args.endpoints)))
@@ -126,6 +138,9 @@ def main(argv: Sequence[str] | None = None) -> int:
126138
from rewardharness.diagnostics import main as diagnostics_main
127139

128140
return diagnostics_main()
141+
if args.command == "release-status":
142+
print(json.dumps(ReleaseIdentity.current().to_dict(), indent=2, sort_keys=True))
143+
return 0
129144
if args.command == "inspect":
130145
return _inspect(args.library_dir)
131146
if args.command == "validate-library":

rewardharness/release.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Release identity and tag validation utilities."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import re
8+
from collections.abc import Sequence
9+
from dataclasses import asdict, dataclass
10+
11+
from rewardharness._version import __version__
12+
13+
_PACKAGE_VERSION = re.compile(r"^(?P<base>\d+\.\d+\.\d+)(?:(?P<stage>a|b|rc)(?P<number>\d+))?$")
14+
_TAG_VERSION = re.compile(r"^v(?P<base>\d+\.\d+\.\d+)(?:-(?P<stage>a|b|rc)(?P<number>\d+))?$")
15+
16+
17+
def package_version_to_tag(version: str) -> str:
18+
"""Convert a PEP 440 package version to the repository tag convention."""
19+
match = _PACKAGE_VERSION.fullmatch(version)
20+
if match is None:
21+
raise ValueError(f"Unsupported package version: {version!r}")
22+
suffix = ""
23+
if match["stage"]:
24+
suffix = f"-{match['stage']}{match['number']}"
25+
return f"v{match['base']}{suffix}"
26+
27+
28+
def tag_to_package_version(tag: str) -> str:
29+
"""Convert a repository release tag to its PEP 440 package version."""
30+
match = _TAG_VERSION.fullmatch(tag)
31+
if match is None:
32+
raise ValueError(f"Unsupported release tag: {tag!r}")
33+
suffix = ""
34+
if match["stage"]:
35+
suffix = f"{match['stage']}{match['number']}"
36+
return f"{match['base']}{suffix}"
37+
38+
39+
@dataclass(frozen=True, slots=True)
40+
class ReleaseIdentity:
41+
"""Canonical identifiers shared by Git, GitHub Releases, and PyPI."""
42+
43+
package_version: str
44+
tag: str
45+
prerelease: bool
46+
pypi_url: str
47+
48+
@classmethod
49+
def current(cls) -> ReleaseIdentity:
50+
match = _PACKAGE_VERSION.fullmatch(__version__)
51+
if match is None:
52+
raise RuntimeError(f"Invalid package version: {__version__!r}")
53+
tag = package_version_to_tag(__version__)
54+
return cls(
55+
package_version=__version__,
56+
tag=tag,
57+
prerelease=bool(match["stage"]),
58+
pypi_url=f"https://pypi.org/project/rewardharness/{__version__}/",
59+
)
60+
61+
def validate_tag(self, tag: str) -> None:
62+
actual_version = tag_to_package_version(tag)
63+
if actual_version != self.package_version:
64+
raise ValueError(
65+
f"Tag {tag!r} resolves to {actual_version!r}; "
66+
f"package version is {self.package_version!r}"
67+
)
68+
69+
def to_dict(self) -> dict[str, str | bool]:
70+
return asdict(self)
71+
72+
73+
def main(argv: Sequence[str] | None = None) -> int:
74+
parser = argparse.ArgumentParser(description=__doc__)
75+
parser.add_argument("--check-tag", metavar="TAG")
76+
args = parser.parse_args(argv)
77+
identity = ReleaseIdentity.current()
78+
if args.check_tag:
79+
identity.validate_tag(args.check_tag)
80+
print(json.dumps(identity.to_dict(), indent=2, sort_keys=True))
81+
return 0
82+
83+
84+
if __name__ == "__main__":
85+
raise SystemExit(main())

tests/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Tests
22

3-
Fully mocked end-to-end test suite (**138 tests**, runs in a few seconds) — no GPU,
3+
Fully mocked end-to-end test suite (**154 tests**, runs in a few seconds) — no GPU,
44
no network, no API keys, and no real Gemini calls. Install development tooling
55
from `requirements-dev.txt` before running the complete quality gate.
66

@@ -23,6 +23,7 @@ make test
2323
| `test_pipeline.py` | `SelfEvolutionPipeline.evolve` over 2 iterations — separate skill/tool rollback, `>= prev - margin` keep condition, checkpoint write, val-acc regression triggers rollback. |
2424
| `test_check_env.py` | `scripts/check_env.py::_probe_one``/v1/models` body parsing for the VLM-swap mismatch detection added in iter 130. |
2525
| `test_public_api.py` | Typed v0.2 API, schema v2, compatibility imports, config validation, and path safety. |
26+
| `test_release.py` | Lazy package imports, canonical PyPI/tag identity, and release-status CLI contracts. |
2627

2728
Everything that touches an external service (Gemini, vLLM, Hugging Face) is mocked with `unittest.mock`. If a test ever makes a real network call, it's a regression — please open an issue.
2829

tests/test_release.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Tests for lightweight imports and canonical release identity."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import subprocess
7+
import sys
8+
9+
import pytest
10+
11+
import rewardharness
12+
import rewardharness.release as release_module
13+
from rewardharness.cli import main as cli_main
14+
from rewardharness.release import (
15+
ReleaseIdentity,
16+
package_version_to_tag,
17+
tag_to_package_version,
18+
)
19+
from rewardharness.release import main as release_main
20+
21+
22+
@pytest.mark.parametrize(
23+
("package_version", "tag"),
24+
[
25+
("1.2.3", "v1.2.3"),
26+
("1.2.3a1", "v1.2.3-a1"),
27+
("1.2.3b2", "v1.2.3-b2"),
28+
("1.2.3rc4", "v1.2.3-rc4"),
29+
],
30+
)
31+
def test_release_version_round_trip(package_version, tag):
32+
assert package_version_to_tag(package_version) == tag
33+
assert tag_to_package_version(tag) == package_version
34+
35+
36+
@pytest.mark.parametrize("value", ["1.2", "v1.2.3", "1.2.3-dev"])
37+
def test_package_version_rejects_unsupported_values(value):
38+
with pytest.raises(ValueError, match="Unsupported package version"):
39+
package_version_to_tag(value)
40+
41+
42+
@pytest.mark.parametrize("value", ["1.2.3", "v1.2", "v1.2.3-dev1"])
43+
def test_tag_rejects_unsupported_values(value):
44+
with pytest.raises(ValueError, match="Unsupported release tag"):
45+
tag_to_package_version(value)
46+
47+
48+
def test_current_release_identity_and_tag_validation():
49+
identity = ReleaseIdentity.current()
50+
assert identity.package_version == "0.2.0rc1"
51+
assert identity.tag == "v0.2.0-rc1"
52+
assert identity.prerelease is True
53+
identity.validate_tag(identity.tag)
54+
with pytest.raises(ValueError, match="package version"):
55+
identity.validate_tag("v0.2.0-rc2")
56+
57+
58+
def test_release_status_cli(capsys):
59+
assert cli_main(["release-status"]) == 0
60+
assert json.loads(capsys.readouterr().out)["tag"] == "v0.2.0-rc1"
61+
62+
63+
def test_release_module_cli_validates_tag(capsys):
64+
assert release_main(["--check-tag", "v0.2.0-rc1"]) == 0
65+
assert json.loads(capsys.readouterr().out)["package_version"] == "0.2.0rc1"
66+
67+
68+
def test_release_identity_rejects_invalid_canonical_version(monkeypatch):
69+
monkeypatch.setattr(release_module, "__version__", "invalid")
70+
with pytest.raises(RuntimeError, match="Invalid package version"):
71+
ReleaseIdentity.current()
72+
73+
74+
def test_top_level_exports_are_lazy_and_discoverable():
75+
assert rewardharness.Preference.A.value == "A"
76+
assert "Preference" in dir(rewardharness)
77+
missing_name = "not_a_public_export"
78+
with pytest.raises(AttributeError, match="has no attribute"):
79+
getattr(rewardharness, missing_name)
80+
81+
82+
def test_package_import_does_not_eagerly_load_heavy_dependencies():
83+
code = (
84+
"import sys, rewardharness; "
85+
"assert rewardharness.__version__ == '0.2.0rc1'; "
86+
"assert 'datasets' not in sys.modules; "
87+
"assert 'transformers' not in sys.modules"
88+
)
89+
subprocess.run([sys.executable, "-c", code], check=True)

0 commit comments

Comments
 (0)