From 94cc4eab00684738ce3aa9f7fb074fb14bf7cc90 Mon Sep 17 00:00:00 2001 From: rsb-23 <57601627+rsb-23@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:01:45 +0530 Subject: [PATCH 1/8] fix: singleton param parsing --- tests/test_from_doctest.py | 2 +- vobjectx/base.py | 43 +++++++++++++++++++------------------- vobjectx/vcard.py | 8 +++---- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/tests/test_from_doctest.py b/tests/test_from_doctest.py index 0cdaab5..8de76d3 100644 --- a/tests/test_from_doctest.py +++ b/tests/test_from_doctest.py @@ -127,4 +127,4 @@ def test_vcf_qp(): ) vcf = vo.read_one(vcf) assert vcf.n.value == Name(given="é") - assert vcf.serialize() == "BEGIN:VCARD\r\nVERSION:2.1\r\nFN:é\r\nN:;é;;;\r\nTEL:0111111111\r\nEND:VCARD\r\n" + assert vcf.serialize() == "BEGIN:VCARD\r\nVERSION:2.1\r\nFN:é\r\nN:;é;;;\r\nTEL;HOME:0111111111\r\nEND:VCARD\r\n" diff --git a/vobjectx/base.py b/vobjectx/base.py index 902c6b5..192e89e 100644 --- a/vobjectx/base.py +++ b/vobjectx/base.py @@ -187,15 +187,11 @@ class ContentLine(VBase): @ivar name: The uppercased name of the contentline. @ivar params: - A dictionary of parameters and associated lists of values (the list may - be empty for empty parameters). + A dictionary of parameters and associated lists of values. + Singleton params (e.g., WORK, CELL in vCard 2.1) are stored with an + empty list as the value. @ivar value: The value of the contentline. - @ivar singletonparams: - A list of parameters for which it's unclear if the string represents the - parameter name or the parameter value. In vCard 2.1, "The value string - can be specified alone in those cases where the value is unambiguous". - This is crazy, but we have to deal with it. @ivar encoded: A boolean describing whether the data in the content line is encoded. Generally, text read from a serialized vCard or vCalendar should be @@ -218,16 +214,14 @@ def __init__( self.name = name.upper() self.encoded = encoded self.params = ContentDict() - self.singletonparams = [] self.is_native = is_native self.line_number = line_number self.value: Any = value # depends on Behavior def update_table(x): - if len(x) == 1: - self.singletonparams += x - else: - paramlist = self.params.setdefault(x[0].upper(), []) + # All params stored uniformly: singleton params get empty list + paramlist = self.params.setdefault(x[0], []) + if len(x) > 1: paramlist.extend(x[1:]) list(map(update_table, params)) @@ -238,9 +232,9 @@ def update_table(x): self.params["ENCODING"].remove("QUOTED-PRINTABLE") if not self.params["ENCODING"]: del self.params["ENCODING"] - if "QUOTED-PRINTABLE" in self.singletonparams: + if "QUOTED-PRINTABLE" in self.params: qp = True - self.singletonparams.remove("QUOTED-PRINTABLE") + del self.params["QUOTED-PRINTABLE"] if qp: if "ENCODING" in self.params: _encoding = self.params["ENCODING"] @@ -269,7 +263,6 @@ def copy(self, copyit): self.params = copy.copy(copyit.params) for k, v in self.params.items(): self.params[k] = copy.copy(v) - self.singletonparams = copy.copy(copyit.singletonparams) self.line_number = copyit.line_number def __eq__(self, other): @@ -325,22 +318,25 @@ def __delattr__(self, name): raise AttributeError(name) from e def value_repr(self): - """ - Transform the representation of the value - according to the behavior, if any. - """ + """Transform the representation of the value according to the behavior, if any.""" return self.behavior.value_repr(self) if self.behavior else self.value + @property + def display_params(self): + return {k: v for k, v in self.params.items() if v} + def __repr__(self): try: value_repr = self.value_repr() except UnicodeEncodeError: value_repr = self.value_repr().encode("utf-8") - return f"<{self.name}{self.params}{value_repr}>" + # Filter out singleton params (empty lists) for display + return f"<{self.name}{self.display_params}{value_repr}>" def __unicode__(self): - return f"<{self.name}{self.params}{self.value_repr()}>" + # Filter out singleton params (empty lists) for display + return f"<{self.name}{self.display_params}{self.value_repr()}>" def pretty_print(self, level=0, tabwidth=3): pre = " " * level * tabwidth @@ -364,7 +360,10 @@ def default_serialize(self, outbuf, line_length): for key in keys: paramstr = ",".join(dquote_escape(p) for p in self.params[key]) try: - s.write(f";{key}={paramstr}") + if paramstr: + s.write(f";{key}={paramstr}") + else: + s.write(f";{key}") except (UnicodeDecodeError, UnicodeEncodeError): s.write(f";{key}={paramstr.encode('utf-8')}") try: diff --git a/vobjectx/vcard.py b/vobjectx/vcard.py index 913ab3a..4399427 100644 --- a/vobjectx/vcard.py +++ b/vobjectx/vcard.py @@ -103,8 +103,8 @@ def decode(cls, line): ENCODING=b """ if line.encoded: - if "BASE64" in line.singletonparams: - line.singletonparams.remove("BASE64") + if "BASE64" in line.params: + del line.params["BASE64"] line.encoding_param = cls.base64string encoding = getattr(line, "encoding_param", None) if encoding: @@ -115,9 +115,7 @@ def decode(cls, line): @classmethod def encode(cls, line): - """ - Backslash escape line.value. - """ + """Backslash escape line.value.""" if not line.encoded: encoding = getattr(line, "encoding_param", None) if encoding and encoding.upper() == cls.base64string: From 4459f6a4b6f369e25c767f1e89ff3a8d4f11dccb Mon Sep 17 00:00:00 2001 From: rsb-23 <57601627+rsb-23@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:14:33 +0530 Subject: [PATCH 2/8] fix: add recurr-id validation --- .gitignore | 2 ++ tests/test_icalendar.py | 47 +++++++++++++++++++++++++++++++++++++++++ vobjectx/icalendar.py | 7 ++++++ 3 files changed, 56 insertions(+) diff --git a/.gitignore b/.gitignore index 9716195..03ee7a9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ venv/ *coverage* new/ +AGENTS.md +QWEN.md diff --git a/tests/test_icalendar.py b/tests/test_icalendar.py index b06d605..9e06db0 100644 --- a/tests/test_icalendar.py +++ b/tests/test_icalendar.py @@ -4,11 +4,13 @@ from random import sample import dateutil +import pytest from dateutil.rrule import MONTHLY, WEEKLY, rrule, rruleset from vobjectx import base from vobjectx.behavior import new_from_behavior from vobjectx.datatypes import Period +from vobjectx.exceptions import ValidateError from vobjectx.icalendar import ( RecurringComponent, TimezoneComponent, @@ -265,3 +267,48 @@ def test_issue50(): test_file = get_test_file("vobject_0050.ics") cal = base.read_one(test_file) assert dt.datetime(2024, 8, 12, 22, 30, tzinfo=UTC_TZ) == cal.vevent.dtend.value + + +def test_vevent_dtstart_recurrence_id_type_mismatch(): + """ + Test that VEVENT validation fails when DTSTART and RECURRENCE-ID have different types. + + DTSTART and RECURRENCE-ID must both be either datetime.date or datetime.datetime objects. + This test verifies the validation logic catches type mismatches. + """ + # TODO: may simplify into simple test after redesign + # Create a VEVENT with DTSTART as datetime and RECURRENCE-ID as date (should fail) + vevent = new_from_behavior("VEVENT") + vevent.add("dtstart").value = dt.datetime(2024, 1, 15, 10, 0) + vevent.add("dtstamp").value = dt.datetime(2024, 1, 15, 10, 0) + vevent.add("recurrence_id").value = dt.date(2024, 1, 22) + vevent.add("uid").value = "dfvbdfvbdfljb" + + # Validation should raise a ValidateError + with pytest.raises(ValidateError, match="RECURRENCE-ID and DTSTART must be of same type"): + vevent.validate(raise_exception=True) + + # Create a VEVENT with DTSTART as date and RECURRENCE-ID as datetime (should fail) + vevent2 = new_from_behavior("VEVENT") + vevent2.add("dtstart").value = dt.date(2024, 1, 15) + vevent2.add("recurrence_id").value = dt.datetime(2024, 1, 22, 10, 0) + + # Validation should raise a ValidateError + with pytest.raises(ValidateError, match="RECURRENCE-ID and DTSTART must be of same type"): + vevent2.validate() + + # Create a VEVENT with both as datetime (should pass) + # vevent3 = new_from_behavior("VEVENT") + vevent.dtstart.value = dt.datetime(2024, 1, 15, 10, 0) + vevent.recurrence_id.value = dt.datetime(2024, 1, 22, 10, 0) + + # Validation should pass + vevent.validate() + + # Create a VEVENT with both as date (should pass) + vevent4 = new_from_behavior("VEVENT") + vevent4.add("dtstart").value = dt.date(2024, 1, 15) + vevent4.add("recurrence_id").value = dt.date(2024, 1, 22) + + # Validation should pass + vevent4.validate() diff --git a/vobjectx/icalendar.py b/vobjectx/icalendar.py index bc262a5..b0780dc 100644 --- a/vobjectx/icalendar.py +++ b/vobjectx/icalendar.py @@ -581,6 +581,13 @@ def generate_implicit_parameters(obj): now = dt.datetime.now(UTC_TZ) obj.add("dtstamp").value = now + @classmethod + def validate(cls, obj, raise_exception=True, complain_unrecognized=False): + if hasattr(obj, "recurrence_id") and hasattr(obj, "dtstart"): + if type(obj.dtstart.value) is not type(obj.recurrence_id.value): + raise ValidateError("RECURRENCE-ID and DTSTART must be of same type") + return super().validate(obj, raise_exception, complain_unrecognized) + class DateTimeBehavior(Behavior): """ From 20a2690bb7928cb818cc48c97ec6d718edffa272 Mon Sep 17 00:00:00 2001 From: rsb-23 <57601627+rsb-23@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:00:53 +0530 Subject: [PATCH 3/8] refactor: get_transition --- tests/test_utils.py | 3 + vobjectx/helper/parser.py | 186 ++++++++++++---------------------- vobjectx/helper/time_funcs.py | 8 ++ vobjectx/ical/ical_helper.py | 9 +- 4 files changed, 81 insertions(+), 125 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 1c2a696..998e747 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,6 @@ import datetime as dt from io import StringIO +from zoneinfo import ZoneInfo from dateutil.tz import tzutc @@ -20,9 +21,11 @@ def test_date_to_string(): def test_datetime_to_string(): + tz_0_offset = ZoneInfo("Africa/Dakar") tc = { (dt.datetime(2000, 10, 29, 3, 0), False): "20001029T030000", (dt.datetime(2007, 3, 13, 12, 34, 32, tzinfo=tzutc()), True): "20070313T123432Z", + (dt.datetime(2000, 10, 29, 3, 0, tzinfo=tz_0_offset), False): "20001029T030000Z", } for inp, out in tc.items(): assert datetime_to_string(*inp) == out diff --git a/vobjectx/helper/parser.py b/vobjectx/helper/parser.py index 497980a..cdfe575 100644 --- a/vobjectx/helper/parser.py +++ b/vobjectx/helper/parser.py @@ -1,143 +1,87 @@ import datetime as dt +from dataclasses import dataclass -from vobjectx.exceptions import warn_if_true +from dateutil.tz import gettz from .constants_tmp import TRANSITIONS -from .imports_ import Callable, Iterator, contextlib +from .imports_ import Callable, Iterator +from .time_funcs import get_tzid CheckFunc = Callable[[dt.datetime], bool] DateIter = Iterator[dt.datetime] +EPOCH = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) + + +@dataclass +class Transition: + transition_dt: dt.datetime + offset: int + is_standard: bool + + def __eq__(self, other) -> bool: + return self.transition_dt == other.transition_dt and self.offset == other.offset + + +def get_transistions(tzinfo: dt.tzinfo, start_year=1900, end_year=2030) -> list[Transition]: + new_year = dt.datetime(end_year, 1, 1) + _tzid = get_tzid(tzinfo) + if _tzid in ("UTC", None): + return [Transition(new_year, 0, True)] + + _tz = gettz(_tzid) + if _tz is None: + return [Transition(new_year, 1, True)] + transistions = [] + + for ts, idx in zip(_tz._trans_list, _tz._trans_idx): # pylint: disable=protected-access + transistion_date = EPOCH + dt.timedelta(seconds=ts) + if start_year <= transistion_date.year <= end_year: + # Use dstoffset to determine if this is standard time + # dstoffset=0 means standard time, dstoffset>0 means daylight time + is_standard = idx.dstoffset == dt.timedelta(0) + transistions.append(Transition(transistion_date, offset=idx.offset, is_standard=is_standard)) + + return transistions + def get_transition(transition_to: str, year: int, tzinfo: dt.tzinfo) -> dt.datetime | None: """ Return the datetime of the transition to/from DST, or None. + + Returns the transition time as a naive datetime in local wall-clock time, + typically at 2:00 AM for most timezones. """ assert transition_to in TRANSITIONS - def first_transition(iter_dates: DateIter, test_func: CheckFunc) -> dt.datetime | None: - """ - Return the last date not matching test, or None if all tests matched. - """ - success = None - for _dt in iter_dates: - if not test_func(_dt): - success = _dt - else: - if success is not None: - return success - return success - - def generate_dates(year_: int, month_: int = None, day_: int = None) -> DateIter: - """ - Iterate over possible dates with unspecified values. - """ - months = range(1, 13) - days = range(1, 32) - hours = range(24) - if month_ is None: - for _month in months: - yield dt.datetime(year_, _month, 1) - elif day_ is None: - for _day in days: - with contextlib.suppress(ValueError): - yield dt.datetime(year_, month_, _day) - else: - for hour in hours: - yield dt.datetime(year_, month_, day_, hour) - - def test(dt_: dt.datetime) -> bool: - is_standard_transition = transition_to == "standard" - is_daylight_transition = not is_standard_transition - - # Detect Ambiguity (Overlap) - if tzinfo.dst(dt_.replace(fold=0)) != tzinfo.dst(dt_.replace(fold=1)): - return is_standard_transition - - # Detect Gap (Non-existent) - dt_no_tz = dt_.replace(tzinfo=None) - - offset = tzinfo.utcoffset(dt_.replace(fold=0)) - if offset is not None: - dt_utc = (dt_no_tz - offset).replace(tzinfo=dt.timezone.utc) - dt_back = dt_utc.astimezone(tzinfo) - if dt_back.replace(tzinfo=None) != dt_no_tz: - return is_daylight_transition - - is_dt_zerodelta = tzinfo.dst(dt_) == dt.timedelta(0) - return is_dt_zerodelta if is_standard_transition else not is_dt_zerodelta - - month_dt = first_transition(generate_dates(year), test) - if month_dt is None: - return dt.datetime(year, 1, 1) # new year - if month_dt.month == 12: - return None - - # there was a good transition somewhere in a non-December month - month = month_dt.month - day = first_transition(generate_dates(year, month), test).day - uncorrected = first_transition(generate_dates(year, month, day), test) - warn_if_true(uncorrected is None) - if transition_to == "standard": - # assuming tzinfo.dst returns a new offset for the first possible hour, we need to add one hour for the - # offset change and another hour because first_transition returns the hour before the transition - return uncorrected + dt.timedelta(hours=2) - - # Detect Gap (Non-existent) at uncorrected + 1 hour - # Note: first_transition for daylight returns the hour before it becomes daylight. - # In zoneinfo, this uncorrected+1 might already be 03:00 if 02:00 was skipped. - check_dt = uncorrected + dt.timedelta(hours=1) - is_gap = False - - # Check if uncorrected + 1 hour is a non-existent time - # In zoneinfo, if check_dt was 02:00, and 02:00 is skipped, - # it might already show up as something else or we can check with fold. - # A better way to detect gap is to see if fold=0 and fold=1 result in same UTC but different wall clock - # OR just check if it was supposed to be uncorrected + 1 but the library moved it. - - # If we are looking for daylight transition, we expect the offset to change. - tzinfo.utcoffset(check_dt.replace(fold=0)) - tzinfo.utcoffset(check_dt.replace(fold=1)) - - # For a gap (Spring forward), fold=0 and fold=1 usually return the same (the 'after' offset) - # but we can detect it by checking if it's "imaginary" - dt_no_tz = check_dt.replace(tzinfo=None) - # Use an offset that we know existed just before - prev_offset = tzinfo.utcoffset((check_dt - dt.timedelta(hours=1)).replace(fold=0)) - dt_utc_supposed = (dt_no_tz - prev_offset).replace(tzinfo=dt.timezone.utc) - dt_actual = dt_utc_supposed.astimezone(tzinfo) - if dt_actual.replace(tzinfo=None) != dt_no_tz: - is_gap = True - - # For daylight (Spring forward), if it's a gap, pytz used to return the start of the gap. - # zoneinfo's get_transition logic (via fold) might find the end of the gap. - # If we found a gap, return the hour before the gap ends (which is the hour it starts). - if is_gap: - return check_dt - dt.timedelta(hours=1) - - return check_dt - - -def tzinfo_eq(tzinfo1: dt.tzinfo, tzinfo2: dt.tzinfo, start_year: int = 2000, end_year: int = 2020) -> bool: - """ - Compare offsets and DST transitions from start_year to end_year. - """ + # Get transitions for the specified year + transitions = get_transistions(tzinfo, start_year=year, end_year=year) + + # Filter transitions based on the requested type + for trans in transitions: + is_standard = trans.is_standard + if (transition_to == "standard" and is_standard) or (transition_to == "daylight" and not is_standard): + # Use the UTC transition date directly, since transitions occur at + # a specific instant that corresponds to 2:00 AM local time + utc_dt = trans.transition_dt + return dt.datetime(utc_dt.year, utc_dt.month, utc_dt.day, 2, 0, 0) + + # No transition found + return None + + +def tzinfo_eq(tzinfo1: dt.tzinfo, tzinfo2: dt.tzinfo, start_year: int = 1950, end_year: int = 2030) -> bool: + """Compare offsets and DST transitions from start_year to end_year.""" if tzinfo1 == tzinfo2: return True if tzinfo1 is None or tzinfo2 is None: return False - def dt_test(_dt): - if _dt is None: - return True - return tzinfo1.utcoffset(_dt) == tzinfo2.utcoffset(_dt) + t1_transitions = get_transistions(tzinfo1, start_year, end_year) + t2_transitions = get_transistions(tzinfo2, start_year, end_year) + + for t1, t2 in zip(t1_transitions, t2_transitions): + if t1 != t2: + return False - if not dt_test(dt.datetime(start_year, 1, 1)): - return False - for year in range(start_year, end_year): - for transition_to in TRANSITIONS: - t1 = get_transition(transition_to, year, tzinfo1) - t2 = get_transition(transition_to, year, tzinfo2) - if t1 != t2 or not dt_test(t1): - return False return True diff --git a/vobjectx/helper/time_funcs.py b/vobjectx/helper/time_funcs.py index a04a7db..48f38fe 100644 --- a/vobjectx/helper/time_funcs.py +++ b/vobjectx/helper/time_funcs.py @@ -17,3 +17,11 @@ def split_delta(delta: dt.timedelta) -> SimpleDelta: hours, seconds = divmod(delta.seconds, 3600) minutes, seconds = divmod(seconds, 60) return SimpleDelta(days=delta.days, hours=hours, minutes=minutes, seconds=seconds) + + +def get_tzid(tzinfo) -> str | None: + for attr in ("key", "_tzid", "zone", "tzid"): + tzid_ = getattr(tzinfo, attr, None) + if tzid_: + return tzid_ + return None diff --git a/vobjectx/ical/ical_helper.py b/vobjectx/ical/ical_helper.py index 854c10b..a75f115 100644 --- a/vobjectx/ical/ical_helper.py +++ b/vobjectx/ical/ical_helper.py @@ -1,6 +1,8 @@ import datetime as dt import math +from dateutil.relativedelta import relativedelta + from vobjectx import datatypes as vtypes from vobjectx.exceptions import ParseError from vobjectx.registry import TzidRegistry @@ -15,10 +17,9 @@ def date_to_datetime_(dt_obj: dt.datetime | dt.date) -> dt.datetime: def from_last_week_(dt_: dt.datetime) -> int: - """ - How many weeks from the end of the month dt is, starting from 1. - """ - next_month = dt.datetime(dt_.year, dt_.month + 1, 1) + """How many weeks from the end of the month dt is, starting from 1.""" + + next_month = dt.datetime(dt_.year, dt_.month, 1) + relativedelta(months=1) time_diff = next_month - dt_ days_gap = time_diff.days + bool(time_diff.seconds) return math.ceil(days_gap / 7) From 10bf33ea1a3a1d9db39e006ac7bd0e5bb6b54783 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 12:15:11 +0000 Subject: [PATCH 4/8] :arrow_up: Bump Mattraks/delete-workflow-runs from 2 to 2.0.6 Bumps [Mattraks/delete-workflow-runs](https://github.com/mattraks/delete-workflow-runs) from 2 to 2.0.6. - [Release notes](https://github.com/mattraks/delete-workflow-runs/releases) - [Commits](https://github.com/mattraks/delete-workflow-runs/compare/v2...v2.0.6) --- updated-dependencies: - dependency-name: Mattraks/delete-workflow-runs dependency-version: 2.0.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/repo-cleaner.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repo-cleaner.yml b/.github/workflows/repo-cleaner.yml index fa4e8eb..91fe321 100644 --- a/.github/workflows/repo-cleaner.yml +++ b/.github/workflows/repo-cleaner.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Delete old workflow runs - uses: Mattraks/delete-workflow-runs@v2 + uses: Mattraks/delete-workflow-runs@v2.0.6 with: retain_days: 15 keep_minimum_runs: 5 From a3fa0f8d01ae97fc44f8c9c4eea15b97901847da Mon Sep 17 00:00:00 2001 From: rsb-23 <57601627+rsb-23@users.noreply.github.com> Date: Sat, 2 May 2026 03:16:02 +0530 Subject: [PATCH 5/8] fix: bad logic marked by AI - set encoded as bool - simplified and/or logic - removed dead code - handled line_validate method - few type hinting --- vobjectx/base.py | 47 +++++++++++++++++++++++++++---------------- vobjectx/behavior.py | 18 +++++------------ vobjectx/icalendar.py | 40 ++++++++++++++++++------------------ 3 files changed, 55 insertions(+), 50 deletions(-) diff --git a/vobjectx/base.py b/vobjectx/base.py index 192e89e..baa79b8 100644 --- a/vobjectx/base.py +++ b/vobjectx/base.py @@ -6,7 +6,7 @@ from .exceptions import NativeError, ParseError, VObjectError from .helper import Character as Char from .helper import byte_decoder, get_buffer, logger, split_by_size -from .helper.imports_ import Any, TextIO, contextlib, copy, sys +from .helper.imports_ import Any, Self, TextIO, contextlib, copy, sys from .patterns import patterns from .registry import BehaviorRegistry @@ -39,7 +39,7 @@ def __init__(self, group=None, *args, **kwds): self.is_native = False self.encoded = False - def copy(self, copyit): + def copy(self, copyit: Self): self.group = copyit.group self.behavior = copyit.behavior self.parent_behavior = copyit.parent_behavior @@ -202,7 +202,16 @@ class ContentLine(VBase): # pylint: disable=r0902,r0917 def __init__( - self, name, params, value, group=None, encoded=False, is_native=False, line_number=None, *args, **kwds + self, + name: str, + params, + value, + group=None, + encoded: bool = False, + is_native: bool = False, + line_number: int = None, + *args, + **kwds, ): """ Take output from parse_line, convert params list to dictionary. @@ -255,11 +264,11 @@ def duplicate(cls, copyit): newcopy.copy(copyit) return newcopy - def copy(self, copyit): + def copy(self, copyit: Self): super().copy(copyit) self.name = copyit.name self.value = copy.copy(copyit.value) - self.encoded = self.encoded + self.encoded = copyit.encoded self.params = copy.copy(copyit.params) for k, v in self.params.items(): self.params[k] = copy.copy(v) @@ -374,6 +383,12 @@ def default_serialize(self, outbuf, line_length): self.behavior.decode(self) fold_one_line(outbuf, s.getvalue(), line_length) + # pylint: disable=w0613 + @classmethod + def line_validate(cls, line, raise_exception=True, complain_unrecognized=False): + """Examine a line's parameters and values, return True if valid.""" + return True + class Component(VBase): """ @@ -407,7 +422,7 @@ def duplicate(cls, copyit): newcopy.copy(copyit) return newcopy - def copy(self, copyit): + def copy(self, copyit: Self): super().copy(copyit) # deep copy of contents @@ -626,14 +641,13 @@ def parse_params(string): """ _all = params_re.findall(string) all_parameters = [] - for tup in _all: - param_list = [tup[0]] # tup looks like (name, values_string) - for pair in param_values_re.findall(tup[1]): + for param in _all: + name, values_string = param + param_list = [name] + for pair in param_values_re.findall(values_string): # pair looks like ('', value) or (value, '') - if pair[0] != "": - param_list.append(pair[0]) - else: - param_list.append(pair[1]) + param_list.append(pair[0] or pair[1]) + all_parameters.append(param_list) return all_parameters @@ -722,10 +736,9 @@ def text_line_to_content_line(text, n=None): return ContentLine(*parse_line(text, n), **{"encoded": True, "line_number": n}) -def dquote_escape(param) -> str: - """ - Return param, or "param" if ',' or ';' or ':' is in param. - """ +def dquote_escape(param: str) -> str: + """Return param, or "param" if ',' or ';' or ':' is in param.""" + if '"' in param: raise VObjectError("Double quotes aren't allowed in parameter values.") for char in ",;:": # sourcery skip # temp diff --git a/vobjectx/behavior.py b/vobjectx/behavior.py index 209c7c0..2128af2 100644 --- a/vobjectx/behavior.py +++ b/vobjectx/behavior.py @@ -78,8 +78,10 @@ def validate(cls, obj, raise_exception=False, complain_unrecognized=False): """ if not cls.allow_group and obj.group is not None: raise VObjectError(f"{obj} has a group, but this object doesn't support groups") + if isinstance(obj, ContentLine): - return cls.line_validate(obj, raise_exception, complain_unrecognized) + return obj.line_validate(obj, raise_exception, complain_unrecognized) + if isinstance(obj, Component): count = {} for child in obj.get_children(): @@ -101,23 +103,13 @@ def validate(cls, obj, raise_exception=False, complain_unrecognized=False): return True raise VObjectError(f"{obj} is not a Component or Contentline") - @classmethod - def line_validate(cls, line, raise_exception, complain_unrecognized): - """Examine a line's parameters and values, return True if valid.""" - # TODO: remove used param line, raise_exception, complain_unrecognized - if any([line, raise_exception, complain_unrecognized]): - pass - return True - @classmethod def decode(cls, line): - if line.encoded: - line.encoded = 0 + line.encoded = False @classmethod def encode(cls, line): - if not line.encoded: - line.encoded = 1 + line.encoded = True @staticmethod def transform_to_native(obj): diff --git a/vobjectx/icalendar.py b/vobjectx/icalendar.py index b0780dc..1fdd143 100644 --- a/vobjectx/icalendar.py +++ b/vobjectx/icalendar.py @@ -9,7 +9,7 @@ from . import datatypes as vtypes from .__about__ import __version__ as VERSION -from .base import Component, ContentLine, fold_one_line +from .base import Component, ContentLine, VBase, fold_one_line from .behavior import Behavior from .exceptions import AllException, NativeError, ParseError, ValidateError, VObjectError, warn_if_true from .helper import backslash_escape, get_buffer, get_random_int, logger @@ -524,7 +524,7 @@ def decode(cls, line): line.encoded = False @classmethod - def encode(cls, line): + def encode(cls, line: VBase): """Backslash escape line.value.""" if not line.encoded: encoding = getattr(line, "encoding_param", None) @@ -610,7 +610,7 @@ def transform_to_native(obj): obj.is_native = True if obj.value == "": return obj - obj.value = obj.value + # we're cheating a little here, parse_dtstart allows DATE obj.value = parse_dtstart(obj) if obj.value.tzinfo is None: @@ -662,7 +662,7 @@ def transform_to_native(obj): obj.is_native = True if obj.value == "": return obj - obj.value = obj.value + obj.value = parse_dtstart(obj, allow_signature_mismatch=True) if getattr(obj, "value_param", "DATE-TIME").upper() == "DATE-TIME" and hasattr(obj, "tzid_param"): # Keep a copy of the original TZID around @@ -1005,11 +1005,11 @@ class VEvent(RecurringBehavior): @classmethod def validate(cls, obj, raise_exception=False, complain_unrecognized=False): - if "dtend" not in obj.contents or "duration" not in obj.contents: - return super().validate(obj, raise_exception, complain_unrecognized) - if raise_exception: - raise ValidateError("VEVENT components cannot contain both DTEND and DURATION components") - return False + if "dtend" in obj.contents and "duration" in obj.contents: + if raise_exception: + raise ValidateError("VEVENT components cannot contain both DTEND and DURATION components") + return False + return super().validate(obj, raise_exception, complain_unrecognized) register_behavior(VEvent) @@ -1060,11 +1060,11 @@ class VTodo(RecurringBehavior): @classmethod def validate(cls, obj, raise_exception=False, complain_unrecognized=False): - if "due" not in obj.contents or "duration" not in obj.contents: - return super().validate(obj, raise_exception, complain_unrecognized) - if raise_exception: - raise ValidateError("VTODO components cannot contain both DUE and DURATION components") - return False + if "due" in obj.contents and "duration" in obj.contents: + if raise_exception: + raise ValidateError("VTODO components cannot contain both DUE and DURATION components") + return False + return super().validate(obj, raise_exception, complain_unrecognized) register_behavior(VTodo) @@ -1210,11 +1210,11 @@ class VAvailability(VCalendarComponentBehavior): @classmethod def validate(cls, obj, raise_exception=False, complain_unrecognized=False): - if "dtend" not in obj.contents or "duration" not in obj.contents: - return super().validate(obj, raise_exception, complain_unrecognized) - if raise_exception: - raise ValidateError("VAVAILABILITY components cannot contain both DTEND and DURATION components") - return False + if "dtend" in obj.contents and "duration" in obj.contents: + if raise_exception: + raise ValidateError("VAVAILABILITY components cannot contain both DTEND and DURATION components") + return False + return super().validate(obj, raise_exception, complain_unrecognized) register_behavior(VAvailability) @@ -1272,7 +1272,7 @@ def transform_to_native(obj): if obj.is_native: return obj obj.is_native = True - obj.value = obj.value + if obj.value == "": return obj From aec9ad914bd5ce0c633d98f5d0b3294b82ae9d35 Mon Sep 17 00:00:00 2001 From: rsb-23 <57601627+rsb-23@users.noreply.github.com> Date: Sat, 2 May 2026 04:08:39 +0530 Subject: [PATCH 6/8] refact: confusing attribute/method name - encoded to is_encoded - copy method to return a copy. --- vobjectx/base.py | 62 +++++++++++++++++++++---------------------- vobjectx/behavior.py | 4 +-- vobjectx/icalendar.py | 16 +++++------ vobjectx/vcard.py | 10 +++---- 4 files changed, 45 insertions(+), 47 deletions(-) diff --git a/vobjectx/base.py b/vobjectx/base.py index baa79b8..b11daa0 100644 --- a/vobjectx/base.py +++ b/vobjectx/base.py @@ -37,9 +37,14 @@ def __init__(self, group=None, *args, **kwds): self.behavior = None self.parent_behavior = None self.is_native = False - self.encoded = False + self.is_encoded = False - def copy(self, copyit: Self): + def copy(self) -> Self: + newcopy = type(self)() + newcopy.upgrade_from(self) + return newcopy # type: ignore + + def upgrade_from(self, copyit: Self): self.group = copyit.group self.behavior = copyit.behavior self.parent_behavior = copyit.parent_behavior @@ -79,11 +84,11 @@ def auto_behavior(self, cascade=False): behavior = BehaviorRegistry.get(self.name, known_child_tup[2]) if behavior is not None: self.set_behavior(behavior, cascade) - if isinstance(self, ContentLine) and self.encoded: + if isinstance(self, ContentLine) and self.is_encoded: self.behavior.decode(self) elif isinstance(self, ContentLine): self.behavior = parent_behavior.default_behavior - if self.encoded and self.behavior: + if self.is_encoded and self.behavior: self.behavior.decode(self) def set_behavior(self, behavior, cascade=True): @@ -107,7 +112,7 @@ def transform_to_native(self): if self.is_native or not self.behavior or not self.behavior.has_native: return self - self_orig = copy.copy(self) + self_orig = self.copy() try: return self.behavior.transform_to_native(self) except ParseError as e: @@ -204,10 +209,10 @@ class ContentLine(VBase): def __init__( self, name: str, - params, - value, + params: list, + value: str, group=None, - encoded: bool = False, + is_encoded: bool = False, is_native: bool = False, line_number: int = None, *args, @@ -221,7 +226,7 @@ def __init__( super().__init__(group, *args, **kwds) self.name = name.upper() - self.encoded = encoded + self.is_encoded = is_encoded self.params = ContentDict() self.is_native = is_native self.line_number = line_number @@ -258,19 +263,18 @@ def update_table(x): except UnicodeDecodeError: self.value = _value.decode("latin-1") - @classmethod - def duplicate(cls, copyit): - newcopy = cls("", {}, "") - newcopy.copy(copyit) - return newcopy + def copy(self) -> Self: + newcopy = ContentLine("", [], "") + newcopy.update_from(self) + return newcopy # type: ignore - def copy(self, copyit: Self): - super().copy(copyit) + def update_from(self, copyit: Self): + super().upgrade_from(copyit) self.name = copyit.name self.value = copy.copy(copyit.value) - self.encoded = copyit.encoded - self.params = copy.copy(copyit.params) - for k, v in self.params.items(): + self.is_encoded = copyit.is_encoded + + for k, v in copyit.params.items(): self.params[k] = copy.copy(v) self.line_number = copyit.line_number @@ -352,11 +356,11 @@ def pretty_print(self, level=0, tabwidth=3): print(pre, f"{self.name}:", self.value_repr()) if self.params: print(pre, "params for ", f"{self.name}:") - for k in self.params.keys(): - print(pre + " " * tabwidth, k, self.params[k]) + for k, v in self.params.items(): + print(pre + " " * tabwidth, k, v) def default_serialize(self, outbuf, line_length): - started_encoded = self.encoded + started_encoded = self.is_encoded if self.behavior and not started_encoded: self.behavior.encode(self) @@ -416,21 +420,15 @@ def __init__(self, name="", *args, **kwds): self.use_begin = bool(name) self.auto_behavior() - @classmethod - def duplicate(cls, copyit): - newcopy = cls() - newcopy.copy(copyit) - return newcopy - - def copy(self, copyit: Self): - super().copy(copyit) + def upgrade_from(self, copyit: Self): + super().upgrade_from(copyit) # deep copy of contents self.contents = ContentDict() for key, lvalue in copyit.contents.items(): newvalue = [] for value in lvalue: - newitem = value.duplicate(value) + newitem = value.copy() newvalue.append(newitem) self.contents[key] = newvalue @@ -733,7 +731,7 @@ def get_logical_lines(fp, allow_qp=True): def text_line_to_content_line(text, n=None): - return ContentLine(*parse_line(text, n), **{"encoded": True, "line_number": n}) + return ContentLine(*parse_line(text, n), **{"is_encoded": True, "line_number": n}) def dquote_escape(param: str) -> str: diff --git a/vobjectx/behavior.py b/vobjectx/behavior.py index 2128af2..92cf4c8 100644 --- a/vobjectx/behavior.py +++ b/vobjectx/behavior.py @@ -105,11 +105,11 @@ def validate(cls, obj, raise_exception=False, complain_unrecognized=False): @classmethod def decode(cls, line): - line.encoded = False + line.is_encoded = False @classmethod def encode(cls, line): - line.encoded = True + line.is_encoded = True @staticmethod def transform_to_native(obj): diff --git a/vobjectx/icalendar.py b/vobjectx/icalendar.py index 1fdd143..9eede69 100644 --- a/vobjectx/icalendar.py +++ b/vobjectx/icalendar.py @@ -515,24 +515,24 @@ class TextBehavior(Behavior): @classmethod def decode(cls, line): """Remove backslash escaping from line.value.""" - if line.encoded: + if line.is_encoded: encoding = getattr(line, "encoding_param", None) if encoding and encoding.upper() == cls.base64string: line.value = base64.b64decode(line.value) else: line.value = string_to_text_values(line.value)[0] - line.encoded = False + line.is_encoded = False @classmethod def encode(cls, line: VBase): """Backslash escape line.value.""" - if not line.encoded: + if not line.is_encoded: encoding = getattr(line, "encoding_param", None) if encoding and encoding.upper() == cls.base64string: line.value = base64.b64encode(line.value.encode("utf-8")).decode("utf-8").replace("\n", "") else: line.value = backslash_escape(line.value) - line.encoded = True + line.is_encoded = True class VCalendarComponentBehavior(Behavior): @@ -751,18 +751,18 @@ def decode(cls, line): """ Remove backslash escaping from line.value, then split on commas. """ - if line.encoded: + if line.is_encoded: line.value = string_to_text_values(line.value, list_separator=cls.list_separator) - line.encoded = False + line.is_encoded = False @classmethod def encode(cls, line): """ Backslash escape line.value. """ - if not line.encoded: + if not line.is_encoded: line.value = cls.list_separator.join(backslash_escape(val) for val in line.value) - line.encoded = True + line.is_encoded = True class SemicolonMultiTextBehavior(MultiTextBehavior): diff --git a/vobjectx/vcard.py b/vobjectx/vcard.py index 4399427..3feef64 100644 --- a/vobjectx/vcard.py +++ b/vobjectx/vcard.py @@ -102,7 +102,7 @@ def decode(cls, line): vCard spec. If we encounter that, then we transform the parameter to ENCODING=b """ - if line.encoded: + if line.is_encoded: if "BASE64" in line.params: del line.params["BASE64"] line.encoding_param = cls.base64string @@ -111,13 +111,13 @@ def decode(cls, line): line.value = byte_decoder(line.value) else: line.value = string_to_text_values(line.value)[0] - line.encoded = False + line.is_encoded = False @classmethod def encode(cls, line): """Backslash escape line.value.""" - if not line.encoded: - encoding = getattr(line, "encoding_param", None) + if not line.is_encoded: + encoding = getattr(line, "encoding_param", "") if encoding and encoding.upper() == cls.base64string: if isinstance(line.value, bytes): line.value = byte_encoder(line.value).decode("utf-8").replace("\n", "") @@ -125,7 +125,7 @@ def encode(cls, line): line.value = byte_encoder(line.value.encode(encoding)).decode("utf-8") else: line.value = backslash_escape(line.value) - line.encoded = True + line.is_encoded = True class VCardBehavior(Behavior): From 888033435bcc8a269be4c66f9db5eac287653eb1 Mon Sep 17 00:00:00 2001 From: rsb-23 <57601627+rsb-23@users.noreply.github.com> Date: Wed, 20 May 2026 06:12:40 +0530 Subject: [PATCH 7/8] refact : reduced memory need --- tests/test_calendar_serialization.py | 6 +++--- tests/test_change_tz.py | 2 +- tests/test_cli.py | 4 +--- vobjectx/__init__.py | 2 +- vobjectx/helper/constants.py | 2 +- vobjectx/helper/parser.py | 2 +- vobjectx/helper/time_funcs.py | 2 +- vobjectx/ics_diff.py | 2 +- 8 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/test_calendar_serialization.py b/tests/test_calendar_serialization.py index b4e9455..6179712 100644 --- a/tests/test_calendar_serialization.py +++ b/tests/test_calendar_serialization.py @@ -8,6 +8,8 @@ from .common import TEST_FILE_DIR, get_test_file +tzs_from_file = dateutil.tz.tzical(f"{TEST_FILE_DIR}/timezones.ics").get("US/Pacific") + def test_scratchbuild(): """CreateCalendar 2.0 format from scratch""" @@ -16,9 +18,7 @@ def test_scratchbuild(): cal.add("vevent") cal.vevent.add("dtstart").value = dt.datetime(2006, 5, 9) cal.vevent.add("description").value = "Test event" - cal.vevent.add("created").value = dt.datetime( - 2006, 1, 1, 10, tzinfo=dateutil.tz.tzical(f"{TEST_FILE_DIR}/timezones.ics").get("US/Pacific") - ) + cal.vevent.add("created").value = dt.datetime(2006, 1, 1, 10, tzinfo=tzs_from_file) cal.vevent.add("uid").value = "Not very random UID" cal.vevent.add("dtstamp").value = dt.datetime(2017, 6, 26, 0, tzinfo=tzutc()) diff --git a/tests/test_change_tz.py b/tests/test_change_tz.py index e4583e0..aa759e4 100644 --- a/tests/test_change_tz.py +++ b/tests/test_change_tz.py @@ -6,7 +6,7 @@ from vobjectx.change_tz import change_tz -@dataclass +@dataclass(slots=True) class Node: value: str diff --git a/tests/test_cli.py b/tests/test_cli.py index df856d9..b74ab50 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,7 @@ import subprocess -from dataclasses import dataclass -@dataclass -class Cli: +class Cli: # pylint: disable=r0903 ics_diff = "ics_diff" change_tz = "change_tz" diff --git a/vobjectx/__init__.py b/vobjectx/__init__.py index 8afa3ae..97a4cd7 100644 --- a/vobjectx/__init__.py +++ b/vobjectx/__init__.py @@ -95,4 +95,4 @@ def vCard(): # pylint:disable=invalid-name return new_from_behavior("vcard", "3.0") -__all__ = ["icalendar", "vcard", "read_components", "read_one", "new_from_behavior", "iCalendar", "vCard"] +__all__ = ["icalendar", "vcard", "read_components", "read_one", "new_from_behavior", "iCalendar", "vCard", "VERSION"] diff --git a/vobjectx/helper/constants.py b/vobjectx/helper/constants.py index 68f899d..d6b1c2d 100644 --- a/vobjectx/helper/constants.py +++ b/vobjectx/helper/constants.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -@dataclass +@dataclass(frozen=True, slots=True) class Character: """Space and Line-break characters""" diff --git a/vobjectx/helper/parser.py b/vobjectx/helper/parser.py index cdfe575..c6051b3 100644 --- a/vobjectx/helper/parser.py +++ b/vobjectx/helper/parser.py @@ -13,7 +13,7 @@ EPOCH = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) -@dataclass +@dataclass(frozen=True, slots=True) class Transition: transition_dt: dt.datetime offset: int diff --git a/vobjectx/helper/time_funcs.py b/vobjectx/helper/time_funcs.py index 48f38fe..a1c4c9f 100644 --- a/vobjectx/helper/time_funcs.py +++ b/vobjectx/helper/time_funcs.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -@dataclass +@dataclass(frozen=True, slots=True) class SimpleDelta: days: int hours: int diff --git a/vobjectx/ics_diff.py b/vobjectx/ics_diff.py index 0c281ea..20c7884 100644 --- a/vobjectx/ics_diff.py +++ b/vobjectx/ics_diff.py @@ -41,7 +41,7 @@ def delete_extraneous(component, ignore_dtstamp=False): del component.dtstamp_list -@dataclass +@dataclass(frozen=True, slots=True) class ObjectWithSides: left: Component | ContentLine right: Component | ContentLine From 13f15132acd72726dbac04ecb1af0d00a4e91074 Mon Sep 17 00:00:00 2001 From: rsb-23 <57601627+rsb-23@users.noreply.github.com> Date: Wed, 20 May 2026 07:31:59 +0530 Subject: [PATCH 8/8] fix : added valarm validation - supporting add_note - improved logical lines --- vobjectx/base.py | 55 +++++++++++++++++++++--------------------- vobjectx/exceptions.py | 19 +++++++++++---- vobjectx/icalendar.py | 43 +++++++++++++++++++++------------ 3 files changed, 69 insertions(+), 48 deletions(-) diff --git a/vobjectx/base.py b/vobjectx/base.py index b11daa0..7d9700d 100644 --- a/vobjectx/base.py +++ b/vobjectx/base.py @@ -6,7 +6,7 @@ from .exceptions import NativeError, ParseError, VObjectError from .helper import Character as Char from .helper import byte_decoder, get_buffer, logger, split_by_size -from .helper.imports_ import Any, Self, TextIO, contextlib, copy, sys +from .helper.imports_ import Any, Iterator, Self, TextIO, contextlib, copy, sys from .patterns import patterns from .registry import BehaviorRegistry @@ -666,7 +666,7 @@ def parse_line(line, line_number=None): ) -def get_logical_lines(fp, allow_qp=True): +def get_logical_lines(fp: TextIO, allow_qp: bool = True) -> Iterator: """ Iterate through a stream, yielding one logical line at a time. @@ -677,6 +677,10 @@ def get_logical_lines(fp, allow_qp=True): Quoted-printable data will be decoded in the Behavior decoding phase. """ + + def get_value(lines: list[str]): + return "".join(lines) + if not allow_qp: val = fp.read(-1) @@ -689,45 +693,40 @@ def get_logical_lines(fp, allow_qp=True): return quoted_printable = False - logical_line = get_buffer() - line_number = 0 + logical_line: list[str] = [] line_start_number = 0 - while True: - line = fp.readline() - if line == "": - break + + for n, line in enumerate(fp, start=1): line = line.rstrip(Char.CRLF) - line_number += 1 if line.rstrip() == "": - if logical_line.tell() > 0: - yield logical_line.getvalue(), line_start_number - line_start_number = line_number - logical_line = get_buffer() + if logical_line: + yield get_value(logical_line), line_start_number + line_start_number = n + logical_line = [] quoted_printable = False continue if quoted_printable and allow_qp: - logical_line.write("\n") + logical_line.append("\n") quoted_printable = False elif line[0] in Char.SPACEORTAB: line = line[1:] - elif logical_line.tell() > 0: - yield logical_line.getvalue(), line_start_number - line_start_number = line_number - logical_line = get_buffer() + elif logical_line: + yield get_value(logical_line), line_start_number + line_start_number = n + logical_line = [] else: - logical_line = get_buffer() - logical_line.write(line) + logical_line = [] + logical_line.append(line) # vCard 2.1 allows parameters to be encoded without a parameter name # False positives are unlikely, but possible. - val = logical_line.getvalue() - if val[-1] == "=" and val.lower().find("quoted-printable") >= 0: + if line[-1] == "=" and "quoted-printable" in get_value(logical_line).lower(): quoted_printable = True - if logical_line.tell() > 0: - yield logical_line.getvalue(), line_start_number + if logical_line: + yield get_value(logical_line), line_start_number def text_line_to_content_line(text, n=None): @@ -765,10 +764,10 @@ def default_serialize(obj, buf, line_length): return buf or outbuf.getvalue() -def read_components(stream_or_string, validate=False, transform=True, ignore_unreadable=False, allow_qp=False): - """ - Generate one Component at a time from a stream. - """ +def read_components( + stream_or_string, validate=False, transform=True, ignore_unreadable=False, allow_qp=False +) -> Iterator[Component]: + """Generate one Component at a time from a stream.""" def raise_parse_error(msg): raise ParseError(msg, n, inputs=stream_or_string) diff --git a/vobjectx/exceptions.py b/vobjectx/exceptions.py index c040809..9c87f9a 100644 --- a/vobjectx/exceptions.py +++ b/vobjectx/exceptions.py @@ -3,13 +3,21 @@ class VObjectError(Exception): def __init__(self, msg, line_number=None): - self.msg = msg + super().__init__(msg) self.line_number = line_number + self.__notes__ = [] + + def add_note(self, note): + # TODO: remove this for 3.10 deprecation + self.__notes__.append(note) def __str__(self): - if self.line_number is None: - return repr(self.msg) - return f"At line {self.line_number!s}: {self.msg!s}" + msg = self.args[0] + if self.line_number is not None: + msg = f"At line {self.line_number}: {msg}" + if self.__notes__: + msg += "\n" + "\n".join(self.__notes__) + return msg class ParseError(VObjectError): @@ -32,7 +40,8 @@ class AllException(VObjectError): class UnusedBranchError(VObjectError): def __init__(self): - super().__init__("Unexpected Execution : Report a bug", None) + super().__init__("Unused Branch Error", None) + super().add_note("Unexpected Execution : Report a bug") def warn_if_true(cond: bool = True, raise_error: bool = True): diff --git a/vobjectx/icalendar.py b/vobjectx/icalendar.py index 9eede69..e65e18a 100644 --- a/vobjectx/icalendar.py +++ b/vobjectx/icalendar.py @@ -1137,9 +1137,7 @@ class VFreeBusy(VCalendarComponentBehavior): class VAlarm(VCalendarComponentBehavior): - """ - Alarm behavior. - """ + """Alarm behavior""" name = "VALARM" description = "Alarms describe when and how to provide alerts about events and to-dos." @@ -1153,9 +1151,7 @@ class VAlarm(VCalendarComponentBehavior): @staticmethod def generate_implicit_parameters(obj): - """ - Create default ACTION and TRIGGER if they're not set. - """ + """Create default ACTION and TRIGGER if they're not set.""" if not hasattr(obj, "action"): obj.add("action").value = "AUDIO" @@ -1163,17 +1159,34 @@ def generate_implicit_parameters(obj): obj.add("trigger").value = dt.timedelta(0) @classmethod - def validate(cls, obj, raise_exception=False, complain_unrecognized=False): - """ - # TODO - if obj.contents.has_key('dtend') and obj.contents.has_key('duration'): + def validate(cls, obj, raise_exception: bool = True, complain_unrecognized: bool = False) -> bool: + contents = obj.contents + + def fail(msg): if raise_exception: - raise ValidateError("VEVENT components cannot contain both DTEND and DURATION components") + raise ValidateError(msg) return False - else: - return super().validate(obj, raise_exception, *args) - """ - return True + + action = contents.action[0].value + + # REPEAT and DURATION must appear together + if ("duration" in contents) ^ ("repeat" in contents): + return fail("VALARM DURATION and REPEAT must both be present/absent.") + + if action == "DISPLAY": + if "description" not in contents: + return fail("DISPLAY VALARM missing DESCRIPTION") + + elif action == "EMAIL": + for prop in ("description", "summary", "attendee"): + if prop not in contents: + return fail(f"EMAIL VALARM missing {prop.upper()}") + + elif action == "AUDIO": + if len(contents.get("attach", [])) > 1: + return fail("AUDIO VALARM can contain only one ATTACH") + + return super().validate(obj, raise_exception, complain_unrecognized) register_behavior(VAlarm)