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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ calendargen = calendargen.cli:main
@@ -0,0 +1,6 @@
1
+ icalendar
2
+ tzlocal
3
+
4
+ [dev]
5
+ ipython
6
+ ruff
@@ -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()