Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 97 additions & 22 deletions cses/__init__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
"""使用 ``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:
"""
用来表示、解析一个 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
Expand All @@ -26,61 +34,128 @@ 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}
built_lessons = {i['name']: [] for i in schedules}
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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: 如果 fp 是一个没有目录的文件名,os.makedirs 可能会失败。

在调用 os.makedirs 之前检查 os.path.dirname(fp) 是否非空,以避免当 fp 只是一个文件名时出现错误。

Original comment in English

issue: os.makedirs may fail if fp is a filename without a directory.

Check if os.path.dirname(fp) is non-empty before calling os.makedirs to avoid errors when fp is just a filename.

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
1 change: 0 additions & 1 deletion cses/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,3 @@ class ParseError(CSESError):

class VersionError(CSESError):
"""解析 CSES 课程文件时,版本号错误抛出的异常。"""

69 changes: 31 additions & 38 deletions cses/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -37,45 +36,39 @@ 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):
"""
单节课程。

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):
Expand All @@ -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
<WeekType.ALL: 'all'>
'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:
"""
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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))
Expand All @@ -161,18 +154,18 @@ class Schedule(UserList[SingleDaySchedule]):
存储每天课程安排的列表。列表会按照星期排序。

.. caution::
在访问一个Schedule中的项目时,注意索引从 1 开始,而不是从 0 开始。
在访问一个 ``Schedule`` 中的项目时,注意索引从 1 开始,而不是从 0 开始。
这是为了可以按照星期访问课表,而不是按照 Python 的逻辑,所以访问星期一的课表使用 ``schedule[1]`` 而不是 ``schedule[0]`` 。
若你想要以 Python 的逻辑访问课表,请使用 ``data`` 属性,如访问星期一的课表需要使用 ``schedule.data[0]`` 。

Examples:
>>> 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
Expand Down
Loading