Skip to content

Commit 5e6e653

Browse files
committed
Use local legend env in tests
1 parent 87ca73a commit 5e6e653

File tree

8 files changed

+159
-447
lines changed

8 files changed

+159
-447
lines changed

.github/workflows/build-ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
needs: [ lint, typing_check ]
4444
strategy:
4545
matrix:
46-
os: [ ubuntu-latest, windows-latest ]
46+
os: [ ubuntu-latest, windows-latest, macos-latest ]
4747
python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ]
4848
runs-on: ${{ matrix.os }}
4949
steps:

pylegend/samples/local_legend_env.py

Lines changed: 140 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
import tempfile
1919
import time
2020
import logging
21+
import subprocess
22+
import sys
23+
import docker # type: ignore
2124
from pathlib import Path
2225
from http.server import BaseHTTPRequestHandler, HTTPServer
2326
from threading import Thread
@@ -28,6 +31,10 @@
2831
from pylegend._typing import (
2932
PyLegendSequence,
3033
PyLegendOptional,
34+
PyLegendDict,
35+
PyLegendUnion,
36+
PyLegendAny,
37+
PyLegendTuple,
3138
)
3239
from pylegend.core.request.legend_client import LegendClient
3340
from pylegend.utils.dynamic_port_generator import generate_dynamic_port
@@ -51,25 +58,30 @@
5158

5259
_DEFAULT_IMAGE = "eclipse-temurin:11-jdk"
5360

54-
_LEGEND_ENGINE_VERSION = "4.121.0"
61+
_LEGEND_ENGINE_VERSION = "4.112.0"
5562
_LEGEND_JAR_URL = (
5663
f"https://repo1.maven.org/maven2/org/finos/legend/engine/legend-engine-server-http-server/"
5764
f"{_LEGEND_ENGINE_VERSION}/legend-engine-server-http-server-{_LEGEND_ENGINE_VERSION}-shaded.jar"
5865
)
5966

6067

6168
class LocalLegendEnv:
62-
def __init__(
69+
def __init__( # type: ignore
6370
self,
6471
image: str = _DEFAULT_IMAGE,
6572
metadata_port: PyLegendOptional[int] = None,
73+
metadata_resources: PyLegendOptional[
74+
PyLegendDict[VersionedProjectCoordinates, PyLegendUnion[str, PyLegendDict[PyLegendAny, PyLegendAny]]]
75+
] = None,
6676
max_wait_seconds: int = 120,
6777
) -> None:
6878
self._image = image
6979
self._metadata_port = metadata_port or generate_dynamic_port()
80+
self._metadata_resources = metadata_resources
7081
self._max_wait_seconds = max_wait_seconds
7182

7283
self._engine_container: PyLegendOptional[DockerContainer] = None
84+
self._engine_process: PyLegendOptional[subprocess.Popen[bytes]] = None
7385
self._metadata_server: PyLegendOptional[HTTPServer] = None
7486
self._config_path: PyLegendOptional[str] = None
7587
self._engine_port: PyLegendOptional[int] = None
@@ -90,10 +102,24 @@ def legend_client(self) -> LegendClient:
90102
return self._legend_client
91103

92104
def start(self) -> "LocalLegendEnv":
93-
self._validate_resources()
105+
if self._engine_container is not None or self._engine_process is not None:
106+
return self
107+
atexit.register(self.stop)
108+
res = dict(self._metadata_resources) if self._metadata_resources is not None else {}
109+
nw = NORTHWIND_PROJECT_COORDINATES
110+
if not any(c.get_group_id() == nw.get_group_id() and
111+
c.get_artifact_id() == nw.get_artifact_id() and
112+
c.get_version() == nw.get_version() for c in res.keys()):
113+
northwind_file = (
114+
_METADATA_DIR / "org.finos.legend.pylegend_pylegend-northwind-models_0.0.1-SNAPSHOT.json"
115+
).resolve()
116+
with open(northwind_file, "r", encoding="utf-8") as f:
117+
res[NORTHWIND_PROJECT_COORDINATES] = f.read()
118+
self._metadata_resources = res
119+
94120
self._server_jar_path = _get_server_jar_path()
95121
self._start_metadata_server()
96-
self._start_engine_container()
122+
self._start_engine()
97123
self._wait_for_engine()
98124
self._legend_client = LegendClient(
99125
"127.0.0.1", self._engine_port, secure_http=False # type: ignore[arg-type]
@@ -103,6 +129,9 @@ def start(self) -> "LocalLegendEnv":
103129
def stop(self) -> None:
104130
if self._engine_container:
105131
self._engine_container.stop()
132+
if self._engine_process:
133+
self._engine_process.terminate()
134+
self._engine_process.wait()
106135
if self._metadata_server:
107136
self._metadata_server.shutdown()
108137
if self._config_path:
@@ -111,22 +140,21 @@ def stop(self) -> None:
111140
os.rmdir(os.path.dirname(self._config_path))
112141
except OSError:
113142
pass
114-
self._engine_container = self._metadata_server = self._config_path = None
143+
self._engine_container = self._engine_process = self._metadata_server = self._config_path = None
115144
self._engine_port = self._testdb_port = self._legend_client = None
116145

117-
def _validate_resources(self) -> None:
118-
meta = _METADATA_DIR
119-
if not meta.exists():
120-
raise FileNotFoundError(
121-
f"Metadata directory not found at {meta}. "
122-
"Make sure you are running from a pylegend repository checkout."
123-
)
124-
125146
def _start_metadata_server(self) -> None:
147+
metadata_map: PyLegendDict[str, str] = {}
148+
if self._metadata_resources:
149+
for coords, content in self._metadata_resources.items():
150+
if isinstance(content, dict):
151+
content = json.dumps(content)
152+
metadata_map[_get_metadata_path(coords)] = content
153+
126154
handler_class = type(
127155
"_Handler",
128156
(_MetadataServerHandler,),
129-
{"metadata_dir": str(_METADATA_DIR.resolve())},
157+
{"metadata_map": metadata_map},
130158
)
131159
self._metadata_server = HTTPServer(
132160
("127.0.0.1", self._metadata_port), handler_class
@@ -135,28 +163,55 @@ def _start_metadata_server(self) -> None:
135163
t.start()
136164
LOGGER.info("Metadata server listening on port %d", self._metadata_port)
137165

138-
def _start_engine_container(self) -> None:
166+
def _start_engine(self) -> None:
139167
if self._server_jar_path is None:
140168
raise RuntimeError("Server JAR path is not set")
141169

142170
self._engine_port = generate_dynamic_port()
143171
self._testdb_port = generate_dynamic_port()
144172
self._config_path = _write_server_config(self._engine_port, "127.0.0.1", self._metadata_port, self._testdb_port)
145173

146-
self._engine_container = (
147-
DockerContainer(self._image)
148-
.with_volume_mapping(str(self._server_jar_path.resolve()), "/legend/server.jar", "ro")
149-
.with_volume_mapping(self._config_path, "/legend/config.json", "ro")
150-
.with_env("JAVA_TOOL_OPTIONS", "-Duser.timezone=UTC -Dfile.encoding=UTF-8")
151-
.with_command("sh -c 'java -jar /legend/server.jar server /legend/config.json'")
152-
)
153-
154-
self._engine_container.with_kwargs(network_mode="host")
155-
156-
LOGGER.info("Starting Legend engine container (%s) …", self._image)
157-
self._engine_container.start()
174+
has_docker = _is_docker_available()
175+
if has_docker:
176+
self._engine_container = (
177+
DockerContainer(self._image)
178+
.with_volume_mapping(str(self._server_jar_path.resolve()), "/legend/server.jar", "ro")
179+
.with_volume_mapping(self._config_path, "/legend/config.json", "ro")
180+
.with_env("JAVA_TOOL_OPTIONS", "-Duser.timezone=UTC -Dfile.encoding=UTF-8")
181+
.with_command("sh -c 'java -jar /legend/server.jar server /legend/config.json'")
182+
)
158183

159-
LOGGER.info("Engine container started; responding on host network port = %d", self._engine_port)
184+
self._engine_container.with_kwargs(network_mode="host")
185+
186+
LOGGER.info("Starting Legend engine container (%s) …", self._image)
187+
self._engine_container.start()
188+
LOGGER.info("Engine container started; responding on host network port = %d", self._engine_port)
189+
else:
190+
LOGGER.info("Docker is unavailable. Falling back to direct Java subprocess.")
191+
java_home = os.environ.get("JAVA_HOME")
192+
if not java_home:
193+
raise RuntimeError("JAVA_HOME environment variable is not set. "
194+
"It is required to run the local Legend engine without Docker.")
195+
java_cmd = os.path.join(java_home, "bin", "java")
196+
197+
cmd = [
198+
java_cmd,
199+
"-jar",
200+
"-Duser.timezone=UTC",
201+
"-Dfile.encoding=UTF-8",
202+
str(self._server_jar_path.resolve()),
203+
"server",
204+
self._config_path
205+
]
206+
207+
self._engine_process = subprocess.Popen(
208+
cmd,
209+
stdout=subprocess.PIPE,
210+
stderr=subprocess.PIPE,
211+
start_new_session=True,
212+
shell=False
213+
)
214+
LOGGER.info("Engine Java subprocess started; responding on port = %d", self._engine_port)
160215

161216
def _wait_for_engine(self) -> None:
162217
url = f"http://127.0.0.1:{self._engine_port}/api/server/v1/info"
@@ -171,26 +226,23 @@ def _wait_for_engine(self) -> None:
171226

172227

173228
class _MetadataServerHandler(BaseHTTPRequestHandler):
174-
metadata_dir: str = ""
229+
metadata_map: PyLegendDict[str, str] = {}
175230

176231
def do_GET(self) -> None:
177-
if (
178-
"/depot/api/projects/org.finos.legend.pylegend/pylegend-northwind-models/versions/"
179-
"0.0.1-SNAPSHOT/pureModelContextData?convertToNewProtocol=false&clientVersion=v1_33_0"
180-
) != self.path:
232+
content = self.metadata_map.get(self.path)
233+
234+
if not content:
181235
return self.send_error(404, f"Unhandled metadata path: {self.path}")
182236

183-
file_path = os.path.join(self.metadata_dir, "org.finos.legend.pylegend_pylegend-northwind-models_0.0.1-SNAPSHOT.json")
184237
try:
185-
with open(file_path, "rb") as f:
186-
content = f.read()
238+
payload = content.encode("utf-8")
187239
self.send_response(200)
188240
self.send_header("Content-Type", "application/json; charset=utf-8")
189-
self.send_header("Content-Length", str(len(content)))
241+
self.send_header("Content-Length", str(len(payload)))
190242
self.end_headers()
191-
self.wfile.write(content)
192-
except OSError:
193-
self.send_error(404, f"Metadata file not found: {file_path}")
243+
self.wfile.write(payload)
244+
except Exception as e:
245+
self.send_error(500, f"Error processing metadata: {e}")
194246

195247
def log_message(self, format: str, *args: object) -> None: pass
196248

@@ -229,15 +281,54 @@ def _get_server_jar_path() -> Path:
229281
return jar
230282

231283

232-
_singleton: PyLegendOptional[LocalLegendEnv] = None
233-
234-
235-
def get_local_legend_env(**kwargs: object) -> LocalLegendEnv:
236-
global _singleton
237-
if _singleton is None:
238-
_singleton = LocalLegendEnv(**kwargs).start() # type: ignore[arg-type]
239-
atexit.register(_singleton.stop)
240-
return _singleton
284+
def _get_metadata_path(coords: VersionedProjectCoordinates) -> str:
285+
return (
286+
f"/depot/api/projects/{coords.get_group_id()}/{coords.get_artifact_id()}/versions/"
287+
f"{coords.get_version()}/pureModelContextData?convertToNewProtocol=false&clientVersion=v1_33_0"
288+
)
289+
290+
291+
def _is_docker_available() -> bool:
292+
if sys.platform != "linux":
293+
return False
294+
295+
try:
296+
client = docker.from_env()
297+
client.ping()
298+
return True
299+
except Exception:
300+
return False
301+
302+
303+
_envs: PyLegendDict[PyLegendTuple[PyLegendAny, ...], LocalLegendEnv] = {} # type: ignore
304+
305+
306+
def get_local_legend_env( # type: ignore
307+
metadata_resources: PyLegendOptional[
308+
PyLegendDict[VersionedProjectCoordinates, PyLegendUnion[str, PyLegendDict[PyLegendAny, PyLegendAny]]]
309+
] = None
310+
) -> LocalLegendEnv:
311+
res_keys = list(metadata_resources.keys()) if metadata_resources else []
312+
nw = NORTHWIND_PROJECT_COORDINATES
313+
if not any(c.get_group_id() == nw.get_group_id() and
314+
c.get_artifact_id() == nw.get_artifact_id() and
315+
c.get_version() == nw.get_version() for c in res_keys):
316+
res_keys.append(nw)
317+
318+
key = tuple(sorted(
319+
json.dumps({
320+
"groupId": c.get_group_id(),
321+
"artifactId": c.get_artifact_id(),
322+
"version": c.get_version()
323+
}, sort_keys=True)
324+
for c in res_keys
325+
))
326+
327+
if key not in _envs:
328+
_envs[key] = LocalLegendEnv(
329+
metadata_resources=metadata_resources
330+
).start()
331+
return _envs[key]
241332

242333

243334
NORTHWIND_PROJECT_COORDINATES = VersionedProjectCoordinates(

0 commit comments

Comments
 (0)