yeonjae-universal-schedule-manager 1.0.1__tar.gz

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,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: yeonjae-universal-schedule-manager
3
+ Version: 1.0.1
4
+ Summary: Universal schedule manager module for job scheduling and execution
5
+ Author-email: Yeonjae <dev@example.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yeonjae/universal-modules
8
+ Project-URL: Repository, https://github.com/yeonjae/universal-modules
9
+ Project-URL: Issues, https://github.com/yeonjae/universal-modules/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Requires-Dist: croniter>=1.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
24
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
25
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
26
+ Requires-Dist: black>=23.0.0; extra == "dev"
27
+ Requires-Dist: isort>=5.12.0; extra == "dev"
28
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
29
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
30
+
31
+ # Universal schedule manager
32
+
33
+ 범용 schedule manager 모듈
34
+
35
+ ## 설치
36
+
37
+ ```bash
38
+ pip install git+https://github.com/yeonjae-work/universal-modules.git#subdirectory=packages/universal-schedule-manager
39
+ ```
@@ -0,0 +1,9 @@
1
+ # Universal schedule manager
2
+
3
+ 범용 schedule manager 모듈
4
+
5
+ ## 설치
6
+
7
+ ```bash
8
+ pip install git+https://github.com/yeonjae-work/universal-modules.git#subdirectory=packages/universal-schedule-manager
9
+ ```
@@ -0,0 +1,81 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "yeonjae-universal-schedule-manager"
7
+ version = "1.0.1"
8
+ description = "Universal schedule manager module for job scheduling and execution"
9
+ authors = [{name = "Yeonjae", email = "dev@example.com"}]
10
+ license = {text = "MIT"}
11
+ readme = "README.md"
12
+ requires-python = ">=3.9"
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ ]
23
+ dependencies = [
24
+ "pydantic>=2.0.0",
25
+ "croniter>=1.0.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=7.0.0",
31
+ "pytest-cov>=4.0.0",
32
+ "pytest-asyncio>=0.21.0",
33
+ "black>=23.0.0",
34
+ "isort>=5.12.0",
35
+ "flake8>=6.0.0",
36
+ "mypy>=1.0.0",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/yeonjae/universal-modules"
41
+ Repository = "https://github.com/yeonjae/universal-modules"
42
+ Issues = "https://github.com/yeonjae/universal-modules/issues"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.setuptools.package-data]
48
+ universal_schedule_manager = ["py.typed"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ python_files = ["test_*.py"]
53
+ python_classes = ["Test*"]
54
+ python_functions = ["test_*"]
55
+ addopts = "-v --tb=short"
56
+
57
+ [tool.coverage.run]
58
+ source = ["src/universal_schedule_manager"]
59
+ omit = ["tests/*"]
60
+
61
+ [tool.coverage.report]
62
+ exclude_lines = [
63
+ "pragma: no cover",
64
+ "def __repr__",
65
+ "raise AssertionError",
66
+ "raise NotImplementedError",
67
+ ]
68
+
69
+ [tool.mypy]
70
+ python_version = "3.9"
71
+ warn_return_any = true
72
+ warn_unused_configs = true
73
+ disallow_untyped_defs = true
74
+
75
+ [tool.black]
76
+ line-length = 100
77
+ target-version = ['py39']
78
+
79
+ [tool.isort]
80
+ profile = "black"
81
+ line_length = 100
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Universal Schedule Manager - 주기적 작업과 백그라운드 태스크를 관리하고 실행하는 범용 모듈"""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,69 @@
1
+ """
2
+ ScheduleManager 모듈의 예외 클래스
3
+
4
+ 스케줄링 작업과 관련된 모든 예외를 정의합니다.
5
+ """
6
+
7
+
8
+ class ScheduleManagerException(Exception):
9
+ """ScheduleManager 기본 예외"""
10
+ def __init__(self, message: str, details: dict = None):
11
+ self.message = message
12
+ self.details = details or {}
13
+ super().__init__(self.message)
14
+
15
+
16
+ class JobNotFoundException(ScheduleManagerException):
17
+ """작업을 찾을 수 없는 경우"""
18
+ def __init__(self, job_id: str):
19
+ message = f"Job not found: {job_id}"
20
+ super().__init__(message, {"job_id": job_id})
21
+
22
+
23
+ class SchedulerNotRunningException(ScheduleManagerException):
24
+ """스케줄러가 실행되지 않은 경우"""
25
+ def __init__(self):
26
+ message = "Scheduler is not running"
27
+ super().__init__(message)
28
+
29
+
30
+ class JobExecutionException(ScheduleManagerException):
31
+ """작업 실행 중 오류가 발생한 경우"""
32
+ def __init__(self, job_id: str, error: Exception):
33
+ message = f"Job execution failed: {job_id}"
34
+ super().__init__(message, {
35
+ "job_id": job_id,
36
+ "error_type": type(error).__name__,
37
+ "error_message": str(error)
38
+ })
39
+
40
+
41
+ class InvalidScheduleConfigException(ScheduleManagerException):
42
+ """잘못된 스케줄 설정인 경우"""
43
+ def __init__(self, config_field: str, reason: str):
44
+ message = f"Invalid schedule config - {config_field}: {reason}"
45
+ super().__init__(message, {
46
+ "config_field": config_field,
47
+ "reason": reason
48
+ })
49
+
50
+
51
+ class InvalidScheduleException(ScheduleManagerException):
52
+ """잘못된 스케줄 예외"""
53
+ def __init__(self, schedule_type: str, expression: str):
54
+ message = f"Invalid schedule type '{schedule_type}' with expression '{expression}'"
55
+ super().__init__(message, {
56
+ "schedule_type": schedule_type,
57
+ "expression": expression
58
+ })
59
+
60
+
61
+ class DataRetrievalException(ScheduleManagerException):
62
+ """데이터 조회 중 오류가 발생한 경우"""
63
+ def __init__(self, operation: str, error: Exception):
64
+ message = f"Data retrieval failed during {operation}"
65
+ super().__init__(message, {
66
+ "operation": operation,
67
+ "error_type": type(error).__name__,
68
+ "error_message": str(error)
69
+ })
@@ -0,0 +1,150 @@
1
+ """
2
+ ScheduleManager 모듈의 데이터 모델
3
+
4
+ 스케줄링 작업과 관련된 모든 데이터 구조를 정의합니다.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from typing import Dict, List, Optional, Any
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class ScheduleType(str, Enum):
14
+ """스케줄 타입"""
15
+ CRON = "cron"
16
+ INTERVAL = "interval"
17
+ ONE_TIME = "one_time"
18
+ DAILY = "daily"
19
+ WEEKLY = "weekly"
20
+ MONTHLY = "monthly"
21
+ CUSTOM = "custom"
22
+
23
+
24
+ class ScheduleStatus(str, Enum):
25
+ """스케줄 상태"""
26
+ ACTIVE = "active"
27
+ INACTIVE = "inactive"
28
+ PAUSED = "paused"
29
+
30
+
31
+ class JobStatus(str, Enum):
32
+ """작업 상태"""
33
+ SCHEDULED = "scheduled"
34
+ RUNNING = "running"
35
+ COMPLETED = "completed"
36
+ FAILED = "failed"
37
+ PAUSED = "paused"
38
+
39
+
40
+ class ScheduleConfig(BaseModel):
41
+ """스케줄 설정"""
42
+ schedule_type: ScheduleType = Field(..., description="스케줄 타입")
43
+ expression: str = Field(..., description="스케줄 표현식 (cron, interval 등)")
44
+ timezone: str = Field(default="Asia/Seoul", description="시간대 설정")
45
+ max_instances: int = Field(default=1, description="최대 동시 실행 인스턴스 수")
46
+ replace_existing: bool = Field(default=True, description="기존 작업 교체 여부")
47
+
48
+
49
+ class JobExecutionInfo(BaseModel):
50
+ """작업 실행 정보"""
51
+ timestamp: datetime = Field(..., description="실행 시간")
52
+ status: JobStatus = Field(..., description="실행 상태")
53
+ error_message: Optional[str] = Field(None, description="에러 메시지")
54
+ execution_time_seconds: Optional[float] = Field(None, description="실행 시간(초)")
55
+
56
+
57
+ class ScheduleJobInfo(BaseModel):
58
+ """스케줄 작업 정보"""
59
+ job_id: str = Field(..., description="작업 ID")
60
+ name: str = Field(..., description="작업 이름")
61
+ status: JobStatus = Field(..., description="작업 상태")
62
+ next_run_time: Optional[datetime] = Field(None, description="다음 실행 시간")
63
+ last_execution: Optional[JobExecutionInfo] = Field(None, description="마지막 실행 결과")
64
+ schedule_config: ScheduleConfig = Field(..., description="스케줄 설정")
65
+
66
+
67
+ class CommitData(BaseModel):
68
+ """커밋 데이터"""
69
+ commit_id: str = Field(..., description="커밋 ID")
70
+ message: str = Field(..., description="커밋 메시지")
71
+ author: str = Field(..., description="작성자")
72
+ timestamp: datetime = Field(..., description="커밋 시간")
73
+ repository: str = Field(..., description="저장소 이름")
74
+ branch: str = Field(..., description="브랜치명")
75
+
76
+
77
+ class DiffData(BaseModel):
78
+ """Diff 데이터"""
79
+ file_path: str = Field(..., description="파일 경로")
80
+ additions: int = Field(..., description="추가된 라인 수")
81
+ deletions: int = Field(..., description="삭제된 라인 수")
82
+ changes: str = Field(..., description="변경 내용")
83
+ complexity_score: Optional[float] = Field(None, description="복잡도 점수")
84
+ language: Optional[str] = Field(None, description="프로그래밍 언어")
85
+
86
+
87
+ class DeveloperSummary(BaseModel):
88
+ """개발자 요약 데이터"""
89
+ developer_id: str = Field(..., description="개발자 ID")
90
+ developer_name: str = Field(..., description="개발자 이름")
91
+ commits: List[CommitData] = Field(default_factory=list, description="커밋 목록")
92
+ diffs: List[DiffData] = Field(default_factory=list, description="diff 목록")
93
+ statistics: Dict[str, int] = Field(default_factory=dict, description="통계 정보")
94
+ date_range: Dict[str, str] = Field(..., description="조회 날짜 범위")
95
+
96
+
97
+ class ScheduleExecutionResult(BaseModel):
98
+ """스케줄 실행 결과"""
99
+ job_id: str = Field(..., description="작업 ID")
100
+ execution_time: datetime = Field(..., description="실행 시간")
101
+ success: bool = Field(..., description="성공 여부")
102
+ processed_developers: List[str] = Field(default_factory=list, description="처리된 개발자 목록")
103
+ error_message: Optional[str] = Field(None, description="에러 메시지")
104
+ performance_metrics: Dict[str, float] = Field(default_factory=dict, description="성능 메트릭")
105
+ total_execution_time_seconds: float = Field(..., description="총 실행 시간(초)")
106
+
107
+
108
+ class DailyReportRequest(BaseModel):
109
+ """일일 리포트 요청"""
110
+ target_date: Optional[datetime] = Field(None, description="대상 날짜 (기본값: 어제)")
111
+ include_statistics: bool = Field(default=True, description="통계 포함 여부")
112
+ developer_filters: Optional[List[str]] = Field(None, description="개발자 필터")
113
+ repository_filters: Optional[List[str]] = Field(None, description="저장소 필터")
114
+
115
+
116
+ class ScheduleRequest(BaseModel):
117
+ """스케줄 요청"""
118
+ name: str = Field(..., description="스케줄 이름")
119
+ description: str = Field(..., description="스케줄 설명")
120
+ schedule_config: ScheduleConfig = Field(..., description="스케줄 설정")
121
+ job_data: Dict[str, Any] = Field(default_factory=dict, description="작업 데이터")
122
+ enabled: bool = Field(default=True, description="활성화 여부")
123
+
124
+
125
+ class ScheduleResponse(BaseModel):
126
+ """스케줄 응답"""
127
+ schedule_id: str = Field(..., description="스케줄 ID")
128
+ name: str = Field(..., description="스케줄 이름")
129
+ status: ScheduleStatus = Field(..., description="스케줄 상태")
130
+ next_run_time: Optional[datetime] = Field(None, description="다음 실행 시간")
131
+ created_at: datetime = Field(..., description="생성 시간")
132
+ updated_at: datetime = Field(..., description="수정 시간")
133
+
134
+
135
+ class JobExecution(BaseModel):
136
+ """작업 실행"""
137
+ job_id: str = Field(..., description="작업 ID")
138
+ schedule_id: str = Field(..., description="스케줄 ID")
139
+ started_at: datetime = Field(..., description="시작 시간")
140
+ ended_at: Optional[datetime] = Field(None, description="종료 시간")
141
+ status: str = Field(..., description="실행 상태")
142
+ result: Optional[Dict[str, Any]] = Field(None, description="실행 결과")
143
+
144
+
145
+ class WeeklySummaryRequest(BaseModel):
146
+ """주간 요약 요청"""
147
+ start_date: Optional[datetime] = Field(None, description="시작 날짜")
148
+ end_date: Optional[datetime] = Field(None, description="종료 날짜")
149
+ include_trends: bool = Field(default=True, description="트렌드 분석 포함 여부")
150
+ team_grouping: bool = Field(default=False, description="팀별 그룹핑 여부")
@@ -0,0 +1,368 @@
1
+ """
2
+ ScheduleManager 서비스
3
+
4
+ 매일 오전 8시에 데이터 분석 및 알림 작업을 예약하고 실행하는 범용 스케줄러입니다.
5
+ APScheduler를 사용하여 안정적인 스케줄링을 제공하며, 스케줄 상태 모니터링 및 실패 처리를 지원합니다.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from datetime import datetime, timedelta
11
+ from typing import Dict, List, Optional, Callable, Any
12
+
13
+ try:
14
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
15
+ from apscheduler.triggers.cron import CronTrigger
16
+ from apscheduler.jobstores.memory import MemoryJobStore
17
+ from apscheduler.executors.asyncio import AsyncIOExecutor
18
+ APSCHEDULER_AVAILABLE = True
19
+ except ImportError:
20
+ APSCHEDULER_AVAILABLE = False
21
+
22
+ from .models import (
23
+ ScheduleConfig,
24
+ JobStatus,
25
+ ScheduleJobInfo,
26
+ ScheduleExecutionResult,
27
+ DeveloperSummary,
28
+ JobExecutionInfo,
29
+ DailyReportRequest,
30
+ WeeklySummaryRequest
31
+ )
32
+ from .exceptions import (
33
+ ScheduleManagerException,
34
+ JobNotFoundException,
35
+ SchedulerNotRunningException,
36
+ JobExecutionException
37
+ )
38
+
39
+
40
+ class MockScheduler:
41
+ """APScheduler가 없을 때 사용하는 Mock 스케줄러"""
42
+
43
+ def __init__(self, timezone='Asia/Seoul'):
44
+ self.timezone = timezone
45
+ self.jobs = {}
46
+ self.running = False
47
+ self.logger = logging.getLogger(__name__)
48
+
49
+ def add_job(self, func, trigger=None, id=None, name=None, **kwargs):
50
+ """Mock job 추가"""
51
+ job_info = {
52
+ 'id': id,
53
+ 'name': name,
54
+ 'func': func,
55
+ 'trigger': trigger,
56
+ 'kwargs': kwargs
57
+ }
58
+ self.jobs[id] = job_info
59
+ self.logger.info(f"Mock job added: {id}")
60
+ return job_info
61
+
62
+ def start(self):
63
+ """Mock 스케줄러 시작"""
64
+ self.running = True
65
+ self.logger.info("Mock scheduler started")
66
+
67
+ def shutdown(self, wait=True):
68
+ """Mock 스케줄러 중지"""
69
+ self.running = False
70
+ self.logger.info("Mock scheduler stopped")
71
+
72
+ def get_job(self, job_id):
73
+ """Mock job 조회"""
74
+ return self.jobs.get(job_id)
75
+
76
+ def remove_job(self, job_id):
77
+ """Mock job 제거"""
78
+ if job_id in self.jobs:
79
+ del self.jobs[job_id]
80
+ self.logger.info(f"Mock job removed: {job_id}")
81
+
82
+
83
+ class ScheduleManagerService:
84
+ """스케줄 관리 서비스"""
85
+
86
+ def __init__(self, timezone: str = "Asia/Seoul"):
87
+ """
88
+ ScheduleManagerService 초기화
89
+
90
+ Args:
91
+ timezone: 시간대 설정 (기본값: Asia/Seoul)
92
+ """
93
+ self.timezone = timezone
94
+ self.logger = logging.getLogger(__name__)
95
+ self.jobs: Dict[str, ScheduleJobInfo] = {}
96
+ self.execution_history: Dict[str, List[JobExecutionInfo]] = {}
97
+
98
+ # APScheduler 설정 또는 Mock 사용
99
+ if APSCHEDULER_AVAILABLE:
100
+ jobstores = {
101
+ 'default': MemoryJobStore()
102
+ }
103
+ executors = {
104
+ 'default': AsyncIOExecutor()
105
+ }
106
+ job_defaults = {
107
+ 'coalesce': False,
108
+ 'max_instances': 3
109
+ }
110
+
111
+ self.scheduler = AsyncIOScheduler(
112
+ jobstores=jobstores,
113
+ executors=executors,
114
+ job_defaults=job_defaults,
115
+ timezone=timezone
116
+ )
117
+ self.logger.info("APScheduler initialized")
118
+ else:
119
+ self.scheduler = MockScheduler(timezone)
120
+ self.logger.warning("APScheduler not available, using mock scheduler")
121
+
122
+ async def start(self) -> None:
123
+ """
124
+ 스케줄러 시작 및 기본 작업 등록
125
+
126
+ 메모리에 따라 매일 08:00 Asia/Seoul 시간대에 일일 요약 스케줄러를 등록합니다.
127
+ """
128
+ try:
129
+ # 스케줄러 시작
130
+ self.scheduler.start()
131
+
132
+ # 기본 일일 리포트 작업 등록 (매일 08:00 Asia/Seoul)
133
+ await self.add_daily_report_job()
134
+
135
+ self.logger.info("ScheduleManager started successfully")
136
+
137
+ except Exception as e:
138
+ self.logger.error(f"Failed to start ScheduleManager: {e}")
139
+ raise ScheduleManagerException(f"Failed to start scheduler: {str(e)}")
140
+
141
+ async def stop(self) -> None:
142
+ """스케줄러 중지"""
143
+ try:
144
+ if hasattr(self.scheduler, 'running') and self.scheduler.running:
145
+ self.scheduler.shutdown(wait=True)
146
+ self.logger.info("ScheduleManager stopped")
147
+ except Exception as e:
148
+ self.logger.error(f"Error stopping scheduler: {e}")
149
+
150
+ async def add_daily_report_job(self) -> str:
151
+ """
152
+ 일일 리포트 작업 등록 (매일 08:00 Asia/Seoul)
153
+
154
+ Returns:
155
+ str: 등록된 작업 ID
156
+ """
157
+ config = ScheduleConfig(
158
+ schedule_time="0 8 * * *", # 매일 오전 8시
159
+ job_id="daily_developer_report",
160
+ function_name="trigger_daily_report",
161
+ timezone="Asia/Seoul"
162
+ )
163
+
164
+ return await self.add_job(config, self.trigger_daily_report)
165
+
166
+ async def add_job(
167
+ self,
168
+ config: ScheduleConfig,
169
+ func: Callable,
170
+ **kwargs
171
+ ) -> str:
172
+ """
173
+ 새로운 스케줄 작업 추가
174
+
175
+ Args:
176
+ config: 스케줄 설정
177
+ func: 실행할 함수
178
+ **kwargs: 추가 인자
179
+
180
+ Returns:
181
+ str: 등록된 작업 ID
182
+ """
183
+ try:
184
+ # 설정 검증
185
+ self._validate_schedule_config(config)
186
+
187
+ # APScheduler에 작업 등록
188
+ if APSCHEDULER_AVAILABLE:
189
+ job = self.scheduler.add_job(
190
+ func=func,
191
+ trigger=CronTrigger.from_crontab(config.schedule_time, timezone=config.timezone),
192
+ id=config.job_id,
193
+ name=f"{config.function_name}_{config.job_id}",
194
+ replace_existing=config.replace_existing,
195
+ max_instances=config.max_instances,
196
+ **kwargs
197
+ )
198
+ next_run_time = job.next_run_time
199
+ else:
200
+ # Mock 스케줄러 사용
201
+ job = self.scheduler.add_job(
202
+ func=func,
203
+ trigger=config.schedule_time,
204
+ id=config.job_id,
205
+ name=f"{config.function_name}_{config.job_id}",
206
+ **kwargs
207
+ )
208
+ next_run_time = None
209
+
210
+ # 작업 정보 저장
211
+ job_info = ScheduleJobInfo(
212
+ job_id=config.job_id,
213
+ name=f"{config.function_name}_{config.job_id}",
214
+ status=JobStatus.SCHEDULED,
215
+ next_run_time=next_run_time,
216
+ schedule_config=config
217
+ )
218
+
219
+ self.jobs[config.job_id] = job_info
220
+ self.execution_history[config.job_id] = []
221
+
222
+ self.logger.info(f"Job added successfully: {config.job_id}")
223
+ return config.job_id
224
+
225
+ except Exception as e:
226
+ self.logger.error(f"Failed to add job {config.job_id}: {e}")
227
+ raise ScheduleManagerException(f"Failed to add job: {str(e)}")
228
+
229
+ def get_job_status(self, job_id: str) -> ScheduleJobInfo:
230
+ """
231
+ 작업 상태 조회
232
+
233
+ Args:
234
+ job_id: 작업 ID
235
+
236
+ Returns:
237
+ ScheduleJobInfo: 작업 정보
238
+ """
239
+ if job_id not in self.jobs:
240
+ raise JobNotFoundException(job_id)
241
+
242
+ return self.jobs[job_id]
243
+
244
+ def list_jobs(self) -> List[ScheduleJobInfo]:
245
+ """
246
+ 모든 작업 목록 조회
247
+
248
+ Returns:
249
+ List[ScheduleJobInfo]: 작업 목록
250
+ """
251
+ return list(self.jobs.values())
252
+
253
+ async def trigger_daily_report(self) -> ScheduleExecutionResult:
254
+ """
255
+ 일일 개발자 활동 리포트 생성
256
+
257
+ Returns:
258
+ ScheduleExecutionResult: 실행 결과
259
+ """
260
+ job_id = "daily_developer_report"
261
+ start_time = datetime.now()
262
+
263
+ try:
264
+ self.logger.info("Starting daily report generation")
265
+
266
+ # 작업 상태 업데이트
267
+ if job_id in self.jobs:
268
+ self.jobs[job_id].status = JobStatus.RUNNING
269
+
270
+ # 어제 날짜 범위 계산
271
+ yesterday = datetime.now() - timedelta(days=1)
272
+ date_range = {
273
+ "start": yesterday.replace(hour=0, minute=0, second=0).isoformat(),
274
+ "end": yesterday.replace(hour=23, minute=59, second=59).isoformat()
275
+ }
276
+
277
+ # DataRetriever를 통한 데이터 조회는 실제 구현 시 추가
278
+ # 현재는 기본 구조만 구현
279
+ processed_developers = []
280
+
281
+ # 실행 시간 계산
282
+ execution_time = (datetime.now() - start_time).total_seconds()
283
+
284
+ # 실행 결과 생성
285
+ result = ScheduleExecutionResult(
286
+ job_id=job_id,
287
+ execution_time=start_time,
288
+ success=True,
289
+ processed_developers=processed_developers,
290
+ performance_metrics={
291
+ "execution_time_seconds": execution_time,
292
+ "processed_count": len(processed_developers)
293
+ },
294
+ total_execution_time_seconds=execution_time
295
+ )
296
+
297
+ # 실행 기록 저장
298
+ execution_info = JobExecutionInfo(
299
+ timestamp=start_time,
300
+ status=JobStatus.COMPLETED,
301
+ execution_time_seconds=execution_time
302
+ )
303
+
304
+ if job_id in self.execution_history:
305
+ self.execution_history[job_id].append(execution_info)
306
+
307
+ # 작업 상태 업데이트
308
+ if job_id in self.jobs:
309
+ self.jobs[job_id].status = JobStatus.COMPLETED
310
+ self.jobs[job_id].last_execution = execution_info
311
+
312
+ self.logger.info(f"Daily report completed in {execution_time:.2f}s")
313
+ return result
314
+
315
+ except Exception as e:
316
+ # 실행 실패 처리
317
+ execution_time = (datetime.now() - start_time).total_seconds()
318
+
319
+ execution_info = JobExecutionInfo(
320
+ timestamp=start_time,
321
+ status=JobStatus.FAILED,
322
+ error_message=str(e),
323
+ execution_time_seconds=execution_time
324
+ )
325
+
326
+ if job_id in self.execution_history:
327
+ self.execution_history[job_id].append(execution_info)
328
+
329
+ if job_id in self.jobs:
330
+ self.jobs[job_id].status = JobStatus.FAILED
331
+ self.jobs[job_id].last_execution = execution_info
332
+
333
+ self.logger.error(f"Daily report generation failed: {e}")
334
+
335
+ return ScheduleExecutionResult(
336
+ job_id=job_id,
337
+ execution_time=start_time,
338
+ success=False,
339
+ error_message=str(e),
340
+ total_execution_time_seconds=execution_time
341
+ )
342
+
343
+ def _validate_schedule_config(self, config: ScheduleConfig) -> None:
344
+ """
345
+ 스케줄 설정 검증
346
+
347
+ Args:
348
+ config: 스케줄 설정
349
+
350
+ Raises:
351
+ InvalidScheduleConfigException: 잘못된 설정인 경우
352
+ """
353
+ if not config.job_id:
354
+ raise InvalidScheduleConfigException("job_id", "Job ID is required")
355
+
356
+ if not config.function_name:
357
+ raise InvalidScheduleConfigException("function_name", "Function name is required")
358
+
359
+ if not config.schedule_time:
360
+ raise InvalidScheduleConfigException("schedule_time", "Schedule time is required")
361
+
362
+ # Cron 표현식 기본 검증 (5개 필드)
363
+ cron_parts = config.schedule_time.split()
364
+ if len(cron_parts) != 5:
365
+ raise InvalidScheduleConfigException(
366
+ "schedule_time",
367
+ "Invalid cron format. Expected 5 fields: minute hour day month day_of_week"
368
+ )
@@ -0,0 +1,330 @@
1
+ """Celery tasks for schedule manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime, timedelta
7
+ from typing import Dict, Any
8
+
9
+ from celery import shared_task
10
+
11
+ from modules.data_retriever.service import DataRetrieverService
12
+ from universal_llm_service import LLMService, LLMInput, LLMProvider, ModelConfig
13
+ from modules.data_aggregator.service import DataAggregatorService
14
+ from modules.prompt_builder.service import PromptBuilderService
15
+ from universal_notification_service import NotificationService
16
+ # Simplified logging for standalone operation
17
+ def log_module_io(module_name: str, operation: str, data: dict):
18
+ pass
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @shared_task(name="schedule_manager.daily_summary_task")
24
+ def daily_summary_task() -> Dict[str, Any]:
25
+ """
26
+ 매일 오전 8시에 실행되는 일일 개발자 활동 요약 작업
27
+
28
+ 기획서 준수 흐름:
29
+ ScheduleManager → DataRetriever → DataAggregator → PromptBuilder → LLMService → NotificationService
30
+
31
+ Returns:
32
+ Dict[str, Any]: 실행 결과
33
+ """
34
+ start_time = datetime.now()
35
+
36
+ try:
37
+ logger.info("🌅 Starting daily developer summary at 08:00 Asia/Seoul")
38
+
39
+ # 어제 날짜 범위 계산 (Asia/Seoul 기준)
40
+ from zoneinfo import ZoneInfo
41
+ seoul_tz = ZoneInfo("Asia/Seoul")
42
+ now_seoul = datetime.now(seoul_tz)
43
+ yesterday_start = (now_seoul - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
44
+ yesterday_end = (now_seoul - timedelta(days=1)).replace(hour=23, minute=59, second=59, microsecond=999999)
45
+
46
+ logger.info(f"📅 Processing data for: {yesterday_start.strftime('%Y-%m-%d')} (Asia/Seoul)")
47
+
48
+ # 1️⃣ DataRetriever: 어제 데이터 조회
49
+ logger.info("📊 DataRetriever: Starting data retrieval")
50
+
51
+ log_module_io("DataRetriever", "INPUT", metadata={
52
+ "operation": "get_daily_summary",
53
+ "target_date": yesterday_start.isoformat(),
54
+ "timezone": "Asia/Seoul"
55
+ })
56
+
57
+ data_retriever = DataRetrieverService()
58
+ retrieved_data = data_retriever.get_daily_summary(target_date=yesterday_start)
59
+
60
+ log_module_io("DataRetriever", "OUTPUT", output_data=retrieved_data, metadata={
61
+ "commits_found": len(retrieved_data.commits),
62
+ "developers_count": len(set(commit.author for commit in retrieved_data.commits)),
63
+ "query_time": retrieved_data.metadata.query_time_seconds if retrieved_data.metadata else 0
64
+ })
65
+
66
+ logger.info(
67
+ "✅ DataRetriever: Found %d records from daily summary",
68
+ len(retrieved_data.commits)
69
+ )
70
+
71
+ # 데이터가 없으면 종료
72
+ if not retrieved_data.commits:
73
+ logger.info("📭 No data found for yesterday. Skipping daily summary.")
74
+ return {
75
+ "success": True,
76
+ "message": "No data found for yesterday",
77
+ "execution_time_seconds": (datetime.now() - start_time).total_seconds()
78
+ }
79
+
80
+ # 2️⃣ DataAggregator: 개발자별 활동 집계
81
+ logger.info("🔢 DataAggregator: Starting data aggregation")
82
+
83
+ # 데이터를 AggregationInput 형태로 변환
84
+ from modules.data_aggregator.models import AggregationInput, CommitData, DiffInfo, DateRange
85
+
86
+ commits = []
87
+ for commit_info in retrieved_data.commits:
88
+ # CommitInfo를 CommitData로 변환
89
+ commit_data = CommitData(
90
+ commit_id=commit_info.commit_hash,
91
+ author=commit_info.author,
92
+ author_email=commit_info.author_email,
93
+ timestamp=commit_info.timestamp,
94
+ message=commit_info.message,
95
+ repository=commit_info.repository,
96
+ branch=commit_info.branch,
97
+ diff_info=[] # 기본값
98
+ )
99
+ commits.append(commit_data)
100
+
101
+ # DateRange를 문자열 형식으로 생성
102
+ date_range = DateRange(
103
+ start=yesterday_start.strftime('%Y-%m-%d'),
104
+ end=yesterday_end.strftime('%Y-%m-%d')
105
+ )
106
+
107
+ aggregation_input = AggregationInput(
108
+ commits=commits,
109
+ date_range=date_range
110
+ )
111
+
112
+ log_module_io("DataAggregator", "INPUT", input_data=aggregation_input, metadata={
113
+ "operation": "aggregate_data",
114
+ "input_commits_count": len(commits)
115
+ })
116
+
117
+ data_aggregator = DataAggregatorService()
118
+ # async 메서드를 동기적으로 실행
119
+ import asyncio
120
+ try:
121
+ # 이미 이벤트 루프가 실행 중인 경우 새 루프 생성
122
+ loop = asyncio.get_event_loop()
123
+ if loop.is_running():
124
+ import concurrent.futures
125
+ with concurrent.futures.ThreadPoolExecutor() as executor:
126
+ future = executor.submit(asyncio.run, data_aggregator.aggregate_data(aggregation_input))
127
+ aggregated_data = future.result()
128
+ else:
129
+ aggregated_data = asyncio.run(data_aggregator.aggregate_data(aggregation_input))
130
+ except RuntimeError:
131
+ # 이벤트 루프가 없는 경우
132
+ aggregated_data = asyncio.run(data_aggregator.aggregate_data(aggregation_input))
133
+
134
+ log_module_io("DataAggregator", "OUTPUT", output_data=aggregated_data, metadata={
135
+ "developers_processed": len(aggregated_data.developer_stats),
136
+ "repositories_processed": len(aggregated_data.repository_stats),
137
+ "time_analysis_completed": aggregated_data.time_analysis is not None,
138
+ "complexity_metrics_completed": aggregated_data.complexity_metrics is not None
139
+ })
140
+
141
+ logger.info(
142
+ "✅ DataAggregator: Processed %d developers, %d repositories",
143
+ len(aggregated_data.developer_stats),
144
+ len(aggregated_data.repository_stats)
145
+ )
146
+
147
+ # 3️⃣ PromptBuilder: LLM용 프롬프트 생성
148
+ logger.info("📝 PromptBuilder: Starting prompt generation")
149
+
150
+ log_module_io("PromptBuilder", "INPUT", input_data=aggregated_data, metadata={
151
+ "operation": "build_daily_summary_prompt",
152
+ "template_type": "daily_developer_summary",
153
+ "language": "korean"
154
+ })
155
+
156
+ prompt_builder = PromptBuilderService()
157
+
158
+ # 전체 개발자 목록에서 첫 번째 개발자를 대상으로 하거나,
159
+ # 개발자가 없으면 "전체 팀"으로 설정
160
+ if aggregated_data.developer_stats:
161
+ # 첫 번째 개발자 선택
162
+ first_developer = list(aggregated_data.developer_stats.values())[0]
163
+ target_developer = first_developer.developer
164
+ else:
165
+ target_developer = "개발팀"
166
+
167
+ # AggregationResult를 딕셔너리로 변환
168
+ aggregated_dict = {
169
+ "developer_stats": {
170
+ email: {
171
+ "developer": stats.developer,
172
+ "commit_count": stats.commit_count,
173
+ "lines_added": stats.lines_added,
174
+ "lines_deleted": stats.lines_deleted,
175
+ "files_changed": stats.files_changed,
176
+ "languages_used": stats.languages_used,
177
+ "avg_complexity": stats.avg_complexity
178
+ }
179
+ for email, stats in aggregated_data.developer_stats.items()
180
+ }
181
+ }
182
+
183
+ prompt_result = prompt_builder.build_daily_summary_prompt(aggregated_dict, target_developer)
184
+
185
+ log_module_io("PromptBuilder", "OUTPUT", output_data=prompt_result, metadata={
186
+ "prompt_length": len(prompt_result.prompt),
187
+ "template_used": prompt_result.template_used,
188
+ "variables_count": len(prompt_result.context_data) if hasattr(prompt_result, 'context_data') else 0
189
+ })
190
+
191
+ logger.info(
192
+ "✅ PromptBuilder: Generated prompt (%d chars) using template '%s'",
193
+ len(prompt_result.prompt),
194
+ prompt_result.template_used
195
+ )
196
+
197
+ # 4️⃣ LLMService: 코드 분석 및 요약 생성
198
+ logger.info("🤖 LLMService: Starting LLM analysis")
199
+
200
+ log_module_io("LLMService", "INPUT", input_data=prompt_result, metadata={
201
+ "operation": "generate_summary",
202
+ "prompt_length": len(prompt_result.prompt),
203
+ "provider": "openai" # 기본값
204
+ })
205
+
206
+ llm_service = LLMService()
207
+
208
+ # LLMInput 객체 생성
209
+ model_config = ModelConfig(
210
+ model="gpt-3.5-turbo", # 기본 모델
211
+ temperature=0.7,
212
+ max_tokens=1000
213
+ )
214
+
215
+ llm_input = LLMInput(
216
+ prompt=prompt_result.prompt,
217
+ llm_provider=LLMProvider.OPENAI,
218
+ model_config=model_config
219
+ )
220
+
221
+ # async 메서드를 동기적으로 실행
222
+ try:
223
+ # 이미 이벤트 루프가 실행 중인 경우 새 루프 생성
224
+ loop = asyncio.get_event_loop()
225
+ if loop.is_running():
226
+ import concurrent.futures
227
+ with concurrent.futures.ThreadPoolExecutor() as executor:
228
+ future = executor.submit(asyncio.run, llm_service.generate_summary(llm_input))
229
+ llm_response = future.result()
230
+ else:
231
+ llm_response = asyncio.run(llm_service.generate_summary(llm_input))
232
+ except RuntimeError:
233
+ # 이벤트 루프가 없는 경우
234
+ llm_response = asyncio.run(llm_service.generate_summary(llm_input))
235
+
236
+ log_module_io("LLMService", "OUTPUT", output_data=llm_response, metadata={
237
+ "response_length": len(llm_response.summary),
238
+ "confidence_score": llm_response.confidence_score,
239
+ "provider_used": llm_response.metadata.provider if llm_response.metadata else "unknown",
240
+ "model_used": llm_response.metadata.model_used if llm_response.metadata else "unknown"
241
+ })
242
+
243
+ logger.info(
244
+ "✅ LLMService: Generated summary (%d chars) using %s/%s",
245
+ len(llm_response.summary),
246
+ llm_response.metadata.provider if llm_response.metadata else "unknown",
247
+ llm_response.metadata.model_used if llm_response.metadata else "unknown"
248
+ )
249
+
250
+ # 5️⃣ NotificationService: Slack 알림 전송
251
+ logger.info("📢 NotificationService: Starting notification delivery")
252
+
253
+ # 알림 메시지 구성
254
+ notification_message = f"""🌅 **일일 개발 활동 요약** - {yesterday_start.strftime('%Y년 %m월 %d일')}
255
+
256
+ {llm_response.summary}
257
+
258
+ ---
259
+ 📊 **전체 통계**
260
+ • 개발자: {len(aggregated_data.developer_stats)}명
261
+ • 저장소: {len(aggregated_data.repository_stats)}개
262
+ • 처리된 커밋: {len(commits)}개
263
+
264
+ _CodePing.AI 자동 생성 리포트_"""
265
+
266
+ log_module_io("NotificationService", "INPUT", metadata={
267
+ "operation": "send_slack_message",
268
+ "message_length": len(notification_message),
269
+ "channel": "default"
270
+ })
271
+
272
+ notification_service = NotificationService()
273
+ notification_result = notification_service.send_daily_summary(
274
+ summary_report=notification_message,
275
+ developer=target_developer,
276
+ developer_email="team@codeping.ai", # 기본 이메일
277
+ slack_channel="#dev-reports" # 기본 채널
278
+ )
279
+
280
+ log_module_io("NotificationService", "OUTPUT", output_data=notification_result, metadata={
281
+ "success": notification_result.success,
282
+ "channel_used": notification_result.channel,
283
+ "delivery_time": notification_result.timestamp.isoformat() if notification_result.timestamp else None
284
+ })
285
+
286
+ if notification_result.success:
287
+ logger.info(
288
+ "✅ NotificationService: Message sent successfully to %s",
289
+ notification_result.channel
290
+ )
291
+ else:
292
+ logger.error(
293
+ "❌ NotificationService: Failed to send message: %s",
294
+ notification_result.error_message
295
+ )
296
+
297
+ # 실행 완료
298
+ total_execution_time = (datetime.now() - start_time).total_seconds()
299
+
300
+ result = {
301
+ "success": True,
302
+ "date_processed": yesterday_start.strftime('%Y-%m-%d'),
303
+ "developers_count": len(aggregated_data.developer_stats),
304
+ "commits_count": len(commits),
305
+ "notification_sent": notification_result.success,
306
+ "execution_time_seconds": total_execution_time,
307
+ "modules_executed": ["DataRetriever", "DataAggregator", "PromptBuilder", "LLMService", "NotificationService"]
308
+ }
309
+
310
+ logger.info(
311
+ "🎉 Daily summary completed successfully in %.2fs: %d developers, %d commits, notification=%s",
312
+ total_execution_time,
313
+ len(aggregated_data.developer_stats),
314
+ len(commits),
315
+ "✅" if notification_result.success else "❌"
316
+ )
317
+
318
+ return result
319
+
320
+ except Exception as exc:
321
+ total_execution_time = (datetime.now() - start_time).total_seconds()
322
+
323
+ logger.exception("❌ Daily summary task failed: %s", exc)
324
+
325
+ return {
326
+ "success": False,
327
+ "error": str(exc),
328
+ "error_type": type(exc).__name__,
329
+ "execution_time_seconds": total_execution_time
330
+ }
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: yeonjae-universal-schedule-manager
3
+ Version: 1.0.1
4
+ Summary: Universal schedule manager module for job scheduling and execution
5
+ Author-email: Yeonjae <dev@example.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yeonjae/universal-modules
8
+ Project-URL: Repository, https://github.com/yeonjae/universal-modules
9
+ Project-URL: Issues, https://github.com/yeonjae/universal-modules/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Requires-Dist: croniter>=1.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
24
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
25
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
26
+ Requires-Dist: black>=23.0.0; extra == "dev"
27
+ Requires-Dist: isort>=5.12.0; extra == "dev"
28
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
29
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
30
+
31
+ # Universal schedule manager
32
+
33
+ 범용 schedule manager 모듈
34
+
35
+ ## 설치
36
+
37
+ ```bash
38
+ pip install git+https://github.com/yeonjae-work/universal-modules.git#subdirectory=packages/universal-schedule-manager
39
+ ```
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/universal_schedule_manager/__init__.py
4
+ src/universal_schedule_manager/exceptions.py
5
+ src/universal_schedule_manager/models.py
6
+ src/universal_schedule_manager/service.py
7
+ src/universal_schedule_manager/tasks.py
8
+ src/yeonjae_universal_schedule_manager.egg-info/PKG-INFO
9
+ src/yeonjae_universal_schedule_manager.egg-info/SOURCES.txt
10
+ src/yeonjae_universal_schedule_manager.egg-info/dependency_links.txt
11
+ src/yeonjae_universal_schedule_manager.egg-info/requires.txt
12
+ src/yeonjae_universal_schedule_manager.egg-info/top_level.txt
13
+ tests/test_basic.py
@@ -0,0 +1,11 @@
1
+ pydantic>=2.0.0
2
+ croniter>=1.0.0
3
+
4
+ [dev]
5
+ pytest>=7.0.0
6
+ pytest-cov>=4.0.0
7
+ pytest-asyncio>=0.21.0
8
+ black>=23.0.0
9
+ isort>=5.12.0
10
+ flake8>=6.0.0
11
+ mypy>=1.0.0
@@ -0,0 +1,98 @@
1
+ """
2
+ Universal Schedule Manager 기본 테스트
3
+
4
+ 모듈의 기본 임포트와 구조를 테스트합니다.
5
+ """
6
+
7
+ import pytest
8
+ from datetime import datetime, timedelta
9
+
10
+
11
+ def test_models_import():
12
+ """모델 임포트 테스트"""
13
+ from universal_schedule_manager.models import (
14
+ ScheduleType, ScheduleStatus, ScheduleConfig,
15
+ ScheduleRequest, ScheduleResponse, JobExecution
16
+ )
17
+
18
+ # 열거형 값 확인
19
+ assert ScheduleType.CRON == "cron"
20
+ assert ScheduleType.INTERVAL == "interval"
21
+ assert ScheduleType.ONE_TIME == "one_time"
22
+ assert ScheduleStatus.ACTIVE == "active"
23
+ assert ScheduleStatus.INACTIVE == "inactive"
24
+
25
+
26
+ def test_exceptions_import():
27
+ """예외 클래스 임포트 테스트"""
28
+ from universal_schedule_manager.exceptions import (
29
+ ScheduleManagerException,
30
+ InvalidScheduleException,
31
+ JobExecutionException
32
+ )
33
+
34
+ # 예외 클래스 확인
35
+ assert issubclass(InvalidScheduleException, ScheduleManagerException)
36
+ assert issubclass(JobExecutionException, ScheduleManagerException)
37
+
38
+
39
+ def test_schedule_config_creation():
40
+ """ScheduleConfig 생성 테스트"""
41
+ from universal_schedule_manager.models import ScheduleConfig, ScheduleType
42
+
43
+ config = ScheduleConfig(
44
+ schedule_type=ScheduleType.CRON,
45
+ expression="0 9 * * 1-5",
46
+ timezone="UTC"
47
+ )
48
+
49
+ assert config.schedule_type == ScheduleType.CRON
50
+ assert config.expression == "0 9 * * 1-5"
51
+ assert config.timezone == "UTC"
52
+
53
+
54
+ def test_schedule_request_creation():
55
+ """ScheduleRequest 생성 테스트"""
56
+ from universal_schedule_manager.models import ScheduleRequest, ScheduleType, ScheduleConfig
57
+
58
+ request = ScheduleRequest(
59
+ name="daily_report",
60
+ description="Generate daily development report",
61
+ schedule_config=ScheduleConfig(
62
+ schedule_type=ScheduleType.CRON,
63
+ expression="0 9 * * *"
64
+ ),
65
+ job_data={"report_type": "daily"}
66
+ )
67
+
68
+ assert request.name == "daily_report"
69
+ assert request.description == "Generate daily development report"
70
+ assert request.schedule_config.expression == "0 9 * * *"
71
+ assert request.job_data["report_type"] == "daily"
72
+
73
+
74
+ def test_job_execution_creation():
75
+ """JobExecution 생성 테스트"""
76
+ from universal_schedule_manager.models import JobExecution
77
+
78
+ execution = JobExecution(
79
+ job_id="job_123",
80
+ schedule_id="schedule_456",
81
+ started_at=datetime.now(),
82
+ status="running"
83
+ )
84
+
85
+ assert execution.job_id == "job_123"
86
+ assert execution.schedule_id == "schedule_456"
87
+ assert execution.status == "running"
88
+
89
+
90
+ def test_exception_creation():
91
+ """예외 생성 테스트"""
92
+ from universal_schedule_manager.exceptions import InvalidScheduleException
93
+
94
+ exception = InvalidScheduleException("invalid_cron", "0 25 * * *")
95
+
96
+ assert "invalid_cron" in str(exception)
97
+ assert exception.details["schedule_type"] == "invalid_cron"
98
+ assert exception.details["expression"] == "0 25 * * *"