diff --git a/README.md b/README.md index 5a8515c..cbb982e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![download](https://pepy.tech/badge/cqu-kb) ![Upload Python Package](https://github.com/CQU-AI/cqu-kb/workflows/Upload%20Python%20Package/badge.svg) -cqu-kb 是一个基于python3的,导出重庆大学课程表的第三方工具。 +cqu-kb 是一个基于python3的,导出重庆大学**本科生和研究生**课程表的第三方工具。 ## 1. 安装和使用 @@ -37,3 +37,4 @@ cqu-kb 是一个基于python3的,导出重庆大学课程表的第三方工具 2. 本程序不存储用户的帐号,密码。 3. 本程序不存储任何人的课表,所有的数据来自于重庆大学教务网。 4. 本程序依赖于[`cqu-jxgl`](https://github.com/CQU-AI/cqu-jxgl) +5. 在订阅使用时,由于直接使用http的get,存在一定的隐私泄露风险,详见 #5 。 \ No newline at end of file diff --git a/setup.py b/setup.py index 5d60c7f..16e7c7a 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages -version = "0.2.2" +version = "0.3.0" # Read the contents of README file source_root = Path(".") diff --git a/src/cqu-kb-subscription/app.py b/src/cqu-kb-subscription/app.py index 14ec8e6..765e2cc 100644 --- a/src/cqu-kb-subscription/app.py +++ b/src/cqu-kb-subscription/app.py @@ -5,49 +5,72 @@ from flask import Flask, send_from_directory, Response, abort, redirect from cqu_kb.__main__ import server_main +from cqu_kb.utils import is_grad, is_under_grad + app = Flask(__name__) base_dir = Path("--enter-path-here--") -@app.route('/') +@app.route("/") def main_repo(): - return redirect('https://github.com/CQU-AI/cqu-kb') + return redirect("https://github.com/CQU-AI/cqu-kb") -@app.route('//') +@app.route("//") def get_ical(username, password): - if not username.isdigit() or len(username) != 8 or not username.startswith("20"): - abort(Response(f"Error: Invalid username. \n" - f"Please check the availability of your account and password.".replace("\n", '
'))) + if (not is_under_grad(username)) and (not is_grad(username)): + abort( + Response( + f"Error: Invalid username. \n" + f"Please check the availability of your account and password.".replace( + "\n", "
" + ) + ) + ) if username == "20170006" and "qazwsx".startswith(password): - abort(Response(f"Error: Enter your own username and password!. \n".replace("\n", '
'))) + abort( + Response( + f"Error: Enter your own username and password!. \n".replace( + "\n", "
" + ) + ) + ) - path = base_dir / f'{username}.ics' + path = base_dir / f"{username}.ics" try: server_main(username, password, path) except ValidationError as _: - abort(Response(f"Error: Invalid password. \n" - f"Please check the availability of your account and password.".replace("\n", '
'))) + abort( + Response( + f"Error: Invalid password. \n" + f"Please check the availability of your account and password.".replace( + "\n", "
" + ) + ) + ) except Exception as _: - abort(Response((f"Error".center(93, "=") + - f"\n" - f"{traceback.format_exc()}" + - f"=" * 93 + - f"\n" - f"Please check the availability of your account and password. \n" - f"If this problem cannot be solved, " - f"please report it at https://github.com/CQU-AI/cqu-kb/issues\n").replace("\n", '
'))) + abort( + Response( + ( + f"Error".center(93, "=") + f"\n" + f"{traceback.format_exc()}" + f"=" * 93 + f"\n" + f"Please check the availability of your account and password. \n" + f"If this problem cannot be solved, " + f"please report it at https://github.com/CQU-AI/cqu-kb/issues\n" + ).replace("\n", "
") + ) + ) return send_from_directory( directory=base_dir.absolute(), - filename=f'{username}.ics', - mimetype='text/calendar', + filename=f"{username}.ics", + mimetype="text/calendar", as_attachment=True, - attachment_filename=f'{username}.ics' + attachment_filename=f"{username}.ics", ) -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=False) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/src/cqu_kb/__init__.py b/src/cqu_kb/__init__.py index 9f8fbb1..835f883 100644 --- a/src/cqu_kb/__init__.py +++ b/src/cqu_kb/__init__.py @@ -3,4 +3,4 @@ __all__ = ["main", "console_main"] -# check_update('cqu-kb') +check_update("cqu-kb") diff --git a/src/cqu_kb/__main__.py b/src/cqu_kb/__main__.py index ca64126..35e69ba 100644 --- a/src/cqu_kb/__main__.py +++ b/src/cqu_kb/__main__.py @@ -1,10 +1,18 @@ import sys from cqu_kb.config.config import config, Config -from cqu_kb.utils import check_user, log, check_output_path +from cqu_kb.utils import check_user, log, check_output_path, select_core from cqu_kb.version import __version__ -from cqu_kb.core import get_cal, get_payload -from cqu_jxgl import Student + + +def main(username, password, path): + core = select_core(username) + cal = core(username, password).main() + + with open(path, "wb") as f: + f.write(cal.to_ical()) + + log(f"课表已经保存到{path}") def server_main(username, password, path): @@ -17,28 +25,6 @@ def local_main(): main(username, password, config["output"]["path"]) -def main(username, password, path): - student = Student( - username=username, - password=password - ) - - student.login() - - cal = get_cal( - student.post( - url="/znpk/Pri_StuSel_rpt.aspx", - headers={"Content-Type": "application/x-www-form-urlencoded;charset=utf-8"}, - data=get_payload(student) - ).content - ) - - with open(path, 'wb') as f: - f.write(cal.to_ical()) - - log(f'课表已经保存到{path}') - - def console_main(): import argparse @@ -47,7 +33,10 @@ def parse_args() -> argparse.Namespace: :return: Namespace with parsed arguments. """ - parser = argparse.ArgumentParser(prog="kb", description="第三方 重庆大学 课表导出工具", ) + parser = argparse.ArgumentParser( + prog="kb", + description="第三方 重庆大学 课表导出工具", + ) parser.add_argument( "-v", @@ -63,7 +52,10 @@ def parse_args() -> argparse.Namespace: action="store_true", ) parser.add_argument( - "-r", "--reset", help="重置配置项", action="store_true", + "-r", + "--reset", + help="重置配置项", + action="store_true", ) parser.add_argument( "-u", @@ -84,7 +76,7 @@ def parse_args() -> argparse.Namespace: "--output", help="课表输出路径", type=str, - default=config['output']['path'], + default=config["output"]["path"], ) return parser.parse_args() @@ -101,5 +93,5 @@ def parse_args() -> argparse.Namespace: local_main() -if __name__ == '__main__': +if __name__ == "__main__": local_main() diff --git a/src/cqu_kb/core.py b/src/cqu_kb/core.py deleted file mode 100644 index df5e927..0000000 --- a/src/cqu_kb/core.py +++ /dev/null @@ -1,143 +0,0 @@ -import uuid -from datetime import datetime, timedelta -from urllib import parse - -import pytz -from bs4 import BeautifulSoup as BS -from icalendar import Calendar, Event -from icalendar import Timezone, vDDDTypes - -from cqu_kb.config import config - - -def get_payload(student): - """ - This function prepares the payload to be sent in HTTP POST method. There are three main fields in the payload. The - only thing that should be paid attention to is "Sel_XNXQ" field, which denotes the years(学年) and terms(学期). - - :param student: an instance of Student from cqu-jxgl - :return: payload to be used in HTTP POST - """ - # First Get. Won't get any information but a cookie. - response = student.get("/znpk/Pri_StuSel.aspx") - soup = BS(response.content, features="html.parser") - return parse.urlencode({ - "Sel_XNXQ": soup.find("option")['value'], - "rad": "on", - "px": "1", - }) - - -def _add_datetime(component, name, time): - vdatetime = vDDDTypes(time) - if 'VALUE' in vdatetime.params and 'TZID' in vdatetime.params: - vdatetime.params.pop('VALUE') - component.add(name, vdatetime) - -def get_cal(page_content): - soup = BS(page_content.decode('gbk'), features="html.parser") - tables = soup.find_all(class_="page_table") - - courses_info = [] - for i in range(len(tables) // 2): - table = tables[i * 2 + 1] - rows = table.find('tbody').find_all('tr') - cols_num = len(rows[0].find_all('td')) - for row in rows: - course_info = {} - cols = row.find_all('td') - course_info.update({'course_name': cols[1].text.split(']')[-1] + ("" if cols_num == 13 else "(实验)")}) - course_info.update({'teacher': cols[ - -4 if cols_num == 13 else -5 - ].text}) - course_info.update({'weeks': cols[-3].text}) - course_info.update({'time': cols[-2].text.replace('节]', '')}) - course_info.update({'location': cols[-1].text}) - courses_info.append(course_info) - for i in range(len(courses_info)): - if courses_info[i]['course_name'] == '' or courses_info[i]['course_name'] == '(实验)': - courses_info[i]['course_name'] = courses_info[i - 1]['course_name'] - - chinese_to_numbers = { - '一': 0, - '二': 1, - '三': 2, - '四': 3, - '五': 4, - '六': 5, - '日': 6 - } - - coursenum_to_time = { - '1': (timedelta(hours=8, minutes=30), timedelta(hours=9, minutes=15)), - '2': (timedelta(hours=9, minutes=25), timedelta(hours=10, minutes=10)), - '3': (timedelta(hours=10, minutes=30), timedelta(hours=11, minutes=15)), - '4': (timedelta(hours=11, minutes=25), timedelta(hours=12, minutes=10)), - '5': (timedelta(hours=13, minutes=30), timedelta(hours=14, minutes=15)), - '6': (timedelta(hours=14, minutes=25), timedelta(hours=15, minutes=10)), - '7': (timedelta(hours=15, minutes=20), timedelta(hours=16, minutes=5)), - '8': (timedelta(hours=16, minutes=25), timedelta(hours=17, minutes=10)), - '9': (timedelta(hours=17, minutes=20), timedelta(hours=18, minutes=5)), - '10': (timedelta(hours=19, minutes=0), timedelta(hours=19, minutes=45)), - '11': (timedelta(hours=19, minutes=55), timedelta(hours=20, minutes=40)), - '12': (timedelta(hours=20, minutes=50), timedelta(hours=21, minutes=35)), - '13': (timedelta(hours=21, minutes=45), timedelta(hours=22, minutes=30)), - } - - term_start_time = datetime( - year=int(config["term_start_time"]["year"]), - month=int(config["term_start_time"]["month"]), - day=int(config["term_start_time"]["day"]), - tzinfo=pytz.timezone('Asia/Shanghai') - ) - - events = [] - - for course in courses_info: - week_nums = [] - week_segs = course['weeks'].split(',') - for seg in week_segs: - delimiter = [int(i) for i in seg.split('-')] - start = delimiter[0] - end = delimiter[1] if len(delimiter) == 2 else start - for i in range(start, end + 1): - week_nums.append(i) - day = chinese_to_numbers[course['time'].split('[')[0]] - seg = course['time'].split('[')[1].split('-') - if len(seg) == 2: - inweek_delta_start = timedelta(days=day) + coursenum_to_time[seg[0]][0] - inweek_delta_end = timedelta(days=day) + coursenum_to_time[seg[1]][1] - for week_num in week_nums: - event_start_datetime = term_start_time + (week_num - 1) * timedelta(days=7) + inweek_delta_start - event_end_datetime = term_start_time + (week_num - 1) * timedelta(days=7) + inweek_delta_end - event = Event() - event.add('summary', '[{}]{}'.format(course['teacher'], course['course_name'])) - event.add('location', course['location']) - _add_datetime(event, 'dtstart', event_start_datetime) - _add_datetime(event, 'dtend', event_end_datetime) - # Fix #2: 添加 dtstamp 与 uid 属性 - event.add('dtstamp', datetime.utcnow()) - namespace = uuid.UUID( - bytes=int(event_start_datetime.timestamp()).to_bytes(length=8, byteorder='big') + - int(event_end_datetime.timestamp()).to_bytes(length=8, byteorder='big') - ) - event.add('uid', uuid.uuid3(namespace, f"{course['course_name']}-{course['teacher']}")) - - events.append(event) - - cal = Calendar() - cal.add('prodid', f'-//重庆大学课表//{config["user_info"]["username"]}//Powered By cqu-kb//') - cal.add('version', '2.0') - cal.add_component(Timezone.from_ical("BEGIN:VTIMEZONE\n" - "TZID:Asia/Shanghai\n" - "X-LIC-LOCATION:Asia/Shanghai\n" - "BEGIN:STANDARD\n" - "TZNAME:CST\n" - "DTSTART:16010101T000000\n" - "TZOFFSETFROM:+0800\n" - "TZOFFSETTO:+0800\n" - "END:STANDARD\n" - "END:VTIMEZONE\n")) - for event in events: - cal.add_component(event) - return cal diff --git a/src/cqu_kb/core/KBCore.py b/src/cqu_kb/core/KBCore.py new file mode 100644 index 0000000..0031885 --- /dev/null +++ b/src/cqu_kb/core/KBCore.py @@ -0,0 +1,141 @@ +import uuid +from abc import abstractmethod, ABC +from datetime import datetime, timedelta + +import pytz +from icalendar import Calendar, Event +from icalendar import Timezone, vDDDTypes + +from cqu_kb.config import config + + +class KBCore(ABC): + def __init__(self, username, password): + self.password = password + self.username = username + + @abstractmethod + def _get_payload(self, **kwargs): + pass + + @abstractmethod + def main(self): + pass + + @abstractmethod + def get_course_info(self, page_content): + pass + + @staticmethod + def _add_datetime(component, name, time): + vdatetime = vDDDTypes(time) + if "VALUE" in vdatetime.params and "TZID" in vdatetime.params: + vdatetime.params.pop("VALUE") + component.add(name, vdatetime) + + def get_cal(self, course_info): + chinese_to_numbers = {"一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6} + + coursenum_to_time = { + "1": (timedelta(hours=8, minutes=30), timedelta(hours=9, minutes=15)), + "2": (timedelta(hours=9, minutes=25), timedelta(hours=10, minutes=10)), + "3": (timedelta(hours=10, minutes=30), timedelta(hours=11, minutes=15)), + "4": (timedelta(hours=11, minutes=25), timedelta(hours=12, minutes=10)), + "5": (timedelta(hours=13, minutes=30), timedelta(hours=14, minutes=15)), + "6": (timedelta(hours=14, minutes=25), timedelta(hours=15, minutes=10)), + "7": (timedelta(hours=15, minutes=20), timedelta(hours=16, minutes=5)), + "8": (timedelta(hours=16, minutes=25), timedelta(hours=17, minutes=10)), + "9": (timedelta(hours=17, minutes=20), timedelta(hours=18, minutes=5)), + "10": (timedelta(hours=19, minutes=0), timedelta(hours=19, minutes=45)), + "11": (timedelta(hours=19, minutes=55), timedelta(hours=20, minutes=40)), + "12": (timedelta(hours=20, minutes=50), timedelta(hours=21, minutes=35)), + "13": (timedelta(hours=21, minutes=45), timedelta(hours=22, minutes=30)), + } + + term_start_time = datetime( + year=int(config["term_start_time"]["year"]), + month=int(config["term_start_time"]["month"]), + day=int(config["term_start_time"]["day"]), + tzinfo=pytz.timezone("Asia/Shanghai"), + ) + + events = [] + + for course in course_info: + week_nums = [] + week_segs = course["weeks"].split(",") + for seg in week_segs: + delimiter = [int(i) for i in seg.split("-")] + start = delimiter[0] + end = delimiter[1] if len(delimiter) == 2 else start + for i in range(start, end + 1): + week_nums.append(i) + if not course["time"].split("[")[0].isdigit(): + day = chinese_to_numbers[course["time"].split("[")[0]] + else: + day = int(course["time"].split("[")[0]) + seg = course["time"].split("[")[1].split("-") + if len(seg) == 2: + inweek_delta_start = timedelta(days=day) + coursenum_to_time[seg[0]][0] + inweek_delta_end = timedelta(days=day) + coursenum_to_time[seg[1]][1] + for week_num in week_nums: + event_start_datetime = ( + term_start_time + + (week_num - 1) * timedelta(days=7) + + inweek_delta_start + ) + event_end_datetime = ( + term_start_time + + (week_num - 1) * timedelta(days=7) + + inweek_delta_end + ) + event = Event() + event.add( + "summary", + "[{}]{}".format(course["teacher"], course["course_name"]), + ) + event.add("location", course["location"]) + self._add_datetime(event, "dtstart", event_start_datetime) + self._add_datetime(event, "dtend", event_end_datetime) + # Fix #2: 添加 dtstamp 与 uid 属性 + event.add("dtstamp", datetime.utcnow()) + namespace = uuid.UUID( + bytes=int(event_start_datetime.timestamp()).to_bytes( + length=8, byteorder="big" + ) + + int(event_end_datetime.timestamp()).to_bytes( + length=8, byteorder="big" + ) + ) + event.add( + "uid", + uuid.uuid3( + namespace, f"{course['course_name']}-{course['teacher']}" + ), + ) + + events.append(event) + + cal = Calendar() + cal.add( + "prodid", + f'-//重庆大学课表//{config["user_info"]["username"]}//Powered By cqu-kb//', + ) + cal.add("version", "2.0") + cal.add_component( + Timezone.from_ical( + "BEGIN:VTIMEZONE\n" + "TZID:Asia/Shanghai\n" + "X-LIC-LOCATION:Asia/Shanghai\n" + "BEGIN:STANDARD\n" + "TZNAME:CST\n" + "DTSTART:16010101T000000\n" + "TZOFFSETFROM:+0800\n" + "TZOFFSETTO:+0800\n" + "END:STANDARD\n" + "END:VTIMEZONE\n" + ) + ) + for event in events: + cal.add_component(event) + return cal diff --git a/src/cqu_kb/core/KBCoreGrad.py b/src/cqu_kb/core/KBCoreGrad.py new file mode 100644 index 0000000..dc97511 --- /dev/null +++ b/src/cqu_kb/core/KBCoreGrad.py @@ -0,0 +1,65 @@ +import re + +from bs4 import BeautifulSoup as BS +from requests import Session + +from cqu_kb.core.KBCore import KBCore + + +class KBCoreGrad(KBCore): + def _get_payload(self): + return { + "userId": self.username, + "password": self.password, + "userType": "student", + "imageField.x": "50", + "imageField.y": "6", + } + + def main(self): + # 实例化 requests.Session 对象 + s = Session() + + # url 配置 + base_url = "http://mis.cqu.edu.cn/mis" + login_url = base_url + "/login.jsp" + select_course_url = base_url + "/student/plan/select_course.jsp" + cal_base_url = base_url + "/curricula/show_stu.jsp?stuSerial=" + + # 系统登录 + s.post(login_url, self._get_payload()) + + # 获取 stuSerial 参数 + res = s.get(select_course_url) + stu_serial = re.findall(r"stuSerial=(\d*)?", res.text)[0] + + # 获取课表 html + cal_url = cal_base_url + stu_serial + cal = s.get(cal_url) + + course_info = self.get_course_info(cal.text) + return self.get_cal(course_info) + + def get_course_info(self, page_content): + page_content = page_content.replace("
", "[br]").replace("
", "[br]") + + soup = BS(page_content, features="html.parser") + table = soup.find_all("table")[0] + + courses_info = [] + for line in table.find_all("tr")[2:]: + for i, element in enumerate(line.find_all("td")[1:-1]): + courses = tuple(filter(lambda x: x != "", element.text.split("[br]"))) + for j in range(len(courses) // 7): + courses_info.append( + { + "course_name": courses[j * 7 + 2][3:], + "teacher": courses[j * 7 + 5][3:], + "weeks": courses[j * 7 + 3][3:-1].replace(" ", ","), + "time": "{}[{}".format( + i, courses[j * 7 + 4][3:] + ), # compromise with UnderGrad core + "location": courses[j * 7 + 6][3:], + } + ) + return courses_info diff --git a/src/cqu_kb/core/KBCoreUnderGrad.py b/src/cqu_kb/core/KBCoreUnderGrad.py new file mode 100644 index 0000000..d0d4511 --- /dev/null +++ b/src/cqu_kb/core/KBCoreUnderGrad.py @@ -0,0 +1,70 @@ +from urllib import parse + +from bs4 import BeautifulSoup as BS +from cqu_jxgl import Student + +from cqu_kb.core.KBCore import KBCore + + +class KBCoreUnderGrad(KBCore): + def _get_payload(self, student): + """ + This function prepares the payload to be sent in HTTP POST method. There are three main fields in the payload. The + only thing that should be paid attention to is "Sel_XNXQ" field, which denotes the years(学年) and terms(学期). + + :param student: an instance of Student from cqu-jxgl + :return: payload to be used in HTTP POST + """ + # First Get. Won't get any information but a cookie. + response = student.get("/znpk/Pri_StuSel.aspx") + soup = BS(response.content, features="html.parser") + return parse.urlencode( + { + "Sel_XNXQ": soup.find("option")["value"], + "rad": "on", + "px": "1", + } + ) + + def main(self): + student = Student(username=self.username, password=self.password) + + student.login() + page_content = student.post( + url="/znpk/Pri_StuSel_rpt.aspx", + headers={"Content-Type": "application/x-www-form-urlencoded;charset=utf-8"}, + data=self._get_payload(student), + ).content + course_info = self.get_course_info(page_content) + return self.get_cal(course_info) + + def get_course_info(self, page_content): + soup = BS(page_content.decode("gbk"), features="html.parser") + tables = soup.find_all(class_="page_table") + + courses_info = [] + for i in range(len(tables) // 2): + table = tables[i * 2 + 1] + rows = table.find("tbody").find_all("tr") + cols_num = len(rows[0].find_all("td")) + for row in rows: + course_info = {} + cols = row.find_all("td") + course_info.update( + { + "course_name": cols[1].text.split("]")[-1] + + ("" if cols_num == 13 else "(实验)") + } + ) + course_info.update({"teacher": cols[-4 if cols_num == 13 else -5].text}) + course_info.update({"weeks": cols[-3].text}) + course_info.update({"time": cols[-2].text.replace("节]", "")}) + course_info.update({"location": cols[-1].text}) + courses_info.append(course_info) + for i in range(len(courses_info)): + if ( + courses_info[i]["course_name"] == "" + or courses_info[i]["course_name"] == "(实验)" + ): + courses_info[i]["course_name"] = courses_info[i - 1]["course_name"] + return courses_info diff --git a/src/cqu_kb/core/__init__.py b/src/cqu_kb/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cqu_kb/utils.py b/src/cqu_kb/utils.py index c45f781..e47ccde 100644 --- a/src/cqu_kb/utils.py +++ b/src/cqu_kb/utils.py @@ -8,22 +8,24 @@ import requests from cqu_kb.config.config import config +from cqu_kb.core.KBCoreGrad import KBCoreGrad +from cqu_kb.core.KBCoreUnderGrad import KBCoreUnderGrad from cqu_kb.version import __version__ ERROR_COUNT = 0 def check_output_path(): - if config['output']['path'] is None: + if config["output"]["path"] is None: flag = False for i in ["Desktop", "桌面", "desktop"]: if (Path.home() / i).is_dir(): flag = True break if flag: - config['output']['path'] = Path.home() / i / "课表.ics" + config["output"]["path"] = Path.home() / i / "课表.ics" else: - config['output']['path'] = Path("./课表.ics").absolute() + config["output"]["path"] = Path("./课表.ics").absolute() def exit(): @@ -53,8 +55,8 @@ def reset_error_count(): def check_user(): if ( - config["user_info"]["username"] is None - or config["user_info"]["password"] is None + config["user_info"]["username"] is None + or config["user_info"]["password"] is None ): print("未找到有效的帐号和密码,请输入你的帐号和密码,它们将被保存在你的电脑上以备下次使用") try: @@ -77,3 +79,20 @@ def check_update(project_name): f"{project_name}的最新版本为{latest_version},当前安装的是{__version__},建议使用`pip install {project_name} -U`来升级", warning=True, ) + + +def select_core(username): + if is_under_grad(username): + return KBCoreUnderGrad + elif is_grad(username): + return KBCoreGrad + else: + raise AttributeError(f"无法匹配的学号:{username}") + + +def is_under_grad(username): + return username.isdigit() and len(username) == 8 and username.startswith("20") + + +def is_grad(username): + return username.isdigit() and len(username) == 12 and username.startswith("20") diff --git a/src/cqu_kb/version.py b/src/cqu_kb/version.py index 52c1159..de0f3eb 100644 --- a/src/cqu_kb/version.py +++ b/src/cqu_kb/version.py @@ -1,2 +1,2 @@ """This file is auto-generated by setup.py, please do not alter.""" -__version__ = "0.2.2" +__version__ = "0.3.0"