codeflowhub 0.0.1__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.
- codeflowhub/__init__.py +13 -0
- codeflowhub/action.py +5 -0
- codeflowhub/base.py +73 -0
- codeflowhub/flow.py +560 -0
- codeflowhub/model.py +19 -0
- codeflowhub/service/__init__.py +3 -0
- codeflowhub/service/airflow_exporter.py +607 -0
- codeflowhub/storage/__init__.py +11 -0
- codeflowhub/storage/local_storage.py +24 -0
- codeflowhub/storage/s3_storage.py +87 -0
- codeflowhub/storage/storage.py +16 -0
- codeflowhub/task.py +107 -0
- codeflowhub/template/__init__.py +4 -0
- codeflowhub/template/analyze_speaker_pkg/__init__.py +0 -0
- codeflowhub/template/analyze_speaker_pkg/main.py +155 -0
- codeflowhub/template/extract_voice_pkg/__init__.py +0 -0
- codeflowhub/template/extract_voice_pkg/main.py +119 -0
- codeflowhub/template/read_pdf_pkg/__init__.py +0 -0
- codeflowhub/template/read_pdf_pkg/main.py +161 -0
- codeflowhub/template/transcript_pkg/__init__.py +0 -0
- codeflowhub/template/transcript_pkg/main.py +143 -0
- codeflowhub-0.0.1.dist-info/METADATA +19 -0
- codeflowhub-0.0.1.dist-info/RECORD +25 -0
- codeflowhub-0.0.1.dist-info/WHEEL +5 -0
- codeflowhub-0.0.1.dist-info/top_level.txt +1 -0
codeflowhub/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .flow import FlowDecorator
|
|
2
|
+
from .task import TaskDecorator
|
|
3
|
+
from .storage import get_storage
|
|
4
|
+
from .model import VolumeMount, Toleration, Volume
|
|
5
|
+
|
|
6
|
+
flow = FlowDecorator
|
|
7
|
+
task = TaskDecorator
|
|
8
|
+
|
|
9
|
+
# CLI 파서 헬퍼 함수
|
|
10
|
+
get_parser = FlowDecorator.get_parser
|
|
11
|
+
parse_args = FlowDecorator.parse_args
|
|
12
|
+
|
|
13
|
+
__all__ = ['get_storage', 'VolumeMount', 'Toleration', 'Volume', 'flow', 'task', 'FlowDecorator', 'get_parser', 'parse_args']
|
codeflowhub/action.py
ADDED
codeflowhub/base.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from .action import Action
|
|
4
|
+
|
|
5
|
+
class BaseDecorator:
|
|
6
|
+
name: str = None
|
|
7
|
+
func = None
|
|
8
|
+
depend: list['BaseDecorator'] = None
|
|
9
|
+
run_log_file: str = 'run.json' # 클래스 변수 (모든 인스턴스 공유)
|
|
10
|
+
|
|
11
|
+
def __init__(self, *args, **kwargs):
|
|
12
|
+
self.func = None
|
|
13
|
+
self.depend = []
|
|
14
|
+
if args and callable(args[0]):
|
|
15
|
+
self._initialize_with_func(args[0])
|
|
16
|
+
elif 'name' in kwargs:
|
|
17
|
+
self.name = kwargs['name']
|
|
18
|
+
|
|
19
|
+
def _initialize_with_func(self, func):
|
|
20
|
+
"""함수로 초기화"""
|
|
21
|
+
self.func = func
|
|
22
|
+
self.name = func.__name__
|
|
23
|
+
self.init()
|
|
24
|
+
|
|
25
|
+
def __call__(self, *args, **kwargs):
|
|
26
|
+
if self.func is None and args and callable(args[0]):
|
|
27
|
+
self._initialize_with_func(args[0])
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
if args and isinstance(args[0], Action):
|
|
31
|
+
return self._handle_action(args[0], *args[1:], **kwargs)
|
|
32
|
+
|
|
33
|
+
return self._wrapper_func(*args, **kwargs) if self.func else self
|
|
34
|
+
|
|
35
|
+
def _handle_action(self, action, *args, **kwargs):
|
|
36
|
+
"""Action을 처리하여 의존성 구성"""
|
|
37
|
+
if action.type == 'build':
|
|
38
|
+
self.depend = [arg for arg in args if isinstance(arg, BaseDecorator)]
|
|
39
|
+
print(f'Task {self.name} depends on: {[d.name for d in self.depend]}')
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def init(self):
|
|
43
|
+
"""서브클래스에서 오버라이드"""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def _wrapper_func(self, *args, **kwargs):
|
|
47
|
+
"""함수 실행 및 결과 로깅"""
|
|
48
|
+
input_data = args[0] if args else {}
|
|
49
|
+
result = self.func(*args, **kwargs)
|
|
50
|
+
|
|
51
|
+
if not isinstance(result, dict):
|
|
52
|
+
raise Exception(f'result must be json serializable, got {type(result)}: {result}')
|
|
53
|
+
|
|
54
|
+
self._save_run_log(input_data, result)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
def _load_log_file(self):
|
|
58
|
+
"""로그 파일 로드"""
|
|
59
|
+
if not os.path.exists(self.run_log_file):
|
|
60
|
+
return {}
|
|
61
|
+
with open(self.run_log_file, 'r', encoding='utf-8') as f:
|
|
62
|
+
return json.load(f)
|
|
63
|
+
|
|
64
|
+
def _write_log_file(self, data):
|
|
65
|
+
"""로그 파일 저장"""
|
|
66
|
+
with open(self.run_log_file, 'w', encoding='utf-8') as f:
|
|
67
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
68
|
+
|
|
69
|
+
def _save_run_log(self, input_data, output_data):
|
|
70
|
+
"""task의 input/output을 run.json에 저장"""
|
|
71
|
+
run_log = self._load_log_file()
|
|
72
|
+
run_log[self.name] = {'input': input_data, 'output': output_data}
|
|
73
|
+
self._write_log_file(run_log)
|
codeflowhub/flow.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import BaseDecorator
|
|
9
|
+
from .action import Action
|
|
10
|
+
from .service import AirflowExporter
|
|
11
|
+
|
|
12
|
+
class FlowDecorator(BaseDecorator):
|
|
13
|
+
|
|
14
|
+
namespace:str
|
|
15
|
+
env:dict
|
|
16
|
+
env_config:dict # 여러 환경 설정을 담는 dict
|
|
17
|
+
current_env:str # 현재 선택된 환경
|
|
18
|
+
flow_name:str # flow의 이름 (name 파라미터)
|
|
19
|
+
description:str # flow의 설명
|
|
20
|
+
params:dict # Airflow DAG params
|
|
21
|
+
tags:list # Airflow DAG tags
|
|
22
|
+
annotations:dict # Kubernetes pod annotations
|
|
23
|
+
service_account_name:str # Kubernetes service account name
|
|
24
|
+
volumes:list # Kubernetes volumes
|
|
25
|
+
airflow_sidecar_image:str # Airflow XCom sidecar 이미지
|
|
26
|
+
repo:str # Git repository URL
|
|
27
|
+
on_failure: 'BaseDecorator' = None # 모든 task의 기본 failure handler
|
|
28
|
+
|
|
29
|
+
def __init__(self, *args, namespace='default', env=None, name=None, description=None, params=None,
|
|
30
|
+
tags=None, annotations=None, service_account_name=None, volumes=None,
|
|
31
|
+
airflow_sidecar_image=None, repo=None, on_failure=None, **kwargs):
|
|
32
|
+
# CLI 속성 먼저 초기화 (init()에서 사용됨)
|
|
33
|
+
self._cli_export = None
|
|
34
|
+
self._cli_job_dir = None
|
|
35
|
+
self._cli_task = None
|
|
36
|
+
self._cli_input_data = None
|
|
37
|
+
self._cli_output_file = None
|
|
38
|
+
self._cli_run_log = None
|
|
39
|
+
|
|
40
|
+
self.flow_name = name
|
|
41
|
+
self.namespace = namespace
|
|
42
|
+
self.description = description
|
|
43
|
+
self.params = params or {}
|
|
44
|
+
self.tags = tags or []
|
|
45
|
+
self.annotations = annotations or {}
|
|
46
|
+
self.service_account_name = service_account_name
|
|
47
|
+
self.volumes = volumes or []
|
|
48
|
+
self.airflow_sidecar_image = airflow_sidecar_image
|
|
49
|
+
self.repo = repo
|
|
50
|
+
self.on_failure = on_failure
|
|
51
|
+
|
|
52
|
+
self._initialize_env(env)
|
|
53
|
+
self._parse_args()
|
|
54
|
+
|
|
55
|
+
# super().__init__()는 마지막에 호출 (init()이 실행됨)
|
|
56
|
+
super().__init__(*args, name=name, **kwargs)
|
|
57
|
+
|
|
58
|
+
def _initialize_env(self, env):
|
|
59
|
+
"""환경 설정 초기화"""
|
|
60
|
+
if env and isinstance(env, dict) and all(isinstance(v, dict) for v in env.values()):
|
|
61
|
+
self.env_config = env
|
|
62
|
+
self.env = env.get('default', {})
|
|
63
|
+
elif env:
|
|
64
|
+
self.env_config = {'default': env}
|
|
65
|
+
self.env = env
|
|
66
|
+
else:
|
|
67
|
+
self.env_config = {}
|
|
68
|
+
self.env = {}
|
|
69
|
+
self.current_env = 'default'
|
|
70
|
+
|
|
71
|
+
# 클래스 변수: 미리 파싱된 결과 저장
|
|
72
|
+
_pre_parsed_args = None
|
|
73
|
+
# 클래스 변수: 커스텀 CLI 인자 저장 (flowhub 기본 인자 제외)
|
|
74
|
+
_custom_cli_args = {}
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def _create_parser(cls, add_help=True):
|
|
78
|
+
"""FlowhHub CLI 파서 생성"""
|
|
79
|
+
parser = argparse.ArgumentParser(description='FlowhHub Workflow', add_help=add_help)
|
|
80
|
+
parser.add_argument('--env', type=str, default='default', help='Environment to use (default: default)')
|
|
81
|
+
parser.add_argument('--id', type=str, default=None, help='Workflow run ID (default: run)')
|
|
82
|
+
parser.add_argument('--export', type=str, default=None, help='Export to external system (airflow)')
|
|
83
|
+
parser.add_argument('--job', type=str, default=None, help='Job directory path (auto-sets --input-data and --run-log)')
|
|
84
|
+
parser.add_argument('--task', type=str, default=None, help='Run specific task (default: all when --job is used)')
|
|
85
|
+
parser.add_argument('--input', '--input-data', type=str, default=None, dest='input_data', help='Input file or directory for task initial data')
|
|
86
|
+
parser.add_argument('--output', '--output-file', type=str, default=None, dest='output_file', help='Output file path for final task result')
|
|
87
|
+
parser.add_argument('--run-log', type=str, default='run.json', help='Run log file storing intermediate task results (default: run.json)')
|
|
88
|
+
return parser
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def get_parser(cls) -> argparse.ArgumentParser:
|
|
92
|
+
"""FlowhHub CLI 파서를 반환
|
|
93
|
+
|
|
94
|
+
외부에서 커스텀 인자를 추가하고 싶을 때 사용합니다.
|
|
95
|
+
파서에 직접 add_argument()로 커스텀 인자를 추가한 후 parse_args()를 호출하세요.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
argparse.ArgumentParser: FlowhHub 인자가 포함된 파서
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
from flowhub import get_parser, parse_args, flow, task
|
|
102
|
+
|
|
103
|
+
# 1. 파서 가져와서 커스텀 인자 추가
|
|
104
|
+
parser = get_parser()
|
|
105
|
+
parser.add_argument('--my-option', type=str, default='default')
|
|
106
|
+
parser.add_argument('--count', type=int, default=1)
|
|
107
|
+
args = parse_args(parser)
|
|
108
|
+
|
|
109
|
+
# 2. 커스텀 값을 사용하여 flow 정의
|
|
110
|
+
@flow(name='my-workflow', env={
|
|
111
|
+
'default': {
|
|
112
|
+
'WORKSPACE': './tmp',
|
|
113
|
+
'MY_OPTION': args.my_option,
|
|
114
|
+
'COUNT': args.count
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
def main(flow_args):
|
|
118
|
+
...
|
|
119
|
+
"""
|
|
120
|
+
return cls._create_parser()
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def parse_args(cls, parser: argparse.ArgumentParser = None) -> argparse.Namespace:
|
|
124
|
+
"""CLI 인자를 파싱하고 결과를 캐시
|
|
125
|
+
|
|
126
|
+
get_parser()로 커스텀 인자를 추가한 파서를 전달하거나,
|
|
127
|
+
파서 없이 호출하면 기본 FlowhHub 파서를 사용합니다.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
parser: 커스텀 인자가 추가된 파서 (optional)
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
argparse.Namespace: 파싱된 인자들
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
from flowhub import get_parser, parse_args, flow
|
|
137
|
+
|
|
138
|
+
parser = get_parser()
|
|
139
|
+
parser.add_argument('--my-option', type=str)
|
|
140
|
+
args = parse_args(parser)
|
|
141
|
+
|
|
142
|
+
@flow(name='my-workflow', env={'default': {'MY_OPTION': args.my_option}})
|
|
143
|
+
def main(flow_args):
|
|
144
|
+
...
|
|
145
|
+
"""
|
|
146
|
+
if parser is None:
|
|
147
|
+
parser = cls._create_parser()
|
|
148
|
+
|
|
149
|
+
if len(sys.argv) <= 1:
|
|
150
|
+
cls._pre_parsed_args = parser.parse_args([])
|
|
151
|
+
return cls._pre_parsed_args
|
|
152
|
+
|
|
153
|
+
args = parser.parse_args()
|
|
154
|
+
cls._pre_parsed_args = args
|
|
155
|
+
|
|
156
|
+
# flowhub 기본 인자 목록
|
|
157
|
+
flowhub_args = {'env', 'id', 'export', 'job', 'task', 'input_data', 'output_file', 'run_log'}
|
|
158
|
+
|
|
159
|
+
# 커스텀 인자 추출 (flowhub 기본 인자 제외)
|
|
160
|
+
cls._custom_cli_args = {
|
|
161
|
+
key: value for key, value in vars(args).items()
|
|
162
|
+
if key not in flowhub_args and value is not None
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return args
|
|
166
|
+
|
|
167
|
+
def _parse_args(self):
|
|
168
|
+
"""Command line arguments 파싱
|
|
169
|
+
|
|
170
|
+
parse_args()로 미리 파싱된 결과가 있으면 그것을 사용하고,
|
|
171
|
+
없으면 parse_known_args()로 flowhub 인자만 파싱합니다.
|
|
172
|
+
"""
|
|
173
|
+
# 미리 파싱된 결과가 있으면 사용
|
|
174
|
+
if FlowDecorator._pre_parsed_args is not None:
|
|
175
|
+
args = FlowDecorator._pre_parsed_args
|
|
176
|
+
elif len(sys.argv) <= 1:
|
|
177
|
+
self._cli_export = self._cli_task = self._cli_input_data = self._cli_output_file = self._cli_run_log = self._cli_job_dir = None
|
|
178
|
+
return
|
|
179
|
+
else:
|
|
180
|
+
# parse_known_args()로 flowhub 인자만 파싱
|
|
181
|
+
# 내부 파싱 시에는 add_help=False (외부 파서와 충돌 방지)
|
|
182
|
+
parser = self._create_parser(add_help=False)
|
|
183
|
+
args, _ = parser.parse_known_args()
|
|
184
|
+
|
|
185
|
+
if args.env and args.env in self.env_config:
|
|
186
|
+
self.select_env(args.env)
|
|
187
|
+
print(f'Using environment: {args.env}')
|
|
188
|
+
|
|
189
|
+
if args.id:
|
|
190
|
+
self.env['run_id'] = args.id
|
|
191
|
+
print(f'Using run_id: {args.id}')
|
|
192
|
+
|
|
193
|
+
# --job 옵션 처리
|
|
194
|
+
if args.job:
|
|
195
|
+
job_path = Path(args.job)
|
|
196
|
+
self._cli_job_dir = args.job
|
|
197
|
+
# --task가 명시되지 않으면 'all' 사용
|
|
198
|
+
self._cli_task = args.task if args.task else 'all'
|
|
199
|
+
|
|
200
|
+
# --input-data가 명시되지 않으면 job 경로 기반
|
|
201
|
+
# (all인 경우 input.json, 특정 task인 경우 None=run-log에서 로드)
|
|
202
|
+
if not args.input_data:
|
|
203
|
+
self._cli_input_data = str(job_path / 'input.json') if self._cli_task == 'all' else None
|
|
204
|
+
else:
|
|
205
|
+
self._cli_input_data = args.input_data
|
|
206
|
+
|
|
207
|
+
# --run-log가 기본값이면 {job_path}/run.json 사용
|
|
208
|
+
self._cli_run_log = args.run_log if args.run_log != 'run.json' else str(job_path / 'run.json')
|
|
209
|
+
self._cli_output_file = args.output_file
|
|
210
|
+
|
|
211
|
+
print(f'📁 Job directory: {args.job}')
|
|
212
|
+
print(f' Task: {self._cli_task}')
|
|
213
|
+
print(f' Input data: {self._cli_input_data or f"(from {self._cli_run_log})"}')
|
|
214
|
+
print(f' Run log: {self._cli_run_log}')
|
|
215
|
+
else:
|
|
216
|
+
self._cli_task = args.task
|
|
217
|
+
self._cli_input_data = args.input_data
|
|
218
|
+
self._cli_output_file = args.output_file
|
|
219
|
+
self._cli_run_log = args.run_log
|
|
220
|
+
self._cli_job_dir = None
|
|
221
|
+
|
|
222
|
+
self._cli_export = args.export
|
|
223
|
+
|
|
224
|
+
# run log 파일 경로를 BaseDecorator.run_log_file에 설정
|
|
225
|
+
if self._cli_run_log:
|
|
226
|
+
BaseDecorator.run_log_file = self._cli_run_log
|
|
227
|
+
|
|
228
|
+
def select_env(self, env_name):
|
|
229
|
+
"""환경 선택"""
|
|
230
|
+
if env_name not in self.env_config:
|
|
231
|
+
available = list(self.env_config.keys())
|
|
232
|
+
raise ValueError(f'환경 "{env_name}"을 찾을 수 없습니다. 사용 가능한 환경: {available}')
|
|
233
|
+
|
|
234
|
+
self.current_env = env_name
|
|
235
|
+
self.env = self.env_config[env_name]
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
def _handle_export(self):
|
|
239
|
+
"""Export 처리"""
|
|
240
|
+
if self._cli_export.lower() != 'airflow':
|
|
241
|
+
print(f'❌ Unknown export target: {self._cli_export}')
|
|
242
|
+
print(f' Supported: airflow')
|
|
243
|
+
os._exit(1)
|
|
244
|
+
|
|
245
|
+
from datetime import datetime, timedelta
|
|
246
|
+
|
|
247
|
+
flow_id = self.flow_name or self.name
|
|
248
|
+
print(f'🚀 Exporting {flow_id} to Airflow DAG...')
|
|
249
|
+
|
|
250
|
+
dag_path = self.export_airflow(
|
|
251
|
+
output_path=f'dags/{flow_id}_dag.py',
|
|
252
|
+
schedule_interval=None,
|
|
253
|
+
start_date=datetime.now(),
|
|
254
|
+
default_args={
|
|
255
|
+
'owner': 'flowhub',
|
|
256
|
+
'retries': 1,
|
|
257
|
+
'retry_delay': timedelta(minutes=5),
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
print(f'✅ Successfully exported to: {dag_path}')
|
|
262
|
+
print(f'📋 DAG ID: {flow_id}')
|
|
263
|
+
print(f'🏷️ Tags: [{self.namespace}]')
|
|
264
|
+
os._exit(0)
|
|
265
|
+
|
|
266
|
+
def _handle_task_run(self):
|
|
267
|
+
"""CLI에서 --task 옵션으로 특정 task 실행"""
|
|
268
|
+
# --task all이면 모든 task를 순서대로 실행
|
|
269
|
+
if self._cli_task == 'all':
|
|
270
|
+
self._run_all_tasks()
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
# 입력 로드
|
|
274
|
+
input_data = self._load_input_data_from_cli(allow_none=True)
|
|
275
|
+
|
|
276
|
+
# run() 메서드 호출
|
|
277
|
+
result = self.run(self._cli_task, input_data)
|
|
278
|
+
|
|
279
|
+
# 결과 저장
|
|
280
|
+
self._save_result_to_output(result, success_message=f'✅ Task "{self._cli_task}" 실행 완료')
|
|
281
|
+
|
|
282
|
+
def _load_input_data_from_cli(self, allow_none=False):
|
|
283
|
+
"""CLI --input-data 옵션에서 입력 데이터 로드
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
allow_none: True면 --input-data 없을 때 None 반환, False면 에러
|
|
287
|
+
"""
|
|
288
|
+
if not self._cli_input_data:
|
|
289
|
+
if allow_none:
|
|
290
|
+
return None
|
|
291
|
+
else:
|
|
292
|
+
print(f'❌ Error: --input-data가 필요합니다.')
|
|
293
|
+
os._exit(1)
|
|
294
|
+
|
|
295
|
+
input_path = Path(self._cli_input_data)
|
|
296
|
+
|
|
297
|
+
if input_path.is_dir():
|
|
298
|
+
return self._load_inputs_from_dir(input_path)
|
|
299
|
+
elif input_path.is_file():
|
|
300
|
+
return self._load_input_from_file(input_path)
|
|
301
|
+
else:
|
|
302
|
+
print(f'❌ Error: 입력 경로 {self._cli_input_data}를 찾을 수 없습니다.')
|
|
303
|
+
os._exit(1)
|
|
304
|
+
|
|
305
|
+
def _load_inputs_from_dir(self, input_path):
|
|
306
|
+
"""디렉토리에서 입력 데이터 로드"""
|
|
307
|
+
print(f'📂 Loading inputs from directory: {input_path}')
|
|
308
|
+
json_files = sorted(input_path.glob('*.json'))
|
|
309
|
+
|
|
310
|
+
if not json_files:
|
|
311
|
+
print(f'❌ Error: {input_path} 폴더에 JSON 파일이 없습니다.')
|
|
312
|
+
os._exit(1)
|
|
313
|
+
|
|
314
|
+
input_data = []
|
|
315
|
+
for json_file in json_files:
|
|
316
|
+
print(f' - {json_file.name}')
|
|
317
|
+
with open(json_file, 'r', encoding='utf-8') as f:
|
|
318
|
+
input_data.append(json.load(f))
|
|
319
|
+
|
|
320
|
+
print(f'✅ Loaded {len(input_data)} input file(s)')
|
|
321
|
+
return input_data
|
|
322
|
+
|
|
323
|
+
def _load_input_from_file(self, input_path):
|
|
324
|
+
"""파일에서 입력 데이터 로드"""
|
|
325
|
+
print(f'📄 Loading input from file: {input_path}')
|
|
326
|
+
with open(input_path, 'r', encoding='utf-8') as f:
|
|
327
|
+
return json.load(f)
|
|
328
|
+
|
|
329
|
+
def _run_all_tasks(self):
|
|
330
|
+
"""모든 task를 flow 전체 실행"""
|
|
331
|
+
# 입력 데이터 로드 (필수)
|
|
332
|
+
input_data = self._load_input_data_from_cli(allow_none=False)
|
|
333
|
+
|
|
334
|
+
print(f'🚀 Running full workflow...')
|
|
335
|
+
print(f'📋 Tasks: {[t.name for t in self.depend]}')
|
|
336
|
+
print()
|
|
337
|
+
|
|
338
|
+
# flow 전체 실행 (self(data))
|
|
339
|
+
result = self(input_data)
|
|
340
|
+
|
|
341
|
+
# 결과 저장
|
|
342
|
+
self._save_result_to_output(result, success_message='\n✅ Workflow completed')
|
|
343
|
+
|
|
344
|
+
def _save_single_task_to_log(self, task_name, result):
|
|
345
|
+
"""개별 task 결과를 run.json에 저장"""
|
|
346
|
+
run_log = self._load_log_file()
|
|
347
|
+
run_log[task_name] = {
|
|
348
|
+
'input': result,
|
|
349
|
+
'output': result
|
|
350
|
+
}
|
|
351
|
+
self._write_log_file(run_log)
|
|
352
|
+
|
|
353
|
+
def _save_result_to_output(self, result, success_message='✅ Task completed'):
|
|
354
|
+
"""결과를 --output-file 또는 run-log에 저장
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
result: 저장할 결과
|
|
358
|
+
success_message: 성공 메시지 (기본: '✅ Task completed')
|
|
359
|
+
"""
|
|
360
|
+
if self._cli_output_file:
|
|
361
|
+
# --output-file 지정된 경우: 파일에 저장
|
|
362
|
+
output_path = Path(self._cli_output_file)
|
|
363
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
365
|
+
json.dump(result, f, indent=2, ensure_ascii=False)
|
|
366
|
+
print(f'{success_message}. Output file: {self._cli_output_file}')
|
|
367
|
+
else:
|
|
368
|
+
# --output-file 없는 경우: run-log에 저장
|
|
369
|
+
if self._cli_task and self._cli_task != 'all':
|
|
370
|
+
self._save_single_task_to_log(self._cli_task, result)
|
|
371
|
+
print(f'{success_message}. Results saved to run log: {BaseDecorator.run_log_file}')
|
|
372
|
+
|
|
373
|
+
def _inject_env_to_data(self, data):
|
|
374
|
+
"""데이터에 env 주입 (중복 코드 방지용 헬퍼 메서드)"""
|
|
375
|
+
if isinstance(data, dict):
|
|
376
|
+
data = data.copy()
|
|
377
|
+
if self.env is not None and 'env' not in data:
|
|
378
|
+
env_with_defaults = self.env.copy()
|
|
379
|
+
env_with_defaults.setdefault('project', self.flow_name or self.name)
|
|
380
|
+
env_with_defaults.setdefault('run_id', 'run')
|
|
381
|
+
env_with_defaults.setdefault('task', None)
|
|
382
|
+
data['env'] = env_with_defaults
|
|
383
|
+
return data
|
|
384
|
+
|
|
385
|
+
def _wrapper_func(self, *args, **kwargs):
|
|
386
|
+
"""Flow 실행 시 args에 env를 자동으로 주입"""
|
|
387
|
+
if args and isinstance(args[0], dict):
|
|
388
|
+
input_data = self._inject_env_to_data(args[0])
|
|
389
|
+
args = (input_data,) + args[1:]
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
return super()._wrapper_func(*args, **kwargs)
|
|
393
|
+
except Exception as e:
|
|
394
|
+
if self.on_failure:
|
|
395
|
+
import traceback
|
|
396
|
+
print(f'❌ Workflow "{self.name}" failed: {e}')
|
|
397
|
+
print(f'🔧 Calling failure handler: {self.on_failure.name}')
|
|
398
|
+
# failure handler에 env 정보 + 에러 정보 포함된 데이터 전달
|
|
399
|
+
failure_data = self._inject_env_to_data({
|
|
400
|
+
'error': str(e),
|
|
401
|
+
'error_type': type(e).__name__,
|
|
402
|
+
'traceback': traceback.format_exc(),
|
|
403
|
+
})
|
|
404
|
+
return self.on_failure(failure_data)
|
|
405
|
+
raise
|
|
406
|
+
|
|
407
|
+
def init(self):
|
|
408
|
+
# Action을 전달하여 flow 실행 → task들이 자동으로 의존성 수집
|
|
409
|
+
action = Action(type='build')
|
|
410
|
+
|
|
411
|
+
# Action에 호출된 task들을 추적할 리스트 추가
|
|
412
|
+
action.called_tasks = []
|
|
413
|
+
|
|
414
|
+
self.func(action)
|
|
415
|
+
|
|
416
|
+
# flow의 depend에 실제로 호출된 task를 실행 순서로 정렬 (병렬인 경우 이름순)
|
|
417
|
+
self.depend = self._sort_tasks_by_execution_order(action.called_tasks)
|
|
418
|
+
|
|
419
|
+
# init 완료 후 CLI 모드 체크
|
|
420
|
+
if self._cli_export:
|
|
421
|
+
self._handle_export()
|
|
422
|
+
elif self._cli_task:
|
|
423
|
+
self._handle_task_run()
|
|
424
|
+
os._exit(0)
|
|
425
|
+
|
|
426
|
+
def _sort_tasks_by_execution_order(self, tasks):
|
|
427
|
+
"""
|
|
428
|
+
Task들을 실행 순서로 정렬 (topological sort)
|
|
429
|
+
같은 레벨(병렬 실행 가능)인 task들은 이름순으로 정렬
|
|
430
|
+
"""
|
|
431
|
+
# 각 task의 레벨(depth) 계산
|
|
432
|
+
def get_task_level(task):
|
|
433
|
+
if not task.depend:
|
|
434
|
+
return 0
|
|
435
|
+
return max(get_task_level(dep) for dep in task.depend) + 1
|
|
436
|
+
|
|
437
|
+
# task를 (level, name, task) 튜플로 변환하여 정렬
|
|
438
|
+
task_with_level = [(get_task_level(task), task.name, task) for task in tasks]
|
|
439
|
+
task_with_level.sort(key=lambda x: (x[0], x[1])) # level 우선, 같으면 name 순
|
|
440
|
+
|
|
441
|
+
return [task for _, _, task in task_with_level]
|
|
442
|
+
|
|
443
|
+
def run(self, task_name, data=None):
|
|
444
|
+
"""특정 task만 실행
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
task_name: 실행할 task 이름
|
|
448
|
+
data: 입력 데이터. dict 또는 list[dict] 가능
|
|
449
|
+
- None: run.json에서 로드
|
|
450
|
+
- dict: 단일 입력
|
|
451
|
+
- list[dict]: 여러 입력 (task 파라미터에 따라 병합 또는 개별 전달)
|
|
452
|
+
"""
|
|
453
|
+
# 모든 실행 가능한 task 목록 생성
|
|
454
|
+
all_tasks = list(self.depend)
|
|
455
|
+
if self.on_failure:
|
|
456
|
+
all_tasks.append(self.on_failure)
|
|
457
|
+
|
|
458
|
+
# task 검색
|
|
459
|
+
target_task = next((task for task in all_tasks if task.name == task_name), None)
|
|
460
|
+
|
|
461
|
+
if not target_task:
|
|
462
|
+
available_tasks = [t.name for t in all_tasks]
|
|
463
|
+
raise ValueError(f'Task "{task_name}"을 찾을 수 없습니다. 사용 가능한 task: {available_tasks}')
|
|
464
|
+
|
|
465
|
+
# 입력 데이터 준비
|
|
466
|
+
if data is None:
|
|
467
|
+
input_data = self._load_task_input_from_log(task_name)
|
|
468
|
+
print(f'🚀 Running task: {task_name} (from run.json)')
|
|
469
|
+
else:
|
|
470
|
+
input_data = data
|
|
471
|
+
print(f'🚀 Running task: {task_name}')
|
|
472
|
+
|
|
473
|
+
# list[dict]인 경우 처리
|
|
474
|
+
if isinstance(input_data, list):
|
|
475
|
+
sig = inspect.signature(target_task.func)
|
|
476
|
+
param_count = len([p for p in sig.parameters.values() if p.default == inspect.Parameter.empty])
|
|
477
|
+
|
|
478
|
+
if len(input_data) == 1:
|
|
479
|
+
# 단일 입력
|
|
480
|
+
input_data = self._inject_env_to_data(input_data[0])
|
|
481
|
+
result = target_task(input_data)
|
|
482
|
+
elif param_count > 1:
|
|
483
|
+
# 여러 파라미터를 받는 task - 개별 전달
|
|
484
|
+
input_data = [self._inject_env_to_data(d) for d in input_data]
|
|
485
|
+
result = target_task(*input_data)
|
|
486
|
+
else:
|
|
487
|
+
# 단일 파라미터 - 병합
|
|
488
|
+
merged = {}
|
|
489
|
+
for d in input_data:
|
|
490
|
+
merged.update(d)
|
|
491
|
+
merged = self._inject_env_to_data(merged)
|
|
492
|
+
result = target_task(merged)
|
|
493
|
+
else:
|
|
494
|
+
# dict인 경우
|
|
495
|
+
input_data = self._inject_env_to_data(input_data)
|
|
496
|
+
result = target_task(input_data)
|
|
497
|
+
|
|
498
|
+
# 결과 출력
|
|
499
|
+
print(f'📤 Output: {json.dumps(result, ensure_ascii=False, indent=2)}')
|
|
500
|
+
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
def _load_task_input_from_log(self, task_name):
|
|
504
|
+
"""run.json에서 task input 로드
|
|
505
|
+
|
|
506
|
+
- task가 이미 실행된 적이 있으면: 해당 task의 input 반환
|
|
507
|
+
- task가 실행된 적이 없으면: 의존하는 task들의 output을 병합하여 반환
|
|
508
|
+
"""
|
|
509
|
+
if not os.path.exists(BaseDecorator.run_log_file):
|
|
510
|
+
raise FileNotFoundError(f'{BaseDecorator.run_log_file} 파일이 없습니다. --input-data 인자를 제공하거나 먼저 전체 workflow를 실행하세요.')
|
|
511
|
+
|
|
512
|
+
run_log = self._load_log_file()
|
|
513
|
+
|
|
514
|
+
# 해당 task가 이미 실행된 적이 있으면 그 input 사용
|
|
515
|
+
if task_name in run_log:
|
|
516
|
+
return run_log[task_name]['input']
|
|
517
|
+
|
|
518
|
+
# 실행된 적이 없으면 의존 task들의 output을 병합
|
|
519
|
+
target_task = next((task for task in self.depend if task.name == task_name), None)
|
|
520
|
+
if not target_task or not target_task.depend:
|
|
521
|
+
raise ValueError(f'Task "{task_name}"의 실행 로그가 없고 의존 task도 없습니다. --input-data를 제공하거나 먼저 필요한 task들을 실행하세요.')
|
|
522
|
+
|
|
523
|
+
# 의존 task들의 output 수집
|
|
524
|
+
merged_input = {}
|
|
525
|
+
missing_deps = []
|
|
526
|
+
|
|
527
|
+
for dep_task in target_task.depend:
|
|
528
|
+
if dep_task.name not in run_log:
|
|
529
|
+
missing_deps.append(dep_task.name)
|
|
530
|
+
else:
|
|
531
|
+
# 의존 task의 output을 병합
|
|
532
|
+
dep_output = run_log[dep_task.name]['output']
|
|
533
|
+
if isinstance(dep_output, dict):
|
|
534
|
+
merged_input.update(dep_output)
|
|
535
|
+
|
|
536
|
+
if missing_deps:
|
|
537
|
+
raise ValueError(f'Task "{task_name}"의 의존 task가 실행되지 않았습니다: {missing_deps}. 먼저 해당 task들을 실행하세요.')
|
|
538
|
+
|
|
539
|
+
return merged_input
|
|
540
|
+
|
|
541
|
+
def export_airflow(self, output_path=None, schedule_interval=None, start_date=None, default_args=None):
|
|
542
|
+
"""
|
|
543
|
+
Airflow DAG로 export
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
output_path: DAG 파일 저장 경로 (기본: dags/{dag_id}.py)
|
|
547
|
+
schedule_interval: DAG 스케줄 (기본: None)
|
|
548
|
+
start_date: DAG 시작 날짜 (기본: 오늘)
|
|
549
|
+
default_args: DAG default_args
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
str: 생성된 DAG 파일 경로
|
|
553
|
+
"""
|
|
554
|
+
exporter = AirflowExporter(self)
|
|
555
|
+
return exporter.export(
|
|
556
|
+
output_path=output_path,
|
|
557
|
+
schedule_interval=schedule_interval,
|
|
558
|
+
start_date=start_date,
|
|
559
|
+
default_args=default_args
|
|
560
|
+
)
|
codeflowhub/model.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
@dataclass
|
|
4
|
+
class Toleration:
|
|
5
|
+
key: str
|
|
6
|
+
operator: str
|
|
7
|
+
effect: str
|
|
8
|
+
value: Optional[str] = None
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class VolumeMount:
|
|
12
|
+
name: str
|
|
13
|
+
mount_path: str
|
|
14
|
+
readOnly: bool = False
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Volume:
|
|
18
|
+
name: str
|
|
19
|
+
persistent_volume_claim: str
|