calendargen 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- calendargen-0.1.0/LICENSE +21 -0
- calendargen-0.1.0/PKG-INFO +96 -0
- calendargen-0.1.0/README.md +79 -0
- calendargen-0.1.0/pyproject.toml +33 -0
- calendargen-0.1.0/setup.cfg +4 -0
- calendargen-0.1.0/src/calendargen/__init__.py +1 -0
- calendargen-0.1.0/src/calendargen/cli.py +29 -0
- calendargen-0.1.0/src/calendargen/schedule.py +324 -0
- calendargen-0.1.0/src/calendargen.egg-info/PKG-INFO +96 -0
- calendargen-0.1.0/src/calendargen.egg-info/SOURCES.txt +13 -0
- calendargen-0.1.0/src/calendargen.egg-info/dependency_links.txt +1 -0
- calendargen-0.1.0/src/calendargen.egg-info/entry_points.txt +2 -0
- calendargen-0.1.0/src/calendargen.egg-info/requires.txt +6 -0
- calendargen-0.1.0/src/calendargen.egg-info/top_level.txt +1 -0
- calendargen-0.1.0/tests/test_calendar.py +424 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Harumaru169
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: calendargen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate an iCalendar file from a TOML class schedule.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/Harumaru169/calendargen
|
|
7
|
+
Project-URL: Issues, https://github.com/Harumaru169/calendargen/issues
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: icalendar
|
|
12
|
+
Requires-Dist: tzlocal
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: ipython; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# CalendarGen
|
|
19
|
+
|
|
20
|
+
CalendarGen turns a declarative TOML schedule into an iCalendar (`.ics`) file. It is designed for events that usually happen every week, such as university lectures. Define the term dates, class times, and weekly events once; CalendarGen expands them into individual calendar events.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Requires Python 3.12 or later.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install calendargen
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With [uv](https://docs.astral.sh/uv/), install the CLI as a tool:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
uv tool install calendargen
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
To install from a local checkout for development, use `uv tool install --editable .`. You can also run it from this repository without a separate installation:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv run calendargen init calendar.toml
|
|
40
|
+
uv run calendargen gen calendar.toml calendar.ics
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
Create a starter schedule, edit it, and generate an ICS file:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
calendargen init calendar.toml
|
|
49
|
+
calendargen gen calendar.toml calendar.ics
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Import `calendar.ics` into a calendar application that supports iCalendar files. `init` creates a new file and will not overwrite an existing one.
|
|
53
|
+
|
|
54
|
+
### Updating an imported schedule
|
|
55
|
+
|
|
56
|
+
Edit `calendar.toml` and run `calendargen gen calendar.toml calendar.ics` again. Before importing the new ICS file, remove the events from the previous import in your calendar application. A distinctive `description_footer` helps you find those events; importing the new file alone may leave duplicates, depending on the application. Import the regenerated ICS file after removing the old events.
|
|
57
|
+
|
|
58
|
+
## Schedule format
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
term_start = 2026-10-01
|
|
62
|
+
term_end = 2027-01-22
|
|
63
|
+
excluded_dates = [2026-11-03]
|
|
64
|
+
description_footer = "Generated by CalendarGen (Fall 2026)"
|
|
65
|
+
|
|
66
|
+
[time_slots]
|
|
67
|
+
"period 2" = { start = "10:30", end = "12:00" }
|
|
68
|
+
|
|
69
|
+
[[courses]]
|
|
70
|
+
title = "Applied Algebra"
|
|
71
|
+
weekday = "mon"
|
|
72
|
+
time_slot = "period 2"
|
|
73
|
+
location = "Room 201"
|
|
74
|
+
description = "Professor Smith"
|
|
75
|
+
|
|
76
|
+
[[day_overrides]]
|
|
77
|
+
date = 2026-10-15
|
|
78
|
+
use_weekday = "mon"
|
|
79
|
+
all_day_event_title = "Monday schedule"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `term_start` and `term_end` are inclusive TOML dates.
|
|
83
|
+
- `excluded_dates` skips all events on the listed dates. It can be omitted.
|
|
84
|
+
- Each `time_slots` entry defines a named local start and end time. Courses refer to these names through `time_slot`.
|
|
85
|
+
- Each `[[courses]]` entry defines a weekly event. `title`, `weekday`, and `time_slot` are required; `location` and `description` are optional. Weekdays are `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, or `sun`.
|
|
86
|
+
- A `[[day_overrides]]` entry uses another weekday's course schedule on a specific date. In the example, October 15 follows the Monday schedule. Overrides are optional and cannot share a date with `excluded_dates`.
|
|
87
|
+
- `all_day_event_title` is an optional text field on a day override. When nonblank, it creates an additional all-day event on that date with the text as its title. Omit it on overrides that need no all-day event.
|
|
88
|
+
- `description_footer` is optional. When set, CalendarGen appends it after a blank line to each course description and adds it as the description of events that have none, including all-day override events. Omit it or set it to a blank string to leave descriptions unchanged. A distinctive footer makes it easier to search for and delete all generated events after importing an ICS file, so you can revise the schedule and import it again.
|
|
89
|
+
|
|
90
|
+
Unknown field names in the schedule, time slots, courses, or day overrides are errors. Dates in `excluded_dates` and `day_overrides` must fall between `term_start` and `term_end`, inclusive; out-of-range dates are also errors. CalendarGen does not create or replace the ICS file when validation fails.
|
|
91
|
+
|
|
92
|
+
Event times use the local timezone of the machine running CalendarGen. The generated ICS file contains one event for each occurrence within the term.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT. See [LICENSE](https://github.com/Harumaru169/calendargen/blob/main/LICENSE).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# CalendarGen
|
|
2
|
+
|
|
3
|
+
CalendarGen turns a declarative TOML schedule into an iCalendar (`.ics`) file. It is designed for events that usually happen every week, such as university lectures. Define the term dates, class times, and weekly events once; CalendarGen expands them into individual calendar events.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Requires Python 3.12 or later.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install calendargen
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
With [uv](https://docs.astral.sh/uv/), install the CLI as a tool:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
uv tool install calendargen
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
To install from a local checkout for development, use `uv tool install --editable .`. You can also run it from this repository without a separate installation:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
uv run calendargen init calendar.toml
|
|
23
|
+
uv run calendargen gen calendar.toml calendar.ics
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Create a starter schedule, edit it, and generate an ICS file:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
calendargen init calendar.toml
|
|
32
|
+
calendargen gen calendar.toml calendar.ics
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Import `calendar.ics` into a calendar application that supports iCalendar files. `init` creates a new file and will not overwrite an existing one.
|
|
36
|
+
|
|
37
|
+
### Updating an imported schedule
|
|
38
|
+
|
|
39
|
+
Edit `calendar.toml` and run `calendargen gen calendar.toml calendar.ics` again. Before importing the new ICS file, remove the events from the previous import in your calendar application. A distinctive `description_footer` helps you find those events; importing the new file alone may leave duplicates, depending on the application. Import the regenerated ICS file after removing the old events.
|
|
40
|
+
|
|
41
|
+
## Schedule format
|
|
42
|
+
|
|
43
|
+
```toml
|
|
44
|
+
term_start = 2026-10-01
|
|
45
|
+
term_end = 2027-01-22
|
|
46
|
+
excluded_dates = [2026-11-03]
|
|
47
|
+
description_footer = "Generated by CalendarGen (Fall 2026)"
|
|
48
|
+
|
|
49
|
+
[time_slots]
|
|
50
|
+
"period 2" = { start = "10:30", end = "12:00" }
|
|
51
|
+
|
|
52
|
+
[[courses]]
|
|
53
|
+
title = "Applied Algebra"
|
|
54
|
+
weekday = "mon"
|
|
55
|
+
time_slot = "period 2"
|
|
56
|
+
location = "Room 201"
|
|
57
|
+
description = "Professor Smith"
|
|
58
|
+
|
|
59
|
+
[[day_overrides]]
|
|
60
|
+
date = 2026-10-15
|
|
61
|
+
use_weekday = "mon"
|
|
62
|
+
all_day_event_title = "Monday schedule"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `term_start` and `term_end` are inclusive TOML dates.
|
|
66
|
+
- `excluded_dates` skips all events on the listed dates. It can be omitted.
|
|
67
|
+
- Each `time_slots` entry defines a named local start and end time. Courses refer to these names through `time_slot`.
|
|
68
|
+
- Each `[[courses]]` entry defines a weekly event. `title`, `weekday`, and `time_slot` are required; `location` and `description` are optional. Weekdays are `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, or `sun`.
|
|
69
|
+
- A `[[day_overrides]]` entry uses another weekday's course schedule on a specific date. In the example, October 15 follows the Monday schedule. Overrides are optional and cannot share a date with `excluded_dates`.
|
|
70
|
+
- `all_day_event_title` is an optional text field on a day override. When nonblank, it creates an additional all-day event on that date with the text as its title. Omit it on overrides that need no all-day event.
|
|
71
|
+
- `description_footer` is optional. When set, CalendarGen appends it after a blank line to each course description and adds it as the description of events that have none, including all-day override events. Omit it or set it to a blank string to leave descriptions unchanged. A distinctive footer makes it easier to search for and delete all generated events after importing an ICS file, so you can revise the schedule and import it again.
|
|
72
|
+
|
|
73
|
+
Unknown field names in the schedule, time slots, courses, or day overrides are errors. Dates in `excluded_dates` and `day_overrides` must fall between `term_start` and `term_end`, inclusive; out-of-range dates are also errors. CalendarGen does not create or replace the ICS file when validation fails.
|
|
74
|
+
|
|
75
|
+
Event times use the local timezone of the machine running CalendarGen. The generated ICS file contains one event for each occurrence within the term.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT. See [LICENSE](https://github.com/Harumaru169/calendargen/blob/main/LICENSE).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "calendargen"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Generate an iCalendar file from a TOML class schedule."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = ["icalendar", "tzlocal"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/Harumaru169/calendargen"
|
|
17
|
+
Issues = "https://github.com/Harumaru169/calendargen/issues"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
calendargen = "calendargen.cli:main"
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["ipython", "ruff"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
lint.select = ["E", "F", "I", "UP", "B"]
|
|
30
|
+
lint.ignore = ["E501"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff.format]
|
|
33
|
+
quote-style = "double"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generate class calendars from TOML schedules."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Command-line interface for CalendarGen."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .schedule import TEMPLATE, generate_calendar, load_schedule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
parser = argparse.ArgumentParser(prog="calendargen")
|
|
11
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
12
|
+
|
|
13
|
+
init = commands.add_parser("init", help="create a sample TOML schedule")
|
|
14
|
+
init.add_argument("path", type=Path, nargs="?", default=Path("calendar.toml"))
|
|
15
|
+
|
|
16
|
+
gen = commands.add_parser("gen", help="generate an ICS file")
|
|
17
|
+
gen.add_argument("toml_path", type=Path)
|
|
18
|
+
gen.add_argument("ics_path", type=Path)
|
|
19
|
+
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
try:
|
|
22
|
+
if args.command == "init":
|
|
23
|
+
with args.path.open("x", encoding="utf-8") as file:
|
|
24
|
+
file.write(TEMPLATE)
|
|
25
|
+
else:
|
|
26
|
+
schedule = load_schedule(args.toml_path)
|
|
27
|
+
args.ics_path.write_bytes(generate_calendar(schedule, args.toml_path))
|
|
28
|
+
except (OSError, ValueError) as error:
|
|
29
|
+
parser.error(str(error))
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Read a class schedule and turn it into an iCalendar file."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, date, datetime, time, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from uuid import NAMESPACE_URL, uuid5
|
|
8
|
+
|
|
9
|
+
from icalendar import Calendar, Event
|
|
10
|
+
from tzlocal import get_localzone
|
|
11
|
+
|
|
12
|
+
TEMPLATE = """term_start = 2026-10-01
|
|
13
|
+
term_end = 2027-01-22
|
|
14
|
+
excluded_dates = [2026-10-12, 2026-11-03]
|
|
15
|
+
description_footer = "Generated by CalendarGen (Fall 2026)"
|
|
16
|
+
|
|
17
|
+
[time_slots]
|
|
18
|
+
"period 1" = { start = "08:45", end = "10:15" }
|
|
19
|
+
"period 2" = { start = "10:30", end = "12:00" }
|
|
20
|
+
"lunch" = { start = "12:00", end = "13:15" }
|
|
21
|
+
"period 3" = { start = "13:15", end = "14:45" }
|
|
22
|
+
"period 4" = { start = "15:00", end = "16:30" }
|
|
23
|
+
"period 5" = { start = "16:45", end = "18:15" }
|
|
24
|
+
|
|
25
|
+
[[courses]]
|
|
26
|
+
title = "Applied Algebra"
|
|
27
|
+
description = "Professor Tsujimoto"
|
|
28
|
+
weekday = "mon"
|
|
29
|
+
time_slot = "period 2"
|
|
30
|
+
location = "Lecture Room 2, Research Building No.8"
|
|
31
|
+
|
|
32
|
+
[[courses]]
|
|
33
|
+
title = "Fluid Mechanics"
|
|
34
|
+
description = "Professor Taguchi"
|
|
35
|
+
weekday = "tue"
|
|
36
|
+
time_slot = "period 2"
|
|
37
|
+
location = "Lecture Room 4, Research Building No.8"
|
|
38
|
+
|
|
39
|
+
[[day_overrides]]
|
|
40
|
+
date = 2026-10-15
|
|
41
|
+
use_weekday = "mon"
|
|
42
|
+
all_day_event_title = "swapped to Monday schedule"
|
|
43
|
+
|
|
44
|
+
[[day_overrides]]
|
|
45
|
+
date = 2026-11-26
|
|
46
|
+
use_weekday = "tue"
|
|
47
|
+
all_day_event_title = "swapped to Tuesday schedule, don't forget!"
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
WEEKDAYS = {
|
|
51
|
+
name: number
|
|
52
|
+
for number, name in enumerate(("mon", "tue", "wed", "thu", "fri", "sat", "sun"))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class TimeSlot:
|
|
58
|
+
name: str
|
|
59
|
+
start: time
|
|
60
|
+
end: time
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class Course:
|
|
65
|
+
title: str
|
|
66
|
+
weekday: int
|
|
67
|
+
time_slot: TimeSlot
|
|
68
|
+
location: str | None = None
|
|
69
|
+
description: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class DayOverride:
|
|
74
|
+
date: date
|
|
75
|
+
use_weekday: int
|
|
76
|
+
all_day_event_title: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Schedule:
|
|
81
|
+
start: date
|
|
82
|
+
end: date
|
|
83
|
+
excluded_dates: frozenset[date]
|
|
84
|
+
time_slots: dict[str, TimeSlot]
|
|
85
|
+
courses: tuple[Course, ...]
|
|
86
|
+
overrides: dict[date, DayOverride]
|
|
87
|
+
description_footer: str | None = None
|
|
88
|
+
|
|
89
|
+
def weekday_on(self, day: date) -> int:
|
|
90
|
+
override = self.overrides.get(day)
|
|
91
|
+
return day.weekday() if override is None else override.use_weekday
|
|
92
|
+
|
|
93
|
+
def courses_on(self, day: date) -> list[tuple[int, Course]]:
|
|
94
|
+
if day < self.start or day > self.end or day in self.excluded_dates:
|
|
95
|
+
return []
|
|
96
|
+
weekday = self.weekday_on(day)
|
|
97
|
+
return [
|
|
98
|
+
(index, course)
|
|
99
|
+
for index, course in enumerate(self.courses)
|
|
100
|
+
if course.weekday == weekday
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _date(value: object, name: str) -> date:
|
|
105
|
+
if type(value) is not date:
|
|
106
|
+
raise ValueError(f"{name} must be a TOML date")
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _check_fields(data: dict, allowed: set[str], context: str) -> None:
|
|
111
|
+
for name in data:
|
|
112
|
+
if name not in allowed:
|
|
113
|
+
raise ValueError(f"{context} has unknown field {name!r}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_term_date(day: date, start: date, end: date, context: str) -> None:
|
|
117
|
+
if not start <= day <= end:
|
|
118
|
+
raise ValueError(f"{context} {day} is outside term {start} to {end}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _weekday(value: object, name: str) -> int:
|
|
122
|
+
if not isinstance(value, str) or value not in WEEKDAYS:
|
|
123
|
+
raise ValueError(f"{name} must be one of: {', '.join(WEEKDAYS)}")
|
|
124
|
+
return WEEKDAYS[value]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _clock(value: object, name: str) -> time:
|
|
128
|
+
if not isinstance(value, str):
|
|
129
|
+
raise ValueError(f"{name} must be a time string such as '08:45'")
|
|
130
|
+
try:
|
|
131
|
+
result = time.fromisoformat(value)
|
|
132
|
+
except ValueError as error:
|
|
133
|
+
raise ValueError(f"{name} must be a time string such as '08:45'") from error
|
|
134
|
+
if result.tzinfo is not None:
|
|
135
|
+
raise ValueError(f"{name} must be a local time without an offset")
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_schedule(path: Path) -> Schedule:
|
|
140
|
+
"""Load and check the fields needed to generate the calendar."""
|
|
141
|
+
try:
|
|
142
|
+
with path.open("rb") as file:
|
|
143
|
+
data = tomllib.load(file)
|
|
144
|
+
except tomllib.TOMLDecodeError as error:
|
|
145
|
+
raise ValueError(f"invalid TOML in {path}: {error}") from error
|
|
146
|
+
|
|
147
|
+
_check_fields(
|
|
148
|
+
data,
|
|
149
|
+
{
|
|
150
|
+
"term_start",
|
|
151
|
+
"term_end",
|
|
152
|
+
"excluded_dates",
|
|
153
|
+
"description_footer",
|
|
154
|
+
"time_slots",
|
|
155
|
+
"courses",
|
|
156
|
+
"day_overrides",
|
|
157
|
+
},
|
|
158
|
+
"schedule",
|
|
159
|
+
)
|
|
160
|
+
start = _date(data.get("term_start"), "term_start")
|
|
161
|
+
end = _date(data.get("term_end"), "term_end")
|
|
162
|
+
if end < start:
|
|
163
|
+
raise ValueError("term_end must not precede term_start")
|
|
164
|
+
|
|
165
|
+
excluded_dates = data.get("excluded_dates", [])
|
|
166
|
+
if not isinstance(excluded_dates, list):
|
|
167
|
+
raise ValueError("excluded_dates must be a list of TOML dates")
|
|
168
|
+
parsed_excluded_dates: set[date] = set()
|
|
169
|
+
for value in excluded_dates:
|
|
170
|
+
day = _date(value, "excluded_dates item")
|
|
171
|
+
_check_term_date(day, start, end, "excluded_dates item")
|
|
172
|
+
parsed_excluded_dates.add(day)
|
|
173
|
+
excluded_dates = frozenset(parsed_excluded_dates)
|
|
174
|
+
|
|
175
|
+
description_footer = data.get("description_footer")
|
|
176
|
+
if description_footer is not None and not isinstance(description_footer, str):
|
|
177
|
+
raise ValueError("description_footer must be a string")
|
|
178
|
+
|
|
179
|
+
time_slots = data.get("time_slots")
|
|
180
|
+
if not isinstance(time_slots, dict) or not time_slots:
|
|
181
|
+
raise ValueError("time_slots must contain at least one time slot")
|
|
182
|
+
parsed_time_slots: dict[str, TimeSlot] = {}
|
|
183
|
+
for name, time_slot in time_slots.items():
|
|
184
|
+
if not name.strip() or not isinstance(time_slot, dict):
|
|
185
|
+
raise ValueError(f"invalid time slot: {name!r}")
|
|
186
|
+
_check_fields(time_slot, {"start", "end"}, f"time slot {name!r}")
|
|
187
|
+
begins = _clock(time_slot.get("start"), f"time slot {name!r} start")
|
|
188
|
+
ends = _clock(time_slot.get("end"), f"time slot {name!r} end")
|
|
189
|
+
if ends <= begins:
|
|
190
|
+
raise ValueError(f"time slot {name!r} end must be after start")
|
|
191
|
+
parsed_time_slots[name] = TimeSlot(name, begins, ends)
|
|
192
|
+
|
|
193
|
+
courses = data.get("courses")
|
|
194
|
+
if not isinstance(courses, list):
|
|
195
|
+
raise ValueError("courses must be a list of tables")
|
|
196
|
+
parsed_courses: list[Course] = []
|
|
197
|
+
for index, course in enumerate(courses, start=1):
|
|
198
|
+
if not isinstance(course, dict):
|
|
199
|
+
raise ValueError(f"course {index} must be a table")
|
|
200
|
+
_check_fields(
|
|
201
|
+
course,
|
|
202
|
+
{"title", "weekday", "time_slot", "location", "description"},
|
|
203
|
+
f"course {index}",
|
|
204
|
+
)
|
|
205
|
+
title = course.get("title")
|
|
206
|
+
if not isinstance(title, str) or not title.strip():
|
|
207
|
+
raise ValueError(f"course {index} needs a title")
|
|
208
|
+
weekday = _weekday(course.get("weekday"), f"course {index} weekday")
|
|
209
|
+
time_slot = course.get("time_slot")
|
|
210
|
+
if not isinstance(time_slot, str) or time_slot not in parsed_time_slots:
|
|
211
|
+
raise ValueError(f"course {index} refers to an unknown time_slot")
|
|
212
|
+
location = course.get("location")
|
|
213
|
+
if location is not None and not isinstance(location, str):
|
|
214
|
+
raise ValueError(f"course {index} location must be a string")
|
|
215
|
+
description = course.get("description")
|
|
216
|
+
if description is not None and not isinstance(description, str):
|
|
217
|
+
raise ValueError(f"course {index} description must be a string")
|
|
218
|
+
parsed_courses.append(
|
|
219
|
+
Course(title, weekday, parsed_time_slots[time_slot], location, description)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
overrides = data.get("day_overrides", [])
|
|
223
|
+
if not isinstance(overrides, list):
|
|
224
|
+
raise ValueError("day_overrides must be a list of tables")
|
|
225
|
+
parsed_overrides: dict[date, DayOverride] = {}
|
|
226
|
+
for index, override in enumerate(overrides, start=1):
|
|
227
|
+
if not isinstance(override, dict):
|
|
228
|
+
raise ValueError(f"day_overrides item {index} must be a table")
|
|
229
|
+
_check_fields(
|
|
230
|
+
override,
|
|
231
|
+
{"date", "use_weekday", "all_day_event_title"},
|
|
232
|
+
f"day_overrides item {index}",
|
|
233
|
+
)
|
|
234
|
+
day = _date(override.get("date"), f"day_overrides item {index} date")
|
|
235
|
+
_check_term_date(day, start, end, f"day_overrides item {index} date")
|
|
236
|
+
if day in excluded_dates:
|
|
237
|
+
raise ValueError(f"{day} appears in both excluded_dates and day_overrides")
|
|
238
|
+
if day in parsed_overrides:
|
|
239
|
+
raise ValueError(f"duplicate day_overrides date: {day}")
|
|
240
|
+
weekday = _weekday(
|
|
241
|
+
override.get("use_weekday"), f"day_overrides item {index} use_weekday"
|
|
242
|
+
)
|
|
243
|
+
annotation = override.get("all_day_event_title")
|
|
244
|
+
if annotation is not None and not isinstance(annotation, str):
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"day_overrides item {index} all_day_event_title must be a string"
|
|
247
|
+
)
|
|
248
|
+
parsed_overrides[day] = DayOverride(day, weekday, annotation)
|
|
249
|
+
|
|
250
|
+
return Schedule(
|
|
251
|
+
start,
|
|
252
|
+
end,
|
|
253
|
+
excluded_dates,
|
|
254
|
+
parsed_time_slots,
|
|
255
|
+
tuple(parsed_courses),
|
|
256
|
+
parsed_overrides,
|
|
257
|
+
description_footer,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def generate_calendar(schedule: Schedule, source: Path) -> bytes:
|
|
262
|
+
"""Generate course occurrences and optional all-day override annotations."""
|
|
263
|
+
local_zone = get_localzone()
|
|
264
|
+
calendar = Calendar()
|
|
265
|
+
calendar.add("prodid", "-//CalendarGen//EN")
|
|
266
|
+
calendar.add("version", "2.0")
|
|
267
|
+
|
|
268
|
+
footer = schedule.description_footer
|
|
269
|
+
if footer is not None and not footer.strip():
|
|
270
|
+
footer = None
|
|
271
|
+
|
|
272
|
+
day = schedule.start
|
|
273
|
+
while day <= schedule.end:
|
|
274
|
+
if day not in schedule.excluded_dates:
|
|
275
|
+
override = schedule.overrides.get(day)
|
|
276
|
+
if (
|
|
277
|
+
override is not None
|
|
278
|
+
and override.all_day_event_title
|
|
279
|
+
and override.all_day_event_title.strip()
|
|
280
|
+
):
|
|
281
|
+
annotation = Event()
|
|
282
|
+
annotation.add("summary", override.all_day_event_title)
|
|
283
|
+
annotation.add("dtstart", day)
|
|
284
|
+
annotation.add("dtend", day + timedelta(days=1))
|
|
285
|
+
annotation.add("dtstamp", datetime.now(UTC))
|
|
286
|
+
if footer is not None:
|
|
287
|
+
annotation.add("description", footer)
|
|
288
|
+
# Keep the UID stable when the TOML field is renamed.
|
|
289
|
+
annotation.add(
|
|
290
|
+
"uid",
|
|
291
|
+
f"{uuid5(NAMESPACE_URL, f'{source.resolve()}:allday_annotation:{day}')}@calendargen",
|
|
292
|
+
)
|
|
293
|
+
calendar.add_component(annotation)
|
|
294
|
+
for index, course in schedule.courses_on(day):
|
|
295
|
+
event = Event()
|
|
296
|
+
event.add("summary", course.title)
|
|
297
|
+
event.add(
|
|
298
|
+
"dtstart", datetime.combine(day, course.time_slot.start, local_zone)
|
|
299
|
+
)
|
|
300
|
+
event.add(
|
|
301
|
+
"dtend", datetime.combine(day, course.time_slot.end, local_zone)
|
|
302
|
+
)
|
|
303
|
+
event.add("dtstamp", datetime.now(UTC))
|
|
304
|
+
event.add(
|
|
305
|
+
"uid",
|
|
306
|
+
f"{uuid5(NAMESPACE_URL, f'{source.resolve()}:{index}:{day}')}@calendargen",
|
|
307
|
+
)
|
|
308
|
+
if course.location:
|
|
309
|
+
event.add("location", course.location)
|
|
310
|
+
description = course.description
|
|
311
|
+
if footer is not None:
|
|
312
|
+
description = (
|
|
313
|
+
f"{description}\n\n{footer}" if description else footer
|
|
314
|
+
)
|
|
315
|
+
if description is not None:
|
|
316
|
+
event.add("description", description)
|
|
317
|
+
calendar.add_component(event)
|
|
318
|
+
day += timedelta(days=1)
|
|
319
|
+
|
|
320
|
+
calendar.add_missing_timezones(
|
|
321
|
+
first_date=schedule.start,
|
|
322
|
+
last_date=schedule.end + timedelta(days=1),
|
|
323
|
+
)
|
|
324
|
+
return calendar.to_ical()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: calendargen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate an iCalendar file from a TOML class schedule.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/Harumaru169/calendargen
|
|
7
|
+
Project-URL: Issues, https://github.com/Harumaru169/calendargen/issues
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: icalendar
|
|
12
|
+
Requires-Dist: tzlocal
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: ipython; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# CalendarGen
|
|
19
|
+
|
|
20
|
+
CalendarGen turns a declarative TOML schedule into an iCalendar (`.ics`) file. It is designed for events that usually happen every week, such as university lectures. Define the term dates, class times, and weekly events once; CalendarGen expands them into individual calendar events.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Requires Python 3.12 or later.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install calendargen
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With [uv](https://docs.astral.sh/uv/), install the CLI as a tool:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
uv tool install calendargen
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
To install from a local checkout for development, use `uv tool install --editable .`. You can also run it from this repository without a separate installation:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv run calendargen init calendar.toml
|
|
40
|
+
uv run calendargen gen calendar.toml calendar.ics
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
Create a starter schedule, edit it, and generate an ICS file:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
calendargen init calendar.toml
|
|
49
|
+
calendargen gen calendar.toml calendar.ics
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Import `calendar.ics` into a calendar application that supports iCalendar files. `init` creates a new file and will not overwrite an existing one.
|
|
53
|
+
|
|
54
|
+
### Updating an imported schedule
|
|
55
|
+
|
|
56
|
+
Edit `calendar.toml` and run `calendargen gen calendar.toml calendar.ics` again. Before importing the new ICS file, remove the events from the previous import in your calendar application. A distinctive `description_footer` helps you find those events; importing the new file alone may leave duplicates, depending on the application. Import the regenerated ICS file after removing the old events.
|
|
57
|
+
|
|
58
|
+
## Schedule format
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
term_start = 2026-10-01
|
|
62
|
+
term_end = 2027-01-22
|
|
63
|
+
excluded_dates = [2026-11-03]
|
|
64
|
+
description_footer = "Generated by CalendarGen (Fall 2026)"
|
|
65
|
+
|
|
66
|
+
[time_slots]
|
|
67
|
+
"period 2" = { start = "10:30", end = "12:00" }
|
|
68
|
+
|
|
69
|
+
[[courses]]
|
|
70
|
+
title = "Applied Algebra"
|
|
71
|
+
weekday = "mon"
|
|
72
|
+
time_slot = "period 2"
|
|
73
|
+
location = "Room 201"
|
|
74
|
+
description = "Professor Smith"
|
|
75
|
+
|
|
76
|
+
[[day_overrides]]
|
|
77
|
+
date = 2026-10-15
|
|
78
|
+
use_weekday = "mon"
|
|
79
|
+
all_day_event_title = "Monday schedule"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `term_start` and `term_end` are inclusive TOML dates.
|
|
83
|
+
- `excluded_dates` skips all events on the listed dates. It can be omitted.
|
|
84
|
+
- Each `time_slots` entry defines a named local start and end time. Courses refer to these names through `time_slot`.
|
|
85
|
+
- Each `[[courses]]` entry defines a weekly event. `title`, `weekday`, and `time_slot` are required; `location` and `description` are optional. Weekdays are `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, or `sun`.
|
|
86
|
+
- A `[[day_overrides]]` entry uses another weekday's course schedule on a specific date. In the example, October 15 follows the Monday schedule. Overrides are optional and cannot share a date with `excluded_dates`.
|
|
87
|
+
- `all_day_event_title` is an optional text field on a day override. When nonblank, it creates an additional all-day event on that date with the text as its title. Omit it on overrides that need no all-day event.
|
|
88
|
+
- `description_footer` is optional. When set, CalendarGen appends it after a blank line to each course description and adds it as the description of events that have none, including all-day override events. Omit it or set it to a blank string to leave descriptions unchanged. A distinctive footer makes it easier to search for and delete all generated events after importing an ICS file, so you can revise the schedule and import it again.
|
|
89
|
+
|
|
90
|
+
Unknown field names in the schedule, time slots, courses, or day overrides are errors. Dates in `excluded_dates` and `day_overrides` must fall between `term_start` and `term_end`, inclusive; out-of-range dates are also errors. CalendarGen does not create or replace the ICS file when validation fails.
|
|
91
|
+
|
|
92
|
+
Event times use the local timezone of the machine running CalendarGen. The generated ICS file contains one event for each occurrence within the term.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT. See [LICENSE](https://github.com/Harumaru169/calendargen/blob/main/LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/calendargen/__init__.py
|
|
5
|
+
src/calendargen/cli.py
|
|
6
|
+
src/calendargen/schedule.py
|
|
7
|
+
src/calendargen.egg-info/PKG-INFO
|
|
8
|
+
src/calendargen.egg-info/SOURCES.txt
|
|
9
|
+
src/calendargen.egg-info/dependency_links.txt
|
|
10
|
+
src/calendargen.egg-info/entry_points.txt
|
|
11
|
+
src/calendargen.egg-info/requires.txt
|
|
12
|
+
src/calendargen.egg-info/top_level.txt
|
|
13
|
+
tests/test_calendar.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
calendargen
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
import tomllib
|
|
5
|
+
import unittest
|
|
6
|
+
from contextlib import redirect_stderr
|
|
7
|
+
from datetime import date, time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
from icalendar import Calendar
|
|
13
|
+
|
|
14
|
+
from calendargen.cli import main
|
|
15
|
+
from calendargen.schedule import (
|
|
16
|
+
TEMPLATE,
|
|
17
|
+
Course,
|
|
18
|
+
DayOverride,
|
|
19
|
+
Schedule,
|
|
20
|
+
TimeSlot,
|
|
21
|
+
generate_calendar,
|
|
22
|
+
load_schedule,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
SCHEDULE = """term_start = 2026-05-04
|
|
26
|
+
term_end = 2026-05-11
|
|
27
|
+
excluded_dates = [2026-05-05]
|
|
28
|
+
|
|
29
|
+
[time_slots]
|
|
30
|
+
"period 1" = { start = "08:45", end = "10:15" }
|
|
31
|
+
"lunch" = { start = "12:00", end = "13:15" }
|
|
32
|
+
"2" = { start = "13:15", end = "14:45" }
|
|
33
|
+
|
|
34
|
+
[[courses]]
|
|
35
|
+
title = "Monday class"
|
|
36
|
+
description = "Professor Smith"
|
|
37
|
+
weekday = "mon"
|
|
38
|
+
time_slot = "period 1"
|
|
39
|
+
location = "A101"
|
|
40
|
+
|
|
41
|
+
[[courses]]
|
|
42
|
+
title = "Tuesday class"
|
|
43
|
+
weekday = "tue"
|
|
44
|
+
time_slot = "lunch"
|
|
45
|
+
|
|
46
|
+
[[courses]]
|
|
47
|
+
title = "Wednesday class"
|
|
48
|
+
weekday = "wed"
|
|
49
|
+
time_slot = "2"
|
|
50
|
+
|
|
51
|
+
[[day_overrides]]
|
|
52
|
+
date = 2026-05-06
|
|
53
|
+
use_weekday = "mon"
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CalendarTests(unittest.TestCase):
|
|
58
|
+
def setUp(self):
|
|
59
|
+
self.temporary = tempfile.TemporaryDirectory()
|
|
60
|
+
self.addCleanup(self.temporary.cleanup)
|
|
61
|
+
self.directory = Path(self.temporary.name)
|
|
62
|
+
self.toml_path = self.directory / "calendar.toml"
|
|
63
|
+
self.toml_path.write_text(SCHEDULE, encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
def test_template_is_valid_toml(self):
|
|
66
|
+
data = tomllib.loads(TEMPLATE)
|
|
67
|
+
self.assertEqual(
|
|
68
|
+
data["description_footer"], "Generated by CalendarGen (Fall 2026)"
|
|
69
|
+
)
|
|
70
|
+
self.assertEqual(data["time_slots"]["period 1"]["start"], "08:45")
|
|
71
|
+
self.assertIn("lunch", data["time_slots"])
|
|
72
|
+
self.assertEqual(data["courses"][0]["description"], "Professor Tsujimoto")
|
|
73
|
+
self.assertEqual(
|
|
74
|
+
data["day_overrides"][0]["all_day_event_title"],
|
|
75
|
+
"swapped to Monday schedule",
|
|
76
|
+
)
|
|
77
|
+
self.toml_path.write_text(TEMPLATE, encoding="utf-8")
|
|
78
|
+
load_schedule(self.toml_path)
|
|
79
|
+
|
|
80
|
+
def test_schedule_uses_domain_objects_and_selects_courses(self):
|
|
81
|
+
schedule = load_schedule(self.toml_path)
|
|
82
|
+
self.assertIsInstance(schedule, Schedule)
|
|
83
|
+
self.assertIsInstance(schedule.time_slots["period 1"], TimeSlot)
|
|
84
|
+
self.assertIsInstance(schedule.courses[0], Course)
|
|
85
|
+
self.assertIs(schedule.courses[0].time_slot, schedule.time_slots["period 1"])
|
|
86
|
+
self.assertIsInstance(schedule.overrides[date(2026, 5, 6)], DayOverride)
|
|
87
|
+
self.assertEqual(schedule.weekday_on(date(2026, 5, 6)), 0)
|
|
88
|
+
self.assertEqual(
|
|
89
|
+
schedule.courses_on(date(2026, 5, 6)), [(0, schedule.courses[0])]
|
|
90
|
+
)
|
|
91
|
+
self.assertEqual(
|
|
92
|
+
schedule.courses_on(date(2026, 5, 4)), [(0, schedule.courses[0])]
|
|
93
|
+
)
|
|
94
|
+
self.assertEqual(schedule.courses_on(date(2026, 5, 5)), [])
|
|
95
|
+
self.assertEqual(schedule.courses_on(date(2026, 5, 12)), [])
|
|
96
|
+
|
|
97
|
+
@patch("calendargen.schedule.get_localzone", return_value=ZoneInfo("Asia/Tokyo"))
|
|
98
|
+
def test_calendar_dates_overrides_and_timezone(self, _zone):
|
|
99
|
+
calendar = Calendar.from_ical(
|
|
100
|
+
generate_calendar(load_schedule(self.toml_path), self.toml_path)
|
|
101
|
+
)
|
|
102
|
+
events = list(calendar.walk("VEVENT"))
|
|
103
|
+
self.assertEqual(len(events), 3)
|
|
104
|
+
self.assertEqual(
|
|
105
|
+
[event.decoded("DTSTART").date() for event in events],
|
|
106
|
+
[date(2026, 5, 4), date(2026, 5, 6), date(2026, 5, 11)],
|
|
107
|
+
)
|
|
108
|
+
self.assertEqual(
|
|
109
|
+
[str(event["SUMMARY"]) for event in events], ["Monday class"] * 3
|
|
110
|
+
)
|
|
111
|
+
self.assertEqual(events[1].decoded("DTSTART").time(), time(8, 45))
|
|
112
|
+
self.assertEqual(events[1].decoded("DTEND").time(), time(10, 15))
|
|
113
|
+
self.assertEqual(
|
|
114
|
+
events[1].decoded("DTSTART").utcoffset().total_seconds(), 9 * 3600
|
|
115
|
+
)
|
|
116
|
+
self.assertEqual(len(list(calendar.walk("VTIMEZONE"))), 1)
|
|
117
|
+
self.assertEqual(events[0]["LOCATION"], "A101")
|
|
118
|
+
self.assertEqual(str(events[0]["DESCRIPTION"]), "Professor Smith")
|
|
119
|
+
self.assertEqual(
|
|
120
|
+
[event["UID"] for event in events],
|
|
121
|
+
[
|
|
122
|
+
event["UID"]
|
|
123
|
+
for event in Calendar.from_ical(
|
|
124
|
+
generate_calendar(load_schedule(self.toml_path), self.toml_path)
|
|
125
|
+
).walk("VEVENT")
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def test_excluded_date_and_override_collision_is_an_error(self):
|
|
130
|
+
self.toml_path.write_text(
|
|
131
|
+
SCHEDULE.replace(
|
|
132
|
+
"excluded_dates = [2026-05-05]", "excluded_dates = [2026-05-06]"
|
|
133
|
+
),
|
|
134
|
+
encoding="utf-8",
|
|
135
|
+
)
|
|
136
|
+
with self.assertRaisesRegex(
|
|
137
|
+
ValueError, "both excluded_dates and day_overrides"
|
|
138
|
+
):
|
|
139
|
+
load_schedule(self.toml_path)
|
|
140
|
+
|
|
141
|
+
def test_unknown_fields_identify_their_location(self):
|
|
142
|
+
cases = (
|
|
143
|
+
(
|
|
144
|
+
"schedule",
|
|
145
|
+
"term_strat",
|
|
146
|
+
SCHEDULE.replace(
|
|
147
|
+
"term_start = 2026-05-04",
|
|
148
|
+
"term_start = 2026-05-04\nterm_strat = 2026-05-04",
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
(
|
|
152
|
+
"time slot 'period 1'",
|
|
153
|
+
"star",
|
|
154
|
+
SCHEDULE.replace('start = "08:45"', 'star = "08:45"'),
|
|
155
|
+
),
|
|
156
|
+
(
|
|
157
|
+
"course 1",
|
|
158
|
+
"titel",
|
|
159
|
+
SCHEDULE.replace('title = "Monday class"', 'titel = "Monday class"'),
|
|
160
|
+
),
|
|
161
|
+
(
|
|
162
|
+
"day_overrides item 1",
|
|
163
|
+
"use_weekdays",
|
|
164
|
+
SCHEDULE.replace('use_weekday = "mon"', 'use_weekdays = "mon"'),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
for context, field, contents in cases:
|
|
168
|
+
with self.subTest(context=context):
|
|
169
|
+
self.toml_path.write_text(contents, encoding="utf-8")
|
|
170
|
+
with self.assertRaises(ValueError) as raised:
|
|
171
|
+
load_schedule(self.toml_path)
|
|
172
|
+
self.assertIn(context, str(raised.exception))
|
|
173
|
+
self.assertIn(field, str(raised.exception))
|
|
174
|
+
|
|
175
|
+
def test_excluded_dates_must_be_within_term_including_boundaries(self):
|
|
176
|
+
for day in ("2026-05-03", "2026-05-12"):
|
|
177
|
+
with self.subTest(day=day):
|
|
178
|
+
self.toml_path.write_text(
|
|
179
|
+
SCHEDULE.replace("2026-05-05]", f"{day}]"),
|
|
180
|
+
encoding="utf-8",
|
|
181
|
+
)
|
|
182
|
+
with self.assertRaises(ValueError) as raised:
|
|
183
|
+
load_schedule(self.toml_path)
|
|
184
|
+
self.assertIn(day, str(raised.exception))
|
|
185
|
+
self.assertIn("2026-05-04 to 2026-05-11", str(raised.exception))
|
|
186
|
+
|
|
187
|
+
self.toml_path.write_text(
|
|
188
|
+
SCHEDULE.replace("2026-05-05]", "2026-05-04, 2026-05-11]"),
|
|
189
|
+
encoding="utf-8",
|
|
190
|
+
)
|
|
191
|
+
self.assertEqual(
|
|
192
|
+
load_schedule(self.toml_path).excluded_dates,
|
|
193
|
+
frozenset((date(2026, 5, 4), date(2026, 5, 11))),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def test_day_overrides_dates_must_be_within_term_including_boundaries(self):
|
|
197
|
+
for day in ("2026-05-03", "2026-05-12"):
|
|
198
|
+
with self.subTest(day=day):
|
|
199
|
+
self.toml_path.write_text(
|
|
200
|
+
SCHEDULE.replace("date = 2026-05-06", f"date = {day}"),
|
|
201
|
+
encoding="utf-8",
|
|
202
|
+
)
|
|
203
|
+
with self.assertRaises(ValueError) as raised:
|
|
204
|
+
load_schedule(self.toml_path)
|
|
205
|
+
self.assertIn(day, str(raised.exception))
|
|
206
|
+
self.assertIn("2026-05-04 to 2026-05-11", str(raised.exception))
|
|
207
|
+
|
|
208
|
+
for day in ("2026-05-04", "2026-05-11"):
|
|
209
|
+
with self.subTest(day=day):
|
|
210
|
+
self.toml_path.write_text(
|
|
211
|
+
SCHEDULE.replace("date = 2026-05-06", f"date = {day}"),
|
|
212
|
+
encoding="utf-8",
|
|
213
|
+
)
|
|
214
|
+
self.assertIn(
|
|
215
|
+
date.fromisoformat(day), load_schedule(self.toml_path).overrides
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
@patch("calendargen.schedule.get_localzone", return_value=ZoneInfo("Asia/Tokyo"))
|
|
219
|
+
def test_all_day_event_title_adds_an_event_with_or_without_a_course(self, _zone):
|
|
220
|
+
for weekday, event_count in (("mon", 4), ("sun", 3)):
|
|
221
|
+
with self.subTest(weekday=weekday):
|
|
222
|
+
self.toml_path.write_text(
|
|
223
|
+
SCHEDULE.replace(
|
|
224
|
+
'use_weekday = "mon"',
|
|
225
|
+
f'use_weekday = "{weekday}"\nall_day_event_title = "Schedule change"',
|
|
226
|
+
),
|
|
227
|
+
encoding="utf-8",
|
|
228
|
+
)
|
|
229
|
+
schedule = load_schedule(self.toml_path)
|
|
230
|
+
events = list(
|
|
231
|
+
Calendar.from_ical(
|
|
232
|
+
generate_calendar(schedule, self.toml_path)
|
|
233
|
+
).walk("VEVENT")
|
|
234
|
+
)
|
|
235
|
+
annotations = [
|
|
236
|
+
event
|
|
237
|
+
for event in events
|
|
238
|
+
if str(event["SUMMARY"]) == "Schedule change"
|
|
239
|
+
]
|
|
240
|
+
self.assertEqual(len(annotations), 1)
|
|
241
|
+
annotation = annotations[0]
|
|
242
|
+
self.assertEqual(annotation.decoded("DTSTART"), date(2026, 5, 6))
|
|
243
|
+
self.assertEqual(annotation.decoded("DTEND"), date(2026, 5, 7))
|
|
244
|
+
self.assertEqual(len(events), event_count)
|
|
245
|
+
self.assertEqual(
|
|
246
|
+
len({str(event["UID"]) for event in events}), len(events)
|
|
247
|
+
)
|
|
248
|
+
regenerated = Calendar.from_ical(
|
|
249
|
+
generate_calendar(schedule, self.toml_path)
|
|
250
|
+
)
|
|
251
|
+
self.assertEqual(
|
|
252
|
+
str(annotation["UID"]),
|
|
253
|
+
str(
|
|
254
|
+
next(
|
|
255
|
+
event
|
|
256
|
+
for event in regenerated.walk("VEVENT")
|
|
257
|
+
if str(event["SUMMARY"]) == "Schedule change"
|
|
258
|
+
)["UID"]
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def test_blank_or_missing_all_day_event_title_adds_no_event(self):
|
|
263
|
+
for field in ("", 'all_day_event_title = ""', 'all_day_event_title = " "'):
|
|
264
|
+
with self.subTest(field=field):
|
|
265
|
+
self.toml_path.write_text(SCHEDULE + field + "\n", encoding="utf-8")
|
|
266
|
+
events = Calendar.from_ical(
|
|
267
|
+
generate_calendar(load_schedule(self.toml_path), self.toml_path)
|
|
268
|
+
)
|
|
269
|
+
self.assertEqual(len(list(events.walk("VEVENT"))), 3)
|
|
270
|
+
|
|
271
|
+
def test_all_day_event_title_must_be_text(self):
|
|
272
|
+
self.toml_path.write_text(
|
|
273
|
+
SCHEDULE + "all_day_event_title = 42\n", encoding="utf-8"
|
|
274
|
+
)
|
|
275
|
+
with self.assertRaisesRegex(ValueError, "all_day_event_title must be a string"):
|
|
276
|
+
load_schedule(self.toml_path)
|
|
277
|
+
|
|
278
|
+
def test_named_and_numeric_time_slots(self):
|
|
279
|
+
self.toml_path.write_text(
|
|
280
|
+
SCHEDULE.replace(
|
|
281
|
+
"excluded_dates = [2026-05-05]", "excluded_dates = []"
|
|
282
|
+
).replace('use_weekday = "mon"', 'use_weekday = "wed"'),
|
|
283
|
+
encoding="utf-8",
|
|
284
|
+
)
|
|
285
|
+
calendar = Calendar.from_ical(
|
|
286
|
+
generate_calendar(load_schedule(self.toml_path), self.toml_path)
|
|
287
|
+
)
|
|
288
|
+
events = list(calendar.walk("VEVENT"))
|
|
289
|
+
starts = [event.decoded("DTSTART") for event in events]
|
|
290
|
+
self.assertNotIn("DESCRIPTION", events[1])
|
|
291
|
+
self.assertEqual(
|
|
292
|
+
[(start.date(), start.time()) for start in starts],
|
|
293
|
+
[
|
|
294
|
+
(date(2026, 5, 4), time(8, 45)),
|
|
295
|
+
(date(2026, 5, 5), time(12, 0)),
|
|
296
|
+
(date(2026, 5, 6), time(13, 15)),
|
|
297
|
+
(date(2026, 5, 11), time(8, 45)),
|
|
298
|
+
],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def test_unknown_time_slot_is_an_error(self):
|
|
302
|
+
self.toml_path.write_text(
|
|
303
|
+
SCHEDULE.replace('time_slot = "period 1"', 'time_slot = "unknown"', 1),
|
|
304
|
+
encoding="utf-8",
|
|
305
|
+
)
|
|
306
|
+
with self.assertRaisesRegex(ValueError, "unknown time_slot"):
|
|
307
|
+
load_schedule(self.toml_path)
|
|
308
|
+
|
|
309
|
+
def test_description_must_be_text(self):
|
|
310
|
+
self.toml_path.write_text(
|
|
311
|
+
SCHEDULE.replace('description = "Professor Smith"', "description = 42"),
|
|
312
|
+
encoding="utf-8",
|
|
313
|
+
)
|
|
314
|
+
with self.assertRaisesRegex(ValueError, "description must be a string"):
|
|
315
|
+
load_schedule(self.toml_path)
|
|
316
|
+
|
|
317
|
+
@patch("calendargen.schedule.get_localzone", return_value=ZoneInfo("Asia/Tokyo"))
|
|
318
|
+
def test_description_footer_applies_to_all_generated_events(self, _zone):
|
|
319
|
+
self.toml_path.write_text(
|
|
320
|
+
SCHEDULE.replace(
|
|
321
|
+
"excluded_dates = [2026-05-05]",
|
|
322
|
+
'excluded_dates = []\ndescription_footer = "My import batch"',
|
|
323
|
+
).replace(
|
|
324
|
+
'use_weekday = "mon"',
|
|
325
|
+
'use_weekday = "mon"\nall_day_event_title = "Schedule change"',
|
|
326
|
+
),
|
|
327
|
+
encoding="utf-8",
|
|
328
|
+
)
|
|
329
|
+
events = list(
|
|
330
|
+
Calendar.from_ical(
|
|
331
|
+
generate_calendar(load_schedule(self.toml_path), self.toml_path)
|
|
332
|
+
).walk("VEVENT")
|
|
333
|
+
)
|
|
334
|
+
self.assertEqual(len(events), 5)
|
|
335
|
+
for event in events:
|
|
336
|
+
expected = (
|
|
337
|
+
"My import batch"
|
|
338
|
+
if str(event["SUMMARY"]) in ("Schedule change", "Tuesday class")
|
|
339
|
+
else "Professor Smith\n\nMy import batch"
|
|
340
|
+
)
|
|
341
|
+
self.assertEqual(str(event["DESCRIPTION"]), expected)
|
|
342
|
+
|
|
343
|
+
def test_blank_description_footer_leaves_descriptions_unchanged(self):
|
|
344
|
+
for footer in ('""', '" "'):
|
|
345
|
+
with self.subTest(footer=footer):
|
|
346
|
+
self.toml_path.write_text(
|
|
347
|
+
SCHEDULE.replace(
|
|
348
|
+
"excluded_dates = [2026-05-05]",
|
|
349
|
+
f"excluded_dates = [2026-05-05]\ndescription_footer = {footer}",
|
|
350
|
+
).replace(
|
|
351
|
+
'use_weekday = "mon"',
|
|
352
|
+
'use_weekday = "mon"\nall_day_event_title = "Schedule change"',
|
|
353
|
+
),
|
|
354
|
+
encoding="utf-8",
|
|
355
|
+
)
|
|
356
|
+
events = list(
|
|
357
|
+
Calendar.from_ical(
|
|
358
|
+
generate_calendar(load_schedule(self.toml_path), self.toml_path)
|
|
359
|
+
).walk("VEVENT")
|
|
360
|
+
)
|
|
361
|
+
descriptions = {
|
|
362
|
+
str(event["SUMMARY"]): event.get("DESCRIPTION") for event in events
|
|
363
|
+
}
|
|
364
|
+
self.assertEqual(str(descriptions["Monday class"]), "Professor Smith")
|
|
365
|
+
self.assertIsNone(descriptions["Schedule change"])
|
|
366
|
+
|
|
367
|
+
def test_description_footer_must_be_text(self):
|
|
368
|
+
self.toml_path.write_text(
|
|
369
|
+
SCHEDULE.replace(
|
|
370
|
+
"excluded_dates = [2026-05-05]",
|
|
371
|
+
"excluded_dates = [2026-05-05]\ndescription_footer = 42",
|
|
372
|
+
),
|
|
373
|
+
encoding="utf-8",
|
|
374
|
+
)
|
|
375
|
+
with self.assertRaisesRegex(ValueError, "description_footer must be a string"):
|
|
376
|
+
load_schedule(self.toml_path)
|
|
377
|
+
|
|
378
|
+
def test_cli_init_and_gen(self):
|
|
379
|
+
init_path = self.directory / "new.toml"
|
|
380
|
+
ics_path = self.directory / "calendar.ics"
|
|
381
|
+
with patch.object(sys, "argv", ["calendargen", "init", str(init_path)]):
|
|
382
|
+
main()
|
|
383
|
+
self.assertEqual(init_path.read_text(encoding="utf-8"), TEMPLATE)
|
|
384
|
+
with patch.object(sys, "argv", ["calendargen", "init", str(init_path)]):
|
|
385
|
+
with self.assertRaises(SystemExit), redirect_stderr(io.StringIO()):
|
|
386
|
+
main()
|
|
387
|
+
with patch.object(
|
|
388
|
+
sys, "argv", ["calendargen", "gen", str(self.toml_path), str(ics_path)]
|
|
389
|
+
):
|
|
390
|
+
main()
|
|
391
|
+
self.assertEqual(
|
|
392
|
+
len(list(Calendar.from_ical(ics_path.read_bytes()).walk("VEVENT"))), 3
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def test_cli_validation_failure_does_not_create_or_overwrite_output(self):
|
|
396
|
+
self.toml_path.write_text(
|
|
397
|
+
SCHEDULE.replace('use_weekday = "mon"', 'use_weekdays = "mon"'),
|
|
398
|
+
encoding="utf-8",
|
|
399
|
+
)
|
|
400
|
+
for existing in (False, True):
|
|
401
|
+
with self.subTest(existing=existing):
|
|
402
|
+
ics_path = self.directory / "calendar.ics"
|
|
403
|
+
if existing:
|
|
404
|
+
ics_path.write_bytes(b"previous calendar")
|
|
405
|
+
with (
|
|
406
|
+
patch.object(
|
|
407
|
+
sys,
|
|
408
|
+
"argv",
|
|
409
|
+
["calendargen", "gen", str(self.toml_path), str(ics_path)],
|
|
410
|
+
),
|
|
411
|
+
self.assertRaises(SystemExit) as raised,
|
|
412
|
+
redirect_stderr(io.StringIO()) as stderr,
|
|
413
|
+
):
|
|
414
|
+
main()
|
|
415
|
+
self.assertEqual(raised.exception.code, 2)
|
|
416
|
+
self.assertIn("use_weekdays", stderr.getvalue())
|
|
417
|
+
if existing:
|
|
418
|
+
self.assertEqual(ics_path.read_bytes(), b"previous calendar")
|
|
419
|
+
else:
|
|
420
|
+
self.assertFalse(ics_path.exists())
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
if __name__ == "__main__":
|
|
424
|
+
unittest.main()
|