1818import tempfile
1919import time
2020import logging
21+ import subprocess
22+ import sys
23+ import docker # type: ignore
2124from pathlib import Path
2225from http .server import BaseHTTPRequestHandler , HTTPServer
2326from threading import Thread
2831from pylegend ._typing import (
2932 PyLegendSequence ,
3033 PyLegendOptional ,
34+ PyLegendDict ,
35+ PyLegendUnion ,
36+ PyLegendAny ,
37+ PyLegendTuple ,
3138)
3239from pylegend .core .request .legend_client import LegendClient
3340from pylegend .utils .dynamic_port_generator import generate_dynamic_port
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
6168class 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
173228class _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
243334NORTHWIND_PROJECT_COORDINATES = VersionedProjectCoordinates (
0 commit comments