diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a047a94
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.idea/
+__pycache__/
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2ba468b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,65 @@
+## 简介
+plana是一个非常简单的定时任务框架,内置了异常提醒和任务记录功能 \
+现阶段使用需要配置一个MongoDB,因为还没有前端的展示功能,待后续开发,两天前萌生了一个想法,想要做一个定时任务框架,现在我的定时任务都基于linux的Crontab来管理,
+许多小脚本存放于服务器的各个地方,想使用一种统一的方式来管理他们。开源的方案也有,看了一些任务调度框架,但是它们部署都挺麻烦,而且不一定适用,于是就有了这个项目,它是基于APScheduler开发的。
+
+## 快速上手
+```python
+from plana.core import add_job, IntervalTrigger, start
+from plana.core.notice import EmailNotice
+
+# 使用EMAIL提醒需要提供settings.py配置文件或者实例化的时候传入参数
+notice = EmailNotice()
+
+@add_job(IntervalTrigger(seconds=3), name='你好啊', notice=notice)
+def say_hello():
+ """Interval 示例"""
+ print("hello world")
+
+start()
+```
+plana中有几个基本的对象
+trigger 定时器,有两种选择,间隔时间,或者linux cron 格式的定时器,示例中使用的是间隔时间,每隔3秒运行一次
+notice 提醒器,内置了两种,钉钉和邮件提醒
+使用plana添加定时任务分为如下几步
+1. 引入相关的对象
+2. 使用add_job注册一个定时任务,需要提供trigger,name和notice是可选的,当你的任务需要异常提醒的时候要提供notice,建议提供name,这样有利于阅读
+3. 编写定时函数内容
+4. 运行start方法
+
+## 触发异常提醒
+要触发提醒,需要抛出一个NoticeException
+```python
+from plana.core.Exceptions import NoticeException
+
+def test():
+ raise NoticeException('定时任务异常啦')
+```
+
+![](./static/emailnotice.jpg)
+
+## 配置文件
+由于定时任务存储目前依赖于MongoDB,所有需要用户提供一个mongodb的连接
+可选的配置:
+```python
+# Mongo连接, mongodb://root:123456@127.0.0.1:27017
+MONGO_URI = ''
+
+# 如果定时任务存在某个模块下,提供模块的名字
+WORK_MODULE = 'work'
+
+# 钉钉的Token
+DING_TOKEN = ''
+
+# 邮件设置
+EMAIL_USER = '' # 账户
+EMAIL_PWD = '' # IMAP密码
+EMAIL_HOST = ''
+EMAIL_TO_USER = ''
+```
+
+## todo
+目前这个项目处于非常早期的版本,不足之处还有很多,后续要陆续优化 \
+后续还打算提供一个简单的前端页面来展示定时任务运行记录 \
+除了用MongoDB做任务存储以外,还要支持其他的数据库 \
+为了再降低使用成本,还要添加基于内存的存储
diff --git a/example/cron.py b/example/cron.py
new file mode 100644
index 0000000..b0c4ce5
--- /dev/null
+++ b/example/cron.py
@@ -0,0 +1,13 @@
+"""
+@author: yudeqiang
+@file: cron.py
+@time: 2022/04/13
+@describe:
+"""
+from plana.core import add_job, CronTrigger
+
+
+@add_job(CronTrigger(second=3))
+def say_hello_cron():
+ """cron 示例"""
+ print("hello world cron")
diff --git a/example/interval.py b/example/interval.py
new file mode 100644
index 0000000..385b26e
--- /dev/null
+++ b/example/interval.py
@@ -0,0 +1,13 @@
+"""
+@author: yudeqiang
+@file: interval.py
+@time: 2022/04/13
+@describe:
+"""
+from plana.core import add_job, IntervalTrigger
+
+
+@add_job(IntervalTrigger(seconds=3))
+def say_hello():
+ """Interval 示例"""
+ print("hello world")
diff --git a/plana/__init__.py b/plana/__init__.py
new file mode 100644
index 0000000..c6c73bf
--- /dev/null
+++ b/plana/__init__.py
@@ -0,0 +1,44 @@
+"""
+@author: yudeqiang
+@file: __init__.py.py
+@time: 2022/04/15
+@describe:
+"""
+import importlib
+import importlib.util
+from pathlib import Path
+import os
+
+from .core import *
+from .settings import WORK_MODULE
+
+MODULE_EXTENSIONS = '.py'
+
+
+def package_contents(package_name):
+ """查找模块下有哪些py文件"""
+ spec = importlib.util.find_spec(package_name)
+ if spec is None:
+ return set()
+
+ pathname = Path(spec.origin).parent
+ ret = set()
+ with os.scandir(pathname) as entries:
+ for entry in entries:
+ if entry.name.startswith('__'):
+ continue
+ current = '.'.join((package_name, entry.name.partition('.')[0]))
+ if entry.is_file():
+ if entry.name.endswith(MODULE_EXTENSIONS):
+ ret.add(current)
+ elif entry.is_dir():
+ ret.add(current)
+ ret |= package_contents(current)
+
+ return ret
+
+
+content = package_contents(WORK_MODULE)
+for c in content:
+ importlib.import_module(c)
+
diff --git a/plana/core/Exceptions.py b/plana/core/Exceptions.py
new file mode 100644
index 0000000..d012b16
--- /dev/null
+++ b/plana/core/Exceptions.py
@@ -0,0 +1,14 @@
+"""
+@author: yudeqiang
+@file: Exceptions.py
+@time: 2022/04/14
+@describe:
+"""
+
+
+class NotTokenException(Exception):
+ pass
+
+
+class NoticeException(Exception):
+ pass
diff --git a/plana/core/VERSION b/plana/core/VERSION
new file mode 100644
index 0000000..8a9ecc2
--- /dev/null
+++ b/plana/core/VERSION
@@ -0,0 +1 @@
+0.0.1
\ No newline at end of file
diff --git a/plana/core/__init__.py b/plana/core/__init__.py
new file mode 100644
index 0000000..e6bc085
--- /dev/null
+++ b/plana/core/__init__.py
@@ -0,0 +1,104 @@
+"""
+@author: yudeqiang
+@file: __init__.py
+@time: 2022/04/13
+@describe:
+"""
+import functools
+from typing import Optional
+
+from apscheduler.triggers.interval import IntervalTrigger
+from apscheduler.triggers.cron import CronTrigger
+
+from .log import log
+from .Exceptions import NoticeException
+from .schedulers import MySchedulerBase, SxBlockingScheduler, SxBackgroundScheduler
+from plana.settings import SCHEDULER_CLS, JOB_BACKEND_DB, TASK_BACKEND_DB
+from .task import Task
+from .backend import MongoJobBackend, MongoTaskBackend
+from .notice.base import Notice
+
+if SCHEDULER_CLS == 'block':
+ SCHEDULER_CLS = SxBlockingScheduler
+elif SCHEDULER_CLS == 'background':
+ SCHEDULER_CLS = SxBackgroundScheduler
+else:
+ print('未知的调度器')
+ exit()
+
+if JOB_BACKEND_DB == 'mongo':
+ JOB_BACKEND_DB = MongoJobBackend
+else:
+ print('未知的Job存储')
+ exit()
+
+if TASK_BACKEND_DB == 'mongo':
+ TASK_BACKEND_DB = MongoTaskBackend
+else:
+ print('未知的任务存储')
+ exit()
+
+
+def schedulerFactory() -> MySchedulerBase:
+ backend = JOB_BACKEND_DB()
+ scheduler = SCHEDULER_CLS(backend, {
+ # 'apscheduler.jobstores.default': {
+ # 'type': 'redis',
+ # 'host': '192.168.0.54',
+ # 'password': '123456',
+ # 'db': 1
+ # },
+ 'apscheduler.executors.default': {
+ 'class': 'apscheduler.executors.pool:ThreadPoolExecutor',
+ 'max_workers': '20'
+ },
+ 'apscheduler.job_defaults.coalesce': 'false',
+ 'apscheduler.job_defaults.max_instances': '3',
+ 'apscheduler.timezone': 'Asia/Shanghai',
+ })
+ return scheduler
+
+
+scheduler = schedulerFactory()
+task_backend_db = TASK_BACKEND_DB()
+
+
+def add_job(trigger, name=None, notice: Optional[Notice] = None):
+ """
+ :param name: 为定时任务取一个名字,默认取函数的名字
+ :param trigger: apscheduler.triggers.base.BaseTrigger trigger
+ :param notice: 异常提醒 一个Notice实例
+ :return:
+ """
+
+ def warp(func):
+ nonlocal name
+ if name is None:
+ name = func.__name__
+
+ @functools.wraps(func)
+ @scheduler.scheduled_job(trigger, name=name)
+ def inner(*args, **kwargs):
+ job = scheduler.find_job(name)
+ task = Task(job, task_backend_db)
+ task.start()
+ log.debug(f'{job.name}->开始执行')
+ try:
+ func(*args, **kwargs)
+ except NoticeException as e:
+ notice.notice(str(e))
+ task.set_exception(e)
+ except Exception as e:
+ task.set_exception(e)
+ log.error(f'{name}->执行异常:{str(e)}')
+ finally:
+ task.stop()
+ log.debug(f'{name}->执行结束')
+
+ return inner
+
+ return warp
+
+
+def start():
+ scheduler.start()
diff --git a/plana/core/backend.py b/plana/core/backend.py
new file mode 100644
index 0000000..eaad656
--- /dev/null
+++ b/plana/core/backend.py
@@ -0,0 +1,72 @@
+"""
+@author: yudeqiang
+@file: backend.py
+@time: 2022/04/15
+@describe:
+"""
+import datetime
+from abc import ABC, abstractmethod
+
+import pytz
+from apscheduler.job import Job
+from pymongo import MongoClient
+
+from plana.settings import MONGO_URI
+
+
+assert MONGO_URI
+
+
+class JobBackendDb(ABC):
+
+ @abstractmethod
+ def register(self, job: Job):
+ """注册一个Job到数据库中"""
+
+ @abstractmethod
+ def get_all_job(self) -> list:
+ """获取所有的Job"""
+
+
+class MongoJobBackend(JobBackendDb):
+
+ def __init__(self):
+ cli = MongoClient(MONGO_URI)
+ db = cli['scheduler']
+ self.col = db['job']
+
+ def register(self, job: Job):
+ if self.col.find_one({'job_id': job.id}):
+ return
+ self.col.insert_one({'job_id': job.id, 'job_name': job.name,
+ 'registry_time': datetime.datetime.now(pytz.timezone('Asia/Shanghai'))})
+
+ def get_all_job(self) -> list:
+ return list(self.col.find({}, {'_id': 0}))
+
+
+class TaskBackendDb(ABC):
+ """Task的后端存储"""
+
+ @abstractmethod
+ def insert_task(self, task):
+ pass
+
+ @abstractmethod
+ def update_task(self, condition, val):
+ pass
+
+
+class MongoTaskBackend(TaskBackendDb):
+
+ def __init__(self):
+ cli = MongoClient(MONGO_URI)
+ db = cli['scheduler']
+ self.col = db['task']
+
+ def insert_task(self, data):
+ self.col.insert_one(data)
+
+ def update_task(self, condition, val):
+ self.col.update_one(condition, val)
+
diff --git a/plana/core/core.py b/plana/core/core.py
new file mode 100644
index 0000000..e69de29
diff --git a/plana/core/log.py b/plana/core/log.py
new file mode 100644
index 0000000..03c4bfb
--- /dev/null
+++ b/plana/core/log.py
@@ -0,0 +1,261 @@
+"""
+@author: yudeqiang
+@file: log.py
+@time: 2022/04/13
+@describe:
+"""
+# -*- coding: utf-8 -*-
+import logging
+import os
+import sys
+from logging.handlers import BaseRotatingHandler
+
+import loguru
+from better_exceptions import format_exception
+from warnings import filterwarnings
+from pytz_deprecation_shim import PytzUsageWarning
+
+from plana import settings as setting
+
+filterwarnings('ignore', category=PytzUsageWarning)
+
+
+class InterceptHandler(logging.Handler):
+ def emit(self, record):
+ # Retrieve context where the logging call occurred, this happens to be in the 6th frame upward
+ logger_opt = loguru.logger.opt(depth=6, exception=record.exc_info)
+ logger_opt.log(record.levelname, record.getMessage())
+
+
+# 重写 RotatingFileHandler 自定义log的文件名
+# 原来 xxx.log xxx.log.1 xxx.log.2 xxx.log.3 文件由近及远
+# 现在 xxx.log xxx1.log xxx2.log 如果backup_count 是2位数时 则 01 02 03 三位数 001 002 .. 文件由近及远
+class RotatingFileHandler(BaseRotatingHandler):
+ def __init__(
+ self, filename, mode="a", max_bytes=0, backup_count=0, encoding=None, delay=0
+ ):
+ BaseRotatingHandler.__init__(self, filename, mode, encoding, delay)
+ self.max_bytes = max_bytes
+ self.backup_count = backup_count
+ self.placeholder = str(len(str(backup_count)))
+
+ def doRollover(self):
+ if self.stream:
+ self.stream.close()
+ self.stream = None
+ if self.backup_count > 0:
+ for i in range(self.backup_count - 1, 0, -1):
+ sfn = ("%0" + self.placeholder + "d.") % i # '%2d.'%i -> 02
+ sfn = sfn.join(self.baseFilename.split("."))
+ # sfn = "%d_%s" % (i, self.baseFilename)
+ # dfn = "%d_%s" % (i + 1, self.baseFilename)
+ dfn = ("%0" + self.placeholder + "d.") % (i + 1)
+ dfn = dfn.join(self.baseFilename.split("."))
+ if os.path.exists(sfn):
+ # print "%s -> %s" % (sfn, dfn)
+ if os.path.exists(dfn):
+ os.remove(dfn)
+ os.rename(sfn, dfn)
+ dfn = (("%0" + self.placeholder + "d.") % 1).join(
+ self.baseFilename.split(".")
+ )
+ if os.path.exists(dfn):
+ os.remove(dfn)
+ # Issue 18940: A file may not have been created if delay is True.
+ if os.path.exists(self.baseFilename):
+ os.rename(self.baseFilename, dfn)
+ if not self.delay:
+ self.stream = self._open()
+
+ def shouldRollover(self, record):
+
+ if self.stream is None: # delay was set...
+ self.stream = self._open()
+ if self.max_bytes > 0: # are we rolling over?
+ msg = "%s\n" % self.format(record)
+ self.stream.seek(0, 2) # due to non-posix-compliant Windows feature
+ if self.stream.tell() + len(msg) >= self.max_bytes:
+ return 1
+ return 0
+
+
+def get_logger(
+ name=None,
+ path=None,
+ log_level=None,
+ is_write_to_console=None,
+ is_write_to_file=None,
+ color=None,
+ mode=None,
+ max_bytes=None,
+ backup_count=None,
+ encoding=None,
+):
+ """
+ @summary: 获取log
+ ---------
+ @param name: log名
+ @param path: log文件存储路径 如 D://xxx.log
+ @param log_level: log等级 CRITICAL/ERROR/WARNING/INFO/DEBUG
+ @param is_write_to_console: 是否输出到控制台
+ @param is_write_to_file: 是否写入到文件 默认否
+ @param color:是否有颜色
+ @param mode:写文件模式
+ @param max_bytes: 每个日志文件的最大字节数
+ @param backup_count:日志文件保留数量
+ @param encoding:日志文件编码
+ ---------
+ @result:
+ """
+ # 加载setting里最新的值
+ name = name or setting.LOG_NAME
+ path = path or setting.LOG_PATH
+ log_level = log_level or setting.LOG_LEVEL
+ is_write_to_console = (
+ is_write_to_console
+ if is_write_to_console is not None
+ else setting.LOG_IS_WRITE_TO_CONSOLE
+ )
+ is_write_to_file = (
+ is_write_to_file
+ if is_write_to_file is not None
+ else setting.LOG_IS_WRITE_TO_FILE
+ )
+ color = color if color is not None else setting.LOG_COLOR
+ mode = mode or setting.LOG_MODE
+ max_bytes = max_bytes or setting.LOG_MAX_BYTES
+ backup_count = backup_count or setting.LOG_BACKUP_COUNT
+ encoding = encoding or setting.LOG_ENCODING
+
+ # logger 配置
+ name = name.split(os.sep)[-1].split(".")[0] # 取文件名
+
+ logger = logging.getLogger(name)
+ logger.setLevel(log_level)
+
+ formatter = logging.Formatter(setting.LOG_FORMAT)
+ if setting.PRINT_EXCEPTION_DETAILS:
+ formatter.formatException = lambda exc_info: format_exception(*exc_info)
+
+ # 定义一个RotatingFileHandler,最多备份5个日志文件,每个日志文件最大10M
+ if is_write_to_file:
+ if path and not os.path.exists(os.path.dirname(path)):
+ os.makedirs(os.path.dirname(path))
+
+ rf_handler = RotatingFileHandler(
+ path,
+ mode=mode,
+ max_bytes=max_bytes,
+ backup_count=backup_count,
+ encoding=encoding,
+ )
+ rf_handler.setFormatter(formatter)
+ logger.addHandler(rf_handler)
+ if color and is_write_to_console:
+ loguru_handler = InterceptHandler()
+ loguru_handler.setFormatter(formatter)
+ # logging.basicConfig(handlers=[loguru_handler], level=0)
+ logger.addHandler(loguru_handler)
+ elif is_write_to_console:
+ stream_handler = logging.StreamHandler()
+ stream_handler.stream = sys.stdout
+ stream_handler.setFormatter(formatter)
+ logger.addHandler(stream_handler)
+
+ _handler_list = []
+ _handler_name_list = []
+ # 检查是否存在重复handler
+ for _handler in logger.handlers:
+ if str(_handler) not in _handler_name_list:
+ _handler_name_list.append(str(_handler))
+ _handler_list.append(_handler)
+ logger.handlers = _handler_list
+ return logger
+
+
+# logging.disable(logging.DEBUG) # 关闭所有log
+
+# 不让打印log的配置
+STOP_LOGS = [
+ # ES
+ "urllib3.response",
+ "urllib3.connection",
+ "elasticsearch.trace",
+ "requests.packages.urllib3.util",
+ "requests.packages.urllib3.util.retry",
+ "urllib3.util",
+ "requests.packages.urllib3.response",
+ "requests.packages.urllib3.contrib.pyopenssl",
+ "requests.packages",
+ "urllib3.util.retry",
+ "requests.packages.urllib3.contrib",
+ "requests.packages.urllib3.connectionpool",
+ "requests.packages.urllib3.poolmanager",
+ "urllib3.connectionpool",
+ "requests.packages.urllib3.connection",
+ "elasticsearch",
+ "log_request_fail",
+ # requests
+ "requests",
+ "selenium.webdriver.remote.remote_connection",
+ "selenium.webdriver.remote",
+ "selenium.webdriver",
+ "selenium",
+ # markdown
+ "MARKDOWN",
+ "build_extension",
+ # newspaper
+ "calculate_area",
+ "largest_image_url",
+ "newspaper.images",
+ "newspaper",
+ "Importing",
+ "PIL",
+ "apscheduler"
+]
+
+# 关闭日志打印
+for STOP_LOG in STOP_LOGS:
+ log_level = eval("logging." + setting.OTHERS_LOG_LEVAL)
+ logging.getLogger(STOP_LOG).setLevel(log_level)
+
+# print(logging.Logger.manager.loggerDict) # 取使用debug模块的name
+
+# 日志级别大小关系为:CRITICAL > ERROR > WARNING > INFO > DEBUG
+
+
+class Log:
+ log = None
+
+ def __getattr__(self, name):
+ # 调用log时再初始化,为了加载最新的setting
+ if self.__class__.log is None:
+ self.__class__.log = get_logger()
+ return getattr(self.__class__.log, name)
+
+ @property
+ def debug(self):
+ return self.__class__.log.debug
+
+ @property
+ def info(self):
+ return self.__class__.log.info
+
+ @property
+ def warning(self):
+ return self.__class__.log.warning
+
+ @property
+ def exception(self):
+ return self.__class__.log.exception
+
+ @property
+ def error(self):
+ return self.__class__.log.error
+
+ @property
+ def critical(self):
+ return self.__class__.log.critical
+
+
+log = Log()
\ No newline at end of file
diff --git a/plana/core/notice/__init__.py b/plana/core/notice/__init__.py
new file mode 100644
index 0000000..949475d
--- /dev/null
+++ b/plana/core/notice/__init__.py
@@ -0,0 +1,8 @@
+"""
+@author: yudeqiang
+@file: __init__.py
+@time: 2022/04/14
+@describe:
+"""
+from .dingding import DingDingNotice
+from .email import EmailNotice
diff --git a/plana/core/notice/base.py b/plana/core/notice/base.py
new file mode 100644
index 0000000..01671ac
--- /dev/null
+++ b/plana/core/notice/base.py
@@ -0,0 +1,14 @@
+"""
+@author: yudeqiang
+@file: base.py
+@time: 2022/04/14
+@describe:
+"""
+from abc import ABC, abstractmethod
+
+
+class Notice(ABC):
+
+ @abstractmethod
+ def notice(self, msg):
+ pass
diff --git a/plana/core/notice/dingding.py b/plana/core/notice/dingding.py
new file mode 100644
index 0000000..68a89b6
--- /dev/null
+++ b/plana/core/notice/dingding.py
@@ -0,0 +1,36 @@
+"""
+@author: yudeqiang
+@file: dingding.py
+@time: 2022/04/15
+@describe:
+"""
+import json
+
+import requests
+from .base import Notice
+from plana.settings import DING_TOKEN
+from ..Exceptions import NotTokenException
+
+
+class DingDingNotice(Notice):
+
+ def __init__(self, token=None):
+ if token:
+ self.token = token
+ else:
+ if not DING_TOKEN:
+ raise NotTokenException('钉钉token未提供')
+ self.token = DING_TOKEN
+
+ def notice(self, msg):
+ url = f"https://oapi.dingtalk.com/robot/send?access_token={self.token}"
+ data = {
+ "msgtype": 'text',
+ "text": {
+ "content": msg + 'sx'
+ }
+ }
+ json_data = json.dumps(data).encode(encoding='utf-8')
+ headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko',
+ "Content-Type": "application/json"}
+ return requests.post(url, data=json_data, headers=headers)
diff --git a/plana/core/notice/email.py b/plana/core/notice/email.py
new file mode 100644
index 0000000..053a5b7
--- /dev/null
+++ b/plana/core/notice/email.py
@@ -0,0 +1,49 @@
+"""
+@author: yudeqiang
+@file: email.py
+@time: 2022/04/15
+@describe:
+"""
+from .base import Notice
+import yagmail
+from plana.settings import EMAIL_PWD, EMAIL_HOST, EMAIL_USER, EMAIL_TO_USER
+
+
+class EmailNotice(Notice):
+
+ def __init__(self, user=None, pwd=None, host=None, to_user=None):
+ """
+ :param user: 邮箱账户 e.g. ydq@qq.com
+ :param pwd: 申请的IMAP密码
+ :param host: 邮件服务器 e.g. smtp.qq.com
+ :param to_user: 邮件接收人
+ """
+ if user:
+ self.user = user
+ else:
+ assert EMAIL_USER
+ self.user = EMAIL_USER
+
+ if pwd:
+ self.pwd = pwd
+ else:
+ assert EMAIL_PWD
+ self.pwd = pwd
+
+ if host:
+ self.host = host
+ else:
+ assert EMAIL_HOST
+ self.host = EMAIL_HOST
+
+ if to_user:
+ self.to_user = to_user
+ else:
+ assert EMAIL_TO_USER
+ self.to_user = EMAIL_TO_USER
+
+ def notice(self, msg):
+ yag = yagmail.SMTP(user=EMAIL_USER, password=EMAIL_PWD, host=EMAIL_HOST)
+ # 发送邮件
+ title = '定时任务警告'
+ yag.send(EMAIL_TO_USER, title, msg)
diff --git a/plana/core/schedulers.py b/plana/core/schedulers.py
new file mode 100644
index 0000000..7e99229
--- /dev/null
+++ b/plana/core/schedulers.py
@@ -0,0 +1,63 @@
+"""
+@author: yudeqiang
+@file: schedulers.py
+@time: 2022/04/14
+@describe:
+"""
+from abc import ABC
+from typing import List
+
+
+from apscheduler.schedulers.base import BaseScheduler
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.schedulers.blocking import BlockingScheduler
+from apscheduler.job import Job
+from apscheduler.util import _Undefined
+
+from .backend import JobBackendDb
+
+undefined = _Undefined()
+
+
+class MySchedulerBase(BaseScheduler, ABC):
+
+ _jobs: List[Job] = []
+
+ def __init__(self, backend: JobBackendDb, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.backend = backend
+
+ def register_job(self, job):
+ self._jobs.append(job)
+ self.backend.register(job)
+
+ def add_job(self, func, trigger=None, args=None, kwargs=None, id=None, name=None,
+ misfire_grace_time=undefined, coalesce=undefined, max_instances=undefined,
+ next_run_time=undefined, jobstore='default', executor='default',
+ replace_existing=False, **trigger_args):
+ job = super(MySchedulerBase, self).add_job(func, trigger, args, kwargs, id, name,
+ misfire_grace_time, coalesce, max_instances,
+ next_run_time, jobstore, executor,
+ replace_existing, **trigger_args)
+ self.register_job(job)
+ return job
+
+ def find_job(self, name):
+ for i in self._jobs:
+ if i.name == name:
+ return i
+ raise Exception('job is not found')
+
+
+class SxBlockingScheduler(MySchedulerBase, BlockingScheduler):
+ """
+ 阻塞的调度器,当程序中只有一个线程时使用
+ """
+ pass
+
+
+class SxBackgroundScheduler(MySchedulerBase, BackgroundScheduler):
+ """
+ 非阻塞的调度器,用在多线程任务中
+ """
+ pass
diff --git a/plana/core/task.py b/plana/core/task.py
new file mode 100644
index 0000000..029e69c
--- /dev/null
+++ b/plana/core/task.py
@@ -0,0 +1,57 @@
+"""
+@author: yudeqiang
+@file: task.py
+@time: 2022/04/15
+@describe:
+"""
+import datetime
+import uuid
+
+import pytz
+
+from .schedulers import Job
+from .backend import TaskBackendDb
+
+
+class Task:
+
+ def __init__(self, job: Job, backend: TaskBackendDb):
+ self.job = job
+ self.backend = backend
+ self.start_time = None
+ self.end_time = None
+ self.running = False
+ self.run_time = None
+ self.exception = None
+ self.task_id = uuid.uuid4().hex
+
+ def start(self):
+ self.running = True
+ self.start_time = self.now()
+ self.backend.insert_task({'job_id': self.job.id, 'job_name': self.job.name,
+ 'task_id': self.task_id, 'start_time': self.now()})
+
+ def stop(self):
+ self.running = False
+ self.end_time = self.now()
+ self.run_time = str(self.end_time - self.start_time) # 用字符串表示运行时间
+ self.backend.update_task({'task_id': self.task_id}, {'$set': {'end_timd': self.end_time,
+ 'run_time': self.run_time,
+ 'exception': str(self.exception)}})
+
+ @staticmethod
+ def now() -> datetime.datetime:
+ tz = pytz.timezone('Asia/Shanghai')
+ return datetime.datetime.now(tz)
+
+ def get_stats(self):
+ return self.__str__()
+
+ def __str__(self):
+ name = self.job.name
+ stats = '运行中' if self.running else '运行结束'
+ return f"{name}->{stats}"
+
+ def set_exception(self, exception: Exception):
+ self.exception = exception
+
diff --git a/plana/settings.py b/plana/settings.py
new file mode 100644
index 0000000..f1488fb
--- /dev/null
+++ b/plana/settings.py
@@ -0,0 +1,50 @@
+"""
+@author: yudeqiang
+@file: settings.py
+@time: 2022/04/13
+@describe:
+"""
+import os
+
+LOG_NAME = os.path.basename(os.getcwd())
+LOG_PATH = "log/%s.log" % LOG_NAME # log存储路径
+LOG_LEVEL = "DEBUG"
+LOG_COLOR = True # 是否带有颜色
+LOG_IS_WRITE_TO_CONSOLE = True # 是否打印到控制台
+LOG_IS_WRITE_TO_FILE = False # 是否写文件
+LOG_MODE = "w" # 写文件的模式
+LOG_MAX_BYTES = 10 * 1024 * 1024 # 每个日志文件的最大字节数
+LOG_BACKUP_COUNT = 20 # 日志文件保留数量
+LOG_ENCODING = "utf8" # 日志文件编码
+# 是否详细的打印异常
+PRINT_EXCEPTION_DETAILS = True
+# 设置不带颜色的日志格式
+LOG_FORMAT = "%(threadName)s|%(asctime)s|%(filename)s|%(funcName)s|line:%(lineno)d|%(levelname)s| %(message)s"
+# 设置带有颜色的日志格式
+os.environ["LOGURU_FORMAT"] = (
+ "{time:YYYY-MM-DD HH:mm:ss.SSS} | "
+ "{level: <8} | "
+ "{name}:{function}:line:{line} | {message}"
+)
+OTHERS_LOG_LEVAL = "ERROR" # 第三方库的log等级
+MONGO_URI = ''
+
+SCHEDULER_CLS = 'block'
+JOB_BACKEND_DB = 'mongo'
+TASK_BACKEND_DB = 'mongo'
+
+WORK_MODULE = 'work'
+
+DING_TOKEN = ''
+
+# 邮件设置
+EMAIL_USER = '' # 账户
+EMAIL_PWD = '' # IMAP密码
+EMAIL_HOST = ''
+EMAIL_TO_USER = ''
+
+############# 导入用户自定义的setting #############
+try:
+ from settings import *
+except:
+ pass
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..25856b9
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+redis==4.2.2
+pymongo==4.1.1
+APScheduler==3.8.1
+loguru==0.6.0
+better-exceptions>=0.2.2
+requests
\ No newline at end of file
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..de178e6
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,46 @@
+"""
+@author: yudeqiang
+@file: setup.py
+@time: 2022/04/15
+@describe:
+"""
+# -*- coding: utf-8 -*-
+from os.path import dirname, join
+from sys import version_info
+
+import setuptools
+
+if version_info < (3, 6, 0):
+ raise SystemExit("Sorry! feapder requires python 3.6.0 or later.")
+
+with open(join(dirname(__file__), "plana/core/VERSION"), "rb") as f:
+ version = f.read().decode("ascii").strip()
+
+# with open("README.md", "r") as fh:
+# long_description = fh.read()
+
+packages = setuptools.find_packages()
+
+requires = [
+ "redis==4.2.2",
+ "pymongo==4.1.1",
+ "APScheduler==3.8.1",
+ "loguru==0.6.0",
+ "better-exceptions>=0.2.2",
+]
+setuptools.setup(
+ name="plana",
+ version=version,
+ author="yudeqiang",
+ license="MIT",
+ author_email="yudeqang@gmail.com",
+ python_requires=">=3.6",
+ description="plana是一个定时任务框架,包含任务运行信息,异常提醒等功能",
+ # long_description=long_description,
+ # long_description_content_type="text/markdown",
+ install_requires=requires,
+ url="https://github.com/yudeqang",
+ packages=packages,
+ include_package_data=True,
+ classifiers=["Programming Language :: Python :: 3"],
+)
\ No newline at end of file
diff --git a/static/emailnotice.jpg b/static/emailnotice.jpg
new file mode 100644
index 0000000..124d1bc
Binary files /dev/null and b/static/emailnotice.jpg differ
diff --git a/tests/settings.py b/tests/settings.py
new file mode 100644
index 0000000..a2f5ec4
--- /dev/null
+++ b/tests/settings.py
@@ -0,0 +1,14 @@
+"""
+@author: yudeqiang
+@file: settings.py
+@time: 2022/04/15
+@describe:
+"""
+# WORK_MODULE = 'works'
+
+EMAIL_USER = '726576562@qq.com' # 账户
+EMAIL_PWD = '' # IMAP密码
+EMAIL_HOST = 'smtp.qq.com'
+EMAIL_TO_USER = '1017192795@qq.com'
+
+MONGO_URI = ''
diff --git a/tests/test_main.py b/tests/test_main.py
new file mode 100644
index 0000000..f01b15d
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,19 @@
+"""
+@author: yudeqiang
+@file: test.py
+@time: 2022/04/13
+@describe:
+"""
+from plana import start, content, add_job, NoticeException, IntervalTrigger
+from plana.core.notice.email import EmailNotice
+
+
+notice = EmailNotice()
+
+
+@add_job(IntervalTrigger(seconds=10), name='测试notice', notice=notice)
+def test_notice():
+ raise NoticeException('定时任务异常提醒测试, 这是为啥呢')
+
+
+start()
diff --git a/tests/works/__init__.py b/tests/works/__init__.py
new file mode 100644
index 0000000..291b8a7
--- /dev/null
+++ b/tests/works/__init__.py
@@ -0,0 +1,7 @@
+"""
+@author: yudeqiang
+@file: __init__.py
+@time: 2022/04/13
+@describe:
+"""
+# from .work1 import *
diff --git a/tests/works/work1.py b/tests/works/work1.py
new file mode 100644
index 0000000..f08fead
--- /dev/null
+++ b/tests/works/work1.py
@@ -0,0 +1,23 @@
+"""
+@author: yudeqiang
+@file: work1.py
+@time: 2022/04/13
+@describe:
+"""
+from plana.core import add_job, log, IntervalTrigger, scheduler, CronTrigger
+
+
+@add_job(IntervalTrigger(seconds=2), '输出hello world')
+def say_hello():
+ """输出hello world"""
+ log.info('hello world')
+
+
+@add_job(CronTrigger(second=3))
+def say_hello_cron():
+ """cron 示例"""
+ print("hello world cron")
+
+
+if __name__ == '__main__':
+ say_hello()
diff --git a/tests/works/work2.py b/tests/works/work2.py
new file mode 100644
index 0000000..bb98611
--- /dev/null
+++ b/tests/works/work2.py
@@ -0,0 +1,7 @@
+"""
+@author: yudeqiang
+@file: work2.py
+@time: 2022/04/14
+@describe:
+"""
+print('这是work2')