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:
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
+