66import json
77import inspect
88import ast
9- from typing import Any , Dict , Optional , Union , Tuple
9+ from typing import Any , Dict , Optional , Union , Tuple , TypedDict
1010
1111import xmltodict
12+ import requests
1213from pydantic import TypeAdapter
1314
1415from . import _iris
1516from ._message import _Message , _PydanticMessage
1617
18+ class RemoteSettings (TypedDict , total = False ):
19+ """Typed dictionary for remote migration settings."""
20+ url : str # Required: the host url to connect to
21+ namespace : str # Optional: the namespace to use (default: 'USER')
22+ package : str # Optional: the package to use (default: 'python')
23+ remote_folder : str # Optional: the folder to use (default: '')
24+ username : str # Optional: the username to use to connect (default: '')
25+ password : str # Optional: the password to use to connect (default: '')
26+
1727class _Utils ():
1828 @staticmethod
1929 def raise_on_error (sc ):
@@ -250,6 +260,85 @@ def filename_to_module(filename) -> str:
250260
251261 return module
252262
263+ @staticmethod
264+ def migrate_remote (filename = None ):
265+ """
266+ Read a settings file from the filename
267+ If the settings.py file has a key 'REMOTE_SETTING' then it will use the value of that key
268+ as the remote host to connect to.
269+ the REMOTE_SETTING is a RemoteSettings dictionary with the following keys:
270+ * 'url': the host url to connect to (mandatory)
271+ * 'namespace': the namespace to use (optional, default is 'USER')
272+ * 'package': the package to use (optional, default is 'python')
273+ * 'remote_folder': the folder to use (optional, default is '')
274+ * 'username': the username to use to connect (optional, default is '')
275+ * 'password': the password to use to connect (optional, default is '')
276+
277+ The remote host is a rest API that will be used to register the components
278+ The payload will be a json object with the following keys:
279+ * 'namespace': the namespace to use
280+ * 'package': the package to use
281+ * 'body': the body of the request, it will be a json object with the following keys:
282+ * 'name': name of the file
283+ * 'data': the data of the file, it will be an UTF-8 encoded string
284+
285+ 'body' will be constructed with all the files in the folder if the folder is not empty else use root folder of settings.py
286+ """
287+ settings , path = _Utils ._load_settings (filename )
288+ remote_settings : Optional [RemoteSettings ] = getattr (settings , 'REMOTE_SETTING' , None ) if settings else None
289+
290+ if not remote_settings :
291+ _Utils .migrate (filename )
292+ return
293+
294+ # Validate required fields
295+ if 'url' not in remote_settings :
296+ raise ValueError ("REMOTE_SETTING must contain 'url' field" )
297+
298+ # prepare the payload with defaults
299+ payload = {
300+ 'namespace' : remote_settings .get ('namespace' , 'USER' ),
301+ 'package' : remote_settings .get ('package' , 'python' ),
302+ 'remote_folder' : remote_settings .get ('remote_folder' , '' ),
303+ 'body' : []
304+ }
305+
306+ # get the folder to register
307+ folder = _Utils ._get_folder_path (filename , path )
308+
309+ # iterate over all files in the folder
310+ for root , _ , files in os .walk (folder ):
311+ for file in files :
312+ if file .endswith ('.py' ) or file .endswith ('.cls' ):
313+ file_path = os .path .join (root , file )
314+ relative_path = os .path .relpath (file_path , folder )
315+ # Normalize path separators for cross-platform compatibility
316+ relative_path = relative_path .replace (os .sep , '/' )
317+ with open (file_path , 'r' , encoding = 'utf-8' ) as f :
318+ data = f .read ()
319+ payload ['body' ].append ({
320+ 'name' : relative_path ,
321+ 'data' : data
322+ })
323+
324+ # send the request to the remote settings
325+ response = requests .put (
326+ url = f"{ remote_settings ['url' ]} /api/iop/migrate" ,
327+ json = payload ,
328+ headers = {
329+ 'Content-Type' : 'application/json' ,
330+ 'Accept' : 'application/json'
331+ },
332+ auth = (remote_settings .get ('username' , '' ), remote_settings .get ('password' , '' )),
333+ timeout = 10
334+ )
335+
336+ # check the response status
337+ if response .status_code != 200 :
338+ raise RuntimeError (f"Failed to migrate: { response .status_code } - { response .text } " )
339+ else :
340+ print (f"Migration successful: { response .status_code } - { response .text } " )
341+
253342 @staticmethod
254343 def migrate (filename = None ):
255344 """
@@ -265,43 +354,96 @@ def migrate(filename=None):
265354 * SCHEMAS
266355 List of classes
267356 """
268- path = None
269- # try to load the settings file
357+ settings , path = _Utils ._load_settings (filename )
358+
359+ _Utils ._register_settings_components (settings , path )
360+
361+ _Utils ._cleanup_sys_path (path )
362+
363+ @staticmethod
364+ def _load_settings (filename ):
365+ """Load settings module from file or default location.
366+
367+ Returns:
368+ tuple: (settings_module, path_added_to_sys)
369+ """
370+ path_added = None
371+
270372 if filename :
271373 # check if the filename is absolute or relative
272- if os .path .isabs (filename ):
273- path = os .path .dirname (filename )
274- else :
374+ if not os .path .isabs (filename ):
275375 raise ValueError ("The filename must be absolute" )
376+
276377 # add the path to the system path to the beginning
277- sys .path .insert (0 ,os .path .normpath (path ))
378+ path_added = os .path .normpath (os .path .dirname (filename ))
379+ sys .path .insert (0 , path_added )
278380 # import settings from the specified file
279- settings = _Utils .import_module_from_path ('settings' ,filename )
381+ settings = _Utils .import_module_from_path ('settings' , filename )
280382 else :
281383 # import settings from the settings module
282- import settings # type: ignore
283- # get the path of the settings file
284- path = os .path .dirname (inspect .getfile (settings ))
384+ import settings # type: ignore
385+
386+ return settings , path_added
387+
388+ @staticmethod
389+ def _get_folder_path (filename , path_added_to_sys ):
390+ """Get the folder path for migration operations.
391+
392+ Args:
393+ filename: Original filename parameter
394+ path_added_to_sys: Path that was added to sys.path
395+
396+ Returns:
397+ str: Folder path to use for migration
398+ """
399+ if filename :
400+ return os .path .dirname (filename )
401+ else :
402+ return os .getcwd ()
403+
404+ @staticmethod
405+ def _register_settings_components (settings , path ):
406+ """Register all components from settings (classes, productions, schemas).
407+
408+ Args:
409+ settings: Settings module containing CLASSES, PRODUCTIONS, SCHEMAS
410+ path: Base path for component registration
411+ """
412+ # Use settings file location if path not provided
413+ if not path :
414+ path = os .path .dirname (inspect .getfile (settings ))
415+
285416 try :
286417 # set the classes settings
287- _Utils .set_classes_settings (settings .CLASSES ,path )
418+ _Utils .set_classes_settings (settings .CLASSES , path )
288419 except AttributeError :
289420 print ("No classes to register" )
421+
290422 try :
291423 # set the productions settings
292- _Utils .set_productions_settings (settings .PRODUCTIONS ,path )
424+ _Utils .set_productions_settings (settings .PRODUCTIONS , path )
293425 except AttributeError :
294426 print ("No productions to register" )
427+
295428 try :
296429 # set the schemas
297430 for cls in settings .SCHEMAS :
298431 _Utils .register_message_schema (cls )
299432 except AttributeError :
300433 print ("No schemas to register" )
301- try :
302- sys .path .remove (os .path .normpath (path ))
303- except ValueError :
304- pass
434+
435+ @staticmethod
436+ def _cleanup_sys_path (path ):
437+ """Remove path from sys.path if it was added.
438+
439+ Args:
440+ path: Path to remove from sys.path
441+ """
442+ if path :
443+ try :
444+ sys .path .remove (os .path .normpath (path ))
445+ except ValueError :
446+ pass
305447
306448 @staticmethod
307449 def import_module_from_path (module_name , file_path ):
0 commit comments