diff --git a/cses/__init__.py b/cses/__init__.py index 8434628..5dcaf06 100644 --- a/cses/__init__.py +++ b/cses/__init__.py @@ -1,8 +1,16 @@ """使用 ``CSES`` 类可以表示、解析一个 CSES 课程文件。""" +import datetime +import os + import yaml import cses.structures as st import cses.errors as err +from cses.utils import log, repr_ +from cses import utils + +yaml.add_representer(datetime.time, utils.serialize_time) +log.info("cseslib4py initialized!") class CSES: @@ -10,12 +18,12 @@ class CSES: 用来表示、解析一个 CSES 课程文件的类。 该类有如下属性: - - ``schedule``: 课程安排列表,每个元素是一个 ``SingleDaySchedule`` 对象。 + - ``schedules``: 课程安排列表,每个元素是一个 ``SingleDaySchedule`` 对象。 - ``version``: 课程文件的版本号。目前只能为 ``1`` ,参见 CSES 官方文档与 Schema 文件。 - ``subjects``: 科目列表,每个元素是一个 ``Subject`` 对象。 Examples: - >>> c = CSES(open('../cses_example.yaml', encoding='utf8').read()) + >>> c = CSES.from_file('../cses_example.yaml') >>> c.version # 只会为 1 1 >>> c.subjects # doctest: +NORMALIZE_WHITESPACE @@ -26,41 +34,45 @@ class CSES: """ - def __init__(self, content: str): + def __init__(self): """ - 初始化 CSES。 + 初始化一个空CSES课表。 - Args: - content (str): CSES 课程文件的内容。 + .. warning:: 不应该直接调用 ``CSES()`` 构造函数, 而是应该使用 ``CSES.from_str()`` 工厂方法。 """ - self.schedule = None - self.version = None - self.subjects = None - - self._load(content) + self.version = 1 + self.subjects = {} + self.schedules = [] - def _load(self, content: str): + @classmethod + def from_str(cls, content: str) -> 'CSES': """ - 从 ``content`` 加载 CSES 课程文件的内容。 + 从 ``content`` 新建一个 CSES 课表对象。 Args: content (str): CSES 课程文件的内容。 """ + data = yaml.safe_load(content) + new_schedule = cls() + log.info(f"Loading CSES schedules {repr_(content)}") # 版本处理&检查 - self.version = data['version'] - if self.version != 1: - raise err.VersionError(f'不支持的版本号: {self.version}') + log.debug(f"Checking version: {data['version']}") + new_schedule.version = data['version'] + if new_schedule.version != 1: + raise err.VersionError(f'不支持的版本号: {new_schedule.version}') # 科目处理&检查 try: - self.subjects = {s['name']: st.Subject(**s) for s in data['subjects']} + log.debug(f"Processing subjects: {repr_(data['subjects'])}") + new_schedule.subjects = {s['name']: st.Subject(**s) for s in data['subjects']} except st.ValidationError as e: raise err.ParseError(f'科目数据有误: {data['subjects']}') from e # 课程处理&检查 schedules = data['schedules'] + log.debug(f"Processing schedules: {repr_(schedules)}") try: # 先构造课程列表,再构造课表 schedule_classes = {i['name']: i['classes'] for i in schedules} @@ -68,19 +80,82 @@ def _load(self, content: str): for name, classes in schedule_classes.items(): for lesson in classes: built_lessons[name].append( - st.Lesson(**(lesson | {'subject': self.subjects[lesson['subject']]})) - ) # 从self.subjects中获取合法的Subject对象 + st.Lesson(**lesson) + ) + log.debug(f"Built lessons: {repr_(built_lessons)}") # 从构造好的课程列表中构造课表 - self.schedule = [ + new_schedule.schedules = st.Schedule([ st.SingleDaySchedule( enable_day=day['enable_day'], classes=built_lessons[day['name']], name=day['name'], - weeks=st.WeekType(day['weeks']), + weeks=day['weeks'], ) for day in schedules - ] + ]) + log.debug(f"Built schedules: {repr_(new_schedule.schedules)}") except st.ValidationError as e: raise err.ParseError(f'课程数据有误: {data['schedules']}') from e + log.info(f"Created Schedule: {repr_(new_schedule)}") + return new_schedule + + @classmethod + def from_file(cls, fp: str) -> 'CSES': + """ + 从路径 ``fp`` 中读取并新建一个 CSES 课表对象。 + + Args: + fp (str): CSES 课程文件的路径。 + """ + with open(fp, encoding='utf8') as f: + return CSES.from_str(f.read()) + + def to_yaml(self) -> str: + """ + 将当前 CSES 课表对象转换为 YAML 字符串。 + + Returns: + str: 当前 CSES 课表对象的 YAML 字符串表示。 + """ + res = yaml.dump(self._gen_dict(), + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + indent=2, + Dumper=utils.NoAliasDumper) + log.debug(f"Generated YAML: {repr_(res)}") + return res + + def to_file(self, fp: str, mode: str = 'w'): + """ + 将当前 CSES 课表对象转换为 YAML CSES 课程CSES入路径 ``fp`` 中。若文件夹/文件不存在,则会自动创建。 + + Args: + fp (str): 要写入的文件路径。 + mode (str, optional): 写入模式,默认值为 ``'w'`` ,即覆盖写入。 + """ + os.makedirs(os.path.dirname(fp), exist_ok=True) + with open(fp, mode, encoding='utf8') as f: + f.write(self.to_yaml()) + log.info(f"Written CSES schedule file to {repr_(fp)}.") + + def _gen_dict(self) -> dict: + """ + 生成当前 CSES 课表对象的字典表示。 + + Returns: + dict: 当前 CSES 课表对象的字典表示。 + """ + return { + 'version': self.version, + 'subjects': [subject.model_dump() for subject in self.subjects.values()], + 'schedules': [schedule.model_dump() for schedule in self.schedules], + } + + def __eq__(self, other): + if isinstance(other, type(self)): + return self._gen_dict() == other._gen_dict() + else: + return NotImplemented diff --git a/cses/errors.py b/cses/errors.py index 16e440b..5f08733 100644 --- a/cses/errors.py +++ b/cses/errors.py @@ -11,4 +11,3 @@ class ParseError(CSESError): class VersionError(CSESError): """解析 CSES 课程文件时,版本号错误抛出的异常。""" - diff --git a/cses/structures.py b/cses/structures.py index 87f2cb9..d317d8c 100644 --- a/cses/structures.py +++ b/cses/structures.py @@ -5,12 +5,11 @@ .. caution:: 该模块中的数据结构仅用于表示课程结构(与其附属工具),不包含实际的读取/写入功能。 """ import datetime -from enum import Enum from collections import UserList from collections.abc import Sequence -from typing import override +from typing import override, Optional, Literal, Annotated -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ValidationError, BeforeValidator, field_serializer import cses.utils as utils @@ -37,9 +36,9 @@ class Subject(BaseModel): 'A101' """ name: str - simplified_name: str = "" - teacher: str = "" - room: str = "" + simplified_name: Optional[str] = None + teacher: Optional[str] = None + room: Optional[str] = None class Lesson(BaseModel): @@ -47,35 +46,29 @@ class Lesson(BaseModel): 单节课程。 Args: - subject (Subject): 课程的科目 - start_time (datetime.time): 开始的时间 - end_time (datetime.time): 结束的时间 + subject (str): 课程的科目,应与 ``Subject`` 中之一的 ``name`` 属性相同 + start_time (str | int | datetime.time): 开始的时间(若输入为 ``str`` 或 ``int`` ,则会转化为datetime.time对象) + end_time (str | int | datetime.time): 结束的时间(若输入为 ``str`` 或 ``int`` ,则会转化为datetime.time对象) + + .. warning:: + ``start_time`` 与 ``end_time`` 均为 ``datetime.time`` 对象,即使输入为(合法的) ``str`` (针对时间文字) 或 ``int`` (针对一天中的秒数)。 Examples: - >>> l = Lesson(subject=Subject(name='语文', simplified_name='语', teacher='张三'), \ - start_time=datetime.time(8, 0, 0), end_time=datetime.time(8, 45, 0)) - >>> l.subject.name + >>> l = Lesson(subject='语文', start_time="08:00:00", end_time="08:45:00") + >>> l.subject '语文' >>> l.start_time datetime.time(8, 0) >>> l.end_time datetime.time(8, 45) """ - subject: Subject - start_time: datetime.time - end_time: datetime.time - + subject: str + start_time: Annotated[datetime.time, BeforeValidator(utils.ensure_time)] + end_time: Annotated[datetime.time, BeforeValidator(utils.ensure_time)] -class WeekType(Enum): - """ - 周次类型。 - ALL: 适用于所有周 - ODD: 仅适用于单周 - EVEN: 仅适用于双周 - """ - ALL = "all" - ODD = "odd" - EVEN = "even" + @field_serializer("start_time", "end_time") + def serialize_time(self, time: datetime.time) -> str: + return time.strftime("%H:%M:%S") class SingleDaySchedule(BaseModel): @@ -91,18 +84,18 @@ class SingleDaySchedule(BaseModel): Examples: >>> s = SingleDaySchedule(enable_day=1, classes=[Lesson(subject=Subject(name='语文', \ simplified_name='语', teacher='张三'), start_time=datetime.time(8, 0, 0), \ - end_time=datetime.time(8, 45, 0))], name='星期一', weeks=WeekType.ALL) + end_time=datetime.time(8, 45, 0))], name='星期一', weeks='all') >>> s.enable_day 1 >>> s.name '星期一' >>> s.weeks - + 'all' """ - enable_day: int + enable_day: Literal[1, 2, 3, 4, 5, 6, 7] classes: list[Lesson] name: str - weeks: WeekType + weeks: Literal['all', 'odd', 'even'] def is_enabled_on_week(self, week: int) -> bool: """ @@ -117,7 +110,7 @@ def is_enabled_on_week(self, week: int) -> bool: Examples: >>> s = SingleDaySchedule(enable_day=1, classes=[Lesson(subject=Subject(name='语文', \ simplified_name='语', teacher='张三'), start_time=datetime.time(8, 0, 0), \ - end_time=datetime.time(8, 45, 0))], name='星期一', weeks=WeekType.ODD) + end_time=datetime.time(8, 45, 0))], name='星期一', weeks='odd') >>> s.is_enabled_on_week(3) True >>> s.is_enabled_on_week(6) @@ -126,9 +119,9 @@ def is_enabled_on_week(self, week: int) -> bool: True """ return { - WeekType.ALL: True, # 适用于所有周 -> 永久启用 - WeekType.ODD: week % 2 == 1, # 单周 - WeekType.EVEN: week % 2 == 0 # 双周 + 'all': True, # 适用于所有周 -> 永久启用 + 'odd': week % 2 == 1, # 单周 + 'even': week % 2 == 0 # 双周 }[self.weeks] def is_enabled_on_day(self, start_day: datetime.date, day: datetime.date) -> bool: @@ -145,7 +138,7 @@ def is_enabled_on_day(self, start_day: datetime.date, day: datetime.date) -> boo Examples: >>> s = SingleDaySchedule(enable_day=1, classes=[Lesson(subject=Subject(name='语文', \ simplified_name='语', teacher='张三'), start_time=datetime.time(8, 0, 0), \ - end_time=datetime.time(8, 45, 0))], name='星期一', weeks=WeekType.ODD) + end_time=datetime.time(8, 45, 0))], name='星期一', weeks='odd') >>> s.is_enabled_on_day(datetime.date(2025, 9, 1), datetime.date(2025, 9, 4)) True >>> s.is_enabled_on_day(datetime.date(2025, 9, 1), datetime.date(2025, 9, 16)) @@ -161,7 +154,7 @@ class Schedule(UserList[SingleDaySchedule]): 存储每天课程安排的列表。列表会按照星期排序。 .. caution:: - 在访问一个Schedule中的项目时,注意索引从 1 开始,而不是从 0 开始。 + 在访问一个 ``Schedule`` 中的项目时,注意索引从 1 开始,而不是从 0 开始。 这是为了可以按照星期访问课表,而不是按照 Python 的逻辑,所以访问星期一的课表使用 ``schedule[1]`` 而不是 ``schedule[0]`` 。 若你想要以 Python 的逻辑访问课表,请使用 ``data`` 属性,如访问星期一的课表需要使用 ``schedule.data[0]`` 。 @@ -169,10 +162,10 @@ class Schedule(UserList[SingleDaySchedule]): >>> s = Schedule([ ... SingleDaySchedule(enable_day=1, classes=[Lesson(subject=Subject(name='语文', ... simplified_name='语', teacher='张三'), start_time=datetime.time(8, 0, 0), - ... end_time=datetime.time(8, 45, 0))], name='星期一', weeks=WeekType.ODD), + ... end_time=datetime.time(8, 45, 0))], name='星期一', weeks='odd'), ... SingleDaySchedule(enable_day=2, classes=[Lesson(subject=Subject(name='数学', ... simplified_name='数', teacher='李四'), start_time=datetime.time(9, 0, 0), - ... end_time=datetime.time(9, 45, 0))], name='星期二', weeks=WeekType.EVEN) + ... end_time=datetime.time(9, 45, 0))], name='星期二', weeks='even') ... ]) >>> s[1].enable_day 1 diff --git a/cses/utils.py b/cses/utils.py index a45a33d..e989693 100644 --- a/cses/utils.py +++ b/cses/utils.py @@ -2,6 +2,24 @@ 该模块包含了一些用于内部处理的辅助函数。您也可以在您的代码中独立调用这些函数。 """ import datetime +import re +import logging +import reprlib +from sys import stderr + +import yaml + +logging.basicConfig(level=logging.DEBUG, + format="[{asctime} - {module}.{funcName}:{lineno}] \t{levelname}:\t {message}", + style="{", + stream=stderr) +log = logging.getLogger(__name__) +log.info(f"Loaded logger: {log!r}") + +repr_ = reprlib.Repr( + maxlevel=3, maxtuple=3, maxlist=3, maxarray=3, maxdict=3, maxset=3, maxfrozenset=3, maxdeque=3, + maxstring=30, maxlong=50, maxother=30, fillvalue=' ... ', indent=None +).repr def week_num(start_day: datetime.date, day: datetime.date) -> int: @@ -23,4 +41,69 @@ def week_num(start_day: datetime.date, day: datetime.date) -> int: >>> week_num(datetime.date(2025, 9, 1), datetime.date(2025, 10, 24)) 8 """ - return (day - start_day).days // 7 + 1 + res = (day - start_day).days // 7 + 1 + return res + + +def ensure_time(any_time: str | int | datetime.time) -> datetime.time: + """ + 将时间字符串/整数值转换为 ``datetime.time`` 对象。 + + Args: + any_time (str | int | datetime.time): 时间字符串,格式为 ``HH:MM:SS`` 或一个表示一天中经过的秒数的整数。 + + Returns: + datetime.time: 对应的时间对象 + + Examples: + >>> ensure_time("08:00:00") + datetime.time(8, 0) + >>> ensure_time(10*3600 + 10*60 +10) # =36610 + datetime.time(10, 10, 10) + """ + pattern_for_str = re.compile(r"([01]\d|2[0-3]):([0-5]\d):([0-5]\d)") # CSES Schema 指定的时间格式 + + if isinstance(any_time, str): # 使用regex处理字符串格式的时间 + if not (matched := pattern_for_str.match(any_time)): + raise ValueError(f"Invalid time format for CSES format: {any_time!r}") + else: + res = datetime.time(*map(int, matched.groups())) + + elif isinstance(any_time, int): # 将秒数转换为时间对象 + res = datetime.time(any_time // 3600, (any_time // 60) % 60, any_time % 60) + + elif isinstance(any_time, datetime.time): # 已经是datetime.time对象,直接返回 + res = any_time + + else: + log.error(f"Unknown time type: {type(any_time)}, raising an error...") + raise ValueError(f"Invalid time value for CSES format: {any_time}") + + return res + + +def serialize_time(dumper: yaml.representer.BaseRepresenter, any_time: datetime.time) -> yaml.nodes.ScalarNode: + """ + 适用于 ``datetime.time`` 对象的PyYAML钩子。 + + Args: + dumper: PyYAML Dumper对象,用于序列化 + any_time (datetime.time): 要转换的时间对象 + + Returns: + str: 对应的时间字符串,格式为 ``HH:MM:SS`` + """ + res = any_time.strftime("%H:%M:%S") + return dumper.represent_scalar('tag:yaml.org,2002:str', res) + + +class NoAliasDumper(yaml.Dumper): + """ + 禁用PyYAML的别名功能,确保每个对象都被序列化。这样就不会出现 ``&id001`` 等类似的引用。 + """ + def ignore_aliases(self, data): + return True + + def increase_indent(self, flow=False, indentless=False): + # 确保列表项使用两个空格缩进 + return super().increase_indent(flow, False) diff --git a/cses_example.yaml b/cses_example.yaml index d2fab62..20f1ee6 100644 --- a/cses_example.yaml +++ b/cses_example.yaml @@ -48,4 +48,11 @@ schedules: end_time: "09:00:00" - subject: 英语 start_time: "09:00:00" - end_time: "10:00:00" \ No newline at end of file + end_time: "10:00:00" + - name: HackerTest + enable_day: 7 + weeks: all + classes: + - subject: 英语 + start_time: 00:01:10 + end_time: 11:01:01 \ No newline at end of file