Skip to content

Commit 67f8d95

Browse files
committed
Implement remote migration functionality and update CLI tests
1 parent bdb9d9d commit 67f8d95

File tree

6 files changed

+373
-20
lines changed

6 files changed

+373
-20
lines changed

src/iop/_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def _handle_migrate(self) -> None:
147147
if migrate_path is not None:
148148
if not os.path.isabs(migrate_path):
149149
migrate_path = os.path.join(os.getcwd(), migrate_path)
150-
_Utils.migrate(migrate_path)
150+
_Utils.migrate_remote(migrate_path)
151151

152152
def _handle_log(self) -> None:
153153
if self.args.log == 'not_set':

src/iop/_remote.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# This module provides a REST API for remote I/O operations
2+
# It uses Flask to handle incoming requests and route them to the appropriate I/O functions
3+
# It should be able to help the migrate command of IoP Cli to work remotely:
4+
# this means copy all the .py files from the current directory of (settings.py) to the remote server
5+
# and run the api migrate from the remote server
6+
# the default folder is based on the NAMESPACE variable in settings.py
7+
from flask import Flask, request, jsonify
8+
9+
@app.route('/remote_io', methods=['POST'])
10+
def remote_io():
11+
data = request.json
12+
if not data or 'operation' not in data:
13+
return jsonify({'error': 'Invalid request'}), 400
14+
15+
operation = data['operation']
16+
# Here you would implement the logic to handle the operation
17+
# For example, you could call a function that performs the I/O operation
18+
# and return the result as JSON.
19+
20+
# Placeholder response for demonstration purposes
21+
response = {'status': 'success', 'operation': operation}
22+
return jsonify(response), 200
23+
24+
# ClassMethod UploadPackage(
25+
# namespace As %String,
26+
# body As %DynamicArray) As %DynamicObject
27+
# {
28+
# // check for namespace existence and user permissions against namespace
29+
# If '..NamespaceCheck(namespace) {
30+
# Return ""
31+
# }
32+
# New $NAMESPACE
33+
# Set $NAMESPACE = namespace
34+
35+
# //Create directory for custom packages
36+
# Do ##class(%ZHSLIB.HealthShareMgr).GetDBNSInfo(namespace,.out)
37+
# Set customPackagesPath = ##class(%Library.File).NormalizeDirectory("fhir_packages", out.globalsDatabase.directory)
38+
# If '##class(%Library.File).DirectoryExists(customPackagesPath) {
39+
# If '##class(%Library.File).CreateDirectory(customPackagesPath) {
40+
# $$$ThrowStatus($$$ERROR($$$DirectoryCannotCreate, customPackagesPath))
41+
# }
42+
# }
43+
44+
# //Find package name
45+
# Set iterator = body.%GetIterator()
46+
# Set packageName = ""
47+
# While iterator.%GetNext(, .fileObject ) {
48+
# If fileObject.name = "package.json" {
49+
# Set packageName = fileObject.data.name_"@"_fileObject.data.version
50+
# }
51+
# }
52+
# If packageName = "" {
53+
# Do ..%ReportRESTError($$$HTTP400,$$$ERROR($$$HSFHIRErrPackageNotFound))
54+
# Return ""
55+
# }
56+
57+
# Set packagePath = ##class(%Library.File).NormalizeDirectory(packageName, customPackagesPath)
58+
# // If the package already exists then we must be meaning to re-load it. Delete files/directory/metadata and recreate fresh.
59+
# If ##class(%Library.File).DirectoryExists(packagePath) {
60+
# If '##class(%Library.File).RemoveDirectoryTree(packagePath) {
61+
# $$$ThrowStatus($$$ERROR($$$DirectoryPermission , packagePath))
62+
# }
63+
# }
64+
# If '##class(%Library.File).CreateDirectory(packagePath) {
65+
# $$$ThrowStatus($$$ERROR($$$DirectoryCannotCreate, customPackagesPath))
66+
# }
67+
# Set pkg = ##class(HS.FHIRMeta.Storage.Package).FindById(packageName)
68+
# If $ISOBJECT(pkg) {
69+
# // Will fail and throw if the package is in-use or has dependencies preventing it from being deleted.
70+
# Do ##class(HS.FHIRServer.ServiceAdmin).DeleteMetadataPackage(packageName)
71+
# }
72+
# Kill pkg
73+
74+
# //Unpack JSON objects
75+
# Set iterator = body.%GetIterator()
76+
# While iterator.%GetNext(.key , .fileObject ) {
77+
# Set fileName = ##class(%Library.File).NormalizeFilename(fileObject.name,packagePath)
78+
# Set fileStream = ##class(%Stream.FileCharacter).%New()
79+
# Set fileStream.TranslateTable = "UTF8"
80+
# $$$ThrowOnError(fileStream.LinkToFile(fileName))
81+
# Do fileObject.data.%ToJSON(.fileStream)
82+
# $$$ThrowOnError(fileStream.%Save())
83+
# }
84+
85+
# //Import package
86+
# Do ##class(HS.FHIRMeta.Load.NpmLoader).importPackages(packagePath)
87+
# Set pkg = ..GetOnePackage(packageName, namespace)
88+
# Do ..%SetStatusCode($$$HTTP201)
89+
# Do ..%SetHeader("location", %request.Application _ "packages/" _ packageName _ "?namespace=" _ namespace)
90+
# Return pkg
91+
# }

src/iop/_utils.py

Lines changed: 159 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,24 @@
66
import json
77
import inspect
88
import ast
9-
from typing import Any, Dict, Optional, Union, Tuple
9+
from typing import Any, Dict, Optional, Union, Tuple, TypedDict
1010

1111
import xmltodict
12+
import requests
1213
from pydantic import TypeAdapter
1314

1415
from . import _iris
1516
from ._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+
1727
class _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):
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
Class IOP.Service.Remote.Handler Extends %CSP.REST
2+
{
3+
4+
Parameter CHARSET = "utf-8";
5+
6+
Parameter CONVERTINPUTSTREAM = 1;
7+
8+
Parameter CONTENTTYPE = "application/json";
9+
10+
/// This parameter influences the CORS support. The default is an empty string meaning 'not specified'.
11+
/// If set to true (1) then CORS processing is ON. If set to false (0) then CORS processing is OFF.
12+
/// If left unset "" then the decision to process CORS is delegated to the setting on the URL map route.
13+
Parameter HandleCorsRequest = 1;
14+
15+
Parameter UseSession As Integer = 0;
16+
17+
/// Ignore any writes done directly by the REST method.
18+
Parameter IgnoreWrites = 0;
19+
20+
XData UrlMap [ XMLNamespace = "http://www.intersystems.com/urlmap" ]
21+
{
22+
<Routes>
23+
<!-- Iop Management -->
24+
<Map Prefix="/v1" Forward="IOP.Service.Remote.Rest.v1"/>
25+
<!-- make the default forward to the highest current version of the API -->
26+
<Map Prefix="/*" Forward="IOP.Service.Remote.Rest.v1"/>
27+
</Routes>
28+
}
29+
30+
}

0 commit comments

Comments
 (0)