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.
@@ -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
@@ -0,0 +1,5 @@
1
+
2
+ class Action:
3
+
4
+ def __init__(self, type):
5
+ self.type = type
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
@@ -0,0 +1,3 @@
1
+ from .airflow_exporter import AirflowExporter
2
+
3
+ __all__ = ['AirflowExporter']