File: component.py

package info (click to toggle)
python-ical 12.1.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 1,776 kB
  • sloc: python: 15,157; sh: 9; makefile: 5
file content (119 lines) | stat: -rw-r--r-- 3,947 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
"""Library for handling rfc5545 components.

An iCalendar object consists of one or more components, that may have
properties or sub-components. An example of a component might be the
calendar itself, an event, a to-do, a journal entry, timezone info, etc.

Components created here have no semantic meaning, but hold all the
data needed to interpret based on the type (e.g. by a pydantic model)
"""

# mypy: allow-any-generics

from __future__ import annotations

import re
import textwrap
from dataclasses import dataclass, field
from collections.abc import Generator

from .const import (
    ATTR_BEGIN,
    ATTR_END,
    FOLD,
    FOLD_INDENT,
    FOLD_LEN,
    ATTR_BEGIN_LOWER,
    ATTR_END_LOWER,
)
from .property import ParsedProperty, parse_contentlines

FOLD_RE = re.compile(FOLD, flags=re.MULTILINE)
LINES_RE = re.compile(r"\r?\n")


@dataclass
class ParsedComponent:
    """An rfc5545 component."""

    name: str
    properties: list[ParsedProperty] = field(default_factory=list)
    components: list[ParsedComponent] = field(default_factory=list)

    def as_dict(self) -> dict[str, str | list[ParsedProperty | dict]]:
        """Convert the component into a pydantic parseable dictionary."""
        result: dict[str, list[ParsedProperty | dict]] = {}
        for prop in self.properties:
            result.setdefault(prop.name, [])
            result[prop.name].append(prop)
        for component in self.components:
            result.setdefault(component.name, [])
            result[component.name].append(component.as_dict())
        return {
            "name": self.name,
            **result,
        }

    def ics(self) -> str:
        """Encode a component as rfc5545 text."""
        contentlines = []
        name = self.name.upper()
        contentlines.append(f"{ATTR_BEGIN}:{name}")
        for prop in self.properties:
            contentlines.extend(_fold(prop.ics()))
        contentlines.extend([component.ics() for component in self.components])
        contentlines.append(f"{ATTR_END}:{name}")
        return "\n".join(contentlines)


def _fold(contentline: str) -> list[str]:
    return textwrap.wrap(
        contentline,
        width=FOLD_LEN,
        subsequent_indent=FOLD_INDENT,
        drop_whitespace=False,
        replace_whitespace=False,
        expand_tabs=False,
        break_on_hyphens=False,
    )


def parse_content(content: str) -> list[ParsedComponent]:
    """Parse content into raw properties.

    This includes all necessary unfolding of long lines into full properties.

    This is fairly straight forward in that it walks through each line and uses
    a stack to associate properties with the current object. This does the absolute
    minimum possible parsing into a dictionary of objects to get the right structure.
    All the more detailed parsing of the objects is handled by pydantic, elsewhere.
    """
    lines = unfolded_lines(content)
    properties = parse_contentlines(lines)

    stack: list[ParsedComponent] = [ParsedComponent(name="stream")]
    for prop in properties:
        if prop.name == ATTR_BEGIN_LOWER:
            stack.append(ParsedComponent(name=prop.value.lower()))
        elif prop.name == ATTR_END_LOWER:
            component = stack.pop()
            if prop.value.lower() != component.name:
                raise ValueError(
                    f"Unexpected '{prop}', expected {ATTR_END}:{component.name}"
                )
            stack[-1].components.append(component)
        else:
            stack[-1].properties.append(prop)

    return stack[0].components


def encode_content(components: list[ParsedComponent]) -> str:
    """Encode a set of parsed properties into content."""
    return "\n".join([component.ics() for component in components])


def unfolded_lines(content: str) -> Generator[str, None, None]:
    """Read content and unfold lines."""
    content = FOLD_RE.sub("", content)
    yield from LINES_RE.split(content)