schd 0.0.10__tar.gz → 0.0.13__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.
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: schd
3
- Version: 0.0.10
3
+ Version: 0.0.13
4
4
  Home-page: https://github.com/kevenli/schd
5
5
  License: ApacheV2
6
6
  Requires-Dist: apscheduler<4.0
7
7
  Requires-Dist: pyaml
8
+ Requires-Dist: aiohttp
@@ -20,6 +20,24 @@ start a daemon
20
20
  schd -c conf/schd.yaml
21
21
  ```
22
22
 
23
+ ## local scheduler
24
+ default
25
+
26
+ conf/schd.yaml
27
+ ```
28
+ scheduler_cls: LocalScheduler
29
+ ```
30
+
31
+ ## remote scheduler
32
+ schedule by RemoteScheduler (schd-server)
33
+
34
+ conf/schd.yaml
35
+ ```
36
+ scheduler_cls: RemoteScheduler
37
+ scheduler_remote_host: http://localhost:8899/
38
+ worker_name: local
39
+ ```
40
+
23
41
 
24
42
  # Email Notifier
25
43
 
@@ -0,0 +1 @@
1
+ __version__ = '0.0.12'
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import logging
2
3
  import sys
3
4
  from .base import CommandBase
@@ -22,4 +23,4 @@ class DaemonCommand(CommandBase):
22
23
  log_stream = sys.stdout
23
24
 
24
25
  logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S', stream=log_stream)
25
- run_daemon(config_file)
26
+ asyncio.run(run_daemon(config_file))
@@ -0,0 +1,101 @@
1
+ from dataclasses import dataclass, field, fields, is_dataclass
2
+ import os
3
+ from typing import Any, Dict, Optional, Type, TypeVar, Union, get_args, get_origin, get_type_hints
4
+ import yaml
5
+
6
+ T = TypeVar("T", bound="ConfigValue")
7
+
8
+
9
+ class ConfigValue:
10
+ """
11
+ ConfigValue present some config settings.
12
+ A configvalue class should also be decorated as @dataclass.
13
+ A ConfigValue class contains some fields, for example:
14
+
15
+ @dataclass
16
+ class SimpleIntValue(ConfigValue):
17
+ a: int
18
+
19
+ User can call derived class 's from_dict class method to construct an instance.
20
+ config = SimpleIntValue.from_dict({'a': 1})
21
+ """
22
+ @classmethod
23
+ def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
24
+ """
25
+ Creates an instance of the class using the fields specified in the dictionary.
26
+ Handles nested fields that are also derived from ConfigValue.
27
+ """
28
+ type_hints = get_type_hints(cls)
29
+ init_data:Dict[str,Any] = {}
30
+ if not is_dataclass(cls):
31
+ raise TypeError('class %s is not dataclass' % cls)
32
+
33
+ for f in fields(cls):
34
+ field_name = f.name
35
+ json_key = f.metadata.get("json", f.name)
36
+ field_type = type_hints[field_name]
37
+ origin = get_origin(field_type)
38
+ args = get_args(field_type)
39
+
40
+ if json_key in data:
41
+ value = data[json_key]
42
+ # Handle nested ConfigValue objects
43
+ if isinstance(field_type, type) and issubclass(field_type, ConfigValue):
44
+ init_data[field_name] = field_type.from_dict(value)
45
+ # Handle lists of ConfigValue objects List[ConfigValue]
46
+ elif origin is list and issubclass(args[0], ConfigValue):
47
+ nested_type = field_type.__args__[0]
48
+ init_data[field_name] = [nested_type.from_dict(item) for item in value]
49
+ # Handle Optional[ConfigValue]
50
+ elif origin is Union and type(None) in args:
51
+ actual_type = next((arg for arg in args if arg is not type(None)), None)
52
+ if actual_type and issubclass(actual_type, ConfigValue):
53
+ init_data[field_name] = actual_type.from_dict(value) if value is not None else None
54
+ else:
55
+ init_data[field_name] = value
56
+ # Case 4: Dict[str, ConfigValue]
57
+ elif origin is dict and issubclass(args[1], ConfigValue):
58
+ value_type = args[1]
59
+ init_data[field_name] = {
60
+ k: value_type.from_dict(v) for k, v in value.items()
61
+ }
62
+ else:
63
+ init_data[field_name] = value
64
+ return cls(**init_data)
65
+
66
+
67
+ @dataclass
68
+ class JobConfig(ConfigValue):
69
+ cls: str = field(metadata={"json": "class"})
70
+ cron: str
71
+ cmd: Optional[str] = None
72
+ params: dict = field(default_factory=dict)
73
+ timezone: Optional[str] = None
74
+ queue: str = ''
75
+
76
+
77
+ @dataclass
78
+ class SchdConfig(ConfigValue):
79
+ jobs: Dict[str, JobConfig] = field(default_factory=dict)
80
+ scheduler_cls: str = 'LocalScheduler'
81
+ scheduler_remote_host: Optional[str] = None
82
+ worker_name: str = 'local'
83
+
84
+ def __getitem__(self,key):
85
+ # compatible to old fashion config['key']
86
+ if hasattr(self, key):
87
+ return getattr(self,key)
88
+ else:
89
+ raise KeyError(key)
90
+
91
+
92
+ def read_config(config_file=None) -> SchdConfig:
93
+ if config_file is None and 'SCHD_CONFIG' in os.environ:
94
+ config_file = os.environ['SCHD_CONFIG']
95
+
96
+ if config_file is None:
97
+ config_file = 'conf/schd.yaml'
98
+
99
+ with open(config_file, 'r', encoding='utf8') as f:
100
+ config = SchdConfig.from_dict(yaml.load(f, Loader=yaml.FullLoader))
101
+ return config
@@ -0,0 +1,25 @@
1
+ from typing import Protocol, Union
2
+
3
+
4
+ class JobExecutionResult(Protocol):
5
+ def get_code(self) -> int:...
6
+
7
+
8
+ class JobContext:
9
+ def __init__(self, job_name:str, logger=None, stdout=None, stderr=None):
10
+ self.job_name = job_name
11
+ self.logger = logger
12
+ self.output_to_console = False
13
+ self.stdout = stdout
14
+ self.stderr = stderr
15
+
16
+
17
+ class Job(Protocol):
18
+ """
19
+ Protocol to represent a job structure.
20
+ """
21
+ def execute(self, context:JobContext) -> Union[JobExecutionResult, int, None]:
22
+ """
23
+ execute the job
24
+ """
25
+ pass
@@ -1,9 +1,12 @@
1
1
  import argparse
2
+ import asyncio
3
+ from contextlib import redirect_stdout
2
4
  import logging
3
5
  import importlib
6
+ import io
4
7
  import os
5
8
  import sys
6
- from typing import Any
9
+ from typing import Any, Optional, Dict
7
10
  import smtplib
8
11
  from email.mime.text import MIMEText
9
12
  from email.header import Header
@@ -12,33 +15,41 @@ import tempfile
12
15
  from apscheduler.schedulers.blocking import BlockingScheduler
13
16
  from apscheduler.triggers.cron import CronTrigger
14
17
  from apscheduler.executors.pool import ThreadPoolExecutor
15
- import yaml
16
18
  from schd import __version__ as schd_version
19
+ from schd.schedulers.remote import RemoteScheduler
17
20
  from schd.util import ensure_bool
18
-
21
+ from schd.job import Job, JobContext, JobExecutionResult
22
+ from schd.config import JobConfig, SchdConfig, read_config
19
23
 
20
24
  logger = logging.getLogger(__name__)
21
25
 
22
26
 
23
- def build_job(job_name, job_class_name, config):
24
- if not '.' in job_class_name:
27
+ class DefaultJobExecutionResult(JobExecutionResult):
28
+ def __init__(self, code:int, log:str):
29
+ self.code = code
30
+ self.log = log
31
+
32
+
33
+ def build_job(job_name, job_class_name, config:JobConfig)->Job:
34
+ if not ':' in job_class_name:
25
35
  module = sys.modules[__name__]
26
36
  job_cls = getattr(module, job_class_name)
27
37
  else:
28
- module_name, cls_name = job_class_name.rsplit('.', 1)
38
+ # format "packagea.moduleb:ClassC"
39
+ module_name, cls_name = job_class_name.rsplit(':', 1)
29
40
  m = importlib.import_module(module_name)
30
41
  job_cls = getattr(m, cls_name)
31
42
 
32
43
  if hasattr(job_cls, 'from_settings'):
33
44
  job = job_cls.from_settings(job_name=job_name, config=config)
34
45
  else:
35
- job = job_cls(**config)
46
+ job = job_cls(**config.params)
36
47
 
37
48
  return job
38
49
 
39
50
 
40
51
  class JobFailedException(Exception):
41
- def __init__(self, job_name, error_message, inner_ex:"Exception"=None):
52
+ def __init__(self, job_name, error_message, inner_ex:"Optional[Exception]"=None):
42
53
  self.job_name = job_name
43
54
  self.error_message = error_message
44
55
  self.inner_ex = inner_ex
@@ -51,12 +62,6 @@ class CommandJobFailedException(JobFailedException):
51
62
  self.output = output
52
63
 
53
64
 
54
- class JobContext:
55
- def __init__(self, job_name):
56
- self.job_name = job_name
57
- self.output_to_console = False
58
-
59
-
60
65
  class CommandJob:
61
66
  def __init__(self, cmd, job_name=None):
62
67
  self.cmd = cmd
@@ -65,9 +70,30 @@ class CommandJob:
65
70
 
66
71
  @classmethod
67
72
  def from_settings(cls, job_name=None, config=None, **kwargs):
68
- return cls(cmd=config['cmd'], job_name=job_name)
73
+ # compatible with old cmd field
74
+ command = config.params.get('cmd') or config.cmd
75
+ return cls(cmd=command, job_name=job_name)
69
76
 
70
- def __call__(self, context:"JobContext"=None, **kwds: Any) -> Any:
77
+ def execute(self, context:JobContext) -> int:
78
+ process = subprocess.Popen(
79
+ self.cmd,
80
+ shell=True,
81
+ env=os.environ,
82
+ stdout=subprocess.PIPE,
83
+ stderr=subprocess.PIPE,
84
+ text=True
85
+ )
86
+
87
+ stdout, stderr = process.communicate()
88
+ if context.stdout:
89
+ context.stdout.write(stdout)
90
+ context.stdout.write(stderr)
91
+
92
+ ret_code = process.wait()
93
+ return ret_code
94
+
95
+
96
+ def __call__(self, context:"Optional[JobContext]"=None, **kwds: Any) -> Any:
71
97
  output_to_console = False
72
98
  if context is not None:
73
99
  output_to_console = context.output_to_console
@@ -120,7 +146,6 @@ class EmailErrorNotifier:
120
146
 
121
147
  def __call__(self, ex:"Exception"):
122
148
  if isinstance(ex, JobFailedException):
123
- ex: "JobFailedException" = ex
124
149
  job_name = ex.job_name
125
150
  error_message = str(ex)
126
151
  else:
@@ -129,9 +154,9 @@ class EmailErrorNotifier:
129
154
 
130
155
  mail_subject = f'Schd job failed. {job_name}'
131
156
  msg = MIMEText(error_message, 'plain', 'utf8')
132
- msg['From'] = Header(self.from_addr)
133
- msg['To'] = Header(self.to_addr)
134
- msg['Subject'] = Header(mail_subject)
157
+ msg['From'] = str(Header(self.from_addr, 'utf8'))
158
+ msg['To'] = str(Header(self.to_addr, 'utf8'))
159
+ msg['Subject'] = str(Header(mail_subject, 'utf8'))
135
160
 
136
161
  try:
137
162
  smtp = smtplib.SMTP(self.smtp_server, self.smtp_port)
@@ -153,24 +178,104 @@ class ConsoleErrorNotifier:
153
178
  print(e)
154
179
 
155
180
 
156
- def read_config(config_file=None):
157
- if config_file is None and 'SCHD_CONFIG' in os.environ:
158
- config_file = os.environ['SCHD_CONFIG']
181
+ class LocalScheduler:
182
+ def __init__(self, max_concurrent_jobs: int = 10):
183
+ """
184
+ Initialize the LocalScheduler with support for concurrent job execution.
185
+
186
+ :param max_concurrent_jobs: Maximum number of jobs to run concurrently.
187
+ """
188
+ executors = {
189
+ 'default': ThreadPoolExecutor(max_concurrent_jobs)
190
+ }
191
+ self.scheduler = BlockingScheduler(executors=executors)
192
+ self._jobs:Dict[str, Job] = {}
193
+ logger.info("LocalScheduler initialized in 'local' mode with concurrency support")
194
+
195
+ async def init(self):
196
+ pass
197
+
198
+ async def add_job(self, job: Job, job_name: str, job_config:JobConfig) -> None:
199
+ """
200
+ Add a job to the scheduler.
201
+
202
+ :param job: An instance of a class conforming to the Job protocol.
203
+ :param cron_expression: A string representing the cron schedule.
204
+ :param job_name: Optional name for the job.
205
+ """
206
+ self._jobs[job_name] = job
207
+ try:
208
+ cron_expression = job_config.cron
209
+ cron_trigger = CronTrigger.from_crontab(cron_expression)
210
+ self.scheduler.add_job(self.execute_job, cron_trigger, kwargs={'job_name':job_name})
211
+ logger.info(f"Job '{job_name or job.__class__.__name__}' added with cron expression: {cron_expression}")
212
+ except Exception as e:
213
+ logger.error(f"Failed to add job '{job_name or job.__class__.__name__}': {str(e)}")
214
+ raise
215
+
216
+ def execute_job(self, job_name:str):
217
+ job = self._jobs[job_name]
218
+ output_stream = io.StringIO()
219
+ context = JobContext(job_name=job_name, stdout=output_stream)
220
+ try:
221
+ with redirect_stdout(output_stream):
222
+ job_result = job.execute(context)
223
+
224
+ if job_result is None:
225
+ ret_code = 0
226
+ elif isinstance(job_result, int):
227
+ ret_code = job_result
228
+ elif hasattr(job_result, 'get_code'):
229
+ ret_code = job_result.get_code()
230
+ else:
231
+ raise ValueError('unsupported result type: %s', job_result)
232
+
233
+ except Exception as ex:
234
+ logger.exception('error when executing job, %s', ex)
235
+ ret_code = -1
236
+
237
+ logger.info('job %s execute complete: %d', job_name, ret_code)
238
+ logger.info('job %s process output: \n%s', job_name, output_stream.getvalue())
159
239
 
160
- if config_file is None:
161
- config_file = 'conf/schd.yaml'
240
+ def run(self):
241
+ """
242
+ Start the scheduler.
243
+ """
244
+ try:
245
+ logger.info("Starting LocalScheduler...")
246
+ self.scheduler.start()
247
+ except (KeyboardInterrupt, SystemExit):
248
+ logger.info("Scheduler stopped.")
162
249
 
163
- with open(config_file, 'r', encoding='utf8') as f:
164
- config = yaml.load(f, Loader=yaml.FullLoader)
250
+ def start(self):
251
+ self.scheduler.start()
165
252
 
166
- return config
253
+
254
+ def build_scheduler(config:SchdConfig):
255
+ scheduler_cls = os.environ.get('SCHD_SCHEDULER_CLS') or config.scheduler_cls
256
+
257
+ if scheduler_cls == 'LocalScheduler':
258
+ scheduler = LocalScheduler()
259
+ elif scheduler_cls == 'RemoteScheduler':
260
+ logger.info('scheduler_cls: %s', scheduler_cls)
261
+ scheduler_remote_host = os.environ.get('SCHD_SCHEDULER_REMOTE_HOST') or config.scheduler_remote_host
262
+ assert scheduler_remote_host, 'scheduler_remote_host cannot be none'
263
+ logger.info('scheduler_remote_host: %s ', scheduler_remote_host)
264
+ worker_name = os.environ.get('SCHD_WORKER_NAME') or config.worker_name
265
+ assert worker_name, 'worker_name cannot be none'
266
+ logger.info('worker_name: %s ', worker_name)
267
+ scheduler = RemoteScheduler(worker_name=worker_name, remote_host=scheduler_remote_host)
268
+ else:
269
+ raise ValueError('invalid scheduler_cls: %s' % scheduler_cls)
270
+ return scheduler
167
271
 
168
272
 
169
- def run_daemon(config_file=None):
273
+ async def run_daemon(config_file=None):
170
274
  config = read_config(config_file=config_file)
171
- sched = BlockingScheduler(executors={'default': ThreadPoolExecutor(10)})
275
+ scheduler = build_scheduler(config)
276
+ await scheduler.init()
172
277
 
173
- if 'error_notifier' in config:
278
+ if hasattr(config, 'error_notifier'):
174
279
  error_notifier_config = config['error_notifier']
175
280
  error_notifier_type = error_notifier_config.get('type', 'console')
176
281
  if error_notifier_type == 'console':
@@ -196,24 +301,28 @@ def run_daemon(config_file=None):
196
301
  else:
197
302
  job_error_handler = ConsoleErrorNotifier()
198
303
 
199
- for job_name, job_config in config['jobs'].items():
200
- job_class_name = job_config.pop('class')
201
- job_cron = job_config.pop('cron')
304
+ for job_name, job_config in config.jobs.items():
305
+ job_class_name = job_config.cls
306
+ job_cron = job_config.cron
202
307
  job = build_job(job_name, job_class_name, job_config)
203
- job_warpped = JobExceptionWrapper(job, job_error_handler)
204
- sched.add_job(job_warpped, CronTrigger.from_crontab(job_cron), id=job_name, misfire_grace_time=10)
308
+ await scheduler.add_job(job, job_name, job_config)
205
309
  logger.info('job added, %s', job_name)
206
310
 
207
311
  logger.info('scheduler starting.')
208
- sched.start()
312
+ scheduler.start()
313
+ while True:
314
+ await asyncio.sleep(1000)
209
315
 
210
- def main():
316
+
317
+ async def main():
211
318
  parser = argparse.ArgumentParser()
212
319
  parser.add_argument('--logfile')
213
320
  parser.add_argument('--config', '-c')
214
321
  args = parser.parse_args()
215
322
  config_file = args.config
216
323
 
324
+ logging.basicConfig(level=logging.DEBUG)
325
+
217
326
  print(f'starting schd, {schd_version}, config_file={config_file}')
218
327
 
219
328
  if args.logfile:
@@ -224,8 +333,8 @@ def main():
224
333
  log_stream = sys.stdout
225
334
 
226
335
  logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)s - %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S', stream=log_stream)
227
- run_daemon(config_file)
336
+ await run_daemon(config_file)
228
337
 
229
338
 
230
339
  if __name__ == '__main__':
231
- main()
340
+ asyncio.run(main())
File without changes
@@ -0,0 +1,173 @@
1
+ import asyncio
2
+ from contextlib import redirect_stdout
3
+ import io
4
+ import json
5
+ import os
6
+ from typing import Dict, Tuple
7
+ from urllib.parse import urljoin
8
+ import aiohttp
9
+ import aiohttp.client_exceptions
10
+ from schd.config import JobConfig
11
+ from schd.job import JobContext, Job
12
+
13
+ import logging
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class RemoteApiClient:
19
+ def __init__(self, base_url:str):
20
+ self._base_url = base_url
21
+
22
+ async def register_worker(self, name:str):
23
+ url = urljoin(self._base_url, f'/api/workers/{name}')
24
+ async with aiohttp.ClientSession() as session:
25
+ async with session.put(url) as response:
26
+ response.raise_for_status()
27
+ result = await response.json()
28
+
29
+ async def register_job(self, worker_name, job_name, cron, timezone=None):
30
+ url = urljoin(self._base_url, f'/api/workers/{worker_name}/jobs/{job_name}')
31
+ post_data = {
32
+ 'cron': cron,
33
+ }
34
+ if timezone:
35
+ post_data['timezone'] = timezone
36
+
37
+ async with aiohttp.ClientSession() as session:
38
+ async with session.put(url, json=post_data) as response:
39
+ response.raise_for_status()
40
+ result = await response.json()
41
+
42
+ async def subscribe_worker_eventstream(self, worker_name):
43
+ url = urljoin(self._base_url, f'/api/workers/{worker_name}/eventstream')
44
+
45
+ timeout = aiohttp.ClientTimeout(sock_read=600)
46
+ async with aiohttp.ClientSession(timeout=timeout) as session:
47
+ async with session.get(url) as resp:
48
+ resp.raise_for_status()
49
+ async for line in resp.content:
50
+ decoded = line.decode("utf-8").strip()
51
+ logger.info('got event, raw data: %s', decoded)
52
+ event = json.loads(decoded)
53
+ event_type = event['event_type']
54
+ if event_type == 'NewJobInstance':
55
+ # event = JobInstanceEvent()
56
+ yield event
57
+ else:
58
+ raise ValueError('unknown event type %s' % event_type)
59
+
60
+ async def update_job_instance(self, worker_name, job_name, job_instance_id, status, ret_code=None):
61
+ url = urljoin(self._base_url, f'/api/workers/{worker_name}/jobs/{job_name}/{job_instance_id}')
62
+ post_data = {'status':status}
63
+ if ret_code is not None:
64
+ post_data['ret_code'] = ret_code
65
+
66
+ async with aiohttp.ClientSession() as session:
67
+ async with session.put(url, json=post_data) as response:
68
+ response.raise_for_status()
69
+ result = await response.json()
70
+
71
+ async def commit_job_log(self, worker_name, job_name, job_instance_id, logfile_path):
72
+ upload_url = urljoin(self._base_url, f'/api/workers/{worker_name}/jobs/{job_name}/{job_instance_id}/log')
73
+ async with aiohttp.ClientSession() as session:
74
+ with open(logfile_path, 'rb') as f:
75
+ data = aiohttp.FormData()
76
+ data.add_field('logfile', f, filename=os.path.basename(logfile_path), content_type='application/octet-stream')
77
+
78
+ async with session.put(upload_url, data=data) as resp:
79
+ print("Status:", resp.status)
80
+ print("Response:", await resp.text())
81
+
82
+
83
+ class RemoteScheduler:
84
+ def __init__(self, worker_name:str, remote_host:str):
85
+ self.client = RemoteApiClient(remote_host)
86
+ self._worker_name = worker_name
87
+ self._jobs:"Dict[str,Tuple[Job,str]]" = {}
88
+ self._loop_task = None
89
+ self._loop = asyncio.get_event_loop()
90
+ self.queue_semaphores = {}
91
+
92
+ async def init(self):
93
+ await self.client.register_worker(self._worker_name)
94
+
95
+ async def add_job(self, job:Job, job_name:str, job_config:JobConfig):
96
+ cron = job_config.cron
97
+ queue_name = job_config.queue or ''
98
+ await self.client.register_job(self._worker_name, job_name=job_name, cron=cron, timezone=job_config.timezone)
99
+ self._jobs[job_name] = (job, queue_name)
100
+ if queue_name not in self.queue_semaphores:
101
+ # each queue has a max concurrency of 1
102
+ max_conc = 1
103
+ self.queue_semaphores[queue_name] = asyncio.Semaphore(max_conc)
104
+
105
+ async def start_main_loop(self):
106
+ while True:
107
+ logger.info('start_main_loop ')
108
+ try:
109
+ async for event in self.client.subscribe_worker_eventstream(self._worker_name):
110
+ print(event)
111
+ job_name = event['data']['job_name']
112
+ instance_id = event['data']['id']
113
+ _, queue_name = self._jobs[job_name]
114
+ # Queue concurrency control
115
+ semaphore = self.queue_semaphores[queue_name]
116
+ self._loop.create_task(self._run_with_semaphore(semaphore, job_name, instance_id))
117
+ # await self.execute_task(event['data']['job_name'], event['data']['id'])
118
+ except aiohttp.client_exceptions.ClientPayloadError:
119
+ logger.info('connection lost')
120
+ await asyncio.sleep(1)
121
+ except aiohttp.client_exceptions.SocketTimeoutError:
122
+ logger.info('SocketTimeoutError')
123
+ await asyncio.sleep(1)
124
+ except aiohttp.client_exceptions.ClientConnectorError:
125
+ # cannot connect, try later
126
+ logger.debug('connect failed, ClientConnectorError, try later.')
127
+ await asyncio.sleep(10)
128
+ continue
129
+ except Exception as ex:
130
+ logger.error('error in start_main_loop, %s', ex, exc_info=ex)
131
+ break
132
+
133
+ def start(self):
134
+ self._loop_task = self._loop.create_task(self.start_main_loop())
135
+
136
+ async def execute_task(self, job_name, instance_id:int):
137
+ job, _ = self._jobs[job_name]
138
+ logfile_dir = f'joblog/{instance_id}'
139
+ if not os.path.exists(logfile_dir):
140
+ os.makedirs(logfile_dir)
141
+ logfile_path = os.path.join(logfile_dir, 'output.txt')
142
+ output_stream = io.FileIO(logfile_path, mode='w+')
143
+ text_stream = io.TextIOWrapper(output_stream, encoding='utf-8')
144
+
145
+ context = JobContext(job_name=job_name, stdout=text_stream)
146
+ await self.client.update_job_instance(self._worker_name, job_name, instance_id, status='RUNNING')
147
+ try:
148
+ with redirect_stdout(text_stream):
149
+ job_result = job.execute(context)
150
+
151
+ if job_result is None:
152
+ ret_code = 0
153
+ elif isinstance(job_result, int):
154
+ ret_code = job_result
155
+ elif hasattr(job_result, 'get_code'):
156
+ ret_code = job_result.get_code()
157
+ else:
158
+ raise ValueError('unsupported result type: %s', job_result)
159
+
160
+ except Exception as ex:
161
+ logger.exception('error when executing job, %s', ex)
162
+ ret_code = -1
163
+
164
+ logger.info('job %s execute complete: %d, log_file: %s', job_name, ret_code, logfile_path)
165
+ text_stream.flush()
166
+ output_stream.flush()
167
+ output_stream.close()
168
+ await self.client.commit_job_log(self._worker_name, job_name, instance_id, logfile_path)
169
+ await self.client.update_job_instance(self._worker_name, job_name, instance_id, status='COMPLETED', ret_code=ret_code)
170
+
171
+ async def _run_with_semaphore(self, semaphore, job_name, instance_id):
172
+ async with semaphore:
173
+ await self.execute_task(job_name, instance_id)
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: schd
3
- Version: 0.0.10
3
+ Version: 0.0.13
4
4
  Home-page: https://github.com/kevenli/schd
5
5
  License: ApacheV2
6
6
  Requires-Dist: apscheduler<4.0
7
7
  Requires-Dist: pyaml
8
+ Requires-Dist: aiohttp
@@ -3,6 +3,8 @@ README.md
3
3
  setup.cfg
4
4
  setup.py
5
5
  schd/__init__.py
6
+ schd/config.py
7
+ schd/job.py
6
8
  schd/scheduler.py
7
9
  schd/util.py
8
10
  schd.egg-info/PKG-INFO
@@ -17,4 +19,7 @@ schd/cmds/daemon.py
17
19
  schd/cmds/jobs.py
18
20
  schd/cmds/run.py
19
21
  schd/cmds/schd.py
22
+ schd/schedulers/__init__.py
23
+ schd/schedulers/remote.py
24
+ tests/test_scheduler.py
20
25
  tests/test_util.py
@@ -1,2 +1,3 @@
1
1
  apscheduler<4.0
2
2
  pyaml
3
+ aiohttp
@@ -7,10 +7,10 @@ def read_requirements():
7
7
 
8
8
  setup(
9
9
  name="schd",
10
- version="0.0.10",
10
+ version="0.0.13",
11
11
  url="https://github.com/kevenli/schd",
12
12
  packages=find_packages(exclude=('tests', 'tests.*')),
13
- install_requires=['apscheduler<4.0', 'pyaml'],
13
+ install_requires=['apscheduler<4.0', 'pyaml', 'aiohttp'],
14
14
  entry_points={
15
15
  'console_scripts': [
16
16
  'schd = schd.cmds.schd:main',
@@ -0,0 +1,74 @@
1
+ import unittest
2
+ from contextlib import redirect_stdout
3
+ import io
4
+ from schd.config import JobConfig
5
+ from schd.scheduler import LocalScheduler, build_job
6
+
7
+
8
+ class TestOutputJob:
9
+ def execute(self, context):
10
+ print('test output')
11
+
12
+
13
+ class RedirectStdoutTest(unittest.TestCase):
14
+ def test_redirect(self):
15
+ buffer = io.StringIO()
16
+ with redirect_stdout(buffer):
17
+ print("This goes into the buffer, not the console.")
18
+ output = buffer.getvalue()
19
+ # print(f"Captured: {output}")
20
+ self.assertEqual('This goes into the buffer, not the console.\n', output)
21
+
22
+ def test_redirect_job(self):
23
+ buffer = io.StringIO()
24
+ with redirect_stdout(buffer):
25
+ job = TestOutputJob()
26
+ job.execute(None)
27
+ output = buffer.getvalue()
28
+ self.assertEqual('test output\n', output)
29
+
30
+
31
+ class LocalSchedulerTest(unittest.IsolatedAsyncioTestCase):
32
+ async def test_add_execute(self):
33
+ job = TestOutputJob()
34
+ target = LocalScheduler()
35
+ await target.add_job(job, "0 1 * * *", 'test_job')
36
+ target.execute_job("test_job")
37
+
38
+
39
+ class JobHasParams:
40
+ def __init__(self, x, y):
41
+ self.x = x
42
+ self.y = y
43
+
44
+
45
+ class JobHasFromSettingsMethod:
46
+ def __init__(self, job_name, z):
47
+ self.job_name = job_name
48
+ self.z = z
49
+ @classmethod
50
+ def from_settings(cls, job_name=None, config=None, **kwargs):
51
+ return JobHasFromSettingsMethod(job_name, config.params['z'])
52
+
53
+
54
+ class TestBuildJob(unittest.TestCase):
55
+ def test_build_no_param(self):
56
+ job_cls = 'test_scheduler:TestOutputJob'
57
+ built_job = build_job('TestOutputJob', job_cls, JobConfig(cls=job_cls, cron='* * * * *'))
58
+ self.assertIsNotNone(built_job)
59
+
60
+ def test_build_has_param(self):
61
+ job_cls = 'test_scheduler:JobHasParams'
62
+ built_job:JobHasParams = build_job('JobHasParams', job_cls, JobConfig(cls=job_cls, cron='* * * * *', params={'x':1,'y':2}))
63
+ self.assertIsNotNone(built_job)
64
+ # build_job should pass params into contrustor accordingly
65
+ self.assertEqual(built_job.x, 1)
66
+ self.assertEqual(built_job.y, 2)
67
+
68
+ def test_build_from_settings(self):
69
+ job_cls = 'test_scheduler:JobHasFromSettingsMethod'
70
+ built_job:JobHasFromSettingsMethod = build_job('JobHasFromSettingsMethod', job_cls, JobConfig(cls=job_cls, cron='* * * * *', params={'z':3}))
71
+ self.assertIsNotNone(built_job)
72
+ # build_job should pass params into contrustor accordingly
73
+ self.assertEqual(built_job.job_name, 'JobHasFromSettingsMethod')
74
+ self.assertEqual(built_job.z, 3)
@@ -1 +0,0 @@
1
- __version__ = '0.0.10'
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes