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')