From f02e1f05f0f594944a3a670997b0998b70efa38f Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:44:23 +0200 Subject: [PATCH] fix(api): preserve legacy score create alias --- scripts/patch_generated_score_compat.py | 264 ++++++++++++++++++ .../unit/test_patch_generated_score_compat.py | 154 ++++++++++ 2 files changed, 418 insertions(+) create mode 100644 scripts/patch_generated_score_compat.py create mode 100644 tests/unit/test_patch_generated_score_compat.py diff --git a/scripts/patch_generated_score_compat.py b/scripts/patch_generated_score_compat.py new file mode 100644 index 000000000..1636cc424 --- /dev/null +++ b/scripts/patch_generated_score_compat.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Preserve the legacy score-create API after Fern moves it to ``scores``. + +Fern owns ``langfuse/api`` and regenerates it from the main Langfuse repository. +This postprocessor is intentionally narrow and fail-closed: it only patches the +known generated shape where ``scores.create`` exists and +``legacy.score_v1.create`` no longer does. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +CLIENT_IMPORTS = """\ +from ...commons.types.create_score_value import CreateScoreValue +from ...commons.types.score_data_type import ScoreDataType +from ...scores.client import ( + AsyncScoresClient as CanonicalAsyncScoresClient, + ScoresClient as CanonicalScoresClient, + OMIT, +) +from ...scores.types.create_score_response import CreateScoreResponse +from ...scores.types.create_score_source import CreateScoreSource +""" + +SYNC_CREATE_METHOD = '''\ + def create( + self, + *, + name: str, + value: CreateScoreValue, + id: typing.Optional[str] = OMIT, + trace_id: typing.Optional[str] = OMIT, + session_id: typing.Optional[str] = OMIT, + observation_id: typing.Optional[str] = OMIT, + dataset_run_id: typing.Optional[str] = OMIT, + comment: typing.Optional[str] = OMIT, + metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + environment: typing.Optional[str] = OMIT, + queue_id: typing.Optional[str] = OMIT, + data_type: typing.Optional[ScoreDataType] = OMIT, + config_id: typing.Optional[str] = OMIT, + source: typing.Optional[CreateScoreSource] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateScoreResponse: + """**Deprecated compatibility alias.** Use ``client.scores.create``.""" + return CanonicalScoresClient( + client_wrapper=self._raw_client._client_wrapper + ).create( + name=name, + value=value, + id=id, + trace_id=trace_id, + session_id=session_id, + observation_id=observation_id, + dataset_run_id=dataset_run_id, + comment=comment, + metadata=metadata, + environment=environment, + queue_id=queue_id, + data_type=data_type, + config_id=config_id, + source=source, + request_options=request_options, + ) + +''' + +ASYNC_CREATE_METHOD = '''\ + async def create( + self, + *, + name: str, + value: CreateScoreValue, + id: typing.Optional[str] = OMIT, + trace_id: typing.Optional[str] = OMIT, + session_id: typing.Optional[str] = OMIT, + observation_id: typing.Optional[str] = OMIT, + dataset_run_id: typing.Optional[str] = OMIT, + comment: typing.Optional[str] = OMIT, + metadata: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + environment: typing.Optional[str] = OMIT, + queue_id: typing.Optional[str] = OMIT, + data_type: typing.Optional[ScoreDataType] = OMIT, + config_id: typing.Optional[str] = OMIT, + source: typing.Optional[CreateScoreSource] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> CreateScoreResponse: + """**Deprecated compatibility alias.** Use ``client.scores.create``.""" + return await CanonicalAsyncScoresClient( + client_wrapper=self._raw_client._client_wrapper + ).create( + name=name, + value=value, + id=id, + trace_id=trace_id, + session_id=session_id, + observation_id=observation_id, + dataset_run_id=dataset_run_id, + comment=comment, + metadata=metadata, + environment=environment, + queue_id=queue_id, + data_type=data_type, + config_id=config_id, + source=source, + request_options=request_options, + ) + +''' + +TYPE_NAMES = ( + "CreateScoreRequest", + "CreateScoreResponse", + "CreateScoreSource", +) +LEGACY_ALIAS_MARKER = ( + "**Deprecated compatibility alias.** Use ``client.scores.create``." +) + + +def _replace_once(contents: str, marker: str, replacement: str, *, path: Path) -> str: + count = contents.count(marker) + if count != 1: + raise RuntimeError( + f"Expected exactly one compatibility marker in {path}: {marker!r}; " + f"found {count}" + ) + return contents.replace(marker, replacement, 1) + + +def _append_type_exports(path: Path, imports_by_type: dict[str, str]) -> None: + exports_marker = "# Score-create compatibility aliases (LFE-10397)." + contents = path.read_text() + if exports_marker in contents: + return + + imports = "\n".join( + f"from {module_name} import {type_name}" + for type_name, module_name in imports_by_type.items() + ) + names = ", ".join(repr(type_name) for type_name in TYPE_NAMES) + path.write_text( + f"{contents.rstrip()}\n\n{exports_marker}\n{imports}\n" + f"__all__ = [*__all__, {names}]\n" + ) + + +def _create_type_aliases(types_dir: Path) -> None: + types_dir.mkdir(parents=True, exist_ok=True) + for type_name in TYPE_NAMES: + module_name = "".join( + f"_{character.lower()}" if character.isupper() else character + for character in type_name + ).lstrip("_") + alias_path = types_dir / f"{module_name}.py" + alias_path.write_text( + "# This compatibility alias is applied after Fern generation.\n\n" + f"from ....scores.types.{module_name} import {type_name}\n\n" + f'__all__ = ["{type_name}"]\n' + ) + + +def patch_generated_api(api_root: Path) -> bool: + canonical_client_path = api_root / "scores" / "client.py" + legacy_client_path = api_root / "legacy" / "score_v1" / "client.py" + + canonical_client = canonical_client_path.read_text() + legacy_client = legacy_client_path.read_text() + + canonical_has_create = ( + "\n def create(" in canonical_client + and "\n async def create(" in canonical_client + ) + legacy_has_create = ( + "\n def create(" in legacy_client + and "\n async def create(" in legacy_client + ) + + if legacy_has_create: + if LEGACY_ALIAS_MARKER in legacy_client and canonical_has_create: + return False + if canonical_has_create: + raise RuntimeError( + "Both canonical and legacy generated score clients define create; " + "refusing to introduce a third compatibility surface" + ) + return False + + if not canonical_has_create: + raise RuntimeError( + "Neither generated score client defines both sync and async create methods" + ) + + legacy_client = _replace_once( + legacy_client, + "from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper\n", + "from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper\n" + f"{CLIENT_IMPORTS}", + path=legacy_client_path, + ) + legacy_client = _replace_once( + legacy_client, + "\n def delete(", + f"\n{SYNC_CREATE_METHOD} def delete(", + path=legacy_client_path, + ) + legacy_client = _replace_once( + legacy_client, + "\n async def delete(", + f"\n{ASYNC_CREATE_METHOD} async def delete(", + path=legacy_client_path, + ) + legacy_client_path.write_text(legacy_client) + + legacy_package_path = api_root / "legacy" / "score_v1" / "__init__.py" + legacy_parent_package_path = api_root / "legacy" / "__init__.py" + legacy_types_path = api_root / "legacy" / "score_v1" / "types" / "__init__.py" + _create_type_aliases(legacy_types_path.parent) + + if not legacy_types_path.exists(): + legacy_types_path.write_text( + "# This package is generated by Fern and extended for compatibility.\n\n" + "__all__: list[str] = []\n" + ) + + _append_type_exports( + legacy_types_path, + { + "CreateScoreRequest": ".create_score_request", + "CreateScoreResponse": ".create_score_response", + "CreateScoreSource": ".create_score_source", + }, + ) + _append_type_exports( + legacy_package_path, {type_name: ".types" for type_name in TYPE_NAMES} + ) + _append_type_exports( + legacy_parent_package_path, + {type_name: ".score_v1" for type_name in TYPE_NAMES}, + ) + return True + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--api-root", + type=Path, + default=Path("langfuse/api"), + help="Path to the freshly generated Fern Python package", + ) + args = parser.parse_args() + changed = patch_generated_api(args.api_root) + print( + "Patched legacy score create compatibility alias." + if changed + else "No patch needed." + ) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_patch_generated_score_compat.py b/tests/unit/test_patch_generated_score_compat.py new file mode 100644 index 000000000..5d154c80a --- /dev/null +++ b/tests/unit/test_patch_generated_score_compat.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import subprocess +import sys +import typing +from pathlib import Path +from runpy import run_path +from types import SimpleNamespace + + +def _write_generated_fixture(api_root: Path, *, canonical_has_create: bool) -> None: + scores_dir = api_root / "scores" + legacy_dir = api_root / "legacy" / "score_v1" + legacy_types_dir = legacy_dir / "types" + scores_dir.mkdir(parents=True) + legacy_types_dir.mkdir(parents=True) + + canonical_methods = ( + "\n def create(self):\n pass\n" + "\n async def create(self):\n pass\n" + if canonical_has_create + else "" + ) + canonical_body = canonical_methods or " pass\n" + (scores_dir / "client.py").write_text(f"class ScoresClient:{canonical_body}") + (legacy_dir / "client.py").write_text( + "import typing\n" + "from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper\n\n" + "class ScoreV1Client:\n" + " def delete(self):\n" + " pass\n\n" + "class AsyncScoreV1Client:\n" + " async def delete(self):\n" + " pass\n" + ) + (legacy_dir / "__init__.py").write_text("__all__ = []\n") + (legacy_dir.parent / "__init__.py").write_text("__all__ = []\n") + (legacy_types_dir / "__init__.py").write_text("__all__ = []\n") + + +def _run_postprocessor(api_root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "scripts/patch_generated_score_compat.py", + "--api-root", + str(api_root), + ], + check=False, + capture_output=True, + text=True, + ) + + +def test_postprocessor_adds_delegating_methods_and_type_aliases(tmp_path: Path) -> None: + api_root = tmp_path / "api" + _write_generated_fixture(api_root, canonical_has_create=True) + + result = _run_postprocessor(api_root) + + assert result.returncode == 0, result.stderr + client = (api_root / "legacy" / "score_v1" / "client.py").read_text() + assert "def create(" in client + assert "async def create(" in client + assert "CanonicalScoresClient(" in client + assert "CanonicalAsyncScoresClient(" in client + assert "client.scores.create" in client + compile(client, "", "exec") + + response_alias = ( + api_root / "legacy" / "score_v1" / "types" / "create_score_response.py" + ).read_text() + assert ( + "from ....scores.types.create_score_response import CreateScoreResponse" + in response_alias + ) + assert ( + "CreateScoreResponse" + in (api_root / "legacy" / "score_v1" / "__init__.py").read_text() + ) + assert "CreateScoreResponse" in (api_root / "legacy" / "__init__.py").read_text() + + second_result = _run_postprocessor(api_root) + assert second_result.returncode == 0, second_result.stderr + assert second_result.stdout.strip() == "No patch needed." + + +def test_postprocessor_fails_when_no_generated_create_exists(tmp_path: Path) -> None: + api_root = tmp_path / "api" + _write_generated_fixture(api_root, canonical_has_create=False) + + result = _run_postprocessor(api_root) + + assert result.returncode != 0 + assert "Neither generated score client defines" in result.stderr + + +def test_generated_sync_alias_delegates_to_canonical_client() -> None: + postprocessor = run_path("scripts/patch_generated_score_compat.py") + sync_create_method = postprocessor["SYNC_CREATE_METHOD"] + calls: dict[str, object] = {} + omitted = object() + + class FakeCanonicalScoresClient: + def __init__(self, *, client_wrapper: object): + calls["client_wrapper"] = client_wrapper + + def create(self, **kwargs: object) -> str: + calls["kwargs"] = kwargs + return "created" + + namespace = { + "CanonicalScoresClient": FakeCanonicalScoresClient, + "CreateScoreResponse": str, + "CreateScoreSource": object, + "CreateScoreValue": object, + "OMIT": omitted, + "RequestOptions": object, + "ScoreDataType": object, + "typing": typing, + } + exec( + "class LegacyScoreClient:\n" + " def __init__(self, client_wrapper):\n" + " self._raw_client = SimpleNamespace(" + "_client_wrapper=client_wrapper)\n" + f"{sync_create_method}", + {**namespace, "SimpleNamespace": SimpleNamespace}, + namespace, + ) + + client_wrapper = object() + legacy_client = namespace["LegacyScoreClient"](client_wrapper) + response = legacy_client.create(name="quality", value=0.9, trace_id="trace-id") + + assert response == "created" + assert calls["client_wrapper"] is client_wrapper + assert calls["kwargs"] == { + "name": "quality", + "value": 0.9, + "id": omitted, + "trace_id": "trace-id", + "session_id": omitted, + "observation_id": omitted, + "dataset_run_id": omitted, + "comment": omitted, + "metadata": omitted, + "environment": omitted, + "queue_id": omitted, + "data_type": omitted, + "config_id": omitted, + "source": omitted, + "request_options": None, + }