From cca55fc8c28a388ba1a98baa93847bc5cbd13c59 Mon Sep 17 00:00:00 2001 From: Nicco Kunzmann Date: Wed, 23 Apr 2025 19:36:46 +0200 Subject: [PATCH] Add categories property --- CHANGES.rst | 1 + src/icalendar/attr.py | 75 ++++++++++++++++++- src/icalendar/cal.py | 5 ++ src/icalendar/prop.py | 9 ++- src/icalendar/tests/test_rfc_7529.py | 2 +- .../tests/test_rfc_7986_categories.py | 70 +++++++++++++++++ 6 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 src/icalendar/tests/test_rfc_7986_categories.py diff --git a/CHANGES.rst b/CHANGES.rst index 9def3d5..af04523 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,6 +17,7 @@ New features: - Add attributes to the calendar for properties ``NAME``, ``DESCRIPTION``, and ``COLOR``. See `Issue 655 `_. - Add ``sequence`` attribute to ``Event``, ``Todo``, and ``Journal`` components. See `Issue 802 `_. +- Add ``categories`` attribute to ``Calendar``, ``Event``, ``Todo``, and ``Journal`` components. See `Issue 655 `_. - Add compatibility to :rfc:`6868`. See `Issue 652 `_. Bug fixes: diff --git a/src/icalendar/attr.py b/src/icalendar/attr.py index 1e0d39c..6cda57c 100644 --- a/src/icalendar/attr.py +++ b/src/icalendar/attr.py @@ -2,10 +2,11 @@ from __future__ import annotations from datetime import date, datetime +import itertools from typing import TYPE_CHECKING, Optional from icalendar.error import InvalidCalendar -from icalendar.prop import vDDDTypes, vText +from icalendar.prop import vCategory, vDDDTypes, vText from icalendar.timezone import tzp if TYPE_CHECKING: @@ -179,9 +180,79 @@ Examples: """ ) +def _get_categories(component: Component) -> list[str]: + """Get all the categories.""" + categories : Optional[vCategory|list[vCategory]] = component.get("CATEGORIES") + if isinstance(categories, list): + _set_categories(component, list(itertools.chain.from_iterable(cat.cats for cat in categories))) + return _get_categories(component) + if categories is None: + categories = vCategory([]) + component.add("CATEGORIES", categories) + return categories.cats + +def _set_categories(component: Component, cats: list[str]) -> None: + """Set the categories.""" + component["CATEGORIES"] = categories = vCategory(cats) + cats.clear() + cats.extend(categories.cats) + categories.cats = cats + + +def _del_categories(component: Component) -> None: + """Delete the categories.""" + component.pop("CATEGORIES", None) + + +categories_property = property( + _get_categories, + _set_categories, + _del_categories, + """This property defines the categories for a component. + +Property Parameters: + + IANA, non-standard, and language property parameters can be specified on this + property. + +Conformance: + + The property can be specified within "VEVENT", "VTODO", or "VJOURNAL" calendar + components. + Since :rfc:`7986` it can also be defined on a "VCALENDAR" component. + +Description: + + This property is used to specify categories or subtypes + of the calendar component. The categories are useful in searching + for a calendar component of a particular type and category. + Within the "VEVENT", "VTODO", or "VJOURNAL" calendar components, + more than one category can be specified as a COMMA-separated list + of categories. + +Example: + + >>> from icalendar import Event + >>> event = Event() + >>> event.categories = ["Work", "Meeting"] + >>> print(event.to_ical()) + BEGIN:VEVENT + CATEGORIES:Work,Meeting + END:VEVENT + >>> event.categories.append("Lecture") + >>> event.categories == ["Work", "Meeting", "Lecture"] + True + +.. note:: + + At present, we do not take the LANGUAGE parameter into account. +""" +) + __all__ = [ "single_utc_property", "multi_language_text_property", "single_int_property", - "sequence_property" + "sequence_property", + "categories_property", ] diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index d8376e2..67a957b 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -15,6 +15,7 @@ import dateutil.rrule import dateutil.tz from icalendar.attr import ( + categories_property, multi_language_text_property, sequence_property, single_int_property, @@ -894,6 +895,7 @@ class Event(Component): X_MOZ_SNOOZE_TIME = _X_MOZ_SNOOZE_TIME X_MOZ_LASTACK = _X_MOZ_LASTACK sequence = sequence_property + categories = categories_property class Todo(Component): @@ -1083,6 +1085,7 @@ class Todo(Component): return Alarms(self) sequence = sequence_property + categories = categories_property class Journal(Component): @@ -1168,6 +1171,7 @@ class Journal(Component): return timedelta(0) sequence = sequence_property + categories = categories_property class FreeBusy(Component): @@ -2101,6 +2105,7 @@ class Calendar(Component): END:VCALENDAR """ ) + categories = categories_property # These are read only singleton, so one instance is enough for the module types_factory = TypesFactory() diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index eb01359..688985d 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -430,17 +430,20 @@ class vDDDLists: class vCategory: params: Parameters - def __init__(self, c_list, params={}): + def __init__(self, c_list:list[str] | str, params={}): if not hasattr(c_list, "__iter__") or isinstance(c_list, str): c_list = [c_list] - self.cats = [vText(c) for c in c_list] + self.cats : list[vText|str] = [vText(c) for c in c_list] self.params = Parameters(params) def __iter__(self): return iter(vCategory.from_ical(self.to_ical())) def to_ical(self): - return b",".join([c.to_ical() for c in self.cats]) + return b",".join([ + c.to_ical() if hasattr(c, "to_ical") else vText(c).to_ical() + for c in self.cats + ]) @staticmethod def from_ical(ical): diff --git a/src/icalendar/tests/test_rfc_7529.py b/src/icalendar/tests/test_rfc_7529.py index a8e22d5..e74be7e 100644 --- a/src/icalendar/tests/test_rfc_7529.py +++ b/src/icalendar/tests/test_rfc_7529.py @@ -1,7 +1,7 @@ """This tests the compatibility with RFC 7529. See -- https://github.com/collective/icalendar/issues/653 +- https://github.com/collective/icalendar/issues/655 - https://www.rfc-editor.org/rfc/rfc7529.html """ diff --git a/src/icalendar/tests/test_rfc_7986_categories.py b/src/icalendar/tests/test_rfc_7986_categories.py new file mode 100644 index 0000000..c068165 --- /dev/null +++ b/src/icalendar/tests/test_rfc_7986_categories.py @@ -0,0 +1,70 @@ +"""This tests the compatibility with RFC 7529. + +CATEGORIES property + +See +- https://github.com/collective/icalendar/issues/655 +- https://www.rfc-editor.org/rfc/rfc7529.html +""" + +from typing import Union + +import pytest + +from icalendar import Calendar, Event, Journal, Todo + +CTJE = Union[Calendar, Event, Journal, Todo] + +@pytest.fixture(params=[Event, Calendar, Todo, Journal]) +def component(request): + """An empty component with possible categories.""" + return request.param() + + +def test_no_categories_at_creation(component: CTJE): + """An empty component has no categories.""" + assert "CATEGORIES" not in component + assert component.categories == [] + + +def test_add_one_category(component: CTJE): + """Add one category.""" + component.add("categories", "Lecture") + assert component.categories == ["Lecture"] + +def test_add_multiple_categories(component: CTJE): + """Add categories.""" + component.add("categories", ["Lecture", "Workshop"]) + assert component.categories == ["Lecture", "Workshop"] + +def test_set_categories(component: CTJE): + """Set categories.""" + component.categories = ["Lecture", "Workshop"] + assert component.categories == ["Lecture", "Workshop"] + + +def test_modify_list(component: CTJE): + """Modify the list and it still works.""" + component.categories = categories = ["cat1"] + categories.append("cat2") + assert component.categories == ["cat1", "cat2"] + + +def test_delete_categories(component: CTJE): + """Delete categories.""" + component.categories = ["Lecture", "Workshop"] + del component.categories + assert "CATEGORIES" not in component + assert component.categories == [] + + +def test_deal_with_several_categories(component: CTJE): + """If we have categories several times, we should all use them.""" + component.add("categories", ["c1", "c2"]) + component.add("categories", ["c3", "c4"]) + assert component.categories == ["c1", "c2", "c3", "c4"] + component.categories.append("c5") + assert component.categories == ["c1", "c2", "c3", "c4", "c5"] + component.categories.remove("c2") + assert component.categories == ["c1", "c3", "c4", "c5"] + assert "c1,c3,c4,c5" in component.to_ical().decode()