ungana

Unnamed repository; edit this file 'description' to name the repository.
Info | Log | Files | Refs | README

commit eea67d2125348af25dc899fa0b62f7e74ca48da3
parent 45fe4c3dc17e365ede591d15debe271a7b669671
Author: lash <dev@holbrook.no>
Date:   Mon, 15 Sep 2025 15:20:53 +0100

Merge branch 'master' into lash/gui

Diffstat:
MREADME.md | 8++++----
Mpyproject.toml | 3+++
Atests/__init__.py | 0
Atests/attachment/__init__.py | 0
Atests/attachment/test_attachment_manager.py | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/cmd/__init__.py | 0
Atests/cmd/test_args_parser.py | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/ical/__init__.py | 0
Atests/ical/test_ical_helper.py | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/ical/test_ical_manager.py | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_utils.py | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mungana/cmd/args_parser.py | 74+++++++++++++++++++++++++++++++++++++-------------------------------------
Mungana/ical/ical_helper.py | 17+++++++++++++----
13 files changed, 374 insertions(+), 45 deletions(-)

diff --git a/README.md b/README.md @@ -1,6 +1,6 @@ -# CalendarApp +# Ungana -**CalendarApp** is a Python CLI tool for creating customized iCalendar (`.ics`) files, designed for a ticket booking and event reservation system. +**Ungana** is a Python CLI tool for creating customized iCalendar (`.ics`) files. ## Requirements @@ -25,13 +25,13 @@ source venv/bin/activate #### Editable Mode ```bash -cd calendarapp +cd ungana pip install -e . ``` ## Without Installation ```bash -python3 -m calendarapp.runnable.calendar_cli +python3 -m ungana.runnable.ungana_cal_cli ``` ## Commands diff --git a/pyproject.toml b/pyproject.toml @@ -30,3 +30,6 @@ include = ["ungana*"] ungana = [ "ungana/data/*" ] + +[tool.unittest] +start-directory = "tests" diff --git a/tests/__init__.py b/tests/__init__.py diff --git a/tests/attachment/__init__.py b/tests/attachment/__init__.py diff --git a/tests/attachment/test_attachment_manager.py b/tests/attachment/test_attachment_manager.py @@ -0,0 +1,67 @@ +import unittest +import tempfile +import os +import stat +import hashlib + +from ungana.attachment.attachment_manager import AttachmentManager + +class TestAttachmentManager(unittest.TestCase): + + def setUp(self): + self.manager = AttachmentManager() + + def _make_temp_file(self, suffix, content=b"foo bar"): + """Helper to create a temp file with given content.""" + fd, path = tempfile.mkstemp(suffix=suffix) + with os.fdopen(fd, "wb") as f: + f.write(content) + return path + + def test_create_attachment_image_poster(self): + path = self._make_temp_file(".png", b"fakeimage") + result = self.manager.create_attachment(path, ctx="poster") + + self.assertEqual(result[0], "ATTACH") + self.assertTrue(result[1].startswith("sha256:")) + self.assertEqual(result[2]["CTX"], "poster") + self.assertEqual(result[2]["FMTTYPE"], "image/png") + + def test_create_attachment_text_long(self): + path = self._make_temp_file(".txt", b"xyz xyz xyz") + result = self.manager.create_attachment(path, ctx="long") + self.assertEqual(result[2]["FMTTYPE"], "text/plain") + + def test_create_attachment_file_not_found(self): + with self.assertRaises(FileNotFoundError): + self.manager.create_attachment("does_not_exist.txt", ctx="poster") + + def test_create_attachment_permission_denied(self): + path = self._make_temp_file(".txt") + os.chmod(path, 0) + try: + with self.assertRaises(PermissionError): + self.manager.create_attachment(path, ctx="long") + finally: + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # clean up + + def test_validate_wrong_type_for_poster(self): + path = self._make_temp_file(".txt", b"not an image") + with self.assertRaises(ValueError): + self.manager.validate(path, ctx="poster") + + def test_validate_wrong_type_for_long(self): + path = self._make_temp_file(".png", b"fakeimage") + with self.assertRaises(ValueError): + self.manager.validate(path, ctx="long") + + def test_digest_correctness(self): + content = b"foobar" + path = self._make_temp_file(".txt", content) + digest = self.manager.digest(path) + expected = hashlib.sha256(content).hexdigest() + self.assertEqual(digest, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cmd/__init__.py b/tests/cmd/__init__.py diff --git a/tests/cmd/test_args_parser.py b/tests/cmd/test_args_parser.py @@ -0,0 +1,62 @@ +import unittest +from unittest.mock import MagicMock, patch +import argparse + +from ungana.cmd.args_parser import ArgsParser + +class TestArgsParser(unittest.TestCase): + + def setUp(self): + self.args_parser = ArgsParser() + self.mock_manager = self.args_parser.ical_manager + + def test_ensure_no_multiline_input_valid(self): + result = self.args_parser._ensure_no_multiline_input("foo bar") + self.assertEqual(result, "foo bar") + + def test_ensure_no_multiline_input_invalid(self): + with self.assertRaises(argparse.ArgumentTypeError): + self.args_parser._ensure_no_multiline_input("bad\nline") + + def test_process_contact_arg_with_params(self): + value, params = self.args_parser._process_contact_arg("Go pher|ALTREP=mailto:gophers@go.org") + self.assertEqual(value, "Go pher") + self.assertEqual(params, {"ALTREP": "mailto:gophers@go.org"}) + + def test_process_contact_arg_simple(self): + value, params = self.args_parser._process_contact_arg("gophers@go.org") + self.assertEqual(value, "gophers@go.org") + self.assertEqual(params, {}) + + + @patch("sys.argv", ["prog", "create", "-s", "Gophers Meetup", "--start", "2025-09-06 10:00", "-d", "Gophers yearly meetup", "-l", "Gopher Confrence Hall", "-o", "events@gophers.com"]) + def test_parse_args_create_command(self): + args = self.args_parser.parse_args() + self.assertEqual(args.command, "create") + self.assertEqual(args.summary, "Gophers Meetup") + self.assertEqual(args.location, "Gopher Confrence Hall") + + + + @patch("sys.argv", ["prog", "edit","calendar.ics","-s", "Updated Gophers Meetup","-l", "Updated Conference Hall","-o", "neworganizer@gophers.com","--description", "Updated description"]) + def test_parse_args_edit_command(self): + args = self.args_parser.parse_args() + self.assertEqual(args.command, "edit") + self.assertEqual(args.ics_file, "calendar.ics") + self.assertEqual(args.summary, "Updated Gophers Meetup") + self.assertEqual(args.location, "Updated Conference Hall") + self.assertEqual(args.organizer, "neworganizer@gophers.com") + self.assertEqual(args.description, "Updated description") + + + @patch("sys.argv", ["prog", "edit", "calendar.ics","--host", "http://host.com","--venue", "http://venue.com","--presenter", "http://presenter.com"]) + def test_parse_args_edit_with_presenter_host_venue(self): + args = self.args_parser.parse_args() + self.assertEqual(args.host, "http://host.com") + self.assertEqual(args.venue, "http://venue.com") + self.assertEqual(args.presenter, "http://presenter.com") + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ical/__init__.py b/tests/ical/__init__.py diff --git a/tests/ical/test_ical_helper.py b/tests/ical/test_ical_helper.py @@ -0,0 +1,62 @@ +import os +import unittest +from datetime import datetime, timedelta, timezone +from icalendar import Calendar, Event +from ungana.ical.ical_helper import ICalHelper + + +class TestICalHelper(unittest.TestCase): + + def setUp(self): + self.cal = Calendar() + self.event = Event() + self.event.add("UID", "12345") + self.event.add("DTSTART", datetime(2025, 9, 6, 10, 0, tzinfo=timezone.utc)) + self.event.add("DTEND", datetime(2025, 9, 6, 11, 0, tzinfo=timezone.utc)) + self.event.add("SUMMARY", "Gophers Meetup") + self.cal.add_component(self.event) + + def test_update_event_summary(self): + updates = {"SUMMARY": "Gophers Weekly Meetup"} + updated = ICalHelper.update_event(self.cal, updates) + ev = ICalHelper.get_first_event(updated) + self.assertEqual(str(ev["SUMMARY"]), "Gophers Weekly Meetup") + + def test_update_event_with_url_role(self): + updates = {"URL;ROLE=HOST": "http://gophers.org"} + updated = ICalHelper.update_event(self.cal, updates) + ev = ICalHelper.get_first_event(updated) + urls = ev.get("URL") + self.assertIn("HOST", urls.params["ROLE"]) + + def test_get_first_event_and_all_events(self): + first = ICalHelper.get_first_event(self.cal) + self.assertEqual(str(first["SUMMARY"]), "Gophers Meetup") + all_events = ICalHelper.get_all_events(self.cal) + self.assertEqual(len(all_events), 1) + + def test_normalize_ical_field_datetime_with_tzid(self): + dt = datetime(2025, 9, 6, 10, 0) # naive + #normalized = ICalHelper.normalize_ical_field(dt, "UTC") + normalized = ICalHelper.normalize_ical_field(dt, "Africa/Nairobi") + self.assertIsNotNone(normalized.tzinfo) + + def test_check_existing_event_true(self): + exists = ICalHelper.check_existing_event(self.cal, self.event) + self.assertTrue(exists) + + def test_check_existing_event_false(self): + ev = Event() + ev.add("DTSTART", datetime(2025, 9, 7, 10, 0, tzinfo=timezone.utc)) + ev.add("DTEND", datetime(2025, 9, 7, 11, 0, tzinfo=timezone.utc)) + ev.add("SUMMARY", "Gophers meetup") + exists = ICalHelper.check_existing_event(self.cal, ev) + self.assertFalse(exists) + + def test_update_event_invalid_calendar(self): + with self.assertRaises(ValueError): + ICalHelper.update_event(None, {"SUMMARY": "x"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ical/test_ical_manager.py b/tests/ical/test_ical_manager.py @@ -0,0 +1,72 @@ +import unittest +import os +import tempfile +from datetime import datetime, timezone +from icalendar import Event +from ungana.ical.ical_manager import ICalManager + +class TestICalManager(unittest.TestCase): + + def setUp(self): + self.manager = ICalManager() + self.compulsory_event_fields = { + "start": datetime(2025, 9, 6, 10, 0, tzinfo=timezone.utc), + "summary": "Gophers Meetup", + "location": "Gopher Confrence Hall", + "description": "Gophers yearly meetup", + "organizer": "mailto:events@gophers.com", + "uid": "test-uid-123" + } + + def test_create_event_basic(self): + event = self.manager.create_event(self.compulsory_event_fields) + self.assertIsInstance(event, Event) + self.assertEqual(str(event["summary"]), "Gophers Meetup") + self.assertEqual(str(event["organizer"]), "mailto:events@gophers.com") + self.assertEqual(str(event["uid"]), "test-uid-123") + + def test_create_event_with_duration(self): + data = self.compulsory_event_fields.copy() + data["duration"] = "PT1H" # 1 hour ISO duration + event = self.manager.create_event(data) + self.assertIn("duration", event) + + def test_create_event_with_contact(self): + data = self.compulsory_event_fields.copy() + data["contact"] = "Foo bar" + event = self.manager.create_event(data) + self.assertIn("contact", event) + self.assertEqual(str(event["contact"]), "Foo bar") + + def test_create_event_with_attachments(self): + data = self.compulsory_event_fields.copy() + data["attachments"] = [ + "http://foo.com/foo.pdf", + ("ATTACH", "http://foo.com/foo.pdf", {"FMTTYPE": "application/pdf"}) + ] + event = self.manager.create_event(data) + self.assertIn("ATTACH", event) + + def test_load_nonexistent_file_returns_new_calendar(self): + cal = self.manager.load_ical_file("does_not_exist.ics") + self.assertEqual(cal["VERSION"], "2.0") + self.assertEqual(cal["PRODID"], "-//Ungana//mxm.dk//") + + def test_save_and_load_event(self): + event = self.manager.create_event(self.compulsory_event_fields) + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "xyz.ics") + self.manager.save_ical_file(event, filepath) + + # Verify file exists + self.assertTrue(os.path.exists(filepath)) + + # Reload calendar + cal = self.manager.load_ical_file(filepath) + events = [c for c in cal.walk() if c.name == "VEVENT"] + self.assertEqual(len(events), 1) + self.assertEqual(str(events[0]["summary"]), "Gophers Meetup") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py @@ -0,0 +1,54 @@ +import unittest +from datetime import timedelta, datetime +from ungana.utils import (parse_duration,parse_datetime,generate_uid,validate_datetime,validate_duration) + + +class TestUtils(unittest.TestCase): + + def test_parse_duration_hours_minutes(self): + self.assertEqual(parse_duration("2h 30m"), timedelta(hours=2, minutes=30)) + self.assertEqual(parse_duration("1h"), timedelta(hours=1)) + self.assertEqual(parse_duration("45m"), timedelta(minutes=45)) + self.assertEqual(parse_duration(" 2h,15m "), timedelta(hours=2, minutes=15)) + + def test_parse_datetime_valid_formats(self): + dt1 = parse_datetime("2025-09-08 14:30") + dt2 = parse_datetime("08-09-2025 14:30") + self.assertEqual(dt1, datetime(2025, 9, 8, 14, 30)) + self.assertEqual(dt2, datetime(2025, 9, 8, 14, 30)) + + def test_parse_datetime_invalid(self): + with self.assertRaises(ValueError): + parse_datetime("2025/09/08 14:30") + + def test_generate_uid(self): + uid = generate_uid("gophers.org") + self.assertTrue(uid.endswith("@gophers.org")) + + def test_validate_datetime_iso(self): + iso = "2025-09-08T14:30:00" + self.assertEqual(validate_datetime(iso), iso) + + def test_validate_datetime_dmy_format(self): + dt_str = "08-09-2025 14:30" + result = validate_datetime(dt_str) + self.assertEqual(result, datetime(2025, 9, 8, 14, 30).isoformat()) + + def test_validate_datetime_invalid(self): + with self.assertRaises(Exception): + validate_datetime("2025/09/08") + + def test_validate_duration_valid(self): + self.assertEqual(validate_duration("2h"), "2h") + self.assertEqual(validate_duration("30m"), "30m") + self.assertEqual(validate_duration("1h30m"), "1h30m") + + def test_validate_duration_invalid(self): + with self.assertRaises(Exception): + validate_duration("2hours") + with self.assertRaises(Exception): + validate_duration("foobar") + + +if __name__ == "__main__": + unittest.main() diff --git a/ungana/cmd/args_parser.py b/ungana/cmd/args_parser.py @@ -53,9 +53,6 @@ class ArgsParser: def add_common_args(self, parser, required=False): - parser.add_argument("-i", "--interactive", action="store_true", - help="Run interactive calendar creation") - parser.add_argument("-s", "--summary",type = self._ensure_no_multiline_input, required=required, help="Event summary") parser.add_argument("--start", type=validate_datetime, required=required,help="Event start time (ISO format or DD-MM-YYYY HH:MM)") parser.add_argument("-d", "--description",type=self._ensure_no_multiline_input,required=required, help="Event description") @@ -66,37 +63,28 @@ class ArgsParser: parser.add_argument("--description-file", dest="description_file", help="File containing event description") parser.add_argument("--tzid", help="Time zone ID") parser.add_argument("--duration", type=validate_duration, help="Event duration") - parser.add_argument("--end", type=validate_datetime, help="Event end time") - + parser.add_argument("--end", type=validate_datetime, help="Event end time") def add_create_args(self, parser): - mode_group = parser.add_mutually_exclusive_group(required=False) - mode_group.add_argument( - "-i", "--interactive", - action="store_true", - help="Run interactive calendar creation" - ) + event_fields = parser.add_argument_group("event fields") + event_fields.add_argument("-s", "--summary",type=self._ensure_no_multiline_input,help="Event summary") + event_fields.add_argument("--start",type=validate_datetime,help="Event start time (ISO format or DD-MM-YYYY HH:MM)") + event_fields.add_argument("-d", "--description",type=self._ensure_no_multiline_input,help="Event description") + event_fields.add_argument("-l", "--location",type=self._ensure_no_multiline_input,help="Event location") + event_fields.add_argument("-o", "--organizer",type=self._ensure_no_multiline_input,help="Event organizer") + event_fields.add_argument("--summary-file",dest="summary_file",help="File containing event summary") + event_fields.add_argument("--description-file",dest="description_file",help="File containing event description") + event_fields.add_argument("--tzid", help="Time zone ID") + event_fields.add_argument("-p", "--poster", help="Event headline image") + event_fields.add_argument("--long",type=self._ensure_no_multiline_input,help="Exhaustive description of the event") + event_fields.add_argument("-c", "--contact",type=self._ensure_no_multiline_input,help="Contact details") - non_interactive = parser.add_argument_group("non-interactive arguments") - non_interactive.add_argument("-s", "--summary",type=self._ensure_no_multiline_input, help="Event summary") - non_interactive.add_argument("--start", type=validate_datetime, help="Event start time (ISO format or DD-MM-YYYY HH:MM)") - non_interactive.add_argument("-d", "--description",type=self._ensure_no_multiline_input, help="Event description") - non_interactive.add_argument("-l", "--location",type=self._ensure_no_multiline_input, help="Event location") - non_interactive.add_argument("-o", "--organizer",type=self._ensure_no_multiline_input, help="Event organizer") + parser.add_argument("ics_filename",nargs="?",help="Output .ics filename (default: event_<date>.ics)") + parser.add_argument("--domain",type=self._ensure_no_multiline_input,help="Domain used to generate event UID (default: ungana.local)",default="ungana.local") - non_interactive.add_argument("--summary-file", dest="summary_file", help="File containing event summary") - non_interactive.add_argument("--description-file", dest="description_file", help="File containing event description") - non_interactive.add_argument("--tzid", help="Time zone ID") - non_interactive.add_argument("-p", "--poster", help="Event headline image") - non_interactive.add_argument("--long", type= self._ensure_no_multiline_input,help="Exhaustive description of the event") - non_interactive.add_argument("-c", "--contact",type=self._ensure_no_multiline_input, help="Contact details") - - parser.add_argument("ics_filename", nargs="?", help="Output .ics filename (default: event_<date>.ics)") - parser.add_argument("--domain", type=self._ensure_no_multiline_input,help="Domain used to generate event UID (default: ungana.local)",default="ungana.local") - - event_end_time_group = non_interactive.add_mutually_exclusive_group(required=False) - event_end_time_group.add_argument("--end", type=validate_datetime,help="Event end time (ISO format or DD-MM-YYYY HH:MM). ""Required if no --duration is specified.",) - event_end_time_group.add_argument( "--duration",type=validate_duration, help="Event duration (e.g shorthand like '1h30m'). Required if no --end is specified.",) + event_end_time_group = event_fields.add_mutually_exclusive_group(required=False) + event_end_time_group.add_argument("--end",type=validate_datetime,help="Event end time (ISO format or DD-MM-YYYY HH:MM). ""Required if no --duration is specified.") + event_end_time_group.add_argument("--duration",type=validate_duration,help="Event duration (e.g shorthand like '1h30m'). ""Required if no --end is specified.") @@ -105,8 +93,9 @@ class ArgsParser: parser.add_argument("-p", "--poster", help="Event headline image") parser.add_argument("--long", type= self._ensure_no_multiline_input,help="Exhaustive description of the event") parser.add_argument("-c", "--contact",type=self._ensure_no_multiline_input, help="Contact details") - - + parser.add_argument("--host", type=self._ensure_no_multiline_input,help="URL for the event host (entity responsible for local production)") + parser.add_argument("--venue", type=self._ensure_no_multiline_input,help="URL for the venue (entity providing the physical location)") + parser.add_argument("--presenter", type=self._ensure_no_multiline_input,help="URL for the presenter or content provider (entity responsible for content)") def _add_logging_arguments(self, parser): @@ -206,9 +195,12 @@ class ArgsParser: "location": args.location, "organizer": args.organizer, "tzid": args.tzid, + "poster": args.poster, + "long": args.long, + "contact": args.contact } - if args.interactive or not any(event_args.values()): + if not any(event_args.values()): ics_filename = args.ics_filename or f"event_{datetime.now().strftime('%Y%m%d_%H%M%S')}.ics" cal = self.ical_manager.load_ical_file(ics_filename) if cal: @@ -381,6 +373,14 @@ class ArgsParser: else: updates["CONTACT"] = value + if args.presenter: + updates["URL;ROLE=PRESENTER"] = args.presenter + if args.host: + updates["URL;ROLE=HOST"] = args.host + + if args.venue: + updates["URL;ROLE=VENUE"] = args.venue + for ctx_name in ("poster", "long"): arg_val = getattr(args, ctx_name, None) if arg_val: @@ -450,7 +450,6 @@ class ArgsParser: if args.domain: domain = args.domain - if args.contact: value, params = self._process_contact_arg(args.contact) if not params and self.EMAIL_RE.match(value): @@ -461,9 +460,9 @@ class ArgsParser: else: contact = value else: - contact = None, - + contact = None + for ctx_name in ("poster", "long"): arg_val = getattr(args, ctx_name, None) if arg_val: @@ -481,10 +480,11 @@ class ArgsParser: 'start': args.start_dt, 'duration': args.duration, 'tzid': args.tzid, - 'contact': contact, 'domain': domain, 'attachments': attachments } + if contact is not None: + event_data['contact'] = contact return event_data diff --git a/ungana/ical/ical_helper.py b/ungana/ical/ical_helper.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Any, Dict, Optional from zoneinfo import ZoneInfo -from icalendar import Calendar, Event +from icalendar import Calendar, Event, vUri class ICalHelper: @@ -22,6 +22,13 @@ class ICalHelper: if component.name == "VEVENT" and str(component.get("UID")) == uid: event_found = True for key, value in updates.items(): + if key.startswith("URL;ROLE="): + role = key.split("=", 1)[1] + url_prop = vUri(value) + url_prop.params["ROLE"] = role + component.add("URL", url_prop) + continue + if key in component: component.pop(key) @@ -100,10 +107,11 @@ class ICalHelper: tzid = event_data.get("tzid") candidate_key = ( - ICalHelper.normalize_ical_field(event_data.get("start"), tzid), - ICalHelper.normalize_ical_field(event_data.get("end"), tzid), - ICalHelper.normalize_ical_field(event_data.get("summary")), + ICalHelper.normalize_ical_field(event_data.get("DTSTART"), tzid), + ICalHelper.normalize_ical_field(event_data.get("DTEND"), tzid), + ICalHelper.normalize_ical_field(event_data.get("SUMMARY")), ) + for component in cal.walk("VEVENT"): existing_key = ( ICalHelper.normalize_ical_field(component.get("DTSTART"), tzid), @@ -115,3 +123,4 @@ class ICalHelper: return False +