pam-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pam/__init__.py ADDED
File without changes
pam/api.py ADDED
@@ -0,0 +1,42 @@
1
+ import requests
2
+ from pam.utils import log
3
+
4
+
5
+ class API:
6
+
7
+ def http_post(self, url, data):
8
+ """
9
+ Sends an HTTP POST request to the specified URL with the given data as JSON.
10
+
11
+ :param url: The URL to send the POST request to.
12
+ :param data: A dictionary to be used as the JSON body of the POST request.
13
+ :return: The response from the server.
14
+ """
15
+ headers = {
16
+ 'Content-Type': 'application/json'}
17
+ try:
18
+ response = requests.post(
19
+ url, json=data, timeout=30, headers=headers)
20
+ response.raise_for_status() # Raise an exception for HTTP errors
21
+ return response
22
+ except requests.RequestException as e:
23
+ log(f"HTTP POST request failed: {e}")
24
+ return None
25
+
26
+ def http_upload(self, url, file_path):
27
+ """
28
+ Uploads a file to the specified URL.
29
+
30
+ :param url: The URL to upload the file to.
31
+ :param file_path: The path to the file to be uploaded.
32
+ :return: The response from the server.
33
+ """
34
+ try:
35
+ with open(file_path, 'rb') as file:
36
+ files = {'file': file}
37
+ response = requests.post(url, files=files, timeout=300)
38
+ response.raise_for_status() # Raise an exception for HTTP errors
39
+ return response
40
+ except requests.RequestException as e:
41
+ log(f"File upload failed: {e}")
42
+ return None
pam/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ import os
2
+ import sys
3
+ import pkg_resources
4
+ import shutil
5
+ import glob
6
+ import subprocess
7
+
8
+ def main():
9
+ args = sys.argv[1:]
10
+ if len(args) == 0:
11
+ print("Usage: pam <command> [args]")
12
+ return
13
+
14
+ cmd = args[0]
15
+
16
+ if cmd == "init":
17
+ init_project()
18
+ elif cmd == "new":
19
+ type = args[1]
20
+ if type == "service":
21
+ name = args[2]
22
+ create_service(name)
23
+ elif cmd == "test":
24
+ if len(args) < 2:
25
+ print("Usage: pam test <modulename>")
26
+ return
27
+ module_name = args[1]
28
+ test_module(module_name)
29
+ else:
30
+ print(f"Unknown command: {args.command}")
31
+
32
+ def test_module(module_name):
33
+ """Run all test files matching '*.test.py' in the specified module directory."""
34
+ # Locate all test files in the module directory
35
+ test_files = glob.glob(f"{module_name}/*.test.py")
36
+
37
+ if not test_files:
38
+ print(f"Error: No test files found in module '{module_name}' (expected '*.test.py').")
39
+ return
40
+
41
+ print(f"Found {len(test_files)} test file(s) in '{module_name}':")
42
+ for test_file in test_files:
43
+ print(f" - {test_file}")
44
+
45
+ # Run each test file
46
+ for test_file in test_files:
47
+ print(f"\nRunning tests in {test_file}...")
48
+ result = subprocess.run([sys.executable, test_file], capture_output=True, text=True)
49
+
50
+ print("\nTest Output:")
51
+ print(result.stdout)
52
+ if result.stderr:
53
+ print("\nErrors:")
54
+ print(result.stderr)
55
+ print("-" * 40) # Separator for clarity
56
+
57
+ def cpy(src, dest):
58
+ template_dir = pkg_resources.resource_filename("pam", "templates")
59
+ src_file = os.path.join(template_dir, src)
60
+ shutil.copy(src_file, dest)
61
+
62
+ def replace_template_content(service_name, file_name):
63
+ file_path = os.path.join(service_name, file_name)
64
+ with open(file_path, 'r+') as file:
65
+ filedata = file.read()
66
+ updated_data = filedata.replace('#CLASS_NAME#', service_name)
67
+ file.seek(0) # Move the file pointer to the beginning of the file
68
+ file.write(updated_data)
69
+ file.truncate() # Remove any leftover content after the replacement
70
+
71
+ def create_service(name):
72
+ if os.path.exists(name):
73
+ response = input(f"Service {name} already exists. Do you want to overwrite it? (y/N): ").strip().lower()
74
+ if response == 'y':
75
+ shutil.rmtree(name)
76
+ else:
77
+ print("Cancelled.")
78
+ return
79
+
80
+ os.mkdir(name)
81
+ open(os.path.join(name, "__init__.py"), 'a', encoding='utf-8').close()
82
+ cpy("service/ServiceClass.tmpl", os.path.join(name, name+"_service.py") )
83
+ cpy("service/service.yaml", os.path.join(name, "service.yaml") )
84
+ cpy("service/functions.tmpl", os.path.join(name, "functions.py") )
85
+ cpy("service/service.test.tmpl", os.path.join(name, name+".test.py") )
86
+
87
+
88
+ replace_template_content(name, name+"_service.py")
89
+ replace_template_content(name, "service.yaml")
90
+ replace_template_content(name, name+".test.py")
91
+
92
+ print(f"Service {name} created.")
93
+ print(f"Run `pam test {name}` to run tests for the service.")
94
+
95
+ def init_project():
96
+ cpy("init/main.tmpl", "main.py")
97
+ cpy("docker/Dockerfile", "Dockerfile")
98
+ cpy("buildcmd/pamb", "pamb")
99
+ cpy("buildcmd/pamb-base.sh", "pamb-base.sh")
100
+ if not os.path.exists("requirements.txt"):
101
+ open("requirements.txt", 'a', encoding='utf-8').close()
102
+
103
+ with open("requirements.txt", "w", encoding='utf-8') as f:
104
+ subprocess.run(["pip", "freeze"], stdout=f, check=True)
105
+
106
+ print("--- Welcome to PAM ---\n")
107
+ print("To create a new servive run\n`pam new service <service_name>`\n\n")
@@ -0,0 +1,30 @@
1
+ from abc import ABC, abstractmethod
2
+ from pam.models.request_command import RequestCommand
3
+ from pam.service import Service
4
+
5
+
6
+ class ITaskManager(ABC):
7
+
8
+ @abstractmethod
9
+ def on_dataset_input(self, req: RequestCommand):
10
+ pass
11
+
12
+ @abstractmethod
13
+ def start_service(self, service_class, req: RequestCommand, service_name):
14
+ pass
15
+
16
+ @abstractmethod
17
+ def terminate_service(self, token):
18
+ pass
19
+
20
+ @abstractmethod
21
+ def service_request_data(self, service: Service, page):
22
+ pass
23
+
24
+ @abstractmethod
25
+ def service_upload_result(self, service: Service, file_path):
26
+ pass
27
+
28
+ @abstractmethod
29
+ def service_upload_report(self, service: Service, file_path):
30
+ pass
pam/models/__init__.py ADDED
File without changes
@@ -0,0 +1,138 @@
1
+
2
+ """
3
+ RequestCommand is object that Wrap the http request to object that simple to access the parameters
4
+ """
5
+
6
+ import os
7
+ from dataclasses import dataclass
8
+ import zipfile
9
+ from pathlib import Path
10
+ from pam.utils import log
11
+ from pam.temp_file_utils import TempfileUtils
12
+
13
+
14
+ @dataclass
15
+ class RequestCommand:
16
+ """
17
+ RequestCommand is object that Wrap the http request
18
+ """
19
+
20
+ token: str
21
+ cmd: str
22
+ data_request_api: str
23
+ response_api: str
24
+ is_end: bool
25
+ next: str
26
+ input_files: list[str]
27
+ service_name: str
28
+
29
+ def is_start_command(self):
30
+ """Determine is this command is start command"""
31
+ return self.cmd == "start"
32
+
33
+ def is_dataset_command(self):
34
+ """Determine is this command is dataset command"""
35
+ return self.cmd == "dataset"
36
+
37
+ @staticmethod
38
+ def __safe_str_to_bool(value):
39
+ if isinstance(value, bool):
40
+ return value
41
+ if isinstance(value, str):
42
+ if value.lower() in ['true', '1', 'yes']:
43
+ return True
44
+ if value.lower() in ['false', '0', 'no']:
45
+ return False
46
+ return False
47
+
48
+ @staticmethod
49
+ def __extract_data_from_request(request, keys):
50
+ result = {}
51
+
52
+ if request.content_type == 'application/json':
53
+ data = request.get_json()
54
+ for key in keys:
55
+ if key in data:
56
+ result[key] = data[key]
57
+ else:
58
+ result[key] = ""
59
+ elif request.content_type.startswith('multipart/form-data'):
60
+ for key in keys:
61
+ data_str = request.form.get(key, '')
62
+ if data_str:
63
+ result[key] = data_str
64
+ else:
65
+ result[key] = ""
66
+
67
+ return result
68
+
69
+ @staticmethod
70
+ def __allowed_file(filename):
71
+ return '.' in filename and filename.rsplit(
72
+ '.', 1)[1].lower() in {'zip'}
73
+
74
+ @staticmethod
75
+ def __extract_input_file(zip_file, service_name, token):
76
+
77
+ if zip_file.filename == '':
78
+ log("handle_file_upload 'No selected file'")
79
+ return ([], "No selected file")
80
+
81
+ if not RequestCommand.__allowed_file(zip_file.filename):
82
+ log("handle_file_upload 'Invalid file type'")
83
+ return ([], "Invalid file type")
84
+
85
+ zip_file_dir = TempfileUtils.get_temp_file_name(
86
+ service_name, token, "dataset_")
87
+
88
+ zip_file_name = f"{zip_file_dir}.zip"
89
+ zip_file.save(zip_file_name)
90
+
91
+ extract_dir = Path(zip_file_dir)
92
+ extract_dir.mkdir(parents=True, exist_ok=True)
93
+
94
+ with zipfile.ZipFile(zip_file_name, 'r') as zip_ref:
95
+
96
+ zip_ref.extractall(zip_file_dir)
97
+
98
+ if os.path.exists(f"{extract_dir}/__MACOSX"):
99
+ os.rmdir(f"{extract_dir}/__MACOSX")
100
+ if os.path.exists(f"{extract_dir}/.DS_Store"):
101
+ os.rmdir(f"{extract_dir}/.DS_Store")
102
+
103
+ input_files = sorted([os.path.join(extract_dir, name)
104
+ for name in os.listdir(extract_dir)])
105
+
106
+ log(f"Extract dir: {extract_dir}")
107
+ log(f"File save to: {input_files}")
108
+
109
+ return (input_files, "")
110
+
111
+ @staticmethod
112
+ def parse(request, service_name):
113
+ """Create RequestCommand object from http request"""
114
+ params = RequestCommand.__extract_data_from_request(
115
+ request, ["cmd", "token", "data", "response", "is_end", "next"])
116
+
117
+ token = params["token"]
118
+ cmd = params["cmd"]
119
+ data = params["data"]
120
+ response = params["response"]
121
+ is_end = RequestCommand.__safe_str_to_bool(params["is_end"])
122
+ next_page = params["next"]
123
+ input_files = []
124
+ error_message = ""
125
+
126
+ if not token:
127
+ error_message = 'The `token` parameter is required.'
128
+ else:
129
+ if cmd == "dataset":
130
+ zip_file = request.files['file']
131
+ (files, error) = RequestCommand.__extract_input_file(
132
+ zip_file, service_name, token)
133
+ error_message = error
134
+ input_files = files
135
+
136
+ return RequestCommand(
137
+ token, cmd, data, response, is_end, next_page, input_files, service_name
138
+ ), error_message
pam/server.py ADDED
@@ -0,0 +1,170 @@
1
+ """
2
+ Data Plugin Server
3
+ """
4
+
5
+ import os
6
+ import importlib
7
+ import yaml
8
+ from setuptools import find_packages
9
+ from flask import request, make_response, Flask, jsonify
10
+ from pam.utils import log
11
+ from pam.models.request_command import RequestCommand
12
+ from pam.task_manager import TaskManager
13
+ from pam.temp_file_utils import TempfileUtils
14
+
15
+
16
+ class Server:
17
+ """
18
+ Data Plugin Server
19
+ """
20
+
21
+ app: Flask
22
+ servicePool = {}
23
+
24
+ def __init__(self, app):
25
+ self.task_manager = TaskManager(self)
26
+ self.app = app
27
+ self.register_service()
28
+ self.task_manager.start_service_monitoring_schedul()
29
+
30
+ @app.route('/', methods=['GET'])
31
+ def home():
32
+ return self.response_ok({'message': "Hello, Data plugin v2."})
33
+
34
+ @app.route('/service/<service_name>', methods=['POST'])
35
+ def run(service_name):
36
+ TempfileUtils.clean_temp()
37
+
38
+ req, error_request = RequestCommand.parse(request, service_name)
39
+
40
+ if error_request != "":
41
+ response = {
42
+ 'message': error_request
43
+ }
44
+ return self.response_error(response, 400)
45
+
46
+ response, code = self.handle_service_cmd(service_name, req)
47
+ return self.response_error(response, code)
48
+
49
+ @app.route('/tasks', methods=['GET'])
50
+ def tasks():
51
+ return self.response_ok({'message': "OK"})
52
+
53
+ @app.route('/status/<service_name>', methods=['GET'])
54
+ def ping(service_name):
55
+ # TODO: check the service status
56
+ return self.response_ok({'message': f"OK {service_name}"})
57
+
58
+ def get_service_class(self, service_name):
59
+ """
60
+ Get service class from service name
61
+ """
62
+ if service_name in self.servicePool:
63
+ return self.servicePool[service_name]
64
+
65
+ return None
66
+
67
+ def handle_service_cmd(self, service_name, req: RequestCommand):
68
+ """
69
+ Handle all cmd type
70
+ """
71
+ service_class = self.get_service_class(service_name)
72
+
73
+ if service_class is not None:
74
+ if req.is_start_command():
75
+ log(f"Request on start {service_name} token={req.token}")
76
+ return self.on_start(service_class, req, service_name)
77
+
78
+ if req.is_dataset_command():
79
+
80
+ log(f"""Request on dataset {service_name} token={
81
+ req.token} files={req.input_files}""")
82
+
83
+ return self.on_dataset(req)
84
+
85
+ response = {'message': f'Service `{service_name}` Notfound. '}
86
+ return response, 404
87
+
88
+ def on_start(self, service_class, req, service_name):
89
+ """
90
+ start a service
91
+ """
92
+ log(f"{service_name} -> on_start")
93
+ self.task_manager.start_service(service_class, req, service_name)
94
+ return {'acknowledge': True}, 200
95
+
96
+ def on_dataset(self, req):
97
+ """
98
+ task_manager handle cmd=dataset
99
+ """
100
+ log(f"{req.service_name} -> on_dataset")
101
+ self.task_manager.on_dataset_input(req)
102
+ return {'acknowledge': True}, 200
103
+
104
+ def on_register_service(self, service, end_point):
105
+ """
106
+ register a service
107
+ """
108
+ log(f"register service \"{service}\", endpoint: http://localhost/service/{end_point}")
109
+ self.servicePool[end_point] = service
110
+
111
+ def run(self, host='0.0.0.0', port=8000):
112
+ """
113
+ run flask app
114
+ """
115
+ self.app.run(host=host, port=port)
116
+
117
+ def response_error(self, json, code):
118
+ """
119
+ create error response with custom http status code
120
+ """
121
+ data = jsonify(json)
122
+ return make_response(data, code)
123
+
124
+ def response_ok(self, json, code=200):
125
+ """
126
+ create response object with 200OK
127
+ """
128
+ data = jsonify(json)
129
+ return make_response(data, code)
130
+
131
+ def get_service_config(self, package):
132
+ """
133
+ load the service config from service.yml
134
+ """
135
+ config_path_1 = f'{package}/service.yaml'
136
+ config_path_2 = f'{package}/service.yml'
137
+ if os.path.exists(config_path_1) and os.path.isfile(config_path_1):
138
+ with open(config_path_1, 'r', encoding='utf-8') as file:
139
+ config = yaml.safe_load(file)
140
+ return config
141
+ elif os.path.exists(config_path_2) and os.path.isfile(config_path_2):
142
+ with open(config_path_2, 'r', encoding='utf-8') as file:
143
+ config = yaml.safe_load(file)
144
+ return config
145
+ return None
146
+
147
+ def register_service(self):
148
+ """
149
+ scan project to register all plugin services
150
+ """
151
+ packages = find_packages()
152
+ for package in packages:
153
+ config = self.get_service_config(package)
154
+ if config is not None:
155
+ endpoint = config['endpoint']
156
+ class_name = config['class']
157
+ module = importlib.import_module(
158
+ f'{package}.{class_name}')
159
+
160
+ try:
161
+ class_ = getattr(module, class_name)
162
+ except AttributeError:
163
+ log(
164
+ f"Error: Class '{class_name}' not found in module '{module}'.")
165
+ class_ = None
166
+
167
+ if endpoint.startswith("/"):
168
+ endpoint = endpoint[1:]
169
+
170
+ self.on_register_service(class_, endpoint)
pam/service.py ADDED
@@ -0,0 +1,101 @@
1
+ """
2
+ Service base class
3
+ """
4
+
5
+ import os
6
+ import tempfile
7
+ import uuid
8
+ from abc import abstractmethod
9
+ import json
10
+ from typing import TYPE_CHECKING
11
+ import pandas as pd
12
+ import dask.dataframe as dd
13
+ from pam.utils import log, deep_convert_numbers_to_strings, get_adapter_id
14
+ from pam.models.request_command import RequestCommand
15
+ from pam.temp_file_utils import TempfileUtils
16
+
17
+ if TYPE_CHECKING:
18
+ from pam.task_manager import TaskManager
19
+
20
+
21
+ class Service:
22
+ """
23
+ Service base class
24
+ """
25
+
26
+ # pylint: disable=too-many-instance-attributes
27
+ def __init__(self, task_manager: 'TaskManager', req: RequestCommand):
28
+ self.request = req
29
+ self.task_manager = task_manager
30
+
31
+ # == REQUEST DATA ===
32
+ def _request_data(self, page=""):
33
+ log(f"{self.request.service_name}: Request data page={page}")
34
+ self.task_manager.service_request_data(self, page)
35
+
36
+ # == UPLOAD RESULT ===
37
+ def _upload_result(self, df):
38
+ if isinstance(df, dd.DataFrame):
39
+ df = df.compute()
40
+ elif not isinstance(df, pd.DataFrame):
41
+ raise ValueError("The input must be a Pandas or Dask DataFrame")
42
+
43
+ tmp_file_name = TempfileUtils.get_temp_file_name(
44
+ self.request.service_name, self.request.token, "result_", ".csv")
45
+
46
+ df.to_csv(tmp_file_name, index=False)
47
+
48
+ log(f"{self.request.service_name}: Upload Result File {tmp_file_name}")
49
+ self.task_manager.service_upload_result(self, tmp_file_name)
50
+
51
+ return tmp_file_name
52
+
53
+ # == UPLOAD REPORT ===
54
+
55
+ def _upload_report(self, report_name: str, report_json: str):
56
+ adapter_id = get_adapter_id(self.request.response_api)
57
+ service_name = self.request.service_name
58
+ token = self.request.token
59
+
60
+ # important need to recursive and convert numbers to str
61
+ report_json = deep_convert_numbers_to_strings(
62
+ report_json)
63
+
64
+ json_string = json.dumps(report_json)
65
+
66
+ report_name = f"data_{report_name}_no_index"
67
+
68
+ df = pd.DataFrame({
69
+ 'customer': [adapter_id],
70
+ report_name: [json_string]
71
+ })
72
+
73
+ report_csv_path = TempfileUtils.get_temp_file_name(
74
+ service_name, token, f"report_{report_name}_", '.csv')
75
+
76
+ df.to_csv(report_csv_path, index=False)
77
+ self.task_manager.service_upload_report(self, report_csv_path)
78
+ # =========
79
+
80
+ def _exit(self):
81
+ self.task_manager.service_exit(self)
82
+
83
+ @abstractmethod
84
+ def on_start(self):
85
+ """on sstart"""
86
+
87
+ @abstractmethod
88
+ def on_data_input(self, req):
89
+ """on_data_input"""
90
+
91
+ @abstractmethod
92
+ def on_destroy(self):
93
+ """on_destroy"""
94
+
95
+ @abstractmethod
96
+ def on_terminate(self):
97
+ """on_terminate"""
98
+
99
+ @abstractmethod
100
+ def get_status(self):
101
+ """get_status"""
pam/task_manager.py ADDED
@@ -0,0 +1,141 @@
1
+ from datetime import datetime, timedelta
2
+ import time
3
+ import threading
4
+ from typing import Dict
5
+ from pam.utils import log
6
+ from pam.api import API
7
+ from pam.service import Service
8
+ from pam.models.request_command import RequestCommand
9
+ from pam.interface_task_manager import ITaskManager
10
+ from pam.temp_file_utils import TempfileUtils
11
+
12
+
13
+ class ServiceHolder:
14
+ def __init__(self, service: Service):
15
+ self.service = service
16
+ self.last_activity = datetime.now()
17
+
18
+ def update_timestamp(self):
19
+ self.last_activity = datetime.now()
20
+
21
+ def has_timed_out(self, timeout=2):
22
+ # ตรวจสอบว่า service นี้ไม่เคลื่อนไหวเกินกว่า timeout ชั่วโมงหรือไม่
23
+ return datetime.now() - self.last_activity > timedelta(hours=timeout)
24
+
25
+
26
+ class TaskManager(ITaskManager):
27
+ """
28
+ Manage service thread
29
+ """
30
+
31
+ def __init__(self, server):
32
+ self.services: Dict[str, ServiceHolder] = {}
33
+ self.api = API()
34
+ self.server = server
35
+ self.thread_lock = threading.Lock()
36
+
37
+ def _add_service(self, token, service):
38
+ with self.thread_lock:
39
+ self.services[token] = ServiceHolder(service)
40
+
41
+ def _get_service_holder(self, token):
42
+ with self.thread_lock:
43
+ return self.services.get(token)
44
+
45
+ def _update_service(self, token):
46
+ with self.thread_lock:
47
+ if token in self.services:
48
+ self.services[token].update_timestamp()
49
+
50
+ def _check_timeout(self, token, timeout=2):
51
+ with self.thread_lock:
52
+ if token in self.services:
53
+ return self.services[token].has_timed_out(timeout)
54
+ return False
55
+
56
+ def _remove_service(self, token):
57
+ with self.thread_lock:
58
+ if token in self.services:
59
+ service_holder = self.services[token]
60
+ log(f"Service Exit: {
61
+ service_holder.service.request.service_name}, {service_holder.service.request.token}")
62
+ service_holder.service.on_destroy()
63
+ del self.services[token]
64
+
65
+ def start_service_monitoring_schedul(self):
66
+ schedule_thread = threading.Thread(target=self._monitor_services)
67
+ schedule_thread.daemon = True
68
+ schedule_thread.start()
69
+
70
+ def _monitor_services(self, interval=600, timeout=2):
71
+ while True:
72
+ time.sleep(interval)
73
+ log("Service Monitor running.")
74
+ tokens_to_remove = [
75
+ token for token in self.services if self._check_timeout(token, timeout)
76
+ ]
77
+ log(f"Found: {len(tokens_to_remove)} services timeout.")
78
+ for token in tokens_to_remove:
79
+ log(f"Service {token} has timed out. Removing...")
80
+ self._remove_service(token)
81
+
82
+ def on_dataset_input(self, req: RequestCommand):
83
+ """
84
+ handle cmd dataset
85
+ """
86
+ service_holder = self._get_service_holder(req.token)
87
+ if service_holder is not None:
88
+ service_holder.update_timestamp()
89
+ service_holder.service.on_data_input(req)
90
+
91
+ def start_service(self, service_class, req: RequestCommand, service_name):
92
+ """
93
+ Start new service
94
+ """
95
+ log(f"Start Services {service_name}, Token: {req.token}")
96
+ service_instance = service_class(self, req)
97
+ service_instance.on_start()
98
+ self._add_service(req.token, service_instance)
99
+
100
+ def terminate_service(self, token):
101
+ """
102
+ stop service that started from a token
103
+ """
104
+ service_holder = self._get_service_holder(token)
105
+ if service_holder is not None:
106
+ service_holder.service.on_terminate()
107
+ self._remove_service(token)
108
+
109
+ # ======= Service Callback function ======
110
+ def service_request_data(self, service: Service, page):
111
+ endpoint = service.request.data_request_api
112
+ token = service.request.token
113
+
114
+ json_data = {"page": page, "token": token}
115
+
116
+ log(f"Request Data to: {endpoint}, page: {page}, token={token}")
117
+ http_thread = threading.Thread(
118
+ target=self.api.http_post, args=(endpoint, json_data, ))
119
+ http_thread.start()
120
+ http_thread.join()
121
+
122
+ def service_upload_result(self, service: Service, file_path):
123
+ endpoint = service.request.response_api
124
+
125
+ log(f"Upload Data to: {endpoint}")
126
+ http_thread = threading.Thread(
127
+ target=self.api.http_upload, args=(endpoint, file_path, ))
128
+ http_thread.start()
129
+ http_thread.join()
130
+
131
+ def service_upload_report(self, service: Service, file_path):
132
+ endpoint = service.request.response_api
133
+
134
+ log(f"Upload Report to: {endpoint}")
135
+ http_thread = threading.Thread(
136
+ target=self.api.http_upload, args=(endpoint, file_path, ))
137
+ http_thread.start()
138
+ http_thread.join()
139
+
140
+ def service_exit(self, service: Service):
141
+ self._remove_service(service.request.token)
pam/temp_file_utils.py ADDED
@@ -0,0 +1,75 @@
1
+ from pathlib import Path
2
+ from datetime import datetime, timedelta
3
+ import shutil
4
+ import uuid
5
+ from pam.utils import log
6
+
7
+
8
+ def clean_old_folders(days: int):
9
+
10
+ base_folder = Path(TempfileUtils.temp_datasource_path)
11
+
12
+ if not base_folder.exists():
13
+ log("No temp dir to clean")
14
+ return
15
+ elif base_folder.is_file():
16
+ log("Something wrong, temp dir is not folder. Plugin will delete it.")
17
+ base_folder.unlink()
18
+ return
19
+
20
+ cutoff_date = datetime.now() - timedelta(days=days)
21
+
22
+ # Iterate through all subfolders in the base_folder
23
+ for folder in base_folder.iterdir():
24
+ if folder.is_dir():
25
+ try:
26
+ # Extract the date from the folder name
27
+ folder_date = datetime.strptime(folder.name, "%Y_%m_%d")
28
+
29
+ # If the folder is older than the cutoff date, remove it
30
+ if folder_date < cutoff_date:
31
+ shutil.rmtree(folder)
32
+ log(f"Removed folder: {folder}")
33
+ except ValueError:
34
+ # If folder name doesn't match the expected format, skip it
35
+ log(f"""Skipping folder: {
36
+ folder} (unexpected name format)""")
37
+
38
+
39
+ class TempfileUtils:
40
+
41
+ temp_base_path = "/app/data"
42
+ temp_datasource_path = "/app/data/data_sources"
43
+
44
+ @staticmethod
45
+ def clean_temp():
46
+ clean_old_folders(10)
47
+
48
+ @staticmethod
49
+ def get_temp_path(service_name, token):
50
+ date_path = datetime.now().strftime("%Y_%m_%d")
51
+ temp_dir = f"""{TempfileUtils.temp_datasource_path}/{date_path}/{service_name}/{
52
+ token}"""
53
+ folder_path = Path(temp_dir)
54
+ folder_path.mkdir(parents=True, exist_ok=True)
55
+ return temp_dir
56
+
57
+ @staticmethod
58
+ def get_temp_file_name(service_name, token, prefix="", extension=""):
59
+ unique_filename = uuid.uuid1().hex
60
+
61
+ if prefix:
62
+ if prefix.endswith("_"):
63
+ unique_filename = f"{prefix}{unique_filename}"
64
+ else:
65
+ unique_filename = f"{prefix}_{unique_filename}"
66
+
67
+ if extension:
68
+ if extension.startswith("."):
69
+ unique_filename = f"{unique_filename}{extension}"
70
+ else:
71
+ unique_filename = f"{unique_filename}.{extension}"
72
+
73
+ temp_path = TempfileUtils.get_temp_path(service_name, token)
74
+ full_path = Path(temp_path) / unique_filename
75
+ return full_path
@@ -0,0 +1,31 @@
1
+ #!/bin/bash
2
+
3
+ CMD=$1
4
+ APP_VERSION=$2
5
+ IMAGE=$3
6
+
7
+ function print_howto() {
8
+ echo "How to use : pamb CMD APP_VERSION TIMESTAMP NAMESPACE OTHER_PARAM1 OTHER_PARAM2 (eg. pamb build_pam 6.0 201906011030 stg)"
9
+ }
10
+
11
+ if [ "$CMD" = "" ]; then
12
+ echo -e "CMD cannot be blank"
13
+ print_howto
14
+ exit 1
15
+ fi
16
+
17
+ if [ "$APP_VERSION" = "" ]; then
18
+ echo -e "APP_VERSION cannot be blank"
19
+ print_howto
20
+ exit 1
21
+ fi
22
+
23
+ if [ "$IMAGE" = "" ]; then
24
+ echo -e "IMAGE cannot be blank"
25
+ print_howto
26
+ exit 1
27
+ fi
28
+
29
+ source ./pamb-base.sh
30
+
31
+ eval ${CMD}
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+
3
+ function commit_version() {
4
+ local IMAGE=$1
5
+ local VERSION=$2
6
+ echo "docker push image : 3dsinteractive/$IMAGE:$VERSION"
7
+ docker login -u $DOCKER_USER -p $DOCKER_PASS
8
+ docker push 3dsinteractive/$IMAGE:$VERSION
9
+ }
10
+
11
+ function prepare_project() {
12
+ git config --global url.ssh://git@bitbucket.org/.insteadOf https://bitbucket.org/
13
+ }
14
+
15
+ function build_plugin() {
16
+ prepare_project
17
+
18
+ docker build -f Dockerfile -t 3dsinteractive/$IMAGE:$APP_VERSION .
19
+ commit_version $IMAGE $APP_VERSION
20
+ }
@@ -0,0 +1,17 @@
1
+ FROM python:3.12-slim
2
+
3
+ COPY . /app
4
+
5
+ WORKDIR /app
6
+
7
+ ENV DATA_FOLDER=/app/data
8
+ RUN mkdir -p $DATA_FOLDER
9
+
10
+ RUN pip install --upgrade pip
11
+ RUN pip install -r requirements.txt
12
+
13
+ EXPOSE 8000
14
+
15
+ ENV PYTHONUNBUFFERED=1
16
+
17
+ CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:8000", "main:app"]
@@ -0,0 +1,11 @@
1
+ import os
2
+ from flask import Flask
3
+ from pam.server import Server
4
+
5
+
6
+ app = Flask(__name__)
7
+
8
+ pluginServer = Server(app)
9
+
10
+ if __name__ == '__main__':
11
+ pluginServer.run(host='0.0.0.0', port=8000)
@@ -0,0 +1,48 @@
1
+ import pandas as pd
2
+ from pam.service import Service
3
+ from pam.models.request_command import RequestCommand
4
+ from pam.utils import log
5
+ from functions import *
6
+
7
+
8
+ class #CLASS_NAME#(Service):
9
+
10
+ def __init__(self, task_manager, req):
11
+ super().__init__(task_manager, req)
12
+
13
+ def on_start(self):
14
+ log("on_start")
15
+ self._request_data()
16
+
17
+ def on_data_input(self, req: RequestCommand):
18
+ log(f"on_data_input req.is_end = {req.is_end}")
19
+
20
+ dataframe = self.__process_data(req.input_files)
21
+ self._upload_result(dataframe)
22
+
23
+ if not req.is_end:
24
+ self._request_data(req.next)
25
+ else:
26
+ report_df = create_report()
27
+ report_name = "#CLASS_NAME#_report1"
28
+ self._upload_report(report_name, report_df)
29
+
30
+ self._exit()
31
+
32
+ def __process_data(self, input_files):
33
+ file_path = input_files[0]
34
+ df = load_csv_file(file_path)
35
+ df['finished'] = True
36
+ return df
37
+
38
+ def __create_report():
39
+ pass
40
+
41
+ def on_destroy(self):
42
+ log("on_destroy")
43
+
44
+ def on_terminate(self):
45
+ log("on_terminate")
46
+
47
+ def get_status(self):
48
+ return ""
@@ -0,0 +1,22 @@
1
+ import pandas as pd
2
+
3
+ def load_csv_file(csv_file):
4
+ dtype_spec = {
5
+ 'id': 'str'
6
+ }
7
+ df = pd.read_csv(csv_file, dtype=dtype_spec, parse_dates=['created_date'])
8
+ return df
9
+
10
+ def create_report():
11
+ report = {
12
+ "type": "TABLE",
13
+ "headers": ["test",
14
+ "column1",
15
+ "column2"
16
+ ],
17
+ "rows": [
18
+ ["test1", 1, 2],
19
+ ["test2", 3, 4]
20
+ ]
21
+ }
22
+ return report
@@ -0,0 +1,83 @@
1
+
2
+ from typing import List, Tuple
3
+ from #CLASS_NAME#_service import #CLASS_NAME#
4
+ from pam.tester_task import TesterTask
5
+ from pam.models.request_command import RequestCommand
6
+ from faker import Faker
7
+ import pandas as pd
8
+ import os
9
+ import shutil
10
+ import uuid
11
+ from pam.temp_file_utils import TempfileUtils
12
+
13
+ def __create_mock_csv(page):
14
+ fake = Faker()
15
+ data = {
16
+ "id": range(1, 11), # 10 rows
17
+ "name": [fake.name() for _ in range(10)],
18
+ "age": [fake.random_int(min=18, max=65) for _ in range(10)],
19
+ "email": [fake.email() for _ in range(10)],
20
+ "created_date": [fake.date_time_this_year() for _ in range(10)]
21
+ }
22
+ file_name = f"{mock_dir}/mock_data_faker_{page}.csv"
23
+ df = pd.DataFrame(data)
24
+ df.to_csv(file_name, index=False)
25
+ return file_name
26
+
27
+ def can_convert_to_int(x):
28
+ try:
29
+ int(x)
30
+ return True
31
+ except (ValueError, TypeError):
32
+ return False
33
+
34
+ def on_request_data(page) -> Tuple[List[str], bool, str]:
35
+ page_num = 1
36
+ if can_convert_to_int(page):
37
+ page_num = int(page)
38
+
39
+ next_page = str(page_num + 1)
40
+ is_end = False
41
+ if page_num == MAXPAGE:
42
+ is_end = True
43
+ next_page = ""
44
+
45
+ mock_csv = __create_mock_csv(page_num)
46
+
47
+ return [mock_csv], is_end, next_page
48
+
49
+
50
+ def on_upload_report(report_file):
51
+ print(f"Uploaded Report {report_file}")
52
+
53
+
54
+ def on_upload_result(result_file):
55
+ print(f"Uploaded {result_file}")
56
+
57
+
58
+
59
+ #---- Constants ----
60
+ MAXPAGE=3
61
+ mock_dir = "./TEMP_TEST_DATA"
62
+ TempfileUtils.temp_base_path = f"{mock_dir}/app/data"
63
+ TempfileUtils.temp_datasource_path = f"{mock_dir}/app/data/data_sources"
64
+
65
+
66
+ #---- Create Mock Dir ----
67
+ if os.path.exists(mock_dir):
68
+ shutil.rmtree(mock_dir)
69
+ os.makedirs(mock_dir)
70
+
71
+ #---- Create Tester Task ----
72
+ testerTask = TesterTask()
73
+ testerTask.set_on_upload_result(on_upload_result)
74
+ testerTask.set_on_request_data(on_request_data)
75
+ testerTask.set_on_upload_report(on_upload_report)
76
+
77
+ #---- Create Request Command ----
78
+ token=unique_id = uuid.uuid4()
79
+ req = RequestCommand(token, "start", "-", "-", None, None, None, '#CLASS_NAME#')
80
+ #CLASS_NAME# = #CLASS_NAME#(testerTask, req)
81
+
82
+ #---- Start Test ----
83
+ testerTask.test_service(#CLASS_NAME#)
@@ -0,0 +1,2 @@
1
+ endpoint: /#CLASS_NAME#
2
+ class: #CLASS_NAME#
pam/tester_task.py ADDED
@@ -0,0 +1,60 @@
1
+ from typing import Callable, Tuple, List
2
+ from pam.models.request_command import RequestCommand
3
+ from pam.interface_task_manager import ITaskManager
4
+ from pam.service import Service
5
+
6
+
7
+ RequestDataCallbackType = Callable[[str], Tuple[List[str], bool, str]]
8
+
9
+ UploadResultCallbackType = Callable[[str], None]
10
+
11
+ UploadReportCallbackType = Callable[[str], None]
12
+
13
+
14
+ class TesterTask(ITaskManager):
15
+
16
+ def __init__(self):
17
+ self.request_data_callback: RequestDataCallbackType = None
18
+ self.upload_result_calllback: UploadResultCallbackType = None
19
+ self.upload_report_calllback: UploadReportCallbackType = None
20
+
21
+ def set_on_request_data(self, request_data_callback: RequestDataCallbackType):
22
+ self.request_data_callback = request_data_callback
23
+
24
+ def set_on_upload_result(self, upload_result_calllback: UploadResultCallbackType):
25
+ self.upload_result_calllback = upload_result_calllback
26
+
27
+ def set_on_upload_report(self, upload_report_calllback: UploadReportCallbackType):
28
+ self.upload_report_calllback = upload_report_calllback
29
+
30
+ def test_service(self, service):
31
+ service.on_start()
32
+
33
+ # -----
34
+
35
+ def on_dataset_input(self, req: RequestCommand):
36
+ pass
37
+
38
+ def start_service(self, service_class, req: RequestCommand, service_name):
39
+ pass
40
+
41
+ def terminate_service(self, token):
42
+ pass
43
+
44
+ def service_exit(self, service_class):
45
+ print(f"{service_class.request.service_name} Exit.")
46
+
47
+ def service_request_data(self, service: Service, page):
48
+ if self.request_data_callback is not None:
49
+ files, is_end, next_page = self.request_data_callback(page)
50
+ req = RequestCommand(service.request.token, "dataset", "",
51
+ "", is_end, next_page, files, service.request.service_name)
52
+ service.on_data_input(req)
53
+
54
+ def service_upload_result(self, service: Service, file_path):
55
+ if self.upload_result_calllback is not None:
56
+ self.upload_result_calllback(file_path)
57
+
58
+ def service_upload_report(self, service: Service, file_path):
59
+ if self.upload_report_calllback is not None:
60
+ self.upload_report_calllback(file_path)
pam/utils.py ADDED
@@ -0,0 +1,38 @@
1
+ import os
2
+ import tempfile
3
+ from datetime import datetime
4
+ import re
5
+ import pytz
6
+
7
+
8
+ def get_adapter_id(url: str) -> str:
9
+ pattern = r'/api/dataadapter/(\w+)/response'
10
+ match = re.search(pattern, url)
11
+ if match:
12
+ return match.group(1)
13
+ return ""
14
+
15
+
16
+ def deep_convert_numbers_to_strings(data):
17
+ if isinstance(data, dict):
18
+ return {key: deep_convert_numbers_to_strings(
19
+ value) for key, value in data.items()}
20
+
21
+ if isinstance(data, list):
22
+ return [deep_convert_numbers_to_strings(item) for item in data]
23
+
24
+ if isinstance(data, tuple):
25
+ return tuple(deep_convert_numbers_to_strings(item) for item in data)
26
+
27
+ if isinstance(data, (int, float)):
28
+ return str(data)
29
+
30
+ return data
31
+
32
+
33
+ def log(msg: str):
34
+ utc_dt = datetime.now(pytz.utc) # Get current time in UTC
35
+ tz = pytz.timezone('Asia/Bangkok')
36
+ bangkok_time = utc_dt.astimezone(tz) # Convert UTC time to Bangkok time
37
+ date_str = bangkok_time.strftime("%m/%d/%Y %H:%M:%S")
38
+ print(f"[{date_str}]: {msg}")
@@ -0,0 +1,17 @@
1
+ This software is provided as part of the agreement between 3dsinteractive,co.ltd ("the Provider") and its customers ("the Licensee") under the following conditions:
2
+
3
+ 1. **Usage Rights for Active Customers**:
4
+ - The Licensee may use, modify, and distribute this software exclusively for their internal operations in connection with the services provided by the Provider.
5
+ - No disclosure of source code is required while the Licensee maintains active status as a customer.
6
+
7
+ 2. **Termination of Customer Status**:
8
+ - Upon termination of the agreement between the Provider and the Licensee, the Licensee must either:
9
+ a. Cease using and delete all copies of this software, or
10
+ b. Open-source any derived works based on this software under an OSI-approved license.
11
+
12
+ 3. **Restrictions**:
13
+ - Redistribution of this software to third parties not under the Licensee's operational control is strictly prohibited.
14
+ - The Licensee may not use this software for competitive purposes against the Provider.
15
+
16
+ 4. **Liability**:
17
+ - The Provider provides this software "as is" without warranty of any kind, either expressed or implied.
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.1
2
+ Name: pam-python
3
+ Version: 0.1.0
4
+ Summary: Pam Python Library
5
+ Home-page: https://github.com/heart/pam-python
6
+ Author: Narongrit Kanhanoi
7
+ Author-email: narongrit@pams.ai
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.8
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: License :: Other/Proprietary License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Customer Service
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE.txt
19
+ Requires-Dist: setuptools>=70.0.0
20
+ Requires-Dist: Flask>=3.0.2
21
+ Requires-Dist: aiohttp>=3.11.11
22
+ Requires-Dist: pandas>=2.2.3
23
+ Requires-Dist: Faker>=33.1.0
24
+ Requires-Dist: dask>=2024.12.1
25
+ Requires-Dist: dask-expr>=1.1.21
26
+
27
+ ## License
28
+
29
+ This library is distributed under a custom license. Active customers of [Your Company Name] may use it as per the terms stated in the `LICENSE.txt` file. For details, contact contact@3dsinteractive.com.
@@ -0,0 +1,26 @@
1
+ pam/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pam/api.py,sha256=pNY1qTSTalCk8nzXRZ845gXGZwpK2iNwOtjZnEZdEDY,1472
3
+ pam/cli.py,sha256=dqfGXMJGBqt1HJ-ZbxHLndPa9hV_UaHgu0SvxwFAZrQ,3671
4
+ pam/interface_task_manager.py,sha256=6DGAUcf6IxNIYIZZcFRjZh6NynSm5BWZgrJP0GdCrdQ,717
5
+ pam/server.py,sha256=hvp-ZQcp2zJTFxOeVLEQg3f9aZgBLwTTSgfeRV94J6A,5374
6
+ pam/service.py,sha256=oavcz_ggkKTk7RgvGNwwpbHw75nqg2b5fTxsMMKkKwE,2803
7
+ pam/task_manager.py,sha256=6ChMLTLp9GCxKUcJAZzHtb9l-5K83mc_3XRwtxD-Esk,5011
8
+ pam/temp_file_utils.py,sha256=pvI8klHQqnH3-GL_7vGPHKtvZRyEC1PBGqbxrqwi05Y,2434
9
+ pam/tester_task.py,sha256=S-fxXMH5FMaeJVv_FAxHiJCFb3cStSQoRrRqxK8Yutk,2191
10
+ pam/utils.py,sha256=nOndtbbdlHx5vfUZJpO-qR59eWvvF3xoCBOQSqKckiM,1029
11
+ pam/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ pam/models/request_command.py,sha256=KnARL5sVbIr_xUcF0EMMxCDHQRX57M2gUcRLPKm2Vjs,4239
13
+ pam/templates/buildcmd/pamb,sha256=58GCN59ysvHGNhPvTD3nkF4aLg-kM1WfWmH7CL3eQVo,530
14
+ pam/templates/buildcmd/pamb-base.sh,sha256=Q0DkbeHtPx33PIjc68EeI7ZIwBOLlwcSmPhpxALXxVY,518
15
+ pam/templates/docker/Dockerfile,sha256=d266YB4XYRJEM3mtU9uSQcL_rsCZojoA04fW8OqovPU,268
16
+ pam/templates/init/main.tmpl,sha256=v0n0H4YNrRPPPZlbzdL2vpLwQgCMgJQ6Cm7nR6oo4qU,192
17
+ pam/templates/service/ServiceClass.tmpl,sha256=M30LOdAIBfnLBLhBO--3DWzUz1EWwtcRHS-I-vUBvZE,1188
18
+ pam/templates/service/functions.tmpl,sha256=RMG8_PsKyhHgBMeXBfCavRlUW00rqNoo6QKfG3pZgqU,470
19
+ pam/templates/service/service.test.tmpl,sha256=YmdEnVMmdpwM8iT1nXF1fSKOjg0XBWXfbXRwcecxi0s,2198
20
+ pam/templates/service/service.yaml,sha256=S9r6MeysjlBZVr-6OykrwFO5RJqrhsoLr6R8FQtD8gg,44
21
+ pam_python-0.1.0.dist-info/LICENSE.txt,sha256=yeHD2-gzrkkET2ZkMe22WyUGpDjlwTrqA4POb0FoZi4,1138
22
+ pam_python-0.1.0.dist-info/METADATA,sha256=nnkU4wLWA0kRda746Ytv0_s5AHIdYbUvMYsT60nHUEg,1102
23
+ pam_python-0.1.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
24
+ pam_python-0.1.0.dist-info/entry_points.txt,sha256=xqYpsIqvNlO0j8elZc02bDwR-12dLK9FU2B1ACsLYxw,37
25
+ pam_python-0.1.0.dist-info/top_level.txt,sha256=0EOjbyc3hQyzjhn6iyMgsEseqA66Xz0p27iBN7G7W1w,4
26
+ pam_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pam = pam.cli:main
@@ -0,0 +1 @@
1
+ pam