From 6e9caade9732864e33a9967ebbe649c73f80826f Mon Sep 17 00:00:00 2001 From: "deqiang.yu" <1017192795@qq.com> Date: Fri, 15 Apr 2022 16:02:34 +0800 Subject: [PATCH] init --- .gitignore | 2 + README.md | 65 +++++++++ example/cron.py | 13 ++ example/interval.py | 13 ++ plana/__init__.py | 44 ++++++ plana/core/Exceptions.py | 14 ++ plana/core/VERSION | 1 + plana/core/__init__.py | 104 ++++++++++++++ plana/core/backend.py | 72 ++++++++++ plana/core/core.py | 0 plana/core/log.py | 261 ++++++++++++++++++++++++++++++++++ plana/core/notice/__init__.py | 8 ++ plana/core/notice/base.py | 14 ++ plana/core/notice/dingding.py | 36 +++++ plana/core/notice/email.py | 49 +++++++ plana/core/schedulers.py | 63 ++++++++ plana/core/task.py | 57 ++++++++ plana/settings.py | 50 +++++++ requirements.txt | 6 + setup.py | 46 ++++++ static/emailnotice.jpg | Bin 0 -> 32921 bytes tests/settings.py | 14 ++ tests/test_main.py | 19 +++ tests/works/__init__.py | 7 + tests/works/work1.py | 23 +++ tests/works/work2.py | 7 + 26 files changed, 988 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 example/cron.py create mode 100644 example/interval.py create mode 100644 plana/__init__.py create mode 100644 plana/core/Exceptions.py create mode 100644 plana/core/VERSION create mode 100644 plana/core/__init__.py create mode 100644 plana/core/backend.py create mode 100644 plana/core/core.py create mode 100644 plana/core/log.py create mode 100644 plana/core/notice/__init__.py create mode 100644 plana/core/notice/base.py create mode 100644 plana/core/notice/dingding.py create mode 100644 plana/core/notice/email.py create mode 100644 plana/core/schedulers.py create mode 100644 plana/core/task.py create mode 100644 plana/settings.py create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 static/emailnotice.jpg create mode 100644 tests/settings.py create mode 100644 tests/test_main.py create mode 100644 tests/works/__init__.py create mode 100644 tests/works/work1.py create mode 100644 tests/works/work2.py 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 0000000000000000000000000000000000000000..124d1bce16b4f0829ae32015ae8649afd1d50b34 GIT binary patch literal 32921 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 WzptbE)=IMwQTGtzce)E~z4u>(s